MainWindow.xaml.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. using System;
  2. using System.ComponentModel;
  3. using System.Diagnostics;
  4. using System.Globalization;
  5. using System.IO;
  6. using System.Media;
  7. using System.Windows;
  8. using System.Windows.Controls;
  9. using System.Windows.Input;
  10. using CommunityToolkit.Mvvm.ComponentModel;
  11. using CommunityToolkit.Mvvm.Input;
  12. using DesktopClock.Properties;
  13. using H.NotifyIcon;
  14. using H.NotifyIcon.EfficiencyMode;
  15. using Humanizer;
  16. using WpfWindowPlacement;
  17. namespace DesktopClock;
  18. /// <summary>
  19. /// Interaction logic for MainWindow.xaml
  20. /// </summary>
  21. [ObservableObject]
  22. public partial class MainWindow : Window
  23. {
  24. private readonly SystemClockTimer _systemClockTimer;
  25. private TaskbarIcon _trayIcon;
  26. private TimeZoneInfo _timeZone;
  27. private SoundPlayer _soundPlayer;
  28. /// <summary>
  29. /// The date and time to countdown to, or <c>null</c> if regular clock is desired.
  30. /// </summary>
  31. [ObservableProperty]
  32. private DateTimeOffset? _countdownTo;
  33. /// <summary>
  34. /// The current date and time in the selected time zone, or countdown as a formatted string.
  35. /// </summary>
  36. [ObservableProperty]
  37. private string _currentTimeOrCountdownString;
  38. public MainWindow()
  39. {
  40. InitializeComponent();
  41. DataContext = this;
  42. _timeZone = Settings.Default.GetTimeZoneInfo();
  43. UpdateCountdownEnabled();
  44. Settings.Default.PropertyChanged += (s, e) => Dispatcher.Invoke(() => Settings_PropertyChanged(s, e));
  45. // Not done through binding due to what's explained in the comment in WindowUtil.HideFromScreen().
  46. ShowInTaskbar = Settings.Default.ShowInTaskbar;
  47. // Restore the structure of the last state using the display text.
  48. CurrentTimeOrCountdownString = Settings.Default.LastDisplay;
  49. _systemClockTimer = new();
  50. _systemClockTimer.SecondChanged += SystemClockTimer_SecondChanged;
  51. // The context menu is shared between right-clicking the window and the tray icon.
  52. ContextMenu = Resources["MainContextMenu"] as ContextMenu;
  53. ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, true);
  54. UpdateSoundPlayerEnabled();
  55. }
  56. /// <summary>
  57. /// Copies the current time string to the clipboard.
  58. /// </summary>
  59. [RelayCommand]
  60. public void CopyToClipboard() => Clipboard.SetText(CurrentTimeOrCountdownString);
  61. /// <summary>
  62. /// Minimizes the window.
  63. /// </summary>
  64. [RelayCommand]
  65. public void HideForNow()
  66. {
  67. if (!Settings.Default.TipsShown.HasFlag(TeachingTips.HideForNow))
  68. {
  69. MessageBox.Show(this, "Clock will be minimized and can be opened again from the taskbar (or system tray if enabled).",
  70. Title, MessageBoxButton.OK, MessageBoxImage.Information);
  71. Settings.Default.TipsShown |= TeachingTips.HideForNow;
  72. }
  73. this.HideFromScreen();
  74. }
  75. /// <summary>
  76. /// Sets the app's theme to the given value.
  77. /// </summary>
  78. [RelayCommand]
  79. public void SetTheme(Theme theme) => Settings.Default.Theme = theme;
  80. /// <summary>
  81. /// Opens a new settings window or activates the existing one.
  82. /// </summary>
  83. [RelayCommand]
  84. public void OpenSettings() => App.ShowSingletonWindow<SettingsWindow>(this);
  85. /// <summary>
  86. /// Asks the user then creates a new clock executable and starts it.
  87. /// </summary>
  88. [RelayCommand]
  89. public void NewClock()
  90. {
  91. if (!Settings.Default.TipsShown.HasFlag(TeachingTips.NewClock))
  92. {
  93. var result = MessageBox.Show(this,
  94. "This will copy the executable and start it with new settings.\n\n" +
  95. "Continue?",
  96. Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
  97. if (result != MessageBoxResult.OK)
  98. return;
  99. Settings.Default.TipsShown |= TeachingTips.NewClock;
  100. }
  101. var newExePath = Path.Combine(App.MainFileInfo.DirectoryName, App.MainFileInfo.GetFileAtNextIndex().Name);
  102. // Copy and start the new clock.
  103. File.Copy(App.MainFileInfo.FullName, newExePath);
  104. Process.Start(newExePath);
  105. }
  106. /// <summary>
  107. /// Opens the GitHub Releases page.
  108. /// </summary>
  109. [RelayCommand]
  110. public void CheckForUpdates()
  111. {
  112. if (!Settings.Default.TipsShown.HasFlag(TeachingTips.CheckForUpdates))
  113. {
  114. var result = MessageBox.Show(this,
  115. "This will take you to GitHub to view the latest releases.\n\n" +
  116. "Continue?",
  117. Title, MessageBoxButton.OKCancel, MessageBoxImage.Question, MessageBoxResult.OK);
  118. if (result != MessageBoxResult.OK)
  119. return;
  120. Settings.Default.TipsShown |= TeachingTips.CheckForUpdates;
  121. }
  122. Process.Start("https://github.com/danielchalmers/DesktopClock/releases");
  123. }
  124. /// <summary>
  125. /// Closes the app.
  126. /// </summary>
  127. [RelayCommand]
  128. public void Exit()
  129. {
  130. Application.Current.Shutdown();
  131. }
  132. private void ConfigureTrayIcon(bool showIcon, bool firstLaunch)
  133. {
  134. if (showIcon)
  135. {
  136. if (_trayIcon == null)
  137. {
  138. // Construct the tray from the resources defined.
  139. _trayIcon = Resources["TrayIcon"] as TaskbarIcon;
  140. _trayIcon.ContextMenu = Resources["MainContextMenu"] as ContextMenu;
  141. _trayIcon.ContextMenu.DataContext = this;
  142. _trayIcon.ForceCreate(enablesEfficiencyMode: false);
  143. _trayIcon.TrayLeftMouseDoubleClick += (_, _) =>
  144. {
  145. WindowState = WindowState.Normal;
  146. Activate();
  147. };
  148. }
  149. // Show a notice if the icon was moved during runtime, but not at the start because the user will already expect it.
  150. if (!firstLaunch)
  151. _trayIcon.ShowNotification("Hidden from taskbar", "Icon was moved to the tray");
  152. }
  153. else
  154. {
  155. _trayIcon?.Dispose();
  156. _trayIcon = null;
  157. }
  158. }
  159. /// <summary>
  160. /// Handles property changes in settings and updates the corresponding properties in the UI.
  161. /// </summary>
  162. private void Settings_PropertyChanged(object sender, PropertyChangedEventArgs e)
  163. {
  164. switch (e.PropertyName)
  165. {
  166. case nameof(Settings.Default.TimeZone):
  167. _timeZone = Settings.Default.GetTimeZoneInfo();
  168. UpdateTimeString();
  169. break;
  170. case nameof(Settings.Default.Format):
  171. case nameof(Settings.Default.CountdownFormat):
  172. UpdateTimeString();
  173. break;
  174. case nameof(Settings.Default.ShowInTaskbar):
  175. ShowInTaskbar = Settings.Default.ShowInTaskbar;
  176. ConfigureTrayIcon(!Settings.Default.ShowInTaskbar, false);
  177. break;
  178. case nameof(Settings.Default.CountdownTo):
  179. UpdateCountdownEnabled();
  180. UpdateTimeString();
  181. break;
  182. case nameof(Settings.Default.WavFilePath):
  183. case nameof(Settings.Default.WavFileInterval):
  184. UpdateSoundPlayerEnabled();
  185. break;
  186. }
  187. }
  188. /// <summary>
  189. /// Handles the event when the system clock timer signals a second change.
  190. /// </summary>
  191. private void SystemClockTimer_SecondChanged(object sender, EventArgs e)
  192. {
  193. UpdateTimeString();
  194. TryPlaySound();
  195. }
  196. /// <summary>
  197. /// Updates the countdown enabled state based on the settings.
  198. /// </summary>
  199. private void UpdateCountdownEnabled()
  200. {
  201. if (Settings.Default.CountdownTo == default)
  202. {
  203. CountdownTo = null;
  204. return;
  205. }
  206. CountdownTo = Settings.Default.CountdownTo.ToDateTimeOffset(_timeZone.BaseUtcOffset);
  207. }
  208. /// <summary>
  209. /// Initializes the sound player for the specified file if enabled; otherwise, sets it to <c>null</c>.
  210. /// </summary>
  211. private void UpdateSoundPlayerEnabled()
  212. {
  213. var soundPlayerEnabled =
  214. !string.IsNullOrWhiteSpace(Settings.Default.WavFilePath) &&
  215. Settings.Default.WavFileInterval != default &&
  216. File.Exists(Settings.Default.WavFilePath);
  217. _soundPlayer = soundPlayerEnabled ? new(Settings.Default.WavFilePath) : null;
  218. }
  219. /// <summary>
  220. /// Tries to play a sound based on the settings if it hits the specified interval and the file exists.
  221. /// </summary>
  222. private void TryPlaySound()
  223. {
  224. if (_soundPlayer == null)
  225. return;
  226. // Whether we hit the interval specified in settings, which is calculated differently in countdown mode and not.
  227. var isOnInterval = CountdownTo == null ?
  228. (int)DateTimeOffset.Now.TimeOfDay.TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0 :
  229. (int)(CountdownTo.Value - DateTimeOffset.Now).TotalSeconds % (int)Settings.Default.WavFileInterval.TotalSeconds == 0;
  230. if (!isOnInterval)
  231. return;
  232. try
  233. {
  234. _soundPlayer.Play();
  235. }
  236. catch
  237. {
  238. // Ignore errors because we don't want a sound issue to crash the app.
  239. }
  240. }
  241. private void UpdateTimeString()
  242. {
  243. string GetTimeString()
  244. {
  245. var timeInSelectedZone = TimeZoneInfo.ConvertTime(DateTimeOffset.Now, _timeZone);
  246. if (CountdownTo == null)
  247. {
  248. return Tokenizer.FormatWithTokenizerOrFallBack(timeInSelectedZone, Settings.Default.Format, CultureInfo.DefaultThreadCurrentCulture);
  249. }
  250. else
  251. {
  252. if (string.IsNullOrWhiteSpace(Settings.Default.CountdownFormat))
  253. return CountdownTo.Humanize(timeInSelectedZone);
  254. return Tokenizer.FormatWithTokenizerOrFallBack(Settings.Default.CountdownTo - timeInSelectedZone, Settings.Default.CountdownFormat, CultureInfo.DefaultThreadCurrentCulture);
  255. }
  256. }
  257. CurrentTimeOrCountdownString = GetTimeString();
  258. }
  259. private void Window_MouseDown(object sender, MouseButtonEventArgs e)
  260. {
  261. // Drag the window to move it.
  262. if (e.ChangedButton == MouseButton.Left && Settings.Default.DragToMove)
  263. {
  264. // Pause time updates to maintain placement.
  265. _systemClockTimer.Stop();
  266. DragMove();
  267. UpdateTimeString();
  268. _systemClockTimer.Start();
  269. }
  270. }
  271. private void Window_MouseDoubleClick(object sender, MouseButtonEventArgs e)
  272. {
  273. CopyToClipboard();
  274. }
  275. private void Window_MouseWheel(object sender, MouseWheelEventArgs e)
  276. {
  277. // Resize the window when scrolling if the Ctrl key is pressed.
  278. if (Keyboard.Modifiers == ModifierKeys.Control)
  279. {
  280. // Amount of scroll that occurred and whether it was positive or negative.
  281. var steps = e.Delta / (double)Mouse.MouseWheelDeltaForOneLine;
  282. Settings.Default.ScaleHeight(steps);
  283. }
  284. }
  285. private void Window_SourceInitialized(object sender, EventArgs e)
  286. {
  287. this.SetPlacement(Settings.Default.Placement);
  288. UpdateTimeString();
  289. _systemClockTimer.Start();
  290. // 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.
  291. SizeChanged += Window_SizeChanged;
  292. if (Settings.Default.StartHidden)
  293. {
  294. _trayIcon?.ShowNotification("Started hidden", "Icon is in the tray");
  295. this.HideFromScreen();
  296. }
  297. // Show the window now that it's finished loading.
  298. // This was mainly done to stop the StartHidden option from flashing the window briefly.
  299. Opacity = 1;
  300. }
  301. private void Window_ContentRendered(object sender, EventArgs e)
  302. {
  303. // Make sure the user is aware that their changes will not be saved.
  304. if (!Settings.CanBeSaved)
  305. {
  306. MessageBox.Show(this,
  307. "Settings can't be saved because of an access error.\n\n" +
  308. $"Make sure {Title} is in a folder that doesn't require admin privileges, " +
  309. "and that you got it from the original source: https://github.com/danielchalmers/DesktopClock.\n\n" +
  310. "If the problem still persists, feel free to create a new Issue at the above link with as many details as possible.",
  311. Title, MessageBoxButton.OK, MessageBoxImage.Warning);
  312. }
  313. }
  314. private void Window_Closing(object sender, CancelEventArgs e)
  315. {
  316. // Save the last text and the placement to preserve dimensions and position of the clock.
  317. Settings.Default.LastDisplay = CurrentTimeOrCountdownString;
  318. Settings.Default.Placement = this.GetPlacement();
  319. // Stop the file watcher before saving.
  320. Settings.Default.Dispose();
  321. if (Settings.CanBeSaved)
  322. Settings.Default.Save();
  323. App.SetRunOnStartup(Settings.Default.RunOnStartup);
  324. }
  325. private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
  326. {
  327. // Adjust the window position for right-alignment.
  328. if (e.WidthChanged && Settings.Default.RightAligned)
  329. {
  330. var widthChange = e.NewSize.Width - e.PreviousSize.Width;
  331. Left -= widthChange;
  332. }
  333. }
  334. private void Window_StateChanged(object sender, EventArgs e)
  335. {
  336. if (WindowState == WindowState.Minimized)
  337. {
  338. // Save resources while minimized.
  339. _systemClockTimer.Stop();
  340. EfficiencyModeUtilities.SetEfficiencyMode(true);
  341. }
  342. else
  343. {
  344. // Run like normal without withholding resources.
  345. UpdateTimeString();
  346. _systemClockTimer.Start();
  347. EfficiencyModeUtilities.SetEfficiencyMode(false);
  348. }
  349. }
  350. private void Window_KeyDown(object sender, KeyEventArgs e)
  351. {
  352. if (Keyboard.Modifiers == ModifierKeys.Control)
  353. {
  354. switch (e.Key)
  355. {
  356. case Key.OemMinus:
  357. Settings.Default.ScaleHeight(-1);
  358. break;
  359. case Key.OemPlus:
  360. Settings.Default.ScaleHeight(1);
  361. break;
  362. }
  363. }
  364. }
  365. }