From 91c26e64301b3bfadf5da3c292b94d84f190c2d2 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Tue, 22 Sep 2026 13:05:05 -0500 Subject: [PATCH 1/3] Move UI text into a resource file All user-facing text now lives in Localization/Strings.resx and is looked up with {local:Loc Key} in XAML or Loc.Get in code, following the Windows display language with English as the fallback. The English UI is unchanged. - Translations compile into the exe instead of satellite assemblies, because Costura doesn't embed the app's own satellites and the app ships as a single file - Relative time ("in 3 days") picks plural forms per language instead of a fixed singular/plural pair - Countdown presets that contain words ("{%d} days left") are translatable; theme preset names too - Docs links drop /en-us so they open in the reader's language - Tests that assert English text pin the UI language --- .../RelativeTimeFormatterTests.cs | 1 + .../TimeStringFormatterTests.cs | 1 + DesktopClock.Tests/UseUICultureAttribute.cs | 25 + DesktopClock/ClockTheme.cs | 16 +- DesktopClock/CountdownTargetEditor.xaml | 9 +- DesktopClock/CountdownTargetEditor.xaml.cs | 10 +- DesktopClock/DesktopClock.csproj | 5 + DesktopClock/FormatEditor.xaml | 11 +- DesktopClock/FormatEditor.xaml.cs | 64 +- DesktopClock/Localization/Loc.cs | 64 ++ DesktopClock/Localization/Strings.resx | 709 ++++++++++++++++++ DesktopClock/MainWindow.xaml | 17 +- DesktopClock/MainWindow.xaml.cs | 13 +- DesktopClock/SettingsWindow.xaml | 236 +++--- DesktopClock/SettingsWindow.xaml.cs | 17 +- DesktopClock/ThemePresetPicker.xaml.cs | 2 +- DesktopClock/Utilities/CrashHandler.cs | 6 +- .../Utilities/RelativeTimeFormatter.cs | 65 +- DesktopClock/Utilities/Tokenizer.cs | 2 +- 19 files changed, 1051 insertions(+), 222 deletions(-) create mode 100644 DesktopClock.Tests/UseUICultureAttribute.cs create mode 100644 DesktopClock/Localization/Loc.cs create mode 100644 DesktopClock/Localization/Strings.resx diff --git a/DesktopClock.Tests/RelativeTimeFormatterTests.cs b/DesktopClock.Tests/RelativeTimeFormatterTests.cs index 20ed00b..cf893d7 100644 --- a/DesktopClock.Tests/RelativeTimeFormatterTests.cs +++ b/DesktopClock.Tests/RelativeTimeFormatterTests.cs @@ -2,6 +2,7 @@ namespace DesktopClock.Tests; +[UseUICulture("en-US")] public class RelativeTimeFormatterTests { private static readonly DateTime Now = new(2026, 3, 15, 12, 34, 56, 500); diff --git a/DesktopClock.Tests/TimeStringFormatterTests.cs b/DesktopClock.Tests/TimeStringFormatterTests.cs index c08fcdf..c25feb4 100644 --- a/DesktopClock.Tests/TimeStringFormatterTests.cs +++ b/DesktopClock.Tests/TimeStringFormatterTests.cs @@ -3,6 +3,7 @@ namespace DesktopClock.Tests; +[UseUICulture("en-US")] public class TimeStringFormatterTests { [Fact] diff --git a/DesktopClock.Tests/UseUICultureAttribute.cs b/DesktopClock.Tests/UseUICultureAttribute.cs new file mode 100644 index 0000000..1023809 --- /dev/null +++ b/DesktopClock.Tests/UseUICultureAttribute.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Reflection; +using System.Threading; +using Xunit.Sdk; + +namespace DesktopClock.Tests; + +/// +/// Runs a test with the given UI language so text assertions don't depend on the machine's display language. +/// +public sealed class UseUICultureAttribute(string culture) : BeforeAfterTestAttribute +{ + private CultureInfo _originalCulture; + + public override void Before(MethodInfo methodUnderTest) + { + _originalCulture = Thread.CurrentThread.CurrentUICulture; + Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(culture); + } + + public override void After(MethodInfo methodUnderTest) + { + Thread.CurrentThread.CurrentUICulture = _originalCulture; + } +} diff --git a/DesktopClock/ClockTheme.cs b/DesktopClock/ClockTheme.cs index 2fbd36e..5a09b61 100644 --- a/DesktopClock/ClockTheme.cs +++ b/DesktopClock/ClockTheme.cs @@ -45,25 +45,25 @@ public static IReadOnlyList GetBuiltInThemes() return new[] { CreateSystemTheme(), - new ClockTheme("Accent", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeAccent"), "Segoe UI", "SemiBold", Color.FromRgb(0xFF, 0xFF, 0xFF), SystemThemeService.GetSystemAccentColor(), backgroundEnabled: true, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Smoke", "Segoe UI", "Normal", + new ClockTheme(Loc.Get("ThemeSmoke"), "Segoe UI", "Normal", Color.FromRgb(0xF2, 0xF2, 0xF2), Color.FromRgb(0x0A, 0x0A, 0x10), backgroundEnabled: true, backgroundOpacity: 0.55, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Terminal", "Consolas", "Bold", + new ClockTheme(Loc.Get("ThemeTerminal"), "Consolas", "Bold", Color.FromRgb(0x00, 0xE5, 0xFF), Color.FromRgb(0x0C, 0x0C, 0x0C), backgroundEnabled: true, backgroundOpacity: 0.85, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Midnight", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeMidnight"), "Segoe UI", "SemiBold", Color.FromRgb(0x4C, 0xC2, 0xFF), Color.FromRgb(0x1B, 0x1B, 0x1B), backgroundEnabled: true, backgroundOpacity: 0.95, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Paper", "Georgia", "Normal", + new ClockTheme(Loc.Get("ThemePaper"), "Georgia", "Normal", Color.FromRgb(0x1A, 0x1A, 0x1A), Color.FromRgb(0xFA, 0xF9, 0xF6), backgroundEnabled: true, backgroundOpacity: 0.97, backgroundCornerRadius: 1, outlineThickness: 0.2), - new ClockTheme("Minimal", "Segoe UI", "Light", + new ClockTheme(Loc.Get("ThemeMinimal"), "Segoe UI", "Light", Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x00, 0x00, 0x00), backgroundEnabled: false, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 0), - new ClockTheme("Chalk", "Segoe UI", "SemiBold", + new ClockTheme(Loc.Get("ThemeChalk"), "Segoe UI", "SemiBold", Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x00, 0x00, 0x00), backgroundEnabled: false, backgroundOpacity: 1, backgroundCornerRadius: 1, outlineThickness: 1.5), }; @@ -101,7 +101,7 @@ private static ClockTheme CreateSystemTheme() outerColor = Color.FromRgb(247, 247, 247); } - return new ClockTheme("System", "Consolas", "Normal", textColor, outerColor, + return new ClockTheme(Loc.Get("ThemeSystem"), "Consolas", "Normal", textColor, outerColor, backgroundEnabled: true, backgroundOpacity: 0.9, backgroundCornerRadius: 1, outlineThickness: 0.2); } } diff --git a/DesktopClock/CountdownTargetEditor.xaml b/DesktopClock/CountdownTargetEditor.xaml index 33ebe9a..a3d5a5f 100644 --- a/DesktopClock/CountdownTargetEditor.xaml +++ b/DesktopClock/CountdownTargetEditor.xaml @@ -1,15 +1,16 @@ - - @@ -20,7 +21,7 @@ @@ -104,8 +104,8 @@ IsEnabled="{x:Static p:Settings.CanBeSaved}"> - + @@ -114,8 +114,8 @@ IsEnabled="{x:Static p:Settings.CanBeSaved}"> - + @@ -132,159 +132,159 @@ - - - + - Learn more + - - - - - - - + - Learn more + - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -296,7 +296,7 @@ Text="{Binding Settings.TextColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - @@ -331,16 +331,16 @@ Text="{Binding Settings.TextOpacity, StringFormat=0.00}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -352,7 +352,7 @@ Text="{Binding Settings.OuterColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - - - - @@ -393,11 +393,11 @@ Text="{Binding Settings.BackgroundOpacity, StringFormat=0.00}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -415,11 +415,11 @@ Text="{Binding Settings.BackgroundCornerRadius, StringFormat=0.0}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -437,11 +437,11 @@ Text="{Binding Settings.OutlineThickness, StringFormat=0.0}" Style="{StaticResource SliderValueTextBlock}" /> - - @@ -454,30 +454,30 @@