diff --git a/DesktopClock.Tests/BackgroundBrushConverterTests.cs b/DesktopClock.Tests/BackgroundBrushConverterTests.cs new file mode 100644 index 0000000..f668195 --- /dev/null +++ b/DesktopClock.Tests/BackgroundBrushConverterTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Globalization; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace DesktopClock.Tests; + +public class BackgroundBrushConverterTests +{ + [Theory] + [InlineData("{0}")] + [InlineData("\"{0}\"")] // Copy as path in Explorer adds quotes. + [InlineData(" \"{0}\" ")] + public void Convert_ShowsTheImage(string pathFormat) + { + var image = Path.Combine(Path.GetTempPath(), $"DesktopClock background {Guid.NewGuid():N}.png"); + try + { + using (var file = File.Create(image)) + { + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[16], 8))); + encoder.Save(file); + } + + var brush = Convert(string.Format(pathFormat, image)); + + Assert.IsType(brush); + } + finally + { + File.Delete(image); + } + } + + [Fact] + public void Convert_FallsBackToTheColorWhenTheImageIsMissing() + { + var brush = Convert(Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.png")); + + Assert.Equal(Colors.Red, Assert.IsType(brush).Color); + } + + private static object Convert(string path) => + new BackgroundBrushConverter().Convert([true, path, Colors.Red, 1d, Stretch.Fill], typeof(Brush), null, CultureInfo.InvariantCulture); +} diff --git a/DesktopClock.Tests/FileUtilTests.cs b/DesktopClock.Tests/FileUtilTests.cs index d52b118..ef6f596 100644 --- a/DesktopClock.Tests/FileUtilTests.cs +++ b/DesktopClock.Tests/FileUtilTests.cs @@ -85,4 +85,13 @@ public void IsInTempFolder_ShouldNotMatchAFolderNextToIt() Assert.False(exe.IsInTempFolder()); } + + [Theory] + [InlineData("\"C:\\My Sounds\\chime.wav\"", "C:\\My Sounds\\chime.wav")] + [InlineData("C:\\My Sounds\\chime.wav ", "C:\\My Sounds\\chime.wav")] + [InlineData("", "")] + public void UnquotePath_ShouldRemoveTheQuotesFromCopyAsPath(string path, string expected) + { + Assert.Equal(expected, FileUtil.UnquotePath(path)); + } } diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs index 4148a12..bf4d9bf 100644 --- a/DesktopClock.Tests/FormatPresetsTests.cs +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -53,6 +53,21 @@ public void DefaultClockFormat_ReadsNaturallyInTheRegion(string culture, string Assert.Equal(expected, Tokenizer.FormatWithTokenizerOrFallBack(Sample, format, Region(culture))); } + // Windows lets people switch only the short time, which the taskbar shows, between 12 and 24 hours. + [Theory] + [InlineData("en-US", "HH:mm", "{HH:mm:ss}")] + [InlineData("en-US", "H:mm", "{H:mm:ss}")] + [InlineData("en-GB", "h:mm tt", "{h:mm:ss tt}")] + [InlineData("en-US", "hh:mm tt", "{h:mm:ss tt}")] // Still 12-hour, so the regional time with seconds stays. + public void TimeWithSeconds_FollowsAShortTimeSwitchedBetween12And24Hours(string culture, string shortTime, string expected) + { + var region = (CultureInfo)Region(culture).Clone(); + region.DateTimeFormat.ShortTimePattern = shortTime; + + Assert.Equal(expected, FormatPresets.ForClock(region).Single(p => p.Name == "Time with seconds").Format); + Assert.EndsWith(expected, FormatPresets.DefaultClockFormat(region)); + } + [Theory] [InlineData("en-US", "Tue, Sep 22, 1:01 PM")] [InlineData("fr-FR", "mar. 22 sept. 13:01")] diff --git a/DesktopClock.Tests/WheelNotchesTests.cs b/DesktopClock.Tests/WheelNotchesTests.cs new file mode 100644 index 0000000..37fb7df --- /dev/null +++ b/DesktopClock.Tests/WheelNotchesTests.cs @@ -0,0 +1,50 @@ +namespace DesktopClock.Tests; + +public class WheelNotchesTests +{ + [Theory] + [InlineData(120, 1)] + [InlineData(-120, -1)] + [InlineData(360, 3)] + public void Add_CountsWholeNotchesFromAWheel(int delta, int expected) + { + Assert.Equal(expected, new WheelNotches().Add(delta)); + } + + [Fact] + public void Add_KeepsTheRestOfANotchForNextTime() + { + var wheel = new WheelNotches(); + + Assert.Equal(0, wheel.Add(100)); + Assert.Equal(1, wheel.Add(40)); + Assert.Equal(1, wheel.Add(100)); + } + + [Fact] + public void Add_ScrollingBackCancelsOut() + { + var wheel = new WheelNotches(); + + Assert.Equal(0, wheel.Add(60)); + Assert.Equal(0, wheel.Add(-60)); + Assert.Equal(0, wheel.Add(60)); + } + + [Fact] + public void TouchpadScroll_ResizesLikeOneNotch() + { + // A precision touchpad sends many small deltas, here twelve of a twelfth of a notch. + var wheel = new WheelNotches(); + var height = 48; + for (var i = 0; i < 12; i++) + { + var notches = wheel.Add(10); + if (notches != 0) + height = HeightScaleConverter.ScaleHeight(height, notches); + } + + Assert.Equal(HeightScaleConverter.ScaleHeight(48, 1), height); + Assert.NotEqual(48, height); + } +} diff --git a/DesktopClock/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index 23debe1..7c72ba8 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Linq; using System.Media; using System.Runtime.InteropServices; using System.Text; @@ -35,6 +36,7 @@ public partial class MainWindow : Window private bool _toldHowToShow; private FullscreenHideManager _fullscreenHideManager; private DesktopPinner _desktopPinner; + private readonly WheelNotches _wheelNotches = new(); private readonly PropertyChangedEventHandler _settingsPropertyChanged; /// @@ -390,7 +392,7 @@ private void TryPlaySound(DateTimeOffset second) var previous = _previousSecond; _previousSecond = second; - var path = Settings.Default.WavFilePath; + var path = FileUtil.UnquotePath(Settings.Default.WavFilePath); if (string.IsNullOrWhiteSpace(path) || (Settings.Default.WavFileInterval == default && !Settings.Default.PlaySoundOnCountdown)) return; @@ -494,9 +496,10 @@ private void Window_MouseWheel(object sender, MouseWheelEventArgs e) // Resize the window when scrolling if the Ctrl key is pressed. if (Keyboard.Modifiers == ModifierKeys.Control) { - // Amount of scroll that occurred and whether it was positive or negative. - var steps = e.Delta / (double)Mouse.MouseWheelDeltaForOneLine; - Settings.Default.Height = HeightScaleConverter.ScaleHeight(Settings.Default.Height, steps); + // A fraction of a notch on its own rounds back to the same height, so touchpads only resize once they add up to a notch. + var notches = _wheelNotches.Add(e.Delta); + if (notches != 0) + Settings.Default.Height = HeightScaleConverter.ScaleHeight(Settings.Default.Height, notches); } } @@ -551,6 +554,10 @@ private void Window_ContentRendered(object sender, EventArgs e) private void Window_Closing(object sender, CancelEventArgs e) { + // Close Settings first, which commits a value still being typed, like a countdown target, so it's in the save below. Exiting closes it anyway, but only after this save. + foreach (var settingsWindow in Application.Current.Windows.OfType().ToList()) + settingsWindow.Close(); + // Save the last text and the placement to preserve dimensions and position of the clock. PixelShifter?.RestoreBasePosition(this); Settings.Default.LastDisplay = CurrentTimeOrCountdownString; diff --git a/DesktopClock/Utilities/BackgroundBrushConverter.cs b/DesktopClock/Utilities/BackgroundBrushConverter.cs index 4009aaf..fe1f560 100644 --- a/DesktopClock/Utilities/BackgroundBrushConverter.cs +++ b/DesktopClock/Utilities/BackgroundBrushConverter.cs @@ -51,7 +51,7 @@ private static ImageSource TryLoadImage(string path) var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; // Decode now so a missing or invalid file fails here, and don't hold the file locked. - bitmap.UriSource = new Uri(path, UriKind.Absolute); + bitmap.UriSource = new Uri(FileUtil.UnquotePath(path), UriKind.Absolute); bitmap.EndInit(); bitmap.Freeze(); return bitmap; diff --git a/DesktopClock/Utilities/FileUtil.cs b/DesktopClock/Utilities/FileUtil.cs index 8d6cf56..271222d 100644 --- a/DesktopClock/Utilities/FileUtil.cs +++ b/DesktopClock/Utilities/FileUtil.cs @@ -26,6 +26,11 @@ public static FileInfo GetFileAtNextIndex(this FileInfo fileInfo) return file; } + /// + /// Removes the quotes that Copy as path in Explorer puts around a path, so a pasted path works like a typed one. + /// + public static string UnquotePath(string path) => path?.Trim().Trim('"'); + /// /// Whether the file is in the temp folder, which is where Windows puts an app opened straight from inside a zip, in a new folder each time. /// diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs index 4db01d7..0148a63 100644 --- a/DesktopClock/Utilities/FormatPresets.cs +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -17,7 +17,7 @@ public static class FormatPresets public static string DefaultClockFormat(CultureInfo culture) { var format = culture.DateTimeFormat; - return WithTime(culture, WeekdayAndDate(culture, "{ddd}", Token(ShortMonthDayPattern(format))), Token(format.LongTimePattern)); + return WithTime(culture, WeekdayAndDate(culture, "{ddd}", Token(ShortMonthDayPattern(format))), Token(TimeWithSeconds(format))); } /// @@ -34,7 +34,7 @@ public static string DefaultClockFormat(CultureInfo culture) if (Uses12HourClock(format)) presets.Add((Loc.Get("ClockPresetTime24"), "{HH:mm}")); - presets.Add((Loc.Get("ClockPresetTimeSeconds"), Token(format.LongTimePattern))); + presets.Add((Loc.Get("ClockPresetTimeSeconds"), Token(TimeWithSeconds(format)))); presets.Add((Loc.Get("ClockPresetDayTime"), WithTime(culture, "{ddd}", time))); var dateTime = WithTime(culture, WeekdayAndDate(culture, "{ddd}", Token(ShortMonthDayPattern(format))), time); var fullDateTime = WithTime(culture, WeekdayAndDate(culture, "{dddd}", monthDay), time); @@ -120,9 +120,24 @@ public static string ShortMonthDayPattern(DateTimeFormatInfo format) } /// - /// Whether the regional time format uses a 12-hour clock. Quoted text is skipped so a literal like the "h" in Canadian French "HH 'h' mm" doesn't count. + /// Whether the regional time format uses a 12-hour clock. /// - public static bool Uses12HourClock(DateTimeFormatInfo format) => Regex.Replace(format.ShortTimePattern, "'[^']*'", "").Contains("h"); + public static bool Uses12HourClock(DateTimeFormatInfo format) => Uses12Hours(format.ShortTimePattern); + + /// + /// The regional time with seconds, such as "h:mm:ss tt" in the US. Where someone switched only the short time between 12 and 24 hours, which is what the taskbar shows, it follows the short time instead. + /// + private static string TimeWithSeconds(DateTimeFormatInfo format) + { + if (Uses12Hours(format.ShortTimePattern) == Uses12Hours(format.LongTimePattern)) + return format.LongTimePattern; + + var minutes = format.ShortTimePattern.IndexOf("mm", StringComparison.Ordinal); + return minutes < 0 ? format.ShortTimePattern : format.ShortTimePattern.Insert(minutes + 2, ":ss"); + } + + // Quoted text is skipped so a literal like the "h" in Canadian French "HH 'h' mm" doesn't count. + private static bool Uses12Hours(string pattern) => Regex.Replace(pattern, "'[^']*'", "").Contains("h"); /// /// Puts the weekday and date in the order and with the punctuation the region uses: "Tue, Sep 22", "mar. 22 sept." in French, "22 Eyl Sal" in Turkish, and date first in Chinese, Japanese, and Korean, with a short weekday in parentheses where they use them ("9月22日(火)", "9월 22일 (화)"). diff --git a/DesktopClock/Utilities/WheelNotches.cs b/DesktopClock/Utilities/WheelNotches.cs new file mode 100644 index 0000000..92dabf5 --- /dev/null +++ b/DesktopClock/Utilities/WheelNotches.cs @@ -0,0 +1,22 @@ +using System.Windows.Input; + +namespace DesktopClock; + +/// +/// Adds up mouse wheel movement into whole notches, so touchpads and smooth-scrolling mice, which send a fraction of a notch at a time, count like a wheel. +/// +public sealed class WheelNotches +{ + private int _delta; + + /// + /// Adds a wheel delta and returns the whole notches it completes, keeping the rest for next time. + /// + public int Add(int delta) + { + _delta += delta; + var notches = _delta / Mouse.MouseWheelDeltaForOneLine; + _delta %= Mouse.MouseWheelDeltaForOneLine; + return notches; + } +}