mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-09-09 14:17:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b31d3211 | ||
|
|
5c2b14fc03 | ||
|
|
51889d3c39 | ||
|
|
6661a7a10b | ||
|
|
6cafffc883 | ||
|
|
b58811bfe0 | ||
|
|
e50aa3522a | ||
|
|
a3f1323154 | ||
|
|
081a70a537 | ||
|
|
391ad0cba2 | ||
|
|
77325b1e00 | ||
|
|
c3d01f36d2 | ||
|
|
7bbf80e5f7 | ||
|
|
8cbc3dda5b | ||
|
|
526786b902 | ||
|
|
f96d88074a | ||
|
|
e243c589a8 | ||
|
|
a5dec52aaa | ||
|
|
4e0f32591b | ||
|
|
3e1af07ec2 |
Generated
+1
-1
@@ -59,7 +59,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.10"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claude-code-usage-bubble"
|
name = "claude-code-usage-bubble"
|
||||||
version = "0.1.10"
|
version = "0.3.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
description = "Floating bubble showing Claude Code and Codex usage on Windows"
|
||||||
@@ -23,6 +23,7 @@ version = "0.58"
|
|||||||
features = [
|
features = [
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Globalization",
|
"Win32_Globalization",
|
||||||
|
"Win32_Graphics_Dwm",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ self-updater are all written from scratch against the same public APIs
|
|||||||
Codex usage as a percentage and a colored progress ring
|
Codex usage as a percentage and a colored progress ring
|
||||||
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
- Drag anywhere — the bubble snaps to monitor work-area edges when
|
||||||
released
|
released
|
||||||
- Resize with `Ctrl + MouseWheel` on the bubble (32–128 pixels)
|
- Resize with `Ctrl + MouseWheel` on the bubble, or use **Controls** →
|
||||||
|
**Make smaller / Make larger / Reset size** from the right-click menu
|
||||||
|
(140–360 logical pixels)
|
||||||
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
- Left-click the bubble for an expanded panel with both **5h** and **7d**
|
||||||
bars plus reset countdowns
|
bars plus reset countdowns
|
||||||
- Right-click for refresh, displayed models, update frequency, language,
|
- Right-click for refresh, displayed models, update frequency, language,
|
||||||
@@ -84,10 +86,11 @@ 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)
|
- **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, models, refresh frequency, language, "Start
|
||||||
with Windows", auto-update check (Disabled / Hourly / Daily / Weekly),
|
with Windows", controls, auto-update check (Disabled / Hourly / Daily /
|
||||||
manual "Check for updates", exit
|
Weekly), manual "Check for updates", exit
|
||||||
- **Drag** anywhere — it floats on top of all other windows
|
- **Drag** anywhere — it floats on top of all other windows
|
||||||
- **Ctrl + MouseWheel** on the bubble to resize it
|
- **Ctrl + MouseWheel** on the bubble, or **Controls** in the right-click
|
||||||
|
menu, to resize it
|
||||||
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
- **Tray icon** (if enabled): left-click toggles the bubble visibility,
|
||||||
right-click opens the same menu
|
right-click opens the same menu
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# UI/UX Improvement Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
- Product: native Windows floating usage bubble for Claude Code/Codex.
|
||||||
|
- Current UI stack: Win32 popup/layered windows, tiny-skia drawing, GDI text, Shell tray icons.
|
||||||
|
- Current baseline: `cargo check` passes on 2026-05-23.
|
||||||
|
- Primary files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, `src/tray/*`, `src/usage_color.rs`, `src/i18n/locales/*.toml`.
|
||||||
|
|
||||||
|
## Phase 1 - Accessibility And Status Clarity
|
||||||
|
- Status: Partially Complete
|
||||||
|
- Priority: High
|
||||||
|
- Files: `src/usage_color.rs`, `src/bubble.rs`, `src/panel.rs`, `src/tray/mod.rs`, locale TOMLs.
|
||||||
|
- Improve non-color status cues for normal/warning/critical/auth/error states.
|
||||||
|
- Add richer tray tooltips: model, 5h percent/countdown, 7d percent/countdown, current state.
|
||||||
|
- Add localized strings for warning/critical labels and unavailable/auth states.
|
||||||
|
- Keep usage colors centralized in `usage_color.rs`; avoid per-surface color drift.
|
||||||
|
- Validation: contrast check for light/dark colors, manual tray tooltip check, `cargo check`.
|
||||||
|
- Completed 2026-05-23: richer tray tooltip now includes model, 5h, 7d, and left-click hint.
|
||||||
|
- Completed 2026-05-23: tray tooltip uses shorter localized tray hint text to reduce truncation risk.
|
||||||
|
|
||||||
|
## Phase 2 - Discoverability And Native Controls
|
||||||
|
- Status: Partially Complete
|
||||||
|
- Priority: High
|
||||||
|
- Files: `src/app.rs`, `src/bubble.rs`, locale TOMLs.
|
||||||
|
- Add menu items for common hidden actions: resize smaller/larger, reset size, show details.
|
||||||
|
- Add a short localized "Help" or "Controls" submenu listing drag, click, right-click, Ctrl+wheel.
|
||||||
|
- Make resize available through menu commands, not only Ctrl+MouseWheel.
|
||||||
|
- Review context-menu grouping so status/update/model/settings actions scan as separate groups.
|
||||||
|
- Validation: keyboard-access menu traversal, menu command behavior, persisted settings.
|
||||||
|
- Completed 2026-05-23: added localized Controls submenu with resize actions and disabled help rows.
|
||||||
|
- Completed 2026-05-23: disabled resize commands when they would no-op and unified menu/wheel resize through shared bubble size.
|
||||||
|
|
||||||
|
## Phase 3 - Bubble Legibility And Interaction Robustness
|
||||||
|
- Status: Planned
|
||||||
|
- Priority: Medium
|
||||||
|
- Files: `src/bubble.rs`, optional extracted bubble modules.
|
||||||
|
- Improve layout for smallest sizes: reserve stable text bounds, handle `100%`, placeholder, `!`, and long countdowns.
|
||||||
|
- Consider minimum size/shape copy update because code uses 140-360 logical width while README mentions 32-128 pixels.
|
||||||
|
- Add drag threshold and click behavior review around `WM_EXITSIZEMOVE` to reduce accidental panel opens.
|
||||||
|
- Add optional pulse reduction path if Windows animation/reduced-motion preference is available.
|
||||||
|
- Validation: manual checks at min/default/max size, 100/125/150/200% DPI, both models enabled.
|
||||||
|
|
||||||
|
## Phase 4 - Expanded Panel Redesign
|
||||||
|
- Status: Planned
|
||||||
|
- Priority: Medium
|
||||||
|
- Files: `src/panel.rs`, locale TOMLs, maybe `src/app.rs`.
|
||||||
|
- Replace fixed 280x120 assumptions with measured or wider adaptive layout.
|
||||||
|
- Make rows self-explanatory: model header, 5h and 7d labels, percent plus reset countdown.
|
||||||
|
- Add explicit error/auth/loading state rendering instead of only symbols/placeholders.
|
||||||
|
- Improve panel placement near screen edges and multi-monitor boundaries.
|
||||||
|
- Consider extracting panel layout/painting into smaller modules before behavior changes.
|
||||||
|
- Validation: all locales, long countdown text, light/dark theme, focus-loss close behavior.
|
||||||
|
|
||||||
|
## Phase 5 - Tray And Notification Polish
|
||||||
|
- Status: Planned
|
||||||
|
- Priority: Medium
|
||||||
|
- Files: `src/tray/mod.rs`, `src/tray/badge.rs`, `src/app.rs`.
|
||||||
|
- Make tray icon state readable without exact color distinction: tooltip carries exact data, icon bands remain coarse.
|
||||||
|
- Review notification throttling and text for threshold crossings.
|
||||||
|
- Ensure tray left-click/right-click behavior matches Windows notification-area conventions.
|
||||||
|
- Add manual test matrix for one-provider and two-provider modes.
|
||||||
|
- Validation: tray add/modify/delete, balloon messages, no stale icons after exit/restart.
|
||||||
|
|
||||||
|
## Phase 6 - Structure And Verification
|
||||||
|
- Status: Planned
|
||||||
|
- Priority: Medium
|
||||||
|
- Files: `src/bubble.rs`, `src/panel.rs`, `src/app.rs`, docs if behavior changes.
|
||||||
|
- Split only where it reduces real risk: bubble layout/rendering/interaction and panel layout/rendering first.
|
||||||
|
- Keep public behavior stable while extracting.
|
||||||
|
- Add unit tests for pure functions where practical: color bands, size clamps, layout math, countdown formatting.
|
||||||
|
- Run `cargo check`; run `cargo test` if tests are added.
|
||||||
|
- Update README/docs after behavior changes, especially controls and size range.
|
||||||
|
- Completed 2026-05-23: added locale schema tests covering embedded locale parsing and Controls/tray strings.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- Bubble and panel remain readable at min/default/max sizes and common DPI scales.
|
||||||
|
- Warning/critical/auth/error states are understandable without relying only on color.
|
||||||
|
- Hidden interactions have menu alternatives or discoverable help text.
|
||||||
|
- Panel handles all existing locales without clipping core data.
|
||||||
|
- Tray tooltip and notifications communicate exact state.
|
||||||
|
- Source still compiles; new pure behavior has focused tests where feasible.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- Native Win32 UI changes require manual Windows runtime verification; screenshots/tests are limited.
|
||||||
|
- `src/bubble.rs` and `src/app.rs` are large and coupled; extract before broad changes when touching multiple concerns.
|
||||||
|
- Adaptive text/layout can regress small-size readability if not verified at 140 logical width.
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
- Should the bubble stay stadium-shaped, or should compact circular mode return as an option?
|
||||||
|
- Should menu help be always present, or only shown on first run/first right-click?
|
||||||
|
- Should reduced-motion preference disable only pulse, or all nonessential animation?
|
||||||
+187
-87
@@ -27,9 +27,7 @@ use crate::net;
|
|||||||
use crate::os;
|
use crate::os;
|
||||||
use crate::panel::{self, PanelData};
|
use crate::panel::{self, PanelData};
|
||||||
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
use crate::settings::{self, Settings, POLL_15_MIN, POLL_1_HOUR, POLL_1_MIN, POLL_5_MIN};
|
||||||
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData};
|
use crate::tray::{self, TrayAction, TrayIcon as TrayIconData, WM_APP_TRAY};
|
||||||
use crate::usage::ProviderId as TrayIconKind;
|
|
||||||
use crate::tray::WM_APP_TRAY;
|
|
||||||
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
use crate::update::{self, Channel as InstallChannel, CheckOutcome};
|
||||||
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
use crate::usage::{self, ProviderId, Registry, UsageWindows};
|
||||||
|
|
||||||
@@ -72,6 +70,9 @@ const IDM_START_WITH_WINDOWS: u16 = 30;
|
|||||||
const IDM_RESET_POSITION: u16 = 31;
|
const IDM_RESET_POSITION: u16 = 31;
|
||||||
const IDM_VERSION_ACTION: u16 = 32;
|
const IDM_VERSION_ACTION: u16 = 32;
|
||||||
const IDM_RESTART: u16 = 33;
|
const IDM_RESTART: u16 = 33;
|
||||||
|
const IDM_SIZE_SMALLER: u16 = 34;
|
||||||
|
const IDM_SIZE_LARGER: u16 = 35;
|
||||||
|
const IDM_RESET_SIZE: u16 = 36;
|
||||||
const IDM_LANG_SYSTEM: u16 = 40;
|
const IDM_LANG_SYSTEM: u16 = 40;
|
||||||
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
// 50 is reserved by tray::IDM_TOGGLE_WIDGET — keep the auto-update range
|
||||||
// clear of it (and any future tray ids in the 5x band).
|
// clear of it (and any future tray ids in the 5x band).
|
||||||
@@ -110,7 +111,7 @@ enum UpdateStatus {
|
|||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
msg_hwnd: SendHwnd,
|
msg_hwnd: SendHwnd,
|
||||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
is_dark: bool,
|
is_dark: bool,
|
||||||
@@ -145,6 +146,21 @@ fn lock_state() -> MutexGuard<'static, Option<AppState>> {
|
|||||||
state().lock().expect("app state mutex poisoned")
|
state().lock().expect("app state mutex poisoned")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run `f` with mutable access to `AppState`, then snapshot `Settings` and
|
||||||
|
/// persist it to disk. The lock is released before the disk write so the UI
|
||||||
|
/// thread doesn't block on I/O. No-op if state hasn't been initialised yet.
|
||||||
|
fn update_settings(f: impl FnOnce(&mut AppState)) {
|
||||||
|
let snap = {
|
||||||
|
let mut guard = lock_state();
|
||||||
|
let Some(s) = guard.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
f(s);
|
||||||
|
s.settings.clone()
|
||||||
|
};
|
||||||
|
settings::save(&snap);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Entry ----------
|
// ---------- Entry ----------
|
||||||
|
|
||||||
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
/// Acquire the singleton mutex, optionally retrying for ~3s if the
|
||||||
@@ -230,6 +246,15 @@ pub fn run(args: crate::AppArgs) {
|
|||||||
last_balloon_at: None,
|
last_balloon_at: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bubble::install_callbacks(bubble::Callbacks {
|
||||||
|
on_click: on_bubble_click,
|
||||||
|
on_right_click: on_bubble_right_click,
|
||||||
|
on_moved: on_bubble_moved,
|
||||||
|
on_resized: on_bubble_resized,
|
||||||
|
on_menu_command,
|
||||||
|
on_settings_changed: recheck_theme,
|
||||||
|
});
|
||||||
|
|
||||||
create_initial_bubbles();
|
create_initial_bubbles();
|
||||||
refresh_tray_icons();
|
refresh_tray_icons();
|
||||||
|
|
||||||
@@ -307,7 +332,7 @@ fn create_initial_bubbles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
|
fn spawn_bubble(kind: ProviderId, settings: &Settings, is_dark: bool) {
|
||||||
// "…" matches the in-flight/transient-error placeholder used by
|
// "…" matches the in-flight/transient-error placeholder used by
|
||||||
// `apply_results`, so the bubble has visible feedback during the first
|
// `apply_results`, so the bubble has visible feedback during the first
|
||||||
// poll rather than rendering with two empty grey tracks.
|
// poll rather than rendering with two empty grey tracks.
|
||||||
@@ -318,8 +343,10 @@ fn spawn_bubble(kind: TrayIconKind, settings: &Settings, is_dark: bool) {
|
|||||||
position: settings.bubble_positions.get(kind),
|
position: settings.bubble_positions.get(kind),
|
||||||
session_pct: None,
|
session_pct: None,
|
||||||
session_text: placeholder.clone(),
|
session_text: placeholder.clone(),
|
||||||
|
session_resets_at: None,
|
||||||
weekly_pct: None,
|
weekly_pct: None,
|
||||||
weekly_text: placeholder,
|
weekly_text: placeholder,
|
||||||
|
weekly_resets_at: None,
|
||||||
is_dark,
|
is_dark,
|
||||||
});
|
});
|
||||||
if hwnd != HWND::default() {
|
if hwnd != HWND::default() {
|
||||||
@@ -365,40 +392,24 @@ unsafe extern "system" fn msg_wnd_proc(
|
|||||||
|
|
||||||
// ---------- Bubble callbacks ----------
|
// ---------- Bubble callbacks ----------
|
||||||
|
|
||||||
pub fn on_bubble_click(hwnd: HWND, model: TrayIconKind) {
|
fn on_bubble_click(hwnd: HWND, model: ProviderId) {
|
||||||
let data = build_panel_data(model);
|
let data = build_panel_data(model);
|
||||||
panel::toggle(data, hwnd);
|
panel::toggle(data, hwnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bubble_right_click(hwnd: HWND, _model: TrayIconKind, _pt: POINT) {
|
fn on_bubble_right_click(hwnd: HWND, _model: ProviderId, _pt: POINT) {
|
||||||
show_context_menu(hwnd);
|
show_context_menu(hwnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bubble_moved(model: TrayIconKind, pos: (i32, i32)) {
|
fn on_bubble_moved(model: ProviderId, pos: (i32, i32)) {
|
||||||
let snap = {
|
update_settings(|s| s.settings.bubble_positions.set(model, pos));
|
||||||
let mut s = lock_state();
|
|
||||||
let Some(s) = s.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
s.settings.bubble_positions.set(model, pos);
|
|
||||||
s.settings.clone()
|
|
||||||
};
|
|
||||||
settings::save(&snap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bubble_resized(_model: TrayIconKind, size_logical: i32) {
|
fn on_bubble_resized(_model: ProviderId, size_logical: i32) {
|
||||||
let snap = {
|
set_bubble_size(size_logical);
|
||||||
let mut s = lock_state();
|
|
||||||
let Some(s) = s.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
s.settings.bubble_size_logical = size_logical;
|
|
||||||
s.settings.clone()
|
|
||||||
};
|
|
||||||
settings::save(&snap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
||||||
let id = (id & 0xFFFF) as u16;
|
let id = (id & 0xFFFF) as u16;
|
||||||
match id {
|
match id {
|
||||||
IDM_REFRESH => spawn_poll_thread(),
|
IDM_REFRESH => spawn_poll_thread(),
|
||||||
@@ -412,13 +423,14 @@ pub fn on_menu_command(id: u32, _owner_hwnd: HWND) {
|
|||||||
IDM_START_WITH_WINDOWS => toggle_startup(),
|
IDM_START_WITH_WINDOWS => toggle_startup(),
|
||||||
IDM_RESET_POSITION => reset_positions(),
|
IDM_RESET_POSITION => reset_positions(),
|
||||||
IDM_VERSION_ACTION => version_action(),
|
IDM_VERSION_ACTION => version_action(),
|
||||||
|
IDM_SIZE_SMALLER => resize_bubbles(-bubble::RESIZE_STEP_LOGICAL),
|
||||||
|
IDM_SIZE_LARGER => resize_bubbles(bubble::RESIZE_STEP_LOGICAL),
|
||||||
|
IDM_RESET_SIZE => set_bubble_size(bubble::DEFAULT_BUBBLE_SIZE),
|
||||||
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
|
IDM_UPDATE_AUTO_OFF => set_update_check_interval(None),
|
||||||
IDM_UPDATE_AUTO_HOURLY => {
|
IDM_UPDATE_AUTO_HOURLY => {
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
set_update_check_interval(Some(settings::UPDATE_CHECK_HOURLY_SECS))
|
||||||
}
|
}
|
||||||
IDM_UPDATE_AUTO_DAILY => {
|
IDM_UPDATE_AUTO_DAILY => set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS)),
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_DAILY_SECS))
|
|
||||||
}
|
|
||||||
IDM_UPDATE_AUTO_WEEKLY => {
|
IDM_UPDATE_AUTO_WEEKLY => {
|
||||||
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
set_update_check_interval(Some(settings::UPDATE_CHECK_WEEKLY_SECS))
|
||||||
}
|
}
|
||||||
@@ -618,8 +630,7 @@ fn propagate_to_ui() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (kind, hwnd) in snap.bubbles.iter() {
|
for (kind, hwnd) in snap.bubbles.iter() {
|
||||||
let id = kind_to_provider(*kind);
|
let entry = snap.snapshots.get(kind);
|
||||||
let entry = snap.snapshots.get(&id);
|
|
||||||
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
let session_pct = entry.map(|s| s.windows.primary.utilization);
|
||||||
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
let weekly_pct = entry.map(|s| s.windows.secondary.utilization);
|
||||||
// The bubble paints the percent inline inside the bar fill, so it
|
// The bubble paints the percent inline inside the bar fill, so it
|
||||||
@@ -631,20 +642,23 @@ fn propagate_to_ui() {
|
|||||||
let weekly_text = entry
|
let weekly_text = entry
|
||||||
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
.map(|s| i18n::format_countdown(s.windows.secondary.resets_at, &snap.i18n_strings))
|
||||||
.unwrap_or_default();
|
.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(
|
bubble::update_data(
|
||||||
hwnd.to_hwnd(),
|
hwnd.to_hwnd(),
|
||||||
session_pct,
|
session_pct,
|
||||||
session_text,
|
session_text,
|
||||||
|
session_resets_at,
|
||||||
weekly_pct,
|
weekly_pct,
|
||||||
weekly_text,
|
weekly_text,
|
||||||
|
weekly_resets_at,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
refresh_tray_icons_with(&snap);
|
refresh_tray_icons_with(&snap);
|
||||||
|
|
||||||
if panel::is_visible() {
|
if panel::is_visible() {
|
||||||
if let Some(model) = panel::current_model() {
|
if let Some(model) = panel::current_model() {
|
||||||
let id = kind_to_provider(model);
|
if let Some(provider_state) = snap.snapshots.get(&model) {
|
||||||
if let Some(provider_state) = snap.snapshots.get(&id) {
|
|
||||||
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
panel::refresh_data(build_panel_data_from(&snap, model, provider_state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,7 +668,7 @@ fn propagate_to_ui() {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct UiSnapshot {
|
struct UiSnapshot {
|
||||||
bubbles: HashMap<TrayIconKind, SendHwnd>,
|
bubbles: HashMap<ProviderId, SendHwnd>,
|
||||||
snapshots: HashMap<ProviderId, ProviderUiState>,
|
snapshots: HashMap<ProviderId, ProviderUiState>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n_strings: LocaleStrings,
|
i18n_strings: LocaleStrings,
|
||||||
@@ -663,21 +677,13 @@ struct UiSnapshot {
|
|||||||
last_poll_ok: bool,
|
last_poll_ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind_to_provider(k: TrayIconKind) -> ProviderId {
|
fn build_panel_data(model: ProviderId) -> PanelData {
|
||||||
match k {
|
|
||||||
ProviderId::Claude => ProviderId::Claude,
|
|
||||||
ProviderId::ChatGpt => ProviderId::ChatGpt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_panel_data(model: TrayIconKind) -> PanelData {
|
|
||||||
let s = lock_state();
|
let s = lock_state();
|
||||||
let Some(s) = s.as_ref() else {
|
let Some(s) = s.as_ref() else {
|
||||||
return placeholder_panel(model);
|
return placeholder_panel(model);
|
||||||
};
|
};
|
||||||
let id = kind_to_provider(model);
|
|
||||||
let strings = s.i18n.strings().clone();
|
let strings = s.i18n.strings().clone();
|
||||||
let provider_state = s.snapshots.get(&id).cloned().unwrap_or_default();
|
let provider_state = s.snapshots.get(&model).cloned().unwrap_or_default();
|
||||||
PanelData {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
session_pct: provider_state.windows.primary.utilization,
|
session_pct: provider_state.windows.primary.utilization,
|
||||||
@@ -689,7 +695,7 @@ fn build_panel_data(model: TrayIconKind) -> PanelData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiState) -> PanelData {
|
fn build_panel_data_from(snap: &UiSnapshot, model: ProviderId, p: &ProviderUiState) -> PanelData {
|
||||||
PanelData {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
session_pct: p.windows.primary.utilization,
|
session_pct: p.windows.primary.utilization,
|
||||||
@@ -701,7 +707,7 @@ fn build_panel_data_from(snap: &UiSnapshot, model: TrayIconKind, p: &ProviderUiS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn placeholder_panel(model: TrayIconKind) -> PanelData {
|
fn placeholder_panel(model: ProviderId) -> PanelData {
|
||||||
let strings = i18n::I18n::load(None).strings().clone();
|
let strings = i18n::I18n::load(None).strings().clone();
|
||||||
PanelData {
|
PanelData {
|
||||||
model,
|
model,
|
||||||
@@ -763,14 +769,7 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
tooltip: format!(
|
tooltip: tray_tooltip(&snap.i18n_strings.claude_label, entry, &snap.i18n_strings),
|
||||||
"{} {}: {} | {}: {}",
|
|
||||||
snap.i18n_strings.claude_label,
|
|
||||||
snap.i18n_strings.session_window,
|
|
||||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
|
||||||
snap.i18n_strings.weekly_window,
|
|
||||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if snap.settings.show_codex {
|
if snap.settings.show_codex {
|
||||||
@@ -782,19 +781,27 @@ fn refresh_tray_icons_with(snap: &UiSnapshot) {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
tooltip: format!(
|
tooltip: tray_tooltip(&snap.i18n_strings.chatgpt_label, entry, &snap.i18n_strings),
|
||||||
"{} {}: {} | {}: {}",
|
|
||||||
snap.i18n_strings.chatgpt_label,
|
|
||||||
snap.i18n_strings.session_window,
|
|
||||||
entry.map(|e| e.primary_text.as_str()).unwrap_or(""),
|
|
||||||
snap.i18n_strings.weekly_window,
|
|
||||||
entry.map(|e| e.secondary_text.as_str()).unwrap_or(""),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
tray::sync(snap.msg_hwnd.to_hwnd(), &icons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tray_tooltip(label: &str, entry: Option<&ProviderUiState>, strings: &LocaleStrings) -> String {
|
||||||
|
let session = entry
|
||||||
|
.map(|e| e.primary_text.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("...");
|
||||||
|
let weekly = entry
|
||||||
|
.map(|e| e.secondary_text.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("...");
|
||||||
|
format!(
|
||||||
|
"{label}\n{}: {session}\n{}: {weekly}\n{}",
|
||||||
|
strings.session_window, strings.weekly_window, strings.tray_left_click
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_tray_action(action: TrayAction) {
|
fn handle_tray_action(action: TrayAction) {
|
||||||
match action {
|
match action {
|
||||||
TrayAction::None => {}
|
TrayAction::None => {}
|
||||||
@@ -816,7 +823,7 @@ fn handle_tray_action(action: TrayAction) {
|
|||||||
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
/// the UI. Called from each bubble's WM_SETTINGCHANGE handler — Windows
|
||||||
/// posts that to every top-level window when the user toggles light/dark
|
/// posts that to every top-level window when the user toggles light/dark
|
||||||
/// in Settings, so this naturally fires once per change.
|
/// in Settings, so this naturally fires once per change.
|
||||||
pub fn recheck_theme() {
|
fn recheck_theme() {
|
||||||
let now_dark = os::theme::is_dark();
|
let now_dark = os::theme::is_dark();
|
||||||
let changed = {
|
let changed = {
|
||||||
let mut s = lock_state();
|
let mut s = lock_state();
|
||||||
@@ -930,6 +937,7 @@ struct ContextMenuSnapshot {
|
|||||||
widget_visible: bool,
|
widget_visible: bool,
|
||||||
install_channel: InstallChannel,
|
install_channel: InstallChannel,
|
||||||
update_status: UpdateStatus,
|
update_status: UpdateStatus,
|
||||||
|
bubble_size_logical: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_context_menu(owner_hwnd: HWND) {
|
fn show_context_menu(owner_hwnd: HWND) {
|
||||||
@@ -949,6 +957,7 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
widget_visible: s.settings.widget_visible,
|
widget_visible: s.settings.widget_visible,
|
||||||
install_channel: s.install_channel,
|
install_channel: s.install_channel,
|
||||||
update_status: s.update_status,
|
update_status: s.update_status,
|
||||||
|
bubble_size_logical: s.settings.bubble_size_logical,
|
||||||
},
|
},
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
@@ -990,13 +999,21 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
models,
|
models,
|
||||||
IDM_MODEL_CLAUDE,
|
IDM_MODEL_CLAUDE,
|
||||||
&snap.strings.claude_label,
|
&snap.strings.claude_label,
|
||||||
if snap.show_claude { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
if snap.show_claude {
|
||||||
|
MF_CHECKED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
append_item(
|
append_item(
|
||||||
models,
|
models,
|
||||||
IDM_MODEL_CHATGPT,
|
IDM_MODEL_CHATGPT,
|
||||||
&snap.strings.chatgpt_label,
|
&snap.strings.chatgpt_label,
|
||||||
if snap.show_chatgpt { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
if snap.show_chatgpt {
|
||||||
|
MF_CHECKED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
append_submenu(menu, models, &snap.strings.models);
|
append_submenu(menu, models, &snap.strings.models);
|
||||||
|
|
||||||
@@ -1009,7 +1026,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
settings_menu,
|
settings_menu,
|
||||||
IDM_START_WITH_WINDOWS,
|
IDM_START_WITH_WINDOWS,
|
||||||
&snap.strings.start_with_windows,
|
&snap.strings.start_with_windows,
|
||||||
if is_startup_enabled() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
if is_startup_enabled() {
|
||||||
|
MF_CHECKED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
append_item(
|
append_item(
|
||||||
settings_menu,
|
settings_menu,
|
||||||
@@ -1028,7 +1049,11 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
lang,
|
lang,
|
||||||
IDM_LANG_SYSTEM,
|
IDM_LANG_SYSTEM,
|
||||||
&snap.strings.system_default,
|
&snap.strings.system_default,
|
||||||
if snap.language_override.is_none() { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
if snap.language_override.is_none() {
|
||||||
|
MF_CHECKED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
for (i, (code, name)) in snap.available.iter().enumerate() {
|
for (i, (code, name)) in snap.available.iter().enumerate() {
|
||||||
let id = IDM_LANG_BASE + i as u16;
|
let id = IDM_LANG_BASE + i as u16;
|
||||||
@@ -1056,7 +1081,12 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
} else {
|
} else {
|
||||||
MENU_ITEM_FLAGS(0)
|
MENU_ITEM_FLAGS(0)
|
||||||
};
|
};
|
||||||
append_item(settings_menu, IDM_VERSION_ACTION, &version_label, version_flags);
|
append_item(
|
||||||
|
settings_menu,
|
||||||
|
IDM_VERSION_ACTION,
|
||||||
|
&version_label,
|
||||||
|
version_flags,
|
||||||
|
);
|
||||||
|
|
||||||
let Ok(auto_update) = CreatePopupMenu() else {
|
let Ok(auto_update) = CreatePopupMenu() else {
|
||||||
log::error!("CreatePopupMenu(auto_update) failed");
|
log::error!("CreatePopupMenu(auto_update) failed");
|
||||||
@@ -1092,11 +1122,58 @@ fn show_context_menu(owner_hwnd: HWND) {
|
|||||||
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
append_submenu(settings_menu, auto_update, &snap.strings.auto_update_check);
|
||||||
append_submenu(menu, settings_menu, &snap.strings.settings);
|
append_submenu(menu, settings_menu, &snap.strings.settings);
|
||||||
|
|
||||||
|
let Ok(controls) = CreatePopupMenu() else {
|
||||||
|
log::error!("CreatePopupMenu(controls) failed");
|
||||||
|
let _ = DestroyMenu(menu);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
append_item(
|
||||||
|
controls,
|
||||||
|
IDM_SIZE_SMALLER,
|
||||||
|
&snap.strings.size_smaller,
|
||||||
|
if snap.bubble_size_logical <= bubble::MIN_BUBBLE_SIZE {
|
||||||
|
MF_GRAYED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
append_item(
|
||||||
|
controls,
|
||||||
|
IDM_SIZE_LARGER,
|
||||||
|
&snap.strings.size_larger,
|
||||||
|
if snap.bubble_size_logical >= bubble::MAX_BUBBLE_SIZE {
|
||||||
|
MF_GRAYED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
append_item(
|
||||||
|
controls,
|
||||||
|
IDM_RESET_SIZE,
|
||||||
|
&snap.strings.reset_size,
|
||||||
|
if snap.bubble_size_logical == bubble::DEFAULT_BUBBLE_SIZE {
|
||||||
|
MF_GRAYED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = AppendMenuW(controls, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
|
append_item(controls, 0, &snap.strings.control_left_click, MF_GRAYED);
|
||||||
|
append_item(controls, 0, &snap.strings.control_right_click, MF_GRAYED);
|
||||||
|
append_item(controls, 0, &snap.strings.control_drag, MF_GRAYED);
|
||||||
|
append_item(controls, 0, &snap.strings.control_ctrl_wheel, MF_GRAYED);
|
||||||
|
append_item(controls, 0, &snap.strings.control_tray_click, MF_GRAYED);
|
||||||
|
append_submenu(menu, controls, &snap.strings.controls);
|
||||||
|
|
||||||
append_item(
|
append_item(
|
||||||
menu,
|
menu,
|
||||||
tray::IDM_TOGGLE_WIDGET,
|
tray::IDM_TOGGLE_WIDGET,
|
||||||
&snap.strings.show_widget,
|
&snap.strings.show_widget,
|
||||||
if snap.widget_visible { MF_CHECKED } else { MENU_ITEM_FLAGS(0) },
|
if snap.widget_visible {
|
||||||
|
MF_CHECKED
|
||||||
|
} else {
|
||||||
|
MENU_ITEM_FLAGS(0)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
append_item(menu, IDM_RESTART, &snap.strings.restart, MENU_ITEM_FLAGS(0));
|
||||||
@@ -1120,7 +1197,12 @@ fn append_item(menu: HMENU, id: u16, label: &str, flags: MENU_ITEM_FLAGS) {
|
|||||||
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
fn append_submenu(menu: HMENU, submenu: HMENU, label: &str) {
|
||||||
let w = os::to_utf16_nul(label);
|
let w = os::to_utf16_nul(label);
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = AppendMenuW(menu, MF_POPUP, submenu.0 as usize, PCWSTR::from_raw(w.as_ptr()));
|
let _ = AppendMenuW(
|
||||||
|
menu,
|
||||||
|
MF_POPUP,
|
||||||
|
submenu.0 as usize,
|
||||||
|
PCWSTR::from_raw(w.as_ptr()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1243,7 @@ fn set_poll_interval(ms: u32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_model(model: TrayIconKind) {
|
fn toggle_model(model: ProviderId) {
|
||||||
let (settings, is_dark) = {
|
let (settings, is_dark) = {
|
||||||
let mut s = lock_state();
|
let mut s = lock_state();
|
||||||
let Some(s) = s.as_mut() else {
|
let Some(s) = s.as_mut() else {
|
||||||
@@ -1185,7 +1267,9 @@ fn toggle_model(model: TrayIconKind) {
|
|||||||
ProviderId::Claude => settings.show_claude_code,
|
ProviderId::Claude => settings.show_claude_code,
|
||||||
ProviderId::ChatGpt => settings.show_codex,
|
ProviderId::ChatGpt => settings.show_codex,
|
||||||
};
|
};
|
||||||
let existing = lock_state().as_ref().and_then(|s| s.bubbles.get(&model).copied());
|
let existing = lock_state()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.bubbles.get(&model).copied());
|
||||||
match (want, existing) {
|
match (want, existing) {
|
||||||
(true, None) => spawn_bubble(model, &settings, is_dark),
|
(true, None) => spawn_bubble(model, &settings, is_dark),
|
||||||
(false, Some(h)) => {
|
(false, Some(h)) => {
|
||||||
@@ -1248,34 +1332,50 @@ fn reset_positions() {
|
|||||||
spawn_poll_thread();
|
spawn_poll_thread();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_language(_dummy: Option<()>) {
|
fn resize_bubbles(delta: i32) {
|
||||||
let snap = {
|
let current = lock_state()
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.settings.bubble_size_logical)
|
||||||
|
.unwrap_or(bubble::DEFAULT_BUBBLE_SIZE);
|
||||||
|
set_bubble_size(current + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_bubble_size(size_logical: i32) {
|
||||||
|
let (hwnds, snap) = {
|
||||||
let mut s = lock_state();
|
let mut s = lock_state();
|
||||||
let Some(s) = s.as_mut() else {
|
let Some(s) = s.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
s.i18n.set_active(None);
|
let new_size = size_logical.clamp(bubble::MIN_BUBBLE_SIZE, bubble::MAX_BUBBLE_SIZE);
|
||||||
s.settings.language = None;
|
if new_size == s.settings.bubble_size_logical {
|
||||||
s.settings.clone()
|
return;
|
||||||
|
}
|
||||||
|
s.settings.bubble_size_logical = new_size;
|
||||||
|
let hwnds = s.bubbles.values().map(|h| h.to_hwnd()).collect::<Vec<_>>();
|
||||||
|
(hwnds, s.settings.clone())
|
||||||
};
|
};
|
||||||
settings::save(&snap);
|
settings::save(&snap);
|
||||||
|
for hwnd in hwnds {
|
||||||
|
bubble::set_size_logical(hwnd, snap.bubble_size_logical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_language(_dummy: Option<()>) {
|
||||||
|
update_settings(|s| {
|
||||||
|
s.i18n.set_active(None);
|
||||||
|
s.settings.language = None;
|
||||||
|
});
|
||||||
propagate_to_ui();
|
propagate_to_ui();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_language_by_index(idx: usize) {
|
fn set_language_by_index(idx: usize) {
|
||||||
let snap = {
|
update_settings(|s| {
|
||||||
let mut s = lock_state();
|
|
||||||
let Some(s) = s.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
let code = s.i18n.available().nth(idx).map(|(c, _)| c.to_string());
|
||||||
if let Some(c) = code.as_deref() {
|
if let Some(c) = code.as_deref() {
|
||||||
s.i18n.set_active(Some(c));
|
s.i18n.set_active(Some(c));
|
||||||
}
|
}
|
||||||
s.settings.language = code;
|
s.settings.language = code;
|
||||||
s.settings.clone()
|
});
|
||||||
};
|
|
||||||
settings::save(&snap);
|
|
||||||
propagate_to_ui();
|
propagate_to_ui();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+658
-438
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
code = "de"
|
|
||||||
native_name = "Deutsch"
|
|
||||||
|
|
||||||
window_title = "Claude Code Usage Bubble"
|
|
||||||
refresh = "Aktualisieren"
|
|
||||||
update_frequency = "Aktualisierungsintervall"
|
|
||||||
one_minute = "1 Minute"
|
|
||||||
five_minutes = "5 Minuten"
|
|
||||||
fifteen_minutes = "15 Minuten"
|
|
||||||
one_hour = "1 Stunde"
|
|
||||||
models = "Modelle"
|
|
||||||
claude_label = "Claude Code"
|
|
||||||
chatgpt_label = "Codex"
|
|
||||||
settings = "Einstellungen"
|
|
||||||
start_with_windows = "Mit Windows starten"
|
|
||||||
reset_position = "Position zurücksetzen"
|
|
||||||
language = "Sprache"
|
|
||||||
system_default = "Systemstandard"
|
|
||||||
check_for_updates = "Nach Updates suchen"
|
|
||||||
checking_for_updates = "Suche läuft…"
|
|
||||||
up_to_date = "Aktuell"
|
|
||||||
update_failed = "Update fehlgeschlagen"
|
|
||||||
applying_update = "Update wird angewendet…"
|
|
||||||
update_available = "Update verfügbar"
|
|
||||||
update_via_winget = "über WinGet"
|
|
||||||
auto_update_check = "Automatische Updateprüfung"
|
|
||||||
auto_check_disabled = "Deaktiviert"
|
|
||||||
auto_check_hourly = "Stündlich"
|
|
||||||
auto_check_daily = "Täglich"
|
|
||||||
auto_check_weekly = "Wöchentlich"
|
|
||||||
exit = "Beenden"
|
|
||||||
restart = "Neu starten"
|
|
||||||
show_widget = "Widget anzeigen"
|
|
||||||
session_window = "5h"
|
|
||||||
weekly_window = "7d"
|
|
||||||
now = "jetzt"
|
|
||||||
day_suffix = "T"
|
|
||||||
hour_suffix = "h"
|
|
||||||
minute_suffix = "m"
|
|
||||||
second_suffix = "s"
|
|
||||||
token_expired_title = "Claude Code-Sitzung abgelaufen"
|
|
||||||
token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
|
||||||
chatgpt_token_expired_title = "Codex-Sitzung abgelaufen"
|
|
||||||
chatgpt_token_expired_body = "Melde dich erneut an, um die Nutzung weiter zu verfolgen."
|
|
||||||
threshold_80_body = "5-Stunden-Limit naht."
|
|
||||||
threshold_95_body = "Limit fast erreicht — gönn dir eine Pause."
|
|
||||||
update_applied_title = "Update angewendet"
|
|
||||||
update_applied_body = "Aktualisiert auf v"
|
|
||||||
update_rollback_failed_body = "Update fehlgeschlagen. Deine ursprüngliche Binärdatei liegt unter: "
|
|
||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
start_with_windows = "Start with Windows"
|
start_with_windows = "Start with Windows"
|
||||||
reset_position = "Reset position"
|
reset_position = "Reset position"
|
||||||
|
size_smaller = "Make smaller"
|
||||||
|
size_larger = "Make larger"
|
||||||
|
reset_size = "Reset size"
|
||||||
|
controls = "Controls"
|
||||||
|
control_left_click = "Left-click: details"
|
||||||
|
control_right_click = "Right-click: menu"
|
||||||
|
control_drag = "Drag: move/snap"
|
||||||
|
control_ctrl_wheel = "Ctrl+Wheel: resize"
|
||||||
|
control_tray_click = "Tray click: show/hide"
|
||||||
|
tray_left_click = "Left-click: show/hide"
|
||||||
language = "Language"
|
language = "Language"
|
||||||
system_default = "System default"
|
system_default = "System default"
|
||||||
check_for_updates = "Check for updates"
|
check_for_updates = "Check for updates"
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
code = "es"
|
|
||||||
native_name = "Español"
|
|
||||||
|
|
||||||
window_title = "Claude Code Usage Bubble"
|
|
||||||
refresh = "Actualizar"
|
|
||||||
update_frequency = "Frecuencia de actualización"
|
|
||||||
one_minute = "1 minuto"
|
|
||||||
five_minutes = "5 minutos"
|
|
||||||
fifteen_minutes = "15 minutos"
|
|
||||||
one_hour = "1 hora"
|
|
||||||
models = "Modelos"
|
|
||||||
claude_label = "Claude Code"
|
|
||||||
chatgpt_label = "Codex"
|
|
||||||
settings = "Ajustes"
|
|
||||||
start_with_windows = "Iniciar con Windows"
|
|
||||||
reset_position = "Restablecer posición"
|
|
||||||
language = "Idioma"
|
|
||||||
system_default = "Predeterminado del sistema"
|
|
||||||
check_for_updates = "Buscar actualizaciones"
|
|
||||||
checking_for_updates = "Buscando actualizaciones…"
|
|
||||||
up_to_date = "Al día"
|
|
||||||
update_failed = "Actualización fallida"
|
|
||||||
applying_update = "Aplicando actualización…"
|
|
||||||
update_available = "Actualización disponible"
|
|
||||||
update_via_winget = "vía WinGet"
|
|
||||||
auto_update_check = "Búsqueda automática de actualizaciones"
|
|
||||||
auto_check_disabled = "Desactivada"
|
|
||||||
auto_check_hourly = "Cada hora"
|
|
||||||
auto_check_daily = "Cada día"
|
|
||||||
auto_check_weekly = "Cada semana"
|
|
||||||
exit = "Salir"
|
|
||||||
restart = "Reiniciar"
|
|
||||||
show_widget = "Mostrar widget"
|
|
||||||
session_window = "5h"
|
|
||||||
weekly_window = "7d"
|
|
||||||
now = "ahora"
|
|
||||||
day_suffix = "d"
|
|
||||||
hour_suffix = "h"
|
|
||||||
minute_suffix = "m"
|
|
||||||
second_suffix = "s"
|
|
||||||
token_expired_title = "Sesión de Claude Code caducada"
|
|
||||||
token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
|
||||||
chatgpt_token_expired_title = "Sesión de Codex caducada"
|
|
||||||
chatgpt_token_expired_body = "Vuelve a iniciar sesión para seguir registrando el uso."
|
|
||||||
threshold_80_body = "Cerca del límite de 5 horas."
|
|
||||||
threshold_95_body = "Límite casi alcanzado — reduce el ritmo."
|
|
||||||
update_applied_title = "Actualización aplicada"
|
|
||||||
update_applied_body = "Actualizado a v"
|
|
||||||
update_rollback_failed_body = "Actualización fallida. Tu binario original está guardado en: "
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
code = "fr"
|
|
||||||
native_name = "Français"
|
|
||||||
|
|
||||||
window_title = "Claude Code Usage Bubble"
|
|
||||||
refresh = "Actualiser"
|
|
||||||
update_frequency = "Fréquence de mise à jour"
|
|
||||||
one_minute = "1 minute"
|
|
||||||
five_minutes = "5 minutes"
|
|
||||||
fifteen_minutes = "15 minutes"
|
|
||||||
one_hour = "1 heure"
|
|
||||||
models = "Modèles"
|
|
||||||
claude_label = "Claude Code"
|
|
||||||
chatgpt_label = "Codex"
|
|
||||||
settings = "Paramètres"
|
|
||||||
start_with_windows = "Lancer avec Windows"
|
|
||||||
reset_position = "Réinitialiser la position"
|
|
||||||
language = "Langue"
|
|
||||||
system_default = "Paramètre système"
|
|
||||||
check_for_updates = "Rechercher des mises à jour"
|
|
||||||
checking_for_updates = "Recherche en cours…"
|
|
||||||
up_to_date = "À jour"
|
|
||||||
update_failed = "Mise à jour échouée"
|
|
||||||
applying_update = "Mise à jour en cours…"
|
|
||||||
update_available = "Mise à jour disponible"
|
|
||||||
update_via_winget = "via WinGet"
|
|
||||||
auto_update_check = "Vérification automatique des mises à jour"
|
|
||||||
auto_check_disabled = "Désactivée"
|
|
||||||
auto_check_hourly = "Toutes les heures"
|
|
||||||
auto_check_daily = "Quotidienne"
|
|
||||||
auto_check_weekly = "Hebdomadaire"
|
|
||||||
exit = "Quitter"
|
|
||||||
restart = "Redémarrer"
|
|
||||||
show_widget = "Afficher le widget"
|
|
||||||
session_window = "5h"
|
|
||||||
weekly_window = "7j"
|
|
||||||
now = "maintenant"
|
|
||||||
day_suffix = "j"
|
|
||||||
hour_suffix = "h"
|
|
||||||
minute_suffix = "m"
|
|
||||||
second_suffix = "s"
|
|
||||||
token_expired_title = "Session Claude Code expirée"
|
|
||||||
token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
|
||||||
chatgpt_token_expired_title = "Session Codex expirée"
|
|
||||||
chatgpt_token_expired_body = "Reconnectez-vous pour continuer à suivre votre utilisation."
|
|
||||||
threshold_80_body = "Approche de la limite de 5 heures."
|
|
||||||
threshold_95_body = "Limite proche — pensez à lever le pied."
|
|
||||||
update_applied_title = "Mise à jour appliquée"
|
|
||||||
update_applied_body = "Mis à jour vers v"
|
|
||||||
update_rollback_failed_body = "Échec de la mise à jour. Votre binaire d'origine est enregistré à : "
|
|
||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "設定"
|
settings = "設定"
|
||||||
start_with_windows = "Windows起動時に開始"
|
start_with_windows = "Windows起動時に開始"
|
||||||
reset_position = "位置をリセット"
|
reset_position = "位置をリセット"
|
||||||
|
size_smaller = "小さくする"
|
||||||
|
size_larger = "大きくする"
|
||||||
|
reset_size = "サイズをリセット"
|
||||||
|
controls = "操作"
|
||||||
|
control_left_click = "左クリック: 詳細"
|
||||||
|
control_right_click = "右クリック: メニュー"
|
||||||
|
control_drag = "ドラッグ: 移動/吸着"
|
||||||
|
control_ctrl_wheel = "Ctrl+ホイール: サイズ変更"
|
||||||
|
control_tray_click = "トレイ左クリック: 表示/非表示"
|
||||||
|
tray_left_click = "左クリック: 表示/非表示"
|
||||||
language = "言語"
|
language = "言語"
|
||||||
system_default = "システム既定"
|
system_default = "システム既定"
|
||||||
check_for_updates = "更新を確認"
|
check_for_updates = "更新を確認"
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "설정"
|
settings = "설정"
|
||||||
start_with_windows = "Windows 시작 시 실행"
|
start_with_windows = "Windows 시작 시 실행"
|
||||||
reset_position = "위치 초기화"
|
reset_position = "위치 초기화"
|
||||||
|
size_smaller = "작게"
|
||||||
|
size_larger = "크게"
|
||||||
|
reset_size = "크기 초기화"
|
||||||
|
controls = "조작"
|
||||||
|
control_left_click = "왼쪽 클릭: 상세"
|
||||||
|
control_right_click = "오른쪽 클릭: 메뉴"
|
||||||
|
control_drag = "드래그: 이동/스냅"
|
||||||
|
control_ctrl_wheel = "Ctrl+휠: 크기 조절"
|
||||||
|
control_tray_click = "트레이 클릭: 표시/숨김"
|
||||||
|
tray_left_click = "왼쪽 클릭: 표시/숨김"
|
||||||
language = "언어"
|
language = "언어"
|
||||||
system_default = "시스템 기본값"
|
system_default = "시스템 기본값"
|
||||||
check_for_updates = "업데이트 확인"
|
check_for_updates = "업데이트 확인"
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
code = "nl"
|
|
||||||
native_name = "Nederlands"
|
|
||||||
|
|
||||||
window_title = "Claude Code Usage Bubble"
|
|
||||||
refresh = "Vernieuwen"
|
|
||||||
update_frequency = "Bijwerkfrequentie"
|
|
||||||
one_minute = "1 minuut"
|
|
||||||
five_minutes = "5 minuten"
|
|
||||||
fifteen_minutes = "15 minuten"
|
|
||||||
one_hour = "1 uur"
|
|
||||||
models = "Modellen"
|
|
||||||
claude_label = "Claude Code"
|
|
||||||
chatgpt_label = "Codex"
|
|
||||||
settings = "Instellingen"
|
|
||||||
start_with_windows = "Starten met Windows"
|
|
||||||
reset_position = "Positie herstellen"
|
|
||||||
language = "Taal"
|
|
||||||
system_default = "Systeemstandaard"
|
|
||||||
check_for_updates = "Controleren op updates"
|
|
||||||
checking_for_updates = "Bezig met controleren…"
|
|
||||||
up_to_date = "Up-to-date"
|
|
||||||
update_failed = "Update mislukt"
|
|
||||||
applying_update = "Update toepassen…"
|
|
||||||
update_available = "Update beschikbaar"
|
|
||||||
update_via_winget = "via WinGet"
|
|
||||||
auto_update_check = "Automatische updatecontrole"
|
|
||||||
auto_check_disabled = "Uitgeschakeld"
|
|
||||||
auto_check_hourly = "Per uur"
|
|
||||||
auto_check_daily = "Dagelijks"
|
|
||||||
auto_check_weekly = "Wekelijks"
|
|
||||||
exit = "Afsluiten"
|
|
||||||
restart = "Opnieuw starten"
|
|
||||||
show_widget = "Widget tonen"
|
|
||||||
session_window = "5u"
|
|
||||||
weekly_window = "7d"
|
|
||||||
now = "nu"
|
|
||||||
day_suffix = "d"
|
|
||||||
hour_suffix = "u"
|
|
||||||
minute_suffix = "m"
|
|
||||||
second_suffix = "s"
|
|
||||||
token_expired_title = "Claude Code-sessie verlopen"
|
|
||||||
token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
|
||||||
chatgpt_token_expired_title = "Codex-sessie verlopen"
|
|
||||||
chatgpt_token_expired_body = "Meld je opnieuw aan om gebruik te blijven volgen."
|
|
||||||
threshold_80_body = "Je nadert de 5-uurslimiet."
|
|
||||||
threshold_95_body = "Limiet bijna bereikt — overweeg even gas terug te nemen."
|
|
||||||
update_applied_title = "Update toegepast"
|
|
||||||
update_applied_body = "Bijgewerkt naar v"
|
|
||||||
update_rollback_failed_body = "Update mislukt. Je oorspronkelijke bestand staat op: "
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
code = "vi"
|
||||||
|
native_name = "Tiếng Việt"
|
||||||
|
|
||||||
|
window_title = "Claude Code Usage Bubble"
|
||||||
|
refresh = "Làm mới"
|
||||||
|
update_frequency = "Tần suất cập nhật"
|
||||||
|
one_minute = "1 phút"
|
||||||
|
five_minutes = "5 phút"
|
||||||
|
fifteen_minutes = "15 phút"
|
||||||
|
one_hour = "1 giờ"
|
||||||
|
models = "Mô hình"
|
||||||
|
claude_label = "Claude Code"
|
||||||
|
chatgpt_label = "Codex"
|
||||||
|
settings = "Cài đặt"
|
||||||
|
start_with_windows = "Khởi động cùng Windows"
|
||||||
|
reset_position = "Đặt lại vị trí"
|
||||||
|
size_smaller = "Thu nhỏ"
|
||||||
|
size_larger = "Phóng to"
|
||||||
|
reset_size = "Đặt lại kích thước"
|
||||||
|
controls = "Điều khiển"
|
||||||
|
control_left_click = "Nhấp trái: chi tiết"
|
||||||
|
control_right_click = "Nhấp phải: menu"
|
||||||
|
control_drag = "Kéo: di chuyển/bám"
|
||||||
|
control_ctrl_wheel = "Ctrl+cuộn: đổi cỡ"
|
||||||
|
control_tray_click = "Khay: hiện/ẩn"
|
||||||
|
tray_left_click = "Nhấp trái: hiện/ẩn"
|
||||||
|
language = "Ngôn ngữ"
|
||||||
|
system_default = "Mặc định hệ thống"
|
||||||
|
check_for_updates = "Kiểm tra cập nhật"
|
||||||
|
checking_for_updates = "Đang kiểm tra cập nhật…"
|
||||||
|
up_to_date = "Đã là phiên bản mới nhất"
|
||||||
|
update_failed = "Cập nhật thất bại"
|
||||||
|
applying_update = "Đang áp dụng cập nhật…"
|
||||||
|
update_available = "Có bản cập nhật mới"
|
||||||
|
update_via_winget = "qua WinGet"
|
||||||
|
auto_update_check = "Tự động kiểm tra cập nhật"
|
||||||
|
auto_check_disabled = "Tắt"
|
||||||
|
auto_check_hourly = "Mỗi giờ"
|
||||||
|
auto_check_daily = "Hằng ngày"
|
||||||
|
auto_check_weekly = "Hằng tuần"
|
||||||
|
exit = "Thoát"
|
||||||
|
restart = "Khởi động lại"
|
||||||
|
show_widget = "Hiện widget"
|
||||||
|
session_window = "5g"
|
||||||
|
weekly_window = "7n"
|
||||||
|
now = "ngay"
|
||||||
|
day_suffix = "n"
|
||||||
|
hour_suffix = "g"
|
||||||
|
minute_suffix = "p"
|
||||||
|
second_suffix = "s"
|
||||||
|
token_expired_title = "Phiên Claude Code đã hết hạn"
|
||||||
|
token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
|
||||||
|
chatgpt_token_expired_title = "Phiên Codex đã hết hạn"
|
||||||
|
chatgpt_token_expired_body = "Hãy đăng nhập lại để tiếp tục theo dõi mức sử dụng."
|
||||||
|
threshold_80_body = "Sắp chạm giới hạn 5 giờ."
|
||||||
|
threshold_95_body = "Sắp tới giới hạn — hãy cân nhắc giảm tốc."
|
||||||
|
update_applied_title = "Đã áp dụng cập nhật"
|
||||||
|
update_applied_body = "Đã cập nhật lên v"
|
||||||
|
update_rollback_failed_body = "Cập nhật thất bại. Tệp gốc của bạn được lưu tại: "
|
||||||
@@ -14,6 +14,16 @@ chatgpt_label = "Codex"
|
|||||||
settings = "設定"
|
settings = "設定"
|
||||||
start_with_windows = "隨 Windows 啟動"
|
start_with_windows = "隨 Windows 啟動"
|
||||||
reset_position = "重設位置"
|
reset_position = "重設位置"
|
||||||
|
size_smaller = "縮小"
|
||||||
|
size_larger = "放大"
|
||||||
|
reset_size = "重設大小"
|
||||||
|
controls = "操作"
|
||||||
|
control_left_click = "左鍵: 詳細"
|
||||||
|
control_right_click = "右鍵: 選單"
|
||||||
|
control_drag = "拖曳: 移動/吸附"
|
||||||
|
control_ctrl_wheel = "Ctrl+滾輪: 調整大小"
|
||||||
|
control_tray_click = "系統匣: 顯示/隱藏"
|
||||||
|
tray_left_click = "左鍵: 顯示/隱藏"
|
||||||
language = "語言"
|
language = "語言"
|
||||||
system_default = "系統預設"
|
system_default = "系統預設"
|
||||||
check_for_updates = "檢查更新"
|
check_for_updates = "檢查更新"
|
||||||
|
|||||||
+82
-9
@@ -34,6 +34,16 @@ pub struct LocaleStrings {
|
|||||||
pub settings: String,
|
pub settings: String,
|
||||||
pub start_with_windows: String,
|
pub start_with_windows: String,
|
||||||
pub reset_position: String,
|
pub reset_position: String,
|
||||||
|
pub size_smaller: String,
|
||||||
|
pub size_larger: String,
|
||||||
|
pub reset_size: String,
|
||||||
|
pub controls: String,
|
||||||
|
pub control_left_click: String,
|
||||||
|
pub control_right_click: String,
|
||||||
|
pub control_drag: String,
|
||||||
|
pub control_ctrl_wheel: String,
|
||||||
|
pub control_tray_click: String,
|
||||||
|
pub tray_left_click: String,
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub system_default: String,
|
pub system_default: String,
|
||||||
pub check_for_updates: String,
|
pub check_for_updates: String,
|
||||||
@@ -87,12 +97,9 @@ struct LocaleFile {
|
|||||||
|
|
||||||
const RAW_LOCALES: &[(&str, &str)] = &[
|
const RAW_LOCALES: &[(&str, &str)] = &[
|
||||||
("en", include_str!("locales/en.toml")),
|
("en", include_str!("locales/en.toml")),
|
||||||
("nl", include_str!("locales/nl.toml")),
|
|
||||||
("es", include_str!("locales/es.toml")),
|
|
||||||
("fr", include_str!("locales/fr.toml")),
|
|
||||||
("de", include_str!("locales/de.toml")),
|
|
||||||
("ja", include_str!("locales/ja.toml")),
|
("ja", include_str!("locales/ja.toml")),
|
||||||
("ko", include_str!("locales/ko.toml")),
|
("ko", include_str!("locales/ko.toml")),
|
||||||
|
("vi", include_str!("locales/vi.toml")),
|
||||||
("zh-TW", include_str!("locales/zh-TW.toml")),
|
("zh-TW", include_str!("locales/zh-TW.toml")),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -161,9 +168,7 @@ impl I18n {
|
|||||||
pub fn set_active(&mut self, requested: Option<&str>) {
|
pub fn set_active(&mut self, requested: Option<&str>) {
|
||||||
let new_active = requested
|
let new_active = requested
|
||||||
.and_then(|c| normalise(c, &self.available))
|
.and_then(|c| normalise(c, &self.available))
|
||||||
.or_else(|| {
|
.or_else(|| detect::detect_system_locale().and_then(|c| normalise(&c, &self.available)))
|
||||||
detect::detect_system_locale().and_then(|c| normalise(&c, &self.available))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
.unwrap_or_else(|| FALLBACK_CODE.to_string());
|
||||||
self.active = new_active;
|
self.active = new_active;
|
||||||
}
|
}
|
||||||
@@ -186,7 +191,9 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
|
|||||||
}
|
}
|
||||||
// Special-case: Traditional Chinese variants → zh-TW
|
// Special-case: Traditional Chinese variants → zh-TW
|
||||||
let lower = cleaned.to_ascii_lowercase();
|
let lower = cleaned.to_ascii_lowercase();
|
||||||
if lower.starts_with("zh") && (lower.contains("tw") || lower.contains("hk") || lower.contains("hant")) {
|
if lower.starts_with("zh")
|
||||||
|
&& (lower.contains("tw") || lower.contains("hk") || lower.contains("hant"))
|
||||||
|
{
|
||||||
if available.contains_key("zh-TW") {
|
if available.contains_key("zh-TW") {
|
||||||
return Some("zh-TW".to_string());
|
return Some("zh-TW".to_string());
|
||||||
}
|
}
|
||||||
@@ -195,7 +202,13 @@ fn normalise(input: &str, available: &BTreeMap<String, (String, LocaleStrings)>)
|
|||||||
let prefix = lower.split('-').next().unwrap_or("");
|
let prefix = lower.split('-').next().unwrap_or("");
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
for key in available.keys() {
|
for key in available.keys() {
|
||||||
if key.split('-').next().map(str::to_ascii_lowercase).as_deref() == Some(prefix) {
|
if key
|
||||||
|
.split('-')
|
||||||
|
.next()
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
== Some(prefix)
|
||||||
|
{
|
||||||
return Some(key.clone());
|
return Some(key.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,3 +275,63 @@ pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Durati
|
|||||||
};
|
};
|
||||||
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
Some(Duration::from_secs(secs.saturating_sub(bucket_start) + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_locales_parse_and_include_required_control_strings() {
|
||||||
|
let mut has_fallback = false;
|
||||||
|
for (expected_code, body) in RAW_LOCALES {
|
||||||
|
let file = toml::from_str::<LocaleFile>(body)
|
||||||
|
.unwrap_or_else(|e| panic!("locale {expected_code} failed to parse: {e}"));
|
||||||
|
assert_eq!(file.code, *expected_code);
|
||||||
|
has_fallback |= file.code == FALLBACK_CODE;
|
||||||
|
|
||||||
|
let strings = file.strings;
|
||||||
|
for (name, value) in [
|
||||||
|
("size_smaller", strings.size_smaller.as_str()),
|
||||||
|
("size_larger", strings.size_larger.as_str()),
|
||||||
|
("reset_size", strings.reset_size.as_str()),
|
||||||
|
("controls", strings.controls.as_str()),
|
||||||
|
("control_left_click", strings.control_left_click.as_str()),
|
||||||
|
("control_right_click", strings.control_right_click.as_str()),
|
||||||
|
("control_drag", strings.control_drag.as_str()),
|
||||||
|
("control_ctrl_wheel", strings.control_ctrl_wheel.as_str()),
|
||||||
|
("control_tray_click", strings.control_tray_click.as_str()),
|
||||||
|
("tray_left_click", strings.tray_left_click.as_str()),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!value.trim().is_empty(),
|
||||||
|
"locale {expected_code} has empty {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(has_fallback, "fallback locale {FALLBACK_CODE} missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locale_schema_rejects_missing_or_malformed_control_strings() {
|
||||||
|
let (_, fallback_body) = RAW_LOCALES
|
||||||
|
.iter()
|
||||||
|
.find(|(code, _)| *code == FALLBACK_CODE)
|
||||||
|
.expect("fallback locale fixture must exist");
|
||||||
|
|
||||||
|
let missing_control =
|
||||||
|
fallback_body.replace("tray_left_click = \"Left-click: show/hide\"\n", "");
|
||||||
|
assert!(
|
||||||
|
toml::from_str::<LocaleFile>(&missing_control).is_err(),
|
||||||
|
"missing tray_left_click should fail locale deserialization"
|
||||||
|
);
|
||||||
|
|
||||||
|
let malformed_control = fallback_body.replace(
|
||||||
|
"control_tray_click = \"Tray click: show/hide\"",
|
||||||
|
"control_tray_click = [\"Tray click: show/hide\"]",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
toml::from_str::<LocaleFile>(&malformed_control).is_err(),
|
||||||
|
"malformed control_tray_click should fail locale deserialization"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod os;
|
|||||||
mod tray;
|
mod tray;
|
||||||
mod update;
|
mod update;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
mod usage_color;
|
||||||
|
|
||||||
// Application surface.
|
// Application surface.
|
||||||
mod app;
|
mod app;
|
||||||
|
|||||||
+33
-23
@@ -7,15 +7,18 @@ use std::sync::{Mutex, MutexGuard, OnceLock};
|
|||||||
|
|
||||||
use windows::core::PCWSTR;
|
use windows::core::PCWSTR;
|
||||||
use windows::Win32::Foundation::*;
|
use windows::Win32::Foundation::*;
|
||||||
|
use windows::Win32::Graphics::Dwm::{
|
||||||
|
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||||
|
};
|
||||||
use windows::Win32::Graphics::Gdi::*;
|
use windows::Win32::Graphics::Gdi::*;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
use windows::Win32::UI::HiDpi::GetDpiForWindow;
|
||||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||||
|
|
||||||
use crate::i18n::LocaleStrings;
|
use crate::i18n::LocaleStrings;
|
||||||
|
use crate::os::dpi::scale as scale_to_dpi;
|
||||||
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
use crate::os::{to_utf16_nul as wide_str, Rgb as Color};
|
||||||
use crate::usage::ProviderId;
|
use crate::usage::ProviderId;
|
||||||
type TrayIconKind = ProviderId;
|
|
||||||
|
|
||||||
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
const CLASS_NAME: &str = "ClaudeCodeUsageBubblePanel";
|
||||||
const PANEL_W_LOGICAL: i32 = 280;
|
const PANEL_W_LOGICAL: i32 = 280;
|
||||||
@@ -27,7 +30,7 @@ const RIGHT_TEXT_W_LOGICAL: i32 = 96;
|
|||||||
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
const BAR_HEIGHT_LOGICAL: i32 = 14;
|
||||||
|
|
||||||
pub struct PanelData {
|
pub struct PanelData {
|
||||||
pub model: TrayIconKind,
|
pub model: ProviderId,
|
||||||
pub session_pct: f64,
|
pub session_pct: f64,
|
||||||
pub session_text: String,
|
pub session_text: String,
|
||||||
pub weekly_pct: f64,
|
pub weekly_pct: f64,
|
||||||
@@ -83,7 +86,7 @@ pub fn is_visible() -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_model() -> Option<TrayIconKind> {
|
pub fn current_model() -> Option<ProviderId> {
|
||||||
lock_state().as_ref().map(|p| p.data.model)
|
lock_state().as_ref().map(|p| p.data.model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +121,8 @@ pub fn show(data: PanelData, anchor_hwnd: HWND) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
apply_win11_window_chrome(hwnd);
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = lock_state();
|
let mut guard = lock_state();
|
||||||
if let Some(p) = guard.as_mut() {
|
if let Some(p) = guard.as_mut() {
|
||||||
@@ -152,7 +157,7 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
WS_EX_TOOLWINDOW | WS_EX_TOPMOST,
|
||||||
PCWSTR::from_raw(class_w.as_ptr()),
|
PCWSTR::from_raw(class_w.as_ptr()),
|
||||||
PCWSTR::from_raw(title_w.as_ptr()),
|
PCWSTR::from_raw(title_w.as_ptr()),
|
||||||
WS_POPUP | WS_BORDER,
|
WS_POPUP,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
w,
|
w,
|
||||||
@@ -172,6 +177,23 @@ fn create_panel_window(x: i32, y: i32, w: i32, h: i32) -> Option<HWND> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply Windows 11 rounded corners. Win11-only — `DwmSetWindowAttribute`
|
||||||
|
/// returns an error on Win10 and earlier, which we deliberately swallow so
|
||||||
|
/// the panel falls back to square corners without complaint. Idempotent:
|
||||||
|
/// DWM ignores redundant identical-value sets, so calling this on every
|
||||||
|
/// `show()` is safe.
|
||||||
|
fn apply_win11_window_chrome(hwnd: HWND) {
|
||||||
|
unsafe {
|
||||||
|
let pref = DWMWCP_ROUND;
|
||||||
|
let _ = DwmSetWindowAttribute(
|
||||||
|
hwnd,
|
||||||
|
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||||
|
&pref as *const _ as *const _,
|
||||||
|
std::mem::size_of_val(&pref) as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn hide() {
|
pub fn hide() {
|
||||||
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
let hwnd_opt = lock_state().as_ref().map(|p| p.hwnd);
|
||||||
if let Some(hwnd) = hwnd_opt {
|
if let Some(hwnd) = hwnd_opt {
|
||||||
@@ -269,21 +291,17 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
} else {
|
} else {
|
||||||
Color::from_hex("#D6D6D6")
|
Color::from_hex("#D6D6D6")
|
||||||
};
|
};
|
||||||
let accent = bar_color_for(data.model, data.session_pct.max(data.weekly_pct), data.is_dark);
|
let session_accent =
|
||||||
|
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.session_pct);
|
||||||
|
let weekly_accent =
|
||||||
|
crate::usage_color::bar_fill_color(data.model, data.is_dark, data.weekly_pct);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
let bg_brush = CreateSolidBrush(COLORREF(bg.into_colorref()));
|
||||||
FillRect(hdc, &rc, bg_brush);
|
FillRect(hdc, &rc, bg_brush);
|
||||||
let _ = DeleteObject(bg_brush);
|
let _ = DeleteObject(bg_brush);
|
||||||
|
|
||||||
// 4-px accent stripe matching the bubble — same provider color so the
|
let stripe_color = crate::usage_color::accent_color_for(data.model, data.is_dark);
|
||||||
// identity carries across both surfaces. Codex is theme-aware so a
|
|
||||||
// pure white stripe doesn't vanish into the light-mode background.
|
|
||||||
let stripe_color = match (data.model, data.is_dark) {
|
|
||||||
(ProviderId::Claude, _) => Color::from_hex("#D97757"),
|
|
||||||
(ProviderId::ChatGpt, true) => Color::from_hex("#FFFFFF"),
|
|
||||||
(ProviderId::ChatGpt, false) => Color::from_hex("#2A2A2A"),
|
|
||||||
};
|
|
||||||
let stripe_w = scaled(4);
|
let stripe_w = scaled(4);
|
||||||
let stripe_rect = RECT {
|
let stripe_rect = RECT {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -333,7 +351,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.session_text,
|
&data.session_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
session_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -349,7 +367,7 @@ fn paint(hwnd: HWND, hdc: HDC) {
|
|||||||
&data.weekly_text,
|
&data.weekly_text,
|
||||||
text_color,
|
text_color,
|
||||||
track,
|
track,
|
||||||
accent,
|
weekly_accent,
|
||||||
dpi,
|
dpi,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -482,10 +500,6 @@ fn draw_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bar_color_for(model: ProviderId, percent: f64, is_dark: bool) -> Color {
|
|
||||||
crate::bubble::bar_fill_color(model, is_dark, percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_data() -> Option<PanelData> {
|
fn clone_data() -> Option<PanelData> {
|
||||||
let guard = lock_state();
|
let guard = lock_state();
|
||||||
let p = guard.as_ref()?;
|
let p = guard.as_ref()?;
|
||||||
@@ -528,7 +542,3 @@ fn place_near(anchor: RECT, panel_w: i32, panel_h: i32) -> (i32, i32) {
|
|||||||
}
|
}
|
||||||
(x, y)
|
(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scale_to_dpi(logical: i32, dpi: u32) -> i32 {
|
|
||||||
((logical as i64) * (dpi as i64) / 96) as i32
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-4
@@ -6,7 +6,6 @@ use windows::Win32::Graphics::Gdi::{MonitorFromRect, MONITOR_DEFAULTTONULL};
|
|||||||
|
|
||||||
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
use crate::bubble::DEFAULT_BUBBLE_SIZE;
|
||||||
use crate::usage::ProviderId;
|
use crate::usage::ProviderId;
|
||||||
type TrayIconKind = ProviderId;
|
|
||||||
|
|
||||||
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
// 140px matches MIN_BUBBLE_SIZE — a saved top-left a few px past the work-area
|
||||||
// edge still passes the validator, but a position fully on a disconnected
|
// edge still passes the validator, but a position fully on a disconnected
|
||||||
@@ -52,19 +51,19 @@ pub struct BubblePositions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BubblePositions {
|
impl BubblePositions {
|
||||||
pub fn get(&self, model: TrayIconKind) -> Option<(i32, i32)> {
|
pub fn get(&self, model: ProviderId) -> Option<(i32, i32)> {
|
||||||
match model {
|
match model {
|
||||||
ProviderId::Claude => self.claude,
|
ProviderId::Claude => self.claude,
|
||||||
ProviderId::ChatGpt => self.codex,
|
ProviderId::ChatGpt => self.codex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn set(&mut self, model: TrayIconKind, pos: (i32, i32)) {
|
pub fn set(&mut self, model: ProviderId, pos: (i32, i32)) {
|
||||||
match model {
|
match model {
|
||||||
ProviderId::Claude => self.claude = Some(pos),
|
ProviderId::Claude => self.claude = Some(pos),
|
||||||
ProviderId::ChatGpt => self.codex = Some(pos),
|
ProviderId::ChatGpt => self.codex = Some(pos),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn reset(&mut self, model: TrayIconKind) {
|
pub fn reset(&mut self, model: ProviderId) {
|
||||||
match model {
|
match model {
|
||||||
ProviderId::Claude => self.claude = None,
|
ProviderId::Claude => self.claude = None,
|
||||||
ProviderId::ChatGpt => self.codex = None,
|
ProviderId::ChatGpt => self.codex = None,
|
||||||
|
|||||||
+8
-36
@@ -9,8 +9,7 @@
|
|||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
|
|
||||||
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||||
use windows::core::PCWSTR;
|
use windows::Win32::Foundation::{BOOL, HWND};
|
||||||
use windows::Win32::Foundation::HWND;
|
|
||||||
use windows::Win32::Graphics::Gdi::{
|
use windows::Win32::Graphics::Gdi::{
|
||||||
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
CreateBitmap, CreateDIBSection, DeleteObject, GetDC, ReleaseDC, BITMAPINFO, BITMAPINFOHEADER,
|
||||||
DIB_RGB_COLORS, HBITMAP,
|
DIB_RGB_COLORS, HBITMAP,
|
||||||
@@ -66,7 +65,7 @@ fn render_pixmap(kind: ProviderId, percent: Option<f64>) -> Pixmap {
|
|||||||
if let Some(p) = percent {
|
if let Some(p) = percent {
|
||||||
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
let sweep = (p.clamp(0.0, 100.0) / 100.0) as f32;
|
||||||
if sweep > 0.0 {
|
if sweep > 0.0 {
|
||||||
let fill = usage_color(p);
|
let fill = usage_color(kind, p);
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
paint.set_color_rgba8(fill[0], fill[1], fill[2], 255);
|
||||||
paint.anti_alias = true;
|
paint.anti_alias = true;
|
||||||
@@ -116,33 +115,12 @@ fn base_color(kind: ProviderId) -> [u8; 3] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage_color(percent: f64) -> [u8; 3] {
|
/// Sweep-ring fill color for the tray badge. The badge inner disk is always
|
||||||
// Color gradient: soft orange (low usage) → red (near-cap).
|
/// dark regardless of system theme, so we pass `is_dark = true` to keep the
|
||||||
let stops: [(f64, [u8; 3]); 5] = [
|
/// ring readable (Codex sweep stays white instead of charcoal).
|
||||||
(0.0, [0xD9, 0x77, 0x57]),
|
fn usage_color(kind: ProviderId, percent: f64) -> [u8; 3] {
|
||||||
(50.0, [0xD9, 0x77, 0x57]),
|
let c = crate::usage_color::bar_fill_color(kind, true, percent);
|
||||||
(75.0, [0xCC, 0x8C, 0x20]),
|
[c.r, c.g, c.b]
|
||||||
(90.0, [0xC4, 0x50, 0x20]),
|
|
||||||
(100.0, [0xB8, 0x20, 0x20]),
|
|
||||||
];
|
|
||||||
for pair in stops.windows(2) {
|
|
||||||
let (a_p, a_c) = pair[0];
|
|
||||||
let (b_p, b_c) = pair[1];
|
|
||||||
if percent <= b_p {
|
|
||||||
let span = (b_p - a_p).max(f64::EPSILON);
|
|
||||||
let t = ((percent - a_p) / span).clamp(0.0, 1.0);
|
|
||||||
return [
|
|
||||||
lerp(a_c[0], b_c[0], t),
|
|
||||||
lerp(a_c[1], b_c[1], t),
|
|
||||||
lerp(a_c[2], b_c[2], t),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stops[stops.len() - 1].1
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lerp(a: u8, b: u8, t: f64) -> u8 {
|
|
||||||
(a as f64 + (b as f64 - a as f64) * t).round() as u8
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Pixmap → HICON ----------
|
// ---------- Pixmap → HICON ----------
|
||||||
@@ -220,9 +198,3 @@ fn pixmap_to_hicon(pixmap: &Pixmap) -> Option<HICON> {
|
|||||||
hicon
|
hicon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Silence import warnings if we end up not needing PCWSTR after later edits.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
const _: PCWSTR = PCWSTR::null();
|
|
||||||
|
|
||||||
use windows::Win32::Foundation::BOOL;
|
|
||||||
|
|||||||
+46
-20
@@ -133,7 +133,7 @@ fn try_messages_endpoint(http: &Client, token: &str) -> Result<UsageWindows, Err
|
|||||||
|
|
||||||
fn bucket_to_window(bucket: Bucket) -> Window {
|
fn bucket_to_window(bucket: Bucket) -> Window {
|
||||||
Window {
|
Window {
|
||||||
utilization: bucket.utilization,
|
utilization: bucket.utilization.clamp(0.0, 100.0),
|
||||||
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
resets_at: bucket.resets_at.as_deref().and_then(parse_iso8601),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,18 +163,21 @@ fn token_is_expired(expires_at_unix_ms: Option<i64>) -> bool {
|
|||||||
now_ms >= exp_ms
|
now_ms >= exp_ms
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|+00:00]") ---
|
// --- ISO 8601 parsing (minimal — handles "YYYY-MM-DDTHH:MM:SS[.frac][Z|±HH:MM]") ---
|
||||||
|
|
||||||
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
||||||
let trimmed = s.split('Z').next().unwrap_or(s);
|
let (date, time_with_offset) = s.split_once('T')?;
|
||||||
let trimmed = trimmed.split('+').next().unwrap_or(trimmed);
|
|
||||||
let trimmed = trimmed.split('-').take(3).collect::<Vec<_>>().join("-");
|
// Split the time-and-offset on the first 'Z' / '+' / '-' marker.
|
||||||
// We want the original `s` for parsing time-part. Re-split on 'T'.
|
let offset_pos = time_with_offset
|
||||||
let (date, time) = s
|
.char_indices()
|
||||||
.split_once('T')
|
.find(|(_, c)| matches!(c, 'Z' | '+' | '-'))
|
||||||
.map(|(d, t)| (d, t))
|
.map(|(i, _)| i);
|
||||||
.or_else(|| Some(("", "")))?;
|
let (time, offset_str) = match offset_pos {
|
||||||
let _ = trimmed; // shadow; using the raw `date` + `time` below.
|
Some(p) => (&time_with_offset[..p], &time_with_offset[p..]),
|
||||||
|
None => (time_with_offset, ""),
|
||||||
|
};
|
||||||
|
let time = time.split_once('.').map_or(time, |(t, _)| t);
|
||||||
|
|
||||||
let date_parts: Vec<&str> = date.split('-').collect();
|
let date_parts: Vec<&str> = date.split('-').collect();
|
||||||
if date_parts.len() != 3 {
|
if date_parts.len() != 3 {
|
||||||
@@ -183,13 +186,16 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|||||||
let y: u64 = date_parts[0].parse().ok()?;
|
let y: u64 = date_parts[0].parse().ok()?;
|
||||||
let mo: u64 = date_parts[1].parse().ok()?;
|
let mo: u64 = date_parts[1].parse().ok()?;
|
||||||
let d: u64 = date_parts[2].parse().ok()?;
|
let d: u64 = date_parts[2].parse().ok()?;
|
||||||
|
if y < 1970 || mo == 0 || mo > 12 || d == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let max_day = DAYS_IN_MONTH[mo as usize] + if mo == 2 && is_leap(y) { 1 } else { 0 };
|
||||||
|
if d > max_day {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let time_no_offset = time
|
let time_parts: Vec<&str> = time.split(':').collect();
|
||||||
.split(|c| c == 'Z' || c == '+' || (c == '-' && time.find(c) != Some(0)))
|
|
||||||
.next()
|
|
||||||
.unwrap_or(time);
|
|
||||||
let time_no_frac = time_no_offset.split('.').next().unwrap_or(time_no_offset);
|
|
||||||
let time_parts: Vec<&str> = time_no_frac.split(':').collect();
|
|
||||||
if time_parts.len() != 3 {
|
if time_parts.len() != 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -197,11 +203,26 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|||||||
let mi: u64 = time_parts[1].parse().ok()?;
|
let mi: u64 = time_parts[1].parse().ok()?;
|
||||||
let se: u64 = time_parts[2].parse().ok()?;
|
let se: u64 = time_parts[2].parse().ok()?;
|
||||||
|
|
||||||
|
let offset_minutes: i64 = if offset_str.is_empty() || offset_str == "Z" {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let sign: i64 = if offset_str.starts_with('+') {
|
||||||
|
1
|
||||||
|
} else if offset_str.starts_with('-') {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let (oh_str, om_str) = offset_str[1..].split_once(':')?;
|
||||||
|
let oh: i64 = oh_str.parse().ok()?;
|
||||||
|
let om: i64 = om_str.parse().ok()?;
|
||||||
|
sign * (oh * 60 + om)
|
||||||
|
};
|
||||||
|
|
||||||
let mut days: u64 = 0;
|
let mut days: u64 = 0;
|
||||||
for year in 1970..y {
|
for year in 1970..y {
|
||||||
days += if is_leap(year) { 366 } else { 365 };
|
days += if is_leap(year) { 366 } else { 365 };
|
||||||
}
|
}
|
||||||
const DAYS_IN_MONTH: [u64; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
||||||
for month in 1..mo {
|
for month in 1..mo {
|
||||||
days += DAYS_IN_MONTH[month as usize];
|
days += DAYS_IN_MONTH[month as usize];
|
||||||
if month == 2 && is_leap(y) {
|
if month == 2 && is_leap(y) {
|
||||||
@@ -209,8 +230,13 @@ fn parse_iso8601(s: &str) -> Option<SystemTime> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
days += d - 1;
|
days += d - 1;
|
||||||
let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
|
||||||
Some(UNIX_EPOCH + Duration::from_secs(secs))
|
let local_secs = days * 86_400 + h * 3_600 + mi * 60 + se;
|
||||||
|
let utc_secs = (local_secs as i64) - offset_minutes * 60;
|
||||||
|
if utc_secs < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(UNIX_EPOCH + Duration::from_secs(utc_secs as u64))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_leap(year: u64) -> bool {
|
fn is_leap(year: u64) -> bool {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ fn envelope_to_windows(envelope: Envelope) -> Option<UsageWindows> {
|
|||||||
|
|
||||||
fn window_from(w: ApiWindow) -> Window {
|
fn window_from(w: ApiWindow) -> Window {
|
||||||
Window {
|
Window {
|
||||||
utilization: w.used_percent,
|
utilization: w.used_percent.clamp(0.0, 100.0),
|
||||||
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
resets_at: unix_to_systemtime(Some(w.reset_at)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,18 @@ use crate::usage::{UsageWindows, Window};
|
|||||||
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
pub fn parse_anthropic(response: &Response) -> UsageWindows {
|
||||||
UsageWindows {
|
UsageWindows {
|
||||||
primary: Window {
|
primary: Window {
|
||||||
utilization: header_f64(response, "anthropic-ratelimit-unified-5h-utilization") * 100.0,
|
utilization: (header_f64(response, "anthropic-ratelimit-unified-5h-utilization")
|
||||||
|
* 100.0)
|
||||||
|
.clamp(0.0, 100.0),
|
||||||
resets_at: unix_to_systemtime(header_i64(
|
resets_at: unix_to_systemtime(header_i64(
|
||||||
response,
|
response,
|
||||||
"anthropic-ratelimit-unified-5h-reset",
|
"anthropic-ratelimit-unified-5h-reset",
|
||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
secondary: Window {
|
secondary: Window {
|
||||||
utilization: header_f64(response, "anthropic-ratelimit-unified-7d-utilization") * 100.0,
|
utilization: (header_f64(response, "anthropic-ratelimit-unified-7d-utilization")
|
||||||
|
* 100.0)
|
||||||
|
.clamp(0.0, 100.0),
|
||||||
resets_at: unix_to_systemtime(header_i64(
|
resets_at: unix_to_systemtime(header_i64(
|
||||||
response,
|
response,
|
||||||
"anthropic-ratelimit-unified-7d-reset",
|
"anthropic-ratelimit-unified-7d-reset",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Shared usage→color ramp used by the floating bubble, the expanded panel,
|
||||||
|
// and the tray badge. Keeping the function in one place ensures the three
|
||||||
|
// surfaces never disagree about what "78% used" looks like.
|
||||||
|
|
||||||
|
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 {
|
||||||
|
match model {
|
||||||
|
ProviderId::Claude => Color::from_hex("#D97757"),
|
||||||
|
ProviderId::ChatGpt => Color::from_hex("#10A37F"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// - <60% → provider accent
|
||||||
|
/// - 60–80% → amber (dark `#E0A040`, light `#B47A20` for WCAG AA contrast)
|
||||||
|
/// - 80–95% → red `#C45020`
|
||||||
|
/// - ≥95% → deep red `#A01818` — paired with pulse animation
|
||||||
|
pub fn bar_fill_color(model: ProviderId, is_dark: bool, percent: f64) -> Color {
|
||||||
|
if percent < 60.0 {
|
||||||
|
accent_color_for(model, is_dark)
|
||||||
|
} else if percent < 80.0 {
|
||||||
|
if is_dark {
|
||||||
|
Color::from_hex("#E0A040")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#B47A20")
|
||||||
|
}
|
||||||
|
} else if percent < 95.0 {
|
||||||
|
Color::from_hex("#C45020")
|
||||||
|
} else {
|
||||||
|
Color::from_hex("#A01818")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user