diff --git a/README.md b/README.md index 446c132..dc88b53 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,10 @@ of `codex.cmd` or `codex.exe`. - Remaining percentage and reset time for every general Codex usage window - A selectable displayed limit shared by the widget headline, taskbar label, and tray icon -- Compact and detailed layouts, including credits, spend controls, earned resets, token - activity, and model-specific limits when Codex returns them +- Compact and detailed desktop-widget layouts selected from Settings or the quick chevron, + including credits, spend controls, earned resets, token activity, and model-specific limits + when Codex returns them +- System, light, and dark themes with five preset accent colors selected from Settings - A movable, always-on-top desktop widget and a compact label beside the notification area - Live task activity dots based on official local Codex lifecycle hooks - Automatic refresh every two minutes plus live rate-limit notifications @@ -51,7 +53,7 @@ visible below it. Compact mode focuses on limits; Details adds account and token **Taskbar label.** Shows the same selected percentage beside the Windows notification area. The tray icon and its tooltip follow that selection too. -Choose `5h limit`, `Weekly limit`, or `Most constrained` from the **Displayed limit** menu. +Choose `5h limit`, `Weekly limit`, or `Most constrained` in **Settings** under **Usage**. The 5-hour window is the default. Available windows depend on the Codex account, so the widget falls back to an available window when Codex does not return the selected one. @@ -63,8 +65,10 @@ subscription percentage. ![Codex Usage Widget taskbar label preview](docs/images/taskbar-label.png) Select the `−` button to move the widget to the taskbar. Right-click the taskbar label or -tray icon to refresh, change the display mode or displayed limit, configure activity dots, -toggle start with Windows, check for updates, or exit. +tray icon to refresh, change the display mode, open Settings, check for updates, or exit. +The Settings window opens from the gear button on the widget too. Theme, accent color, +widget layout, displayed limit, and Start with Windows changes apply as soon as they are +selected. ## Activity dots @@ -74,7 +78,7 @@ read prompt text, responses, transcript paths, or model output. To enable them: -1. Open **Activity dots...** from the widget, taskbar label, or tray menu. +1. Open **Settings**, then find **Codex activity** under **Features**. 2. Select **Install hooks** and review the exact proposed `~/.codex/hooks.json` change. 3. Select **Copy /hooks and open Codex**, paste `/hooks`, and trust the three definitions. 4. Return to the widget and select **Check again**. @@ -95,6 +99,8 @@ The application writes only under `%LOCALAPPDATA%\CodexUsageWidget`: - `display-mode.txt`: desktop or taskbar display preference - `widget-density.txt`: compact or detailed widget preference - `displayed-limit.txt`: selected summary limit +- `theme.txt`: system, light, or dark theme preference +- `accent-palette.txt`: selected preset accent color - `logs\codex-usage-widget-YYYYMMDD.log`: diagnostic logs retained for 14 days The widget displays ChatGPT and Codex subscription limits. It does not display OpenAI API @@ -102,8 +108,9 @@ billing or API-key usage. ## Uninstall -1. If activity hooks are installed, open **Activity dots...** and select **Remove hooks**. -2. Turn off **Start with Windows** from the widget or tray menu. +1. If activity hooks are installed, open **Settings**, find **Codex activity** under + **Features**, and select **Remove hooks**. +2. Turn off **Start with Windows** in **Settings** under **General**. 3. Exit the widget. 4. Delete the extracted application folder and `%LOCALAPPDATA%\CodexUsageWidget`. The local data folder contains any stable copy, saved preferences, and diagnostic logs. diff --git a/docs/ACTIVITY_DOTS.md b/docs/ACTIVITY_DOTS.md index 6a7cb0b..8771b6e 100644 --- a/docs/ACTIVITY_DOTS.md +++ b/docs/ACTIVITY_DOTS.md @@ -19,20 +19,20 @@ paths, or model output. ## Set up in the widget -1. Select the three-dot activity button in the desktop widget, or choose - **Activity dots...** from the taskbar-label or tray menu. +1. Open **Settings** from the desktop widget, taskbar-label menu, or tray menu, then find + **Codex activity** under **Features**. 2. Select **Install hooks**. 3. Review the exact proposed `~/.codex/hooks.json` content and confirm the change. 4. Select **Copy /hooks and open Codex**. 5. Paste `/hooks` into Codex and trust the new `UserPromptSubmit`, `Stop`, and `SessionEnd` definitions. -6. Return to the setup window and select **Check again**. +6. Return to Settings and select **Check again**. Codex owns hook trust. New or changed definitions require approval in Codex. The widget only reads the reported trust state. -To remove the handlers, return to the setup window and select **Remove hooks**. The widget -preserves unrelated hooks and unknown configuration fields. +To remove the handlers, return to **Codex activity** under **Features** in Settings and +select **Remove hooks**. The widget preserves unrelated hooks and unknown configuration fields. ## Command-line setup and removal diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 25afc79..9b32b37 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,11 +36,16 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence transitions. 8. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads trust state through `hooks/list`; `CodexHookTrustStatusParser` owns the protocol shape. -9. `ActivityHookSetupWindow` presents setup status while a separate review dialog shows - the exact proposed file content before installation or removal. +9. `ActivityHookSetupControl` presents setup status inside Settings while a separate review + dialog shows the exact proposed file content before installation or removal. 10. `UsageWidgetViewModel` maps snapshots to immutable presentation state. -11. `MainWindow` remains a window-lifecycle shell while focused user controls render - compact, detailed, and repeated limit-row content. +11. `AppThemeController` applies the saved system, light, or dark theme plus the selected + accent palette, and observes Windows theme changes without leaking registry access into + view code. +12. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates + activity-hook setup plus immediate theme, accent, widget-layout, displayed-limit, and Windows + startup preferences. Focused user controls render compact, detailed, and repeated limit-row + content. ## Dependency direction @@ -79,4 +84,6 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence - Keep reusable presentation state in `Views/ViewModels` and focused visual sections in `Views/Controls`; `MainWindow` should not absorb endpoint or rendering responsibilities. - Keep Win32 calls under `Infrastructure/Windows` and UI rendering under `Views`. +- Extend user preferences through the Settings window. Keep appearance application in + `AppThemeController` instead of placing theme decisions in individual views. - Avoid placing persistence, process management, or protocol parsing in code-behind. diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index 2fd0697..0a15190 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -17,6 +17,7 @@ public partial class App : System.Windows.Application, IDisposable private SingleInstanceGuard? _singleInstanceGuard; private FileLogger? _logger; private GlobalExceptionHandler? _exceptionHandler; + private AppThemeController? _themeController; private bool _disposed; protected override void OnStartup(StartupEventArgs e) @@ -66,6 +67,12 @@ protected override void OnStartup(StartupEventArgs e) var activityHookSetupService = new CodexActivityHookSetupService( new CodexHookConfigurationManager(), appServerSession); + _themeController = new AppThemeController( + this, + new ThemePreferenceMonitor( + new ThemePreferenceStore(), + new WindowsThemeMonitor()), + new AccentPaletteStore()); var window = new MainWindow( usageMonitor, @@ -76,7 +83,8 @@ protected override void OnStartup(StartupEventArgs e) new WidgetDensityStore(), new DisplayedLimitPreferenceStore(), startupRegistrationService, - new TrayIconService()); + new TrayIconService(), + _themeController); MainWindow = window; activityMonitor.StartAsync().GetAwaiter().GetResult(); window.Show(); @@ -126,6 +134,8 @@ public void Dispose() _disposed = true; _exceptionHandler?.Dispose(); _exceptionHandler = null; + _themeController?.Dispose(); + _themeController = null; _singleInstanceGuard?.Dispose(); _singleInstanceGuard = null; GC.SuppressFinalize(this); diff --git a/src/CodexUsageWidget/Infrastructure/AppPaths.cs b/src/CodexUsageWidget/Infrastructure/AppPaths.cs index d7fc402..7d4daf8 100644 --- a/src/CodexUsageWidget/Infrastructure/AppPaths.cs +++ b/src/CodexUsageWidget/Infrastructure/AppPaths.cs @@ -16,5 +16,9 @@ public static class AppPaths LocalDataDirectory, "displayed-limit.txt"); + public static string ThemePreferenceFile => Path.Combine(LocalDataDirectory, "theme.txt"); + + public static string AccentPaletteFile => Path.Combine(LocalDataDirectory, "accent-palette.txt"); + public static string LogDirectory => Path.Combine(LocalDataDirectory, "logs"); } diff --git a/src/CodexUsageWidget/Infrastructure/Settings/AccentPalette.cs b/src/CodexUsageWidget/Infrastructure/Settings/AccentPalette.cs new file mode 100644 index 0000000..042e395 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/AccentPalette.cs @@ -0,0 +1,10 @@ +namespace CodexUsageWidget.Infrastructure.Settings; + +public enum AccentPalette +{ + Blue, + Violet, + Teal, + Emerald, + Pink +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/AccentPaletteStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/AccentPaletteStore.cs new file mode 100644 index 0000000..dd227e3 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/AccentPaletteStore.cs @@ -0,0 +1,51 @@ +using System.IO; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class AccentPaletteStore +{ + private readonly string _path; + + public AccentPaletteStore(string? path = null) + { + _path = path ?? AppPaths.AccentPaletteFile; + } + + public AccentPalette Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "violet" => AccentPalette.Violet, + "teal" => AccentPalette.Teal, + "emerald" => AccentPalette.Emerald, + "pink" => AccentPalette.Pink, + _ => AccentPalette.Blue + }; + } + catch (IOException) + { + return AccentPalette.Blue; + } + catch (UnauthorizedAccessException) + { + return AccentPalette.Blue; + } + } + + public void Save(AccentPalette palette) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, palette.ToString().ToLowerInvariant()); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/EffectiveTheme.cs b/src/CodexUsageWidget/Infrastructure/Settings/EffectiveTheme.cs new file mode 100644 index 0000000..cea8960 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/EffectiveTheme.cs @@ -0,0 +1,7 @@ +namespace CodexUsageWidget.Infrastructure.Settings; + +public enum EffectiveTheme +{ + Light, + Dark +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/ThemePreference.cs b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreference.cs new file mode 100644 index 0000000..abde7a0 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreference.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Infrastructure.Settings; + +public enum ThemePreference +{ + System, + Light, + Dark +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceMonitor.cs b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceMonitor.cs new file mode 100644 index 0000000..896882b --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceMonitor.cs @@ -0,0 +1,71 @@ +using CodexUsageWidget.Infrastructure.Windows; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class ThemePreferenceMonitor : IDisposable +{ + private readonly ThemePreferenceStore _store; + private readonly WindowsThemeMonitor _windowsThemeMonitor; + private bool _disposed; + + public ThemePreferenceMonitor( + ThemePreferenceStore store, + WindowsThemeMonitor windowsThemeMonitor) + { + _store = store; + _windowsThemeMonitor = windowsThemeMonitor; + Preference = store.Load(); + EffectiveTheme = ResolveEffectiveTheme(); + _windowsThemeMonitor.ThemeChanged += WindowsThemeMonitorOnThemeChanged; + } + + public event Action? EffectiveThemeChanged; + + public event Action? SystemThemeChanged; + + public ThemePreference Preference { get; private set; } + + public EffectiveTheme EffectiveTheme { get; private set; } + + public EffectiveTheme SystemTheme => _windowsThemeMonitor.UsesLightSystemTheme + ? EffectiveTheme.Light + : EffectiveTheme.Dark; + + public void SetPreference(ThemePreference preference) + { + Preference = preference; + _store.Save(preference); + SetEffectiveTheme(ResolveEffectiveTheme()); + } + + private void WindowsThemeMonitorOnThemeChanged(object? sender, EventArgs e) + { + SystemThemeChanged?.Invoke(SystemTheme); + if (Preference == ThemePreference.System) + { + SetEffectiveTheme(ResolveEffectiveTheme()); + } + } + + private EffectiveTheme ResolveEffectiveTheme() => ThemePreferenceResolver.Resolve( + Preference, + _windowsThemeMonitor.UsesLightAppTheme); + + private void SetEffectiveTheme(EffectiveTheme theme) + { + EffectiveTheme = theme; + EffectiveThemeChanged?.Invoke(theme); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _windowsThemeMonitor.ThemeChanged -= WindowsThemeMonitorOnThemeChanged; + _windowsThemeMonitor.Dispose(); + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceResolver.cs b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceResolver.cs new file mode 100644 index 0000000..eedb202 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceResolver.cs @@ -0,0 +1,13 @@ +namespace CodexUsageWidget.Infrastructure.Settings; + +public static class ThemePreferenceResolver +{ + public static EffectiveTheme Resolve( + ThemePreference preference, + bool appsUseLightTheme) => preference switch + { + ThemePreference.Light => EffectiveTheme.Light, + ThemePreference.Dark => EffectiveTheme.Dark, + _ => appsUseLightTheme ? EffectiveTheme.Light : EffectiveTheme.Dark + }; +} diff --git a/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceStore.cs new file mode 100644 index 0000000..b14df62 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/ThemePreferenceStore.cs @@ -0,0 +1,56 @@ +using System.IO; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class ThemePreferenceStore +{ + private readonly string _path; + + public ThemePreferenceStore(string? path = null) + { + _path = path ?? AppPaths.ThemePreferenceFile; + } + + public ThemePreference Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "light" => ThemePreference.Light, + "dark" => ThemePreference.Dark, + _ => ThemePreference.System + }; + } + catch (IOException) + { + return ThemePreference.System; + } + catch (UnauthorizedAccessException) + { + return ThemePreference.System; + } + } + + public void Save(ThemePreference preference) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText( + _path, + preference switch + { + ThemePreference.Light => "light", + ThemePreference.Dark => "dark", + _ => "system" + }); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index c10f5b7..4741a22 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -1,4 +1,3 @@ -using CodexUsageWidget.Application; using CodexUsageWidget.Infrastructure.Settings; using Forms = System.Windows.Forms; @@ -6,16 +5,10 @@ namespace CodexUsageWidget.Infrastructure.Windows; public sealed class TrayIconService : IDisposable { - private const string FiveHourUnavailableText = - "Codex did not return a global 5h limit for this account."; - private readonly Forms.NotifyIcon _notifyIcon; + private readonly Forms.ContextMenuStrip _menu; private readonly Forms.ToolStripMenuItem _desktopWidgetModeItem; private readonly Forms.ToolStripMenuItem _taskbarIndicatorModeItem; - private readonly Forms.ToolStripMenuItem _fiveHourLimitItem; - private readonly Forms.ToolStripMenuItem _weeklyLimitItem; - private readonly Forms.ToolStripMenuItem _mostConstrainedLimitItem; - private readonly Forms.ToolStripMenuItem _startWithWindowsItem; private System.Drawing.Icon _currentIcon; private bool _disposed; @@ -28,9 +21,9 @@ public TrayIconService() menu.Items.Add("Open", null, (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add("Refresh", null, (_, _) => RefreshRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add( - "Activity dots...", + "Settings...", null, - (_, _) => ActivityDotsSetupRequested?.Invoke(this, EventArgs.Empty)); + (_, _) => SettingsRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add(new Forms.ToolStripSeparator()); var displayModeMenu = new Forms.ToolStripMenuItem("Display mode"); @@ -46,31 +39,6 @@ public TrayIconService() displayModeMenu.DropDownItems.Add(_taskbarIndicatorModeItem); menu.Items.Add(displayModeMenu); - var displayedLimitMenu = new Forms.ToolStripMenuItem("Displayed limit"); - _fiveHourLimitItem = new Forms.ToolStripMenuItem("5h limit") - { - Enabled = false, - ToolTipText = FiveHourUnavailableText - }; - _fiveHourLimitItem.Click += (_, _) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.FiveHour); - _weeklyLimitItem = new Forms.ToolStripMenuItem("Weekly limit"); - _weeklyLimitItem.Click += (_, _) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.Weekly); - _mostConstrainedLimitItem = new Forms.ToolStripMenuItem("Most constrained"); - _mostConstrainedLimitItem.Click += (_, _) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.MostConstrained); - displayedLimitMenu.DropDownItems.Add(_fiveHourLimitItem); - displayedLimitMenu.DropDownItems.Add(_weeklyLimitItem); - displayedLimitMenu.DropDownItems.Add(_mostConstrainedLimitItem); - menu.Items.Add(displayedLimitMenu); - _startWithWindowsItem = new Forms.ToolStripMenuItem("Start with Windows") - { - CheckOnClick = true - }; - _startWithWindowsItem.Click += (_, _) => - StartupToggleRequested?.Invoke(this, EventArgs.Empty); - menu.Items.Add(_startWithWindowsItem); menu.Items.Add( "Check for updates...", null, @@ -79,6 +47,7 @@ public TrayIconService() menu.Items.Add("Exit", null, (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)); _currentIcon = UsageIconFactory.Create(null); + _menu = menu; _notifyIcon = new Forms.NotifyIcon { Icon = _currentIcon, @@ -87,22 +56,19 @@ public TrayIconService() ContextMenuStrip = menu }; _notifyIcon.MouseClick += NotifyIconOnMouseClick; + SetTheme(EffectiveTheme.Dark); } public event EventHandler? OpenRequested; public event EventHandler? RefreshRequested; - public event EventHandler? ActivityDotsSetupRequested; + public event EventHandler? SettingsRequested; public event EventHandler? DesktopModeRequested; public event EventHandler? TaskbarModeRequested; - public event Action? DisplayedLimitPreferenceChanged; - - public event EventHandler? StartupToggleRequested; - public event EventHandler? UpdateCheckRequested; public event EventHandler? ExitRequested; @@ -113,19 +79,37 @@ public void SetDisplayMode(WidgetDisplayMode mode) _taskbarIndicatorModeItem.Checked = mode == WidgetDisplayMode.TaskbarIndicator; } - public void SetStartupEnabled(bool enabled) => _startWithWindowsItem.Checked = enabled; - - public void SetDisplayedLimitPreference(DisplayedLimitPreference preference) + public void SetTheme(EffectiveTheme theme) { - _fiveHourLimitItem.Checked = preference == DisplayedLimitPreference.FiveHour; - _weeklyLimitItem.Checked = preference == DisplayedLimitPreference.Weekly; - _mostConstrainedLimitItem.Checked = preference == DisplayedLimitPreference.MostConstrained; + var light = theme == EffectiveTheme.Light; + var background = light + ? System.Drawing.Color.FromArgb(250, 250, 251) + : System.Drawing.Color.FromArgb(36, 36, 36); + var foreground = light + ? System.Drawing.Color.FromArgb(35, 35, 37) + : System.Drawing.Color.FromArgb(232, 232, 232); + + _menu.BackColor = background; + _menu.ForeColor = foreground; + ApplyMenuColors(_menu.Items, background, foreground); + _menu.Renderer = new Forms.ToolStripProfessionalRenderer( + new TrayMenuColorTable(light)); } - public void SetFiveHourLimitAvailability(bool available) + private static void ApplyMenuColors( + Forms.ToolStripItemCollection items, + System.Drawing.Color background, + System.Drawing.Color foreground) { - _fiveHourLimitItem.Enabled = available; - _fiveHourLimitItem.ToolTipText = available ? string.Empty : FiveHourUnavailableText; + foreach (Forms.ToolStripItem item in items) + { + item.BackColor = background; + item.ForeColor = foreground; + if (item is Forms.ToolStripMenuItem menuItem) + { + ApplyMenuColors(menuItem.DropDownItems, background, foreground); + } + } } public void UpdateUsage(double? remainingPercent) @@ -163,4 +147,45 @@ public void Dispose() _notifyIcon.Dispose(); _currentIcon.Dispose(); } + + private sealed class TrayMenuColorTable(bool light) : Forms.ProfessionalColorTable + { + private readonly System.Drawing.Color _background = light + ? System.Drawing.Color.FromArgb(250, 250, 251) + : System.Drawing.Color.FromArgb(36, 36, 36); + private readonly System.Drawing.Color _border = light + ? System.Drawing.Color.FromArgb(215, 217, 222) + : System.Drawing.Color.FromArgb(69, 69, 69); + private readonly System.Drawing.Color _hover = light + ? System.Drawing.Color.FromArgb(236, 238, 241) + : System.Drawing.Color.FromArgb(54, 54, 54); + + public override System.Drawing.Color ToolStripDropDownBackground => _background; + + public override System.Drawing.Color ImageMarginGradientBegin => _background; + + public override System.Drawing.Color ImageMarginGradientMiddle => _background; + + public override System.Drawing.Color ImageMarginGradientEnd => _background; + + public override System.Drawing.Color MenuBorder => _border; + + public override System.Drawing.Color MenuItemBorder => _hover; + + public override System.Drawing.Color MenuItemSelected => _hover; + + public override System.Drawing.Color MenuItemSelectedGradientBegin => _hover; + + public override System.Drawing.Color MenuItemSelectedGradientEnd => _hover; + + public override System.Drawing.Color MenuItemPressedGradientBegin => _hover; + + public override System.Drawing.Color MenuItemPressedGradientMiddle => _hover; + + public override System.Drawing.Color MenuItemPressedGradientEnd => _hover; + + public override System.Drawing.Color SeparatorDark => _border; + + public override System.Drawing.Color SeparatorLight => _background; + } } diff --git a/src/CodexUsageWidget/Infrastructure/Windows/WindowWorkAreaProvider.cs b/src/CodexUsageWidget/Infrastructure/Windows/WindowWorkAreaProvider.cs new file mode 100644 index 0000000..837e64c --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/WindowWorkAreaProvider.cs @@ -0,0 +1,26 @@ +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using Forms = System.Windows.Forms; + +namespace CodexUsageWidget.Infrastructure.Windows; + +public interface IWindowWorkAreaProvider +{ + double GetAvailableHeightInDips(Window referenceWindow); +} + +public sealed class WindowWorkAreaProvider : IWindowWorkAreaProvider +{ + private const double WindowMargin = 24d; + + public double GetAvailableHeightInDips(Window referenceWindow) + { + ArgumentNullException.ThrowIfNull(referenceWindow); + + var handle = new WindowInteropHelper(referenceWindow).EnsureHandle(); + var workArea = Forms.Screen.FromHandle(handle).WorkingArea; + var dpiScale = VisualTreeHelper.GetDpi(referenceWindow).DpiScaleY; + return Math.Max(0d, workArea.Height / dpiScale - WindowMargin); + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/WindowsThemeMonitor.cs b/src/CodexUsageWidget/Infrastructure/Windows/WindowsThemeMonitor.cs new file mode 100644 index 0000000..4c68f85 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/WindowsThemeMonitor.cs @@ -0,0 +1,45 @@ +using Microsoft.Win32; + +namespace CodexUsageWidget.Infrastructure.Windows; + +public sealed class WindowsThemeMonitor : IDisposable +{ + private const string PersonalizeKey = + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + private const string AppsUseLightThemeValue = "AppsUseLightTheme"; + private const string SystemUsesLightThemeValue = "SystemUsesLightTheme"; + private readonly string _personalizeKey; + private bool _disposed; + + public WindowsThemeMonitor(string? personalizeKey = null) + { + _personalizeKey = personalizeKey ?? PersonalizeKey; + SystemEvents.UserPreferenceChanged += SystemEventsOnUserPreferenceChanged; + } + + public event EventHandler? ThemeChanged; + + public bool UsesLightAppTheme => ReadLightThemeValue(AppsUseLightThemeValue); + + public bool UsesLightSystemTheme => ReadLightThemeValue(SystemUsesLightThemeValue); + + private bool ReadLightThemeValue(string valueName) + { + using var key = Registry.CurrentUser.OpenSubKey(_personalizeKey); + return key?.GetValue(valueName) is not int value || value != 0; + } + + private void SystemEventsOnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) => + ThemeChanged?.Invoke(this, EventArgs.Empty); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + SystemEvents.UserPreferenceChanged -= SystemEventsOnUserPreferenceChanged; + } +} diff --git a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml index 61c3916..6b34e44 100644 --- a/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml +++ b/src/CodexUsageWidget/Views/ActivityHookChangeReviewWindow.xaml @@ -1,6 +1,7 @@ + Foreground="{DynamicResource TextPrimary}" /> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CodexUsageWidget/Views/SettingsWindow.xaml.cs b/src/CodexUsageWidget/Views/SettingsWindow.xaml.cs new file mode 100644 index 0000000..f367693 --- /dev/null +++ b/src/CodexUsageWidget/Views/SettingsWindow.xaml.cs @@ -0,0 +1,195 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Settings; +using CodexUsageWidget.Infrastructure.Windows; +using CodexUsageWidget.Views.Controls; + +namespace CodexUsageWidget.Views; + +public partial class SettingsWindow : Window +{ + private readonly IWindowWorkAreaProvider _workAreaProvider; + private bool _suppressChangeEvents; + + public SettingsWindow( + ThemePreference themePreference, + WidgetDensity widgetDensity, + DisplayedLimitPreference displayedLimitPreference, + bool fiveHourLimitAvailable, + bool startWithWindowsEnabled, + IActivityHookSetupService activityHookSetupService, + ICodexLauncher codexLauncher, + AccentPalette accentPalette = AccentPalette.Blue, + IWindowWorkAreaProvider? workAreaProvider = null) + { + _workAreaProvider = workAreaProvider ?? new WindowWorkAreaProvider(); + InitializeComponent(); + ActivityDotsHost.Content = new ActivityHookSetupControl( + activityHookSetupService, + codexLauncher); + _suppressChangeEvents = true; + SetSelectedTheme(themePreference); + SetSelectedAccentPalette(accentPalette); + SetWidgetDensity(widgetDensity); + SetDisplayedLimitPreference(displayedLimitPreference); + SetFiveHourLimitAvailability(fiveHourLimitAvailable); + SetStartWithWindowsEnabled(startWithWindowsEnabled); + _suppressChangeEvents = false; + } + + public event Action? ThemePreferenceChanged; + + public event Action? AccentPaletteChanged; + + public event Action? WidgetDensityChanged; + + public event Action? DisplayedLimitPreferenceChanged; + + public event Action? StartWithWindowsChanged; + + public ThemePreference SelectedTheme => + LightThemeOption.IsChecked == true + ? ThemePreference.Light + : DarkThemeOption.IsChecked == true + ? ThemePreference.Dark + : ThemePreference.System; + + public AccentPalette SelectedAccentPalette => + VioletAccentOption.IsChecked == true + ? AccentPalette.Violet + : TealAccentOption.IsChecked == true + ? AccentPalette.Teal + : EmeraldAccentOption.IsChecked == true + ? AccentPalette.Emerald + : PinkAccentOption.IsChecked == true + ? AccentPalette.Pink + : AccentPalette.Blue; + + public DisplayedLimitPreference SelectedDisplayedLimit => + WeeklyLimitOption.IsChecked == true + ? DisplayedLimitPreference.Weekly + : MostConstrainedLimitOption.IsChecked == true + ? DisplayedLimitPreference.MostConstrained + : DisplayedLimitPreference.FiveHour; + + public WidgetDensity SelectedWidgetDensity => + DetailedLayoutOption.IsChecked == true + ? WidgetDensity.Detailed + : WidgetDensity.Compact; + + public bool StartWithWindowsEnabled => StartWithWindowsOption.IsChecked == true; + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + var availableHeight = _workAreaProvider.GetAvailableHeightInDips(Owner ?? this); + if (!double.IsFinite(availableHeight) || availableHeight <= 0d) + { + return; + } + + MaxHeight = Math.Min(MaxHeight, availableHeight); + Height = Math.Min(Height, MaxHeight); + } + + public void SetDisplayedLimitPreference(DisplayedLimitPreference preference) + { + var previousSuppression = _suppressChangeEvents; + _suppressChangeEvents = true; + FiveHourLimitOption.IsChecked = preference == DisplayedLimitPreference.FiveHour; + WeeklyLimitOption.IsChecked = preference == DisplayedLimitPreference.Weekly; + MostConstrainedLimitOption.IsChecked = + preference == DisplayedLimitPreference.MostConstrained; + _suppressChangeEvents = previousSuppression; + } + + public void SetFiveHourLimitAvailability(bool available) + { + FiveHourLimitOption.IsEnabled = available; + ToolTipService.SetIsEnabled(FiveHourLimitOption, !available); + } + + public void SetWidgetDensity(WidgetDensity density) + { + var previousSuppression = _suppressChangeEvents; + _suppressChangeEvents = true; + CompactLayoutOption.IsChecked = density == WidgetDensity.Compact; + DetailedLayoutOption.IsChecked = density == WidgetDensity.Detailed; + _suppressChangeEvents = previousSuppression; + } + + public void SetStartWithWindowsEnabled(bool enabled) + { + var previousSuppression = _suppressChangeEvents; + _suppressChangeEvents = true; + StartWithWindowsOption.IsChecked = enabled; + _suppressChangeEvents = previousSuppression; + } + + private void SetSelectedTheme(ThemePreference preference) + { + SystemThemeOption.IsChecked = preference == ThemePreference.System; + LightThemeOption.IsChecked = preference == ThemePreference.Light; + DarkThemeOption.IsChecked = preference == ThemePreference.Dark; + } + + private void SetSelectedAccentPalette(AccentPalette palette) + { + BlueAccentOption.IsChecked = palette == AccentPalette.Blue; + VioletAccentOption.IsChecked = palette == AccentPalette.Violet; + TealAccentOption.IsChecked = palette == AccentPalette.Teal; + EmeraldAccentOption.IsChecked = palette == AccentPalette.Emerald; + PinkAccentOption.IsChecked = palette == AccentPalette.Pink; + } + + private void Header_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ButtonState == MouseButtonState.Pressed) + { + DragMove(); + } + } + + private void ThemeOption_OnChecked(object sender, RoutedEventArgs e) + { + if (!_suppressChangeEvents) + { + ThemePreferenceChanged?.Invoke(SelectedTheme); + } + } + + private void AccentPaletteOption_OnChecked(object sender, RoutedEventArgs e) + { + if (!_suppressChangeEvents) + { + AccentPaletteChanged?.Invoke(SelectedAccentPalette); + } + } + + private void DisplayedLimitOption_OnChecked(object sender, RoutedEventArgs e) + { + if (!_suppressChangeEvents) + { + DisplayedLimitPreferenceChanged?.Invoke(SelectedDisplayedLimit); + } + } + + private void WidgetLayoutOption_OnChecked(object sender, RoutedEventArgs e) + { + if (!_suppressChangeEvents) + { + WidgetDensityChanged?.Invoke(SelectedWidgetDensity); + } + } + + private void StartWithWindowsOption_OnChanged(object sender, RoutedEventArgs e) + { + if (!_suppressChangeEvents) + { + StartWithWindowsChanged?.Invoke(StartWithWindowsEnabled); + } + } +} diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml index 2112643..d0e51a1 100644 --- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml +++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml @@ -19,6 +19,9 @@ + + + - + @@ -64,13 +67,6 @@ Style="{StaticResource TaskbarMenuIcon}" /> - - - - - - + - - - - - - - - - - @@ -124,15 +90,6 @@ Style="{StaticResource TaskbarMenuIcon}" /> - - - - - + Foreground="{DynamicResource TaskbarTextSecondaryBrush}" /> diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs index f16172f..ac576c1 100644 --- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs @@ -3,7 +3,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Interop; -using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Settings; using CodexUsageWidget.Infrastructure.Windows; namespace CodexUsageWidget.Views; @@ -63,16 +63,12 @@ public TaskbarLabelWindow() public event EventHandler? RefreshRequested; - public event EventHandler? ActivityDotsSetupRequested; + public event EventHandler? SettingsRequested; public event EventHandler? ActivityPreviewChanged; public event EventHandler? DesktopModeRequested; - public event Action? DisplayedLimitPreferenceChanged; - - public event EventHandler? StartupToggleRequested; - public event EventHandler? UpdateCheckRequested; public event EventHandler? ExitRequested; @@ -142,19 +138,23 @@ public void SetActivityState(bool isActive) ActivityDots.IsActive = isActive; } - public void SetStartupEnabled(bool enabled) => StartWithWindowsMenuItem.IsChecked = enabled; - - public void SetDisplayedLimitPreference(DisplayedLimitPreference preference) - { - FiveHourLimitMenuItem.IsChecked = preference == DisplayedLimitPreference.FiveHour; - WeeklyLimitMenuItem.IsChecked = preference == DisplayedLimitPreference.Weekly; - MostConstrainedLimitMenuItem.IsChecked = preference == DisplayedLimitPreference.MostConstrained; - } - - public void SetFiveHourLimitAvailability(bool available) + public void SetSystemTheme(EffectiveTheme theme) { - FiveHourLimitMenuItem.IsEnabled = available; - ToolTipService.SetIsEnabled(FiveHourLimitMenuItem, !available); + var light = theme == EffectiveTheme.Light; + var primaryBrush = new System.Windows.Media.SolidColorBrush( + light + ? System.Windows.Media.Color.FromRgb(32, 33, 36) + : System.Windows.Media.Color.FromRgb(242, 242, 242)); + Resources["TaskbarTextPrimaryBrush"] = primaryBrush; + ActivityDots.DotBrush = primaryBrush; + Resources["TaskbarTextSecondaryBrush"] = new System.Windows.Media.SolidColorBrush( + light + ? System.Windows.Media.Color.FromRgb(72, 73, 78) + : System.Windows.Media.Color.FromRgb(212, 212, 212)); + Resources["TaskbarLabelHoverBrush"] = new System.Windows.Media.SolidColorBrush( + light + ? System.Windows.Media.Color.FromArgb(18, 0, 0, 0) + : System.Windows.Media.Color.FromArgb(24, 255, 255, 255)); } public void UpdateUsage( @@ -237,8 +237,8 @@ private void OpenMenuItem_OnClick(object sender, RoutedEventArgs e) => private void RefreshMenuItem_OnClick(object sender, RoutedEventArgs e) => RefreshRequested?.Invoke(this, EventArgs.Empty); - private void ActivityDotsMenuItem_OnClick(object sender, RoutedEventArgs e) => - ActivityDotsSetupRequested?.Invoke(this, EventArgs.Empty); + private void SettingsMenuItem_OnClick(object sender, RoutedEventArgs e) => + SettingsRequested?.Invoke(this, EventArgs.Empty); private void ActivityPreviewMenuItem_OnClick(object sender, RoutedEventArgs e) => ActivityPreviewChanged?.Invoke(this, EventArgs.Empty); @@ -276,18 +276,6 @@ private void CloseTaskbarMenu() private void DesktopModeMenuItem_OnClick(object sender, RoutedEventArgs e) => DesktopModeRequested?.Invoke(this, EventArgs.Empty); - private void FiveHourLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.FiveHour); - - private void WeeklyLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.Weekly); - - private void MostConstrainedLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => - DisplayedLimitPreferenceChanged?.Invoke(DisplayedLimitPreference.MostConstrained); - - private void StartWithWindowsMenuItem_OnClick(object sender, RoutedEventArgs e) => - StartupToggleRequested?.Invoke(this, EventArgs.Empty); - private void CheckForUpdatesMenuItem_OnClick(object sender, RoutedEventArgs e) => UpdateCheckRequested?.Invoke(this, EventArgs.Empty); diff --git a/src/CodexUsageWidget/Views/ViewModels/UsageLimitViewModel.cs b/src/CodexUsageWidget/Views/ViewModels/UsageLimitViewModel.cs index 001b6ea..ac29186 100644 --- a/src/CodexUsageWidget/Views/ViewModels/UsageLimitViewModel.cs +++ b/src/CodexUsageWidget/Views/ViewModels/UsageLimitViewModel.cs @@ -10,6 +10,7 @@ public UsageLimitViewModel(string label, UsageWindow window) { Label = label; UsedPercent = window.UsedPercent; + IsNormal = window.RemainingPercent > 25; UsedText = $"{Math.Round(window.UsedPercent):0}% used"; RemainingText = $"{Math.Round(window.RemainingPercent):0}% remaining"; ResetText = window.ResetsAt is null @@ -24,6 +25,8 @@ public UsageLimitViewModel(string label, UsageWindow window) public double UsedPercent { get; } + public bool IsNormal { get; } + public string UsedText { get; } public string RemainingText { get; } diff --git a/tests/CodexUsageWidget.Tests/AccentPaletteStoreTests.cs b/tests/CodexUsageWidget.Tests/AccentPaletteStoreTests.cs new file mode 100644 index 0000000..dec108f --- /dev/null +++ b/tests/CodexUsageWidget.Tests/AccentPaletteStoreTests.cs @@ -0,0 +1,34 @@ +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Tests; + +public sealed class AccentPaletteStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "CodexUsageWidget.Tests", + Guid.NewGuid().ToString("N")); + + [Theory] + [InlineData(AccentPalette.Blue)] + [InlineData(AccentPalette.Violet)] + [InlineData(AccentPalette.Teal)] + [InlineData(AccentPalette.Emerald)] + [InlineData(AccentPalette.Pink)] + public void SavePersistsPalette(AccentPalette palette) + { + var store = new AccentPaletteStore(Path.Combine(_directory, "accent-palette.txt")); + + store.Save(palette); + + Assert.Equal(palette, store.Load()); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/tests/CodexUsageWidget.Tests/TaskbarSettingsMenuTests.cs b/tests/CodexUsageWidget.Tests/TaskbarSettingsMenuTests.cs index 6bfbe84..4decda7 100644 --- a/tests/CodexUsageWidget.Tests/TaskbarSettingsMenuTests.cs +++ b/tests/CodexUsageWidget.Tests/TaskbarSettingsMenuTests.cs @@ -1,13 +1,18 @@ using System.Runtime.ExceptionServices; using System.Windows.Controls; +using System.Windows.Media; +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Settings; +using CodexUsageWidget.Infrastructure.Windows; using CodexUsageWidget.Views; +using CodexUsageWidget.Views.Controls; namespace CodexUsageWidget.Tests; public sealed class TaskbarSettingsMenuTests { [Fact] - public void SettingsMenuOffersUpdateCheck() + public void SettingsUiOffersExpectedActionsAndThemeChoices() { Exception? failure = null; var thread = new Thread(() => @@ -16,18 +21,230 @@ public void SettingsMenuOffersUpdateCheck() { var application = new App(); application.InitializeComponent(); + var themeTestDirectory = Path.Combine( + Path.GetTempPath(), + "CodexUsageWidget.Tests", + Guid.NewGuid().ToString("N")); + var themeStore = new ThemePreferenceStore( + Path.Combine(themeTestDirectory, "theme.txt")); + themeStore.Save(ThemePreference.Dark); + var accentStore = new AccentPaletteStore( + Path.Combine(themeTestDirectory, "accent.txt")); + accentStore.Save(AccentPalette.Violet); + using var themeController = new AppThemeController( + application, + new ThemePreferenceMonitor(themeStore, new WindowsThemeMonitor()), + accentStore); + try + { var window = new TaskbarLabelWindow(); var menu = Assert.IsType(window.FindName("TaskbarMenu")); + window.SetSystemTheme(EffectiveTheme.Light); + var activityDots = Assert.IsType( + window.FindName("ActivityDots")); + var activityDotBrush = Assert.IsType(activityDots.DotBrush); + Assert.Equal(Color.FromRgb(32, 33, 36), activityDotBrush.Color); + Assert.Contains( menu.Items.OfType(), item => string.Equals( item.Header as string, "Check for updates...", StringComparison.Ordinal)); + Assert.Contains( + menu.Items.OfType(), + item => string.Equals( + item.Header as string, + "Settings...", + StringComparison.Ordinal)); + Assert.DoesNotContain( + menu.Items.OfType(), + item => string.Equals( + item.Header as string, + "Activity dots...", + StringComparison.Ordinal)); + Assert.DoesNotContain( + menu.Items.OfType(), + item => string.Equals( + item.Header as string, + "Displayed limit", + StringComparison.Ordinal)); + Assert.DoesNotContain( + menu.Items.OfType(), + item => string.Equals( + item.Header as string, + "Start with Windows", + StringComparison.Ordinal)); + + var cases = new[] + { + (ThemePreference.System, "SystemThemeOption"), + (ThemePreference.Light, "LightThemeOption"), + (ThemePreference.Dark, "DarkThemeOption") + }; + foreach (var (preference, expectedOptionName) in cases) + { + var settings = new SettingsWindow( + themePreference: preference, + widgetDensity: WidgetDensity.Compact, + displayedLimitPreference: DisplayedLimitPreference.FiveHour, + fiveHourLimitAvailable: true, + startWithWindowsEnabled: false, + activityHookSetupService: new StubActivityHookSetupService(), + codexLauncher: new StubCodexLauncher()); + var option = Assert.IsType( + settings.FindName(expectedOptionName)); + Assert.True(option.IsChecked); + settings.Close(); + } + + var liveSettings = new SettingsWindow( + themePreference: ThemePreference.System, + widgetDensity: WidgetDensity.Compact, + displayedLimitPreference: DisplayedLimitPreference.FiveHour, + fiveHourLimitAvailable: true, + startWithWindowsEnabled: false, + activityHookSetupService: new StubActivityHookSetupService(), + codexLauncher: new StubCodexLauncher()); + ThemePreference? changedPreference = null; + liveSettings.ThemePreferenceChanged += preference => + changedPreference = preference; + + var lightOption = Assert.IsType( + liveSettings.FindName("LightThemeOption")); + lightOption.IsChecked = true; + + Assert.Equal(ThemePreference.Light, changedPreference); + liveSettings.Close(); + + var usageSettings = new SettingsWindow( + themePreference: ThemePreference.System, + widgetDensity: WidgetDensity.Compact, + displayedLimitPreference: DisplayedLimitPreference.Weekly, + fiveHourLimitAvailable: false, + startWithWindowsEnabled: true, + activityHookSetupService: new StubActivityHookSetupService(), + codexLauncher: new StubCodexLauncher(), + accentPalette: AccentPalette.Violet); + Assert.NotNull(usageSettings.FindName("ActivityDotsSection")); + var activityDotsHost = Assert.IsType( + usageSettings.FindName("ActivityDotsHost")); + Assert.IsType(activityDotsHost.Content); + Assert.NotNull(usageSettings.FindName("BlueAccentOption")); + var violetAccentOption = Assert.IsType( + usageSettings.FindName("VioletAccentOption")); + Assert.True(violetAccentOption.IsChecked); + Assert.NotNull(usageSettings.FindName("TealAccentOption")); + Assert.NotNull(usageSettings.FindName("EmeraldAccentOption")); + Assert.NotNull(usageSettings.FindName("PinkAccentOption")); + AccentPalette? changedAccentPalette = null; + usageSettings.AccentPaletteChanged += palette => + changedAccentPalette = palette; + var emeraldAccentOption = Assert.IsType( + usageSettings.FindName("EmeraldAccentOption")); + emeraldAccentOption.IsChecked = true; + + Assert.Equal(AccentPalette.Emerald, changedAccentPalette); + var weeklyOption = Assert.IsType( + usageSettings.FindName("WeeklyLimitOption")); + var fiveHourOption = Assert.IsType( + usageSettings.FindName("FiveHourLimitOption")); + Assert.True(weeklyOption.IsChecked); + Assert.False(fiveHourOption.IsEnabled); + + DisplayedLimitPreference? changedLimit = null; + usageSettings.DisplayedLimitPreferenceChanged += preference => + changedLimit = preference; + var mostConstrainedOption = Assert.IsType( + usageSettings.FindName("MostConstrainedLimitOption")); + mostConstrainedOption.IsChecked = true; + + Assert.Equal(DisplayedLimitPreference.MostConstrained, changedLimit); + + var startupOption = Assert.IsType( + usageSettings.FindName("StartWithWindowsOption")); + Assert.True(startupOption.IsChecked); + bool? changedStartupState = null; + usageSettings.StartWithWindowsChanged += enabled => + changedStartupState = enabled; + startupOption.IsChecked = false; + + Assert.False(changedStartupState); + + var compactLayoutOption = Assert.IsType( + usageSettings.FindName("CompactLayoutOption")); + Assert.True(compactLayoutOption.IsChecked); + WidgetDensity? changedDensity = null; + usageSettings.WidgetDensityChanged += density => + changedDensity = density; + var detailedLayoutOption = Assert.IsType( + usageSettings.FindName("DetailedLayoutOption")); + detailedLayoutOption.IsChecked = true; + + Assert.Equal(WidgetDensity.Detailed, changedDensity); + usageSettings.Close(); + + var constrainedSettings = new SettingsWindow( + themePreference: ThemePreference.System, + widgetDensity: WidgetDensity.Compact, + displayedLimitPreference: DisplayedLimitPreference.FiveHour, + fiveHourLimitAvailable: true, + startWithWindowsEnabled: false, + activityHookSetupService: new StubActivityHookSetupService(), + codexLauncher: new StubCodexLauncher(), + workAreaProvider: new StubWindowWorkAreaProvider(600d)); + constrainedSettings.Show(); + try + { + Assert.Equal(600d, constrainedSettings.Height); + } + finally + { + constrainedSettings.Close(); + } + + var accentButton = new AccentButton + { + Content = "Accent action" + }; + accentButton.Style = Assert.IsType( + application.FindResource("PrimaryDialogButton")); + accentButton.ApplyTemplate(); + Assert.Equal( + Color.FromRgb(124, 58, 237), + Assert.IsType(accentButton.PrimaryBrush).Color); + + themeController.SetAccentPalette(AccentPalette.Emerald); + accentButton.RaiseEvent( + new System.Windows.RoutedEventArgs( + System.Windows.FrameworkElement.LoadedEvent)); + try + { + Assert.Equal( + Color.FromRgb(29, 145, 72), + Assert.IsType(accentButton.PrimaryBrush).Color); + Assert.Equal( + Color.FromRgb(29, 145, 72), + GetButtonSurfaceColor(accentButton)); + } + finally + { + accentButton.RaiseEvent( + new System.Windows.RoutedEventArgs( + System.Windows.FrameworkElement.UnloadedEvent)); + } window.Close(); application.Shutdown(); + } + finally + { + if (Directory.Exists(themeTestDirectory)) + { + Directory.Delete(themeTestDirectory, recursive: true); + } + } } catch (Exception ex) { @@ -44,4 +261,37 @@ public void SettingsMenuOffersUpdateCheck() ExceptionDispatchInfo.Capture(failure).Throw(); } } + + private static Color GetButtonSurfaceColor(Button button) + { + var surface = Assert.IsType(button.Template.FindName("ButtonSurface", button)); + return Assert.IsType(surface.Background).Color; + } + + private sealed class StubActivityHookSetupService : IActivityHookSetupService + { + public Task GetStatusAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(new ActivityHookSetupStatus(ActivityHookSetupState.Active)); + + public ActivityHookChangePreview PrepareChange(ActivityHookChangeKind kind) => + new(kind, HasChanges: false, ProposedContent: string.Empty); + + public void ApplyChange(ActivityHookChangePreview preview) + { + } + } + + private sealed class StubCodexLauncher : ICodexLauncher + { + public void OpenInteractive() + { + } + } + + private sealed class StubWindowWorkAreaProvider(double availableHeight) : IWindowWorkAreaProvider + { + public double GetAvailableHeightInDips(System.Windows.Window referenceWindow) => + availableHeight; + } } diff --git a/tests/CodexUsageWidget.Tests/ThemePreferenceResolverTests.cs b/tests/CodexUsageWidget.Tests/ThemePreferenceResolverTests.cs new file mode 100644 index 0000000..a98f20c --- /dev/null +++ b/tests/CodexUsageWidget.Tests/ThemePreferenceResolverTests.cs @@ -0,0 +1,21 @@ +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Tests; + +public sealed class ThemePreferenceResolverTests +{ + [Theory] + [InlineData(ThemePreference.System, true, EffectiveTheme.Light)] + [InlineData(ThemePreference.System, false, EffectiveTheme.Dark)] + [InlineData(ThemePreference.Light, false, EffectiveTheme.Light)] + [InlineData(ThemePreference.Dark, true, EffectiveTheme.Dark)] + public void ResolveUsesPreferenceOrAppTheme( + ThemePreference preference, + bool appsUseLightTheme, + EffectiveTheme expected) + { + Assert.Equal( + expected, + ThemePreferenceResolver.Resolve(preference, appsUseLightTheme)); + } +} diff --git a/tests/CodexUsageWidget.Tests/ThemePreferenceStoreTests.cs b/tests/CodexUsageWidget.Tests/ThemePreferenceStoreTests.cs new file mode 100644 index 0000000..add5fa6 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/ThemePreferenceStoreTests.cs @@ -0,0 +1,40 @@ +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Tests; + +public sealed class ThemePreferenceStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "CodexUsageWidget.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void LoadUsesSystemWhenPreferenceDoesNotExist() + { + var store = new ThemePreferenceStore(Path.Combine(_directory, "theme.txt")); + + Assert.Equal(ThemePreference.System, store.Load()); + } + + [Theory] + [InlineData(ThemePreference.System)] + [InlineData(ThemePreference.Light)] + [InlineData(ThemePreference.Dark)] + public void SavePersistsPreference(ThemePreference preference) + { + var store = new ThemePreferenceStore(Path.Combine(_directory, "theme.txt")); + + store.Save(preference); + + Assert.Equal(preference, store.Load()); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/tests/CodexUsageWidget.Tests/WindowsThemeMonitorTests.cs b/tests/CodexUsageWidget.Tests/WindowsThemeMonitorTests.cs new file mode 100644 index 0000000..e7589d4 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/WindowsThemeMonitorTests.cs @@ -0,0 +1,29 @@ +using CodexUsageWidget.Infrastructure.Windows; +using Microsoft.Win32; + +namespace CodexUsageWidget.Tests; + +public sealed class WindowsThemeMonitorTests : IDisposable +{ + private readonly string _keyPath = $@"Software\CodexUsageWidget.Tests\{Guid.NewGuid():N}"; + + [Fact] + public void ReadsAppAndWindowsThemeSettingsIndependently() + { + using (var key = Registry.CurrentUser.CreateSubKey(_keyPath)) + { + key.SetValue("AppsUseLightTheme", 1, RegistryValueKind.DWord); + key.SetValue("SystemUsesLightTheme", 0, RegistryValueKind.DWord); + } + + using var monitor = new WindowsThemeMonitor(_keyPath); + + Assert.True(monitor.UsesLightAppTheme); + Assert.False(monitor.UsesLightSystemTheme); + } + + public void Dispose() + { + Registry.CurrentUser.DeleteSubKeyTree(_keyPath, throwOnMissingSubKey: false); + } +}