diff --git a/DesktopClock.Tests/DateTimeTests.cs b/DesktopClock.Tests/DateTimeTests.cs
index 8600319..d90d244 100644
--- a/DesktopClock.Tests/DateTimeTests.cs
+++ b/DesktopClock.Tests/DateTimeTests.cs
@@ -1,6 +1,4 @@
using System;
-using System.Globalization;
-using System.Linq;
namespace DesktopClock.Tests;
@@ -143,77 +141,4 @@ public void IsOnInterval_CountdownReached_ShouldReturnTrue()
Assert.True(result);
}
- [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/Data/DateFormatExample.cs b/DesktopClock/Data/DateFormatExample.cs
deleted file mode 100644
index 1a9cf0f..0000000
--- a/DesktopClock/Data/DateFormatExample.cs
+++ /dev/null
@@ -1,79 +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"
-
- // 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();
-}
diff --git a/DesktopClock/Properties/Settings.cs b/DesktopClock/Properties/Settings.cs
index 6cbde02..8361e39 100644
--- a/DesktopClock/Properties/Settings.cs
+++ b/DesktopClock/Properties/Settings.cs
@@ -348,25 +348,25 @@ private Settings()
/// Persisted width of the settings window.
///
///
- /// This remembers how wide you last made the settings window so it feels familiar the next time you open it.
+ /// This helps the settings experience reopen at a comfortable size instead of snapping back to a fixed default.
///
- public double SettingsWindowWidth { get; set; } = 720;
+ public double SettingsWindowWidth { get; set; } = 1120;
///
/// Persisted height of the settings window.
///
///
- /// This remembers how tall you last made the settings window so you do not have to resize it every time.
+ /// This helps the settings experience reopen at a comfortable size instead of snapping back to a fixed default.
///
- public double SettingsWindowHeight { get; set; } = 600;
+ public double SettingsWindowHeight { get; set; } = 860;
///
- /// Persisted vertical scroll offset of the settings window.
+ /// Persisted vertical scroll position of the settings window.
///
///
- /// This helps reopen the settings window near the same section you were working in before.
+ /// This lets the scrollable settings experience reopen near the last section the user was editing.
///
- public double SettingsScrollPosition { get; set; } = 0;
+ public double SettingsScrollPosition { get; set; }
///
/// Bit flags describing which one-time teaching tips have already been shown.
diff --git a/DesktopClock/SettingsWindow.xaml b/DesktopClock/SettingsWindow.xaml
index 09b9ae9..6fbc06c 100644
--- a/DesktopClock/SettingsWindow.xaml
+++ b/DesktopClock/SettingsWindow.xaml
@@ -1,678 +1,1308 @@
-
+ Background="Transparent"
+ UseLayoutRounding="True"
+ SnapsToDevicePixels="True">
+
+
+
+
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Learn more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Learn more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Right-click:
- Open the menu
-
-
- Double-click:
- Copy the displayed text
-
-
- Drag:
- Move the clock
-
-
- Ctrl + Scroll:
- Change the size
-
-
- Ctrl + +:
- Increase the size
-
-
- Ctrl + -:
- Decrease the size
-
-
-
-
-
-
-
-
-
-
- Good Diary
-
-
-
-
-
- Network Monitor
-
-
-
-
-
- Sentry Replay
-
-
-
-
-
- Radial Actions
-
-
-
-
-
- All my projects on GitHub
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- CommunityToolkit
-
-
-
-
- Costura.Fody
-
-
-
-
- H.NotifyIcon.Wpf
-
-
-
-
- Humanizer
-
-
-
-
- Newtonsoft.Json
-
-
-
-
- PropertyChanged.Fody
-
-
-
-
- WpfWindowPlacement
-
-
-
-
- IconKitchen
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
diff --git a/DesktopClock/SettingsWindow.xaml.cs b/DesktopClock/SettingsWindow.xaml.cs
index 5a9e6ee..23edda7 100644
--- a/DesktopClock/SettingsWindow.xaml.cs
+++ b/DesktopClock/SettingsWindow.xaml.cs
@@ -1,341 +1,589 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
-using System.Drawing.Text;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Navigation;
-using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
using DesktopClock.Properties;
using Microsoft.Win32;
namespace DesktopClock;
+[ObservableObject]
public partial class SettingsWindow : Window
{
- private bool _restoringScrollPosition = true;
+ private static readonly string[] _fontStyles = ["Normal", "Italic", "Oblique"];
+ private static readonly string[] _fontWeights = ["Thin", "Light", "Normal", "Medium", "SemiBold", "Bold", "Black"];
+ private static readonly string[] _intervalFormats =
+ [
+ @"m\:ss",
+ @"mm\:ss",
+ @"h\:mm",
+ @"hh\:mm",
+ @"h\:mm\:ss",
+ @"hh\:mm\:ss",
+ ];
+
+ private readonly SystemClockTimer _previewTimer;
+ private readonly PropertyChangedEventHandler _settingsPropertyChanged;
+
+ [ObservableProperty]
+ private string _previewCaption = string.Empty;
+
+ [ObservableProperty]
+ private string _previewSupportText = string.Empty;
+
+ [ObservableProperty]
+ private string _previewText = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasCountdownValidationMessage))]
+ private string _countdownValidationMessage = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasSoundIntervalValidationMessage))]
+ private string _soundIntervalValidationMessage = string.Empty;
public SettingsWindow()
{
InitializeComponent();
- DataContext = new SettingsWindowViewModel(Settings.Default);
- Closing += SettingsWindow_Closing;
+
+ DataContext = this;
+
+ FontFamilies = Fonts.SystemFontFamilies
+ .Select(fontFamily => fontFamily.Source)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(name => name)
+ .ToArray();
+
+ FontStyles = _fontStyles;
+ FontWeights = _fontWeights;
+
+ StretchModes =
+ [
+ new StretchOption("Fill the panel", Stretch.Fill),
+ new StretchOption("Keep aspect ratio", Stretch.Uniform),
+ new StretchOption("Fill and crop", Stretch.UniformToFill),
+ new StretchOption("Original size", Stretch.None),
+ ];
+
+ TimeZones = TimeZoneInfo.GetSystemTimeZones()
+ .Select(timeZone => new TimeZoneOption(timeZone.Id, timeZone.DisplayName))
+ .ToArray();
+
+ _settingsPropertyChanged = (_, _) => RefreshDerivedState();
+ Settings.PropertyChanged += _settingsPropertyChanged;
+
+ _previewTimer = new SystemClockTimer();
+ _previewTimer.SecondChanged += PreviewTimer_SecondChanged;
+ _previewTimer.Start();
+ RefreshDerivedState();
}
- private SettingsWindowViewModel ViewModel => (SettingsWindowViewModel)DataContext;
+ public Settings Settings => Settings.Default;
+
+ public IReadOnlyList FontFamilies { get; }
+
+ public IReadOnlyList FontStyles { get; }
- private void SelectFormat(object sender, SelectionChangedEventArgs e)
+ public IReadOnlyList FontWeights { get; }
+
+ public IReadOnlyList StretchModes { get; }
+
+ public IReadOnlyList TimeZones { get; }
+
+ public bool CountdownEnabled
{
- if (e.AddedItems.Count == 0)
+ get => Settings.CountdownTo != default;
+ set
{
- return;
- }
+ if (value == CountdownEnabled)
+ return;
- var value = e.AddedItems[0] as DateFormatExample;
+ Settings.CountdownTo = value ? CreateDefaultCountdownTarget() : default;
+ CountdownValidationMessage = string.Empty;
+ RefreshDerivedState();
+ }
+ }
- if (value == null)
+ public DateTime? CountdownDate
+ {
+ get => CountdownEnabled ? Settings.CountdownTo.Date : null;
+ set
{
- return;
- }
+ if (!value.HasValue)
+ return;
- ViewModel.Settings.Format = value.Format;
+ var timeOfDay = CountdownEnabled ? Settings.CountdownTo.TimeOfDay : CreateDefaultCountdownTarget().TimeOfDay;
+ Settings.CountdownTo = value.Value.Date + timeOfDay;
+ CountdownValidationMessage = string.Empty;
+ RefreshDerivedState();
+ }
}
- private void BrowseBackgroundImagePath(object sender, RoutedEventArgs e)
+ public string CountdownTimeText
{
- var openFileDialog = new OpenFileDialog
+ get => CountdownEnabled ? Settings.CountdownTo.ToString("HH:mm", CultureInfo.InvariantCulture) : string.Empty;
+ set
{
- Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg|All files (*.*)|*.*"
- };
+ if (!CountdownEnabled)
+ return;
+
+ if (!TryParseTimeOfDay(value, out var timeOfDay))
+ {
+ CountdownValidationMessage = "Enter the countdown time as HH:mm, for example 09:30 or 18:45.";
+ OnPropertyChanged(nameof(CountdownTimeText));
+ return;
+ }
+
+ Settings.CountdownTo = Settings.CountdownTo.Date + timeOfDay;
+ CountdownValidationMessage = string.Empty;
+ RefreshDerivedState();
+ }
+ }
- if (openFileDialog.ShowDialog() != true)
+ public bool HasCountdownValidationMessage => !string.IsNullOrWhiteSpace(CountdownValidationMessage);
+
+ public string SelectedTimeZoneId
+ {
+ get => string.IsNullOrWhiteSpace(Settings.TimeZone) ? TimeZoneInfo.Local.Id : Settings.TimeZone;
+ set
{
- return;
+ if (string.Equals(value, SelectedTimeZoneId, StringComparison.Ordinal))
+ return;
+
+ Settings.TimeZone = value ?? string.Empty;
+ RefreshDerivedState();
}
+ }
- ViewModel.Settings.BackgroundImagePath = openFileDialog.FileName;
+ public string TextColorHex
+ {
+ get => ToHex(Settings.TextColor);
+ set
+ {
+ if (!TryParseColor(value, out var color))
+ {
+ OnPropertyChanged(nameof(TextColorHex));
+ return;
+ }
+
+ Settings.TextColor = color;
+ RefreshDerivedState();
+ }
}
- private void BrowseWavFilePath(object sender, RoutedEventArgs e)
+ public string OuterColorHex
{
- var openFileDialog = new OpenFileDialog
+ get => ToHex(Settings.OuterColor);
+ set
{
- Filter = "WAV files (*.wav)|*.wav|All files (*.*)|*.*"
- };
+ if (!TryParseColor(value, out var color))
+ {
+ OnPropertyChanged(nameof(OuterColorHex));
+ return;
+ }
+
+ Settings.OuterColor = color;
+ RefreshDerivedState();
+ }
+ }
- if (openFileDialog.ShowDialog() != true)
+ public string SoundIntervalText
+ {
+ get => Settings.WavFileInterval == default
+ ? string.Empty
+ : Settings.WavFileInterval.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture);
+ set
{
- return;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ Settings.WavFileInterval = default;
+ SoundIntervalValidationMessage = string.Empty;
+ RefreshDerivedState();
+ return;
+ }
+
+ if (!TryParseInterval(value, out var interval))
+ {
+ SoundIntervalValidationMessage = "Enter a duration like 00:01:00, 00:15:00, or 01:00:00.";
+ OnPropertyChanged(nameof(SoundIntervalText));
+ return;
+ }
+
+ Settings.WavFileInterval = interval;
+ SoundIntervalValidationMessage = string.Empty;
+ RefreshDerivedState();
}
+ }
- ViewModel.Settings.WavFilePath = openFileDialog.FileName;
+ public bool HasSoundIntervalValidationMessage => !string.IsNullOrWhiteSpace(SoundIntervalValidationMessage);
+
+ public bool UsesBackgroundImage => !string.IsNullOrWhiteSpace(Settings.BackgroundImagePath);
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ RefreshDerivedState();
}
- private void PickTextColor(object sender, RoutedEventArgs e)
+ private void SettingsScrollViewer_Loaded(object sender, RoutedEventArgs e)
{
- PickColor(color => ViewModel.Settings.TextColor = color, ViewModel.Settings.TextColor);
+ TabContentScrollViewer.ScrollToVerticalOffset(Settings.SettingsScrollPosition);
}
- private void PickOuterColor(object sender, RoutedEventArgs e)
+ private void SettingsScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
- PickColor(color => ViewModel.Settings.OuterColor = color, ViewModel.Settings.OuterColor);
+ if (!IsLoaded)
+ return;
+
+ Settings.SettingsScrollPosition = e.VerticalOffset;
}
- private void PickColor(Action applyColor, Color currentColor)
+ private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
- using var colorDialog = new System.Windows.Forms.ColorDialog
- {
- AllowFullOpen = true,
- FullOpen = true,
- Color = System.Drawing.Color.FromArgb(currentColor.A, currentColor.R, currentColor.G, currentColor.B)
- };
+ if (e.ChangedButton != MouseButton.Left)
+ return;
- if (colorDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
+ if (e.ClickCount == 2)
{
+ ToggleMaximizeRestore();
return;
}
- applyColor(Color.FromArgb(
- colorDialog.Color.A,
- colorDialog.Color.R,
- colorDialog.Color.G,
- colorDialog.Color.B));
+ DragMove();
}
- private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
+ private void MinimizeButton_Click(object sender, RoutedEventArgs e)
{
- Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
- e.Handled = true;
+ WindowState = WindowState.Minimized;
}
- private void OpenSettingsFile(object sender, RoutedEventArgs e)
+ private void MaximizeRestoreButton_Click(object sender, RoutedEventArgs e)
{
- // Teach user how it works.
- if (!Settings.Default.TipsShown.HasFlag(TeachingTips.AdvancedSettings))
- {
- MessageBox.Show(this,
- "Settings are stored in JSON and will open in Notepad. Save the file for changes to take effect. To start fresh, delete your '.settings' file.",
- Title, MessageBoxButton.OK, MessageBoxImage.Information);
+ ToggleMaximizeRestore();
+ }
- Settings.Default.TipsShown |= TeachingTips.AdvancedSettings;
- }
+ private void CloseButton_Click(object sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ protected override void OnClosed(EventArgs e)
+ {
+ _previewTimer.Stop();
+ _previewTimer.SecondChanged -= PreviewTimer_SecondChanged;
+ _previewTimer.Dispose();
+ Settings.PropertyChanged -= _settingsPropertyChanged;
+ Settings.Save();
+ base.OnClosed(e);
+ }
- // Save first if we can so it's up-to-date.
- if (Settings.CanBeSaved)
- Settings.Default.Save();
+ private void PreviewTimer_SecondChanged(object sender, EventArgs e)
+ {
+ Dispatcher.Invoke(UpdatePreview);
+ }
- // 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)
+ private void BrowseBackgroundImage_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
{
- MessageBox.Show(this,
- "Settings file doesn't exist and couldn't be created.",
- Title, MessageBoxButton.OK, MessageBoxImage.Error);
+ CheckFileExists = true,
+ Filter = "Image files|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.webp|All files|*.*",
+ Title = "Choose a background image",
+ };
+
+ if (dialog.ShowDialog(this) != true)
return;
- }
- // Open settings file in notepad.
- try
- {
- Process.Start("notepad", Settings.FilePath);
- }
- catch (Exception ex)
- {
- // Lazy scammers on the Microsoft Store may reupload without realizing it gets sandboxed, making it unable to start the Notepad process (#1, #12).
- MessageBox.Show(this,
- "Couldn't open settings file in Notepad.\n\n" +
- "This app may have been stolen. If you paid for it, ask for a refund and download it for free from 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);
- }
+ Settings.BackgroundImagePath = dialog.FileName;
+ RefreshDerivedState();
}
- private void OpenSettingsFolder(object sender, RoutedEventArgs e)
+ private void ClearBackgroundImage_Click(object sender, RoutedEventArgs e)
{
- OpenSettingsPath(Settings.FilePath);
+ Settings.BackgroundImagePath = string.Empty;
+ RefreshDerivedState();
}
- private void CreateNewClock(object sender, RoutedEventArgs e)
+ private void BrowseSoundFile_Click(object sender, RoutedEventArgs e)
{
- var result = MessageBox.Show(this,
- "This will copy the executable and start it with new settings.\n\n" +
- "Continue?",
- Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
+ var dialog = new OpenFileDialog
+ {
+ CheckFileExists = true,
+ Filter = "Wave files|*.wav|All files|*.*",
+ Title = "Choose a WAV file",
+ };
- if (result != MessageBoxResult.OK)
+ if (dialog.ShowDialog(this) != true)
return;
- var newExePath = Path.Combine(App.MainFileInfo.DirectoryName, App.MainFileInfo.GetFileAtNextIndex().Name);
-
- // Copy and start the new clock.
- File.Copy(App.MainFileInfo.FullName, newExePath);
- Process.Start(newExePath);
+ Settings.WavFilePath = dialog.FileName;
+ RefreshDerivedState();
}
- private void CheckForUpdates(object sender, RoutedEventArgs e)
+ private void ClearSoundFile_Click(object sender, RoutedEventArgs e)
{
- OpenUrl("https://github.com/danielchalmers/DesktopClock/releases");
+ Settings.WavFilePath = string.Empty;
+ RefreshDerivedState();
}
- private void SettingsScrollViewer_Loaded(object sender, RoutedEventArgs e)
+ private void OpenSettingsFolder_Click(object sender, RoutedEventArgs e)
{
- if (!_restoringScrollPosition)
+ var folderPath = Path.GetDirectoryName(Settings.FilePath);
+ if (string.IsNullOrWhiteSpace(folderPath))
+ return;
+
+ if (File.Exists(Settings.FilePath))
{
+ OpenShellTarget("explorer.exe", $"/select,\"{Settings.FilePath}\"");
return;
}
- Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(() =>
- {
- SettingsScrollViewer.ScrollToVerticalOffset(ViewModel.Settings.SettingsScrollPosition);
- ViewModel.Settings.SettingsScrollPosition = SettingsScrollViewer.VerticalOffset;
- _restoringScrollPosition = false;
- }));
+ OpenShellTarget(folderPath);
}
- private void SettingsScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
+ private void OpenDateFormatDocs_Click(object sender, RoutedEventArgs e)
{
- if (_restoringScrollPosition)
- {
- return;
- }
+ OpenShellTarget("https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings");
+ }
- ViewModel.Settings.SettingsScrollPosition = e.VerticalOffset;
+ private void OpenDurationFormatDocs_Click(object sender, RoutedEventArgs e)
+ {
+ OpenShellTarget("https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-timespan-format-strings");
}
- private void SettingsWindow_Closing(object sender, CancelEventArgs e)
+ private void ClockFormatPreset_Click(object sender, RoutedEventArgs e)
{
- ViewModel.Settings.SettingsScrollPosition = SettingsScrollViewer.VerticalOffset;
+ Settings.Format = ((FrameworkElement)sender).Tag?.ToString() ?? Settings.Format;
+ RefreshDerivedState();
}
- private static void OpenUrl(string url)
+ private void CountdownFormatPreset_Click(object sender, RoutedEventArgs e)
{
- Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ Settings.CountdownFormat = ((FrameworkElement)sender).Tag?.ToString() ?? string.Empty;
+ RefreshDerivedState();
}
- private static void OpenSettingsPath(string filePath)
+ private void SoundIntervalPreset_Click(object sender, RoutedEventArgs e)
{
- var folderPath = Path.GetDirectoryName(filePath);
+ SoundIntervalText = ((FrameworkElement)sender).Tag?.ToString() ?? string.Empty;
+ }
- if (string.IsNullOrWhiteSpace(folderPath))
+ private void TextColorSwatch_Click(object sender, RoutedEventArgs e)
+ {
+ TextColorHex = ((FrameworkElement)sender).Tag?.ToString() ?? TextColorHex;
+ }
+
+ private void OuterColorSwatch_Click(object sender, RoutedEventArgs e)
+ {
+ OuterColorHex = ((FrameworkElement)sender).Tag?.ToString() ?? OuterColorHex;
+ }
+
+ private void ThemePreset_Click(object sender, RoutedEventArgs e)
+ {
+ switch (((FrameworkElement)sender).Tag?.ToString())
{
- return;
+ case "Studio":
+ Settings.BackgroundEnabled = true;
+ Settings.TextColor = ParseColor("#152238");
+ Settings.OuterColor = ParseColor("#FFFFFF");
+ Settings.TextOpacity = 1;
+ Settings.BackgroundOpacity = 0.92;
+ Settings.BackgroundCornerRadius = 20;
+ Settings.OutlineThickness = 0.18;
+ break;
+
+ case "Contrast":
+ Settings.BackgroundEnabled = false;
+ Settings.TextColor = ParseColor("#FFFFFF");
+ Settings.OuterColor = ParseColor("#111827");
+ Settings.TextOpacity = 1;
+ Settings.BackgroundOpacity = 0.9;
+ Settings.BackgroundCornerRadius = 12;
+ Settings.OutlineThickness = 0.38;
+ break;
+
+ case "Night":
+ Settings.BackgroundEnabled = true;
+ Settings.TextColor = ParseColor("#EAF2FF");
+ Settings.OuterColor = ParseColor("#162033");
+ Settings.TextOpacity = 1;
+ Settings.BackgroundOpacity = 0.88;
+ Settings.BackgroundCornerRadius = 22;
+ Settings.OutlineThickness = 0.22;
+ break;
+
+ case "Warm":
+ Settings.BackgroundEnabled = true;
+ Settings.TextColor = ParseColor("#5C2C06");
+ Settings.OuterColor = ParseColor("#FFF1E7");
+ Settings.TextOpacity = 0.98;
+ Settings.BackgroundOpacity = 0.94;
+ Settings.BackgroundCornerRadius = 24;
+ Settings.OutlineThickness = 0.18;
+ break;
}
- Process.Start(new ProcessStartInfo("explorer.exe", folderPath) { UseShellExecute = true });
+ RefreshDerivedState();
}
-}
-public partial class SettingsWindowViewModel : ObservableObject
-{
- public Settings Settings { get; }
-
- public SettingsWindowViewModel(Settings settings)
+ private void RefreshDerivedState()
{
- Settings = settings;
- FontFamilies = GetAllSystemFonts().Distinct().OrderBy(f => f).ToList();
- FontStyles = ["Normal", "Italic", "Oblique"];
- FontWeights = ["Thin", "ExtraLight", "Light", "Normal", "Medium", "SemiBold", "Bold", "ExtraBold", "Black", "ExtraBlack"];
- ImageStretches = Enum.GetValues(typeof(Stretch)).Cast().ToArray();
- TimeZones = TimeZoneInfo.GetSystemTimeZones();
+ OnPropertyChanged(nameof(CountdownEnabled));
+ OnPropertyChanged(nameof(CountdownDate));
+ OnPropertyChanged(nameof(CountdownTimeText));
+ OnPropertyChanged(nameof(CountdownValidationMessage));
+ OnPropertyChanged(nameof(HasCountdownValidationMessage));
+ OnPropertyChanged(nameof(SelectedTimeZoneId));
+ OnPropertyChanged(nameof(TextColorHex));
+ OnPropertyChanged(nameof(OuterColorHex));
+ OnPropertyChanged(nameof(SoundIntervalText));
+ OnPropertyChanged(nameof(SoundIntervalValidationMessage));
+ OnPropertyChanged(nameof(HasSoundIntervalValidationMessage));
+ OnPropertyChanged(nameof(UsesBackgroundImage));
+ UpdatePreview();
}
- ///
- /// All available font families reported by the system.
- ///
- public IList FontFamilies { get; }
-
- ///
- /// All available font styles.
- ///
- public IList FontStyles { get; }
+ private void UpdatePreview()
+ {
+ var now = DateTimeOffset.Now;
+ var localNow = DateTime.Now;
+
+ PreviewText = TimeStringFormatter.Format(
+ now,
+ localNow,
+ Settings.TimeZoneInfo,
+ Settings.CountdownTo,
+ Settings.Format,
+ Settings.CountdownFormat,
+ CultureInfo.CurrentCulture);
+
+ if (CountdownEnabled)
+ {
+ PreviewCaption = $"Counting down to {Settings.CountdownTo:ddd, MMM d yyyy h:mm tt}";
+ PreviewSupportText = string.IsNullOrWhiteSpace(Settings.CountdownFormat)
+ ? "Countdown preview uses natural language until you add a custom duration format."
+ : "Countdown preview uses your custom duration tokens.";
+ return;
+ }
- ///
- /// All available font weights.
- ///
- public IList FontWeights { get; }
+ PreviewCaption = $"Showing time in {Settings.TimeZoneInfo.DisplayName}";
+ PreviewSupportText = Settings.BackgroundEnabled
+ ? "Background, image, and color changes are reflected live in the preview."
+ : "Outline mode is active, so the secondary color is applied to the text stroke.";
+ }
- ///
- /// All available stretch options for background images.
- ///
- public IList ImageStretches { get; }
+ private void ToggleMaximizeRestore()
+ {
+ WindowState = WindowState == WindowState.Maximized
+ ? WindowState.Normal
+ : WindowState.Maximized;
+ }
- ///
- /// All available time zones reported by the system.
- ///
- public IList TimeZones { get; }
+ private void OpenShellTarget(string target, string arguments = null)
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = target,
+ Arguments = arguments ?? string.Empty,
+ UseShellExecute = true,
+ });
+ }
- ///
- /// Sets the format string in settings.
- ///
- [RelayCommand]
- public void SetFormat(DateFormatExample value)
+ private static DateTime CreateDefaultCountdownTarget()
{
- Settings.Default.Format = value.Format;
+ var now = DateTime.Now.AddHours(1);
+ return new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0);
}
- ///
- /// Disables countdown mode by resetting the date to default.
- ///
- [RelayCommand]
- public void ResetCountdown()
+ private static bool TryParseTimeOfDay(string value, out TimeSpan timeOfDay)
{
- Settings.CountdownTo = default;
+ return TimeSpan.TryParseExact(
+ value?.Trim(),
+ [@"h\:mm", @"hh\:mm"],
+ CultureInfo.InvariantCulture,
+ out timeOfDay);
}
- ///
- /// Resets the countdown format to the default (dynamic) format.
- ///
- [RelayCommand]
- public void ResetCountdownFormat()
+ private static bool TryParseInterval(string value, out TimeSpan interval)
{
- Settings.CountdownFormat = string.Empty;
+ return TimeSpan.TryParseExact(
+ value?.Trim(),
+ _intervalFormats,
+ CultureInfo.InvariantCulture,
+ out interval) &&
+ interval > TimeSpan.Zero;
}
- ///
- /// Clears the chime sound file path.
- ///
- [RelayCommand]
- public void ResetWavFilePath()
+ private static bool TryParseColor(string value, out Color color)
{
- Settings.WavFilePath = string.Empty;
+ try
+ {
+ var normalized = value?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ color = default;
+ return false;
+ }
+
+ if (!normalized.StartsWith("#", StringComparison.Ordinal))
+ normalized = "#" + normalized;
+
+ var converted = ColorConverter.ConvertFromString(normalized);
+ if (converted is Color parsedColor)
+ {
+ color = Color.FromRgb(parsedColor.R, parsedColor.G, parsedColor.B);
+ return true;
+ }
+ }
+ catch
+ {
+ }
+
+ color = default;
+ return false;
}
- ///
- /// Resets the chime interval to the default value.
- ///
- [RelayCommand]
- public void ResetWavFileInterval()
+ private static Color ParseColor(string value)
{
- Settings.WavFileInterval = default;
+ return TryParseColor(value, out var color) ? color : Colors.White;
}
- ///
- /// Clears the background image path.
- ///
- [RelayCommand]
- public void ResetBackgroundImagePath()
+ private static string ToHex(Color color)
{
- Settings.BackgroundImagePath = string.Empty;
+ return $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
- private IEnumerable GetAllSystemFonts()
+}
+
+public record StretchOption
+{
+ public StretchOption(string label, Stretch value)
{
- // Get fonts from WPF.
- foreach (var fontFamily in Fonts.SystemFontFamilies)
- {
- yield return fontFamily.Source;
- }
+ Label = label;
+ Value = value;
+ }
- // Get fonts from System.Drawing.
- using var installedFontCollection = new InstalledFontCollection();
- foreach (var fontFamily in installedFontCollection.Families)
- {
- yield return fontFamily.Name;
- }
+ public string Label { get; }
+
+ public Stretch Value { get; }
+}
+
+public record TimeZoneOption
+{
+ public TimeZoneOption(string id, string displayName)
+ {
+ Id = id;
+ DisplayName = displayName;
}
+
+ public string Id { get; }
+
+ public string DisplayName { get; }
}