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..70b49c2 100644
--- a/src/UsageToolTip.cs
+++ b/src/UsageToolTip.cs
@@ -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);
}
private static bool IsHeading(string line) => line.StartsWith("CODEX", StringComparison.Ordinal) ||
line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal);
@@ -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);
+ }
}
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..7c53e0f
--- /dev/null
+++ b/tests/WeeklyUsageIndicator.Tests/UsageTooltipHoverSmoke.cs
@@ -0,0 +1,155 @@
+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 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;
+ 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}, 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");
+ }
+ 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)
+ {
+ 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 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()
+ {
+ 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);
+ [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
+}
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();
-}