Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Account management is optional. After you register a Codex account, the widget s

Open **Codex 계정 관리…** from the widget menu, or double-click the tray icon. The light-themed manager uses one overview and one account list, sorted by the nearest weekly reset. Its default height fits three accounts without scrolling on a sufficiently tall display. Account names appear in the manager and hover details; the compact indicator remains percentages and bars. Hover details separate the current account, the last combined observation with each account reset, and Claude. Hover never requests usage; the combined observation remains available after closing the manager and lasts for the widget process.

Hover details open outside the widget after a short delay and stay in place while you read. New observations appear on the next hover; moving away, dragging, or opening the context menu closes the details. The hover window never takes keyboard focus.

The overview adds the remaining weekly percentages: for example, **168% of 300%** across three accounts. Each account contributes up to 100%; this is an unweighted sum of account percentages, not a shared service limit or a comparison of different plans' absolute quotas. **다음 초기화** identifies the next confirmed reset and that account's remaining amount. The latest reset appears as secondary context; it is not a common deadline for the whole total.

Opening a new manager window starts one sequential usage query for all registered accounts. After that, **전체 갱신** is the only way to refresh the whole observation set. Restoring focus, changing selection, the local five-second UI timer, and the active widget's own polling do not trigger another batch or change this snapshot. Closing the window during a batch cancels the request and waits for safe cleanup. Failure, cancellation, missing/expired weekly data, or changed membership withholds the total and distinguishes the confirmed subtotal from previous values. Cleanup or credential recovery stops the remaining batch.
Expand Down
16 changes: 16 additions & 0 deletions docs/tooltip-stability-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Stable hover details — v1.7.1

The larger v1.7.0 tooltip could be placed over the mouse pointer near the screen edge. This caused repeated widget leave/enter events and tooltip dismissal/reopening. The original UI smoke used an explicitly shown tooltip on an active text box; it did not exercise the widget's actual hover path.

The fix retains the light design and multi-account text, and replaces automatic native tooltip placement with a borderless, nonactivating hover window. It opens after 350 ms outside the widget, ignores mouse input, and retains its content and position for the current hover. Incoming observations are used on the next hover. Leaving, pressing a mouse button, moving or hiding the widget dismisses it. Existing widget topmost maintenance continues using `SWP_NOACTIVATE`.

No account switching, credential storage, query cadence, or aggregate observation rules change.

## Validation

- The actual hover regression reproduced the original pointer overlap before the fix.
- The replacement test holds the pointer at all four screen corners for four seconds each while repeatedly maintaining widget topmost state and updating pending tooltip text. It checks native window bounds, one continuous display, screen fit, pointer/widget separation, and foreground/input focus preservation.
- Context-menu opening dismisses the popup, and hiding the widget cancels a pending hover.
- The full build retains account lifecycle, isolated query/recovery, aggregate observation, and existing widget focus coverage.

The hover test uses only synthetic usage. It briefly moves the cursor and restores it when finished. No real account screenshots or credentials belong in this repository.
10 changes: 4 additions & 6 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@ internal sealed class UsageIndicatorForm : Form
private readonly System.Windows.Forms.Timer _codexStateTimer = new() { Interval = 1_000 };
private readonly UsageToolTip _toolTip = new()
{
InitialDelay = 350,
ReshowDelay = 100,
AutoPopDelay = 30_000,
ShowAlways = true
InitialDelay = 350
};
private readonly ContextMenuStrip _contextMenu = new();
private readonly Font _valueFont = new("Segoe UI", 13f, FontStyle.Bold);
Expand Down Expand Up @@ -118,7 +115,8 @@ public UsageIndicatorForm(bool previewMode, bool openAccounts = false)
_accountTray = new NotifyIcon { Icon = SystemIcons.Application, Text = "Codex 사용량 · 계정 관리", ContextMenuStrip = _contextMenu, Visible = !previewMode };
_accountTray.DoubleClick += (_, _) => ShowAccountManager();
ApplyRoundedRegion();
_toolTip.SetToolTip(this, "Codex 및 Claude 사용량을 불러오는 중…");
_toolTip.SetHoverText("Codex 및 Claude 사용량을 불러오는 중…");
_toolTip.TrackHover(this);

_pollTimer.Tick += async (_, _) => await RefreshUsageAsync();
_codexStateTimer.Tick += (_, _) =>
Expand Down Expand Up @@ -410,7 +408,7 @@ private void UpdateToolTip()
catch (AccountStoreBusyException) { }
catch (Exception) { }
}
_toolTip.SetToolTip(this, BuildTooltipText(_codexSnapshot, _claudeUsage, _codexError, _claudeError,
_toolTip.SetHoverText(BuildTooltipText(_codexSnapshot, _claudeUsage, _codexError, _claudeError,
_showClaude, _combinedUsage, accounts.FirstOrDefault(a => a.IsActive)?.Label));
}

Expand Down
140 changes: 125 additions & 15 deletions src/UsageToolTip.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,86 @@
namespace WeeklyUsageIndicator;

// The native ToolTip window stays non-activating; only text layout and light colors are customized.
internal sealed class UsageToolTip : ToolTip
// A non-activating hover window avoids native tooltip placement under the pointer.
internal sealed class UsageToolTip : IDisposable
{
private readonly Font _font = new("맑은 고딕", 9.5f);
private readonly Font _heading = new("맑은 고딕", 9.5f, FontStyle.Bold);
private float _scale = 1;
internal UsageToolTip()
private HoverWindow? _window;
private readonly System.Windows.Forms.Timer _hoverDelay = new();
private Control? _hoverOwner;
private string _hoverText = "";
private bool _dismissedForVisit;
internal int InitialDelay { get; set; } = 350;
internal bool IsVisible => _window is { Visible: true };
internal Rectangle? VisibleBounds => IsVisible ? _window!.Bounds : null;
internal string? VisibleText => IsVisible ? _window!.Content : null;
internal int ShowCount { get; private set; }

// Keep content changes pending until the next hover. Updating a visible native
// tooltip re-runs its automatic placement and can move it under the pointer.
internal void SetHoverText(string text) => _hoverText = text;
internal void TrackHover(Control owner)
{
OwnerDraw = true; UseAnimation = false; UseFading = false;
Popup += (_, e) =>
{
_scale = (e.AssociatedControl?.DeviceDpi ?? 96) / 96f;
var text = GetToolTip(e.AssociatedControl) ?? "";
using var graphics = e.AssociatedControl?.CreateGraphics();
e.ToolTipSize = Measure(text, _scale, graphics);
};
Draw += (_, e) => Render(e.Graphics, e.Bounds, e.ToolTipText ?? "", _scale);
_hoverOwner = owner;
owner.MouseEnter += BeginVisit;
owner.MouseMove += StartHover;
owner.MouseLeave += EndHover;
owner.MouseDown += DismissHover;
owner.LocationChanged += EndHover;
owner.VisibleChanged += EndHover;
_hoverDelay.Tick += ShowHover;
}
private void BeginVisit(object? sender, EventArgs e)
{
_dismissedForVisit = false;
StartHover(sender, e);
}
private void StartHover(object? sender, EventArgs e)
{
// Windows can retain its MouseEnter state across an owned popup or a
// relocated widget. MouseMove also arms a new hover, without restarting it.
if (_dismissedForVisit || IsVisible || _hoverDelay.Enabled || Control.MouseButtons != MouseButtons.None) return;
_hoverDelay.Interval = Math.Max(1, InitialDelay);
_hoverDelay.Start();
}
private void DismissHover(object? sender, EventArgs e)
{
_dismissedForVisit = true;
EndHover(sender, e);
}
private void EndHover(object? sender, EventArgs e)
{
_hoverDelay.Stop();
_window?.Hide();
}
private void ShowHover(object? sender, EventArgs e)
{
_hoverDelay.Stop();
if (_hoverOwner is not { Visible: true, IsDisposed: false } owner ||
Control.MouseButtons != MouseButtons.None || !owner.ClientRectangle.Contains(owner.PointToClient(Cursor.Position))) return;
using var graphics = owner.CreateGraphics();
var scale = owner.DeviceDpi / 96f;
var size = Measure(_hoverText, scale, graphics);
var position = PlaceOutside(owner.RectangleToScreen(owner.ClientRectangle), size,
Screen.FromControl(owner).WorkingArea, (int)(8 * scale));
_window ??= new HoverWindow(this);
_window.Content = _hoverText;
_window.ContentScale = scale;
_window.Bounds = new Rectangle(position, size);
_window.Show(owner);
NativeWindow.TrySetTopMost(_window.Handle);
_window.Invalidate();
ShowCount++;
}
internal static Point PlaceOutside(Rectangle owner, Size size, Rectangle area, int gap)
{
var x = Math.Clamp(owner.Left, area.Left, Math.Max(area.Left, area.Right - size.Width));
if (owner.Top - gap - size.Height >= area.Top) return new(x, owner.Top - gap - size.Height);
if (owner.Bottom + gap + size.Height <= area.Bottom) return new(x, owner.Bottom + gap);
var y = Math.Clamp(owner.Top, area.Top, Math.Max(area.Top, area.Bottom - size.Height));
if (owner.Right + gap + size.Width <= area.Right) return new(owner.Right + gap, y);
if (owner.Left - gap - size.Width >= area.Left) return new(owner.Left - gap - size.Width, y);
return new(x, owner.Top >= area.Top + area.Height / 2 ? owner.Top - gap - size.Height : owner.Bottom + gap);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep fallback tooltip bounds inside the working area

When the measured tooltip cannot fit completely above, below, left, or right of the widget—such as with full multi-account and Claude details on a small or high-DPI display—the fallback returns an unclamped vertical coordinate. This can place the top or bottom of the window outside the working area, making required usage and reset details unreadable. The fallback should constrain the popup to the working area, resize it, or permit cursor-safe overlap with the widget.

AGENTS.md reference: AGENTS.md:L38-L41

Useful? React with 👍 / 👎.

}
private static bool IsHeading(string line) => line.StartsWith("CODEX", StringComparison.Ordinal) ||
line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal);
Expand Down Expand Up @@ -50,6 +114,52 @@ internal void Render(Graphics graphics, Rectangle bounds, string text, float sca
y += height;
}
}
protected override void Dispose(bool disposing)
{ if (disposing) { _font.Dispose(); _heading.Dispose(); } base.Dispose(disposing); }
public void Dispose()
{
_hoverDelay.Dispose(); _window?.Dispose();
if (_hoverOwner is { } owner)
{
owner.MouseEnter -= BeginVisit; owner.MouseMove -= StartHover;
owner.MouseLeave -= EndHover; owner.MouseDown -= DismissHover;
owner.LocationChanged -= EndHover; owner.VisibleChanged -= EndHover;
}
_font.Dispose(); _heading.Dispose();
}
private sealed class HoverWindow : Form
{
private readonly UsageToolTip _renderer;
internal string Content { get; set; } = "";
internal float ContentScale { get; set; }
internal HoverWindow(UsageToolTip renderer)
{
_renderer = renderer;
Name = "UsageHoverPopup"; Text = "Codex usage hover details";
FormBorderStyle = FormBorderStyle.None; ShowInTaskbar = false; ShowIcon = false;
StartPosition = FormStartPosition.Manual; AutoScaleMode = AutoScaleMode.None;
DoubleBuffered = true;
}
protected override bool ShowWithoutActivation => true;
protected override void OnDpiChanged(DpiChangedEventArgs e)
{
// ShowHover already measures and places the window at the owner's DPI.
e.Cancel = true;
base.OnDpiChanged(e);
}
protected override CreateParams CreateParams
{
get
{
var parameters = base.CreateParams;
parameters.ExStyle |= 0x08000000 | 0x00000080 | 0x00000020; // NOACTIVATE | TOOLWINDOW | TRANSPARENT
return parameters;
}
}
protected override void WndProc(ref Message message)
{
if (message.Msg == 0x0084) { message.Result = new IntPtr(-1); return; } // HTTRANSPARENT
if (message.Msg == 0x0021) { message.Result = new IntPtr(3); return; } // MA_NOACTIVATE
base.WndProc(ref message);
}
protected override void OnPaint(PaintEventArgs e) => _renderer.Render(e.Graphics, ClientRectangle, Content, ContentScale);
}
}
2 changes: 1 addition & 1 deletion src/WeeklyUsageIndicator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<AssemblyName>WeeklyUsageIndicator</AssemblyName>
<RootNamespace>WeeklyUsageIndicator</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>1.7.0</Version>
<Version>1.7.1</Version>
<Deterministic>true</Deterministic>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
Expand Down
5 changes: 3 additions & 2 deletions tests/WeeklyUsageIndicator.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

if (args.Contains("--live-combined-usage-smoke")) { await LiveCombinedUsageSmoke.RunAsync(); return; }

if (args.Contains("--tooltip-ui-smoke")) { await UsageTooltipUiSmoke.RunAsync(); return; }
if (args.Contains("--tooltip-ui-smoke")) { await UsageTooltipHoverSmoke.RunAsync(); return; }
if (args.Contains("--tooltip-hover-smoke")) { await UsageTooltipHoverSmoke.RunAsync(); return; }

var tests = new (string Name, Func<Task> Run)[]
{
Expand All @@ -29,7 +30,7 @@
("combined weekly snapshot coverage and reset boundaries", CombinedUsageTests.RunAsync),
("combined manager opening, refresh, cancellation and cleanup", CombinedUsageUiSmoke.RunAsync),
("multi-account tooltip observations and refresh boundaries", UsageTooltipTests.RunAsync),
("native light tooltip layout and focus", UsageTooltipUiSmoke.RunAsync),
("stable light tooltip hover, placement and focus", UsageTooltipHoverSmoke.RunAsync),
("supervisor retries abnormal exits but respects normal Quit", TestSupervisorAsync),
("official Claude /usage output is parsed", TestObservedUsageOutputAsync),
("usage without reset times remains valid through client and tooltip", TestUsageWithoutResetsAsync),
Expand Down
Loading