From 480b291f65461159e3c081110e1b68d22030963c Mon Sep 17 00:00:00 2001 From: ognjeeen Date: Wed, 26 Aug 2026 16:04:55 +0200 Subject: [PATCH 1/4] feat: add selectable taskbar usage limits --- README.md | 2 + src/CodexUsageWidget/App.xaml.cs | 1 + .../Application/TaskbarLimitPreference.cs | 8 ++ .../Application/TaskbarUsageSelector.cs | 39 ++++++ .../Infrastructure/AppPaths.cs | 4 + .../Settings/TaskbarLimitPreferenceStore.cs | 57 +++++++++ .../Infrastructure/Windows/TrayIconService.cs | 46 ++++++- .../Views/ActivityHookSetupWindow.xaml.cs | 30 ++++- src/CodexUsageWidget/Views/MainWindow.xaml.cs | 51 +++++++- .../Views/Resources/TaskbarMenuTheme.xaml | 120 ++++++++++++------ .../Views/TaskbarLabelWindow.xaml | 36 ++++++ .../Views/TaskbarLabelWindow.xaml.cs | 36 +++++- .../TaskbarLimitPreferenceStoreTests.cs | 43 +++++++ .../TaskbarUsageSelectorTests.cs | 109 ++++++++++++++++ 14 files changed, 530 insertions(+), 52 deletions(-) create mode 100644 src/CodexUsageWidget/Application/TaskbarLimitPreference.cs create mode 100644 src/CodexUsageWidget/Application/TaskbarUsageSelector.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Settings/TaskbarLimitPreferenceStore.cs create mode 100644 tests/CodexUsageWidget.Tests/TaskbarLimitPreferenceStoreTests.cs create mode 100644 tests/CodexUsageWidget.Tests/TaskbarUsageSelectorTests.cs diff --git a/README.md b/README.md index 7e9fd61..d26924e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ notifications. - Credit, spend-control, earned-reset, and model-specific limit details when available - Compact, movable, always-on-top desktop widget - Native-looking taskbar label beside the Windows notification area +- Selectable taskbar limit with a 5-hour default and automatic fallback - Event-driven task activity animation through official local Codex lifecycle hooks - Immediate taskbar-label hiding while another app is fullscreen on the same monitor - Persistent desktop/taskbar display preference @@ -193,6 +194,7 @@ The application only writes under `%LOCALAPPDATA%\CodexUsageWidget`: - `app\\CodexUsageWidget.exe` — stable copy used after a direct ZIP launch - `display-mode.txt` — the selected display mode - `widget-density.txt` — the selected compact or detailed widget layout +- `taskbar-limit.txt` — the selected limit shown in the taskbar label - `logs\codex-usage-widget-YYYYMMDD.log` — diagnostics, retained for 14 days No credentials are read or stored by the widget. Authentication remains owned by diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index de02dac..ee10742 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -61,6 +61,7 @@ protected override void OnStartup(StartupEventArgs e) new CodexCliLauncher(), new DisplayModeStore(), new WidgetDensityStore(), + new TaskbarLimitPreferenceStore(), startupRegistrationService, new TrayIconService()); MainWindow = window; diff --git a/src/CodexUsageWidget/Application/TaskbarLimitPreference.cs b/src/CodexUsageWidget/Application/TaskbarLimitPreference.cs new file mode 100644 index 0000000..9d6e12f --- /dev/null +++ b/src/CodexUsageWidget/Application/TaskbarLimitPreference.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public enum TaskbarLimitPreference +{ + FiveHour, + Weekly, + MostConstrained +} diff --git a/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs b/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs new file mode 100644 index 0000000..bb9e7a6 --- /dev/null +++ b/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs @@ -0,0 +1,39 @@ +using CodexUsageWidget.Domain; + +namespace CodexUsageWidget.Application; + +public static class TaskbarUsageSelector +{ + private const long FiveHourDurationMinutes = 300; + private const long WeeklyDurationThresholdMinutes = 10_000; + + public static bool IsAvailable( + UsageSnapshot snapshot, + TaskbarLimitPreference preference) => preference switch + { + TaskbarLimitPreference.FiveHour => snapshot.GeneralWindows.Any( + window => window.WindowDurationMinutes == FiveHourDurationMinutes), + TaskbarLimitPreference.Weekly => snapshot.GeneralWindows.Any( + window => window.WindowDurationMinutes >= WeeklyDurationThresholdMinutes), + TaskbarLimitPreference.MostConstrained => snapshot.MostConstrainedWindow is not null, + _ => false + }; + + public static UsageWindow? Select( + UsageSnapshot snapshot, + TaskbarLimitPreference preference) + { + var fiveHour = snapshot.GeneralWindows.FirstOrDefault( + window => window.WindowDurationMinutes == FiveHourDurationMinutes); + var weekly = snapshot.GeneralWindows.FirstOrDefault( + window => window.WindowDurationMinutes >= WeeklyDurationThresholdMinutes); + + return preference switch + { + TaskbarLimitPreference.FiveHour => fiveHour ?? weekly ?? snapshot.MostConstrainedWindow, + TaskbarLimitPreference.Weekly => weekly ?? fiveHour ?? snapshot.MostConstrainedWindow, + TaskbarLimitPreference.MostConstrained => snapshot.MostConstrainedWindow, + _ => fiveHour ?? weekly ?? snapshot.MostConstrainedWindow + }; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/AppPaths.cs b/src/CodexUsageWidget/Infrastructure/AppPaths.cs index 8eeec42..523323d 100644 --- a/src/CodexUsageWidget/Infrastructure/AppPaths.cs +++ b/src/CodexUsageWidget/Infrastructure/AppPaths.cs @@ -12,5 +12,9 @@ public static class AppPaths public static string WidgetDensityFile => Path.Combine(LocalDataDirectory, "widget-density.txt"); + public static string TaskbarLimitPreferenceFile => Path.Combine( + LocalDataDirectory, + "taskbar-limit.txt"); + public static string LogDirectory => Path.Combine(LocalDataDirectory, "logs"); } diff --git a/src/CodexUsageWidget/Infrastructure/Settings/TaskbarLimitPreferenceStore.cs b/src/CodexUsageWidget/Infrastructure/Settings/TaskbarLimitPreferenceStore.cs new file mode 100644 index 0000000..e8dbbc3 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Settings/TaskbarLimitPreferenceStore.cs @@ -0,0 +1,57 @@ +using System.IO; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Settings; + +public sealed class TaskbarLimitPreferenceStore +{ + private readonly string _path; + + public TaskbarLimitPreferenceStore(string? path = null) + { + _path = path ?? AppPaths.TaskbarLimitPreferenceFile; + } + + public TaskbarLimitPreference Load() + { + try + { + return File.ReadAllText(_path).Trim().ToLowerInvariant() switch + { + "weekly" => TaskbarLimitPreference.Weekly, + "most-constrained" => TaskbarLimitPreference.MostConstrained, + _ => TaskbarLimitPreference.FiveHour + }; + } + catch (IOException) + { + return TaskbarLimitPreference.FiveHour; + } + catch (UnauthorizedAccessException) + { + return TaskbarLimitPreference.FiveHour; + } + } + + public void Save(TaskbarLimitPreference preference) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText( + _path, + preference switch + { + TaskbarLimitPreference.Weekly => "weekly", + TaskbarLimitPreference.MostConstrained => "most-constrained", + _ => "five-hour" + }); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index 10bbc15..127e8ba 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -1,3 +1,4 @@ +using CodexUsageWidget.Application; using CodexUsageWidget.Infrastructure.Settings; using Forms = System.Windows.Forms; @@ -5,16 +6,25 @@ 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.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; public TrayIconService() { - var menu = new Forms.ContextMenuStrip(); + var menu = new Forms.ContextMenuStrip + { + ShowItemToolTips = true + }; menu.Items.Add("Open", null, (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add("Refresh", null, (_, _) => RefreshRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add( @@ -35,6 +45,25 @@ public TrayIconService() displayModeMenu.DropDownItems.Add(_desktopWidgetModeItem); displayModeMenu.DropDownItems.Add(_taskbarIndicatorModeItem); menu.Items.Add(displayModeMenu); + + var taskbarLimitMenu = new Forms.ToolStripMenuItem("Taskbar limit"); + _fiveHourLimitItem = new Forms.ToolStripMenuItem("5h limit") + { + Enabled = false, + ToolTipText = FiveHourUnavailableText + }; + _fiveHourLimitItem.Click += (_, _) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.FiveHour); + _weeklyLimitItem = new Forms.ToolStripMenuItem("Weekly limit"); + _weeklyLimitItem.Click += (_, _) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.Weekly); + _mostConstrainedLimitItem = new Forms.ToolStripMenuItem("Most constrained"); + _mostConstrainedLimitItem.Click += (_, _) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.MostConstrained); + taskbarLimitMenu.DropDownItems.Add(_fiveHourLimitItem); + taskbarLimitMenu.DropDownItems.Add(_weeklyLimitItem); + taskbarLimitMenu.DropDownItems.Add(_mostConstrainedLimitItem); + menu.Items.Add(taskbarLimitMenu); _startWithWindowsItem = new Forms.ToolStripMenuItem("Start with Windows") { CheckOnClick = true @@ -66,6 +95,8 @@ public TrayIconService() public event EventHandler? TaskbarModeRequested; + public event Action? TaskbarLimitPreferenceChanged; + public event EventHandler? StartupToggleRequested; public event EventHandler? ExitRequested; @@ -78,6 +109,19 @@ public void SetDisplayMode(WidgetDisplayMode mode) public void SetStartupEnabled(bool enabled) => _startWithWindowsItem.Checked = enabled; + public void SetTaskbarLimitPreference(TaskbarLimitPreference preference) + { + _fiveHourLimitItem.Checked = preference == TaskbarLimitPreference.FiveHour; + _weeklyLimitItem.Checked = preference == TaskbarLimitPreference.Weekly; + _mostConstrainedLimitItem.Checked = preference == TaskbarLimitPreference.MostConstrained; + } + + public void SetFiveHourLimitAvailability(bool available) + { + _fiveHourLimitItem.Enabled = available; + _fiveHourLimitItem.ToolTipText = available ? string.Empty : FiveHourUnavailableText; + } + public void UpdateUsage(double? remainingPercent) { _notifyIcon.Text = remainingPercent is null diff --git a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs index 1e90ea0..617bf8e 100644 --- a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs @@ -20,6 +20,7 @@ public partial class ActivityHookSetupWindow : Window private readonly CancellationTokenSource _lifetime = new(); private bool _refreshInProgress; private bool _refreshTrustOnActivation; + private bool _reviewDialogOpen; public ActivityHookSetupWindow( IActivityHookSetupService setupService, @@ -33,6 +34,7 @@ public ActivityHookSetupWindow( DataContext = ActivityHookSetupViewModel.Loading(); Loaded += ActivityHookSetupWindowOnLoaded; Activated += ActivityHookSetupWindowOnActivated; + Deactivated += ActivityHookSetupWindowOnDeactivated; Closed += ActivityHookSetupWindowOnClosed; } @@ -55,14 +57,24 @@ private async void ActivityHookSetupWindowOnActivated(object? sender, EventArgs { if (_refreshTrustOnActivation && !_refreshInProgress) { + _refreshTrustOnActivation = false; await RefreshStatusAsync(); } } + private void ActivityHookSetupWindowOnDeactivated(object? sender, EventArgs e) + { + if (!_reviewDialogOpen && !_refreshTrustOnActivation) + { + Close(); + } + } + private void ActivityHookSetupWindowOnClosed(object? sender, EventArgs e) { SizeChanged -= ActivityHookSetupWindowOnSizeChanged; Activated -= ActivityHookSetupWindowOnActivated; + Deactivated -= ActivityHookSetupWindowOnDeactivated; _lifetime.Cancel(); _lifetime.Dispose(); } @@ -90,7 +102,10 @@ private async Task RefreshStatusAsync() _refreshTrustOnActivation = false; } } - catch (OperationCanceledException) when (!_lifetime.IsCancellationRequested) + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (OperationCanceledException) { DataContext = ActivityHookSetupViewModel.Error( "Codex did not report hook status in time. Check that the CLI is available, then try again."); @@ -136,7 +151,18 @@ private async Task ReviewAndApplyAsync(ActivityHookChangeKind kind) } var reviewWindow = new ActivityHookChangeReviewWindow(preview) { Owner = this }; - if (reviewWindow.ShowDialog() != true) + bool accepted; + _reviewDialogOpen = true; + try + { + accepted = reviewWindow.ShowDialog() == true; + } + finally + { + _reviewDialogOpen = false; + } + + if (!accepted) { return; } diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs index ec53955..4a57d7a 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs @@ -23,6 +23,7 @@ public partial class MainWindow : Window private readonly ActivityHookSetupWindowController _activityHookSetupWindows; private readonly DisplayModeStore _displayModeStore; private readonly WidgetDensityStore _densityStore; + private readonly TaskbarLimitPreferenceStore _taskbarLimitPreferenceStore; private readonly StartupRegistrationService _startupRegistration; private readonly TrayIconService _trayIcon; private readonly TaskbarLabelWindow _taskbarLabel = new(); @@ -30,6 +31,8 @@ public partial class MainWindow : Window private readonly MainWindowCloseState _closeState = new(); private WidgetDisplayMode _displayMode; private WidgetDensity _density; + private TaskbarLimitPreference _taskbarLimitPreference; + private UsageSnapshot? _latestSnapshot; private UsageWidgetViewModel _viewModel = UsageWidgetViewModel.Loading(); private bool _isRealActivityActive; private bool _isActivityPreviewEnabled; @@ -42,6 +45,7 @@ public MainWindow( ICodexLauncher codexLauncher, DisplayModeStore displayModeStore, WidgetDensityStore densityStore, + TaskbarLimitPreferenceStore taskbarLimitPreferenceStore, StartupRegistrationService startupRegistration, TrayIconService trayIcon) { @@ -53,10 +57,12 @@ public MainWindow( codexLauncher); _displayModeStore = displayModeStore; _densityStore = densityStore; + _taskbarLimitPreferenceStore = taskbarLimitPreferenceStore; _startupRegistration = startupRegistration; _trayIcon = trayIcon; _displayMode = displayModeStore.Load(); _density = densityStore.Load(); + _taskbarLimitPreference = taskbarLimitPreferenceStore.Load(); _widgetVisibility = new WidgetVisibilityController(() => IsVisible, ShowWidget, Hide); InitializeComponent(); @@ -98,6 +104,8 @@ private void WireEvents() _isActivityPreviewEnabled = _taskbarLabel.IsActivityPreviewEnabled; ApplyActivityIndicatorState(); }); + _taskbarLabel.TaskbarLimitPreferenceChanged += preference => + Dispatcher.BeginInvoke(() => SetTaskbarLimitPreference(preference)); _taskbarLabel.DesktopModeRequested += (_, _) => Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget)); _taskbarLabel.StartupToggleRequested += (_, _) => @@ -113,6 +121,8 @@ private void WireEvents() Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget)); _trayIcon.TaskbarModeRequested += (_, _) => Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.TaskbarIndicator)); + _trayIcon.TaskbarLimitPreferenceChanged += preference => + Dispatcher.BeginInvoke(() => SetTaskbarLimitPreference(preference)); _trayIcon.StartupToggleRequested += (_, _) => Dispatcher.BeginInvoke(ToggleStartupRegistration); _trayIcon.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication); @@ -122,6 +132,7 @@ private async void MainWindowOnLoaded(object sender, RoutedEventArgs e) { PositionNearWorkAreaEdge(); _trayIcon.SetDisplayMode(_displayMode); + SetTaskbarLimitPreferenceState(_taskbarLimitPreference); SetStartupRegistrationState(_startupRegistration.IsEnabled); if (_displayMode == WidgetDisplayMode.TaskbarIndicator) { @@ -156,6 +167,7 @@ private void ApplyActivityIndicatorState() private void RenderSnapshot(UsageSnapshot snapshot) { + _latestSnapshot = snapshot; var nextViewModel = UsageWidgetViewModel.FromSnapshot(snapshot); SetViewModel(nextViewModel); if (_density == WidgetDensity.Detailed) @@ -168,16 +180,47 @@ private void RenderSnapshot(UsageSnapshot snapshot) } _trayIcon.UpdateUsage(nextViewModel.HeadlineRemainingPercent); - _taskbarLabel.UpdateUsage( - nextViewModel.HeadlineRemainingPercent, - nextViewModel.HeadlineResetsAt); + UpdateTaskbarUsage(snapshot); } private void RenderError(string message) { SetViewModel(UsageWidgetViewModel.Error(message)); _trayIcon.UpdateUsage(null); - _taskbarLabel.UpdateUsage(null, null); + _taskbarLabel.UpdateUsage(null, null, null); + } + + private void SetTaskbarLimitPreference(TaskbarLimitPreference preference) + { + _taskbarLimitPreference = preference; + _taskbarLimitPreferenceStore.Save(preference); + SetTaskbarLimitPreferenceState(preference); + + if (_latestSnapshot is { } snapshot) + { + UpdateTaskbarUsage(snapshot); + } + } + + private void SetTaskbarLimitPreferenceState(TaskbarLimitPreference preference) + { + _taskbarLabel.SetTaskbarLimitPreference(preference); + _trayIcon.SetTaskbarLimitPreference(preference); + } + + private void UpdateTaskbarUsage(UsageSnapshot snapshot) + { + var fiveHourAvailable = TaskbarUsageSelector.IsAvailable( + snapshot, + TaskbarLimitPreference.FiveHour); + _taskbarLabel.SetFiveHourLimitAvailability(fiveHourAvailable); + _trayIcon.SetFiveHourLimitAvailability(fiveHourAvailable); + + var selected = TaskbarUsageSelector.Select(snapshot, _taskbarLimitPreference); + _taskbarLabel.UpdateUsage( + selected?.Label, + selected?.RemainingPercent, + selected?.ResetsAt); } private void SetViewModel(UsageWidgetViewModel viewModel) diff --git a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml index 4ce7228..f5308ea 100644 --- a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml +++ b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml @@ -48,55 +48,88 @@ - - - - - - - + + + + + + + + - - - - + + + + + + + + + + - - + + @@ -110,6 +143,9 @@ + + + diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml index e402bc6..264613a 100644 --- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml +++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml @@ -75,6 +75,42 @@ Style="{StaticResource TaskbarMenuIcon}" /> + + + + + + + + + + + + + + diff --git a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs index c64ee93..563cdb7 100644 --- a/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/TaskbarLabelWindow.xaml.cs @@ -1,6 +1,8 @@ using System.Windows; +using System.Windows.Controls; using System.Windows.Input; using System.Windows.Interop; +using CodexUsageWidget.Application; using CodexUsageWidget.Infrastructure.Windows; namespace CodexUsageWidget.Views; @@ -62,6 +64,8 @@ public TaskbarLabelWindow() public event EventHandler? DesktopModeRequested; + public event Action? TaskbarLimitPreferenceChanged; + public event EventHandler? StartupToggleRequested; public event EventHandler? ExitRequested; @@ -111,7 +115,23 @@ public void SetActivityState(bool isActive) public void SetStartupEnabled(bool enabled) => StartWithWindowsMenuItem.IsChecked = enabled; - public void UpdateUsage(double? remainingPercent, DateTimeOffset? resetsAt) + public void SetTaskbarLimitPreference(TaskbarLimitPreference preference) + { + FiveHourLimitMenuItem.IsChecked = preference == TaskbarLimitPreference.FiveHour; + WeeklyLimitMenuItem.IsChecked = preference == TaskbarLimitPreference.Weekly; + MostConstrainedLimitMenuItem.IsChecked = preference == TaskbarLimitPreference.MostConstrained; + } + + public void SetFiveHourLimitAvailability(bool available) + { + FiveHourLimitMenuItem.IsEnabled = available; + ToolTipService.SetIsEnabled(FiveHourLimitMenuItem, !available); + } + + public void UpdateUsage( + string? limitLabel, + double? remainingPercent, + DateTimeOffset? resetsAt) { if (remainingPercent is null) { @@ -122,9 +142,10 @@ public void UpdateUsage(double? remainingPercent, DateTimeOffset? resetsAt) var value = Math.Round(Math.Clamp(remainingPercent.Value, 0d, 100d)); UsageText.Text = $"{value:0}%"; + var label = string.IsNullOrWhiteSpace(limitLabel) ? "Codex" : limitLabel; LabelSurface.ToolTip = resetsAt is null - ? $"Codex: {value:0}% remaining" - : $"Codex: {value:0}% remaining · resets {resetsAt.Value:ddd HH:mm}"; + ? $"{label}: {value:0}% remaining" + : $"{label}: {value:0}% remaining · resets {resetsAt.Value:ddd HH:mm}"; } private void Reposition() @@ -196,6 +217,15 @@ private void ActivityPreviewMenuItem_OnClick(object sender, RoutedEventArgs e) private void DesktopModeMenuItem_OnClick(object sender, RoutedEventArgs e) => DesktopModeRequested?.Invoke(this, EventArgs.Empty); + private void FiveHourLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.FiveHour); + + private void WeeklyLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.Weekly); + + private void MostConstrainedLimitMenuItem_OnClick(object sender, RoutedEventArgs e) => + TaskbarLimitPreferenceChanged?.Invoke(TaskbarLimitPreference.MostConstrained); + private void StartWithWindowsMenuItem_OnClick(object sender, RoutedEventArgs e) => StartupToggleRequested?.Invoke(this, EventArgs.Empty); diff --git a/tests/CodexUsageWidget.Tests/TaskbarLimitPreferenceStoreTests.cs b/tests/CodexUsageWidget.Tests/TaskbarLimitPreferenceStoreTests.cs new file mode 100644 index 0000000..42adf77 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/TaskbarLimitPreferenceStoreTests.cs @@ -0,0 +1,43 @@ +using CodexUsageWidget.Application; +using CodexUsageWidget.Infrastructure.Settings; + +namespace CodexUsageWidget.Tests; + +public sealed class TaskbarLimitPreferenceStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "CodexUsageWidget.Tests", + Guid.NewGuid().ToString("N")); + + [Fact] + public void LoadUsesFiveHourWhenPreferenceDoesNotExist() + { + var store = new TaskbarLimitPreferenceStore( + Path.Combine(_directory, "taskbar-limit.txt")); + + Assert.Equal(TaskbarLimitPreference.FiveHour, store.Load()); + } + + [Theory] + [InlineData(TaskbarLimitPreference.FiveHour)] + [InlineData(TaskbarLimitPreference.Weekly)] + [InlineData(TaskbarLimitPreference.MostConstrained)] + public void SavePersistsPreference(TaskbarLimitPreference preference) + { + var store = new TaskbarLimitPreferenceStore( + Path.Combine(_directory, "taskbar-limit.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/TaskbarUsageSelectorTests.cs b/tests/CodexUsageWidget.Tests/TaskbarUsageSelectorTests.cs new file mode 100644 index 0000000..96f82e3 --- /dev/null +++ b/tests/CodexUsageWidget.Tests/TaskbarUsageSelectorTests.cs @@ -0,0 +1,109 @@ +using CodexUsageWidget.Application; +using CodexUsageWidget.Domain; + +namespace CodexUsageWidget.Tests; + +public sealed class TaskbarUsageSelectorTests +{ + [Fact] + public void FiveHourPreferenceSelectsFiveHourWindowWhenWeeklyIsMoreConstrained() + { + var fiveHour = new UsageWindow("5h limit", 20, 300, DateTimeOffset.Now.AddHours(2)); + var weekly = new UsageWindow("Weekly limit", 85, 10_080, DateTimeOffset.Now.AddDays(2)); + var snapshot = CreateSnapshot(fiveHour, weekly); + + var selected = TaskbarUsageSelector.Select(snapshot, TaskbarLimitPreference.FiveHour); + + Assert.Same(fiveHour, selected); + } + + [Fact] + public void FiveHourPreferenceFallsBackToWeeklyWhenFiveHourWindowIsUnavailable() + { + var weekly = new UsageWindow("Weekly limit", 40, 10_080, DateTimeOffset.Now.AddDays(2)); + var snapshot = CreateSnapshot(weekly); + + var selected = TaskbarUsageSelector.Select(snapshot, TaskbarLimitPreference.FiveHour); + + Assert.Same(weekly, selected); + } + + [Fact] + public void WeeklyPreferenceSelectsWeeklyWindow() + { + var fiveHour = new UsageWindow("5h limit", 85, 300, DateTimeOffset.Now.AddHours(2)); + var weekly = new UsageWindow("Weekly limit", 20, 10_080, DateTimeOffset.Now.AddDays(2)); + var snapshot = CreateSnapshot(fiveHour, weekly); + + var selected = TaskbarUsageSelector.Select(snapshot, TaskbarLimitPreference.Weekly); + + Assert.Same(weekly, selected); + } + + [Fact] + public void MostConstrainedPreferenceSelectsWindowWithLowestRemainingPercentage() + { + var fiveHour = new UsageWindow("5h limit", 20, 300, DateTimeOffset.Now.AddHours(2)); + var weekly = new UsageWindow("Weekly limit", 85, 10_080, DateTimeOffset.Now.AddDays(2)); + var snapshot = CreateSnapshot(fiveHour, weekly); + + var selected = TaskbarUsageSelector.Select( + snapshot, + TaskbarLimitPreference.MostConstrained); + + Assert.Same(weekly, selected); + } + + [Fact] + public void WeeklyPreferenceFallsBackToFiveHourWhenWeeklyWindowIsUnavailable() + { + var fiveHour = new UsageWindow("5h limit", 20, 300, DateTimeOffset.Now.AddHours(2)); + var snapshot = CreateSnapshot(fiveHour); + + var selected = TaskbarUsageSelector.Select(snapshot, TaskbarLimitPreference.Weekly); + + Assert.Same(fiveHour, selected); + } + + [Fact] + public void DurationPreferenceFallsBackToMostConstrainedAvailableGeneralWindow() + { + var daily = new UsageWindow("1d limit", 75, 1_440, DateTimeOffset.Now.AddHours(4)); + var snapshot = CreateSnapshot(daily); + + var selected = TaskbarUsageSelector.Select(snapshot, TaskbarLimitPreference.FiveHour); + + Assert.Same(daily, selected); + } + + [Fact] + public void FiveHourPreferenceIsUnavailableWhenSnapshotHasNoFiveHourWindow() + { + var weekly = new UsageWindow("Weekly limit", 40, 10_080, DateTimeOffset.Now.AddDays(2)); + var snapshot = CreateSnapshot(weekly); + + var available = TaskbarUsageSelector.IsAvailable( + snapshot, + TaskbarLimitPreference.FiveHour); + + Assert.False(available); + } + + private static UsageSnapshot CreateSnapshot(params UsageWindow[] windows) => new( + new UsageRateLimits( + [ + new UsageLimitBucket( + "codex", + "Codex", + IsGeneral: true, + windows, + Credits: null, + IndividualLimit: null, + ReachedState: null, + SpendControlReached: null) + ], + "pro", + ResetCredits: null), + TokenActivity: null, + DateTimeOffset.Now); +} From 385bcac38654a71ea8e690c89d90ec331bc98c7d Mon Sep 17 00:00:00 2001 From: ognjeeen Date: Thu, 27 Aug 2026 10:42:36 +0200 Subject: [PATCH 2/4] feat: refine widget settings experience --- .../Application/TaskbarUsageSelector.cs | 8 ++ .../Windows/ExternalMouseDownWatcher.cs | 121 ++++++++++++++++++ .../Windows/GitHubReleaseLauncher.cs | 22 ++++ .../Infrastructure/Windows/TrayIconService.cs | 6 + .../Views/ActivityHookSetupWindow.xaml.cs | 21 ++- src/CodexUsageWidget/Views/MainWindow.xaml | 57 ++++++--- src/CodexUsageWidget/Views/MainWindow.xaml.cs | 17 +++ .../Views/Resources/TaskbarMenuTheme.xaml | 24 +--- .../Views/TaskbarLabelWindow.xaml | 16 ++- .../Views/TaskbarLabelWindow.xaml.cs | 62 +++++++++ .../TaskbarSettingsMenuTests.cs | 47 +++++++ .../TaskbarUsageSelectorTests.cs | 13 ++ 12 files changed, 374 insertions(+), 40 deletions(-) create mode 100644 src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs create mode 100644 tests/CodexUsageWidget.Tests/TaskbarSettingsMenuTests.cs diff --git a/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs b/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs index bb9e7a6..502f812 100644 --- a/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs +++ b/src/CodexUsageWidget/Application/TaskbarUsageSelector.cs @@ -7,6 +7,14 @@ public static class TaskbarUsageSelector private const long FiveHourDurationMinutes = 300; private const long WeeklyDurationThresholdMinutes = 10_000; + public static TaskbarLimitPreference ResolvePreference( + UsageSnapshot snapshot, + TaskbarLimitPreference preference) => + preference == TaskbarLimitPreference.FiveHour && + !IsAvailable(snapshot, TaskbarLimitPreference.FiveHour) + ? TaskbarLimitPreference.Weekly + : preference; + public static bool IsAvailable( UsageSnapshot snapshot, TaskbarLimitPreference preference) => preference switch diff --git a/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs b/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs new file mode 100644 index 0000000..3c0c4e8 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/ExternalMouseDownWatcher.cs @@ -0,0 +1,121 @@ +using System.Runtime.InteropServices; + +namespace CodexUsageWidget.Infrastructure.Windows; + +public sealed class ExternalMouseDownWatcher : IDisposable +{ + private const int WhMouseLowLevel = 14; + private const int WmLeftButtonDown = 0x0201; + private const int WmRightButtonDown = 0x0204; + private const int WmMiddleButtonDown = 0x0207; + private const int WmXButtonDown = 0x020B; + + private readonly Action _externalMouseDown; + private readonly MouseHookCallback _callback; + private readonly uint _processId = (uint)Environment.ProcessId; + private IntPtr _hook; + + public ExternalMouseDownWatcher(Action externalMouseDown) + { + ArgumentNullException.ThrowIfNull(externalMouseDown); + + _externalMouseDown = externalMouseDown; + _callback = OnMouseHook; + _hook = SetWindowsHookEx( + WhMouseLowLevel, + _callback, + GetModuleHandle(null), + 0); + } + + public void Dispose() + { + if (_hook != IntPtr.Zero) + { + _ = UnhookWindowsHookEx(_hook); + _hook = IntPtr.Zero; + } + + GC.SuppressFinalize(this); + } + + private IntPtr OnMouseHook(int code, IntPtr message, IntPtr mouseData) + { + if (code >= 0 && IsMouseDown(message.ToInt32())) + { + try + { + var data = Marshal.PtrToStructure(mouseData); + var target = WindowFromPoint(data.Position); + _ = GetWindowThreadProcessId(target, out var targetProcessId); + if (target == IntPtr.Zero || targetProcessId != _processId) + { + _externalMouseDown(); + } + } + catch (Exception) + { + // Exceptions must not escape a native hook callback. + } + } + + return CallNextHookEx(IntPtr.Zero, code, message, mouseData); + } + + private static bool IsMouseDown(int message) => + message is WmLeftButtonDown or + WmRightButtonDown or + WmMiddleButtonDown or + WmXButtonDown; + + [DllImport("user32.dll")] + private static extern IntPtr SetWindowsHookEx( + int hookType, + MouseHookCallback callback, + IntPtr module, + uint threadId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnhookWindowsHookEx(IntPtr hook); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx( + IntPtr hook, + int code, + IntPtr message, + IntPtr mouseData); + + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(NativePoint point); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + IntPtr window, + out uint processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? moduleName); + + private delegate IntPtr MouseHookCallback( + int code, + IntPtr message, + IntPtr mouseData); + + [StructLayout(LayoutKind.Sequential)] + private struct NativePoint + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LowLevelMouseInput + { + public NativePoint Position; + public uint MouseData; + public uint Flags; + public uint Time; + public UIntPtr ExtraInfo; + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs b/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs new file mode 100644 index 0000000..2248a10 --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Windows/GitHubReleaseLauncher.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; + +namespace CodexUsageWidget.Infrastructure.Windows; + +internal static class GitHubReleaseLauncher +{ + private const string LatestReleaseUrl = + "https://github.com/ognjeeen/codex-usage-widget/releases/latest"; + + public static void OpenLatestRelease() + { + var startInfo = new ProcessStartInfo(LatestReleaseUrl) + { + UseShellExecute = true + }; + + if (Process.Start(startInfo) is null) + { + throw new InvalidOperationException("Windows could not open the widget release page."); + } + } +} diff --git a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs index 127e8ba..da2de9c 100644 --- a/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs +++ b/src/CodexUsageWidget/Infrastructure/Windows/TrayIconService.cs @@ -71,6 +71,10 @@ public TrayIconService() _startWithWindowsItem.Click += (_, _) => StartupToggleRequested?.Invoke(this, EventArgs.Empty); menu.Items.Add(_startWithWindowsItem); + menu.Items.Add( + "Check for updates...", + null, + (_, _) => UpdateCheckRequested?.Invoke(this, EventArgs.Empty)); menu.Items.Add(new Forms.ToolStripSeparator()); menu.Items.Add("Exit", null, (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)); @@ -99,6 +103,8 @@ public TrayIconService() public event EventHandler? StartupToggleRequested; + public event EventHandler? UpdateCheckRequested; + public event EventHandler? ExitRequested; public void SetDisplayMode(WidgetDisplayMode mode) diff --git a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs index 617bf8e..dd1b184 100644 --- a/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/ActivityHookSetupWindow.xaml.cs @@ -21,6 +21,7 @@ public partial class ActivityHookSetupWindow : Window private bool _refreshInProgress; private bool _refreshTrustOnActivation; private bool _reviewDialogOpen; + private bool _closeRequested; public ActivityHookSetupWindow( IActivityHookSetupService setupService, @@ -35,6 +36,7 @@ public ActivityHookSetupWindow( Loaded += ActivityHookSetupWindowOnLoaded; Activated += ActivityHookSetupWindowOnActivated; Deactivated += ActivityHookSetupWindowOnDeactivated; + Closing += ActivityHookSetupWindowOnClosing; Closed += ActivityHookSetupWindowOnClosed; } @@ -66,15 +68,19 @@ private void ActivityHookSetupWindowOnDeactivated(object? sender, EventArgs e) { if (!_reviewDialogOpen && !_refreshTrustOnActivation) { - Close(); + RequestClose(); } } + private void ActivityHookSetupWindowOnClosing(object? sender, CancelEventArgs e) => + _closeRequested = true; + private void ActivityHookSetupWindowOnClosed(object? sender, EventArgs e) { SizeChanged -= ActivityHookSetupWindowOnSizeChanged; Activated -= ActivityHookSetupWindowOnActivated; Deactivated -= ActivityHookSetupWindowOnDeactivated; + Closing -= ActivityHookSetupWindowOnClosing; _lifetime.Cancel(); _lifetime.Dispose(); } @@ -131,7 +137,18 @@ private void Header_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) } } - private void CloseButton_OnClick(object sender, RoutedEventArgs e) => Close(); + private void CloseButton_OnClick(object sender, RoutedEventArgs e) => RequestClose(); + + private void RequestClose() + { + if (_closeRequested) + { + return; + } + + _closeRequested = true; + Close(); + } private async void InstallButton_OnClick(object sender, RoutedEventArgs e) => await ReviewAndApplyAsync(ActivityHookChangeKind.Install); diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml b/src/CodexUsageWidget/Views/MainWindow.xaml index b916c98..29ecc72 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml +++ b/src/CodexUsageWidget/Views/MainWindow.xaml @@ -70,20 +70,6 @@ - + + + diff --git a/src/CodexUsageWidget/Views/MainWindow.xaml.cs b/src/CodexUsageWidget/Views/MainWindow.xaml.cs index 4a57d7a..4a61248 100644 --- a/src/CodexUsageWidget/Views/MainWindow.xaml.cs +++ b/src/CodexUsageWidget/Views/MainWindow.xaml.cs @@ -110,6 +110,8 @@ private void WireEvents() Dispatcher.BeginInvoke(() => SetDisplayMode(WidgetDisplayMode.DesktopWidget)); _taskbarLabel.StartupToggleRequested += (_, _) => Dispatcher.BeginInvoke(ToggleStartupRegistration); + _taskbarLabel.UpdateCheckRequested += (_, _) => + Dispatcher.BeginInvoke(GitHubReleaseLauncher.OpenLatestRelease); _taskbarLabel.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication); _trayIcon.OpenRequested += (_, _) => Dispatcher.BeginInvoke(_widgetVisibility.Show); @@ -125,6 +127,8 @@ private void WireEvents() Dispatcher.BeginInvoke(() => SetTaskbarLimitPreference(preference)); _trayIcon.StartupToggleRequested += (_, _) => Dispatcher.BeginInvoke(ToggleStartupRegistration); + _trayIcon.UpdateCheckRequested += (_, _) => + Dispatcher.BeginInvoke(GitHubReleaseLauncher.OpenLatestRelease); _trayIcon.ExitRequested += (_, _) => Dispatcher.BeginInvoke(ExitApplication); } @@ -216,6 +220,16 @@ private void UpdateTaskbarUsage(UsageSnapshot snapshot) _taskbarLabel.SetFiveHourLimitAvailability(fiveHourAvailable); _trayIcon.SetFiveHourLimitAvailability(fiveHourAvailable); + var resolvedPreference = TaskbarUsageSelector.ResolvePreference( + snapshot, + _taskbarLimitPreference); + if (resolvedPreference != _taskbarLimitPreference) + { + _taskbarLimitPreference = resolvedPreference; + _taskbarLimitPreferenceStore.Save(resolvedPreference); + SetTaskbarLimitPreferenceState(resolvedPreference); + } + var selected = TaskbarUsageSelector.Select(snapshot, _taskbarLimitPreference); _taskbarLabel.UpdateUsage( selected?.Label, @@ -379,6 +393,9 @@ private async void RefreshButton_OnClick(object sender, RoutedEventArgs e) => private void DensityButton_OnClick(object sender, RoutedEventArgs e) => ToggleDensity(); + private void SettingsButton_OnClick(object sender, RoutedEventArgs e) => + _taskbarLabel.OpenMenu(SettingsButton); + private void ActivityDotsButton_OnClick(object sender, RoutedEventArgs e) => ShowActivityHookSetup(); diff --git a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml index f5308ea..3694960 100644 --- a/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml +++ b/src/CodexUsageWidget/Views/Resources/TaskbarMenuTheme.xaml @@ -1,12 +1,5 @@ - - - +