Browse Source

Move several settings to a new user-friendly window (#51)

Daniel Chalmers 1 năm trước cách đây
mục cha
commit
f470cb05c1

+ 0 - 33
DesktopClock.Tests/DateTimeTests.cs

@@ -4,39 +4,6 @@ namespace DesktopClock.Tests;
 
 public class DateTimeTests
 {
-    [Fact]
-    public void TimeZones_ShouldContainSystemTimeZones()
-    {
-        // Act
-        var timeZones = DateTimeUtil.TimeZones;
-
-        // Assert
-        Assert.NotEmpty(timeZones);
-        Assert.Contains(timeZones, tz => tz.Id == "UTC");
-    }
-
-    [Theory]
-    [InlineData("UTC", true)]
-    [InlineData("Pacific Standard Time", true)]
-    [InlineData("NonExistentTimeZone", false)]
-    public void TryFindSystemTimeZoneById_ShouldReturnExpectedResult(string timeZoneId, bool expectedResult)
-    {
-        // Act
-        var result = DateTimeUtil.TryFindSystemTimeZoneById(timeZoneId, out var timeZoneInfo);
-
-        // Assert
-        Assert.Equal(expectedResult, result);
-        if (expectedResult)
-        {
-            Assert.NotNull(timeZoneInfo);
-            Assert.Equal(timeZoneId, timeZoneInfo.Id);
-        }
-        else
-        {
-            Assert.Null(timeZoneInfo);
-        }
-    }
-
     [Theory]
     [InlineData("2024-07-15T00:00:00Z", "00:00:00")]
     [InlineData("2024-07-15T00:00:00Z", "01:00:00")]

+ 23 - 14
DesktopClock/App.xaml.cs

@@ -1,8 +1,8 @@
 using System;
 using System.Diagnostics;
 using System.IO;
+using System.Linq;
 using System.Windows;
-using DesktopClock.Properties;
 using Microsoft.Win32;
 
 namespace DesktopClock;
@@ -14,18 +14,6 @@ public partial class App : Application
 {
     public static FileInfo MainFileInfo = new(Process.GetCurrentProcess().MainModule.FileName);
 
-    /// <summary>
-    /// Gets the time zone selected in settings, or local by default.
-    /// </summary>
-    public static TimeZoneInfo GetTimeZone() =>
-        DateTimeUtil.TryFindSystemTimeZoneById(Settings.Default.TimeZone, out var timeZoneInfo) ? timeZoneInfo : TimeZoneInfo.Local;
-
-    /// <summary>
-    /// Sets the time zone to be used.
-    /// </summary>
-    public static void SetTimeZone(TimeZoneInfo timeZone) =>
-        Settings.Default.TimeZone = timeZone.Id;
-
     /// <summary>
     /// Sets or deletes a value in the registry which enables the current executable to run on system startup.
     /// </summary>
@@ -50,4 +38,25 @@ public partial class App : Application
         else
             key?.DeleteValue(keyName, false);
     }
-}
+
+    /// <summary>
+    /// Shows a singleton window of the specified type.
+    /// If the window is already open, it activates the existing window.
+    /// Otherwise, it creates and shows a new instance of the window.
+    /// </summary>
+    /// <typeparam name="T">The type of the window to show.</typeparam>
+    /// <param name="owner">The owner window for the singleton window.</param>
+    public static void ShowSingletonWindow<T>(Window owner) where T : Window, new()
+    {
+        var window = Current.Windows.OfType<T>().FirstOrDefault() ?? new T();
+
+        if (window.IsVisible)
+        {
+            window.Activate();
+            return;
+        }
+
+        window.Owner = owner;
+        window.Show();
+    }
+}

+ 36 - 33
DesktopClock/DateFormatExample.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
 using System.Linq;
 
 namespace DesktopClock;
@@ -22,12 +23,14 @@ public record DateFormatExample
     /// </summary>
     public string Example { get; }
 
-    public static DateFormatExample Tutorial => new(string.Empty, "Create my own format...");
-
     /// <summary>
-    /// Creates a <see cref="DateFormatExample" /> from the given format.
+    /// Creates a <see cref="DateFormatExample" /> for the given format.
     /// </summary>
-    public static DateFormatExample FromFormat(string format, DateTimeOffset dateTimeOffset) => new(format, dateTimeOffset.ToString(format));
+    public static DateFormatExample FromFormat(string format, DateTimeOffset dateTimeOffset)
+    {
+        var example = Tokenizer.FormatWithTokenizerOrFallBack(dateTimeOffset, format, CultureInfo.DefaultThreadCurrentCulture);
+        return new(format, example);
+    }
 
     /// <summary>
     /// Common date time formatting strings and an example string for each.
@@ -39,33 +42,33 @@ public record DateFormatExample
     /// </remarks>
     public static IReadOnlyCollection<DateFormatExample> DefaultExamples { get; } = new[]
     {
-        "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"
-        "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"
-        "d",                           // Short date pattern: 6/15/2009 (en-US)
-        "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)
-        "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)
-        "M",                           // Month/day pattern: June 15 (en-US)
-        "O",                           // Round-trip date/time pattern: 2009-06-15T13:45:30.0000000-07:00 (DateTimeOffset)
-        "R",                           // RFC1123 pattern: Mon, 15 Jun 2009 20:45:30 GMT (DateTimeOffset)
-        "s",                           // Sortable date/time pattern: 2009-06-15T13:45:30
-        "t",                           // Short time pattern: 1:45 PM (en-US)
-        "T",                           // Long time pattern: 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.
-        "Y",                           // Year month pattern: June 2009 (en-US)
-    }.Select(f => FromFormat(f, DateTimeOffset.Now)).Append(Tutorial).ToList();
+        "{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"
+        "{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"
+        "d",                               // Short date pattern: 6/15/2009 (en-US)
+        "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)
+        "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)
+        "M",                               // Month/day pattern: June 15 (en-US)
+        "O",                               // Round-trip date/time pattern: 2009-06-15T13:45:30.0000000-07:00 (DateTimeOffset)
+        "R",                               // RFC1123 pattern: Mon, 15 Jun 2009 20:45:30 GMT (DateTimeOffset)
+        "s",                               // Sortable date/time pattern: 2009-06-15T13:45:30
+        "t",                               // Short time pattern: 1:45 PM (en-US)
+        "T",                               // Long time pattern: 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.
+        "Y",                               // Year month pattern: June 2009 (en-US)
+    }.Select(f => FromFormat(f, DateTimeOffset.Now)).ToList();
 }

+ 1 - 21
DesktopClock/DateTimeUtil.cs

@@ -1,29 +1,9 @@
 using System;
-using System.Collections.Generic;
 
 namespace DesktopClock;
 
 public static class DateTimeUtil
 {
-    /// <summary>
-    /// A cached collection of all the time zones about which information is available on the local system.
-    /// </summary>
-    public static IReadOnlyCollection<TimeZoneInfo> TimeZones { get; } = TimeZoneInfo.GetSystemTimeZones();
-
-    public static bool TryFindSystemTimeZoneById(string timeZoneId, out TimeZoneInfo timeZoneInfo)
-    {
-        try
-        {
-            timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId);
-            return true;
-        }
-        catch (TimeZoneNotFoundException)
-        {
-            timeZoneInfo = null;
-            return false;
-        }
-    }
-
     /// <summary>
     /// Converts a DateTime to a DateTimeOffset, without risking any onerous exceptions
     /// the framework quite unfortunately throws within the DateTimeOffset constructor,
@@ -45,4 +25,4 @@ public static class DateTimeUtil
 
         return new DateTimeOffset(dt.Ticks, offset);
     }
-}
+}

+ 3 - 55
DesktopClock/MainWindow.xaml

@@ -37,22 +37,6 @@
                       IsCheckable="True"
                       IsChecked="{Binding Topmost, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
 
-            <MenuItem Header="Show icon in tas_kbar"
-                      IsCheckable="True"
-                      IsChecked="{Binding ShowInTaskbar, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
-
-            <MenuItem Header="Start with _PC"
-                      IsCheckable="True"
-                      IsChecked="{Binding RunOnStartup, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
-
-            <MenuItem Header="Show _background"
-                      IsCheckable="True"
-                      IsChecked="{Binding BackgroundEnabled, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
-
-            <MenuItem Header="_Drag to move"
-                      IsCheckable="True"
-                      IsChecked="{Binding DragToMove, Source={x:Static p:Settings.Default}, Mode=TwoWay}" />
-
             <MenuItem>
                 <MenuItem.Header>
                     <StackPanel Orientation="Horizontal">
@@ -78,48 +62,12 @@
                 </MenuItem.Resources>
             </MenuItem>
 
-            <MenuItem Header="Time _Zone" ItemsSource="{x:Static local:DateTimeUtil.TimeZones}">
-                <MenuItem.Resources>
-                    <Style TargetType="MenuItem">
-                        <Setter Property="Command" Value="{Binding DataContext.SetTimeZoneCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}}" />
-
-                        <Setter Property="CommandParameter" Value="{Binding}" />
-                    </Style>
-                </MenuItem.Resources>
-            </MenuItem>
-
-            <MenuItem Header="_Format" ItemsSource="{x:Static local:DateFormatExample.DefaultExamples}">
-                <MenuItem.Resources>
-                    <Style TargetType="MenuItem">
-                        <Style.Triggers>
-                            <DataTrigger Binding="{Binding}" Value="{x:Static local:DateFormatExample.Tutorial}">
-                                <Setter Property="Command" Value="{Binding DataContext.FormatWizardCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}}" />
-
-                                <Setter Property="IsEnabled" Value="{x:Static p:Settings.CanBeSaved}" />
-                            </DataTrigger>
-                        </Style.Triggers>
-
-                        <Setter Property="Command" Value="{Binding DataContext.SetFormatCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}}" />
-
-                        <Setter Property="CommandParameter" Value="{Binding Format}" />
-
-                        <Setter Property="DisplayMemberPath" Value="Example" />
-                    </Style>
-                </MenuItem.Resources>
-            </MenuItem>
-
             <Separator />
 
-            <MenuItem Command="{Binding NewClockCommand}"
-                      Header="_New clock..."
-                      IsEnabled="{x:Static p:Settings.CanBeSaved}" />
-
-            <MenuItem Command="{Binding CountdownWizardCommand}"
-                      Header="_Countdown to..."
-                      IsEnabled="{x:Static p:Settings.CanBeSaved}" />
+            <MenuItem Command="{Binding OpenSettingsCommand}" Header="_Settings" />
 
-            <MenuItem Command="{Binding OpenSettingsCommand}"
-                      Header="Advanced _settings"
+            <MenuItem Command="{Binding NewClockCommand}"
+                      Header="_New clock"
                       IsEnabled="{x:Static p:Settings.CanBeSaved}" />
 
             <MenuItem Command="{Binding CheckForUpdatesCommand}" Header="Check for _updates" />

+ 14 - 99
DesktopClock/MainWindow.xaml.cs

@@ -45,7 +45,7 @@ public partial class MainWindow : Window
         InitializeComponent();
         DataContext = this;
 
-        _timeZone = App.GetTimeZone();
+        _timeZone = Settings.Default.GetTimeZoneInfo();
         UpdateCountdownEnabled();
 
         Settings.Default.PropertyChanged += (s, e) => Dispatcher.Invoke(() => Settings_PropertyChanged(s, e));
@@ -53,11 +53,13 @@ public partial class MainWindow : Window
         // Not done through binding due to what's explained in the comment in WindowUtil.HideFromScreen().
         ShowInTaskbar = Settings.Default.ShowInTaskbar;
 
+        // Restore the structure of the last state using the display text.
         CurrentTimeOrCountdownString = Settings.Default.LastDisplay;
 
         _systemClockTimer = new();
         _systemClockTimer.SecondChanged += SystemClockTimer_SecondChanged;
 
+        // The context menu is shared between right-clicking the window and the tray icon.
         ContextMenu = Resources["MainContextMenu"] as ContextMenu;
 
         ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, true);
@@ -79,7 +81,7 @@ public partial class MainWindow : Window
     {
         if (!Settings.Default.TipsShown.HasFlag(TeachingTips.HideForNow))
         {
-            MessageBox.Show(this, "Clock will be minimized and can be opened again from the taskbar or system tray (if enabled).",
+            MessageBox.Show(this, "Clock will be minimized and can be opened again from the taskbar (or system tray if enabled).",
                 Title, MessageBoxButton.OK, MessageBoxImage.Information);
 
             Settings.Default.TipsShown |= TeachingTips.HideForNow;
@@ -95,37 +97,13 @@ public partial class MainWindow : Window
     public void SetTheme(Theme theme) => Settings.Default.Theme = theme;
 
     /// <summary>
-    /// Sets the format string in settings to the given string.
+    /// Opens a new settings window or activates the existing one.
     /// </summary>
     [RelayCommand]
-    public void SetFormat(string format) => Settings.Default.Format = format;
+    public void OpenSettings() => App.ShowSingletonWindow<SettingsWindow>(this);
 
     /// <summary>
-    /// Explains how to write a format, then asks the user if they want to view a website and advanced settings to do so.
-    /// </summary>
-    [RelayCommand]
-    public void FormatWizard()
-    {
-        var result = MessageBox.Show(this,
-            $"In advanced settings: edit \"{nameof(Settings.Default.Format)}\" using special \"Custom date and time format strings\", then save." +
-            "\n\nOpen advanced settings and a tutorial now?",
-            Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
-
-        if (result != MessageBoxResult.OK)
-            return;
-
-        Process.Start("https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings");
-        OpenSettings();
-    }
-
-    /// <summary>
-    /// Sets the time zone ID in settings to the given time zone ID.
-    /// </summary>
-    [RelayCommand]
-    public void SetTimeZone(TimeZoneInfo tzi) => App.SetTimeZone(tzi);
-
-    /// <summary>
-    /// Creates a new clock executable and starts it.
+    /// Asks the user then creates a new clock executable and starts it.
     /// </summary>
     [RelayCommand]
     public void NewClock()
@@ -150,68 +128,6 @@ public partial class MainWindow : Window
         Process.Start(newExePath);
     }
 
-    /// <summary>
-    /// Explains how to enable countdown mode, then asks the user if they want to view advanced settings to do so.
-    /// </summary>
-    [RelayCommand]
-    public void CountdownWizard()
-    {
-        var result = MessageBox.Show(this,
-            $"In advanced settings: change \"{nameof(Settings.Default.CountdownTo)}\" in the format of \"{default(DateTime)}\", then save." +
-            "\n\nOpen advanced settings now?",
-            Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
-
-        if (result != MessageBoxResult.OK)
-            return;
-
-        OpenSettings();
-    }
-
-    /// <summary>
-    /// Opens the settings file in Notepad.
-    /// </summary>
-    [RelayCommand]
-    public void OpenSettings()
-    {
-        // Inform user how it works.
-        if (!Settings.Default.TipsShown.HasFlag(TeachingTips.AdvancedSettings))
-        {
-            MessageBox.Show(this,
-                "Settings are stored in JSON format and will be opened in Notepad. Simply save the file to see your changes appear on the clock. To start fresh, delete your '.settings' file.",
-                Title, MessageBoxButton.OK, MessageBoxImage.Information);
-
-            Settings.Default.TipsShown |= TeachingTips.AdvancedSettings;
-        }
-
-        // Save first if we can so it's up-to-date.
-        if (Settings.CanBeSaved)
-            Settings.Default.Save();
-
-        // If it doesn't even exist then it's probably somewhere that requires special access and we shouldn't even be at this point.
-        if (!Settings.Exists)
-        {
-            MessageBox.Show(this,
-                "Settings file doesn't exist and couldn't be created.",
-                Title, MessageBoxButton.OK, MessageBoxImage.Error);
-            return;
-        }
-
-        // Open settings file in Notepad.
-        try
-        {
-            Process.Start("notepad", Settings.FilePath);
-        }
-        catch (Exception ex)
-        {
-            // Lazy scammers on the Microsoft Store reupload without realizing it gets sandboxed, making it unable to start the Notepad process (Issues: #1, #12).
-            MessageBox.Show(this,
-                "Couldn't open settings file.\n\n" +
-                "This app may have been reuploaded without permission. If you paid for it, ask for a refund and download it for free from the original source: https://github.com/danielchalmers/DesktopClock.\n\n" +
-                $"If it still doesn't work, create a new Issue at that link with details on what happened and include this error: \"{ex.Message}\"",
-                Title, MessageBoxButton.OK, MessageBoxImage.Error);
-        }
-    }
-
     /// <summary>
     /// Opens the GitHub Releases page.
     /// </summary>
@@ -221,7 +137,7 @@ public partial class MainWindow : Window
         if (!Settings.Default.TipsShown.HasFlag(TeachingTips.CheckForUpdates))
         {
             var result = MessageBox.Show(this,
-                "This will take you to a website to view the latest release.\n\n" +
+                "This will take you to GitHub to view the latest releases.\n\n" +
                 "Continue?",
                 Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
 
@@ -240,7 +156,7 @@ public partial class MainWindow : Window
     [RelayCommand]
     public void Exit()
     {
-        Close();
+        Application.Current.Shutdown();
     }
 
     private void ConfigureTrayIcon(bool showIcon, bool firstLaunch)
@@ -280,7 +196,7 @@ public partial class MainWindow : Window
         switch (e.PropertyName)
         {
             case nameof(Settings.Default.TimeZone):
-                _timeZone = App.GetTimeZone();
+                _timeZone = Settings.Default.GetTimeZoneInfo();
                 UpdateTimeString();
                 break;
 
@@ -321,17 +237,17 @@ public partial class MainWindow : Window
     /// </summary>
     private void UpdateCountdownEnabled()
     {
-        if (Settings.Default.CountdownTo == null || Settings.Default.CountdownTo == default(DateTime))
+        if (Settings.Default.CountdownTo == default)
         {
             CountdownTo = null;
             return;
         }
 
-        CountdownTo = Settings.Default.CountdownTo.Value.ToDateTimeOffset(_timeZone.BaseUtcOffset);
+        CountdownTo = Settings.Default.CountdownTo.ToDateTimeOffset(_timeZone.BaseUtcOffset);
     }
 
     /// <summary>
-    /// Updates the sound player enabled state based on the settings.
+    /// Initializes the sound player for the specified file if enabled; otherwise, sets it to <c>null</c>.
     /// </summary>
     private void UpdateSoundPlayerEnabled()
     {
@@ -340,7 +256,7 @@ public partial class MainWindow : Window
             Settings.Default.WavFileInterval != default &&
             File.Exists(Settings.Default.WavFilePath);
 
-        _soundPlayer = soundPlayerEnabled ? new() : null;
+        _soundPlayer = soundPlayerEnabled ? new(Settings.Default.WavFilePath) : null;
     }
 
     /// <summary>
@@ -361,7 +277,6 @@ public partial class MainWindow : Window
 
         try
         {
-            _soundPlayer.SoundLocation = Settings.Default.WavFilePath;
             _soundPlayer.Play();
         }
         catch

+ 39 - 21
DesktopClock/Properties/Settings.cs

@@ -73,7 +73,7 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     #region "Properties"
 
     /// <summary>
-    /// Format string for the date and time shown on the clock display.
+    /// .NET format string for the time shown on the clock. Format specific parts inside { and }.
     /// </summary>
     /// <remarks>
     /// See: <see href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings">Custom date and time format strings</see>.
@@ -81,7 +81,7 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     public string Format { get; set; } = "{ddd}, {MMM dd}, {h:mm:ss tt}";
 
     /// <summary>
-    /// Format string shown on the clock display when in countdown mode.
+    /// Format string for the countdown mode. If left blank, it will be dynamic.
     /// </summary>
     /// <remarks>
     /// See: <see href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-timespan-format-strings">Custom TimeSpan format strings</see>.
@@ -89,32 +89,32 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     public string CountdownFormat { get; set; } = "";
 
     /// <summary>
-    /// Target date and time for countdown mode.
+    /// Date and time to countdown to. If left blank, countdown mode is not enabled.
     /// </summary>
-    public DateTime? CountdownTo { get; set; } = default(DateTime);
+    public DateTime CountdownTo { get; set; } = default;
 
     /// <summary>
-    /// Time zone for the clock display.
+    /// A different time zone to be used.
     /// </summary>
     public string TimeZone { get; set; } = string.Empty;
 
     /// <summary>
-    /// Font family for the clock display.
+    /// Font to use for the clock's text.
     /// </summary>
     public string FontFamily { get; set; } = "Consolas";
 
     /// <summary>
-    /// Text color for the clock display.
+    /// Text color for the clock's text.
     /// </summary>
     public Color TextColor { get; set; }
 
     /// <summary>
-    /// The outer color, either for the background or the outline..
+    /// The outer color, for either the background or the outline.
     /// </summary>
     public Color OuterColor { get; set; }
 
     /// <summary>
-    /// Shows a full background instead of a simple outline.
+    /// Shows a solid background instead of an outline.
     /// </summary>
     public bool BackgroundEnabled { get; set; } = true;
 
@@ -129,7 +129,7 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     public double BackgroundCornerRadius { get; set; } = 1;
 
     /// <summary>
-    /// Path to the background image.
+    /// Path to the background image. If left blank, a solid color will be used.
     /// </summary>
     public string BackgroundImagePath { get; set; } = string.Empty;
 
@@ -144,35 +144,32 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     public bool Topmost { get; set; } = true;
 
     /// <summary>
-    /// Shows the app icon in the taskbar.
+    /// Shows the app icon in the taskbar instead of the tray.
     /// </summary>
     public bool ShowInTaskbar { get; set; } = true;
 
     /// <summary>
-    /// Height of the clock display.
+    /// Height of the clock window.
     /// </summary>
     public int Height { get; set; } = 48;
 
     /// <summary>
-    /// Runs the app when the user logs in.
+    /// Opens the app when you log in.
     /// </summary>
-    /// <remarks>
-    /// A registry key is created or deleted.
-    /// </remarks>
     public bool RunOnStartup { get; set; } = false;
 
     /// <summary>
-    /// Starts the app in the "Hide for now" state.
+    /// Starts the app hidden until the taskbar or tray icon is clicked.
     /// </summary>
     public bool StartHidden { get; set; } = false;
 
     /// <summary>
-    /// Allows moving the clock by dragging.
+    /// Allows moving the clock by dragging it with the cursor.
     /// </summary>
     public bool DragToMove { get; set; } = true;
 
     /// <summary>
-    /// Aligns the text to the right.
+    /// Experimental: Keeps the clock window aligned to the right when the size changes.
     /// </summary>
     /// <remarks>
     /// Small glitches can happen because programs are naturally meant to be left-anchored.
@@ -180,15 +177,20 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
     public bool RightAligned { get; set; } = false;
 
     /// <summary>
-    /// Path to a WAV file for audio alerts.
+    /// Path to a WAV file to be played on a specified interval.
     /// </summary>
     public string WavFilePath { get; set; } = string.Empty;
 
     /// <summary>
-    /// Interval for playing the WAV file if one is specified and exists.
+    /// Interval for playing the WAV file if one is specified and exists (HH:mm:ss).
     /// </summary>
     public TimeSpan WavFileInterval { get; set; }
 
+    /// <summary>
+    /// The index of the selected tab in the settings window.
+    /// </summary>
+    public int SettingsTabIndex { get; set; }
+
     /// <summary>
     /// Teaching tips that have already been shown to the user.
     /// </summary>
@@ -323,6 +325,22 @@ public sealed class Settings : INotifyPropertyChanged, IDisposable
         Height = (int)exp;
     }
 
+    /// <summary>
+    /// Gets the time zone selected in settings, or local by default.
+    /// </summary>
+    public TimeZoneInfo GetTimeZoneInfo()
+    {
+
+        try
+        {
+            return TimeZoneInfo.FindSystemTimeZoneById(TimeZone);
+        }
+        catch (TimeZoneNotFoundException)
+        {
+            return TimeZoneInfo.Local;
+        }
+    }
+
     public void Dispose()
     {
         // We don't dispose of the watcher anymore because it would actually hang indefinitely if you had multiple instances of the same clock open.

+ 203 - 0
DesktopClock/SettingsWindow.xaml

@@ -0,0 +1,203 @@
+<Window x:Class="DesktopClock.SettingsWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:local="clr-namespace:DesktopClock"
+        xmlns:p="clr-namespace:DesktopClock.Properties"
+        d:DataContext="{d:DesignInstance Type=local:SettingsWindowViewModel}"
+        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
+        mc:Ignorable="d"
+        Title="DesktopClock Settings"
+        Width="500"
+        ResizeMode="CanMinimize"
+        SizeToContent="Height"
+        WindowStartupLocation="CenterScreen">
+    <TabControl Padding="12,12,12,0" SelectedIndex="{Binding Settings.SettingsTabIndex, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
+        <TabItem Header="Format">
+            <StackPanel>
+                <TextBlock Text="Date and Time Format:" />
+                <Grid>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="*" />
+                        <ColumnDefinition Width="100" />
+                    </Grid.ColumnDefinitions>
+                    <TextBox Grid.Column="0" Text="{Binding Settings.Format, UpdateSourceTrigger=PropertyChanged}" />
+                    <ComboBox Grid.Column="1"
+                              DisplayMemberPath="Example"
+                              ItemsSource="{x:Static local:DateFormatExample.DefaultExamples}"
+                              SelectionChanged="SelectFormat" />
+                </Grid>
+                <TextBlock Text=".NET format string for the time shown on the clock. Format specific parts inside { and }."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="Countdown Format:" />
+                <TextBox Text="{Binding Settings.CountdownFormat, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Format string for the countdown mode. If left blank, it will be dynamic."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="Countdown To:" />
+                <Grid>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="*" />
+                        <ColumnDefinition Width="Auto" />
+                    </Grid.ColumnDefinitions>
+
+                    <TextBox Text="{Binding Settings.CountdownTo, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="0" />
+                    <Button Content="Reset"
+                            Command="{Binding ResetCountdownCommand}"
+                            Grid.Column="1" />
+                </Grid>
+                <TextBlock Text="Date and time to countdown to. If left blank, countdown mode is not enabled."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+
+                <TextBlock Text="Time Zone:" />
+                <ComboBox ItemsSource="{Binding TimeZones}" SelectedItem="{Binding Settings.TimeZone, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="A different time zone to be used."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+            </StackPanel>
+        </TabItem>
+
+        <TabItem Header="Appearance">
+            <StackPanel>
+                <TextBlock Text="Font Family:" />
+                <ComboBox ItemsSource="{Binding FontFamilies}" SelectedItem="{Binding Settings.FontFamily, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Font to use for the clock's text."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="Text Color:" />
+                <TextBox Text="{Binding Settings.TextColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Text color for the clock's text."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="Outer Color:" />
+                <TextBox Text="{Binding Settings.OuterColor, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="The outer color, for either the background or the outline."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <CheckBox Content="Enable Background" IsChecked="{Binding Settings.BackgroundEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Shows a solid background instead of an outline."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="Background Opacity:" />
+                <Slider Value="{Binding Settings.BackgroundOpacity, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
+                        Minimum="0"
+                        Maximum="1"
+                        TickFrequency="0.01"
+                        IsSnapToTickEnabled="True" />
+                <TextBlock Text="Opacity of the background."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <!--  Binding doesn't format correctly and needs better documentation.  -->
+                <!--<TextBlock Text="Background Corner Radius:" />
+                <TextBox Text="{Binding Settings.BackgroundCornerRadius, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Corner radius of the background."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />-->
+
+                <TextBlock Text="Background Image Path:" />
+                <Grid>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="*" />
+                        <ColumnDefinition Width="Auto" />
+                    </Grid.ColumnDefinitions>
+
+                    <TextBox Text="{Binding Settings.BackgroundImagePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="0" />
+                    <Button Content="Browse..."
+                            Click="BrowseBackgroundImagePath"
+                            Grid.Column="1" />
+                </Grid>
+                <TextBlock Text="Path to the background image. If left blank, a solid color will be used."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <!--  Binding doesn't format correctly and needs better documentation.  -->
+                <!--<TextBlock Text="Outline Thickness:" />
+                <TextBox Text="{Binding Settings.OutlineThickness, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Thickness of the outline around the clock."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />-->
+            </StackPanel>
+        </TabItem>
+
+        <TabItem Header="Behavior">
+            <StackPanel>
+                <CheckBox Content="Show in Taskbar" IsChecked="{Binding Settings.ShowInTaskbar, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Shows the app icon in the taskbar instead of the tray."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <CheckBox Content="Run on Startup" IsChecked="{Binding Settings.RunOnStartup, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Opens the app when you log in."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <CheckBox Content="Start Hidden" IsChecked="{Binding Settings.StartHidden, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Starts the app hidden until the taskbar or tray icon is clicked."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <CheckBox Content="Drag to Move" IsChecked="{Binding Settings.DragToMove, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Allows moving the clock by dragging it with the cursor."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <CheckBox Content="Right Aligned" IsChecked="{Binding Settings.RightAligned, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Experimental: Keeps the clock window aligned to the right when the size changes."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="WAV File Path:" />
+                <Grid>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="*" />
+                        <ColumnDefinition Width="Auto" />
+                    </Grid.ColumnDefinitions>
+
+                    <TextBox Text="{Binding Settings.WavFilePath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="0" />
+                    <Button Content="Browse..."
+                            Click="BrowseWavFilePath"
+                            Grid.Column="1" />
+                </Grid>
+                <TextBlock Text="Path to a WAV file to be played on a specified interval."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+
+                <TextBlock Text="WAV File Interval:" />
+                <TextBox Text="{Binding Settings.WavFileInterval, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
+                <TextBlock Text="Interval for playing the WAV file if one is specified and exists (HH:mm:ss)."
+                           FontStyle="Italic"
+                           FontSize="10"
+                           Margin="0,0,0,12" />
+            </StackPanel>
+        </TabItem>
+    </TabControl>
+</Window>

+ 105 - 0
DesktopClock/SettingsWindow.xaml.cs

@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DesktopClock.Properties;
+using Microsoft.Win32;
+
+namespace DesktopClock;
+
+public partial class SettingsWindow : Window
+{
+    public SettingsWindow()
+    {
+        InitializeComponent();
+        DataContext = new SettingsWindowViewModel(Settings.Default);
+    }
+
+    private SettingsWindowViewModel ViewModel => (SettingsWindowViewModel)DataContext;
+
+    private void SelectFormat(object sender, SelectionChangedEventArgs e)
+    {
+        var value = e.AddedItems[0] as DateFormatExample;
+
+        if (value == null)
+        {
+            return;
+        }
+
+        ViewModel.Settings.Format = value.Format;
+    }
+
+    private void BrowseBackgroundImagePath(object sender, RoutedEventArgs e)
+    {
+        var openFileDialog = new OpenFileDialog
+        {
+            Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg|All files (*.*)|*.*"
+        };
+
+        if (openFileDialog.ShowDialog() != true)
+        {
+            return;
+        }
+
+        ViewModel.Settings.BackgroundImagePath = openFileDialog.FileName;
+    }
+
+    private void BrowseWavFilePath(object sender, RoutedEventArgs e)
+    {
+        var openFileDialog = new OpenFileDialog
+        {
+            Filter = "WAV files (*.wav)|*.wav|All files (*.*)|*.*"
+        };
+
+        if (openFileDialog.ShowDialog() != true)
+        {
+            return;
+        }
+
+        ViewModel.Settings.WavFilePath = openFileDialog.FileName;
+    }
+}
+
+public partial class SettingsWindowViewModel : ObservableObject
+{
+    public Settings Settings { get; }
+
+    public SettingsWindowViewModel(Settings settings)
+    {
+        Settings = settings;
+        FontFamilies = Fonts.SystemFontFamilies.Select(ff => ff.Source).ToList();
+        TimeZones = TimeZoneInfo.GetSystemTimeZones().Select(tz => tz.Id).ToList();
+    }
+
+    /// <summary>
+    /// All available font families reported by the system.
+    /// </summary>
+    public IList<string> FontFamilies { get; }
+
+    /// <summary>
+    /// All available time zones reported by the system.
+    /// </summary>
+    public IList<string> TimeZones { get; }
+
+    /// <summary>
+    /// Sets the format string in settings.
+    /// </summary>
+    [RelayCommand]
+    public void SetFormat(DateFormatExample value)
+    {
+        Settings.Default.Format = value.Format;
+    }
+
+    /// <summary>
+    /// Disables countdown mode by resetting the value to default.
+    /// </summary>
+    [RelayCommand]
+    public void ResetCountdown()
+    {
+        Settings.CountdownTo = default;
+    }
+}

+ 19 - 14
DesktopClock/Tokenizer.cs

@@ -16,24 +16,29 @@ public static class Tokenizer
     /// <param name="formatProvider">The format provider.</param>
     public static string FormatWithTokenizerOrFallBack(IFormattable formattable, string format, IFormatProvider formatProvider)
     {
-        try
+        if (!string.IsNullOrWhiteSpace(format))
         {
-            if (format.Contains("}"))
+
+            try
             {
-                return _tokenizerRegex.Replace(format, (m) =>
+                if (format.Contains("}"))
                 {
-                    var formatString = m.Value.Replace("{", "").Replace("}", "");
-                    return formattable.ToString(formatString, formatProvider);
-                });
-            }
+                    return _tokenizerRegex.Replace(format, (m) =>
+                    {
+                        var formatString = m.Value.Replace("{", "").Replace("}", "");
+                        return formattable.ToString(formatString, formatProvider);
+                    });
+                }
 
-            // Use basic formatter if no special formatting tokens are present.
-            return formattable.ToString(format, formatProvider);
-        }
-        catch
-        {
-            // Fallback to the default format.
-            return formattable.ToString();
+                // Use basic formatter if no special formatting tokens are present.
+                return formattable.ToString(format, formatProvider);
+            }
+            catch
+            {
+            }
         }
+
+        // Fall back to the default format.
+        return formattable.ToString();
     }
 }