mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-08 18:17:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac698e2d1 | ||
|
|
2c67dd1217 | ||
|
|
38752ae5c5 | ||
|
|
e099f0a91f | ||
|
|
4e9a66cbc8 | ||
|
|
1d9c59b43b | ||
|
|
7039bf7e98 | ||
|
|
cc73c3f9b4 | ||
|
|
a68d80415b | ||
|
|
cce22cc3b7 | ||
|
|
5e00009e5a | ||
|
|
c91dc996f8 | ||
|
|
b2e48cc119 | ||
|
|
bee9f3cfa0 | ||
|
|
860eb62d1b | ||
|
|
c424b17bd7 | ||
|
|
3b0f96f51c | ||
|
|
5976181cb2 | ||
|
|
f2b31d3211 | ||
|
|
5c2b14fc03 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "claude-code-usage-bubble"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||
|
||||
@@ -33,9 +33,9 @@ self-updater are all written from scratch against the same public APIs
|
||||
(140–360 logical pixels)
|
||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||
bars plus reset countdowns
|
||||
- Right-click for refresh, displayed models, update frequency, language,
|
||||
- Right-click for refresh, displayed providers, update frequency, language,
|
||||
startup, updates, exit
|
||||
- Optional system tray icons (one per enabled model)
|
||||
- Optional system tray icons (one per enabled provider)
|
||||
- Auto-hide when a fullscreen app is in the foreground (games, video,
|
||||
presentations) — reappears when you leave fullscreen
|
||||
|
||||
@@ -43,7 +43,7 @@ self-updater are all written from scratch against the same public APIs
|
||||
|
||||
Windows 10/11 users who already have **Claude Code (CLI or App) installed
|
||||
and signed in**. Codex support is optional — install and sign in to the
|
||||
Codex CLI, then enable Codex from the right-click **Models** menu.
|
||||
Codex CLI, then enable Codex from the right-click **Providers** menu.
|
||||
|
||||
If you use Claude Code through WSL, that is supported too. The monitor
|
||||
can read your Claude Code credentials from Windows or from your WSL
|
||||
@@ -85,7 +85,7 @@ corner of your primary monitor on first launch. Drag it where you want it,
|
||||
release to snap to the nearest edge if you let go close to one.
|
||||
|
||||
- **Left-click** the bubble to open the expanded panel (5h + 7d + countdowns)
|
||||
- **Right-click** for refresh, models, refresh frequency, language, "Start
|
||||
- **Right-click** for refresh, providers, refresh frequency, language, "Start
|
||||
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
|
||||
Weekly), manual "Check for updates", exit
|
||||
- **Drag** anywhere — it floats on top of all other windows
|
||||
@@ -94,14 +94,17 @@ release to snap to the nearest edge if you let go close to one.
|
||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||
right-click opens the same menu
|
||||
|
||||
### Models
|
||||
### Providers
|
||||
|
||||
Use the right-click **Models** menu to choose what is shown:
|
||||
Use the right-click **Providers** menu to choose what is shown:
|
||||
|
||||
- **Claude Code** is enabled by default
|
||||
- **Codex** can be enabled alongside Claude Code or shown by itself
|
||||
- **OpenCode Go** is listed for future support; usage bars remain disabled
|
||||
until OpenCode exposes a stable quota source or console integration is
|
||||
explicitly enabled
|
||||
|
||||
When both models are shown, each gets its own bubble that you can position
|
||||
When multiple providers are shown, each gets its own bubble that you can position
|
||||
independently.
|
||||
|
||||
## Diagnostics
|
||||
@@ -144,7 +147,7 @@ What the app stores locally:
|
||||
- Polling frequency
|
||||
- Language preference
|
||||
- Last update check time
|
||||
- Displayed model preferences
|
||||
- Displayed provider preferences
|
||||
|
||||
What it does **not** do: send credentials to any third-party server, run a
|
||||
backend service, collect analytics, upload your project files, or write to
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "Lock geometry"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "45m"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Lock geometry
|
||||
|
||||
## Overview
|
||||
Define the tail layout contract in `compute_bubble_layout` so both rows consume the same horizontal geometry and differ only in vertical placement and bar height.
|
||||
|
||||
## Requirements
|
||||
- Functional: percent text and countdown text appear after the bar, not inside it.
|
||||
- Functional: `tail_usage_bar_rect.left/right == tail_time_bar_rect.left/right`.
|
||||
- Functional: `tail_usage_pct_rect.left/right == tail_time_text_rect.left/right`.
|
||||
- Non-functional: preserve the 140-360 logical size behavior and the minimum bar-width guard.
|
||||
- Non-functional: no new renderer inputs from `src/app.rs`.
|
||||
|
||||
## Architecture
|
||||
- Data flow: `src/app.rs:639-655` -> `bubble::update_data` -> `PaintInputs` -> `compute_bubble_layout` -> `paint_bubble_pixmap` / `paint_bubble_text`.
|
||||
- Geometry source of truth: shared `text_w`, `text_left`, `bar_left`, and `bar_right` in `src/bubble.rs:1114-1126`.
|
||||
- Current rect assignment already fans those shared values into both tail rows at `src/bubble.rs:1141-1163`.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `src/bubble.rs`
|
||||
- Read-only check: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Re-verify the live mismatch before changing code; the current source already shares bar and text columns.
|
||||
2. Make the target contract explicit in `compute_bubble_layout`: one shared bar lane, one shared text lane, row-specific `top/bottom` only.
|
||||
3. Preserve `bar_min` fallback so long countdowns shrink text first, not bar width below usability.
|
||||
4. Keep bar-height asymmetry unless the user confirms that equal thickness is also required.
|
||||
|
||||
## Todo List
|
||||
- [ ] Confirm whether the bug is still reproducible on `main`.
|
||||
- [ ] Document the target geometry near `compute_bubble_layout`.
|
||||
- [ ] Ensure no later row-specific width override remains.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Both tail bars have identical `left/right` bounds.
|
||||
- [ ] Both tail texts start at the same `left` and end at the same `right`.
|
||||
- [ ] No upstream data-contract change is required.
|
||||
|
||||
## Risk Assessment
|
||||
- High: the source may already satisfy the request; unnecessary edits would add churn. Mitigation: prove the runtime mismatch first.
|
||||
- Medium: long localized countdown strings can starve bar width at minimum size. Mitigation: keep `bar_min` and shared fallback math.
|
||||
- Rollback: revert only the `compute_bubble_layout` diff.
|
||||
|
||||
## Security Considerations
|
||||
- None beyond normal memory-safety review; change is layout-only.
|
||||
|
||||
## Next Steps
|
||||
- Hand off the shared-geometry contract to Phase 2 for text painting and stale-comment cleanup.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Apply renderer change"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "45m"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Apply renderer change
|
||||
|
||||
## Overview
|
||||
Apply the tail text-placement change in the renderer so the weekly percent lane and weekly remaining-time lane follow the same `bar -> text` behavior, without changing provider or state plumbing.
|
||||
|
||||
## Requirements
|
||||
- Functional: weekly percent text renders from `tail_usage_pct_rect`; weekly countdown renders from `tail_time_text_rect`.
|
||||
- Functional: both texts stay right-aligned after the bar using `DT_RIGHT`.
|
||||
- Functional: no tail text is drawn inside the bar fill.
|
||||
- Non-functional: preserve the existing pulse behavior for `weekly_pct >= 95`.
|
||||
- Non-functional: avoid touching `PaintInputs`, polling, or panel code unless a stale comment must be corrected.
|
||||
|
||||
## Architecture
|
||||
- Bar drawing is tiny-skia-only in `src/bubble.rs:1298-1331`.
|
||||
- Tail text drawing is a later GDI overlay in `src/bubble.rs:1643-1661`.
|
||||
- `src/app.rs:636-638` currently describes the bubble percent as inline in the bar fill; if that wording is now false, correct it in the same phase.
|
||||
|
||||
## Related Code Files
|
||||
- Modify: `src/bubble.rs`
|
||||
- Optional modify: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Align `paint_bubble_text` with the Phase 1 geometry contract and keep the percent/countdown text outside the bars.
|
||||
2. Remove any remaining inline-percent assumption in comments or naming if it conflicts with the final behavior.
|
||||
3. Keep the weekly percent highlight behavior and empty-countdown handling intact.
|
||||
4. Stop scope creep: no changes to provider snapshots, update timers, or panel layout.
|
||||
|
||||
## Todo List
|
||||
- [ ] Confirm `paint_bubble_text` is the only text-placement site for the tail rows.
|
||||
- [ ] Update or remove stale inline-bar comments if they become misleading.
|
||||
- [ ] Re-check placeholder and `None` states after the layout change.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Weekly percent text appears after the top tail bar.
|
||||
- [ ] Weekly countdown text appears after the bottom tail bar.
|
||||
- [ ] Tail bar widths are driven only by shared geometry from `compute_bubble_layout`.
|
||||
|
||||
## Risk Assessment
|
||||
- Medium: if the reported mismatch is only visual perception from unequal bar heights, text-placement edits alone will not fix it. Mitigation: compare runtime screenshots before and after Phase 1.
|
||||
- Low: optional comment cleanup in `src/app.rs` can drift from renderer reality. Mitigation: change comments only after the final behavior is locked.
|
||||
- Rollback: revert only the text-placement and comment diffs.
|
||||
|
||||
## Security Considerations
|
||||
- None; no auth, network, or filesystem behavior changes.
|
||||
|
||||
## Next Steps
|
||||
- Hand off to Phase 3 for compile checks and Windows visual verification.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Validate on Windows"
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: "30m"
|
||||
dependencies: [2]
|
||||
---
|
||||
|
||||
# Phase 3: Validate on Windows
|
||||
|
||||
## Overview
|
||||
Verify that the scoped renderer change compiles and that the native layered-window bubble actually presents equal-width tail bars with text after each bar across common runtime conditions.
|
||||
|
||||
## Requirements
|
||||
- Functional: both tail rows visually render as `bar -> text`.
|
||||
- Functional: both tail bars have the same visible width.
|
||||
- Non-functional: confirm no regression to head ring, head text, or tray/panel refresh behavior.
|
||||
- Non-functional: validation stays command-light and uses the existing Windows runtime.
|
||||
|
||||
## Architecture
|
||||
- Compile-time validation covers the Rust renderer path end to end.
|
||||
- Runtime validation must observe the real layered window because there are no snapshot/golden tests for `tiny-skia + GDI` composition in this repo.
|
||||
|
||||
## Related Code Files
|
||||
- Verify: `src/bubble.rs`
|
||||
- Verify if touched: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
1. Run the compile/test commands below.
|
||||
2. Launch the app and verify the bubble at 140, default, and max logical sizes.
|
||||
3. Check light and dark theme, Claude and Codex bubbles, and a long countdown string if available.
|
||||
4. Capture before/after notes so a no-op or perception-only result is explicit.
|
||||
|
||||
## Validation Commands
|
||||
```powershell
|
||||
cargo check
|
||||
cargo test
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Todo List
|
||||
- [ ] `cargo check` passes.
|
||||
- [ ] `cargo test` passes, or any pre-existing failures are called out separately.
|
||||
- [ ] Manual runtime check confirms equal bar widths and text-after-bar alignment.
|
||||
- [ ] No regression is seen in the head ring/text or bubble refresh path.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Compile succeeds on the current branch.
|
||||
- [ ] The top and bottom tail bars share the same left/right edges at runtime.
|
||||
- [ ] The percent and countdown texts both sit to the right of their bars at runtime.
|
||||
- [ ] Any remaining mismatch is explained with evidence, not assumption.
|
||||
|
||||
## Risk Assessment
|
||||
- High: native renderer issues are hard to prove without manual observation. Mitigation: test at minimum/default/maximum sizes and common DPI settings.
|
||||
- Medium: reproducing the original complaint may require a specific locale, DPI, or stale binary. Mitigation: record the runtime conditions used during verification.
|
||||
- Rollback: revert the renderer change if compile or visual regression appears.
|
||||
|
||||
## Security Considerations
|
||||
- None.
|
||||
|
||||
## Next Steps
|
||||
- If validation passes, implementation can be approved as a scoped `src/bubble.rs` change. If not, reopen Phase 1 with the observed runtime evidence.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Bubble tail bar layout alignment"
|
||||
description: "Scoped renderer-only plan to align weekly percent and remaining-time tail bar geometry."
|
||||
status: pending
|
||||
priority: P2
|
||||
effort: 2h
|
||||
branch: "main"
|
||||
tags: [rust, renderer, bubble, layout]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: 2026-05-23
|
||||
createdBy: "ck:plan"
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Bubble tail bar layout alignment
|
||||
|
||||
## Scope
|
||||
- User-facing goal: weekly percent lane and weekly remaining-time lane both render as `bar -> text`, and both bars share identical left/right bounds.
|
||||
- Expected code scope: `src/bubble.rs`; touch `src/app.rs` only if comment cleanup is needed.
|
||||
- Backwards compatibility: no settings, storage, IPC, or provider-data changes.
|
||||
|
||||
## Verified Codebase Facts
|
||||
- Bubble data already provides `weekly_pct`, `weekly_text`, and `weekly_resets_at` through `bubble::update_data`; no new inputs are needed (`src/app.rs:639-655`).
|
||||
- `compute_bubble_layout` already derives one shared text column and one shared bar lane for the two tail rows (`src/bubble.rs:1114-1163`).
|
||||
- `paint_bubble_text` already renders weekly percent and weekly countdown as separate right-aligned texts (`src/bubble.rs:1644-1661`).
|
||||
- `paint_bubble_pixmap` paints both tail bars from rects only; text is a GDI overlay, so geometry must stay the single source of truth (`src/bubble.rs:1298-1331`).
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Lock geometry](./phase-01-lock-geometry.md) | Pending |
|
||||
| 2 | [Apply renderer change](./phase-02-apply-renderer-change.md) | Pending |
|
||||
| 3 | [Validate on Windows](./phase-03-validate-on-windows.md) | Pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Sequence: Phase 1 -> Phase 2 -> Phase 3.
|
||||
- File ownership: `src/bubble.rs` stays single-owner across phases; optional `src/app.rs` comment cleanup happens only in Phase 2.
|
||||
- Related existing plan: `plans/260523-ui-ux-improvement-plan/plan.md` Phase 3 overlaps in theme but does not block this scoped renderer-only change.
|
||||
|
||||
## Rollback
|
||||
- Revert layout math and text-placement changes in `src/bubble.rs`.
|
||||
- Revert optional comment cleanup in `src/app.rs`.
|
||||
- No data migration or persisted-state rollback is needed.
|
||||
|
||||
## Unresolved Questions
|
||||
- Does "same length" mean equal width only, or should the two tail bars also share the same height? Current code intentionally uses different heights (`src/bubble.rs:1105-1110`).
|
||||
- The current source already looks close to the requested behavior. If runtime still differs, is the issue in this branch, a stale binary, or perception caused by different bar heights?
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 1
|
||||
title: Research OpenCode Go contracts
|
||||
status: blocked
|
||||
priority: P1
|
||||
effort: 2h
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: Research OpenCode Go Contracts
|
||||
|
||||
## Overview
|
||||
|
||||
Prove the OpenCode Go credential, provider ID, and usage-data contracts before implementation. This is the gate that prevents guessed percentages.
|
||||
|
||||
Current result: blocked. Provider/model IDs and console quota math are verified, but there is no verified stable CLI/public API for this app to fetch current weekly/monthly quota usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Identify exact local credential path(s), JSON shape, auth key name, refresh/login command, weekly/monthly usage percent, and weekly/monthly reset time.
|
||||
- Non-functional: Use primary sources first: official OpenCode docs, OpenCode source, locally installed `opencode` behavior if available.
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenCode Go will only become a supported provider if it can return normalized weekly/monthly data from a stable source. Official docs confirm Go limits are 5h/weekly/monthly and dollar-value based, but not a public usage endpoint. For this app, OpenCode Go must render a custom four-bar layout: weekly usage percent + weekly remaining time in the upper section, monthly usage percent + monthly remaining time in the lower section.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Read: `src/creds/mod.rs`
|
||||
- Read: `src/creds/codex_auth.rs`
|
||||
- Read: `src/usage/chatgpt.rs`
|
||||
- Read: `src/usage/types.rs`
|
||||
- Read: `src/usage/registry.rs`
|
||||
- Created: `plans/260602-1115-opencode-go-provider-support/reports/research-opencode-go-contracts.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Check official OpenCode Go docs for provider ID, limits, endpoints, and console usage semantics.
|
||||
2. Check official OpenCode CLI/source for `auth.json` storage path and schema.
|
||||
3. If OpenCode is installed locally, run safe read-only commands: `opencode stats --help`, `opencode providers --help`, `opencode models --help`.
|
||||
4. Inspect local `~/.local/share/opencode/auth.json` only with user approval if privacy hook blocks or if file may contain API keys.
|
||||
5. Search for an official usage endpoint or CLI command that returns current weekly/monthly usage percent or used/limit values plus reset/period end times.
|
||||
6. Record exact request/response shape or command output needed by Phase 3, including how to compute remaining time for weekly and monthly sections.
|
||||
7. If only console-authenticated usage exists, stop and ask user before planning implementation beyond auth detection.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Exact credential docs path documented.
|
||||
- [x] Exact provider key documented: `opencode-go`.
|
||||
- [x] Weekly/monthly usage percent and reset-time shape verified in console source.
|
||||
- [x] Stable app-callable quota source confirmed unavailable from current CLI/docs.
|
||||
- [ ] User decision recorded: console integration, limited detector, or defer.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: OpenCode docs mention tracking usage in the console but do not document a public usage API. Mitigation: make Phase 3 blocked until a stable source exists; do not infer usage from price tables.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
---
|
||||
phase: 2
|
||||
title: Refactor provider metadata and settings
|
||||
status: completed
|
||||
priority: P1
|
||||
effort: 4h
|
||||
dependencies:
|
||||
- 1
|
||||
---
|
||||
|
||||
# Phase 2: Refactor Provider Metadata And Settings
|
||||
|
||||
## Overview
|
||||
|
||||
Remove hard-coded two-provider assumptions before adding OpenCode Go. Keep behavior identical for Claude Code and Codex.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Represent enabled providers and positions by `ProviderId`, not one field per provider.
|
||||
- Non-functional: Preserve old `settings.json` compatibility for `show_claude_code`, `show_codex`, and `bubble_positions.{claude,codex}`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Introduce a small provider metadata layer near `usage::types` or `usage::registry`: stable ID, slug, display label key, tray icon ID, default enabled, and display mode. Existing providers use the current two-window mode. OpenCode Go uses a custom weekly/monthly four-bar mode.
|
||||
|
||||
Keep `ProviderId::ChatGpt` internally unless touching the code anyway. The user-facing label remains Codex. Avoid a mechanical rename that adds risk without feature value.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `src/usage/types.rs`
|
||||
- Modify: `src/usage/registry.rs`
|
||||
- Modify: `src/settings.rs`
|
||||
- Modify: `src/app.rs`
|
||||
- Modify: `src/tray/mod.rs`
|
||||
- Modify: `src/tray/badge.rs`
|
||||
- Modify: `src/usage_color.rs`
|
||||
- Modify: `src/bubble.rs`
|
||||
- Modify: `src/panel.rs`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `ProviderId::OpenCodeGo` and a stable `slug()` value `opencode-go`.
|
||||
2. Add `ProviderId::all()` or equivalent ordered provider list: Claude, Codex, OpenCode Go.
|
||||
3. Replace `Settings { show_claude_code, show_codex }` runtime logic with a provider-enabled map or compact struct that can hold `opencode_go`.
|
||||
4. Preserve serde compatibility: deserialize old fields and write new fields only after migration, or keep old fields plus add `show_opencode_go` if map migration is too broad.
|
||||
5. Extend `BubblePositions` to store OpenCode Go position while keeping old `claude` and `codex` JSON keys.
|
||||
6. Update tray icon IDs and badge colors for third provider.
|
||||
7. Add provider-specific display metadata: Claude/Codex mode=`two-window`; OpenCode Go mode=`weekly-monthly-four-bar`.
|
||||
8. Update app loops to iterate providers instead of hand-writing Claude/Codex branches where this reduces match duplication.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Existing `settings.json` with only Claude/Codex still loads.
|
||||
- [ ] If all providers disabled, Claude Code is re-enabled as today.
|
||||
- [ ] Three providers can have separate positions and tray icon IDs.
|
||||
- [ ] OpenCode Go can select a custom four-bar renderer without changing Claude/Codex labels.
|
||||
- [ ] No behavior change for existing Claude Code/Codex users.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Settings migration is the highest regression risk. Mitigation: add serde/default tests with old two-provider JSON and new three-provider JSON.
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Implement OpenCode Go provider"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "5h"
|
||||
dependencies: [1, 2]
|
||||
---
|
||||
|
||||
# Phase 3: Implement OpenCode Go Provider
|
||||
|
||||
## Overview
|
||||
|
||||
Add OpenCode Go credential discovery and polling once Phase 1 proves the source of truth.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: When enabled and authenticated, OpenCode Go returns weekly/monthly usage percent and reset time, enough for four bars: usage percent + remaining time for each section.
|
||||
- Non-functional: Do not store or transmit API keys outside OpenCode/OpenCode Go endpoints. Do not mutate OpenCode auth files.
|
||||
|
||||
## Architecture
|
||||
|
||||
Create an OpenCode Go credential source that reads OpenCode's auth store and a provider implementation that maps verified weekly/monthly usage percent + reset time into a provider-specific snapshot. Do not force OpenCode Go into the existing `UsageWindows.primary/secondary` shape if that would lose the grouped weekly/monthly rendering semantics.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `src/creds/opencode_auth.rs`
|
||||
- Create: `src/usage/opencode_go.rs`
|
||||
- Modify: `src/creds/mod.rs`
|
||||
- Modify: `src/usage/mod.rs`
|
||||
- Modify: `src/usage/registry.rs`
|
||||
- Modify: `src/usage/refresh.rs`
|
||||
- Modify: `src/app.rs`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add `LocalOpenCodeGoCreds` with path detection from Phase 1. Expected primary path: `%LOCALAPPDATA%`/XDG-equivalent for `opencode/auth.json`; verify exact Windows path before coding.
|
||||
2. Parse only the OpenCode Go auth entry. Support API-key style auth if Phase 1 confirms schema.
|
||||
3. Add `RefreshHint::LocalOpenCodeCli` and spawn `opencode auth login` or equivalent only if Phase 1 confirms the command.
|
||||
4. Add `OpenCodeGoProvider::poll`, using the verified usage endpoint/command.
|
||||
5. Add a provider-specific snapshot type if needed, for example `ProviderUsage::TwoWindow(UsageWindows)` and `ProviderUsage::WeeklyMonthly { weekly: Window, monthly: Window }`.
|
||||
6. Map returned weekly/monthly source data to usage percent and reset-time windows. Existing renderer can then draw usage and remaining-time bars for each window.
|
||||
7. Return `AuthRequired` on 401/403 or missing provider auth.
|
||||
8. Add unit tests for credential parsing and response-to-snapshot mapping.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Provider compiles behind the existing registry.
|
||||
- [ ] Missing OpenCode auth produces `NoCredentials`, not panic.
|
||||
- [ ] Expired/invalid auth produces `AuthRequired`.
|
||||
- [ ] Weekly/monthly usage percent clamps to 0-100 and remaining-time bars derive from verified reset times.
|
||||
- [ ] If Phase 1 cannot verify usage, this phase is not implemented.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
OpenCode Go limits are dollar-value based. If the usage source returns dollars instead of percentages, compute percent only as `used / limit * 100` from server-provided weekly/monthly values. Never estimate usage from model prices. If reset times are unavailable, stop and ask before shipping remaining-time bars with guessed periods.
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
phase: 4
|
||||
title: Rename Models UI to Providers
|
||||
status: in-progress
|
||||
priority: P2
|
||||
effort: 2h
|
||||
dependencies:
|
||||
- 2
|
||||
---
|
||||
|
||||
# Phase 4: Rename Models UI To Providers
|
||||
|
||||
## Overview
|
||||
|
||||
Rename the menu and documentation wording from "Models" to "Providers" where the app is selecting quota sources.
|
||||
|
||||
Current result: partial. Provider wording and OpenCode Go listing are implemented. The OpenCode Go four-bar renderer is still blocked by Phase 1 because no stable quota source is available.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Context menu uses "Providers" and lists Claude Code, Codex, OpenCode Go.
|
||||
- Functional: OpenCode Go panel/bubble layout renders two grouped parts: Weekly on top and Monthly below, each with usage percent and remaining-time bars. Claude/Codex keep their current 5h/7d labels.
|
||||
- Non-functional: Keep real model wording where it means actual LLM models, not provider selection.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is mostly i18n and README copy. Keep internal `model` variable renames scoped to files touched by provider iteration. Do not churn every `model` local in renderer code just for style.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `src/i18n/mod.rs`
|
||||
- Modify: `src/i18n/locales/en.toml`
|
||||
- Modify: `src/i18n/locales/ja.toml`
|
||||
- Modify: `src/i18n/locales/ko.toml`
|
||||
- Modify: `src/i18n/locales/vi.toml`
|
||||
- Modify: `src/i18n/locales/zh-TW.toml`
|
||||
- Modify: `src/app.rs`
|
||||
- Modify: `README.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Rename `LocaleStrings.models` to `providers`, or keep field name and change values if minimizing code churn is preferred.
|
||||
2. Update English label to `Providers`; update other locales with best available translation.
|
||||
3. Add `opencode_go_label = "OpenCode Go"` to all locale files.
|
||||
4. Add localized generic labels for Weekly and Monthly group headers. Reuse existing usage-percent and remaining-time visual conventions.
|
||||
5. Add or branch rendering for OpenCode Go's four-bar layout in `bubble.rs` and `panel.rs`; do not distort Claude/Codex two-bar layout.
|
||||
6. Update README sections: "displayed models" -> "displayed providers"; "### Models" -> "### Providers".
|
||||
7. Add locale schema tests for the new label.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] Right-click menu says Providers.
|
||||
- [x] README consistently describes selectable services as providers.
|
||||
- [ ] OpenCode Go UI has Weekly and Monthly grouped sections, each with usage percent and remaining-time bars.
|
||||
- [x] No accidental rename of OpenCode's documented `/models` command.
|
||||
- [x] All embedded locale tests pass.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Translations may be imperfect. Mitigation: keep provider product names untranslated and only translate the generic "Providers" label.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Verify and document"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: "3h"
|
||||
dependencies: [3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Verify And Document
|
||||
|
||||
## Overview
|
||||
|
||||
Prove the third-provider workflow works and document setup/privacy accurately.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: Claude Code, Codex, and OpenCode Go can be toggled independently; polling and tray refresh do not regress.
|
||||
- Non-functional: Privacy docs must list every local file read and network endpoint called.
|
||||
|
||||
## Architecture
|
||||
|
||||
Verification must cover pure tests, build, and Windows manual smoke tests. OpenCode Go cannot be fully claimed without a real authenticated account or a captured fixture from Phase 1.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `README.md`
|
||||
- Optional Modify: `docs/release-process.md` only if release steps change
|
||||
- Read: `src/app.rs`
|
||||
- Read: `src/settings.rs`
|
||||
- Read: `src/usage/*`
|
||||
- Read: `src/creds/*`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Run `cargo test`.
|
||||
2. Run `cargo build --release`.
|
||||
3. Run app with old settings file and confirm migration/defaults.
|
||||
4. Run app with all three providers enabled; verify separate bubbles, positions, tray icons, panel labels, and menu toggles.
|
||||
5. Verify missing OpenCode Go auth shows auth/no-credentials state without breaking other providers.
|
||||
6. With real OpenCode Go auth, verify weekly/monthly percent and remaining-time bars match the official console/source found in Phase 1.
|
||||
7. Update README privacy section with OpenCode auth file and endpoints.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Tests pass.
|
||||
- [ ] Release build passes.
|
||||
- [ ] Existing two-provider settings migrate.
|
||||
- [ ] Three-provider UI works on Windows.
|
||||
- [ ] OpenCode Go renders four bars: weekly usage percent, weekly remaining time, monthly usage percent, monthly remaining time.
|
||||
- [ ] README setup/privacy docs are accurate.
|
||||
- [ ] No unresolved OpenCode Go usage-source uncertainty remains.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Manual verification needs real OpenCode Go auth. If unavailable, ship code only behind disabled-by-default toggle and explicitly mark e2e verification deferred.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: OpenCode Go provider support and providers terminology
|
||||
description: >-
|
||||
Add OpenCode Go as a usage provider and rename user-facing Models wording to
|
||||
Providers.
|
||||
status: in-progress
|
||||
priority: P2
|
||||
branch: main
|
||||
tags:
|
||||
- providers
|
||||
- opencode-go
|
||||
- usage
|
||||
- ui-copy
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: '2026-06-02T04:20:10.983Z'
|
||||
createdBy: 'ck:plan'
|
||||
source: skill
|
||||
---
|
||||
|
||||
# OpenCode Go Provider Support And Providers Terminology
|
||||
|
||||
## Overview
|
||||
|
||||
Add OpenCode Go as a third selectable usage provider beside Claude Code and Codex. Rename the app's user-facing "Models" menu/copy to "Providers" because the app chooses quota sources/services, not individual model IDs.
|
||||
|
||||
I agree with the rename. OpenCode itself still has a `/models` command, but this app's menu toggles Claude Code, Codex, and OpenCode Go providers. Internals already use `ProviderId`, so the naming is conceptually aligned.
|
||||
|
||||
## Codebase Findings
|
||||
|
||||
- Rust Win32 app; usage abstraction lives under `src/usage/*`.
|
||||
- Current extension points: `UsageProvider`, `ProviderId`, `Registry`, `CredentialSource`, `RefreshHint`.
|
||||
- Two-provider assumptions remain in `src/settings.rs`, `src/app.rs`, `src/tray/mod.rs`, `src/tray/badge.rs`, `src/panel.rs`, `src/usage_color.rs`, i18n TOMLs, and README.
|
||||
- OpenCode official docs say Go is configured as an OpenCode provider, uses `/connect`, stores credentials in `~/.local/share/opencode/auth.json`, has 5h/weekly/monthly dollar-value limits, and current usage is visible in the console.
|
||||
- User decision: OpenCode Go should display four bars in two parts: upper part is weekly usage, lower part is monthly usage. Each part has the same two concepts as current Claude/Codex: usage percent and remaining time. Keep Claude/Codex on their existing two-bar presentation unless a separate UI redesign decides otherwise.
|
||||
- OpenCode docs expose model endpoints and `https://opencode.ai/zen/go/v1/models`, but do not document a stable usage endpoint. Do not fake usage percentages.
|
||||
- Phase 1 found the OpenCode console computes `weeklyUsage` and `monthlyUsage` with `usagePercent` and `resetInSec`, but only through an authenticated console server query. `opencode stats` is local cost/token history and cannot drive the requested Go bars.
|
||||
|
||||
## Related Plans
|
||||
|
||||
- `plans/260516-0707-cleanroom-rewrite/plan.md`: introduced current provider abstraction; related, no blocking dependency.
|
||||
- `plans/260523-ui-ux-improvement-plan/plan.md`: overlaps labels/tooltips; related, no blocking dependency.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Research OpenCode Go contracts](./phase-01-research-opencode-go-contracts.md) | Blocked |
|
||||
| 2 | [Refactor provider metadata and settings](./phase-02-refactor-provider-metadata-and-settings.md) | Completed |
|
||||
| 3 | [Implement OpenCode Go provider](./phase-03-implement-opencode-go-provider.md) | Pending |
|
||||
| 4 | [Rename Models UI to Providers](./phase-04-rename-models-ui-to-providers.md) | In Progress |
|
||||
| 5 | [Verify and document](./phase-05-verify-and-document.md) | Pending |
|
||||
|
||||
## Dependencies
|
||||
|
||||
Phase 1 gates Phase 3. If no stable OpenCode Go usage source is found, pause before Phase 3 and ask user whether to defer usage support or ship a limited connectivity/auth detector.
|
||||
|
||||
Phase 2 is implemented. Phase 4 provider wording is implemented; OpenCode Go remains listed but disabled until Phase 1's quota-source decision is resolved.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- OpenCode Go can be enabled/disabled independently without breaking Claude Code or Codex.
|
||||
- Existing settings migrate safely; at least one provider remains enabled.
|
||||
- UI and README say "Providers" where the app means selectable services.
|
||||
- No guessed OpenCode Go usage. Weekly/monthly percent and reset-time data comes from verified source, or feature pauses.
|
||||
- `cargo test` and `cargo build --release` pass.
|
||||
|
||||
## Sources
|
||||
|
||||
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
|
||||
- OpenCode CLI docs: https://opencode.ai/docs/cli/
|
||||
- Current repo README: `README.md`
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Research: OpenCode Go Contracts
|
||||
|
||||
Date: 2026-06-02
|
||||
Status: blocked
|
||||
|
||||
## Summary
|
||||
|
||||
OpenCode Go provider/model identity is verified, and the official console computes the exact quota fields we want. Implementation should not proceed yet because the verified quota source is an authenticated console server query, not a documented CLI command or public endpoint this desktop app can call safely.
|
||||
|
||||
## Verified Facts
|
||||
|
||||
- Provider/model ID format: OpenCode Go models use `opencode-go/<model-id>`, for example `opencode-go/kimi-k2.6`.
|
||||
- Official limits: Go has a rolling 5-hour window, a weekly window, and a monthly window. The requested UI only needs weekly and monthly sections.
|
||||
- Limits are cost based, not request-count based: weekly `$30`, monthly `$60`, and rolling 5-hour `$12`.
|
||||
- CLI auth docs say credentials are stored at `~/.local/share/opencode/auth.json`; locally, `opencode providers` is an alias for auth management.
|
||||
- Local `opencode stats` only aggregates local sessions from the OpenCode database and reports cost/token totals. It does not report Go quota percentages or reset times.
|
||||
- Console source has the desired shape:
|
||||
- `weeklyUsage: { status, resetInSec, usagePercent }`
|
||||
- `monthlyUsage: { status, resetInSec, usagePercent }`
|
||||
- Console source derives those from `LiteTable.weeklyUsage`, `LiteTable.monthlyUsage`, `LiteTable.timeWeeklyUpdated`, `LiteTable.timeMonthlyUpdated`, `LiteTable.timeCreated`, and `LiteData.getLimits()`.
|
||||
|
||||
## Sources
|
||||
|
||||
- OpenCode Go docs: https://dev.opencode.ai/docs/go/
|
||||
- OpenCode CLI docs: https://opencode.ai/docs/cli/
|
||||
- OpenCode source: `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- OpenCode source: `packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx`
|
||||
- OpenCode source: `packages/console/core/src/subscription.ts`
|
||||
- Local command: `opencode stats --help`
|
||||
|
||||
## Decision
|
||||
|
||||
Do not implement OpenCode Go four-bar usage yet. The app cannot honestly render weekly/monthly usage percent and remaining time until one of these is chosen:
|
||||
|
||||
1. Use an official/detected console endpoint with user-approved authentication.
|
||||
2. Ship only provider/auth detection now and hide/disable Go usage bars until quota data is available.
|
||||
3. Ask OpenCode upstream for a documented quota API or CLI output.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Should this app integrate with the authenticated OpenCode console, or should Go support be limited to detection until OpenCode exposes a stable quota API?
|
||||
- Do we still want to continue Phase 2/4 now for the provider terminology rename, while deferring Phase 3?
|
||||
@@ -0,0 +1,221 @@
|
||||
# UI Design Review — Floating Bubble
|
||||
|
||||
Date: 2026-05-23
|
||||
Scope: Visual-only redesign of the stadium bubble (head ring + tail bars).
|
||||
Constraints: tiny-skia primitives only (AA fills, AA stroked arcs, AA pills) + GDI text. No new deps. No animations beyond existing pulse. Logical px values; `scale_to_dpi` handles HiDPI.
|
||||
|
||||
Files reviewed:
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\bubble.rs` (lines 1032–1703)
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\usage_color.rs`
|
||||
- `D:\tiennm99\claude-code-usage-bubble\src\os\color.rs`
|
||||
|
||||
---
|
||||
|
||||
## 1. Issues Found
|
||||
|
||||
### A. Information hierarchy is flat
|
||||
The big head "31%" and the tail "64%" are typographically equal in weight against their backgrounds — but they answer different questions (now vs. weekly). The eye has no anchor. Add weight contrast.
|
||||
|
||||
### B. Tail text is cramped
|
||||
`tail_usage_pct_rect` and `tail_time_text_rect` share the same right edge (`content_right`) with no rule for the gap between the bar end and the percent label. With `pad = 6 logical` and `pct_reserve_w` literally just `measure("100%") + 2 logical`, the "64% / 3d" pair reads as one glyph blob. The two right-aligned tokens stack with only ~2 logical px of internal breathing.
|
||||
|
||||
### C. The inner time ring is nearly invisible
|
||||
At ring_stroke_w 3 logical and time_ring_stroke_w 2 logical with only a 3-logical gap between them (line 1074), and using `#303030` track on a `#1F1F1F` background, the ratio is ~1.13:1. Below the visibility threshold; users won't read it as a ring.
|
||||
|
||||
### D. Two bubbles read as one merged blob
|
||||
Claude and Codex stagger vertically by `height_px + gap=24` (line 1750) but visually the dark-on-dark stadia float without anchor — there's no provider identifier inside the bubble itself. The accent color is the *only* differentiator, and it disappears below 60% (Codex teal vs. orange both reduce to white at that range for tail text — see line 1646).
|
||||
|
||||
### E. Track contrast vs. fill is loud
|
||||
`track = #3A3A3A` on `#1F1F1F` bg (4.0:1) is louder than the fill at low percentages. At 5% usage the dim track screams more than the bright fill — backwards visual priority.
|
||||
|
||||
### F. Time-bar reads as a second-quota
|
||||
The grey time bar fills *left-to-right* same direction as the usage bar, and shares the same shape, position, and visual weight class. A user glancing sees "two progress bars" and assumes both are quotas. The grey hue helps but the *gestalt* fights it.
|
||||
|
||||
### G. Head "5h" label is buried
|
||||
`small_font_px ≈ 55% of big_font_px` and uses `#888888`. At 200 logical width the label is ~10px and dim. It's the only thing telling the user the ring is the *5-hour* window.
|
||||
|
||||
### H. Ring uses round caps but track does not — visual mismatch
|
||||
Active arc has `LineCap::Round` (line 1249) but the track is a full circle. At low percentages the rounded start cap juts out above the track — looks unfinished. The track should be `LineCap::Butt` (default closed circle is fine) but the *visual idiom* would benefit from the track being a hint subtler.
|
||||
|
||||
### I. Corner radius of pill = `canvas_h / 2` is fine, but the head circle inscribed in the same height feels visually small
|
||||
`ring_radius = head_diameter/2 - 4 - stroke/2` makes the ring fill ~92% of the head square — but the head_square equals the canvas height, so the head looks slightly under-sized vs. the visual weight of the tail bars. Slight padding nudge.
|
||||
|
||||
### J. No separator/cue between the two stacked bubbles
|
||||
Not a per-bubble issue, but worth noting: when both providers run, a faint provider mark inside each bubble would let users disambiguate without remembering "the upper one is Codex."
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposed Changes
|
||||
|
||||
All values are **logical px**. Hex colors are dark-theme; the light-theme entry shown after `/`.
|
||||
|
||||
### Change 1 — Demote the head "5h" label, promote into a chip
|
||||
**What:** Keep the small label, but render it as an uppercase, letter-spaced micro-cap inside a 1-px-stroke pill (no fill).
|
||||
**Why:** Reads as a "window selector" tag rather than disambiguated noise.
|
||||
**Values:**
|
||||
- text: `"5H"` (uppercase, was `"5h"`)
|
||||
- font weight: `FW_SEMIBOLD` (was normal)
|
||||
- letter-spacing: simulate via `+1 logical px` between glyphs — actually, just keep tracking from font; the uppercase alone reads stronger.
|
||||
- color: `#A8A8A8` / `#5E5E5E` (was `#888888` / `#6E6E6E`)
|
||||
- no chip border for v1 — KISS. Just style the text. If chip is wanted later, AA stroke a pill rect.
|
||||
- Keep current vertical position; the `label_pct_gap` is fine.
|
||||
|
||||
### Change 2 — Bump big-percent weight + tighten size
|
||||
**What:** Big number gets heavier and slightly smaller; tightens visual mass.
|
||||
**Why:** Heavier weight = stronger anchor without taking more space.
|
||||
**Values:**
|
||||
- weight: `FW_BOLD` (was `FW_SEMIBOLD`)
|
||||
- size factor: `big_font_px = head_diameter * 24/100` (was `26/100`)
|
||||
- color unchanged: `#EAEAEA` / `#1F1F1F`
|
||||
|
||||
### Change 3 — Lift the inner time ring above noise
|
||||
**What:** Increase contrast of the time-ring track and fill; thicken slightly.
|
||||
**Why:** Current ratio of 1.13:1 against bg is invisible. Per WCAG 1.4.11 non-text 3:1 minimum.
|
||||
**Values:**
|
||||
- `time_ring_stroke_w`: `scale_to_dpi(2, dpi).clamp(2, 3)` (was `1..3`, effective 1px floor → too thin)
|
||||
- gap between outer ring inner edge and inner ring outer edge: `4 logical` (was `3`)
|
||||
- time_track: `#2F2F2F` → **`#404040`** (3.5:1) / light unchanged
|
||||
- time_fill (used for inner-ring active arc *and* tail time-bar fill): `#9A9A9A` → **`#B0B0B0`** / `#777777` → `#666666`
|
||||
- Keep `LineCap::Round` on the active arc.
|
||||
|
||||
### Change 4 — Reserve a real gap between tail bar and tail text
|
||||
**What:** Add a `bar_text_gap = 8` between bar end and text left edge (currently `pad = 6`).
|
||||
**Why:** Eight is the eyeballed minimum where the eye registers "two columns" instead of "one wall of glyphs."
|
||||
**Values:**
|
||||
- new constant: `bar_text_gap = scale_to_dpi(8, dpi)` (was effectively `pad = 6`)
|
||||
- `bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);` (line 1126)
|
||||
- `pad` stays `6` for the head→tail content_left inset.
|
||||
|
||||
### Change 5 — Right-edge inset
|
||||
**What:** Increase inner right margin of the tail.
|
||||
**Why:** The current `scale_to_dpi(12, dpi)` insetinto the pill's rounded right cap leaves text near the curvature. Bump to clear the cap visually.
|
||||
**Values:**
|
||||
- `tail_right = width_px - scale_to_dpi(14, dpi)` (was `12`)
|
||||
|
||||
### Change 6 — Reweight tail percent vs. tail countdown
|
||||
**What:** Make the tail percent a touch heavier than the countdown so the *number* anchors the lane.
|
||||
**Why:** Today both are FW_NORMAL same size, both `text_color` — flat. Bigger number with smaller dimmer suffix establishes hierarchy.
|
||||
**Values:**
|
||||
- weekly percent: `FW_SEMIBOLD`, color `text_color` (`#EAEAEA` / `#1F1F1F`)
|
||||
- weekly countdown: `FW_NORMAL`, color **`muted_color`** (`#A8A8A8` / `#5E5E5E`) — was `text_color`
|
||||
- Font sizes unchanged: both `small_font_px` / `main_font_px`.
|
||||
|
||||
### Change 7 — Tone down the usage-bar track
|
||||
**What:** Drop track contrast so the *fill* dominates, not the track.
|
||||
**Why:** At low percent (5–10%) the bright track outscreams the fill. Track should be a hint.
|
||||
**Values:**
|
||||
- `track`: `#3A3A3A` → **`#2C2C2C`** (was 4.0:1 vs. bg; now 1.6:1 — the *fill* hits 4.5:1+ from accent colors and carries the signal)
|
||||
- light theme: `#D6D6D6` → `#E2E2E2`
|
||||
|
||||
### Change 8 — Differentiate the time-bar shape from the usage-bar shape
|
||||
**What:** Make the time bar visibly *thinner and lower-contrast* so it doesn't read as a second quota.
|
||||
**Why:** Current ratio: usage_bar 9% of height, time_bar 5% — close. Push the spread.
|
||||
**Values:**
|
||||
- `usage_bar_h = (height_px * 10 / 100).clamp(6, 12)` (was 9% / clamp 5–12)
|
||||
- `time_bar_h = (height_px * 4 / 100).clamp(3, 6)` (was 5% / clamp 3–7)
|
||||
- `lane_gap = scale_to_dpi(6, dpi)` (was 5)
|
||||
- This gives the usage bar ~2.5× the visual mass of the time bar — clear "primary" vs. "context."
|
||||
|
||||
### Change 9 — Pull the head ring in by 1 px so the head circle feels deliberate
|
||||
**What:** Slightly more head padding; ring sits 1 logical px farther in.
|
||||
**Why:** Ring currently kisses the visual edge of the head square; a touch of breathing room makes the head feel composed and balances vs. the heavier tail.
|
||||
**Values:**
|
||||
- `head_pad = scale_to_dpi(5, dpi)` (was 4)
|
||||
- `ring_stroke_w`: keep `scale_to_dpi(3, dpi).clamp(2, 4)` — already good.
|
||||
|
||||
### Change 10 — Provider mark dot (subtle disambiguator)
|
||||
**What:** A 4×4 logical solid circle in the accent color, positioned at the *outer* edge of the ring at 12 o'clock — between the ring and the head's left edge.
|
||||
**Why:** Today the only provider tell is the accent of the active arc; below 60% the arc *is* the accent so it works, but at >60% the arc shifts to amber/red and the provider identity vanishes. A constant dot fixes that. Also helps when two bubbles stack.
|
||||
**Values:**
|
||||
- center: `(ring_cx, ring_cy - ring_radius - ring_stroke_w/2 - 4)` — i.e. 4 logical px above the ring's outer edge
|
||||
- radius: `scale_to_dpi(2, dpi)` (logical 2 → diameter 4)
|
||||
- color: `accent_color_for(model, is_dark)` (existing function — `#D97757` Claude / `#10A37F` Codex)
|
||||
- Implemented as one extra `pb.push_circle(...)` fill before the ring strokes — zero new dependencies.
|
||||
|
||||
### Change 11 — Round-cap the active tail bars; flat-cap the tracks
|
||||
**What:** Keep the existing `paint_pill` for the *track*, but reduce its end-cap radius. For the *fill*, keep full cap. Actually, simpler: leave both as full-cap pills (current behavior) — just ensure the fill never paints below `2 * cap` width.
|
||||
**Why:** Already correct in code (`paint_pill` does both end-caps). No change needed visually, but lock in a min-fill so the bar at 1% doesn't render as a dot.
|
||||
**Values:**
|
||||
- in the weekly-pct render block (line 1300+): if `fill_w > 0.0 && fill_w < bar_h`, set `fill_w = bar_h` (a one-cap-diameter minimum). Cosmetic only — preserves "I see some progress" cue when usage is 0.1–2%.
|
||||
|
||||
### Change 12 — Text-color for tail percent when bar is in alarm range
|
||||
**What:** When `weekly_pct >= 95`, tint the percent text toward the alarm color instead of bumping its luminance via `brighten` only.
|
||||
**Why:** Today, at 98%, the text just gets *brighter* via the pulse — but in dark mode the bar is already pulsing deep red. Tinting the number red ties it to the bar.
|
||||
**Values:**
|
||||
- if `pct >= 95.0`: `text_color_for_pct = #E08070` (dark) / `#B02810` (light), then apply pulse `brighten` on top.
|
||||
- Keep the FW_SEMIBOLD from Change 6.
|
||||
- 80 ≤ pct < 95: leave at default text color (the bar carries the warning).
|
||||
|
||||
---
|
||||
|
||||
## 3. ASCII Mockup (one bubble, dark, ~270 logical px wide)
|
||||
|
||||
```
|
||||
canvas_w = 270 (logical)
|
||||
<─────────────────────────────────────────────────────────────────>
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ • <─ accent dot (2-logical r, 4 above ring outer) │
|
||||
│ ╭───╮ │
|
||||
│ / \ ┌─────────────────────────────────────┐ │ ^
|
||||
│ │ ┌───┐ │ │▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░│ 64% │ ←lane │ |
|
||||
│ │ │5H │ │ └─────────────────────────────────┴─────┘ │ |
|
||||
│ │ │31%│ │ ┌─────────────────────────────┐ 3d ←FW_NORMAL│ | height
|
||||
│ │ └───┘ │ │██░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ muted│ |
|
||||
│ \ / └─────────────────────────────┘ │ |
|
||||
│ ╰───╯ │ v
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
<──head_diameter──><pad=6><────────bar_w────────><gap=8><text_w>
|
||||
= canvas_h <r-inset=14>
|
||||
```
|
||||
|
||||
Key:
|
||||
- `•` accent dot (4-logical px diameter, provider color)
|
||||
- Outer thick ring = 5h usage (active arc in accent / amber / red)
|
||||
- Inner thinner ring = 5h remaining time (now `#B0B0B0` on `#404040`, visible)
|
||||
- `5H` = uppercase semibold label, `muted` color, sits above the big number
|
||||
- `31%` = big bold pct, `text_color`
|
||||
- `▓▓▓` top tail bar = weekly usage fill (accent/amber/red), `usage_bar_h ≈ 10% of canvas_h`
|
||||
- `░░░` track = `#2C2C2C` (toned down)
|
||||
- `64%` = right-aligned, FW_SEMIBOLD, text_color
|
||||
- `██░░` bottom tail bar = remaining time, `time_bar_h ≈ 4% of canvas_h` (visibly thinner)
|
||||
- `3d` = right-aligned, FW_NORMAL, muted_color
|
||||
- 8-logical gap between bar end and right-aligned text column (Change 4)
|
||||
|
||||
---
|
||||
|
||||
## 4. Do Not Change
|
||||
|
||||
- **Overall stadium shape with `corner_radius = canvas_h/2`** — clean and iconic, photographs well in screenshots.
|
||||
- **Head-on-left, tail-on-right layout** — well-established mental model; sweeping arc + horizontal bar = "circular thing for now-ish, linear thing for week-ish."
|
||||
- **Accent ramp by percent** (60/80/95 thresholds in `usage_color.rs`) — solid color logic; don't touch.
|
||||
- **Pulse animation at ≥95%** — subtle, draws the right amount of attention. Keep.
|
||||
- **`paint_pill` two-circle + middle-rect construction** — exactly right for tiny-skia. Don't refactor to a rounded-rect path; the current is faster and AA-clean.
|
||||
- **`LineCap::Round` on the active arc** — feels alive; the "unfinished" look at low % I mentioned in issue (H) is acceptable trade-off.
|
||||
- **DPI scaling via `scale_to_dpi`** — keep all proposed values in logical px and let the function do its job.
|
||||
- **Locale-aware countdown width via `COUNTDOWN_TEMPLATE = "999시간"`** — clever and right; preserve.
|
||||
- **`DT_END_ELLIPSIS` on tail text** — graceful degradation at narrow widths.
|
||||
- **Existing fallback from countdown to `"5H"` when label rect is too narrow** — keep, just change the static fallback to uppercase per Change 1.
|
||||
- **Aspect ratio taper (`aspect_at_width`)** — tail breathes better at wider sizes; preserve.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- All proposed color tokens belong inline in `paint_bubble_pixmap` and `paint_bubble_text` — no new modules needed.
|
||||
- The accent dot (Change 10) is ~3 new lines in the ring block.
|
||||
- Bar height and gap changes (Change 8) are 3 single-line edits in `compute_bubble_layout`.
|
||||
- Min-fill (Change 11) is a one-line guard before `paint_pill` for the weekly fill.
|
||||
- No new geometry struct fields needed. No new fonts. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **Light-theme accent dot**: Claude `#D97757` and Codex `#10A37F` on `#F3F3F3` — Codex teal contrast is ~3.0:1, borderline. Accept (it's a 4-px decorative dot, not text) or use a darkened variant for light theme?
|
||||
2. **Change 12 alarm-tint**: should it apply to the head big-pct too, or only to the tail pct? Current proposal is tail-only. Confirm whether the head should also tint red at ≥95%.
|
||||
3. **Change 10 dot position**: 12 o'clock is canonical but could be at 10–11 o'clock so the start of the active arc (which begins at 12 sweeping clockwise) doesn't visually merge with the dot. Open to either.
|
||||
|
||||
---
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** Twelve concrete, primitive-implementable changes that hierarchically organize percentages, raise the invisible inner time ring above WCAG 1.4.11, breathe the cramped tail text by 8 logical px, and add a 4-px provider dot for identity — all without new deps or animation engines.
|
||||
@@ -0,0 +1,187 @@
|
||||
# UI Rendering Code Review
|
||||
|
||||
Scope: `src/bubble.rs` (renderer), `src/usage_color.rs`, `src/os/color.rs`,
|
||||
drawing portions of `src/panel.rs`, `src/tray/badge.rs`.
|
||||
|
||||
## Findings
|
||||
|
||||
1. **`src/bubble.rs:1178-1197` — severity: high.** Eight neutral surface
|
||||
colours (`#1F1F1F`, `#F3F3F3`, `#3A3A3A`, `#D6D6D6`, `#303030`, `#E0E0E0`,
|
||||
`#9A9A9A`, `#777777`) are hex literals inside `paint_bubble_pixmap` and
|
||||
duplicated near-verbatim in `panel.rs:279-293` (`#1F1F1F`, `#FAFAFA`,
|
||||
`#EAEAEA`, `#3A3A3A`, `#D6D6D6`) plus `bubble.rs:1590-1597` text colours.
|
||||
Three surfaces silently disagree (`#F3F3F3` bubble bg vs `#FAFAFA` panel
|
||||
bg) and a designer cannot retune the palette without grepping. Fix:
|
||||
centralise into a `palette` module alongside `usage_color.rs` exposing
|
||||
`bg(is_dark)`, `track(is_dark)`, `time_track(is_dark)`, `time_fill(is_dark)`,
|
||||
`text(is_dark)`, `muted(is_dark)`; have `panel.rs` consume the same helpers.
|
||||
|
||||
2. **`src/tray/badge.rs:53, 112, 114` — severity: high.** Badge colours go
|
||||
through `paint.set_color_rgba8(0x3a, 0x3a, 0x3a, 255)` and raw
|
||||
`[u8; 3]` arrays instead of `Rgb` / `os::color`. `#3A3A3A` already exists
|
||||
as the shared "track" colour in `bubble.rs:1184`, but the tray hard-codes
|
||||
it. The two Claude/Codex base tints (`#2A1F1C`, `#1A1F26`) also live only
|
||||
here. Fix: route through the same palette module, even if the badge keeps
|
||||
its own dark inner-disk variants (named constants beat magic byte arrays).
|
||||
|
||||
3. **`src/bubble.rs:1064-1117` — severity: high.** Padding/gap literals
|
||||
`scale_to_dpi(2|4|5|6|8|12, dpi)` appear 15+ times inside
|
||||
`compute_bubble_layout` with no naming. Same logical "edge padding"
|
||||
(`scale_to_dpi(4, dpi)`) is used for `head_pad`, head-label left/right,
|
||||
head-pct left/right (lines 1064, 1086, 1088, 1092, 1094); same "small
|
||||
nudge" (`scale_to_dpi(2, dpi)`) is the ring stroke clamp, label/pct
|
||||
row vertical breathing room, pct-reserve gap, and time-text padding. A
|
||||
designer tweaking head-text padding will touch four lines and miss the
|
||||
fifth. Fix: hoist named DPI-scaled constants at the top of the function
|
||||
(`HEAD_PAD`, `TEXT_VPAD`, `LANE_GAP`, `TAIL_PAD`, `RIGHT_INSET`,
|
||||
`BAR_MIN_W`) and reuse — same pattern `panel.rs` uses with its
|
||||
`*_LOGICAL` constants (line 24-30).
|
||||
|
||||
4. **`src/panel.rs:333-340` — severity: med.** Bar-x / bar-w / row-y math
|
||||
mixes `scaled(PADDING_LOGICAL)`, `scaled(LABEL_W_LOGICAL)`,
|
||||
`scaled(RIGHT_TEXT_W_LOGICAL)` with bare `scaled(4)`, `scaled(8)`,
|
||||
`scaled(24)`, `scaled(18)` — four un-named "small" values doing
|
||||
semantically distinct jobs (label-bar gap, bar-text gap, header height,
|
||||
row-1 offset). Fix: name them (`LABEL_BAR_GAP_LOGICAL`,
|
||||
`BAR_TEXT_GAP_LOGICAL`, `HEADER_H_LOGICAL`, `HEADER_OFFSET_LOGICAL`) so
|
||||
the row geometry is auditable in one place.
|
||||
|
||||
5. **`src/panel.rs:340` — severity: med.** `row2_y = row1_y +
|
||||
scale_to_dpi(BAR_HEIGHT_LOGICAL, dpi) + scale_to_dpi(ROW_GAP_LOGICAL, dpi)
|
||||
+ scaled(8)`. The trailing `+ scaled(8)` is an unexplained extra gap on
|
||||
top of `ROW_GAP_LOGICAL`; this is exactly the inconsistency `ROW_GAP_LOGICAL`
|
||||
was created to prevent. Fix: fold into `ROW_GAP_LOGICAL` (16) or rename
|
||||
the extra into a labelled `ROW_TEXT_GAP_LOGICAL`.
|
||||
|
||||
6. **`src/bubble.rs:1099, 1126` — severity: med.** `tail_right = width_px -
|
||||
scale_to_dpi(12, dpi)` and `bar_right = (text_left - pad).max(bar_left +
|
||||
bar_min)`. The `12` is the right-edge inset to clear the stadium's right
|
||||
end-cap; this is conceptually `corner_radius / 2`-ish but encoded as a
|
||||
constant that won't track if aspect ratio changes. Fix: derive from
|
||||
`layout.corner_radius` or hoist a `TAIL_RIGHT_INSET` constant with a
|
||||
comment tying it to the end-cap curvature.
|
||||
|
||||
7. **`src/bubble.rs:1360-1377` and `src/tray/badge.rs:86-106` — severity:
|
||||
med.** `build_arc` is duplicated verbatim between the bubble renderer
|
||||
and the tray badge — same 64-segment sampling, same `FRAC_PI_2` start,
|
||||
same edge-case `.max(1)` segment count. Fix: lift into a small
|
||||
`geometry` / `tiny_skia_helpers` module shared by both call sites;
|
||||
change neither call site to keep behaviour identical.
|
||||
|
||||
8. **`src/bubble.rs:1339-1352` — severity: med.** `paint_pill` is a perfect
|
||||
helper candidate for `panel.rs`'s bar drawing — `panel.rs` uses
|
||||
`FillRect` rectangles with hard corners (`draw_row` lines 406-428),
|
||||
visually inconsistent with the bubble's rounded pill caps. Fix: extract
|
||||
`paint_pill` to a shared rendering helper module and have panel use it
|
||||
so the two surfaces have matching bar geometry. (Cross-surface
|
||||
consistency was the stated reason for `usage_color.rs` existing — same
|
||||
logic applies to bar shape.)
|
||||
|
||||
9. **`src/bubble.rs:1080-1083, 1111-1112` — severity: med.** `head_label_h`,
|
||||
`head_pct_h`, `time_text_h`, `usage_pct_h` all add `scale_to_dpi(2, dpi)`
|
||||
of "breathing room" to a font height, but never call it that — and the
|
||||
computed rect height is then used by `DrawTextW` with `DT_VCENTER` so a
|
||||
too-tight value would clip ascenders/descenders. Currently safe because
|
||||
2 px (logical) ≈ font leading, but the magic `2` is load-bearing. Fix:
|
||||
`const FONT_VPAD_LOGICAL: i32 = 2;` with a one-line comment "ascender/
|
||||
descender slack for DT_VCENTER".
|
||||
|
||||
10. **`src/bubble.rs:1141-1146, 1153-1158` — severity: low.** The vertical
|
||||
centring expression `usage_bar_top + (usage_bar_h - usage_pct_h) / 2`
|
||||
is computed twice for `tail_usage_pct_rect` (top + bottom). Tiny but
|
||||
if a designer asks "where does the % text sit relative to the bar?"
|
||||
they have to mentally simplify. Fix: compute `pct_text_top` /
|
||||
`time_text_top` as named locals before the struct literal.
|
||||
|
||||
11. **`src/bubble.rs:1076, 1077` — severity: low.** `big_font_px =
|
||||
head_diameter * 26 / 100`; `small_font_px = big_font_px * 55 / 100`.
|
||||
The 26 % and 55 % ratios are the core typographic scale of the head
|
||||
text — promote to `BIG_FONT_RATIO_PCT`, `SMALL_TO_BIG_FONT_PCT`
|
||||
constants with a "tweak these to retune head proportions" comment.
|
||||
|
||||
12. **`src/bubble.rs:1078` — severity: low (dead-code adjacent).**
|
||||
`main_font_px = small_font_px;` — `main_font_px` is identical to
|
||||
`small_font_px` but kept as a separate field on `BubbleLayout`
|
||||
(line 1056) and used for the countdown (line 1604, 1658). If the
|
||||
intent is "may diverge in future", document it; otherwise drop the
|
||||
duplicate field and use `small_font_px` directly.
|
||||
|
||||
13. **`src/bubble.rs:1085-1096` — severity: low.** `head_label_rect` and
|
||||
`head_pct_rect` both use `left: scale_to_dpi(4, dpi)` and `right:
|
||||
head_diameter - scale_to_dpi(4, dpi)` — identical horizontal extents.
|
||||
Could share a single `head_text_left`/`head_text_right` pair to make
|
||||
"head text is centered in the head circle" structurally visible.
|
||||
|
||||
14. **`src/bubble.rs:1216 vs 1339-1352` — severity: low.** Stadium
|
||||
background uses inline two-circle-plus-rect path; the pill helper
|
||||
does the same shape. The stadium could call `paint_pill(pixmap, 0.0,
|
||||
0.0, w, h, h/2.0, bg)` and shed ~15 lines. Worth doing once
|
||||
`paint_pill` moves to a shared module (finding 8).
|
||||
|
||||
15. **`src/bubble.rs:1653` — severity: low (text layout).**
|
||||
`draw_tail_text_in_rect` is called with `DT_RIGHT | DT_VCENTER |
|
||||
DT_SINGLELINE | DT_END_ELLIPSIS`. The `DT_END_ELLIPSIS` on a
|
||||
right-aligned 3-char string ("100%") inside a tight rect will produce
|
||||
`1…` if the rect collapses by even a pixel — fine, but worth
|
||||
confirming the `pct_reserve_w` (line 1103) leaves a 1-px AA safety
|
||||
margin. Current `+ scale_to_dpi(2, dpi)` looks adequate. No fix
|
||||
needed; flag for future locale changes.
|
||||
|
||||
16. **`src/bubble.rs:1674-1685` — severity: low.** `draw_text_in_rect`
|
||||
always uses `DT_NOCLIP`; `draw_tail_text_in_rect` uses
|
||||
`DT_END_ELLIPSIS` (no `DT_NOCLIP`). The two helpers diverge silently
|
||||
on whether text may escape its rect. Document the contract on each
|
||||
helper ("head text trusts layout, tail text fits-or-ellipsises").
|
||||
|
||||
17. **`src/panel.rs:447-501` — severity: low.** `draw_text` creates and
|
||||
destroys a font on every call (4× per `paint`). Same anti-pattern in
|
||||
`bubble.rs:paint_bubble_text` is amortised by caching `big_font`,
|
||||
`small_font`, `main_font` for the whole paint. Not a correctness
|
||||
issue but means the panel allocates 4 GDI fonts on every
|
||||
InvalidateRect. Cache by `(size, bold)` keyed on the HDC.
|
||||
|
||||
## Quick wins
|
||||
|
||||
1. **Centralise the neutral palette** (findings 1, 2). One new module
|
||||
`palette.rs` exposing `bg / track / time_track / time_fill / text /
|
||||
muted` plus tray-specific tints. Replace all `Color::from_hex(...)`
|
||||
calls in `bubble.rs:1178-1197`, `bubble.rs:1589-1598`,
|
||||
`panel.rs:279-293`, and the byte arrays in `tray/badge.rs`. ~20-line
|
||||
diff, kills cross-surface drift.
|
||||
|
||||
2. **Name the padding constants in `compute_bubble_layout`** (finding 3,
|
||||
9). Add ~6 `const` declarations at the top of the function — keeps
|
||||
them locally scoped, matches `panel.rs` style. Designer can retune
|
||||
metrics from one block.
|
||||
|
||||
3. **Lift `build_arc` to a shared `tiny_skia_helpers` module** (finding
|
||||
7). Two-file delete-and-import. Identical behaviour.
|
||||
|
||||
4. **Promote `paint_pill` and reuse in `panel.rs` rows + stadium bg**
|
||||
(findings 8, 14). Makes bar visual style consistent between bubble
|
||||
and panel; bonus simplification of the stadium fill.
|
||||
|
||||
5. **Drop / rename `main_font_px`** (finding 12). Either delete the field
|
||||
and use `small_font_px` directly, or split the constants (`MAIN_FONT_RATIO`)
|
||||
so future divergence is intentional.
|
||||
|
||||
## Defer
|
||||
|
||||
- Finding 4-6 (panel/bubble padding naming, derived `TAIL_RIGHT_INSET`):
|
||||
worth doing alongside the palette refactor but not blocking.
|
||||
- Finding 10, 13 (local temporaries for centring math, shared
|
||||
`head_text_*` extents): pure readability, low payoff.
|
||||
- Finding 15 (ellipsis safety margin on locale changes): keep an eye on
|
||||
this when adding a locale wider than `999시간`.
|
||||
- Finding 17 (panel GDI font caching): only matters if the panel starts
|
||||
refreshing more often than once per poll cycle.
|
||||
|
||||
Out-of-scope sighting (one-line flag as instructed): `compute_bubble_layout`
|
||||
takes an `HDC` purely to measure text — couples geometry calc to a live GDI
|
||||
device. Not a render bug, but it makes the function untestable without a
|
||||
window and is worth refactoring when convenient.
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** Renderer is functionally solid; the dominant problem is
|
||||
duplicated/un-named geometry and colour literals scattered across bubble /
|
||||
panel / badge that drift independently and resist designer-led tuning.
|
||||
+60
-42
@@ -66,6 +66,7 @@ const IDM_FREQ_15MIN: u16 = 12;
|
||||
const IDM_FREQ_1HOUR: u16 = 13;
|
||||
const IDM_MODEL_CLAUDE: u16 = 20;
|
||||
const IDM_MODEL_CHATGPT: u16 = 21;
|
||||
const IDM_MODEL_OPENCODE_GO: u16 = 22;
|
||||
const IDM_START_WITH_WINDOWS: u16 = 30;
|
||||
const IDM_RESET_POSITION: u16 = 31;
|
||||
const IDM_VERSION_ACTION: u16 = 32;
|
||||
@@ -267,6 +268,7 @@ pub fn run(args: crate::AppArgs) {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
update::handoff::cleanup_stale_old_exes(&exe_path);
|
||||
}
|
||||
update::install::cleanup_staged_update_files();
|
||||
|
||||
let poll_interval = lock_state()
|
||||
.as_ref()
|
||||
@@ -324,11 +326,10 @@ fn create_initial_bubbles() {
|
||||
Some(s) => (s.settings.clone(), s.is_dark),
|
||||
None => return,
|
||||
};
|
||||
if settings.show_claude_code {
|
||||
spawn_bubble(ProviderId::Claude, &settings, is_dark);
|
||||
}
|
||||
if settings.show_codex {
|
||||
spawn_bubble(ProviderId::ChatGpt, &settings, is_dark);
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if settings.is_provider_enabled(provider) {
|
||||
spawn_bubble(provider, &settings, is_dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +344,10 @@ fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
|
||||
position: settings.bubble_positions.get(kind),
|
||||
session_pct: None,
|
||||
session_text: placeholder.clone(),
|
||||
session_resets_at: None,
|
||||
weekly_pct: None,
|
||||
weekly_text: placeholder,
|
||||
weekly_resets_at: None,
|
||||
is_dark,
|
||||
});
|
||||
if hwnd != HWND::default() {
|
||||
@@ -418,6 +421,7 @@ fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||
IDM_FREQ_1HOUR => set_poll_interval(POLL_1_HOUR),
|
||||
IDM_MODEL_CLAUDE => toggle_model(ProviderId::Claude),
|
||||
IDM_MODEL_CHATGPT => toggle_model(ProviderId::ChatGpt),
|
||||
IDM_MODEL_OPENCODE_GO => {}
|
||||
IDM_START_WITH_WINDOWS => toggle_startup(),
|
||||
IDM_RESET_POSITION => reset_positions(),
|
||||
IDM_VERSION_ACTION => version_action(),
|
||||
@@ -640,12 +644,16 @@ fn propagate_to_ui() {
|
||||
let weekly_text = entry
|
||||
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
||||
.unwrap_or_default();
|
||||
let session_resets_at = entry.and_then(|s| s.windows.primary.resets_at);
|
||||
let weekly_resets_at = entry.and_then(|s| s.windows.secondary.resets_at);
|
||||
bubble::update_data(
|
||||
hwnd.to_hwnd(),
|
||||
session_pct,
|
||||
session_text,
|
||||
session_resets_at,
|
||||
weekly_pct,
|
||||
weekly_text,
|
||||
weekly_resets_at,
|
||||
);
|
||||
}
|
||||
refresh_tray_icons_with(&snap);
|
||||
@@ -754,28 +762,23 @@ fn refresh_tray_icons() {
|
||||
|
||||
fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
||||
let mut icons = Vec::new();
|
||||
if snap.settings.show_claude_code {
|
||||
let entry = snap.snapshots.get(&ProviderId::Claude);
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if !snap.settings.is_provider_enabled(provider) {
|
||||
continue;
|
||||
}
|
||||
let entry = snap.snapshots.get(&provider);
|
||||
icons.push(TrayIconData {
|
||||
kind: ProviderId::Claude,
|
||||
kind: provider,
|
||||
percent: if snap.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
|
||||
});
|
||||
}
|
||||
if snap.settings.show_codex {
|
||||
let entry = snap.snapshots.get(&ProviderId::ChatGpt);
|
||||
icons.push(TrayIconData {
|
||||
kind: ProviderId::ChatGpt,
|
||||
percent: if snap.last_poll_ok {
|
||||
entry.map(|e| e.windows.primary.utilization)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
|
||||
tooltip: tray_tooltip(
|
||||
&provider_label(provider, &snap.i18n_strings),
|
||||
entry,
|
||||
&snap.i18n_strings,
|
||||
),
|
||||
});
|
||||
}
|
||||
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
||||
@@ -796,6 +799,14 @@ fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleSt
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_label(provider: ProviderId, strings: &LocaleStrings) -> String {
|
||||
match provider {
|
||||
ProviderId::Claude => strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
|
||||
ProviderId::OpenCodeGo => strings.opencode_go_label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tray_action(action: TrayAction) {
|
||||
match action {
|
||||
TrayAction::None => {}
|
||||
@@ -852,10 +863,7 @@ fn show_threshold_balloon(provider: ProviderId, threshold: u8) {
|
||||
}
|
||||
s.last_balloon_at = Some(Instant::now());
|
||||
let strings = s.i18n.strings();
|
||||
let provider_label = match provider {
|
||||
ProviderId::Claude => strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => strings.chatgpt_label.clone(),
|
||||
};
|
||||
let provider_label = provider_label(provider, strings);
|
||||
let title = format!("{provider_label} · {threshold}%");
|
||||
let body = if threshold >= 95 {
|
||||
strings.threshold_95_body.clone()
|
||||
@@ -889,6 +897,10 @@ fn show_token_expired_balloon(failed: ProviderId) {
|
||||
strings.chatgpt_token_expired_title.clone(),
|
||||
strings.chatgpt_token_expired_body.clone(),
|
||||
),
|
||||
ProviderId::OpenCodeGo => (
|
||||
strings.opencode_go_label.clone(),
|
||||
strings.update_failed.clone(),
|
||||
),
|
||||
};
|
||||
(s.msg_hwnd, failed, title, body)
|
||||
};
|
||||
@@ -928,6 +940,7 @@ struct ContextMenuSnapshot {
|
||||
update_check_interval_secs: Option<u64>,
|
||||
show_claude: bool,
|
||||
show_chatgpt: bool,
|
||||
show_opencode_go: bool,
|
||||
widget_visible: bool,
|
||||
install_channel: InstallChannel,
|
||||
update_status: UpdateStatus,
|
||||
@@ -948,6 +961,7 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
update_check_interval_secs: s.settings.update_check_interval_secs,
|
||||
show_claude: s.settings.show_claude_code,
|
||||
show_chatgpt: s.settings.show_codex,
|
||||
show_opencode_go: s.settings.show_opencode_go,
|
||||
widget_visible: s.settings.widget_visible,
|
||||
install_channel: s.install_channel,
|
||||
update_status: s.update_status,
|
||||
@@ -984,13 +998,13 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
}
|
||||
append_submenu(menu, freq, &snap.strings.update_frequency);
|
||||
|
||||
let Ok(models) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(models) failed");
|
||||
let Ok(providers) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(providers) failed");
|
||||
let _ = DestroyMenu(menu);
|
||||
return;
|
||||
};
|
||||
append_item(
|
||||
models,
|
||||
providers,
|
||||
IDM_MODEL_CLAUDE,
|
||||
&snap.strings.claude_label,
|
||||
if snap.show_claude {
|
||||
@@ -1000,7 +1014,7 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
},
|
||||
);
|
||||
append_item(
|
||||
models,
|
||||
providers,
|
||||
IDM_MODEL_CHATGPT,
|
||||
&snap.strings.chatgpt_label,
|
||||
if snap.show_chatgpt {
|
||||
@@ -1009,7 +1023,17 @@ fn show_context_menu(owner_hwnd: HWND) {
|
||||
MENU_ITEM_FLAGS(0)
|
||||
},
|
||||
);
|
||||
append_submenu(menu, models, &snap.strings.models);
|
||||
append_item(
|
||||
providers,
|
||||
IDM_MODEL_OPENCODE_GO,
|
||||
&snap.strings.opencode_go_label,
|
||||
if snap.show_opencode_go {
|
||||
MF_CHECKED | MF_GRAYED
|
||||
} else {
|
||||
MF_GRAYED
|
||||
},
|
||||
);
|
||||
append_submenu(menu, providers, &snap.strings.providers);
|
||||
|
||||
let Ok(settings_menu) = CreatePopupMenu() else {
|
||||
log::error!("CreatePopupMenu(settings_menu) failed");
|
||||
@@ -1243,24 +1267,18 @@ fn toggle_model(model: ProviderId) {
|
||||
let Some(s) = s.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match model {
|
||||
ProviderId::Claude => s.settings.show_claude_code = !s.settings.show_claude_code,
|
||||
ProviderId::ChatGpt => s.settings.show_codex = !s.settings.show_codex,
|
||||
if !model.metadata().live_usage {
|
||||
return;
|
||||
}
|
||||
if !s.settings.show_claude_code && !s.settings.show_codex {
|
||||
match model {
|
||||
ProviderId::Claude => s.settings.show_claude_code = true,
|
||||
ProviderId::ChatGpt => s.settings.show_codex = true,
|
||||
}
|
||||
s.settings.toggle_provider(model);
|
||||
if !s.settings.has_enabled_live_provider() {
|
||||
s.settings.set_provider_enabled(model, true);
|
||||
}
|
||||
(s.settings.clone(), s.is_dark)
|
||||
};
|
||||
settings::save(&settings);
|
||||
|
||||
let want = match model {
|
||||
ProviderId::Claude => settings.show_claude_code,
|
||||
ProviderId::ChatGpt => settings.show_codex,
|
||||
};
|
||||
let want = settings.is_provider_enabled(model);
|
||||
let existing = lock_state()
|
||||
.as_ref()
|
||||
.and_then(|s| s.bubbles.get(&model).copied());
|
||||
|
||||
+553
-123
@@ -2,8 +2,8 @@
|
||||
//
|
||||
// Top-level window with WS_POPUP + WS_EX_LAYERED + WS_EX_TOPMOST + WS_EX_NOACTIVATE.
|
||||
// The shape is a stadium (rounded-rect with corner_radius = height/2). The left
|
||||
// half is the "head" — a stroked progress ring around the 5h percentage glyph.
|
||||
// The right half is the "tail" — small "7d" label, thin progress bar, countdown.
|
||||
// half is the "head" — usage and remaining-time rings around the 5h percentage
|
||||
// glyph. The right half is the "tail" — weekly usage and remaining-time bars.
|
||||
//
|
||||
// Painting is hybrid: tiny-skia renders the shape (AA fills + AA stroked arc)
|
||||
// into a Pixmap; the Pixmap is copied byte-for-byte into a 32bpp BI_RGB DIB;
|
||||
@@ -14,10 +14,14 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tiny_skia::{FillRule, LineCap, Paint, PathBuilder, Pixmap, Rect, Stroke, Transform};
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::Graphics::Dwm::{
|
||||
DwmGetWindowAttribute, DWMWA_CLOAKED, DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::*;
|
||||
use windows::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
|
||||
use windows::Win32::UI::HiDpi::*;
|
||||
@@ -32,7 +36,9 @@ use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||
|
||||
const TIMER_FULLSCREEN_CHECK: usize = 5;
|
||||
const TIMER_PULSE: usize = 6;
|
||||
const TIMER_TIME_PROGRESS: usize = 7;
|
||||
const PULSE_INTERVAL_MS: u32 = 80;
|
||||
const TIME_PROGRESS_INTERVAL_MS: u32 = 60_000;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
// ---------- Public types & API ----------
|
||||
@@ -52,6 +58,9 @@ const TASKBAR_GAP_LOGICAL: i32 = 4;
|
||||
const PEER_ALIGN_TOLERANCE_LOGICAL: i32 = 8;
|
||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubble";
|
||||
const FULLSCREEN_POLL_MS: u32 = 1500;
|
||||
const FULLSCREEN_EDGE_TOLERANCE_PX: i32 = 2;
|
||||
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
||||
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// (num, den) such that bubble_height = (width * den) / num. 3:1 below 200,
|
||||
/// 2.8:1 below 280, 2.6:1 above — the bubble gets a touch taller as it
|
||||
@@ -72,8 +81,10 @@ pub struct BubbleConfig {
|
||||
pub position: Option<(i32, i32)>,
|
||||
pub session_pct: Option<f64>,
|
||||
pub session_text: String,
|
||||
pub session_resets_at: Option<SystemTime>,
|
||||
pub weekly_pct: Option<f64>,
|
||||
pub weekly_text: String,
|
||||
pub weekly_resets_at: Option<SystemTime>,
|
||||
pub is_dark: bool,
|
||||
}
|
||||
|
||||
@@ -82,6 +93,33 @@ fn bubble_height_logical(width_logical: i32) -> i32 {
|
||||
((width_logical * den) / num).max(20)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum UsageWindowKind {
|
||||
Primary,
|
||||
Secondary,
|
||||
}
|
||||
|
||||
fn window_duration_secs(model: ProviderId, window: UsageWindowKind) -> u64 {
|
||||
// Claude exposes 5h/7d directly. Codex exposes primary/secondary fields;
|
||||
// the product maps those to the same short/long windows in the compact UI.
|
||||
match (model, window) {
|
||||
(ProviderId::Claude, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
|
||||
(ProviderId::Claude, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
|
||||
(ProviderId::ChatGpt, UsageWindowKind::Primary) => FIVE_HOURS_SECS,
|
||||
(ProviderId::ChatGpt, UsageWindowKind::Secondary) => SEVEN_DAYS_SECS,
|
||||
(ProviderId::OpenCodeGo, UsageWindowKind::Primary) => SEVEN_DAYS_SECS,
|
||||
(ProviderId::OpenCodeGo, UsageWindowKind::Secondary) => 30 * 24 * 60 * 60,
|
||||
}
|
||||
}
|
||||
|
||||
fn remaining_fraction(resets_at: Option<SystemTime>, duration_secs: u64) -> Option<f32> {
|
||||
let reset = resets_at?;
|
||||
let remaining = reset
|
||||
.duration_since(SystemTime::now())
|
||||
.unwrap_or_else(|_| Duration::from_secs(0));
|
||||
Some((remaining.as_secs_f64() / duration_secs as f64).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// Owner-supplied event callbacks. The bubble window proc is a leaf — it
|
||||
/// doesn't know about `app`. The owner installs these once at startup so the
|
||||
/// proc can dispatch UI events back without an upward `crate::app::` reach.
|
||||
@@ -203,14 +241,17 @@ pub fn create(config: BubbleConfig) -> HWND {
|
||||
dpi,
|
||||
session_pct: config.session_pct,
|
||||
session_text: config.session_text,
|
||||
session_resets_at: config.session_resets_at,
|
||||
weekly_pct: config.weekly_pct,
|
||||
weekly_text: config.weekly_text,
|
||||
weekly_resets_at: config.weekly_resets_at,
|
||||
is_dark: config.is_dark,
|
||||
drag_start_pos: None,
|
||||
hidden_by_fullscreen: false,
|
||||
user_hidden: false,
|
||||
pulse_phase: 0,
|
||||
pulse_timer_armed: false,
|
||||
time_progress_timer_armed: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -238,6 +279,7 @@ pub fn destroy(hwnd: HWND) {
|
||||
unsafe {
|
||||
let _ = KillTimer(hwnd, TIMER_FULLSCREEN_CHECK);
|
||||
let _ = KillTimer(hwnd, TIMER_PULSE);
|
||||
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
|
||||
let _ = DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
@@ -273,8 +315,10 @@ pub fn update_data(
|
||||
hwnd: HWND,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
@@ -283,10 +327,13 @@ pub fn update_data(
|
||||
};
|
||||
b.session_pct = session_pct;
|
||||
b.session_text = session_text;
|
||||
b.session_resets_at = session_resets_at;
|
||||
b.weekly_pct = weekly_pct;
|
||||
b.weekly_text = weekly_text;
|
||||
b.weekly_resets_at = weekly_resets_at;
|
||||
}
|
||||
sync_pulse_timer(hwnd);
|
||||
sync_time_progress_timer(hwnd);
|
||||
render(hwnd);
|
||||
}
|
||||
|
||||
@@ -320,6 +367,32 @@ fn sync_pulse_timer(hwnd: HWND) {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_time_progress_timer(hwnd: HWND) {
|
||||
let (should_be_armed, currently_armed) = {
|
||||
let bubbles = lock_bubbles();
|
||||
let Some(b) = bubbles.get(&(hwnd.0 as isize)) else {
|
||||
return;
|
||||
};
|
||||
(
|
||||
b.session_resets_at.is_some() || b.weekly_resets_at.is_some(),
|
||||
b.time_progress_timer_armed,
|
||||
)
|
||||
};
|
||||
if should_be_armed == currently_armed {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
if should_be_armed {
|
||||
SetTimer(hwnd, TIMER_TIME_PROGRESS, TIME_PROGRESS_INTERVAL_MS, None);
|
||||
} else {
|
||||
let _ = KillTimer(hwnd, TIMER_TIME_PROGRESS);
|
||||
}
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(hwnd.0 as isize)) {
|
||||
b.time_progress_timer_armed = should_be_armed;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_dark_mode(hwnd: HWND, is_dark: bool) {
|
||||
{
|
||||
let mut bubbles = lock_bubbles();
|
||||
@@ -381,8 +454,10 @@ struct BubbleState {
|
||||
dpi: u32,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
is_dark: bool,
|
||||
drag_start_pos: Option<(i32, i32)>,
|
||||
hidden_by_fullscreen: bool,
|
||||
@@ -392,6 +467,8 @@ struct BubbleState {
|
||||
pulse_phase: u32,
|
||||
/// Whether TIMER_PULSE is currently armed for this bubble.
|
||||
pulse_timer_armed: bool,
|
||||
/// Whether TIMER_TIME_PROGRESS is armed to keep reset-time visuals current.
|
||||
time_progress_timer_armed: bool,
|
||||
}
|
||||
|
||||
fn bubbles() -> &'static Mutex<HashMap<isize, BubbleState>> {
|
||||
@@ -505,6 +582,7 @@ unsafe extern "system" fn wnd_proc(
|
||||
}
|
||||
render(hwnd);
|
||||
}
|
||||
w if w == TIMER_TIME_PROGRESS => render(hwnd),
|
||||
_ => {}
|
||||
}
|
||||
LRESULT(0)
|
||||
@@ -893,30 +971,7 @@ fn align_with_peer(this_hwnd: HWND, ny: &mut i32, tolerance: i32) {
|
||||
|
||||
fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
let fg = unsafe { GetForegroundWindow() };
|
||||
if fg == HWND::default() || fg == bubble_hwnd {
|
||||
return;
|
||||
}
|
||||
let mut fr = RECT::default();
|
||||
unsafe {
|
||||
if GetWindowRect(fg, &mut fr).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
return;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = unsafe { GetMonitorInfoW(monitor, &mut info).as_bool() };
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
let mr = info.rcMonitor;
|
||||
let is_fullscreen =
|
||||
fr.left <= mr.left && fr.top <= mr.top && fr.right >= mr.right && fr.bottom >= mr.bottom;
|
||||
let evaluation = evaluate_foreground_fullscreen(fg, bubble_hwnd);
|
||||
|
||||
let (was_hidden_by_fs, user_hidden) = {
|
||||
let bubbles = lock_bubbles();
|
||||
@@ -926,26 +981,267 @@ fn check_fullscreen(bubble_hwnd: HWND) {
|
||||
(b.hidden_by_fullscreen, b.user_hidden)
|
||||
};
|
||||
|
||||
if is_fullscreen && !was_hidden_by_fs {
|
||||
if evaluation.is_fullscreen && !was_hidden_by_fs {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_HIDE);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = true;
|
||||
}
|
||||
} else if !is_fullscreen && was_hidden_by_fs {
|
||||
if !user_hidden {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
// Re-paint so the layered surface has the cached data again
|
||||
// (see comment in `set_user_visible`).
|
||||
render(bubble_hwnd);
|
||||
log_fullscreen_decision("hide", &evaluation, false);
|
||||
} else if !evaluation.is_fullscreen && was_hidden_by_fs {
|
||||
show_after_fullscreen(bubble_hwnd, user_hidden);
|
||||
log_fullscreen_decision("show", &evaluation, user_hidden);
|
||||
}
|
||||
}
|
||||
|
||||
struct FullscreenEvaluation {
|
||||
is_fullscreen: bool,
|
||||
hwnd: HWND,
|
||||
class_name: String,
|
||||
bounds: Option<RECT>,
|
||||
bounds_source: &'static str,
|
||||
monitor: Option<RECT>,
|
||||
reason: &'static str,
|
||||
}
|
||||
|
||||
fn evaluate_foreground_fullscreen(fg: HWND, bubble_hwnd: HWND) -> FullscreenEvaluation {
|
||||
let mut evaluation = FullscreenEvaluation {
|
||||
is_fullscreen: false,
|
||||
hwnd: fg,
|
||||
class_name: String::new(),
|
||||
bounds: None,
|
||||
bounds_source: "none",
|
||||
monitor: None,
|
||||
reason: "not evaluated",
|
||||
};
|
||||
|
||||
if fg == HWND::default() {
|
||||
evaluation.reason = "no foreground window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.class_name = window_class_name(fg);
|
||||
if fg == bubble_hwnd || is_ignored_fullscreen_class(&evaluation.class_name) {
|
||||
evaluation.reason = "ignored foreground class";
|
||||
return evaluation;
|
||||
}
|
||||
if unsafe { !IsWindowVisible(fg).as_bool() || IsIconic(fg).as_bool() } {
|
||||
evaluation.reason = "foreground invisible or minimized";
|
||||
return evaluation;
|
||||
}
|
||||
if is_dwm_cloaked(fg) {
|
||||
evaluation.reason = "foreground cloaked";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
let Some((bounds, source)) = visible_window_bounds(fg) else {
|
||||
evaluation.reason = "foreground bounds unavailable";
|
||||
return evaluation;
|
||||
};
|
||||
evaluation.bounds = Some(bounds);
|
||||
evaluation.bounds_source = source;
|
||||
|
||||
let monitor = unsafe { MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) };
|
||||
if monitor.is_invalid() {
|
||||
evaluation.reason = "foreground monitor unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
let mut info = MONITORINFO {
|
||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
if unsafe { !GetMonitorInfoW(monitor, &mut info).as_bool() } {
|
||||
evaluation.reason = "foreground monitor info unavailable";
|
||||
return evaluation;
|
||||
}
|
||||
evaluation.monitor = Some(info.rcMonitor);
|
||||
|
||||
if !rect_covers_monitor(&bounds, &info.rcMonitor) {
|
||||
evaluation.reason = "visible bounds do not cover monitor";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if !window_style_allows_fullscreen(fg) {
|
||||
evaluation.reason = "standard framed window";
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
evaluation.is_fullscreen = true;
|
||||
evaluation.reason = "visible bounds cover monitor";
|
||||
evaluation
|
||||
}
|
||||
|
||||
fn show_after_fullscreen(bubble_hwnd: HWND, user_hidden: bool) {
|
||||
if !user_hidden {
|
||||
unsafe {
|
||||
let _ = ShowWindow(bubble_hwnd, SW_SHOWNOACTIVATE);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = false;
|
||||
// Re-paint so the layered surface has the cached data again
|
||||
// (see comment in `set_user_visible`).
|
||||
render(bubble_hwnd);
|
||||
}
|
||||
if let Some(b) = lock_bubbles().get_mut(&(bubble_hwnd.0 as isize)) {
|
||||
b.hidden_by_fullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_window_bounds(hwnd: HWND) -> Option<(RECT, &'static str)> {
|
||||
let mut rect = RECT::default();
|
||||
let dwm_ok = unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
&mut rect as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<RECT>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
};
|
||||
if dwm_ok && !rect_is_empty(&rect) {
|
||||
return Some((rect, "dwm-extended-frame"));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
if GetWindowRect(hwnd, &mut rect).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if rect_is_empty(&rect) {
|
||||
None
|
||||
} else {
|
||||
Some((rect, "window-rect"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dwm_cloaked(hwnd: HWND) -> bool {
|
||||
let mut cloaked = 0u32;
|
||||
unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_CLOAKED,
|
||||
&mut cloaked as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
)
|
||||
.is_ok()
|
||||
&& cloaked != 0
|
||||
}
|
||||
}
|
||||
|
||||
fn window_class_name(hwnd: HWND) -> String {
|
||||
let mut buf = [0u16; 256];
|
||||
let len = unsafe { GetClassNameW(hwnd, &mut buf) };
|
||||
if len <= 0 {
|
||||
String::new()
|
||||
} else {
|
||||
String::from_utf16_lossy(&buf[..len as usize])
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ignored_fullscreen_class(class_name: &str) -> bool {
|
||||
["Progman", "WorkerW", "Shell_TrayWnd", CLASS_NAME]
|
||||
.iter()
|
||||
.any(|ignored| class_name.eq_ignore_ascii_case(ignored))
|
||||
}
|
||||
|
||||
fn window_style_allows_fullscreen(hwnd: HWND) -> bool {
|
||||
unsafe {
|
||||
SetLastError(WIN32_ERROR(0));
|
||||
}
|
||||
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) };
|
||||
if style == 0 && unsafe { GetLastError() } != WIN32_ERROR(0) {
|
||||
return false;
|
||||
}
|
||||
window_style_bits_allow_fullscreen(style as u32)
|
||||
}
|
||||
|
||||
fn window_style_bits_allow_fullscreen(style: u32) -> bool {
|
||||
let has_child = style & WS_CHILD.0 != 0;
|
||||
let has_popup = style & WS_POPUP.0 != 0;
|
||||
let has_caption = style & WS_CAPTION.0 != 0;
|
||||
let has_resize_frame = style & WS_THICKFRAME.0 != 0;
|
||||
|
||||
!has_child && (has_popup || (!has_caption && !has_resize_frame))
|
||||
}
|
||||
|
||||
fn rect_covers_monitor(rect: &RECT, bounds: &RECT) -> bool {
|
||||
rect.left <= bounds.left + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.top <= bounds.top + FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.right >= bounds.right - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
&& rect.bottom >= bounds.bottom - FULLSCREEN_EDGE_TOLERANCE_PX
|
||||
}
|
||||
|
||||
fn rect_is_empty(rect: &RECT) -> bool {
|
||||
rect.right <= rect.left || rect.bottom <= rect.top
|
||||
}
|
||||
|
||||
fn log_fullscreen_decision(action: &str, evaluation: &FullscreenEvaluation, user_hidden: bool) {
|
||||
log::info!(
|
||||
"bubble fullscreen {action} fg=0x{:X} class={} reason={} bounds_source={} bounds={} monitor={} user_hidden={}",
|
||||
evaluation.hwnd.0 as usize,
|
||||
if evaluation.class_name.is_empty() {
|
||||
"<unknown>"
|
||||
} else {
|
||||
evaluation.class_name.as_str()
|
||||
},
|
||||
evaluation.reason,
|
||||
evaluation.bounds_source,
|
||||
format_rect(evaluation.bounds.as_ref()),
|
||||
format_rect(evaluation.monitor.as_ref()),
|
||||
user_hidden
|
||||
);
|
||||
}
|
||||
|
||||
fn format_rect(rect: Option<&RECT>) -> String {
|
||||
match rect {
|
||||
Some(r) => format!(
|
||||
"({},{} {}x{})",
|
||||
r.left,
|
||||
r.top,
|
||||
r.right - r.left,
|
||||
r.bottom - r.top
|
||||
),
|
||||
None => String::from("<none>"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fullscreen_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_bits_allow_popup_or_borderless_windows_only() {
|
||||
assert!(window_style_bits_allow_fullscreen(WS_POPUP.0));
|
||||
assert!(window_style_bits_allow_fullscreen(0));
|
||||
assert!(!window_style_bits_allow_fullscreen(
|
||||
WS_CAPTION.0 | WS_THICKFRAME.0
|
||||
));
|
||||
assert!(!window_style_bits_allow_fullscreen(WS_CHILD.0 | WS_POPUP.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cover_check_allows_small_edge_differences() {
|
||||
let monitor = RECT {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 1920,
|
||||
bottom: 1080,
|
||||
};
|
||||
let almost_exact = RECT {
|
||||
left: 1,
|
||||
top: 2,
|
||||
right: 1919,
|
||||
bottom: 1078,
|
||||
};
|
||||
let inset = RECT {
|
||||
left: 8,
|
||||
top: 0,
|
||||
right: 1920,
|
||||
bottom: 1080,
|
||||
};
|
||||
|
||||
assert!(rect_covers_monitor(&almost_exact, &monitor));
|
||||
assert!(!rect_covers_monitor(&inset, &monitor));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Painting ----------
|
||||
@@ -961,7 +1257,7 @@ const COUNTDOWN_TEMPLATE: &str = "999시간";
|
||||
///
|
||||
/// The outline is a stadium (rounded rect with `corner_radius = canvas_h / 2`).
|
||||
/// The left `head_diameter × canvas_h` square holds the 5h progress ring + big
|
||||
/// percent glyph. The rest is the tail: 7d label, thin bar, countdown.
|
||||
/// percent glyph. The rest is the tail: weekly usage lane + reset-time lane.
|
||||
struct BubbleLayout {
|
||||
canvas_w: i32,
|
||||
canvas_h: i32,
|
||||
@@ -971,12 +1267,14 @@ struct BubbleLayout {
|
||||
ring_cy: f32,
|
||||
ring_radius: f32,
|
||||
ring_stroke_w: f32,
|
||||
time_ring_radius: f32,
|
||||
time_ring_stroke_w: f32,
|
||||
head_label_rect: RECT,
|
||||
head_pct_rect: RECT,
|
||||
tail_label_rect: RECT,
|
||||
tail_pct_rect: RECT,
|
||||
tail_bar_rect: RECT,
|
||||
tail_countdown_rect: RECT,
|
||||
tail_usage_pct_rect: RECT,
|
||||
tail_usage_bar_rect: RECT,
|
||||
tail_time_text_rect: RECT,
|
||||
tail_time_bar_rect: RECT,
|
||||
big_font_px: i32,
|
||||
small_font_px: i32,
|
||||
main_font_px: i32,
|
||||
@@ -987,7 +1285,7 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
let height_px = scale_to_dpi(bubble_height_logical(size_logical), dpi);
|
||||
let head_diameter = height_px;
|
||||
|
||||
let head_pad = scale_to_dpi(4, dpi);
|
||||
let head_pad = scale_to_dpi(5, dpi);
|
||||
let ring_stroke_w = scale_to_dpi(3, dpi).clamp(2, 4) as f32;
|
||||
let ring_cx = (head_diameter as f32) / 2.0;
|
||||
let ring_cy = (height_px as f32) / 2.0;
|
||||
@@ -995,8 +1293,14 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
// inside the head padding. ring_radius is the centerline radius.
|
||||
let ring_outer = (head_diameter as f32) / 2.0 - (head_pad as f32);
|
||||
let ring_radius = ring_outer - ring_stroke_w / 2.0;
|
||||
// Inner ring renders the remaining-time arc. Floor stroke at 2 logical so
|
||||
// it stays visible at smaller bubble sizes (clamp 1 produced a hairline
|
||||
// that disappeared into the track on dark themes).
|
||||
let time_ring_stroke_w = scale_to_dpi(2, dpi).clamp(2, 3) as f32;
|
||||
let time_ring_radius =
|
||||
(ring_radius - ring_stroke_w - scale_to_dpi(4, dpi) as f32).max(time_ring_stroke_w);
|
||||
|
||||
let big_font_px = (head_diameter * 26 / 100).max(scale_to_dpi(11, dpi));
|
||||
let big_font_px = (head_diameter * 24 / 100).max(scale_to_dpi(11, dpi));
|
||||
let small_font_px = ((big_font_px * 55) / 100).max(scale_to_dpi(9, dpi));
|
||||
let main_font_px = small_font_px;
|
||||
|
||||
@@ -1019,43 +1323,39 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
};
|
||||
|
||||
let tail_left = head_diameter;
|
||||
let tail_right = width_px - scale_to_dpi(12, dpi);
|
||||
let tail_right = width_px - scale_to_dpi(14, dpi);
|
||||
let pad = scale_to_dpi(6, dpi);
|
||||
// Breathing room between bar end and the right-aligned text. 6 logical
|
||||
// had the bar visually colliding with "100%" / countdown glyphs.
|
||||
let bar_text_gap = scale_to_dpi(8, dpi);
|
||||
|
||||
let countdown_w = measure_text_w(mem_dc, COUNTDOWN_TEMPLATE, main_font_px);
|
||||
let label_w = measure_text_w(mem_dc, "7d", small_font_px);
|
||||
let pct_reserve_w = measure_text_w(mem_dc, "100%", small_font_px) + scale_to_dpi(2, dpi);
|
||||
|
||||
let tail_label_left = tail_left + pad;
|
||||
let tail_label_right = tail_label_left + label_w;
|
||||
let tail_countdown_right = tail_right;
|
||||
let tail_countdown_left = tail_countdown_right - countdown_w;
|
||||
// Usage bar carries the primary signal; make it ~2.5x the mass of the
|
||||
// time bar so the two lanes don't read as two competing quotas.
|
||||
let usage_bar_h = (height_px * 10 / 100).clamp(scale_to_dpi(6, dpi), scale_to_dpi(12, dpi));
|
||||
let time_bar_h = (height_px * 4 / 100).clamp(scale_to_dpi(3, dpi), scale_to_dpi(6, dpi));
|
||||
let lane_gap = scale_to_dpi(6, dpi);
|
||||
let lanes_h = usage_bar_h + lane_gap + time_bar_h;
|
||||
let usage_bar_top = (height_px - lanes_h) / 2;
|
||||
let time_bar_top = usage_bar_top + usage_bar_h + lane_gap;
|
||||
let time_text_h = main_font_px + scale_to_dpi(2, dpi);
|
||||
let usage_pct_h = small_font_px + scale_to_dpi(2, dpi);
|
||||
|
||||
// Try to seat a 7d% text between the "7d" label and the bar. The %
|
||||
// number is the actual data, so it takes precedence over keeping the
|
||||
// bar at its pre-feature minimum: when the % shows, the bar may
|
||||
// compress to `bar_min_with_pct` (still visible as a pill). Only when
|
||||
// even that small bar wouldn't fit do we collapse the % rect entirely
|
||||
// and restore the pre-feature `bar_min` to keep the bar readable.
|
||||
// Without this two-tier threshold, the worst-case CJK countdown column
|
||||
// ("999시간") leaves <20 logical of bar room at the default 200-logical
|
||||
// size on both 100% and 125% DPI, and the % silently disappears.
|
||||
let bar_min = scale_to_dpi(20, dpi);
|
||||
let bar_min_with_pct = scale_to_dpi(8, dpi);
|
||||
let (tail_pct_left, tail_pct_right, tail_bar_left, bar_render_min) = {
|
||||
let pct_left = tail_label_right + pad;
|
||||
let pct_right = pct_left + pct_reserve_w;
|
||||
let bar_left = pct_right + pad;
|
||||
if (tail_countdown_left - pad) - bar_left >= bar_min_with_pct {
|
||||
(pct_left, pct_right, bar_left, bar_min_with_pct)
|
||||
} else {
|
||||
let bar_left = tail_label_right + pad;
|
||||
(bar_left, bar_left, bar_left, bar_min)
|
||||
}
|
||||
let content_left = tail_left + pad;
|
||||
let content_right = tail_right;
|
||||
let content_w = (content_right - content_left).max(0);
|
||||
let bar_min = scale_to_dpi(8, dpi);
|
||||
let desired_text_w = countdown_w.max(pct_reserve_w);
|
||||
let text_w = if content_w >= desired_text_w + bar_text_gap + bar_min {
|
||||
desired_text_w
|
||||
} else {
|
||||
(content_w - bar_text_gap - bar_min).max(0)
|
||||
};
|
||||
let tail_bar_right = (tail_countdown_left - pad).max(tail_bar_left + bar_render_min);
|
||||
let tail_bar_h = (height_px * 9 / 100).clamp(scale_to_dpi(5, dpi), scale_to_dpi(12, dpi));
|
||||
let tail_bar_top = (height_px - tail_bar_h) / 2;
|
||||
let text_left = content_right - text_w;
|
||||
let bar_left = content_left;
|
||||
let bar_right = (text_left - bar_text_gap).max(bar_left + bar_min);
|
||||
|
||||
BubbleLayout {
|
||||
canvas_w: width_px,
|
||||
@@ -1066,31 +1366,33 @@ fn compute_bubble_layout(size_logical: i32, dpi: u32, mem_dc: HDC) -> BubbleLayo
|
||||
ring_cy,
|
||||
ring_radius,
|
||||
ring_stroke_w,
|
||||
time_ring_radius,
|
||||
time_ring_stroke_w,
|
||||
head_label_rect,
|
||||
head_pct_rect,
|
||||
tail_label_rect: RECT {
|
||||
left: tail_label_left,
|
||||
top: 0,
|
||||
right: tail_label_right,
|
||||
bottom: height_px,
|
||||
tail_usage_pct_rect: RECT {
|
||||
left: text_left,
|
||||
top: usage_bar_top + (usage_bar_h - usage_pct_h) / 2,
|
||||
right: content_right,
|
||||
bottom: usage_bar_top + (usage_bar_h - usage_pct_h) / 2 + usage_pct_h,
|
||||
},
|
||||
tail_pct_rect: RECT {
|
||||
left: tail_pct_left,
|
||||
top: 0,
|
||||
right: tail_pct_right,
|
||||
bottom: height_px,
|
||||
tail_usage_bar_rect: RECT {
|
||||
left: bar_left,
|
||||
top: usage_bar_top,
|
||||
right: bar_right,
|
||||
bottom: usage_bar_top + usage_bar_h,
|
||||
},
|
||||
tail_bar_rect: RECT {
|
||||
left: tail_bar_left,
|
||||
top: tail_bar_top,
|
||||
right: tail_bar_right,
|
||||
bottom: tail_bar_top + tail_bar_h,
|
||||
tail_time_text_rect: RECT {
|
||||
left: text_left,
|
||||
top: time_bar_top + (time_bar_h - time_text_h) / 2,
|
||||
right: content_right,
|
||||
bottom: time_bar_top + (time_bar_h - time_text_h) / 2 + time_text_h,
|
||||
},
|
||||
tail_countdown_rect: RECT {
|
||||
left: tail_countdown_left,
|
||||
top: 0,
|
||||
right: tail_countdown_right,
|
||||
bottom: height_px,
|
||||
tail_time_bar_rect: RECT {
|
||||
left: bar_left,
|
||||
top: time_bar_top,
|
||||
right: bar_right,
|
||||
bottom: time_bar_top + time_bar_h,
|
||||
},
|
||||
big_font_px,
|
||||
small_font_px,
|
||||
@@ -1111,9 +1413,21 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
Color::from_hex("#F3F3F3")
|
||||
};
|
||||
let track = if inputs.is_dark {
|
||||
Color::from_hex("#3A3A3A")
|
||||
Color::from_hex("#2C2C2C")
|
||||
} else {
|
||||
Color::from_hex("#D6D6D6")
|
||||
Color::from_hex("#E2E2E2")
|
||||
};
|
||||
// Inner-ring / time-bar neutral track. Lifted off the background to
|
||||
// clear WCAG 1.4.11 3:1 on dark themes (#303030 on #1F1F1F was ~1.13:1).
|
||||
let time_track = if inputs.is_dark {
|
||||
Color::from_hex("#404040")
|
||||
} else {
|
||||
Color::from_hex("#E0E0E0")
|
||||
};
|
||||
let time_fill = if inputs.is_dark {
|
||||
Color::from_hex("#B0B0B0")
|
||||
} else {
|
||||
Color::from_hex("#666666")
|
||||
};
|
||||
|
||||
// ---- Stadium background ----
|
||||
@@ -1174,14 +1488,48 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inner ring: true remaining time for the 5h/primary window. This
|
||||
// stays neutral so it reads as time, not another quota alarm.
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(time_track));
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = layout.time_ring_stroke_w;
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(layout.ring_cx, layout.ring_cy, layout.time_ring_radius);
|
||||
if let Some(p) = pb.finish() {
|
||||
pixmap.stroke_path(&p, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
if let Some(frac) = remaining_fraction(
|
||||
inputs.session_resets_at,
|
||||
window_duration_secs(inputs.model, UsageWindowKind::Primary),
|
||||
) {
|
||||
if frac > 0.0 {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(time_fill));
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = layout.time_ring_stroke_w;
|
||||
stroke.line_cap = LineCap::Round;
|
||||
if let Some(path) = build_remaining_arc(
|
||||
layout.ring_cx,
|
||||
layout.ring_cy,
|
||||
layout.time_ring_radius,
|
||||
frac,
|
||||
) {
|
||||
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tail bar (7d) ----
|
||||
// ---- Tail usage bar + reset-time bar ----
|
||||
{
|
||||
let bar_x = layout.tail_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_bar_rect.right - layout.tail_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_bar_rect.bottom - layout.tail_bar_rect.top) as f32;
|
||||
let bar_x = layout.tail_usage_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_usage_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_usage_bar_rect.right - layout.tail_usage_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_usage_bar_rect.bottom - layout.tail_usage_bar_rect.top) as f32;
|
||||
let cap = bar_h * 0.5;
|
||||
if bar_w > 0.0 && bar_h > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, track);
|
||||
@@ -1195,18 +1543,43 @@ fn paint_bubble_pixmap(layout: &BubbleLayout, inputs: &PaintInputs) -> Option<Pi
|
||||
let t = pulse_triangle(inputs.pulse_phase);
|
||||
color = brighten(color, t);
|
||||
}
|
||||
let clipped_w = fill_w.min(bar_w);
|
||||
// Floor at one cap-diameter so a 1% reading still renders
|
||||
// as a recognizable dot rather than a sub-pixel sliver.
|
||||
let mut clipped_w = fill_w.min(bar_w);
|
||||
if clipped_w < bar_h {
|
||||
clipped_w = bar_h.min(bar_w);
|
||||
}
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, clipped_w, bar_h, cap, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bar_x = layout.tail_time_bar_rect.left as f32;
|
||||
let bar_y = layout.tail_time_bar_rect.top as f32;
|
||||
let bar_w = (layout.tail_time_bar_rect.right - layout.tail_time_bar_rect.left) as f32;
|
||||
let bar_h = (layout.tail_time_bar_rect.bottom - layout.tail_time_bar_rect.top) as f32;
|
||||
let cap = bar_h * 0.5;
|
||||
if bar_w > 0.0 && bar_h > 0.0 {
|
||||
paint_pill(&mut pixmap, bar_x, bar_y, bar_w, bar_h, cap, time_track);
|
||||
if let Some(frac) = remaining_fraction(
|
||||
inputs.weekly_resets_at,
|
||||
window_duration_secs(inputs.model, UsageWindowKind::Secondary),
|
||||
) {
|
||||
let fill_w = (bar_w * frac).min(bar_w);
|
||||
if fill_w > 0.0 {
|
||||
// Anchor on the right edge so the bar shrinks toward the right as time passes.
|
||||
let fill_x = bar_x + bar_w - fill_w;
|
||||
paint_pill(&mut pixmap, fill_x, bar_y, fill_w, bar_h, cap, time_fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(pixmap)
|
||||
}
|
||||
|
||||
/// Fill a horizontal pill at `(x, y, w, h)` with circular end caps of radius
|
||||
/// `cap`. Used for both the track and the fill of the tail's 7d bar.
|
||||
/// `cap`. Used for both track and fill segments in the tail bars.
|
||||
fn paint_pill(pixmap: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, cap: f32, color: Color) {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(rgb_to_skia(color));
|
||||
@@ -1247,6 +1620,34 @@ fn build_arc(cx: f32, cy: f32, radius: f32, sweep_fraction: f32) -> Option<tiny_
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Clockwise arc that ENDS at 12 o'clock; the consumed wedge grows clockwise
|
||||
/// from 12 as `remaining_fraction` shrinks, mirroring a clock-hand countdown.
|
||||
fn build_remaining_arc(
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
radius: f32,
|
||||
remaining_fraction: f32,
|
||||
) -> Option<tiny_skia::Path> {
|
||||
let frac = remaining_fraction.clamp(0.0, 1.0);
|
||||
let segments = ((frac * 64.0).ceil() as usize).max(1);
|
||||
let mut pb = PathBuilder::new();
|
||||
let twelve: f32 = -std::f32::consts::FRAC_PI_2;
|
||||
let total = frac * std::f32::consts::TAU;
|
||||
let start_angle = twelve + (std::f32::consts::TAU - total);
|
||||
for i in 0..=segments {
|
||||
let t = i as f32 / segments as f32;
|
||||
let a = start_angle + t * total;
|
||||
let x = cx + a.cos() * radius;
|
||||
let y = cy + a.sin() * radius;
|
||||
if i == 0 {
|
||||
pb.move_to(x, y);
|
||||
} else {
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// Copy a premultiplied-RGBA `Pixmap` into the 32bpp BI_RGB DIB the bubble
|
||||
/// uses for `UpdateLayeredWindow`. The DIB stores BGRA bytes (little-endian
|
||||
/// `0xAARRGGBB` when read as u32); tiny-skia's premultiplied alpha is exactly
|
||||
@@ -1310,8 +1711,10 @@ struct PaintInputs {
|
||||
model: ProviderId,
|
||||
session_pct: Option<f64>,
|
||||
session_text: String,
|
||||
session_resets_at: Option<SystemTime>,
|
||||
weekly_pct: Option<f64>,
|
||||
weekly_text: String,
|
||||
weekly_resets_at: Option<SystemTime>,
|
||||
is_dark: bool,
|
||||
pulse_phase: u32,
|
||||
}
|
||||
@@ -1329,8 +1732,10 @@ fn render(hwnd: HWND) {
|
||||
model: b.model,
|
||||
session_pct: b.session_pct,
|
||||
session_text: b.session_text.clone(),
|
||||
session_resets_at: b.session_resets_at,
|
||||
weekly_pct: b.weekly_pct,
|
||||
weekly_text: b.weekly_text.clone(),
|
||||
weekly_resets_at: b.weekly_resets_at,
|
||||
is_dark: b.is_dark,
|
||||
pulse_phase: b.pulse_phase,
|
||||
},
|
||||
@@ -1450,8 +1855,8 @@ fn brighten(c: Color, t: f64) -> Color {
|
||||
)
|
||||
}
|
||||
|
||||
/// Paint the new bubble's text overlay via GDI: small "5h" label + big "%"
|
||||
/// glyph in the head, small "7d" label + countdown on the tail.
|
||||
/// Paint the bubble's text overlay via GDI: primary countdown + big "%"
|
||||
/// glyph in the head, weekly percent + weekly countdown on the tail.
|
||||
fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
let text_color = if inputs.is_dark {
|
||||
Color::from_hex("#EAEAEA")
|
||||
@@ -1459,15 +1864,19 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
Color::from_hex("#1F1F1F")
|
||||
};
|
||||
let muted_color = if inputs.is_dark {
|
||||
Color::from_hex("#888888")
|
||||
Color::from_hex("#A8A8A8")
|
||||
} else {
|
||||
Color::from_hex("#6E6E6E")
|
||||
Color::from_hex("#5E5E5E")
|
||||
};
|
||||
|
||||
let font_name = wide_str("Segoe UI");
|
||||
unsafe {
|
||||
let big_font = create_font(layout.big_font_px, &font_name, FW_SEMIBOLD.0 as i32);
|
||||
let small_font = create_font(layout.small_font_px, &font_name, FW_NORMAL.0 as i32);
|
||||
// Big head percent uses FW_BOLD to anchor the eye against the ring.
|
||||
// small_font is semibold because it carries both the "5H" window tag
|
||||
// and the tail weekly-percent — both want a touch more weight than
|
||||
// the countdown text rendered with main_font.
|
||||
let big_font = create_font(layout.big_font_px, &font_name, FW_BOLD.0 as i32);
|
||||
let small_font = create_font(layout.small_font_px, &font_name, FW_SEMIBOLD.0 as i32);
|
||||
let main_font = create_font(layout.main_font_px, &font_name, FW_NORMAL.0 as i32);
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
|
||||
@@ -1482,13 +1891,13 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
let head_label_rect_w = layout.head_label_rect.right - layout.head_label_rect.left;
|
||||
let head_label_text: &str = if inputs.session_text.is_empty() {
|
||||
"5h"
|
||||
"5H"
|
||||
} else if measure_text_w(hdc, &inputs.session_text, layout.small_font_px)
|
||||
<= head_label_rect_w
|
||||
{
|
||||
inputs.session_text.as_str()
|
||||
} else {
|
||||
"5h"
|
||||
"5H"
|
||||
};
|
||||
draw_text_in_rect(hdc, &layout.head_label_rect, head_label_text, DT_CENTER);
|
||||
|
||||
@@ -1501,19 +1910,15 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
};
|
||||
draw_text_in_rect(hdc, &layout.head_pct_rect, &pct_text, DT_CENTER);
|
||||
|
||||
// Tail: "7d" label (muted, left-aligned).
|
||||
SelectObject(hdc, small_font);
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
draw_text_in_rect(hdc, &layout.tail_label_rect, "7d", DT_LEFT);
|
||||
|
||||
// Tail: 7d percent (foreground color, between label and bar). Skipped
|
||||
// Tail: weekly percent (foreground color, right of its usage bar). Skipped
|
||||
// when the layout collapsed the rect at small widths. Foreground —
|
||||
// not the accent color the bar uses — because Codex teal #10A37F on
|
||||
// the light theme background only hits ~3.2:1 contrast, below WCAG
|
||||
// AA for small text. Adjacency to the bar carries the visual
|
||||
// grouping; we don't need hue to do it too.
|
||||
SelectObject(hdc, small_font);
|
||||
if let Some(pct) = inputs.weekly_pct {
|
||||
if layout.tail_pct_rect.right > layout.tail_pct_rect.left {
|
||||
if layout.tail_usage_pct_rect.right > layout.tail_usage_pct_rect.left {
|
||||
let mut color = text_color;
|
||||
if pct >= 95.0 {
|
||||
let t = pulse_triangle(inputs.pulse_phase);
|
||||
@@ -1521,17 +1926,24 @@ fn paint_bubble_text(hdc: HDC, layout: &BubbleLayout, inputs: &PaintInputs) {
|
||||
}
|
||||
SetTextColor(hdc, COLORREF(color.into_colorref()));
|
||||
let weekly_pct_text = format!("{:.0}%", pct);
|
||||
draw_text_in_rect(hdc, &layout.tail_pct_rect, &weekly_pct_text, DT_CENTER);
|
||||
draw_tail_text_in_rect(
|
||||
hdc,
|
||||
&layout.tail_usage_pct_rect,
|
||||
&weekly_pct_text,
|
||||
DT_RIGHT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tail: countdown (right-aligned).
|
||||
// Tail: weekly countdown aligned with its true remaining-time bar.
|
||||
// Muted color — the percent above is the headline; the countdown is
|
||||
// supporting context and should not compete for visual weight.
|
||||
SelectObject(hdc, main_font);
|
||||
SetTextColor(hdc, COLORREF(text_color.into_colorref()));
|
||||
SetTextColor(hdc, COLORREF(muted_color.into_colorref()));
|
||||
if !inputs.weekly_text.is_empty() {
|
||||
draw_text_in_rect(
|
||||
draw_tail_text_in_rect(
|
||||
hdc,
|
||||
&layout.tail_countdown_rect,
|
||||
&layout.tail_time_text_rect,
|
||||
&inputs.weekly_text,
|
||||
DT_RIGHT,
|
||||
);
|
||||
@@ -1561,6 +1973,23 @@ fn draw_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tail_text_in_rect(hdc: HDC, rect: &RECT, text: &str, halign: DRAW_TEXT_FORMAT) {
|
||||
if rect.right <= rect.left {
|
||||
return;
|
||||
}
|
||||
let mut buf = wide_str(text);
|
||||
let len_no_nul = buf.len().saturating_sub(1);
|
||||
let mut r = *rect;
|
||||
unsafe {
|
||||
let _ = DrawTextW(
|
||||
hdc,
|
||||
&mut buf[..len_no_nul],
|
||||
&mut r,
|
||||
halign | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_font(height_px: i32, name_w: &[u16], weight: i32) -> HFONT {
|
||||
unsafe {
|
||||
CreateFontW(
|
||||
@@ -1607,6 +2036,7 @@ fn default_position(width_px: i32, height_px: i32, model: ProviderId) -> (i32, i
|
||||
let stagger = match model {
|
||||
ProviderId::Claude => 0,
|
||||
ProviderId::ChatGpt => height_px + gap,
|
||||
ProviderId::OpenCodeGo => 2 * (height_px + gap),
|
||||
};
|
||||
let x = wa.right - width_px - gap;
|
||||
let y = wa.bottom - height_px - gap - stagger;
|
||||
|
||||
@@ -8,9 +8,10 @@ one_minute = "1 minute"
|
||||
five_minutes = "5 minutes"
|
||||
fifteen_minutes = "15 minutes"
|
||||
one_hour = "1 hour"
|
||||
models = "Models"
|
||||
providers = "Providers"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
opencode_go_label = "OpenCode Go"
|
||||
settings = "Settings"
|
||||
start_with_windows = "Start with Windows"
|
||||
reset_position = "Reset position"
|
||||
|
||||
@@ -8,9 +8,10 @@ one_minute = "1分"
|
||||
five_minutes = "5分"
|
||||
fifteen_minutes = "15分"
|
||||
one_hour = "1時間"
|
||||
models = "モデル"
|
||||
providers = "プロバイダー"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
opencode_go_label = "OpenCode Go"
|
||||
settings = "設定"
|
||||
start_with_windows = "Windows起動時に開始"
|
||||
reset_position = "位置をリセット"
|
||||
|
||||
@@ -8,9 +8,10 @@ one_minute = "1분"
|
||||
five_minutes = "5분"
|
||||
fifteen_minutes = "15분"
|
||||
one_hour = "1시간"
|
||||
models = "모델"
|
||||
providers = "제공자"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
opencode_go_label = "OpenCode Go"
|
||||
settings = "설정"
|
||||
start_with_windows = "Windows 시작 시 실행"
|
||||
reset_position = "위치 초기화"
|
||||
|
||||
@@ -8,9 +8,10 @@ one_minute = "1 phút"
|
||||
five_minutes = "5 phút"
|
||||
fifteen_minutes = "15 phút"
|
||||
one_hour = "1 giờ"
|
||||
models = "Mô hình"
|
||||
providers = "Nhà cung cấp"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
opencode_go_label = "OpenCode Go"
|
||||
settings = "Cài đặt"
|
||||
start_with_windows = "Khởi động cùng Windows"
|
||||
reset_position = "Đặt lại vị trí"
|
||||
|
||||
@@ -8,9 +8,10 @@ one_minute = "1 分鐘"
|
||||
five_minutes = "5 分鐘"
|
||||
fifteen_minutes = "15 分鐘"
|
||||
one_hour = "1 小時"
|
||||
models = "模型"
|
||||
providers = "提供者"
|
||||
claude_label = "Claude Code"
|
||||
chatgpt_label = "Codex"
|
||||
opencode_go_label = "OpenCode Go"
|
||||
settings = "設定"
|
||||
start_with_windows = "隨 Windows 啟動"
|
||||
reset_position = "重設位置"
|
||||
|
||||
+24
-1
@@ -28,9 +28,10 @@ pub struct LocaleStrings {
|
||||
pub five_minutes: String,
|
||||
pub fifteen_minutes: String,
|
||||
pub one_hour: String,
|
||||
pub models: String,
|
||||
pub providers: String,
|
||||
pub claude_label: String,
|
||||
pub chatgpt_label: String,
|
||||
pub opencode_go_label: String,
|
||||
pub settings: String,
|
||||
pub start_with_windows: String,
|
||||
pub reset_position: String,
|
||||
@@ -292,7 +293,9 @@ mod tests {
|
||||
let strings = file.strings;
|
||||
for (name, value) in [
|
||||
("size_smaller", strings.size_smaller.as_str()),
|
||||
("providers", strings.providers.as_str()),
|
||||
("size_larger", strings.size_larger.as_str()),
|
||||
("opencode_go_label", strings.opencode_go_label.as_str()),
|
||||
("reset_size", strings.reset_size.as_str()),
|
||||
("controls", strings.controls.as_str()),
|
||||
("control_left_click", strings.control_left_click.as_str()),
|
||||
@@ -334,4 +337,24 @@ mod tests {
|
||||
"malformed control_tray_click should fail locale deserialization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_schema_rejects_missing_provider_strings() {
|
||||
let (_, fallback_body) = RAW_LOCALES
|
||||
.iter()
|
||||
.find(|(code, _)| *code == FALLBACK_CODE)
|
||||
.expect("fallback locale fixture must exist");
|
||||
|
||||
let missing_providers = fallback_body.replace("providers = \"Providers\"\n", "");
|
||||
assert!(
|
||||
toml::from_str::<LocaleFile>(&missing_providers).is_err(),
|
||||
"missing providers should fail locale deserialization"
|
||||
);
|
||||
|
||||
let missing_opencode = fallback_body.replace("opencode_go_label = \"OpenCode Go\"\n", "");
|
||||
assert!(
|
||||
toml::from_str::<LocaleFile>(&missing_opencode).is_err(),
|
||||
"missing opencode_go_label should fail locale deserialization"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -317,6 +317,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
let header = match data.model {
|
||||
ProviderId::Claude => data.strings.claude_label.clone(),
|
||||
ProviderId::ChatGpt => data.strings.chatgpt_label.clone(),
|
||||
ProviderId::OpenCodeGo => data.strings.opencode_go_label.clone(),
|
||||
};
|
||||
draw_text(
|
||||
hdc,
|
||||
@@ -331,11 +332,8 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
||||
);
|
||||
|
||||
let bar_x = scaled(PADDING_LOGICAL) + scaled(LABEL_W_LOGICAL) + scaled(4);
|
||||
let bar_w = rc.right
|
||||
- bar_x
|
||||
- scaled(PADDING_LOGICAL)
|
||||
- scaled(RIGHT_TEXT_W_LOGICAL)
|
||||
- scaled(4);
|
||||
let bar_w =
|
||||
rc.right - bar_x - scaled(PADDING_LOGICAL) - scaled(RIGHT_TEXT_W_LOGICAL) - scaled(4);
|
||||
let row1_y = scaled(PADDING_LOGICAL) + scaled(24);
|
||||
let row2_y = row1_y + scaled(BAR_HEIGHT_LOGICAL) + scaled(ROW_GAP_LOGICAL) + scaled(8);
|
||||
|
||||
|
||||
+120
-13
@@ -31,6 +31,9 @@ fn default_show_claude() -> bool {
|
||||
fn default_show_codex() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_show_opencode_go() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_widget_visible() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -48,30 +51,35 @@ fn default_update_check_interval_secs() -> Option<u64> {
|
||||
pub struct BubblePositions {
|
||||
pub claude: Option<(i32, i32)>,
|
||||
pub codex: Option<(i32, i32)>,
|
||||
pub opencode_go: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl BubblePositions {
|
||||
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
|
||||
match model {
|
||||
pub fn get(&self, provider: ProviderId) -> Option<(i32, i32)> {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude,
|
||||
ProviderId::ChatGpt => self.codex,
|
||||
ProviderId::OpenCodeGo => self.opencode_go,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
|
||||
match model {
|
||||
pub fn set(&mut self, provider: ProviderId, pos: (i32, i32)) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = Some(pos),
|
||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||
ProviderId::OpenCodeGo => self.opencode_go = Some(pos),
|
||||
}
|
||||
}
|
||||
pub fn reset(&mut self, model: ProviderId) {
|
||||
match model {
|
||||
pub fn reset(&mut self, provider: ProviderId) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.claude = None,
|
||||
ProviderId::ChatGpt => self.codex = None,
|
||||
ProviderId::OpenCodeGo => self.opencode_go = None,
|
||||
}
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
self.claude = None;
|
||||
self.codex = None;
|
||||
self.opencode_go = None;
|
||||
}
|
||||
|
||||
/// Drop any saved position whose top-left no longer falls on a connected
|
||||
@@ -80,16 +88,26 @@ impl BubblePositions {
|
||||
pub fn validate(&mut self) {
|
||||
if let Some((x, y)) = self.claude {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!("bubble position claude ({x},{y}) outside all monitors; resetting to default");
|
||||
log::warn!(
|
||||
"bubble position claude ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.claude = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.codex {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!("bubble position codex ({x},{y}) outside all monitors; resetting to default");
|
||||
log::warn!(
|
||||
"bubble position codex ({x},{y}) outside all monitors; resetting to default"
|
||||
);
|
||||
self.codex = None;
|
||||
}
|
||||
}
|
||||
if let Some((x, y)) = self.opencode_go {
|
||||
if !position_on_any_monitor(x, y) {
|
||||
log::warn!("bubble position opencode-go ({x},{y}) outside all monitors; resetting to default");
|
||||
self.opencode_go = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +130,8 @@ pub struct Settings {
|
||||
pub show_claude_code: bool,
|
||||
#[serde(default = "default_show_codex")]
|
||||
pub show_codex: bool,
|
||||
#[serde(default = "default_show_opencode_go")]
|
||||
pub show_opencode_go: bool,
|
||||
#[serde(default)]
|
||||
pub bubble_positions: BubblePositions,
|
||||
#[serde(default = "default_bubble_size")]
|
||||
@@ -133,6 +153,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
show_claude_code: default_show_claude(),
|
||||
show_codex: default_show_codex(),
|
||||
show_opencode_go: default_show_opencode_go(),
|
||||
bubble_positions: BubblePositions::default(),
|
||||
bubble_size_logical: default_bubble_size(),
|
||||
poll_interval_ms: default_poll_interval_ms(),
|
||||
@@ -144,6 +165,35 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn is_provider_enabled(&self, provider: ProviderId) -> bool {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code,
|
||||
ProviderId::ChatGpt => self.show_codex,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) {
|
||||
match provider {
|
||||
ProviderId::Claude => self.show_claude_code = enabled,
|
||||
ProviderId::ChatGpt => self.show_codex = enabled,
|
||||
ProviderId::OpenCodeGo => self.show_opencode_go = enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_provider(&mut self, provider: ProviderId) {
|
||||
let enabled = !self.is_provider_enabled(provider);
|
||||
self.set_provider_enabled(provider, enabled);
|
||||
}
|
||||
|
||||
pub fn has_enabled_live_provider(&self) -> bool {
|
||||
ProviderId::LIVE_USAGE
|
||||
.iter()
|
||||
.any(|provider| self.is_provider_enabled(*provider))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join(APP_DIR_NAME))
|
||||
}
|
||||
@@ -161,19 +211,76 @@ pub fn load() -> Settings {
|
||||
Err(_) => return Settings::default(),
|
||||
};
|
||||
let mut settings: Settings = serde_json::from_str(&content).unwrap_or_default();
|
||||
// At least one model must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.show_claude_code && !settings.show_codex {
|
||||
// At least one live provider must be visible. Otherwise the app has nothing to show.
|
||||
if !settings.has_enabled_live_provider() {
|
||||
settings.show_claude_code = true;
|
||||
}
|
||||
// Clamp bubble size to safe range in case settings.json was hand-edited.
|
||||
settings.bubble_size_logical = settings
|
||||
.bubble_size_logical
|
||||
.clamp(crate::bubble::MIN_BUBBLE_SIZE, crate::bubble::MAX_BUBBLE_SIZE);
|
||||
settings.bubble_size_logical = settings.bubble_size_logical.clamp(
|
||||
crate::bubble::MIN_BUBBLE_SIZE,
|
||||
crate::bubble::MAX_BUBBLE_SIZE,
|
||||
);
|
||||
// Drop positions on monitors that have since been disconnected.
|
||||
settings.bubble_positions.validate();
|
||||
settings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_two_provider_settings_json_still_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": false,
|
||||
"show_codex": true,
|
||||
"bubble_positions": {
|
||||
"claude": [10, 20],
|
||||
"codex": [30, 40]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("old settings json should parse");
|
||||
|
||||
assert!(!settings.show_claude_code);
|
||||
assert!(settings.show_codex);
|
||||
assert!(!settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::Claude),
|
||||
Some((10, 20))
|
||||
);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::ChatGpt),
|
||||
Some((30, 40))
|
||||
);
|
||||
assert_eq!(settings.bubble_positions.get(ProviderId::OpenCodeGo), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_provider_settings_json_loads() {
|
||||
let json = r#"{
|
||||
"show_claude_code": true,
|
||||
"show_codex": false,
|
||||
"show_opencode_go": true,
|
||||
"bubble_positions": {
|
||||
"opencode_go": [50, 60]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let settings: Settings =
|
||||
serde_json::from_str(json).expect("new settings json should parse");
|
||||
|
||||
assert!(settings.show_claude_code);
|
||||
assert!(!settings.show_codex);
|
||||
assert!(settings.show_opencode_go);
|
||||
assert_eq!(
|
||||
settings.bubble_positions.get(ProviderId::OpenCodeGo),
|
||||
Some((50, 60))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
+9
-1
@@ -43,7 +43,13 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.push_circle(cx, cy, inner);
|
||||
if let Some(path) = pb.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +118,8 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
||||
ProviderId::Claude => [0x2a, 0x1f, 0x1c],
|
||||
// Cool dark slate for ChatGPT/Codex.
|
||||
ProviderId::ChatGpt => [0x1a, 0x1f, 0x26],
|
||||
// Deep green for OpenCode Go.
|
||||
ProviderId::OpenCodeGo => [0x12, 0x2a, 0x20],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-6
@@ -11,8 +11,8 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::Shell::{
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING,
|
||||
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_INFO, NIIF_WARNING, NIM_ADD,
|
||||
NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, NOTIFY_ICON_INFOTIP_FLAGS,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::DestroyIcon;
|
||||
|
||||
@@ -130,10 +130,7 @@ fn build_data(owner: HWND, kind: IconKind) -> NOTIFYICONDATAW {
|
||||
}
|
||||
|
||||
fn icon_id(kind: IconKind) -> u32 {
|
||||
match kind {
|
||||
IconKind::Claude => 1,
|
||||
IconKind::ChatGpt => 2,
|
||||
}
|
||||
kind.metadata().tray_icon_id
|
||||
}
|
||||
|
||||
fn write_utf16(dst: &mut [u16], src: &str) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use windows::Win32::System::Threading::{
|
||||
/// so no zombie wait is required.
|
||||
pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let mut cmdline = build_command_line(exe, args);
|
||||
let exe_w: Vec<u16> = exe.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
@@ -32,7 +33,7 @@ pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()> {
|
||||
let flags = CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
|
||||
let ok = unsafe {
|
||||
CreateProcessW(
|
||||
PCWSTR::null(),
|
||||
PCWSTR::from_raw(exe_w.as_ptr()),
|
||||
windows::core::PWSTR(cmdline.as_mut_ptr()),
|
||||
None,
|
||||
None,
|
||||
|
||||
+148
-55
@@ -1,10 +1,10 @@
|
||||
// Download a release asset and swap it in via native Win32 calls.
|
||||
// Download a release asset and swap it in via a detached helper process.
|
||||
//
|
||||
// After writing the new .exe to a staging path and verifying its
|
||||
// SHA-256, we `MoveFileExW` the running exe sideways (so Windows
|
||||
// releases the file lock on our own image), then `MoveFileExW` the
|
||||
// staged exe into place, then spawn the new binary detached via
|
||||
// `handoff::spawn_detached`. No shell, no console allocation.
|
||||
// The running process cannot reliably replace its own mapped image on
|
||||
// Windows. Instead, it writes the new .exe to a staging path, copies the
|
||||
// current executable to a helper path, starts that helper with
|
||||
// `--apply-update`, and then exits. The helper waits for the parent process
|
||||
// to exit before replacing the install target with the staged new binary.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -16,9 +16,7 @@ use windows::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_COPY_ALLOWED, MOVEFILE_REPLACE_EXISTING, MOVE_FILE_FLAGS,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcessId;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
MessageBoxW, MB_ICONERROR, MB_OK,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK};
|
||||
|
||||
use crate::net::Client;
|
||||
use crate::os::to_utf16_nul;
|
||||
@@ -35,20 +33,46 @@ pub fn begin(http: &Client, release: &super::Release) -> Result<(), super::Error
|
||||
if let Some(parent) = staging.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
download(http, &release.asset_url, &staging, release.asset_sha256.as_ref())?;
|
||||
swap_and_spawn(&staging, ¤t, &release.version)?;
|
||||
download(
|
||||
http,
|
||||
&release.asset_url,
|
||||
&staging,
|
||||
release.asset_sha256.as_ref(),
|
||||
)?;
|
||||
let helper = helper_path()?;
|
||||
reject_unsafe_path(&helper)?;
|
||||
prepare_update_helper(¤t, &helper)?;
|
||||
spawn_update_helper(&helper, &staging, ¤t, &release.version)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLI entry-point compatibility for `--apply-update <target> <source> <pid>`.
|
||||
/// The native handoff already does the swap-and-restart; if this binary
|
||||
/// is invoked with the legacy flag (e.g. from an older release's helper)
|
||||
/// just exit cleanly so the upgrade still completes.
|
||||
/// CLI entry point for `--apply-update <target> <source> <parent-pid> <version>`.
|
||||
/// Runs from the staged new binary, waits for the old UI process to exit,
|
||||
/// replaces the installed exe, and starts the installed copy.
|
||||
pub fn run_cli(args: &[String]) -> Option<i32> {
|
||||
if args.len() >= 2 && args[1] == "--apply-update" {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
if args.get(1).map(String::as_str) != Some("--apply-update") {
|
||||
return None;
|
||||
}
|
||||
let Some(target) = args.get(2).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(source) = args.get(3).map(PathBuf::from) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(parent_pid) = args.get(4).and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return Some(2);
|
||||
};
|
||||
let Some(version) = args.get(5).cloned() else {
|
||||
return Some(2);
|
||||
};
|
||||
|
||||
super::handoff::wait_for_parent_exit(parent_pid, 15_000);
|
||||
match replace_from_helper(&source, &target, &version) {
|
||||
Ok(()) => Some(0),
|
||||
Err(e) => {
|
||||
log::error!("apply-update failed: {e}");
|
||||
Some(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +87,9 @@ fn download(
|
||||
.header("User-Agent", super::release::user_agent())
|
||||
.send()?;
|
||||
if !(200..300).contains(&resp.status()) {
|
||||
return Err(super::Error::Network(crate::net::Error::Status(resp.status())));
|
||||
return Err(super::Error::Network(crate::net::Error::Status(
|
||||
resp.status(),
|
||||
)));
|
||||
}
|
||||
let body = resp.body();
|
||||
if let Some(expected) = expected_sha256 {
|
||||
@@ -94,61 +120,68 @@ fn hex_encode(bytes: &[u8]) -> String {
|
||||
fn reject_unsafe_path(p: &Path) -> Result<(), super::Error> {
|
||||
let s = p.to_string_lossy();
|
||||
if s.contains('%') {
|
||||
return Err(super::Error::UnsafePath(format!(
|
||||
"path contains '%': {s}"
|
||||
)));
|
||||
return Err(super::Error::UnsafePath(format!("path contains '%': {s}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn swap_and_spawn(
|
||||
source: &Path,
|
||||
fn spawn_update_helper(
|
||||
helper: &Path,
|
||||
staging: &Path,
|
||||
target: &Path,
|
||||
version: &super::release::Version,
|
||||
) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
// Step 1: rename running exe sideways. Windows allows renaming a
|
||||
// file even while its image is mapped into memory; this releases
|
||||
// the lock on the original `target` path. Same directory by
|
||||
// construction, so plain MoveFileExW with no flags is sufficient.
|
||||
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
|
||||
|
||||
// Step 2: move staged exe into place. Staging lives under
|
||||
// %LOCALAPPDATA%, target lives wherever the user installed —
|
||||
// COPY_ALLOWED lets MoveFileExW fall back to copy+delete when
|
||||
// the two paths cross volumes (portable installs on D:/E:/etc.).
|
||||
let step2_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
|
||||
if let Err(swap_err) = move_file(source, target, step2_flags) {
|
||||
// Best-effort revert. Same volume, no COPY_ALLOWED needed.
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("rollback also failed: {revert_err}; surfacing modal");
|
||||
let target_name = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
|
||||
surface_rollback_failure(&backup, &target_name);
|
||||
}
|
||||
return Err(swap_err);
|
||||
}
|
||||
|
||||
// Step 3: spawn the new exe detached with --wait-pid + --updated-to.
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let version_str = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
let args = vec![
|
||||
OsString::from("--wait-pid"),
|
||||
OsString::from("--apply-update"),
|
||||
target.as_os_str().to_os_string(),
|
||||
staging.as_os_str().to_os_string(),
|
||||
OsString::from(pid.to_string()),
|
||||
OsString::from("--updated-to"),
|
||||
OsString::from(version_str),
|
||||
];
|
||||
super::handoff::spawn_detached(helper, &args).map_err(super::Error::Io)
|
||||
}
|
||||
|
||||
fn replace_from_helper(source: &Path, target: &Path, version: &str) -> Result<(), super::Error> {
|
||||
let backup = backup_path(target);
|
||||
// Parent has exited, so the install target is no longer mapped.
|
||||
move_file(target, &backup, MOVE_FILE_FLAGS(0))?;
|
||||
|
||||
let swap_flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED;
|
||||
if let Err(swap_err) = move_file(source, target, swap_flags) {
|
||||
// Compatibility for users updating from a release that invoked
|
||||
// the downloaded binary itself as the helper. A mapped source exe
|
||||
// may not be movable, but it can usually still be copied.
|
||||
let copy_result = std::fs::copy(source, target);
|
||||
if copy_result.is_err() {
|
||||
log::error!("source move failed before copy fallback: {swap_err}");
|
||||
}
|
||||
if let Err(copy_err) = copy_result {
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("rollback also failed: {revert_err}; surfacing modal");
|
||||
let target_name = target
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "claude-code-usage-bubble.exe".to_string());
|
||||
surface_rollback_failure(&backup, &target_name);
|
||||
}
|
||||
return Err(super::Error::Io(copy_err));
|
||||
}
|
||||
}
|
||||
|
||||
let args = vec![OsString::from("--updated-to"), OsString::from(version)];
|
||||
if let Err(spawn_err) = super::handoff::spawn_detached(target, &args) {
|
||||
// New binary is on disk but won't auto-launch. Roll back so
|
||||
// the user's next "Restart" stays on the known-good version.
|
||||
log::error!("spawn_detached failed after swap: {spawn_err}; attempting revert");
|
||||
let _ = std::fs::remove_file(target);
|
||||
if let Err(revert_err) = move_file(&backup, target, MOVEFILE_REPLACE_EXISTING) {
|
||||
log::error!("post-spawn revert failed: {revert_err}");
|
||||
}
|
||||
return Err(super::Error::Io(spawn_err));
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(source);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -214,6 +247,47 @@ fn stage_path() -> Result<PathBuf, super::Error> {
|
||||
.join("update.exe"))
|
||||
}
|
||||
|
||||
fn helper_path() -> Result<PathBuf, super::Error> {
|
||||
let base = dirs::data_local_dir().ok_or_else(|| {
|
||||
super::Error::NotWritable("no local data directory available".to_string())
|
||||
})?;
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
Ok(base
|
||||
.join("ClaudeCodeUsageBubble")
|
||||
.join("updates")
|
||||
.join(format!("updater-helper-{pid}.exe")))
|
||||
}
|
||||
|
||||
fn prepare_update_helper(current: &Path, helper: &Path) -> Result<(), super::Error> {
|
||||
if let Some(parent) = helper.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(current, helper)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_staged_update_files() {
|
||||
let Ok(stage) = stage_path() else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = stage.parent() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name == "update.exe" || (name.starts_with("updater-helper-") && name.ends_with(".exe")) {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
log::debug!("cleanup_staged_update_files: remove {:?} failed: {e}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_writable(target: &Path) -> Result<(), super::Error> {
|
||||
let parent = target.parent().ok_or_else(|| {
|
||||
super::Error::NotWritable("could not resolve install directory".to_string())
|
||||
@@ -223,3 +297,22 @@ fn ensure_writable(target: &Path) -> Result<(), super::Error> {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stage_and_helper_paths_are_distinct_exes() {
|
||||
let stage = stage_path().expect("stage path");
|
||||
let helper = helper_path().expect("helper path");
|
||||
|
||||
assert_eq!(stage.file_name().unwrap(), "update.exe");
|
||||
assert!(helper
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("updater-helper-"));
|
||||
assert_ne!(stage, helper);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-7
@@ -7,7 +7,10 @@
|
||||
use crate::creds::Locator;
|
||||
use crate::net::Client;
|
||||
use crate::settings::Settings;
|
||||
use crate::usage::{anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider, UsageWindows};
|
||||
use crate::usage::{
|
||||
anthropic::ClaudeProvider, chatgpt::ChatGptProvider, refresh, Error, ProviderId, UsageProvider,
|
||||
UsageWindows,
|
||||
};
|
||||
|
||||
pub struct Registry {
|
||||
claude: ClaudeProvider,
|
||||
@@ -28,20 +31,32 @@ impl Registry {
|
||||
settings: &Settings,
|
||||
) -> Vec<(ProviderId, Result<UsageWindows, Error>)> {
|
||||
let mut out = Vec::new();
|
||||
if settings.show_claude_code {
|
||||
out.push((ProviderId::Claude, self.claude.poll(http)));
|
||||
}
|
||||
if settings.show_codex {
|
||||
out.push((ProviderId::ChatGpt, self.chatgpt.poll(http)));
|
||||
for provider in ProviderId::LIVE_USAGE {
|
||||
if !settings.is_provider_enabled(provider) {
|
||||
continue;
|
||||
}
|
||||
let result = match provider {
|
||||
ProviderId::Claude => self.claude.poll(http),
|
||||
ProviderId::ChatGpt => self.chatgpt.poll(http),
|
||||
ProviderId::OpenCodeGo => {
|
||||
unreachable!("OpenCode Go has no live usage provider yet")
|
||||
}
|
||||
};
|
||||
out.push((provider, result));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Attempt to refresh the active source for one provider.
|
||||
pub fn try_refresh(&self, id: ProviderId, orchestrator: &refresh::Orchestrator) -> refresh::Outcome {
|
||||
pub fn try_refresh(
|
||||
&self,
|
||||
id: ProviderId,
|
||||
orchestrator: &refresh::Orchestrator,
|
||||
) -> refresh::Outcome {
|
||||
let locator = match id {
|
||||
ProviderId::Claude => self.claude.locator(),
|
||||
ProviderId::ChatGpt => self.chatgpt.locator(),
|
||||
ProviderId::OpenCodeGo => return refresh::Outcome::CliMissing,
|
||||
};
|
||||
match locator.first_available() {
|
||||
Some(src) => orchestrator.refresh(src),
|
||||
|
||||
+50
-5
@@ -1,9 +1,4 @@
|
||||
// Usage data shapes shared across providers.
|
||||
//
|
||||
// Every provider reports its quota as two named "windows" (short + long).
|
||||
// For Claude: 5-hour and 7-day. For ChatGPT: primary and secondary. We
|
||||
// normalise to `primary` + `secondary` so the UI layer doesn't care which
|
||||
// provider produced the snapshot.
|
||||
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -11,15 +6,65 @@ use std::time::SystemTime;
|
||||
pub enum ProviderId {
|
||||
Claude,
|
||||
ChatGpt,
|
||||
OpenCodeGo,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
pub const ALL: [Self; 3] = [Self::Claude, Self::ChatGpt, Self::OpenCodeGo];
|
||||
pub const LIVE_USAGE: [Self; 2] = [Self::Claude, Self::ChatGpt];
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::ChatGpt => "chatgpt",
|
||||
Self::OpenCodeGo => "opencode-go",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata(self) -> ProviderMetadata {
|
||||
match self {
|
||||
Self::Claude => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "claude",
|
||||
default_enabled: true,
|
||||
tray_icon_id: 1,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::ChatGpt => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "chatgpt",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 2,
|
||||
display_mode: ProviderDisplayMode::TwoWindow,
|
||||
live_usage: true,
|
||||
},
|
||||
Self::OpenCodeGo => ProviderMetadata {
|
||||
id: self,
|
||||
slug: "opencode-go",
|
||||
default_enabled: false,
|
||||
tray_icon_id: 3,
|
||||
display_mode: ProviderDisplayMode::WeeklyMonthlyFourBar,
|
||||
live_usage: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProviderDisplayMode {
|
||||
TwoWindow,
|
||||
WeeklyMonthlyFourBar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ProviderMetadata {
|
||||
pub id: ProviderId,
|
||||
pub slug: &'static str,
|
||||
pub default_enabled: bool,
|
||||
pub tray_icon_id: u32,
|
||||
pub display_mode: ProviderDisplayMode,
|
||||
pub live_usage: bool,
|
||||
}
|
||||
|
||||
/// One usage window: how much you've consumed (0–100), and when it resets.
|
||||
|
||||
+16
-8
@@ -5,20 +5,28 @@
|
||||
use crate::os::Rgb as Color;
|
||||
use crate::usage::ProviderId;
|
||||
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex =
|
||||
/// OpenAI brand teal `#10A37F`, used consistently across dark and light
|
||||
/// themes so the badge/bubble/panel never disagree on Codex identity.
|
||||
pub fn accent_color_for(model: ProviderId, _is_dark: bool) -> Color {
|
||||
/// Per-provider identity color. Claude = warm orange `#D97757`. Codex tracks
|
||||
/// the OpenAI Codex monochrome palette — near-white `#E5E5E5` on dark themes,
|
||||
/// near-black `#1A1A1A` on light — so the accent stays readable against both
|
||||
/// the dark bubble surface and the `#F3F3F3` light background.
|
||||
pub fn accent_color_for(model: ProviderId, is_dark: bool) -> Color {
|
||||
match model {
|
||||
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
|
||||
ProviderId::ChatGpt => {
|
||||
if is_dark {
|
||||
Color::from_hex("#E5E5E5")
|
||||
} else {
|
||||
Color::from_hex("#1A1A1A")
|
||||
}
|
||||
}
|
||||
ProviderId::OpenCodeGo => Color::from_hex("#3BAE75"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discrete 4-band fill color. The "safe" band uses the provider's identity
|
||||
/// color so Codex bars stay white-on-dark while Claude bars stay orange; the
|
||||
/// warning bands are theme-aware so light-mode amber stays readable against
|
||||
/// the `#F3F3F3` background.
|
||||
/// color so Codex bars render monochrome (light-on-dark / dark-on-light) while
|
||||
/// Claude bars stay orange; the warning bands are theme-aware so light-mode
|
||||
/// amber stays readable against the `#F3F3F3` background.
|
||||
///
|
||||
/// - <60% → provider accent
|
||||
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||
|
||||
Reference in New Issue
Block a user