From e080a4b204ac8c4a5d7e70b74a45d16c279173f4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 8 Nov 2026 21:18:20 -0600 Subject: [PATCH 1/4] Fix Exit losing value just typed in Settings A user types a countdown target or chime interval, leaves Settings open, and picks Exit from the clock or tray menu, or signs out. Those boxes only save their value when they lose focus or Settings closes, and the clock saved on exit before Settings closed, so the value was lost. The clock now closes Settings before it saves. The Settings scroll position is kept the same way. --- DesktopClock/MainWindow.xaml.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DesktopClock/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index 23debe1..7d1c8f6 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; @@ -551,6 +552,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; From dce2d587931dcfe1d517debded6d2bc5735c8577 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 8 Nov 2026 21:40:54 -0600 Subject: [PATCH 2/4] Fix 24-hour Short time being ignored by default clock --- DesktopClock.Tests/FormatPresetsTests.cs | 15 +++++++++++++++ DesktopClock/Utilities/FormatPresets.cs | 23 +++++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) 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/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일 (화)"). From 087a82f0a5aa7e199fedee2308cb5d125192b450 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 8 Nov 2026 21:42:13 -0600 Subject: [PATCH 3/4] Fix Ctrl+scroll not resizing with touchpads --- DesktopClock.Tests/WheelNotchesTests.cs | 50 +++++++++++++++++++++++++ DesktopClock/MainWindow.xaml.cs | 8 ++-- DesktopClock/Utilities/WheelNotches.cs | 22 +++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 DesktopClock.Tests/WheelNotchesTests.cs create mode 100644 DesktopClock/Utilities/WheelNotches.cs 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 7d1c8f6..1515447 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -36,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; /// @@ -495,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); } } 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; + } +} From 4a9514b9ff20db17596e15db1de56f13df19911a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 8 Nov 2026 21:43:34 -0600 Subject: [PATCH 4/4] Fix pasted Copy as path image and sound not working --- .../BackgroundBrushConverterTests.cs | 47 +++++++++++++++++++ DesktopClock.Tests/FileUtilTests.cs | 9 ++++ DesktopClock/MainWindow.xaml.cs | 2 +- .../Utilities/BackgroundBrushConverter.cs | 2 +- DesktopClock/Utilities/FileUtil.cs | 5 ++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 DesktopClock.Tests/BackgroundBrushConverterTests.cs 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/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index 1515447..7c72ba8 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -392,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; 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. ///