From befea7ad7b496c75171186fec3d28a84a0fb13b4 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:40:25 +0900 Subject: [PATCH 1/4] Keep hover details stable outside the widget without activating it --- README.md | 2 + docs/tooltip-stability-delivery.md | 16 +++ src/Program.cs | 10 +- src/UsageToolTip.cs | 120 ++++++++++++++-- src/WeeklyUsageIndicator.csproj | 2 +- tests/WeeklyUsageIndicator.Tests/Program.cs | 5 +- .../UsageTooltipHoverSmoke.cs | 131 ++++++++++++++++++ .../UsageTooltipUiSmoke.cs | 80 ----------- 8 files changed, 262 insertions(+), 104 deletions(-) create mode 100644 docs/tooltip-stability-delivery.md create mode 100644 tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs delete mode 100644 tests/WeeklyUsageIndicator.Tests/UsageTooltipUiSmoke.cs diff --git a/README.md b/README.md index 929cfbf..6306bf6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/tooltip-stability-delivery.md b/docs/tooltip-stability-delivery.md new file mode 100644 index 0000000..3173c57 --- /dev/null +++ b/docs/tooltip-stability-delivery.md @@ -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. diff --git a/src/Program.cs b/src/Program.cs index d97554e..4d8edbd 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -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); @@ -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 += (_, _) => @@ -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)); } diff --git a/src/UsageToolTip.cs b/src/UsageToolTip.cs index 985cd6a..952f59b 100644 --- a/src/UsageToolTip.cs +++ b/src/UsageToolTip.cs @@ -1,22 +1,73 @@ 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 = ""; + 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 += StartHover; + owner.MouseLeave += EndHover; + owner.MouseDown += EndHover; + owner.LocationChanged += EndHover; + owner.VisibleChanged += EndHover; + _hoverDelay.Tick += ShowHover; + } + private void StartHover(object? sender, EventArgs e) + { + _hoverDelay.Stop(); + if (Control.MouseButtons != MouseButtons.None) return; + _hoverDelay.Interval = Math.Max(1, InitialDelay); + _hoverDelay.Start(); + } + 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); } private static bool IsHeading(string line) => line.StartsWith("CODEX", StringComparison.Ordinal) || line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal); @@ -50,6 +101,45 @@ 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 -= StartHover; owner.MouseLeave -= EndHover; owner.MouseDown -= EndHover; + 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 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); + } } diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index a6e4cbd..40180dd 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.7.0 + 1.7.1 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/Program.cs b/tests/WeeklyUsageIndicator.Tests/Program.cs index c1521af..0842358 100644 --- a/tests/WeeklyUsageIndicator.Tests/Program.cs +++ b/tests/WeeklyUsageIndicator.Tests/Program.cs @@ -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 Run)[] { @@ -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), diff --git a/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs new file mode 100644 index 0000000..8750719 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs @@ -0,0 +1,131 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +internal static class UsageTooltipHoverSmoke +{ + internal static Task RunAsync() + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var savedCursor = Cursor.Position; + try + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); + using var focusForm = new Form { Text = "Tooltip hover regression", StartPosition = FormStartPosition.CenterScreen, Size = new(480, 180) }; + var input = new TextBox { Dock = DockStyle.Top, Text = "Focus must stay here" }; focusForm.Controls.Add(input); + using var widget = new UsageIndicatorForm(previewMode: true); + var tooltip = (UsageToolTip)Field("_toolTip").GetValue(widget)!; + var now = DateTimeOffset.UtcNow; + var accounts = Enumerable.Range(0, 3).Select(i => new SavedCodexAccount(i.ToString(), "테스트 계정 " + i, + "synthetic", i == 0, new(20 + i * 10, now.AddDays(i + 2), 10080, "codex"), now)).ToArray(); + var combined = (CombinedUsageSnapshot)Field("_combinedUsage").GetValue(widget)!; + combined.Initialize(accounts, true); + foreach (var a in accounts) combined.SetResult(a.Id, a, CombinedReadState.Success); + combined.CompletedAt = now; + Field("_showClaude").SetValue(widget, true); + Field("_claudeUsage").SetValue(widget, new ClaudeUsageResult(new(new(10, now.AddHours(3)), new(20, now.AddDays(3)), new(30, now.AddDays(3))), now, null, false)); + focusForm.Shown += async (_, _) => + { + try + { + input.Focus(); widget.Show(); await Task.Delay(150); input.Focus(); + var foreground = GetForegroundWindow(); var focus = GetFocus(); + var area = Screen.FromControl(focusForm).WorkingArea; + var positions = new[] { new Point(area.Right - widget.Width, area.Bottom - widget.Height), area.Location, + new Point(area.Right - widget.Width, area.Top), new Point(area.Left, area.Bottom - widget.Height) }; + foreach (var position in positions) + { + Cursor.Position = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + await Task.Delay(200); widget.Location = position; widget.MaintainVisiblePresentation(); + var before = tooltip.ShowCount; + var pointer = new Point(widget.Left + widget.Width / 2, widget.Top + widget.Height / 2); + Cursor.Position = pointer; + var visibleSamples = 0; var hiddenAfterShown = 0; Rectangle? firstBounds = null; + for (var tick = 0; tick < 80; tick++) + { + await Task.Delay(50); + if (tick % 20 == 0) widget.MaintainVisiblePresentation(); + var previousText = tooltip.VisibleText; + if (tick == 35) + { + tooltip.SetHoverText("Pending refresh"); + Check(tooltip.VisibleText == previousText, "an updated observation does not redraw the visible tooltip"); + typeof(UsageIndicatorForm).GetMethod("UpdateToolTip", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(widget, null); + } + var bounds = VisibleTooltipBounds(); + if (bounds is { } rect) + { + visibleSamples++; firstBounds ??= rect; + Check(rect == firstBounds.Value, "tooltip position stays fixed while hovering"); + Check(!rect.Contains(pointer), "tooltip stays away from the pointer"); + Check(!rect.IntersectsWith(widget.Bounds) && area.Contains(rect), "tooltip fits the screen outside the widget"); + } + else if (visibleSamples > 0) hiddenAfterShown++; + } + var pointerTarget = WindowFromPoint(pointer); + GetWindowThreadProcessId(pointerTarget, out var pointerProcess); + Console.WriteLine($"Hover diagnostic: popups={tooltip.ShowCount - before}, visible={visibleSamples}, hiddenAfterShown={hiddenAfterShown}, cursorHeld={Cursor.Position == pointer}, targetIsWidget={pointerTarget == widget.Handle}, targetIsTestProcess={pointerProcess == Environment.ProcessId}"); + Check(tooltip.ShowCount - before == 1 && visibleSamples >= 50 && hiddenAfterShown == 0, "one continuous popup survives four seconds of actual hover and maintenance"); + Check(GetForegroundWindow() == foreground && GetFocus() == focus && input.Focused, "hover preserves foreground and keyboard focus"); + } + var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + if (!string.IsNullOrWhiteSpace(output) && tooltip.VisibleBounds is { } capture) + { + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + using var bitmap = new Bitmap(capture.Width, capture.Height); + using var graphics = Graphics.FromImage(bitmap); + graphics.CopyFromScreen(capture.Location, Point.Empty, capture.Size); + bitmap.Save(Path.Combine(Path.GetDirectoryName(output)!, "usage-tooltip-stable.png"), ImageFormat.Png); + } + typeof(Control).GetMethod("OnMouseDown", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(widget, new object[] { new MouseEventArgs(MouseButtons.Right, 1, 5, 5, 0) }); + Check(!tooltip.IsVisible && VisibleTooltipBounds() is null, "opening the context menu dismisses the tooltip"); + ((ContextMenuStrip)Field("_contextMenu").GetValue(widget)!).Close(); + Cursor.Position = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); + await Task.Delay(150); + Cursor.Position = new Point(widget.Left + widget.Width / 2, widget.Top + widget.Height / 2); + await Task.Delay(50); + widget.Hide(); await Task.Delay(450); + Check(!tooltip.IsVisible && VisibleTooltipBounds() is null, "hiding the widget cancels a pending hover"); + done.TrySetResult(); + } + catch (Exception ex) { done.TrySetException(ex); } + finally { widget.Close(); focusForm.Close(); } + }; + Application.Run(focusForm); + } + catch (Exception ex) { done.TrySetException(ex); } + finally { Cursor.Position = savedCursor; } + }); + thread.SetApartmentState(ApartmentState.STA); thread.Start(); return done.Task; + } + private static FieldInfo Field(string name) => typeof(UsageIndicatorForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!; + private static void Check(bool value, string message) { if (!value) throw new InvalidOperationException("Tooltip hover: " + message); } + private static Rectangle? VisibleTooltipBounds() + { + Rectangle? result = null; + EnumWindows((h, _) => + { + GetWindowThreadProcessId(h, out var pid); var name = new StringBuilder(128); GetWindowText(h, name, name.Capacity); + if (pid == Environment.ProcessId && IsWindowVisible(h) && name.ToString() == "Codex usage hover details" && GetWindowRect(h, out var r)) + result = Rectangle.FromLTRB(r.Left, r.Top, r.Right, r.Bottom); + return true; + }, IntPtr.Zero); return result; + } + private delegate bool WindowCallback(IntPtr hwnd, IntPtr parameter); + [StructLayout(LayoutKind.Sequential)] private struct Rect { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out int processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr hwnd, StringBuilder value, int length); + [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern IntPtr GetFocus(); + [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(Point point); +} diff --git a/tests/WeeklyUsageIndicator.Tests/UsageTooltipUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/UsageTooltipUiSmoke.cs deleted file mode 100644 index 85d8516..0000000 --- a/tests/WeeklyUsageIndicator.Tests/UsageTooltipUiSmoke.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Drawing; -using System.Drawing.Imaging; -using System.Runtime.InteropServices; -using System.Text; -using System.Windows.Forms; -using WeeklyUsageIndicator; - -internal static class UsageTooltipUiSmoke -{ - internal static Task RunAsync() - { - var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var thread = new Thread(() => - { - try - { - Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.EnableVisualStyles(); - using var form = new Form { Text = "Usage tooltip test", ClientSize = new Size(920, 740), StartPosition = FormStartPosition.CenterScreen }; - AccountUiTheme.SetForm(form); - var input = new TextBox { Location = new Point(24, 24), Width = 300, Text = "Keyboard focus stays here" }; form.Controls.Add(input); - var now = DateTimeOffset.UtcNow; - var accounts = new[] { ("메인 계정", 37), ("서브 계정", 19), ("서브 계정2", 76) } - .Select((a, i) => new SavedCodexAccount(i.ToString(), a.Item1, "synthetic", i == 0, new(a.Item2, now.AddDays(i + 2), 10080, "codex"), now)).ToArray(); - var set = new CombinedUsageSnapshot(); set.Initialize(accounts, true); - foreach (var a in accounts) set.SetResult(a.Id, a, CombinedReadState.Success); set.CompletedAt = now; - var claude = new ClaudeUsageResult(new(new(10, now.AddHours(3)), new(20, now.AddDays(3)), new(30, now.AddDays(3))), now, null, false); - var text = UsageIndicatorForm.BuildTooltipText(accounts[0].Usage, claude, null, null, true, set, "메인 계정"); - using var tooltip = new UsageToolTip { ShowAlways = true }; - tooltip.SetToolTip(input, text); - var popped = false; var drawn = false; - tooltip.Popup += (_, e) => { popped = true; Check(e.ToolTipSize.Height <= Screen.FromControl(form).WorkingArea.Height, "full tooltip fits the available screen height"); }; - tooltip.Draw += (_, _) => drawn = true; - form.Shown += async (_, _) => - { - try - { - input.Focus(); var foreground = GetForegroundWindow(); var focus = GetFocus(); - tooltip.Show(text, input, new Point(0, input.Height + 8)); - await Task.Delay(250); - Check(popped && drawn, "native tooltip executes measured owner drawing"); - Check(foreground == GetForegroundWindow() && focus == GetFocus() && input.Focused, "tooltip preserves foreground and keyboard focus"); - var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); - if (!string.IsNullOrWhiteSpace(output)) - { - IntPtr window = IntPtr.Zero; - EnumWindows((handle, _) => - { - GetWindowThreadProcessId(handle, out var pid); var name = new StringBuilder(64); GetClassName(handle, name, name.Capacity); - if (pid == Environment.ProcessId && IsWindowVisible(handle) && name.ToString().Contains("tooltips_class32", StringComparison.OrdinalIgnoreCase)) window = handle; - return true; - }, IntPtr.Zero); - Check(window != IntPtr.Zero && GetWindowRect(window, out _), "native tooltip has a visible window"); - GetWindowRect(window, out var rect); - using var bitmap = new Bitmap(rect.Right - rect.Left, rect.Bottom - rect.Top); - using var graphics = Graphics.FromImage(bitmap); - graphics.CopyFromScreen(rect.Left, rect.Top, 0, 0, bitmap.Size); - bitmap.Save(Path.Combine(Path.GetDirectoryName(output)!, "usage-tooltip-native.png"), ImageFormat.Png); - } - done.TrySetResult(); - } - catch (Exception ex) { done.TrySetException(ex); } - finally { tooltip.Hide(input); form.Close(); } - }; - Application.Run(form); - } - catch (Exception ex) { done.TrySetException(ex); } - }); - thread.SetApartmentState(ApartmentState.STA); thread.Start(); return done.Task; - } - private static void Check(bool condition, string message) { if (!condition) throw new InvalidOperationException("Native tooltip: " + message); } - private delegate bool WindowCallback(IntPtr hwnd, IntPtr parameter); - [StructLayout(LayoutKind.Sequential)] private struct Rect { public int Left, Top, Right, Bottom; } - [DllImport("user32.dll")] private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter); - [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out int processId); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr hwnd, StringBuilder value, int length); - [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); - [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hwnd); - [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] private static extern IntPtr GetFocus(); -} From 4d0859d2a94e4cd814e2c5d2f7417186ad58cc73 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:44:18 +0900 Subject: [PATCH 2/4] Report hover lifecycle events when validating screen corners --- .../WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs index 8750719..af674b1 100644 --- a/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs @@ -37,12 +37,18 @@ internal static Task RunAsync() input.Focus(); widget.Show(); await Task.Delay(150); input.Focus(); var foreground = GetForegroundWindow(); var focus = GetFocus(); var area = Screen.FromControl(focusForm).WorkingArea; + var events = new List(); + widget.MouseEnter += (_, _) => events.Add("enter"); + widget.MouseLeave += (_, _) => events.Add("leave"); + widget.LocationChanged += (_, _) => events.Add("move"); + widget.VisibleChanged += (_, _) => events.Add("visible=" + widget.Visible); var positions = new[] { new Point(area.Right - widget.Width, area.Bottom - widget.Height), area.Location, new Point(area.Right - widget.Width, area.Top), new Point(area.Left, area.Bottom - widget.Height) }; foreach (var position in positions) { Cursor.Position = new Point(area.Left + area.Width / 2, area.Top + area.Height / 2); await Task.Delay(200); widget.Location = position; widget.MaintainVisiblePresentation(); + events.Clear(); var before = tooltip.ShowCount; var pointer = new Point(widget.Left + widget.Width / 2, widget.Top + widget.Height / 2); Cursor.Position = pointer; @@ -70,7 +76,7 @@ internal static Task RunAsync() } var pointerTarget = WindowFromPoint(pointer); GetWindowThreadProcessId(pointerTarget, out var pointerProcess); - Console.WriteLine($"Hover diagnostic: popups={tooltip.ShowCount - before}, visible={visibleSamples}, hiddenAfterShown={hiddenAfterShown}, cursorHeld={Cursor.Position == pointer}, targetIsWidget={pointerTarget == widget.Handle}, targetIsTestProcess={pointerProcess == Environment.ProcessId}"); + Console.WriteLine($"Hover diagnostic: popups={tooltip.ShowCount - before}, visible={visibleSamples}, hiddenAfterShown={hiddenAfterShown}, cursorHeld={Cursor.Position == pointer}, targetIsWidget={pointerTarget == widget.Handle}, targetIsTestProcess={pointerProcess == Environment.ProcessId}, events={string.Join(',', events)}"); Check(tooltip.ShowCount - before == 1 && visibleSamples >= 50 && hiddenAfterShown == 0, "one continuous popup survives four seconds of actual hover and maintenance"); Check(GetForegroundWindow() == foreground && GetFocus() == focus && input.Focused, "hover preserves foreground and keyboard focus"); } From 57d7d7e3ffc3462d941c06af3579df936644e2b3 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:48:12 +0900 Subject: [PATCH 3/4] Rearm hover from mouse movement and retain measured DPI bounds --- src/UsageToolTip.cs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/UsageToolTip.cs b/src/UsageToolTip.cs index 952f59b..629d61e 100644 --- a/src/UsageToolTip.cs +++ b/src/UsageToolTip.cs @@ -9,6 +9,7 @@ internal sealed class UsageToolTip : IDisposable 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; @@ -21,20 +22,32 @@ internal sealed class UsageToolTip : IDisposable internal void TrackHover(Control owner) { _hoverOwner = owner; - owner.MouseEnter += StartHover; + owner.MouseEnter += BeginVisit; + owner.MouseMove += StartHover; owner.MouseLeave += EndHover; - owner.MouseDown += 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) { - _hoverDelay.Stop(); - if (Control.MouseButtons != MouseButtons.None) return; + // 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(); @@ -106,7 +119,8 @@ public void Dispose() _hoverDelay.Dispose(); _window?.Dispose(); if (_hoverOwner is { } owner) { - owner.MouseEnter -= StartHover; owner.MouseLeave -= EndHover; owner.MouseDown -= EndHover; + owner.MouseEnter -= BeginVisit; owner.MouseMove -= StartHover; + owner.MouseLeave -= EndHover; owner.MouseDown -= DismissHover; owner.LocationChanged -= EndHover; owner.VisibleChanged -= EndHover; } _font.Dispose(); _heading.Dispose(); @@ -125,6 +139,12 @@ internal HoverWindow(UsageToolTip renderer) 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. + base.OnDpiChanged(e); + e.Cancel = true; + } protected override CreateParams CreateParams { get From 3896367ec5351cb656daf95bd7cb29a1f825e17e Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:52:22 +0900 Subject: [PATCH 4/4] Cancel native DPI resizing before the base handler applies it --- src/UsageToolTip.cs | 2 +- .../UsageTooltipHoverSmoke.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/UsageToolTip.cs b/src/UsageToolTip.cs index 629d61e..70b49c2 100644 --- a/src/UsageToolTip.cs +++ b/src/UsageToolTip.cs @@ -142,8 +142,8 @@ internal HoverWindow(UsageToolTip renderer) protected override void OnDpiChanged(DpiChangedEventArgs e) { // ShowHover already measures and places the window at the owner's DPI. - base.OnDpiChanged(e); e.Cancel = true; + base.OnDpiChanged(e); } protected override CreateParams CreateParams { diff --git a/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs index af674b1..7c53e0f 100644 --- a/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs @@ -80,6 +80,12 @@ internal static Task RunAsync() Check(tooltip.ShowCount - before == 1 && visibleSamples >= 50 && hiddenAfterShown == 0, "one continuous popup survives four seconds of actual hover and maintenance"); Check(GetForegroundWindow() == foreground && GetFocus() == focus && input.Focused, "hover preserves foreground and keyboard focus"); } + var popup = (Form)typeof(UsageToolTip).GetField("_window", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(tooltip)!; + var measuredBounds = popup.Bounds; var originalDpi = popup.DeviceDpi; + SendDpiChange(popup, originalDpi == 96 ? 144 : 96); + Check(popup.Bounds == measuredBounds, "DPI notification cannot replace bounds measured at the owner DPI"); + SendDpiChange(popup, originalDpi); + Check(popup.Bounds == measuredBounds, "returning to the original DPI retains measured bounds"); var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); if (!string.IsNullOrWhiteSpace(output) && tooltip.VisibleBounds is { } capture) { @@ -112,6 +118,17 @@ internal static Task RunAsync() thread.SetApartmentState(ApartmentState.STA); thread.Start(); return done.Task; } private static FieldInfo Field(string name) => typeof(UsageIndicatorForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!; + private static void SendDpiChange(Form popup, int dpi) + { + var rect = new Rect { Left = popup.Left + 20, Top = popup.Top + 20, Right = popup.Left + popup.Width * 2, Bottom = popup.Top + popup.Height * 2 }; + var memory = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(rect, memory, false); + SendMessage(popup.Handle, 0x02E0, new IntPtr(dpi | (dpi << 16)), memory); + } + finally { Marshal.FreeHGlobal(memory); } + } private static void Check(bool value, string message) { if (!value) throw new InvalidOperationException("Tooltip hover: " + message); } private static Rectangle? VisibleTooltipBounds() { @@ -134,4 +151,5 @@ internal static Task RunAsync() [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] private static extern IntPtr GetFocus(); [DllImport("user32.dll")] private static extern IntPtr WindowFromPoint(Point point); + [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam); }