diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs new file mode 100644 index 0000000..8906d71 --- /dev/null +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace DesktopClock.Tests; + +[UseUICulture("en-US")] +public class FormatPresetsTests +{ + private static DateTimeFormatInfo Region(string culture) => CultureInfo.GetCultureInfo(culture).DateTimeFormat; + + [Fact] + public void ForClock_KeepsTheUsFormats() + { + var presets = FormatPresets.ForClock(Region("en-US")).ToDictionary(p => p.Name, p => p.Format); + + Assert.Equal("{h:mm tt}", presets["Time"]); + Assert.Equal("{HH:mm}", presets["Time, 24-hour"]); + Assert.Equal("{h:mm:ss tt}", presets["Time with seconds"]); + Assert.Equal("{ddd}, {MMM d}, {h:mm tt}", presets["Date and time"]); + Assert.Equal("{dddd}, {MMMM d}", presets["Date only"]); + } + + [Fact] + public void ForClock_FollowsRegionalTimeAndDateOrder() + { + var german = FormatPresets.ForClock(Region("de-DE")).ToDictionary(p => p.Name, p => p.Format); + Assert.Equal("{HH:mm}", german["Time"]); + Assert.Equal("{dddd}, {d. MMMM}", german["Date only"]); + + var chinese = FormatPresets.ForClock(Region("zh-CN")).ToDictionary(p => p.Name, p => p.Format); + Assert.Equal("{dddd}, {M月d日}", chinese["Date only"]); + } + + [Theory] + [InlineData("en-US", true)] + [InlineData("ko-KR", true)] + [InlineData("de-DE", false)] + [InlineData("ja-JP", false)] + [InlineData("fr-CA", false)] + public void ForClock_OnlyOffersA24HourPresetWhereTheRegionUses12Hours(string culture, bool expected) + { + Assert.Equal(expected, FormatPresets.Uses12HourClock(Region(culture))); + Assert.Equal(expected, FormatPresets.ForClock(Region(culture)).Any(p => p.Name == "Time, 24-hour")); + } + + [Fact] + public void DefaultClockFormat_FollowsTheRegion() + { + Assert.Equal("{ddd}, {MMM d}, {h:mm:ss tt}", FormatPresets.DefaultClockFormat(Region("en-US"))); + Assert.Equal("{ddd}, {d. MMM}, {HH:mm:ss}", FormatPresets.DefaultClockFormat(Region("de-DE"))); + } + + [Fact] + public void AllPresets_RenderInEveryRegion() + { + var now = new DateTimeOffset(2026, 9, 22, 13, 1, 22, TimeSpan.Zero); + + foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) + { + var formats = FormatPresets.ForClock(culture.DateTimeFormat).Select(p => p.Format).Append(FormatPresets.DefaultClockFormat(culture.DateTimeFormat)); + + foreach (var format in formats) + Assert.True(Tokenizer.FormatWithTokenizerOrFallBack(now, format, culture) != Tokenizer.FormatErrorMessage, $"{culture.Name}: {format}"); + } + } +} diff --git a/DesktopClock/CountdownTargetEditor.xaml.cs b/DesktopClock/CountdownTargetEditor.xaml.cs index e24a610..0a7559b 100644 --- a/DesktopClock/CountdownTargetEditor.xaml.cs +++ b/DesktopClock/CountdownTargetEditor.xaml.cs @@ -68,7 +68,7 @@ private void BuildPresetButtons() var example = new TextBlock { - Text = $"{getTarget():ddd, MMM d} {getTarget():t}", + Text = getTarget().ToString($"ddd, {FormatPresets.ShortMonthDayPattern(CultureInfo.CurrentCulture.DateTimeFormat)} {CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern}"), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, }; diff --git a/DesktopClock/FormatEditor.xaml.cs b/DesktopClock/FormatEditor.xaml.cs index e0a072e..a092cf1 100644 --- a/DesktopClock/FormatEditor.xaml.cs +++ b/DesktopClock/FormatEditor.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Windows; using System.Windows.Controls; @@ -23,18 +24,7 @@ public enum FormatEditorMode public partial class FormatEditor : UserControl { // Presets cover the scenarios users most commonly ask for; the raw box stays the escape hatch. - private static readonly (string Name, string Format)[] ClockPresets = - { - (Loc.Get("ClockPresetTime"), "{h:mm tt}"), - (Loc.Get("ClockPresetTime24"), "{HH:mm}"), - (Loc.Get("ClockPresetTimeSeconds"), "{h:mm:ss tt}"), - (Loc.Get("ClockPresetDayTime"), "{ddd}, {h:mm tt}"), - (Loc.Get("ClockPresetDateTime"), "{ddd}, {MMM dd}, {h:mm tt}"), - (Loc.Get("ClockPresetFullDateTime"), "{dddd}, {MMMM dd}, {h:mm tt}"), - (Loc.Get("ClockPresetDateOnly"), "{dddd}, {MMMM dd}"), - (Loc.Get("ClockPresetSortable"), "{yyyy-MM-dd} {HH:mm}"), - (Loc.Get("ClockPresetIsoWeek"), "{weekYear}-W{week}"), - }; + private static readonly IReadOnlyList<(string Name, string Format)> ClockPresets = FormatPresets.ForClock(CultureInfo.CurrentCulture.DateTimeFormat); private static readonly (string Name, string Format)[] CountdownPresets = { @@ -55,7 +45,7 @@ private static readonly (string Name, string Token)[] ClockTokens = (Loc.Get("TokenMonth"), "{MMM}"), (Loc.Get("TokenMonthFull"), "{MMMM}"), (Loc.Get("TokenYear"), "{yyyy}"), - (Loc.Get("TokenTime"), "{h:mm tt}"), + (Loc.Get("TokenTime"), "{" + CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern + "}"), (Loc.Get("TokenTime24"), "{HH:mm}"), (Loc.Get("TokenSeconds"), "{ss}"), (Loc.Get("TokenWeekNumber"), "{week}"), diff --git a/DesktopClock/Properties/Settings.cs b/DesktopClock/Properties/Settings.cs index a24b6e5..8c816e7 100644 --- a/DesktopClock/Properties/Settings.cs +++ b/DesktopClock/Properties/Settings.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Windows.Media; @@ -117,7 +118,7 @@ public static void TrySaveIfLoaded() /// Change it when you want a different arrangement, such as adding the weekday, seconds, or a shorter date. /// See: Custom date and time format strings. /// - public string Format { get; set; } = "{ddd}, {MMM dd}, {h:mm:ss tt}"; + public string Format { get; set; } = FormatPresets.DefaultClockFormat(CultureInfo.CurrentCulture.DateTimeFormat); /// /// Format string used while is enabled. diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs new file mode 100644 index 0000000..c35bfd3 --- /dev/null +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace DesktopClock; + +/// +/// Clock formats built from the Windows regional format, so times are 24-hour where people expect it and dates follow the local day and month order. +/// +public static class FormatPresets +{ + /// + /// The clock's default format: short weekday, short date, and time with seconds, such as "Tue, Sep 22, 1:01:22 PM" in the US or "Di., 22. Sep., 13:01:22" in Germany. + /// + public static string DefaultClockFormat(DateTimeFormatInfo format) => + $"{{ddd}}, {Token(ShortMonthDayPattern(format))}, {Token(format.LongTimePattern)}"; + + /// + /// One-click clock formats for common scenarios. + /// + public static IReadOnlyList<(string Name, string Format)> ForClock(DateTimeFormatInfo format) + { + var time = Token(format.ShortTimePattern); + var monthDay = Token(format.MonthDayPattern); + var presets = new List<(string Name, string Format)> { (Loc.Get("ClockPresetTime"), time) }; + + // A 24-hour preset only adds something where the regional time is 12-hour. + if (Uses12HourClock(format)) + presets.Add((Loc.Get("ClockPresetTime24"), "{HH:mm}")); + + presets.Add((Loc.Get("ClockPresetTimeSeconds"), Token(format.LongTimePattern))); + presets.Add((Loc.Get("ClockPresetDayTime"), $"{{ddd}}, {time}")); + presets.Add((Loc.Get("ClockPresetDateTime"), $"{{ddd}}, {Token(ShortMonthDayPattern(format))}, {time}")); + presets.Add((Loc.Get("ClockPresetFullDateTime"), $"{{dddd}}, {monthDay}, {time}")); + presets.Add((Loc.Get("ClockPresetDateOnly"), $"{{dddd}}, {monthDay}")); + presets.Add((Loc.Get("ClockPresetSortable"), "{yyyy-MM-dd} {HH:mm}")); + presets.Add((Loc.Get("ClockPresetIsoWeek"), "{weekYear}-W{week}")); + return presets; + } + + /// + /// The regional month and day with an abbreviated month, such as "MMM d" in the US or "d. MMM" in Germany. + /// + public static string ShortMonthDayPattern(DateTimeFormatInfo format) => format.MonthDayPattern.Replace("MMMM", "MMM"); + + /// + /// 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. + /// + public static bool Uses12HourClock(DateTimeFormatInfo format) => Regex.Replace(format.ShortTimePattern, "'[^']*'", "").Contains("h"); + + private static string Token(string pattern) => "{" + pattern + "}"; +}