diff --git a/DesktopClock/MainWindow.xaml b/DesktopClock/MainWindow.xaml index 9141f31..7c0cb48 100644 --- a/DesktopClock/MainWindow.xaml +++ b/DesktopClock/MainWindow.xaml @@ -1,202 +1,235 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DesktopClock/MainWindow.xaml.cs b/DesktopClock/MainWindow.xaml.cs index 5cf9ac5..fa13cd4 100644 --- a/DesktopClock/MainWindow.xaml.cs +++ b/DesktopClock/MainWindow.xaml.cs @@ -1,477 +1,543 @@ -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Media; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DesktopClock.Properties; -using H.NotifyIcon; -using H.NotifyIcon.EfficiencyMode; -using Humanizer; -using WpfWindowPlacement; - -namespace DesktopClock; - -/// -/// Interaction logic for MainWindow.xaml -/// -[ObservableObject] -public partial class MainWindow : Window -{ - private readonly SystemClockTimer _systemClockTimer; - private TaskbarIcon _trayIcon; - private TimeZoneInfo _timeZone; - private SoundPlayer _soundPlayer; - - /// - /// The date and time to countdown to, or null if regular clock is desired. - /// - [ObservableProperty] - private DateTimeOffset? _countdownTo; - - /// - /// The current date and time in the selected time zone or countdown as a formatted string. - /// - [ObservableProperty] - private string _currentTimeOrCountdownString; - - public static readonly double MaxSizeLog = 6.5; - public static readonly double MinSizeLog = 2.7; - - public MainWindow() - { - InitializeComponent(); - DataContext = this; - - _timeZone = App.GetTimeZone(); - UpdateCountdownEnabled(); - - Settings.Default.PropertyChanged += (s, e) => Dispatcher.Invoke(() => Settings_PropertyChanged(s, e)); - - // Not done through binding due to what's explained in the comment in HideForNow(). - ShowInTaskbar = Settings.Default.ShowInTaskbar; - - CurrentTimeOrCountdownString = Settings.Default.LastDisplay; - - _systemClockTimer = new(); - _systemClockTimer.SecondChanged += SystemClockTimer_SecondChanged; - - ContextMenu = Resources["MainContextMenu"] as ContextMenu; - - ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, true); - - UpdateSoundPlayerEnabled(); - } - - /// - /// Copies the current time string to the clipboard. - /// - [RelayCommand] - public void CopyToClipboard() => Clipboard.SetText(CurrentTimeOrCountdownString); - - /// - /// Minimizes the window. - /// - [RelayCommand] - public void HideForNow() - { - 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).", - Title, MessageBoxButton.OK, MessageBoxImage.Information); - - Settings.Default.TipsShown |= TeachingTips.HideForNow; - } - - // https://stackoverflow.com/a/28239057. - ShowInTaskbar = true; - WindowState = WindowState.Minimized; - ShowInTaskbar = Settings.Default.ShowInTaskbar; - } - - /// - /// Sets app's theme to given value. - /// - [RelayCommand] - public void SetTheme(Theme theme) => Settings.Default.Theme = theme; - - /// - /// Sets format string in settings to given string. - /// - [RelayCommand] - public void SetFormat(string format) => Settings.Default.Format = format; - - /// - /// Explains how to write a format, then asks user if they want to view a website and Advanced settings to do so. - /// - [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(); - } - - /// - /// Sets time zone ID in settings to given time zone ID. - /// - [RelayCommand] - public void SetTimeZone(TimeZoneInfo tzi) => App.SetTimeZone(tzi); - - /// - /// Creates a new clock executable and starts it. - /// - [RelayCommand] - public void NewClock() - { - if (!Settings.Default.TipsShown.HasFlag(TeachingTips.NewClock)) - { - 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); - - if (result != MessageBoxResult.OK) - return; - - Settings.Default.TipsShown |= TeachingTips.NewClock; - } - - 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); - } - - /// - /// Explains how to enable countdown mode, then asks user if they want to view Advanced settings to do so. - /// - [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(); - } - - /// - /// Opens the settings file in Notepad. - /// - [RelayCommand] - public void OpenSettings() - { - // Teach 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 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.\n\n" + - "This app may have be 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); - } - } - - /// - /// Opens the GitHub Releases page. - /// - [RelayCommand] - public void CheckForUpdates() - { - 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" + - "Continue?", - Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK); - - if (result != MessageBoxResult.OK) - return; - - Settings.Default.TipsShown |= TeachingTips.CheckForUpdates; - } - - Process.Start("https://github.com/danielchalmers/DesktopClock/releases"); - } - - /// - /// Exits the program. - /// - [RelayCommand] - public void Exit() - { - Close(); - } - - private void ConfigureTrayIcon(bool showIcon, bool firstLaunch) - { - if (showIcon) - { - if (_trayIcon == null) - { - _trayIcon = Resources["TrayIcon"] as TaskbarIcon; - _trayIcon.ContextMenu = Resources["MainContextMenu"] as ContextMenu; - _trayIcon.ContextMenu.DataContext = this; - _trayIcon.ForceCreate(enablesEfficiencyMode: false); - _trayIcon.TrayLeftMouseDoubleClick += (_, _) => - { - WindowState = WindowState.Normal; - Activate(); - }; - } - - if (!firstLaunch) - _trayIcon.ShowNotification("Hidden from taskbar", "Icon was moved to the tray"); - } - else - { - _trayIcon?.Dispose(); - _trayIcon = null; - } - } - - private void Settings_PropertyChanged(object sender, PropertyChangedEventArgs e) - { - switch (e.PropertyName) - { - case nameof(Settings.Default.TimeZone): - _timeZone = App.GetTimeZone(); - UpdateTimeString(); - break; - - case nameof(Settings.Default.Format): - case nameof(Settings.Default.CountdownFormat): - UpdateTimeString(); - break; - - case nameof(Settings.Default.ShowInTaskbar): - ShowInTaskbar = Settings.Default.ShowInTaskbar; - ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, false); - break; - - case nameof(Settings.Default.CountdownTo): - UpdateCountdownEnabled(); - UpdateTimeString(); - break; - - case nameof(Settings.Default.WavFilePath): - case nameof(Settings.Default.WavFileInterval): - UpdateSoundPlayerEnabled(); - break; - } - } - - private void SystemClockTimer_SecondChanged(object sender, EventArgs e) - { - UpdateTimeString(); - - TryPlaySound(); - } - - private void UpdateCountdownEnabled() - { - if (Settings.Default.CountdownTo == null || Settings.Default.CountdownTo == default(DateTime)) - { - CountdownTo = null; - return; - } - - CountdownTo = Settings.Default.CountdownTo.Value.ToDateTimeOffset(_timeZone.BaseUtcOffset); - } - - private void UpdateSoundPlayerEnabled() - { - var soundPlayerEnabled = - !string.IsNullOrWhiteSpace(Settings.Default.WavFilePath) && - Settings.Default.WavFileInterval != default && - File.Exists(Settings.Default.WavFilePath); - - _soundPlayer = soundPlayerEnabled ? new() : null; - } - - private void TryPlaySound() - { - if (_soundPlayer == null) - return; - - var isOnInterval = CountdownTo == null ? - (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0 : - (int)(CountdownTo.Value - DateTimeOffset.Now).TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0; - - if (!isOnInterval) - return; - - try - { - _soundPlayer.SoundLocation = Settings.Default.WavFilePath; - _soundPlayer.Play(); - } - catch - { - // Ignore errors. - } - } - - private void UpdateTimeString() - { - string GetTimeString() - { - var timeInSelectedZone = TimeZoneInfo.ConvertTime(DateTimeOffset.Now, _timeZone); - - if (CountdownTo == null) - { - return Tokenizer.FormatWithTokenizerOrFallBack(timeInSelectedZone, Settings.Default.Format, CultureInfo.DefaultThreadCurrentCulture); - } - else - { - if (string.IsNullOrWhiteSpace(Settings.Default.CountdownFormat)) - return CountdownTo.Humanize(timeInSelectedZone); - - return Tokenizer.FormatWithTokenizerOrFallBack(Settings.Default.CountdownTo - timeInSelectedZone, Settings.Default.CountdownFormat, CultureInfo.DefaultThreadCurrentCulture); - } - } - - CurrentTimeOrCountdownString = GetTimeString(); - } - - private void Window_MouseDown(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left && Settings.Default.DragToMove) - { - _systemClockTimer.Stop(); - DragMove(); - UpdateTimeString(); - _systemClockTimer.Start(); - } - } - - private void Window_MouseDoubleClick(object sender, MouseButtonEventArgs e) - { - CopyToClipboard(); - } - - private void Window_MouseWheel(object sender, MouseWheelEventArgs e) - { - if (Keyboard.Modifiers == ModifierKeys.Control) - { - // Amount of scroll that occurred and whether it was positive or negative. - var steps = e.Delta / (double)Mouse.MouseWheelDeltaForOneLine; - - // Convert the height, adjust it, then convert back in the same way as the slider. - var newHeightLog = Math.Log(Settings.Default.Height) + (steps * 0.15); - var newHeightLogClamped = Math.Min(Math.Max(newHeightLog, MinSizeLog), MaxSizeLog); - var exp = Math.Exp(newHeightLogClamped); - - // Apply the new height as an integer to make it easier for the user. - Settings.Default.Height = (int)exp; - } - } - - private void Window_SourceInitialized(object sender, EventArgs e) - { - this.SetPlacement(Settings.Default.Placement); - - UpdateTimeString(); - _systemClockTimer.Start(); - - // Now that everything's been initially rendered and laid out, we can start listening for changes to the size to keep the window right-aligned. - SizeChanged += Window_SizeChanged; - } - - private void Window_ContentRendered(object sender, EventArgs e) - { - if (!Settings.CanBeSaved) - { - MessageBox.Show(this, - "Settings can't be saved because of an access error.\n\n" + - $"Make sure {Title} is in a folder that doesn't require admin privileges, " + - "and that you got it from the original source: https://github.com/danielchalmers/DesktopClock.\n\n" + - "If the problem still persists, feel free to create a new Issue at the above link with as many details as possible.", - Title, MessageBoxButton.OK, MessageBoxImage.Warning); - } - } - - private void Window_Closing(object sender, CancelEventArgs e) - { - Settings.Default.LastDisplay = CurrentTimeOrCountdownString; - Settings.Default.Placement = this.GetPlacement(); - - // Stop the file watcher before saving. - Settings.Default.Dispose(); - - if (Settings.CanBeSaved) - Settings.Default.Save(); - - App.SetRunOnStartup(Settings.Default.RunOnStartup); - } - - private void Window_SizeChanged(object sender, SizeChangedEventArgs e) - { - if (e.WidthChanged && Settings.Default.RightAligned) - { - var widthChange = e.NewSize.Width - e.PreviousSize.Width; - Left -= widthChange; - } - } - - private void Window_StateChanged(object sender, EventArgs e) - { - if (WindowState == WindowState.Minimized) - { - _systemClockTimer.Stop(); - EfficiencyModeUtilities.SetEfficiencyMode(true); - } - else - { - UpdateTimeString(); - _systemClockTimer.Start(); - EfficiencyModeUtilities.SetEfficiencyMode(false); - } - } +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Media; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DesktopClock.Properties; +using H.NotifyIcon; +using H.NotifyIcon.EfficiencyMode; +using Humanizer; +using WpfWindowPlacement; + +namespace DesktopClock; + +/// +/// Interaction logic for MainWindow.xaml +/// +[ObservableObject] +public partial class MainWindow : Window +{ + private readonly SystemClockTimer _systemClockTimer; + private TaskbarIcon _trayIcon; + private TimeZoneInfo _timeZone; + private SoundPlayer _soundPlayer; + + /// + /// The date and time to countdown to, or null if regular clock is desired. + /// + [ObservableProperty] + private DateTimeOffset? _countdownTo; + + /// + /// The current date and time in the selected time zone or countdown as a formatted string. + /// + [ObservableProperty] + private string _currentTimeOrCountdownString; + + [ObservableProperty] + private bool _isMouseDown = false; + + public static readonly double MaxSizeLog = 6.5; + public static readonly double MinSizeLog = 2.7; + + public MainWindow() + { + InitializeComponent(); + DataContext = this; + + _timeZone = App.GetTimeZone(); + UpdateCountdownEnabled(); + + Settings.Default.PropertyChanged += (s, e) => Dispatcher.Invoke(() => Settings_PropertyChanged(s, e)); + if (Settings.Default.Bounce != null) + { + Settings.Default.Bounce.PropertyChanged += (s, e) => Dispatcher.Invoke(() => Bounce_PropertyChanged(s, e)); + } + + // Not done through binding due to what's explained in the comment in HideForNow(). + ShowInTaskbar = Settings.Default.ShowInTaskbar; + + CurrentTimeOrCountdownString = Settings.Default.LastDisplay; + + _systemClockTimer = new(); + _systemClockTimer.SecondChanged += SystemClockTimer_SecondChanged; + + ContextMenu = Resources["MainContextMenu"] as ContextMenu; + + ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, true); + + UpdateSoundPlayerEnabled(); + } + + /// + /// Copies the current time string to the clipboard. + /// + [RelayCommand] + public void CopyToClipboard() => Clipboard.SetText(CurrentTimeOrCountdownString); + + /// + /// Minimizes the window. + /// + [RelayCommand] + public void HideForNow() + { + 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).", + Title, MessageBoxButton.OK, MessageBoxImage.Information); + + Settings.Default.TipsShown |= TeachingTips.HideForNow; + } + + // https://stackoverflow.com/a/28239057. + ShowInTaskbar = true; + WindowState = WindowState.Minimized; + ShowInTaskbar = Settings.Default.ShowInTaskbar; + } + + /// + /// Sets app's theme to given value. + /// + [RelayCommand] + public void SetTheme(Theme theme) => Settings.Default.Theme = theme; + + /// + /// Sets format string in settings to given string. + /// + [RelayCommand] + public void SetFormat(string format) => Settings.Default.Format = format; + + /// + /// Explains how to write a format, then asks user if they want to view a website and Advanced settings to do so. + /// + [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(); + } + + /// + /// Sets time zone ID in settings to given time zone ID. + /// + [RelayCommand] + public void SetTimeZone(TimeZoneInfo tzi) => App.SetTimeZone(tzi); + + /// + /// Creates a new clock executable and starts it. + /// + [RelayCommand] + public void NewClock() + { + if (!Settings.Default.TipsShown.HasFlag(TeachingTips.NewClock)) + { + 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); + + if (result != MessageBoxResult.OK) + return; + + Settings.Default.TipsShown |= TeachingTips.NewClock; + } + + 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); + } + + /// + /// Explains how to enable countdown mode, then asks user if they want to view Advanced settings to do so. + /// + [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(); + } + + /// + /// Opens the settings file in Notepad. + /// + [RelayCommand] + public void OpenSettings() + { + // Teach 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 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.\n\n" + + "This app may have be 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); + } + } + + /// + /// Opens the GitHub Releases page. + /// + [RelayCommand] + public void CheckForUpdates() + { + 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" + + "Continue?", + Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK); + + if (result != MessageBoxResult.OK) + return; + + Settings.Default.TipsShown |= TeachingTips.CheckForUpdates; + } + + Process.Start("https://github.com/danielchalmers/DesktopClock/releases"); + } + + /// + /// Exits the program. + /// + [RelayCommand] + public void Exit() + { + Close(); + } + + private void ConfigureTrayIcon(bool showIcon, bool firstLaunch) + { + if (showIcon) + { + if (_trayIcon == null) + { + _trayIcon = Resources["TrayIcon"] as TaskbarIcon; + _trayIcon.ContextMenu = Resources["MainContextMenu"] as ContextMenu; + _trayIcon.ContextMenu.DataContext = this; + _trayIcon.ForceCreate(enablesEfficiencyMode: false); + _trayIcon.TrayLeftMouseDoubleClick += (_, _) => + { + WindowState = WindowState.Normal; + Activate(); + }; + } + + if (!firstLaunch) + _trayIcon.ShowNotification("Hidden from taskbar", "Icon was moved to the tray"); + } + else + { + _trayIcon?.Dispose(); + _trayIcon = null; + } + } + + private void Settings_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Settings.Default.TimeZone): + _timeZone = App.GetTimeZone(); + UpdateTimeString(); + break; + + case nameof(Settings.Default.Format): + case nameof(Settings.Default.CountdownFormat): + UpdateTimeString(); + break; + + case nameof(Settings.Default.ShowInTaskbar): + ShowInTaskbar = Settings.Default.ShowInTaskbar; + ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, false); + break; + + case nameof(Settings.Default.CountdownTo): + UpdateCountdownEnabled(); + UpdateTimeString(); + break; + + case nameof(Settings.Default.WavFilePath): + case nameof(Settings.Default.WavFileInterval): + UpdateSoundPlayerEnabled(); + break; + } + } + + private void SystemClockTimer_SecondChanged(object sender, EventArgs e) + { + UpdateTimeString(); + + UpdateBounce(); + + TryPlaySound(); + } + + private void UpdateCountdownEnabled() + { + if (Settings.Default.CountdownTo == null || Settings.Default.CountdownTo == default(DateTime)) + { + CountdownTo = null; + return; + } + + CountdownTo = Settings.Default.CountdownTo.Value.ToDateTimeOffset(_timeZone.BaseUtcOffset); + } + + private void UpdateSoundPlayerEnabled() + { + var soundPlayerEnabled = + !string.IsNullOrWhiteSpace(Settings.Default.WavFilePath) && + Settings.Default.WavFileInterval != default && + File.Exists(Settings.Default.WavFilePath); + + _soundPlayer = soundPlayerEnabled ? new() : null; + } + + private void TryPlaySound() + { + if (_soundPlayer == null) + return; + + var isOnInterval = CountdownTo == null ? + (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0 : + (int)(CountdownTo.Value - DateTimeOffset.Now).TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0; + + if (!isOnInterval) + return; + + try + { + _soundPlayer.SoundLocation = Settings.Default.WavFilePath; + _soundPlayer.Play(); + } + catch + { + // Ignore errors. + } + } + + private void UpdateTimeString() + { + string GetTimeString() + { + var timeInSelectedZone = TimeZoneInfo.ConvertTime(DateTimeOffset.Now, _timeZone); + + if (CountdownTo == null) + { + return Tokenizer.FormatWithTokenizerOrFallBack(timeInSelectedZone, Settings.Default.Format, CultureInfo.DefaultThreadCurrentCulture); + } + else + { + if (string.IsNullOrWhiteSpace(Settings.Default.CountdownFormat)) + return CountdownTo.Humanize(timeInSelectedZone); + + return Tokenizer.FormatWithTokenizerOrFallBack(Settings.Default.CountdownTo - timeInSelectedZone, Settings.Default.CountdownFormat, CultureInfo.DefaultThreadCurrentCulture); + } + } + + CurrentTimeOrCountdownString = GetTimeString(); + } + + private void Window_MouseDown(object sender, MouseButtonEventArgs e) + { + this.IsMouseDown = true; + if (e.ChangedButton == MouseButton.Left && Settings.Default.DragToMove) + { + _systemClockTimer.Stop(); + DragMove(); + UpdateTimeString(); + _systemClockTimer.Start(); + } + } + + private void Window_MouseUp(object sender, MouseButtonEventArgs e) + { + this.IsMouseDown = false; + } + + private void Window_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + CopyToClipboard(); + } + + private void Window_MouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers == ModifierKeys.Control) + { + // Amount of scroll that occurred and whether it was positive or negative. + var steps = e.Delta / (double)Mouse.MouseWheelDeltaForOneLine; + + // Convert the height, adjust it, then convert back in the same way as the slider. + var newHeightLog = Math.Log(Settings.Default.Height) + (steps * 0.15); + var newHeightLogClamped = Math.Min(Math.Max(newHeightLog, MinSizeLog), MaxSizeLog); + var exp = Math.Exp(newHeightLogClamped); + + // Apply the new height as an integer to make it easier for the user. + Settings.Default.Height = (int)exp; + } + } + + private void Window_SourceInitialized(object sender, EventArgs e) + { + this.SetPlacement(Settings.Default.Placement); + + UpdateTimeString(); + _systemClockTimer.Start(); + + // Now that everything's been initially rendered and laid out, we can start listening for changes to the size to keep the window right-aligned. + SizeChanged += Window_SizeChanged; + } + + private void Window_ContentRendered(object sender, EventArgs e) + { + if (!Settings.CanBeSaved) + { + MessageBox.Show(this, + "Settings can't be saved because of an access error.\n\n" + + $"Make sure {Title} is in a folder that doesn't require admin privileges, " + + "and that you got it from the original source: https://github.com/danielchalmers/DesktopClock.\n\n" + + "If the problem still persists, feel free to create a new Issue at the above link with as many details as possible.", + Title, MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + private void Window_Closing(object sender, CancelEventArgs e) + { + Settings.Default.LastDisplay = CurrentTimeOrCountdownString; + Settings.Default.Placement = this.GetPlacement(); + + // Stop the file watcher before saving. + Settings.Default.Dispose(); + + if (Settings.CanBeSaved) + Settings.Default.Save(); + + App.SetRunOnStartup(Settings.Default.RunOnStartup); + } + + private void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.WidthChanged && Settings.Default.RightAligned) + { + var widthChange = e.NewSize.Width - e.PreviousSize.Width; + Left -= widthChange; + } + } + + private void Window_StateChanged(object sender, EventArgs e) + { + if (WindowState == WindowState.Minimized) + { + _systemClockTimer.Stop(); + EfficiencyModeUtilities.SetEfficiencyMode(true); + } + else + { + UpdateTimeString(); + _systemClockTimer.Start(); + EfficiencyModeUtilities.SetEfficiencyMode(false); + } + } + + //#region "Bounce - to prevent burn in" + + [ObservableProperty] + private Thickness _bounceMargin = new(0); + + + //Random.Shared is not available in .NET48 + private readonly Random _rnd = new(); + private DateTime _lastBounce; + + private void UpdateBounce() + { + + if ( + (!(Settings.Default.Bounce?.Enabled).GetValueOrDefault()) || + Settings.Default.Bounce.Interval.TotalSeconds < 1 + ) + { + //if Bounce is not enabled or the interval is invalid, effectively disbable the bounce + this.BounceMargin = new Thickness(0); + } + else + { + + if (this._lastBounce.Add(Settings.Default.Bounce.Interval) < DateTime.UtcNow) + { + //- note: it would be more elegant to use a modulus based interval detection + //- but that introduces a whole set of issues around rounding and possibly missing the exact second + + + var h = Math.Max(10.0, Settings.Default.Bounce.HorizontalBounce); + var v = Math.Max(10.0, Settings.Default.Bounce.VerticalBounce); + + var l = this._rnd.NextDouble() * h; + var t = this._rnd.NextDouble() * v; + + this.BounceMargin = new Thickness(l, t, h - l, v - t); + Debug.WriteLine($"Bounce {this.BounceMargin.ToString()}"); + + this._lastBounce= DateTime.UtcNow; + } + } + } + private void Bounce_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + this._lastBounce = new DateTime(0); + UpdateBounce(); + } + + //#endregion } \ No newline at end of file diff --git a/DesktopClock/Properties/BounceSettings.cs b/DesktopClock/Properties/BounceSettings.cs new file mode 100644 index 0000000..90fe003 --- /dev/null +++ b/DesktopClock/Properties/BounceSettings.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace DesktopClock.Properties; +public sealed class BounceSettings : INotifyPropertyChanged +{ + +#pragma warning disable CS0067 // The event 'Settings.PropertyChanged' is never used + public event PropertyChangedEventHandler PropertyChanged; +#pragma warning restore CS0067 // The event 'Settings.PropertyChanged' is never used + + + public bool Enabled { get; set; } = false; + + public double HorizontalBounce { get; set; } = 100.0; + public double VerticalBounce { get; set; } = 50.0; + + + public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(2); + + +} diff --git a/DesktopClock/Properties/Settings.cs b/DesktopClock/Properties/Settings.cs index 12e27c0..a68f497 100644 --- a/DesktopClock/Properties/Settings.cs +++ b/DesktopClock/Properties/Settings.cs @@ -1,190 +1,193 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Windows.Media; -using Newtonsoft.Json; -using WpfWindowPlacement; - -namespace DesktopClock.Properties; - -public sealed class Settings : INotifyPropertyChanged, IDisposable -{ - private readonly FileSystemWatcher _watcher; - - private static readonly Lazy _default = new(LoadAndAttemptSave); - - private static readonly JsonSerializerSettings _jsonSerializerSettings = new() - { - Formatting = Formatting.Indented, - Error = (_, e) => e.ErrorContext.Handled = true, - }; - - private Settings() - { - // Settings file path from same directory as the executable. - var settingsFileName = Path.GetFileNameWithoutExtension(App.MainFileInfo.FullName) + ".settings"; - FilePath = Path.Combine(App.MainFileInfo.DirectoryName, settingsFileName); - - // Watch for changes. - _watcher = new(App.MainFileInfo.DirectoryName, settingsFileName) - { - EnableRaisingEvents = true, - }; - _watcher.Changed += FileChanged; - - // Random default theme before getting overwritten. - Theme = Theme.GetRandomDefaultTheme(); - } - -#pragma warning disable CS0067 // The event 'Settings.PropertyChanged' is never used - public event PropertyChangedEventHandler PropertyChanged; -#pragma warning restore CS0067 // The event 'Settings.PropertyChanged' is never used - - public static Settings Default => _default.Value; - - /// - /// The full path to the settings file. - /// - public static string FilePath { get; private set; } - - /// - /// Can the settings file be saved to? - /// - public static bool CanBeSaved { get; private set; } - - /// - /// Does the settings file exist on the disk? - /// - public static bool Exists => File.Exists(FilePath); - - #region "Properties" - - public string Format { get; set; } = "{ddd}, {MMM dd}, {h:mm:ss tt}"; - public string CountdownFormat { get; set; } = ""; - public DateTime? CountdownTo { get; set; } = default(DateTime); - public string TimeZone { get; set; } = string.Empty; - public string FontFamily { get; set; } = "Consolas"; - public Color TextColor { get; set; } - public Color OuterColor { get; set; } - public bool BackgroundEnabled { get; set; } = true; - public double BackgroundOpacity { get; set; } = 0.90; - public double BackgroundCornerRadius { get; set; } = 1; - public string BackgroundImagePath { get; set; } = string.Empty; - public double OutlineThickness { get; set; } = 0.2; - public bool Topmost { get; set; } = true; - public bool ShowInTaskbar { get; set; } = true; - public int Height { get; set; } = 48; - public bool RunOnStartup { get; set; } = false; - public bool DragToMove { get; set; } = true; - public bool RightAligned { get; set; } = false; - public string WavFilePath { get; set; } = string.Empty; - public TimeSpan WavFileInterval { get; set; } - public TeachingTips TipsShown { get; set; } - public string LastDisplay { get; set; } - public WindowPlacement Placement { get; set; } - - [JsonIgnore] - public Theme Theme - { - get => new("Custom", TextColor.ToString(), OuterColor.ToString()); - set - { - TextColor = (Color)ColorConverter.ConvertFromString(value.PrimaryColor); - OuterColor = (Color)ColorConverter.ConvertFromString(value.SecondaryColor); - } - } - - #endregion "Properties" - - /// - /// Saves to the default path in JSON format. - /// - public bool Save() - { - try - { - var json = JsonConvert.SerializeObject(this, _jsonSerializerSettings); - - // Attempt to save multiple times. - for (var i = 0; i < 4; i++) - { - try - { - File.WriteAllText(FilePath, json); - return true; - } - catch - { - // Wait before next attempt to read. - System.Threading.Thread.Sleep(250); - } - } - } - catch (JsonSerializationException) - { - } - - return false; - } - - /// - /// Populates the given settings with values from the default path. - /// - private static void Populate(Settings settings) - { - using var fileStream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var streamReader = new StreamReader(fileStream); - using var jsonReader = new JsonTextReader(streamReader); - - JsonSerializer.Create(_jsonSerializerSettings).Populate(jsonReader, settings); - } - - /// - /// Loads from the default path in JSON format. - /// - private static Settings LoadFromFile() - { - try - { - var settings = new Settings(); - Populate(settings); - return settings; - } - catch - { - return new(); - } - } - - /// - /// Loads from the default path in JSON format then attempts to save in order to check if it can be done. - /// - private static Settings LoadAndAttemptSave() - { - var settings = LoadFromFile(); - - CanBeSaved = settings.Save(); - - return settings; - } - - /// - /// Occurs after the watcher detects a change in the settings file. - /// - private void FileChanged(object sender, FileSystemEventArgs e) - { - try - { - Populate(this); - } - catch - { - } - } - - 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. - //_watcher?.Dispose(); - } +using System; +using System.ComponentModel; +using System.IO; +using System.Windows.Media; +using Newtonsoft.Json; +using WpfWindowPlacement; + +namespace DesktopClock.Properties; + +public sealed class Settings : INotifyPropertyChanged, IDisposable +{ + private readonly FileSystemWatcher _watcher; + + private static readonly Lazy _default = new(LoadAndAttemptSave); + + private static readonly JsonSerializerSettings _jsonSerializerSettings = new() + { + Formatting = Formatting.Indented, + Error = (_, e) => e.ErrorContext.Handled = true, + }; + + private Settings() + { + // Settings file path from same directory as the executable. + var settingsFileName = Path.GetFileNameWithoutExtension(App.MainFileInfo.FullName) + ".settings"; + FilePath = Path.Combine(App.MainFileInfo.DirectoryName, settingsFileName); + + // Watch for changes. + _watcher = new(App.MainFileInfo.DirectoryName, settingsFileName) + { + EnableRaisingEvents = true, + }; + _watcher.Changed += FileChanged; + + // Random default theme before getting overwritten. + Theme = Theme.GetRandomDefaultTheme(); + } + +#pragma warning disable CS0067 // The event 'Settings.PropertyChanged' is never used + public event PropertyChangedEventHandler PropertyChanged; +#pragma warning restore CS0067 // The event 'Settings.PropertyChanged' is never used + + public static Settings Default => _default.Value; + + /// + /// The full path to the settings file. + /// + public static string FilePath { get; private set; } + + /// + /// Can the settings file be saved to? + /// + public static bool CanBeSaved { get; private set; } + + /// + /// Does the settings file exist on the disk? + /// + public static bool Exists => File.Exists(FilePath); + + #region "Properties" + + public string Format { get; set; } = "{ddd}, {MMM dd}, {h:mm:ss tt}"; + public string CountdownFormat { get; set; } = ""; + public DateTime? CountdownTo { get; set; } = default(DateTime); + public string TimeZone { get; set; } = string.Empty; + public string FontFamily { get; set; } = "Consolas"; + public Color TextColor { get; set; } + public Color OuterColor { get; set; } + public bool BackgroundEnabled { get; set; } = true; + public double BackgroundOpacity { get; set; } = 0.90; + public double BackgroundCornerRadius { get; set; } = 1; + public string BackgroundImagePath { get; set; } = string.Empty; + public double OutlineThickness { get; set; } = 0.2; + public bool Topmost { get; set; } = true; + public bool ShowInTaskbar { get; set; } = true; + public int Height { get; set; } = 48; + public bool RunOnStartup { get; set; } = false; + public bool DragToMove { get; set; } = true; + public bool RightAligned { get; set; } = false; + public string WavFilePath { get; set; } = string.Empty; + public TimeSpan WavFileInterval { get; set; } + public TeachingTips TipsShown { get; set; } + public string LastDisplay { get; set; } + public WindowPlacement Placement { get; set; } + + public BounceSettings Bounce { get; set; } = new BounceSettings(); + + + [JsonIgnore] + public Theme Theme + { + get => new("Custom", TextColor.ToString(), OuterColor.ToString()); + set + { + TextColor = (Color)ColorConverter.ConvertFromString(value.PrimaryColor); + OuterColor = (Color)ColorConverter.ConvertFromString(value.SecondaryColor); + } + } + + #endregion "Properties" + + /// + /// Saves to the default path in JSON format. + /// + public bool Save() + { + try + { + var json = JsonConvert.SerializeObject(this, _jsonSerializerSettings); + + // Attempt to save multiple times. + for (var i = 0; i < 4; i++) + { + try + { + File.WriteAllText(FilePath, json); + return true; + } + catch + { + // Wait before next attempt to read. + System.Threading.Thread.Sleep(250); + } + } + } + catch (JsonSerializationException) + { + } + + return false; + } + + /// + /// Populates the given settings with values from the default path. + /// + private static void Populate(Settings settings) + { + using var fileStream = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var streamReader = new StreamReader(fileStream); + using var jsonReader = new JsonTextReader(streamReader); + + JsonSerializer.Create(_jsonSerializerSettings).Populate(jsonReader, settings); + } + + /// + /// Loads from the default path in JSON format. + /// + private static Settings LoadFromFile() + { + try + { + var settings = new Settings(); + Populate(settings); + return settings; + } + catch + { + return new(); + } + } + + /// + /// Loads from the default path in JSON format then attempts to save in order to check if it can be done. + /// + private static Settings LoadAndAttemptSave() + { + var settings = LoadFromFile(); + + CanBeSaved = settings.Save(); + + return settings; + } + + /// + /// Occurs after the watcher detects a change in the settings file. + /// + private void FileChanged(object sender, FileSystemEventArgs e) + { + try + { + Populate(this); + } + catch + { + } + } + + 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. + //_watcher?.Dispose(); + } } \ No newline at end of file