From bb6a19c814db2674f5dc20a38abf16ee03cd15a3 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Tue, 22 Sep 2026 14:57:27 -0500 Subject: [PATCH 1/6] Build format presets from the regional format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clock presets and the default format were US patterns like {h:mm tt} and {MMM dd}, so in most regions "Time" showed a 12-hour time with no AM/PM (1:01 for 1:01 PM) and dates used US month-day order. They now come from the Windows regional format: 14:56 and "Di, 22. Sep" in Germany, 오후 2:56 in Korea, 9月22日 in China. - US presets are unchanged except month-day uses d instead of dd, so "Sep 05" reads "Sep 5" - "Time, 24-hour" only shows where the regional time is 12-hour, since elsewhere it repeats "Time" - The default format for new installs follows the region; existing settings files keep their saved format - The countdown target examples and the "Time" building block follow the region too --- DesktopClock.Tests/FormatPresetsTests.cs | 67 ++++++++++++++++++++++ DesktopClock/CountdownTargetEditor.xaml.cs | 2 +- DesktopClock/FormatEditor.xaml.cs | 16 +----- DesktopClock/Properties/Settings.cs | 3 +- DesktopClock/Utilities/FormatPresets.cs | 52 +++++++++++++++++ 5 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 DesktopClock.Tests/FormatPresetsTests.cs create mode 100644 DesktopClock/Utilities/FormatPresets.cs diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs new file mode 100644 index 0000000..275a74e --- /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 8e075f0..f0086ae 100644 --- a/DesktopClock/CountdownTargetEditor.xaml.cs +++ b/DesktopClock/CountdownTargetEditor.xaml.cs @@ -64,7 +64,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 + "}"; +} From a0d33de2a1774cfbf76c8885724d47384d1d8a76 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 09:36:22 -0500 Subject: [PATCH 2/6] Write dates the way Chinese, Japanese, and Korean do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presets and default format always put a comma-separated weekday first, which is how Western languages write dates but reads as foreign in East Asia (火, 9月22日, 13:01:22). Chinese, Japanese, and Korean now get the date first with the weekday after it, the way Windows and native apps write it: 9月22日(火) 13:01:22 in Japanese, 9月22日 周二 13:01:22 in Chinese, and 9월 22일 (화) 오후 1:01:22 in Korean. The countdown target examples follow the same order. Other languages are unchanged. --- DesktopClock.Tests/FormatPresetsTests.cs | 43 ++++++++++++++------ DesktopClock/CountdownTargetEditor.xaml.cs | 2 +- DesktopClock/FormatEditor.xaml.cs | 2 +- DesktopClock/Properties/Settings.cs | 2 +- DesktopClock/Utilities/FormatPresets.cs | 46 ++++++++++++++++++---- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs index 275a74e..6ce4715 100644 --- a/DesktopClock.Tests/FormatPresetsTests.cs +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -7,7 +7,9 @@ namespace DesktopClock.Tests; [UseUICulture("en-US")] public class FormatPresetsTests { - private static DateTimeFormatInfo Region(string culture) => CultureInfo.GetCultureInfo(culture).DateTimeFormat; + private static readonly DateTime Sample = new(2026, 9, 22, 13, 1, 22); + + private static CultureInfo Region(string culture) => CultureInfo.GetCultureInfo(culture); [Fact] public void ForClock_KeepsTheUsFormats() @@ -17,6 +19,7 @@ public void ForClock_KeepsTheUsFormats() 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}, {h:mm tt}", presets["Weekday and time"]); Assert.Equal("{ddd}, {MMM d}, {h:mm tt}", presets["Date and time"]); Assert.Equal("{dddd}, {MMMM d}", presets["Date only"]); } @@ -29,7 +32,27 @@ public void ForClock_FollowsRegionalTimeAndDateOrder() 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"]); + Assert.Equal("{M月d日} {dddd}", chinese["Date only"]); + } + + [Theory] + [InlineData("en-US", "Tue, Sep 22, 1:01:22 PM")] + [InlineData("ja-JP", "9月22日(火) 13:01:22")] + [InlineData("zh-CN", "9月22日 周二 13:01:22")] + [InlineData("ko-KR", "9월 22일 (화) 오후 1:01:22")] + public void DefaultClockFormat_ReadsNaturallyInTheRegion(string culture, string expected) + { + var format = FormatPresets.DefaultClockFormat(Region(culture)); + + Assert.Equal(expected, Tokenizer.FormatWithTokenizerOrFallBack(Sample, format, Region(culture))); + } + + [Theory] + [InlineData("en-US", "Tue, Sep 22 1:01 PM")] + [InlineData("ja-JP", "9月22日(火) 13:01")] + public void ShortDateTimePattern_ReadsNaturallyInTheRegion(string culture, string expected) + { + Assert.Equal(expected, Sample.ToString(FormatPresets.ShortDateTimePattern(Region(culture)), Region(culture))); } [Theory] @@ -40,28 +63,24 @@ public void ForClock_FollowsRegionalTimeAndDateOrder() [InlineData("fr-CA", false)] public void ForClock_OnlyOffersA24HourPresetWhereTheRegionUses12Hours(string culture, bool expected) { - Assert.Equal(expected, FormatPresets.Uses12HourClock(Region(culture))); + Assert.Equal(expected, FormatPresets.Uses12HourClock(Region(culture).DateTimeFormat)); 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); + var now = new DateTimeOffset(Sample, 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)); + var formats = FormatPresets.ForClock(culture).Select(p => p.Format) + .Append(FormatPresets.DefaultClockFormat(culture)); foreach (var format in formats) Assert.True(Tokenizer.FormatWithTokenizerOrFallBack(now, format, culture) != Tokenizer.FormatErrorMessage, $"{culture.Name}: {format}"); + + Assert.NotEmpty(now.ToString(FormatPresets.ShortDateTimePattern(culture), culture)); } } } diff --git a/DesktopClock/CountdownTargetEditor.xaml.cs b/DesktopClock/CountdownTargetEditor.xaml.cs index f0086ae..c7cd073 100644 --- a/DesktopClock/CountdownTargetEditor.xaml.cs +++ b/DesktopClock/CountdownTargetEditor.xaml.cs @@ -64,7 +64,7 @@ private void BuildPresetButtons() var example = new TextBlock { - Text = getTarget().ToString($"ddd, {FormatPresets.ShortMonthDayPattern(CultureInfo.CurrentCulture.DateTimeFormat)} {CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern}"), + Text = getTarget().ToString(FormatPresets.ShortDateTimePattern(CultureInfo.CurrentCulture)), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, }; diff --git a/DesktopClock/FormatEditor.xaml.cs b/DesktopClock/FormatEditor.xaml.cs index a092cf1..c9e0eab 100644 --- a/DesktopClock/FormatEditor.xaml.cs +++ b/DesktopClock/FormatEditor.xaml.cs @@ -24,7 +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 IReadOnlyList<(string Name, string Format)> ClockPresets = FormatPresets.ForClock(CultureInfo.CurrentCulture.DateTimeFormat); + private static readonly IReadOnlyList<(string Name, string Format)> ClockPresets = FormatPresets.ForClock(CultureInfo.CurrentCulture); private static readonly (string Name, string Format)[] CountdownPresets = { diff --git a/DesktopClock/Properties/Settings.cs b/DesktopClock/Properties/Settings.cs index 8c816e7..f125f80 100644 --- a/DesktopClock/Properties/Settings.cs +++ b/DesktopClock/Properties/Settings.cs @@ -118,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; } = FormatPresets.DefaultClockFormat(CultureInfo.CurrentCulture.DateTimeFormat); + public string Format { get; set; } = FormatPresets.DefaultClockFormat(CultureInfo.CurrentCulture); /// /// Format string used while is enabled. diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs index c35bfd3..2339117 100644 --- a/DesktopClock/Utilities/FormatPresets.cs +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -10,16 +10,20 @@ namespace DesktopClock; 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. + /// 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, "Di., 22. Sep., 13:01:22" in Germany, or "9月22日(火) 13:01:22" in Japan. /// - public static string DefaultClockFormat(DateTimeFormatInfo format) => - $"{{ddd}}, {Token(ShortMonthDayPattern(format))}, {Token(format.LongTimePattern)}"; + public static string DefaultClockFormat(CultureInfo culture) + { + var format = culture.DateTimeFormat; + return WithTime(culture, WeekdayAndDate(culture, "{ddd}", Token(ShortMonthDayPattern(format))), Token(format.LongTimePattern)); + } /// /// One-click clock formats for common scenarios. /// - public static IReadOnlyList<(string Name, string Format)> ForClock(DateTimeFormatInfo format) + public static IReadOnlyList<(string Name, string Format)> ForClock(CultureInfo culture) { + var format = culture.DateTimeFormat; var time = Token(format.ShortTimePattern); var monthDay = Token(format.MonthDayPattern); var presets = new List<(string Name, string Format)> { (Loc.Get("ClockPresetTime"), time) }; @@ -29,15 +33,21 @@ public static string DefaultClockFormat(DateTimeFormatInfo 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("ClockPresetDayTime"), WithTime(culture, "{ddd}", time))); + presets.Add((Loc.Get("ClockPresetDateTime"), WithTime(culture, WeekdayAndDate(culture, "{ddd}", Token(ShortMonthDayPattern(format))), time))); + presets.Add((Loc.Get("ClockPresetFullDateTime"), WithTime(culture, WeekdayAndDate(culture, "{dddd}", monthDay), time))); + presets.Add((Loc.Get("ClockPresetDateOnly"), WeekdayAndDate(culture, "{dddd}", monthDay))); presets.Add((Loc.Get("ClockPresetSortable"), "{yyyy-MM-dd} {HH:mm}")); presets.Add((Loc.Get("ClockPresetIsoWeek"), "{weekYear}-W{week}")); return presets; } + /// + /// A .NET format for a short weekday, date, and time, such as "ddd, MMM d h:mm tt" in the US or "M月d日(ddd) H:mm" in Japan. + /// + public static string ShortDateTimePattern(CultureInfo culture) => + WeekdayAndDate(culture, "ddd", ShortMonthDayPattern(culture.DateTimeFormat)) + " " + culture.DateTimeFormat.ShortTimePattern; + /// /// The regional month and day with an abbreviated month, such as "MMM d" in the US or "d. MMM" in Germany. /// @@ -48,5 +58,25 @@ public static string DefaultClockFormat(DateTimeFormatInfo format) => /// public static bool Uses12HourClock(DateTimeFormatInfo format) => Regex.Replace(format.ShortTimePattern, "'[^']*'", "").Contains("h"); + /// + /// Puts the weekday and date in the order the language writes them: "Tue, Sep 22" in most languages, but date first in Chinese, Japanese, and Korean, with a short weekday in parentheses where they use them ("9月22日(火)", "9월 22일 (화)"). + /// + private static string WeekdayAndDate(CultureInfo culture, string weekday, string date) + { + var isShortWeekday = !weekday.Contains("dddd"); + return culture.TwoLetterISOLanguageName switch + { + "ja" when isShortWeekday => $"{date}({weekday})", + "ko" when isShortWeekday => $"{date} ({weekday})", + "ja" or "ko" or "zh" => $"{date} {weekday}", + _ => $"{weekday}, {date}", + }; + } + + /// + /// Adds the time after a date: separated by a comma in most languages, and by a space in Chinese, Japanese, and Korean. + /// + private static string WithTime(CultureInfo culture, string date, string time) => culture.TwoLetterISOLanguageName is "ja" or "ko" or "zh" ? $"{date} {time}" : $"{date}, {time}"; + private static string Token(string pattern) => "{" + pattern + "}"; } From 0306d7b7a6614db5a22d7814d571182d69108cae Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 09:37:14 -0500 Subject: [PATCH 3/6] Don't show two identical time building blocks "Time" now inserts the regional time format, so in 24-hour regions like Germany it inserted the same {HH:mm} as "Time (24-hour)". The 24-hour building block now only shows where the regional time is 12-hour, the same rule the presets use, and the building blocks live next to the presets in FormatPresets. --- DesktopClock.Tests/FormatPresetsTests.cs | 4 +++- DesktopClock/FormatEditor.xaml.cs | 17 ++-------------- DesktopClock/Utilities/FormatPresets.cs | 26 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs index 6ce4715..c42e1f3 100644 --- a/DesktopClock.Tests/FormatPresetsTests.cs +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -61,10 +61,11 @@ public void ShortDateTimePattern_ReadsNaturallyInTheRegion(string culture, strin [InlineData("de-DE", false)] [InlineData("ja-JP", false)] [InlineData("fr-CA", false)] - public void ForClock_OnlyOffersA24HourPresetWhereTheRegionUses12Hours(string culture, bool expected) + public void TwentyFourHourOptions_OnlyShowWhereTheRegionUses12Hours(string culture, bool expected) { Assert.Equal(expected, FormatPresets.Uses12HourClock(Region(culture).DateTimeFormat)); Assert.Equal(expected, FormatPresets.ForClock(Region(culture)).Any(p => p.Name == "Time (24-hour)")); + Assert.Equal(expected, FormatPresets.ClockTokens(Region(culture)).Any(t => t.Name == "Time (24-hour)")); } [Fact] @@ -75,6 +76,7 @@ public void AllPresets_RenderInEveryRegion() foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) { var formats = FormatPresets.ForClock(culture).Select(p => p.Format) + .Concat(FormatPresets.ClockTokens(culture).Select(t => t.Token)) .Append(FormatPresets.DefaultClockFormat(culture)); foreach (var format in formats) diff --git a/DesktopClock/FormatEditor.xaml.cs b/DesktopClock/FormatEditor.xaml.cs index c9e0eab..f881308 100644 --- a/DesktopClock/FormatEditor.xaml.cs +++ b/DesktopClock/FormatEditor.xaml.cs @@ -35,23 +35,10 @@ private static readonly (string Name, string Format)[] CountdownPresets = (Loc.Get("CountdownPresetDigital"), "{dd}.{hh}:{mm}:{ss}"), }; + private static readonly IReadOnlyList<(string Name, string Token)> ClockTokens = FormatPresets.ClockTokens(CultureInfo.CurrentCulture); + // Multi-character tokens are used where possible; single characters would be // interpreted as standard format strings, so day/hour counts use the % prefix. - private static readonly (string Name, string Token)[] ClockTokens = - { - (Loc.Get("TokenWeekday"), "{ddd}"), - (Loc.Get("TokenWeekdayFull"), "{dddd}"), - (Loc.Get("TokenDay"), "{dd}"), - (Loc.Get("TokenMonth"), "{MMM}"), - (Loc.Get("TokenMonthFull"), "{MMMM}"), - (Loc.Get("TokenYear"), "{yyyy}"), - (Loc.Get("TokenTime"), "{" + CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern + "}"), - (Loc.Get("TokenTime24"), "{HH:mm}"), - (Loc.Get("TokenSeconds"), "{ss}"), - (Loc.Get("TokenWeekNumber"), "{week}"), - (Loc.Get("TokenUtcOffset"), "{zzz}"), - }; - private static readonly (string Name, string Token)[] CountdownTokens = { (Loc.Get("TokenDays"), "{%d}"), diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs index 2339117..180773c 100644 --- a/DesktopClock/Utilities/FormatPresets.cs +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -42,6 +42,32 @@ public static string DefaultClockFormat(CultureInfo culture) return presets; } + /// + /// Building blocks for the clock format that insert a token at the cursor. + /// + public static IReadOnlyList<(string Name, string Token)> ClockTokens(CultureInfo culture) + { + var format = culture.DateTimeFormat; + var tokens = new List<(string Name, string Token)> + { + (Loc.Get("TokenWeekday"), "{ddd}"), + (Loc.Get("TokenWeekdayFull"), "{dddd}"), + (Loc.Get("TokenDay"), "{dd}"), + (Loc.Get("TokenMonth"), "{MMM}"), + (Loc.Get("TokenMonthFull"), "{MMMM}"), + (Loc.Get("TokenYear"), "{yyyy}"), + (Loc.Get("TokenTime"), Token(format.ShortTimePattern)), + }; + + if (Uses12HourClock(format)) + tokens.Add((Loc.Get("TokenTime24"), "{HH:mm}")); + + tokens.Add((Loc.Get("TokenSeconds"), "{ss}")); + tokens.Add((Loc.Get("TokenWeekNumber"), "{week}")); + tokens.Add((Loc.Get("TokenUtcOffset"), "{zzz}")); + return tokens; + } + /// /// A .NET format for a short weekday, date, and time, such as "ddd, MMM d h:mm tt" in the US or "M月d日(ddd) H:mm" in Japan. /// From 596916e671e306196916681ea3b07b68d525f5c0 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 09:39:41 -0500 Subject: [PATCH 4/6] Remove dead format-example code DateFormatExample and the view model's SetFormat, ResetCountdown, and ResetCountdownFormat commands have had no callers since the inline format and countdown editors replaced the old dropdowns. DateFormatExample also hardcoded US patterns and read the always-null DefaultThreadCurrentCulture, which the clock itself passed as its format provider too; the clock now passes CultureInfo.CurrentCulture like the editor preview does (no change in behavior). --- DesktopClock.Tests/DateTimeTests.cs | 75 ------------------- DesktopClock/MainWindow.xaml.cs | 2 +- DesktopClock/SettingsWindow.xaml.cs | 27 ------- DesktopClock/Utilities/DateFormatExample.cs | 81 --------------------- 4 files changed, 1 insertion(+), 184 deletions(-) delete mode 100644 DesktopClock/Utilities/DateFormatExample.cs diff --git a/DesktopClock.Tests/DateTimeTests.cs b/DesktopClock.Tests/DateTimeTests.cs index f2a1293..92ec066 100644 --- a/DesktopClock.Tests/DateTimeTests.cs +++ b/DesktopClock.Tests/DateTimeTests.cs @@ -1,6 +1,5 @@ using System; using System.Globalization; -using System.Linq; namespace DesktopClock.Tests; @@ -158,78 +157,4 @@ public void GetIsoWeek_MatchesIso8601(string dateString, int expectedWeek, int e Assert.Equal(expectedWeek, date.GetIsoWeekOfYear()); Assert.Equal(expectedWeekYear, date.GetIsoWeekYear()); } - - [Theory] - [InlineData("dddd, MMMM dd", "Monday, January 01")] - [InlineData("yyyy-MM-dd", "2024-01-01")] - [InlineData("HH:mm:ss", "00:00:00")] - [InlineData("MMMM dd, yyyy", "January 01, 2024")] - public void FromFormat_CreatesCorrectExample(string format, string expected) - { - // Arrange - var dateTimeOffset = new DateTime(2024, 01, 01); - - // Act - var dateFormatExample = DateFormatExample.FromFormat(format, dateTimeOffset, CultureInfo.InvariantCulture); - - // Assert - Assert.Equal(format, dateFormatExample.Format); - Assert.Equal(expected, dateFormatExample.Example); - } - - [Fact] - public void FromFormat_WithTokenizedFormat_ShouldWork() - { - // Arrange - var dateTimeOffset = new DateTimeOffset(2024, 3, 15, 14, 30, 0, TimeSpan.Zero); - var format = "{ddd}, {MMM dd}, {HH:mm}"; - - // Act - var dateFormatExample = DateFormatExample.FromFormat(format, dateTimeOffset, CultureInfo.InvariantCulture); - - // Assert - Assert.Equal(format, dateFormatExample.Format); - Assert.Equal("Fri, Mar 15, 14:30", dateFormatExample.Example); - } - - [Fact] - public void DefaultExamples_ShouldNotBeEmpty() - { - // Assert - Assert.NotEmpty(DateFormatExample.DefaultExamples); - } - - [Fact] - public void DefaultExamples_AllShouldHaveFormatAndExample() - { - // Assert - foreach (var example in DateFormatExample.DefaultExamples) - { - Assert.NotNull(example.Format); - Assert.NotEmpty(example.Format); - Assert.NotNull(example.Example); - Assert.NotEmpty(example.Example); - } - } - - [Fact] - public void DefaultExamples_ShouldContainCustomFormats() - { - // Assert - check for some expected custom formats - var formats = DateFormatExample.DefaultExamples.Select(e => e.Format).ToList(); - - Assert.Contains(formats, f => f.Contains("{ddd}")); - Assert.Contains(formats, f => f.Contains("{HH:mm}") || f.Contains("{h:mm tt}")); - } - - [Fact] - public void DefaultExamples_ShouldContainStandardFormats() - { - // Assert - check for some expected standard formats - var formats = DateFormatExample.DefaultExamples.Select(e => e.Format).ToList(); - - Assert.Contains("D", formats); // Long date pattern - Assert.Contains("T", formats); // Long time pattern - Assert.Contains("t", formats); // Short time pattern - } } diff --git a/DesktopClock/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index fa399bb..07d2ddd 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -299,7 +299,7 @@ private void UpdateTimeString() Settings.Default.CountdownTo, Settings.Default.Format, Settings.Default.CountdownFormat, - CultureInfo.DefaultThreadCurrentCulture); + CultureInfo.CurrentCulture); } private void Window_MouseDown(object sender, MouseButtonEventArgs e) diff --git a/DesktopClock/SettingsWindow.xaml.cs b/DesktopClock/SettingsWindow.xaml.cs index d671ba4..3b075b8 100644 --- a/DesktopClock/SettingsWindow.xaml.cs +++ b/DesktopClock/SettingsWindow.xaml.cs @@ -382,33 +382,6 @@ public SettingsWindowViewModel(Settings settings) /// public IList TimeZones => _systemLists.Value.Result.TimeZones; - /// - /// Sets the format string in settings. - /// - [RelayCommand] - public void SetFormat(DateFormatExample value) - { - Settings.Default.Format = value.Format; - } - - /// - /// Disables countdown mode by resetting the date to default. - /// - [RelayCommand] - public void ResetCountdown() - { - Settings.CountdownTo = default; - } - - /// - /// Resets the countdown format to the default (dynamic) format. - /// - [RelayCommand] - public void ResetCountdownFormat() - { - Settings.CountdownFormat = string.Empty; - } - /// /// Clears the chime sound file path. /// diff --git a/DesktopClock/Utilities/DateFormatExample.cs b/DesktopClock/Utilities/DateFormatExample.cs deleted file mode 100644 index 39203ba..0000000 --- a/DesktopClock/Utilities/DateFormatExample.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; - -namespace DesktopClock; - -public record DateFormatExample -{ - private DateFormatExample(string format, string example) - { - Format = format; - Example = example; - } - - /// - /// The actual format (dddd, MMMM dd). - /// - public string Format { get; } - - /// - /// An example of the format in action (Monday, July 15). - /// - public string Example { get; } - - /// - /// Creates a for the given format. - /// - public static DateFormatExample FromFormat(string format, DateTimeOffset dateTimeOffset, IFormatProvider formatProvider) - { - var example = Tokenizer.FormatWithTokenizerOrFallBack(dateTimeOffset, format, formatProvider); - return new(format, example); - } - - /// - /// Common date time formatting strings and an example string for each. - /// - /// - /// Standard date and time format strings - ///
- /// Custom date and time format strings - ///
- public static IReadOnlyCollection DefaultExamples { get; } = new[] - { - // Custom formats - "{ddd}, {MMM dd}, {HH:mm}", // Custom format: "Mon, Apr 10, 14:30" - "{ddd}, {MMM dd}, {h:mm tt}", // Custom format: "Mon, Apr 10, 2:30 PM" - "{ddd}, {MMM dd}, {HH:mm:ss}", // Custom format: "Mon, Apr 10, 14:30:45" - "{ddd}, {MMM dd}, {h:mm:ss tt}", // Custom format: "Mon, Apr 10, 2:30:45 PM" - "{ddd}, {MMM dd}, {HH:mm K}", // Custom format: "Mon, Apr 10, 14:30 +02:00" - "{ddd}, {MMM dd}, {h:mm tt K}", // Custom format: "Mon, Apr 10, 2:30 PM +02:00" - "{ddd}, {MMM dd}, {yyyy} {HH:mm}", // Custom format: "Mon, Apr 10, 2023 14:30" - "{ddd}, {MMM dd}, {yyyy} {h:mm tt}", // Custom format: "Mon, Apr 10, 2023 14:30" - "{dddd}, {MMMM dd}", // Custom format: "Monday, April 10" - "{dddd}, {MMMM dd}, {HH:mm}", // Custom format: "Monday, April 10, 14:30" - "{dddd}, {MMMM dd}, {h:mm tt}", // Custom format: "Monday, April 10, 2:30 PM" - "{dddd}, {MMM dd}, {HH:mm}", // Custom format: "Monday, Apr 10, 14:30" - "{dddd}, {MMM dd}, {h:mm tt}", // Custom format: "Monday, Apr 10, 2:30 PM" - "{dddd}, {MMM dd}, {HH:mm:ss}", // Custom format: "Monday, Apr 10, 14:30:45" - "{dddd}, {MMM dd}, {h:mm:ss tt}", // Custom format: "Monday, Apr 10, 2:30:45 PM" - "Week {week}", // Custom token: "Week 15" - "{weekYear}-W{week}", // Custom tokens: "2023-W15" (ISO week date) - - // Standard formats - "D", // Long date pattern: Monday, June 15, 2009 (en-US) - "f", // Full date/time pattern (short time): Monday, June 15, 2009 1:45 PM (en-US) - "F", // Full date/time pattern (long time): Monday, June 15, 2009 1:45:30 PM (en-US) - "R", // RFC1123 pattern: Mon, 15 Jun 2009 20:45:30 GMT (DateTimeOffset) - "M", // Month/day pattern: June 15 (en-US) - "Y", // Year month pattern: June 2009 (en-US) - "t", // Short time pattern: 1:45 PM (en-US) - "T", // Long time pattern: 1:45:30 PM (en-US) - "d", // Short date pattern: 6/15/2009 (en-US) - "g", // General date/time pattern (short time): 6/15/2009 1:45 PM (en-US) - "G", // General date/time pattern (long time): 6/15/2009 1:45:30 PM (en-US) - "u", // Universal sortable date/time pattern: 2009-06-15 13:45:30Z (DateTime) - //"U", // Universal full date/time pattern: Monday, June 15, 2009 8:45:30 PM (en-US) // Not available for DateTimeOffset. - "s", // Sortable date/time pattern: 2009-06-15T13:45:30 - //"O", // Round-trip date/time pattern: 2009-06-15T13:45:30.0000000-07:00 (DateTimeOffset) // Too precise with milliseconds. - }.Select(f => FromFormat(f, DateTimeOffset.Now, CultureInfo.DefaultThreadCurrentCulture)).ToList(); -} From b4bca6b728f7738214d68b39481f62fa4be178d5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 10:14:03 -0500 Subject: [PATCH 5/6] Follow each region's date punctuation and month names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the running app in each language found the presets still wrote dates the English way outside Chinese, Japanese, and Korean. The weekday order and punctuation now come from the region's own long date format: - French, Italian, and Dutch drop the English commas: mar. 22 sept. 13:01:22 - Turkish puts the weekday after the date: 22 Eyl Sal 13:01:22 - Regions whose long date leaves out the weekday (Canadian French, European Portuguese, British English) follow their language - Where .NET's short month names can't be used after a day number, the date uses the full month (Ukrainian 22 вересня instead of 22 Вер) or numbers (Vietnamese 22/09 instead of 22 Thg9) - The countdown target examples use the same punctuation as the presets, so English gets its comma before the time English, German, Spanish, Brazilian Portuguese, Indonesian, and Polish are unchanged. --- DesktopClock.Tests/FormatPresetsTests.cs | 17 +++++- DesktopClock/Utilities/FormatPresets.cs | 77 ++++++++++++++++++++---- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs index c42e1f3..ffaaf24 100644 --- a/DesktopClock.Tests/FormatPresetsTests.cs +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -40,6 +40,9 @@ public void ForClock_FollowsRegionalTimeAndDateOrder() [InlineData("ja-JP", "9月22日(火) 13:01:22")] [InlineData("zh-CN", "9月22日 周二 13:01:22")] [InlineData("ko-KR", "9월 22일 (화) 오후 1:01:22")] + [InlineData("de-DE", "Di, 22. Sep, 13:01:22")] + [InlineData("fr-FR", "mar. 22 sept. 13:01:22")] + [InlineData("tr-TR", "22 Eyl Sal 13:01:22")] public void DefaultClockFormat_ReadsNaturallyInTheRegion(string culture, string expected) { var format = FormatPresets.DefaultClockFormat(Region(culture)); @@ -48,13 +51,25 @@ public void DefaultClockFormat_ReadsNaturallyInTheRegion(string culture, string } [Theory] - [InlineData("en-US", "Tue, Sep 22 1:01 PM")] + [InlineData("en-US", "Tue, Sep 22, 1:01 PM")] + [InlineData("fr-FR", "mar. 22 sept. 13:01")] [InlineData("ja-JP", "9月22日(火) 13:01")] public void ShortDateTimePattern_ReadsNaturallyInTheRegion(string culture, string expected) { Assert.Equal(expected, Sample.ToString(FormatPresets.ShortDateTimePattern(Region(culture)), Region(culture))); } + [Theory] + [InlineData("en-US", "MMM d")] + [InlineData("de-DE", "d. MMM")] + [InlineData("uk-UA", "d MMMM")] // Short month names aren't in the case a date needs, so the full name is used. + [InlineData("vi-VN", "dd/MM")] // Short month names carry the number (Thg9), so the date is written in numbers. + [InlineData("ja-JP", "M月d日")] + public void ShortMonthDayPattern_UsesNamesThatReadWell(string culture, string expected) + { + Assert.Equal(expected, FormatPresets.ShortMonthDayPattern(Region(culture).DateTimeFormat)); + } + [Theory] [InlineData("en-US", true)] [InlineData("ko-KR", true)] diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs index 180773c..1376eaa 100644 --- a/DesktopClock/Utilities/FormatPresets.cs +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; namespace DesktopClock; @@ -10,7 +12,7 @@ namespace DesktopClock; 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, "Di., 22. Sep., 13:01:22" in Germany, or "9月22日(火) 13:01:22" in Japan. + /// 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, "Di, 22. Sep, 13:01:22" in Germany, "mar. 22 sept. 13:01:22" in France, or "9月22日(火) 13:01:22" in Japan. /// public static string DefaultClockFormat(CultureInfo culture) { @@ -69,15 +71,32 @@ public static string DefaultClockFormat(CultureInfo culture) } /// - /// A .NET format for a short weekday, date, and time, such as "ddd, MMM d h:mm tt" in the US or "M月d日(ddd) H:mm" in Japan. + /// A .NET format for a short weekday, date, and time, such as "ddd, MMM d, h:mm tt" in the US or "M月d日(ddd) H:mm" in Japan. /// public static string ShortDateTimePattern(CultureInfo culture) => - WeekdayAndDate(culture, "ddd", ShortMonthDayPattern(culture.DateTimeFormat)) + " " + culture.DateTimeFormat.ShortTimePattern; + WithTime(culture, WeekdayAndDate(culture, "ddd", ShortMonthDayPattern(culture.DateTimeFormat)), culture.DateTimeFormat.ShortTimePattern); /// /// 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"); + public static string ShortMonthDayPattern(DateTimeFormatInfo format) + { + var pattern = format.MonthDayPattern; + if (!pattern.Contains("MMMM")) + return pattern; + + // Where short month names carry the number (Vietnamese "Thg10"), write the day and month as numbers instead, in the region's short date order. + var shortNames = format.AbbreviatedMonthNames.Take(12).ToList(); + if (shortNames.Any(name => name.Any(char.IsDigit))) + return Regex.Replace(format.ShortDatePattern, "[^dM]*y+[^dM]*", ""); + + // .NET only puts full month names in the grammatical case a date needs, so where the short names change too (Ukrainian "Жов" becomes "жов" after a day), keep the full name. + static string Letters(string name) => name.Replace(".", ""); + if (!shortNames.Select(Letters).SequenceEqual(format.AbbreviatedMonthGenitiveNames.Take(12).Select(Letters), StringComparer.Ordinal)) + return pattern; + + return pattern.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. @@ -85,24 +104,56 @@ public static string ShortDateTimePattern(CultureInfo culture) => public static bool Uses12HourClock(DateTimeFormatInfo format) => Regex.Replace(format.ShortTimePattern, "'[^']*'", "").Contains("h"); /// - /// Puts the weekday and date in the order the language writes them: "Tue, Sep 22" in most languages, but date first in Chinese, Japanese, and Korean, with a short weekday in parentheses where they use them ("9月22日(火)", "9월 22일 (화)"). + /// 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일 (화)"). /// private static string WeekdayAndDate(CultureInfo culture, string weekday, string date) { var isShortWeekday = !weekday.Contains("dddd"); - return culture.TwoLetterISOLanguageName switch + switch (culture.TwoLetterISOLanguageName) { - "ja" when isShortWeekday => $"{date}({weekday})", - "ko" when isShortWeekday => $"{date} ({weekday})", - "ja" or "ko" or "zh" => $"{date} {weekday}", - _ => $"{weekday}, {date}", - }; + case "ja" when isShortWeekday: + return $"{date}({weekday})"; + case "ko" when isShortWeekday: + return $"{date} ({weekday})"; + case "ja" or "ko" or "zh": + return $"{date} {weekday}"; + } + + var (weekdayFirst, separator) = WeekdayStyle(culture); + return weekdayFirst ? $"{weekday}{separator}{date}" : $"{date}{separator}{weekday}"; + } + + /// + /// Adds the time after a date: with a comma where the region puts one after the weekday ("Tue, Sep 22, 1:01 PM"), otherwise with a space ("mar. 22 sept. 13:01"). + /// + private static string WithTime(CultureInfo culture, string date, string time) + { + var usesComma = culture.TwoLetterISOLanguageName is not ("ja" or "ko" or "zh") && WeekdayStyle(culture).Separator.Trim() == ","; + return usesComma ? $"{date}, {time}" : $"{date} {time}"; } /// - /// Adds the time after a date: separated by a comma in most languages, and by a space in Chinese, Japanese, and Korean. + /// Reads how the region writes a weekday with a date from its long date pattern, or its language's when the region leaves the weekday out (like Canadian French): whether the weekday comes first, and what separates them. /// - private static string WithTime(CultureInfo culture, string date, string time) => culture.TwoLetterISOLanguageName is "ja" or "ko" or "zh" ? $"{date} {time}" : $"{date}, {time}"; + private static (bool WeekdayFirst, string Separator) WeekdayStyle(CultureInfo culture) + { + static bool IsDatePart(char c) => c is 'd' or 'M' or 'y' or '\''; + + for (var current = culture; !Equals(current, CultureInfo.InvariantCulture); current = current.Parent) + { + var pattern = current.DateTimeFormat.LongDatePattern; + var weekday = pattern.IndexOf("dddd", StringComparison.Ordinal); + if (weekday < 0) + continue; + + var separator = weekday == 0 + ? new string(pattern.Skip(4).TakeWhile(c => !IsDatePart(c)).ToArray()) + : new string(pattern.Take(weekday).Reverse().TakeWhile(c => !IsDatePart(c)).Reverse().ToArray()); + return (weekday == 0, separator.Length == 0 ? " " : separator); + } + + return (true, ", "); + } private static string Token(string pattern) => "{" + pattern + "}"; } From 22e9fb11709b2a5e47a80e74c5d5995bb5103315 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 10:14:38 -0500 Subject: [PATCH 6/6] Make the building blocks and Digital preset read naturally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chinese, Japanese, and Korean building blocks include the unit: in Japanese, Month and Day inserted bare numbers (10, 05) instead of 10月 and 5日; Japanese and Korean also drop Month (full), which was the same as Month - The Digital countdown preset separated days from hours with a dot (03.04:30:10), which reads as a date in most regions and as a time in Indonesia; it now uses colons (03:04:30:10) --- DesktopClock.Tests/FormatPresetsTests.cs | 14 ++++++++++++++ DesktopClock/FormatEditor.xaml.cs | 2 +- DesktopClock/Utilities/FormatPresets.cs | 23 ++++++++++++++++++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/DesktopClock.Tests/FormatPresetsTests.cs b/DesktopClock.Tests/FormatPresetsTests.cs index ffaaf24..d3896d4 100644 --- a/DesktopClock.Tests/FormatPresetsTests.cs +++ b/DesktopClock.Tests/FormatPresetsTests.cs @@ -70,6 +70,20 @@ public void ShortMonthDayPattern_UsesNamesThatReadWell(string culture, string ex Assert.Equal(expected, FormatPresets.ShortMonthDayPattern(Region(culture).DateTimeFormat)); } + [Fact] + public void ClockTokens_IncludeTheUnitInChineseJapaneseAndKorean() + { + var japanese = FormatPresets.ClockTokens(Region("ja-JP")).ToDictionary(t => t.Name, t => t.Token); + Assert.Equal("{d日}", japanese["Day"]); + Assert.Equal("{M月}", japanese["Month"]); + Assert.Equal("{yyyy年}", japanese["Year"]); + Assert.DoesNotContain("Month (full)", japanese.Keys); + + var english = FormatPresets.ClockTokens(Region("en-US")).ToDictionary(t => t.Name, t => t.Token); + Assert.Equal("{dd}", english["Day"]); + Assert.Equal("{MMMM}", english["Month (full)"]); + } + [Theory] [InlineData("en-US", true)] [InlineData("ko-KR", true)] diff --git a/DesktopClock/FormatEditor.xaml.cs b/DesktopClock/FormatEditor.xaml.cs index f881308..9155bf7 100644 --- a/DesktopClock/FormatEditor.xaml.cs +++ b/DesktopClock/FormatEditor.xaml.cs @@ -32,7 +32,7 @@ private static readonly (string Name, string Format)[] CountdownPresets = (Loc.Get("CountdownPresetDaysLeft"), Loc.Get("CountdownFormatDaysLeft")), (Loc.Get("CountdownPresetDaysHours"), Loc.Get("CountdownFormatDaysHours")), (Loc.Get("CountdownPresetFull"), Loc.Get("CountdownFormatFull")), - (Loc.Get("CountdownPresetDigital"), "{dd}.{hh}:{mm}:{ss}"), + (Loc.Get("CountdownPresetDigital"), "{dd}:{hh}:{mm}:{ss}"), }; private static readonly IReadOnlyList<(string Name, string Token)> ClockTokens = FormatPresets.ClockTokens(CultureInfo.CurrentCulture); diff --git a/DesktopClock/Utilities/FormatPresets.cs b/DesktopClock/Utilities/FormatPresets.cs index 1376eaa..0b5e88c 100644 --- a/DesktopClock/Utilities/FormatPresets.cs +++ b/DesktopClock/Utilities/FormatPresets.cs @@ -50,17 +50,30 @@ public static string DefaultClockFormat(CultureInfo culture) public static IReadOnlyList<(string Name, string Token)> ClockTokens(CultureInfo culture) { var format = culture.DateTimeFormat; + + // Chinese, Japanese, and Korean write a unit after each number (2026年10月5日), and their short month names are bare numbers, so the blocks include the unit. + var units = culture.TwoLetterISOLanguageName switch + { + "ja" or "zh" => ("年", "月", "日"), + "ko" => ("년", "월", "일"), + _ => default((string Year, string Month, string Day)?), + }; + var tokens = new List<(string Name, string Token)> { (Loc.Get("TokenWeekday"), "{ddd}"), (Loc.Get("TokenWeekdayFull"), "{dddd}"), - (Loc.Get("TokenDay"), "{dd}"), - (Loc.Get("TokenMonth"), "{MMM}"), - (Loc.Get("TokenMonthFull"), "{MMMM}"), - (Loc.Get("TokenYear"), "{yyyy}"), - (Loc.Get("TokenTime"), Token(format.ShortTimePattern)), + (Loc.Get("TokenDay"), units is { } u1 ? $"{{d{u1.Day}}}" : "{dd}"), + (Loc.Get("TokenMonth"), units is { } u2 ? $"{{M{u2.Month}}}" : "{MMM}"), }; + // Japanese and Korean full month names are the same as the short ones (10月), so only Chinese, with its written-out months (十月), keeps both. + if (culture.TwoLetterISOLanguageName is not ("ja" or "ko")) + tokens.Add((Loc.Get("TokenMonthFull"), "{MMMM}")); + + tokens.Add((Loc.Get("TokenYear"), units is { } u3 ? $"{{yyyy{u3.Year}}}" : "{yyyy}")); + tokens.Add((Loc.Get("TokenTime"), Token(format.ShortTimePattern))); + if (Uses12HourClock(format)) tokens.Add((Loc.Get("TokenTime24"), "{HH:mm}"));