From e2d5298466a386b992fcff281fa5f383ed6fc8ee Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:34:23 +0800 Subject: [PATCH 01/13] docs: ETD design spec and implementation plan --- docs/superpowers/plans/2026-06-19-etd.md | 596 ++++++++++++++++++ .../specs/2026-06-19-etd-design.md | 122 ++++ 2 files changed, 718 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-etd.md create mode 100644 docs/superpowers/specs/2026-06-19-etd-design.md diff --git a/docs/superpowers/plans/2026-06-19-etd.md b/docs/superpowers/plans/2026-06-19-etd.md new file mode 100644 index 0000000..a1ec664 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-etd.md @@ -0,0 +1,596 @@ +# Estimated Time to Depletion (ETD) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Append an "estimated time to depletion" suffix (e.g. `· 45m ETD`) to each usage cell when the user is on pace to deplete a quota before its window resets. + +**Architecture:** A pure `etd_secs()` math function plus an `etd_suffix()` formatter in `src/poller.rs`, fed by the data already available at the cell-formatting site. A `show_etd` setting is plumbed through `src/window.rs` exactly like the existing `show_codex` bool, exposed as a Settings-submenu checkbox (default OFF), and consumed in `refresh_usage_texts`. Two new localized strings. + +**Tech Stack:** Rust 2021, `serde`/`serde_json` for settings, the `windows` crate for the Win32 menu, a flat `Strings` struct of `&'static str` for localization. + +## Global Constraints + +- Branch off clean `main` (v1.4.3); do **not** depend on the detailed-remaining or pace-indicator branches. +- Menu ID for ETD is exactly `74` (avoids the 70–73 used by the other branches). +- ETD window constants live in `src/poller.rs` (NOT `src/window.rs`). +- ETD duration uses the existing `format_countdown_from_secs` (coarse, single-unit). Do not add a multi-unit formatter. +- `show_etd` defaults to `false` (OFF). +- Do not reference any upstream issue in commit messages or PR text. +- Middle-dot separator is the Unicode escape `\u{00b7}` (matches `format_line`). +- Each task ends green: `cargo build` succeeds, and where tests exist, `cargo test` passes. + +--- + +### Task 1: ETD core math + first test module + +**Files:** +- Modify: `src/poller.rs` (append at end of file, after `app_is_past_reset`, currently ending line 1099) +- Test: `src/poller.rs` (inline `#[cfg(test)] mod tests`) + +**Interfaces:** +- Produces: `pub const SESSION_WINDOW_SECS: u64`, `pub const WEEKLY_WINDOW_SECS: u64`, and `fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option` (module-private; tested inline). + +- [ ] **Step 1: Write the failing tests** + +Append to the end of `src/poller.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etd_none_when_on_safe_pace() { + // 50% used, 1h elapsed of a 5h window (4h remaining). + // Project ~1h more to full → total ~2h « 4h remaining → safe. + assert_eq!(etd_secs(50.0, 4 * 3600, 5 * 3600), None); + } + + #[test] + fn etd_some_when_at_risk() { + // 60% used, 1h elapsed of a 2h window (1h remaining). + // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. + assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); + } + + #[test] + fn etd_none_at_boundaries() { + assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used + assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full + assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 + assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining + assert_eq!(etd_secs(50.0, 3600, 0), None); // no window + } + + #[test] + fn etd_invariant_matches_at_risk_rule() { + // etd_secs is Some iff burn rate exceeds steady pace: + // actual_pct > 100 * elapsed / window. + // Skip a small band around the exact boundary to avoid float flakiness. + let window = 5 * 3600u64; + for remaining in (0..=window).step_by(600) { + let elapsed = window - remaining; + for pct_x10 in 1..1000u64 { + let actual = pct_x10 as f64 / 10.0; + if elapsed == 0 || remaining == 0 { + assert_eq!(etd_secs(actual, remaining, window), None); + continue; + } + let boundary = 100.0 * elapsed as f64 / window as f64; + if (actual - boundary).abs() < 0.05 { + continue; // razor's edge — covered by explicit boundary test + } + let at_risk = actual > boundary; + assert_eq!( + etd_secs(actual, remaining, window).is_some(), + at_risk, + "actual={actual} remaining={remaining} window={window}" + ); + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib etd_ 2>&1 | tail -20` (or `cargo test etd_`) +Expected: FAIL — `cannot find function `etd_secs` in this scope` (compile error). + +- [ ] **Step 3: Write the constants and `etd_secs`** + +Append to `src/poller.rs` *above* the `#[cfg(test)]` module: + +```rust +/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, +/// next to the formatting that consumes them, so the ETD feature is +/// self-contained and does not depend on constants defined elsewhere. +pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; +pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; + +/// Estimated seconds until the quota is fully consumed, assuming the current +/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the +/// projection lands *before* the window resets — i.e. only when the user is +/// genuinely on pace to deplete early. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { + return None; + } + if remaining_secs == 0 || window_secs == 0 { + return None; + } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { + return None; + } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { + return None; + } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test etd_ 2>&1 | tail -20` +Expected: PASS — 4 tests pass. (A `dead_code` warning on `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS`/`etd_secs` is expected until later tasks consume them; it is not an error.) + +- [ ] **Step 5: Commit** + +```bash +git add src/poller.rs +git commit -m "feat: add ETD projection math with unit tests" +``` + +--- + +### Task 2: Localization strings + +**Files:** +- Modify: `src/localization/mod.rs:179` (add two fields to `Strings`, after `codex_window_title`) +- Modify all 10 language files: `src/localization/english.rs`, `dutch.rs`, `spanish.rs`, `french.rs`, `german.rs`, `japanese.rs`, `korean.rs`, `traditional_chinese.rs`, `russian.rs`, `portuguese_brazil.rs` + +**Interfaces:** +- Produces: `Strings::show_etd` and `Strings::etd_suffix` (both `&'static str`), available to later tasks. + +- [ ] **Step 1: Add the fields to the `Strings` struct** + +In `src/localization/mod.rs`, the `Strings` struct ends at line 180 with `pub codex_window_title: &'static str,` on line 179. Add immediately after it: + +```rust + pub show_etd: &'static str, + pub etd_suffix: &'static str, +``` + +- [ ] **Step 2: Verify it fails to compile** + +Run: `cargo build 2>&1 | tail -20` +Expected: FAIL — `missing field `show_etd` in initializer of `Strings`` (one error per language file). + +- [ ] **Step 3: Fill in every language file** + +Add these two lines to the `Strings { ... }` initializer in each file (place them next to the existing `codex_window_title` line). Use these values: + +`english.rs`: +```rust + show_etd: "Show ETD", + etd_suffix: "ETD", +``` +`dutch.rs`: +```rust + show_etd: "Toon ETD", + etd_suffix: "ETD", +``` +`spanish.rs`: +```rust + show_etd: "Mostrar ETD", + etd_suffix: "ETD", +``` +`french.rs`: +```rust + show_etd: "Afficher l'ETD", + etd_suffix: "ETD", +``` +`german.rs`: +```rust + show_etd: "ETD anzeigen", + etd_suffix: "ETD", +``` +`japanese.rs`: +```rust + show_etd: "ETD を表示", + etd_suffix: "ETD", +``` +`korean.rs`: +```rust + show_etd: "ETD 표시", + etd_suffix: "ETD", +``` +`traditional_chinese.rs`: +```rust + show_etd: "顯示 ETD", + etd_suffix: "ETD", +``` +`russian.rs`: +```rust + show_etd: "Показывать ETD", + etd_suffix: "ETD", +``` +`portuguese_brazil.rs`: +```rust + show_etd: "Mostrar ETD", + etd_suffix: "ETD", +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build 2>&1 | tail -20` +Expected: SUCCESS (warnings about unused `show_etd`/`etd_suffix` are fine until later tasks). + +- [ ] **Step 5: Commit** + +```bash +git add src/localization/ +git commit -m "feat: add ETD localization strings" +``` + +--- + +### Task 3: ETD suffix formatter + +**Files:** +- Modify: `src/poller.rs` (add `etd_suffix` next to `etd_secs`) +- Test: `src/poller.rs` (extend the `tests` module) + +**Interfaces:** +- Consumes: `etd_secs` (Task 1), `format_countdown_from_secs` (existing private fn), `Strings::etd_suffix` (Task 2). +- Produces: `pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option` returning a string like `" \u{00b7} 45m ETD"`, or `None` when not at risk. + +- [ ] **Step 1: Write the failing tests** + +Add inside `mod tests` in `src/poller.rs`: + +```rust + use crate::localization::LanguageId; + use std::time::Duration; + + fn section(pct: f64, remaining: Duration) -> UsageSection { + UsageSection { + percentage: pct, + resets_at: Some(SystemTime::now() + remaining), + } + } + + #[test] + fn etd_suffix_present_when_at_risk() { + // 60% used, 1h remaining of a 2h window → at risk. + let s = section(60.0, Duration::from_secs(3600)); + let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); + let out = out.expect("expected a suffix when at risk"); + assert!(out.contains("ETD"), "suffix was: {out}"); + assert!(out.starts_with(" \u{00b7} "), "suffix was: {out}"); + } + + #[test] + fn etd_suffix_absent_when_safe() { + // 10% used, 4h remaining of a 5h window → safe. + let s = section(10.0, Duration::from_secs(4 * 3600)); + assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_suffix_absent_without_reset() { + let s = UsageSection { percentage: 60.0, resets_at: None }; + assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test etd_suffix 2>&1 | tail -20` +Expected: FAIL — `cannot find function `etd_suffix``. + +- [ ] **Step 3: Implement `etd_suffix`** + +In `src/poller.rs`, immediately after `etd_secs`, add: + +```rust +/// The trailing " · 45m ETD" segment for a usage cell, or `None` when the +/// section is not on pace to deplete before reset. Reuses the same coarse, +/// single-unit duration format as the countdown. +pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option { + let reset = section.resets_at?; + let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); + let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; + let dur = format_countdown_from_secs(secs, strings); + Some(format!(" \u{00b7} {dur} {}", strings.etd_suffix)) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test etd 2>&1 | tail -20` +Expected: PASS — all ETD tests pass (`etd_secs` is now used, so its dead-code warning clears; `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS` may still warn until Task 5). + +- [ ] **Step 5: Commit** + +```bash +git add src/poller.rs +git commit -m "feat: add ETD suffix formatter" +``` + +--- + +### Task 4: `show_etd` setting plumbing + +**Files:** +- Modify: `src/window.rs` — `AppState` struct (after line 67 `show_codex: bool,`), `SettingsFile` (after line 217), `SettingsFile::default` (after line 229), `save_state_settings` (after line 284), `AppState` init (after line 1017). + +**Interfaces:** +- Produces: `AppState.show_etd: bool` and `SettingsFile.show_etd: bool`, persisted to `settings.json`, defaulting to `false`. Consumed by Task 5. + +- [ ] **Step 1: Add the `AppState` field** + +In `src/window.rs`, after line 67 (` show_codex: bool,`) add: + +```rust + show_etd: bool, +``` + +- [ ] **Step 2: Add the `SettingsFile` field** + +After line 217 (` show_codex: bool,` inside `struct SettingsFile`) add: + +```rust + #[serde(default)] + show_etd: bool, +``` + +- [ ] **Step 3: Add to `SettingsFile::default`** + +After line 229 (` show_codex: false,`) add: + +```rust + show_etd: false, +``` + +- [ ] **Step 4: Add to `save_state_settings`** + +After line 284 (` show_codex: s.show_codex,`) add: + +```rust + show_etd: s.show_etd, +``` + +- [ ] **Step 5: Add to the `AppState` initializer** + +After line 1017 (` show_codex: settings.show_codex,`) add: + +```rust + show_etd: settings.show_etd, +``` + +- [ ] **Step 6: Verify it compiles** + +Run: `cargo build 2>&1 | tail -20` +Expected: SUCCESS (an unused-field warning on `show_etd` is expected until Task 5 reads it). + +- [ ] **Step 7: Commit** + +```bash +git add src/window.rs +git commit -m "feat: persist show_etd setting" +``` + +--- + +### Task 5: Menu toggle, command handler, and cell wiring + +**Files:** +- Modify: `src/window.rs` — menu ID const (after line 125), `show_context_menu` destructure (lines 2334–2335 and the `None` fallback 2357–2359), Settings-submenu item (after the reset-position item, line 2463), `WM_COMMAND` handler (after the `IDM_MODEL_*` arm, line 2254), and `refresh_usage_texts` (lines 422–436). + +**Interfaces:** +- Consumes: `poller::etd_suffix`, `poller::SESSION_WINDOW_SECS`, `poller::WEEKLY_WINDOW_SECS` (Tasks 1+3), `AppState.show_etd` (Task 4), `Strings::show_etd` (Task 2). + +- [ ] **Step 1: Add the menu ID constant** + +In `src/window.rs`, after line 125 (`const IDM_MODEL_CODEX: u16 = 61;`) add: + +```rust +const IDM_SHOW_ETD: u16 = 74; +``` + +- [ ] **Step 2: Append ETD to the cell text in `refresh_usage_texts`** + +Replace the body block at lines 422–436 (the two `if let Some(...)` blocks) with: + +```rust + if let Some(claude_code) = data.claude_code.as_ref() { + state.session_text = poller::format_line(&claude_code.session, strings); + state.weekly_text = poller::format_line(&claude_code.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings) + { + state.session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.weekly_text.push_str(&s); + } + } + } else if state.show_claude_code { + state.session_text = "!".to_string(); + state.weekly_text = "!".to_string(); + } + + if let Some(codex) = data.codex.as_ref() { + state.codex_session_text = poller::format_line(&codex.session, strings); + state.codex_weekly_text = poller::format_line(&codex.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings) + { + state.codex_session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.codex_weekly_text.push_str(&s); + } + } + } else if state.show_codex { + state.codex_session_text = "!".to_string(); + state.codex_weekly_text = "!".to_string(); + } +``` + +- [ ] **Step 3: Surface `show_etd` in `show_context_menu`** + +(a) In the destructure tuple, after line 2335 (` show_codex,`) add: + +```rust + show_etd, +``` + +(b) In the `Some(s) => ( ... )` arm, after line 2348 (` s.show_codex,`) add: + +```rust + s.show_etd, +``` + +(c) In the `None => ( ... )` fallback, after line 2359 (` false,` — the `show_codex` fallback) add another: + +```rust + false, +``` + +- [ ] **Step 4: Add the Settings-submenu checkbox** + +In `show_context_menu`, after the reset-position `AppendMenuW` block (ends line 2463) add: + +```rust + let etd_str = native_interop::wide_str(strings.show_etd); + let etd_flags = if show_etd { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + etd_flags, + IDM_SHOW_ETD as usize, + PCWSTR::from_raw(etd_str.as_ptr()), + ); +``` + +- [ ] **Step 5: Add the `WM_COMMAND` handler arm** + +In the `WM_COMMAND` match, after the `IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX => { ... }` arm (ends line 2254) add: + +```rust + IDM_SHOW_ETD => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_etd = !s.show_etd; + refresh_usage_texts(s); + } + } + save_state_settings(); + render_layered(); + } +``` + +- [ ] **Step 6: Verify it compiles cleanly** + +Run: `cargo build 2>&1 | tail -20` +Expected: SUCCESS, no `dead_code` / unused warnings for any ETD symbol (all are now consumed). + +- [ ] **Step 7: Run the full test suite** + +Run: `cargo test 2>&1 | tail -20` +Expected: PASS — all ETD tests green. + +- [ ] **Step 8: Manual verification** + +Run: `cargo run` (or build a release and launch). Then: +1. Right-click the widget → Settings. Confirm a `Show ETD` item exists, unchecked (default OFF). +2. Click it → it becomes checked. +3. With a Claude/Codex cell currently over pace, confirm the cell text gains a ` · ETD` suffix; for cells on safe pace, confirm no suffix. +4. Uncheck `Show ETD` → suffixes disappear. +5. Restart the app → the toggle state persisted (read back from `settings.json`). + +Document the observed result (pass/fail per step) in the task notes. + +- [ ] **Step 9: Commit** + +```bash +git add src/window.rs +git commit -m "feat: add Show ETD toggle and wire ETD into usage cells" +``` + +--- + +### Task 6: README + screenshot + +**Files:** +- Modify: `README.md` +- Create: `.github/screenshots/etd.png` (a captured screenshot of an at-risk cell showing the suffix) + +**Interfaces:** none (docs only). + +- [ ] **Step 1: Capture a screenshot** + +With `Show ETD` enabled and a cell at risk, capture the widget showing `… · ETD` and save it to `.github/screenshots/etd.png`. (Match the dimensions/style of the existing screenshots in that folder.) + +- [ ] **Step 2: Add a README paragraph** + +Find the existing screenshots/features section in `README.md` (where the other feature screenshots are referenced) and add, in the same style: + +```markdown +### Estimated Time to Depletion (ETD) + +When you're on pace to use up a quota before its window resets, each affected +usage cell shows an estimate of how long that will take — e.g. `50% · 2h · 45m ETD`. +Enable it from the tray menu under **Settings → Show ETD** (off by default). + +![ETD](.github/screenshots/etd.png) +``` + +- [ ] **Step 3: Verify the link resolves** + +Run: `test -f .github/screenshots/etd.png && echo OK` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add README.md .github/screenshots/etd.png +git commit -m "docs: document the ETD indicator" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- §4 "When shown / format / scope / setting" → Tasks 1, 3, 5. ✓ +- §5 computation + invariant → Task 1 (incl. invariant test). ✓ +- §6 architecture (poller helpers, window plumbing, localization) → Tasks 1–5. ✓ +- §7 changes-by-file → every listed file appears in a task (poller, mod.rs, 10 lang files, window.rs, README). ✓ +- §8 testing (first `#[cfg(test)]`, 5+ tests) → Tasks 1 & 3. ✓ +- Default OFF → Task 4 Step 3 + Task 5 Step 4 flags. ✓ +- Independence/compatibility constraints (ID 74, constants in poller, coarse format) → Global Constraints + Tasks 1, 5. ✓ + +**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to" — all code shown inline. The only deferred artifact is the binary screenshot in Task 6, which is captured manually by definition. ✓ + +**3. Type consistency:** +- `etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option` — same signature in Task 1 def and Task 3 call. ✓ +- `etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option` — Task 3 def matches Task 5 call sites (`poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings)`). ✓ +- `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` are `pub` (Task 1) so Task 5's `poller::` references resolve. ✓ +- `Strings::show_etd` / `Strings::etd_suffix` defined in Task 2, used in Tasks 3 & 5. ✓ +- `IDM_SHOW_ETD = 74` defined Task 5 Step 1, used Steps 4 & 5. ✓ diff --git a/docs/superpowers/specs/2026-06-19-etd-design.md b/docs/superpowers/specs/2026-06-19-etd-design.md new file mode 100644 index 0000000..c4685f8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-etd-design.md @@ -0,0 +1,122 @@ +# Estimated Time to Depletion (ETD) — Design + +- **Date:** 2026-06-19 +- **Branch:** `feat/etd`, branched off **clean `main`** (v1.4.3) +- **Status:** Approved (design); plan written +- **Independence:** Self-contained. Does **not** depend on the "Show detailed remaining time" or pace-indicator ("colored") branches, and is mergeable alongside them with only trivial textual conflicts. +- **PR policy:** Do not reference any upstream issue in PR title, description, or commit messages. + +## 1. Problem + +Users have to mentally extrapolate whether they will exhaust their session/weekly quota before the rolling window resets. ETD answers that quantitatively: "if you keep this rate, you run out in ~45m." + +## 2. Goal + +When the user is on pace to deplete a quota *before* its window resets, append a short estimate to that cell's usage text. When on safe pace, append nothing. + +## 3. Independence & compatibility + +This feature branches off clean `main`, where: + +- Cell text is built by `poller::format_line(section, strings)` → `"50% · 2h"` (percentage + **single-unit** countdown). +- There are **no** window-length constants, no `expected_pace_pct`, and no `show_detailed_remaining` / `show_pace_indicator` settings — those all live only on the author's other two feature branches. + +Consequences, chosen to keep ETD independent yet compatible: + +| Concern | Decision | +|---|---| +| Duration precision | Reuse `main`'s native **coarse single-unit** countdown formatter (`format_countdown_from_secs`). No dependency on the detailed-remaining toggle. | +| Window constants | ETD defines its own `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` in **`poller.rs`** (the pace branch defines same-named constants in `window.rs` — different modules, so no symbol clash on merge). | +| Menu ID | `IDM_SHOW_ETD = 74` (the other branches use 70–73; no numeric clash). | +| Struct/localization fields | Added additively; a three-way merge yields only trivial field-list conflicts. | + +## 4. User-facing behavior + +| Aspect | Behavior | +|---|---| +| **When shown** | Only when projected depletion is *before* the window resets (the at-risk case). | +| **What's shown** | A suffix appended to the cell's existing text, after a middle-dot separator: on base `main` an at-risk cell reads **`50% · 2h · 45m ETD`**. | +| **Format** | The ETD duration uses the same coarse single-unit formatter as the countdown (`45m`, `2h`, `3d`). The trailing word comes from a localized `etd_suffix` string (English `"ETD"`). | +| **Scope** | All four cells: Claude session, Claude weekly, Codex session, Codex weekly. | +| **Setting** | New `Show ETD` checkbox in the Settings submenu. **Default OFF** (opt-in). | +| **Compatibility note** | If the detailed-remaining branch is also merged, the left segment becomes richer (e.g. `50% · 4h 32m`) automatically; the ETD suffix is unaffected. | + +## 5. Computation + +Pure function over three numbers already available at the formatting site: + +```rust +/// Estimated seconds to full depletion at the current burn rate +/// (quota-so-far / time-so-far). `None` unless depletion lands before reset. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { return None; } + if remaining_secs == 0 || window_secs == 0 { return None; } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { return None; } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { return None; } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} +``` + +### Invariant + +`etd_secs` returns `Some` **iff** `actual_pct > 100 * elapsed / window` — i.e. the current burn rate exceeds steady-state pace. (Because `remaining` is an integer, the `as u64` truncation does not move the boundary versus the exact float comparison.) This is the "at-risk" rule, and a free correctness check. + +### Edge cases + +| Condition | Behavior | +|---|---| +| `show_etd` off | Helper not called; nothing appended. | +| `resets_at` is `None` | No reset timestamp → nothing appended. | +| `actual_pct <= 0` or `>= 100` | `None` → nothing appended. | +| `elapsed_secs == 0` (just started) | `None` → nothing appended. | +| Not at risk (`secs >= remaining_secs`) | `None` → nothing appended. | + +## 6. Architecture + +- **`poller.rs`** gains: `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` (pub), private `etd_secs`, and pub `etd_suffix(section, window_secs, strings) -> Option` returning `" · 45m ETD"`. `etd_suffix` reuses the existing private `format_countdown_from_secs`. A `#[cfg(test)] mod tests` (the repo's first) covers `etd_secs`. +- **`window.rs`** gains: the `show_etd` bool plumbed exactly like the existing `show_codex` / `widget_visible` (SettingsFile + Default + save_state_settings + AppState + AppState init), `IDM_SHOW_ETD = 74`, a Settings-submenu checkbox, a `WM_COMMAND` arm, and append calls in `refresh_usage_texts`. +- **`localization/`** gains two `Strings` fields — `show_etd` (menu label) and `etd_suffix` (the trailing word) — filled in all 10 language files. + +### Rejected alternatives + +- **Change `format_line`'s signature** to take `window_secs`/`show_etd`. Rejected: widens blast radius and couples the window-agnostic formatter to ETD; appending via `etd_suffix` at the 4 call sites is more isolated. +- **Reuse the pace branch's constants/helpers.** Rejected: that is exactly the dependency the independence requirement forbids. + +## 7. Changes by file + +| File | Change | +|---|---| +| `src/poller.rs` | `SESSION_WINDOW_SECS`, `WEEKLY_WINDOW_SECS`, `etd_secs`, `etd_suffix`, `#[cfg(test)] mod tests`. | +| `src/localization/mod.rs` | Two new `Strings` fields: `show_etd`, `etd_suffix`. | +| `src/localization/{english,dutch,spanish,french,german,japanese,korean,traditional_chinese,russian,portuguese_brazil}.rs` | Fill in the two fields (10 files). | +| `src/window.rs` | `show_etd` plumbing, `IDM_SHOW_ETD`, menu item, command handler, `refresh_usage_texts` append. | +| `README.md` | One paragraph + one screenshot, matching the existing screenshots section. | + +## 8. Testing (TDD) + +The repo currently has **no tests**; this introduces the first `#[cfg(test)]` module (no Cargo changes — `cargo test` picks up inline unit tests). The pure functions are platform-independent; `cargo test` compiles the whole crate, which builds on Windows (the dev platform). + +1. `etd_none_when_on_safe_pace` +2. `etd_some_when_at_risk` +3. `etd_none_at_boundaries` — 0%, 100%, elapsed=0, remaining=0, window=0 +4. `etd_invariant_matches_at_risk_rule` — property sweep, skipping the razor's-edge band to avoid float-boundary flakiness +5. `etd_suffix_present_when_at_risk` / `etd_suffix_absent_when_safe` — build a `UsageSection` with `resets_at = now + remaining` + +UI / Win32 menu code stays manually verified (build, run, toggle on, drive an at-risk cell, confirm the suffix appears and disappears). + +## 9. Risks + +- **Estimate jitter early in a window.** Suppressed by the at-risk gate; a minimum-elapsed gate is a possible future refinement (not in v1). +- **Translation literalness.** Missing a `Strings` field in any language file is a compile error, so nothing ships untranslated; initial translations may keep `"ETD"` verbatim until refined. +- **Default OFF → low discoverability.** Accepted; conservative first ship, flip later if desired. + +## 10. Out of scope + +- Configurable burn-rate smoothing window. +- Per-message rate (would need new poller data). +- Threshold notifications. +- Tooltip / hover variant. +- Absolute clock-time display. From e275646007ea6358d4436e5a91a8e07bb4b7a0f2 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:42:11 +0800 Subject: [PATCH 02/13] docs: fix ETD safe-pace test to a genuinely safe case --- docs/superpowers/plans/2026-06-19-etd.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-06-19-etd.md b/docs/superpowers/plans/2026-06-19-etd.md index a1ec664..c874cf5 100644 --- a/docs/superpowers/plans/2026-06-19-etd.md +++ b/docs/superpowers/plans/2026-06-19-etd.md @@ -41,9 +41,10 @@ mod tests { #[test] fn etd_none_when_on_safe_pace() { - // 50% used, 1h elapsed of a 5h window (4h remaining). - // Project ~1h more to full → total ~2h « 4h remaining → safe. - assert_eq!(etd_secs(50.0, 4 * 3600, 5 * 3600), None); + // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h + // is 20%, so 10% is UNDER pace → would not deplete before reset. + // (50% in the first hour would be at-risk, not safe — see the invariant.) + assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); } #[test] From fb2db2fe21c65d74d8a43842baff95ef36590cae Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:43:33 +0800 Subject: [PATCH 03/13] feat: add ETD projection math with unit tests --- src/poller.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/poller.rs b/src/poller.rs index 5bd02bc..baae7f9 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1097,3 +1097,89 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool { data.claude_code.as_ref().is_some_and(is_past_reset) || data.codex.as_ref().is_some_and(is_past_reset) } + +/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, +/// next to the formatting that consumes them, so the ETD feature is +/// self-contained and does not depend on constants defined elsewhere. +pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; +pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; + +/// Estimated seconds until the quota is fully consumed, assuming the current +/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the +/// projection lands *before* the window resets — i.e. only when the user is +/// genuinely on pace to deplete early. +fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { + if actual_pct <= 0.0 || actual_pct >= 100.0 { + return None; + } + if remaining_secs == 0 || window_secs == 0 { + return None; + } + let elapsed_secs = window_secs.saturating_sub(remaining_secs); + if elapsed_secs == 0 { + return None; + } + let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; + if !secs.is_finite() || secs < 0.0 { + return None; + } + let secs = secs as u64; + (secs < remaining_secs).then_some(secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etd_none_when_on_safe_pace() { + // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h + // is 20%, so 10% is UNDER pace → would not deplete before reset. + // (50% in the first hour would be at-risk, not safe — see the invariant.) + assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); + } + + #[test] + fn etd_some_when_at_risk() { + // 60% used, 1h elapsed of a 2h window (1h remaining). + // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. + assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); + } + + #[test] + fn etd_none_at_boundaries() { + assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used + assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full + assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 + assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining + assert_eq!(etd_secs(50.0, 3600, 0), None); // no window + } + + #[test] + fn etd_invariant_matches_at_risk_rule() { + // etd_secs is Some iff burn rate exceeds steady pace: + // actual_pct > 100 * elapsed / window. + // Skip a small band around the exact boundary to avoid float flakiness. + let window = 5 * 3600u64; + for remaining in (0..=window).step_by(600) { + let elapsed = window - remaining; + for pct_x10 in 1..1000u64 { + let actual = pct_x10 as f64 / 10.0; + if elapsed == 0 || remaining == 0 { + assert_eq!(etd_secs(actual, remaining, window), None); + continue; + } + let boundary = 100.0 * elapsed as f64 / window as f64; + if (actual - boundary).abs() < 0.05 { + continue; // razor's edge — covered by explicit boundary test + } + let at_risk = actual > boundary; + assert_eq!( + etd_secs(actual, remaining, window).is_some(), + at_risk, + "actual={actual} remaining={remaining} window={window}" + ); + } + } + } +} From d649b29153591c445c60cc349b49199f951c536c Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:48:15 +0800 Subject: [PATCH 04/13] feat: add ETD localization strings --- src/localization/dutch.rs | 2 ++ src/localization/english.rs | 2 ++ src/localization/french.rs | 2 ++ src/localization/german.rs | 2 ++ src/localization/japanese.rs | 2 ++ src/localization/korean.rs | 2 ++ src/localization/mod.rs | 2 ++ src/localization/portuguese_brazil.rs | 2 ++ src/localization/russian.rs | 2 ++ src/localization/spanish.rs | 2 ++ src/localization/traditional_chinese.rs | 2 ++ 11 files changed, 22 insertions(+) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 0eb486d..112fb7b 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex-authenticatiefout", codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.", codex_window_title: "Codex-gebruiksmonitor", + show_etd: "Toon ETD", + etd_suffix: "ETD", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 2b92f36..99b232b 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex Auth Error", codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.", codex_window_title: "Codex Usage Monitor", + show_etd: "Show ETD", + etd_suffix: "ETD", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index fa448fb..8b4a0a9 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erreur d'authentification Codex", codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.", codex_window_title: "Moniteur d'utilisation Codex", + show_etd: "Afficher l'ETD", + etd_suffix: "ETD", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 5c7bb23..7a7666f 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex-Authentifizierungsfehler", codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.", codex_window_title: "Codex-Nutzungsmonitor", + show_etd: "ETD anzeigen", + etd_suffix: "ETD", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 0020018..960d0f2 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 認証エラー", codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。", codex_window_title: "Codex 使用量モニター", + show_etd: "ETD を表示", + etd_suffix: "ETD", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 59e3829..5f7b374 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 인증 오류", codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.", codex_window_title: "Codex 사용량 모니터", + show_etd: "ETD 표시", + etd_suffix: "ETD", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 39ee702..5f0a1f7 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -177,6 +177,8 @@ pub struct Strings { pub codex_token_expired_title: &'static str, pub codex_token_expired_body: &'static str, pub codex_window_title: &'static str, + pub show_etd: &'static str, + pub etd_suffix: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 691ee77..4a47489 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -43,4 +43,6 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Erro de Autenticação do Codex", codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.", codex_window_title: "Monitor de uso do Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index 786d0fa..f26552d 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -43,4 +43,6 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Ошибка авторизации Codex", codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.", codex_window_title: "Монитор использования Codex", + show_etd: "Показывать ETD", + etd_suffix: "ETD", }; \ No newline at end of file diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e6513e..8e1f4e9 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Error de autenticacion de Codex", codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.", codex_window_title: "Monitor de uso de Codex", + show_etd: "Mostrar ETD", + etd_suffix: "ETD", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index 809ebba..e976b00 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -42,5 +42,7 @@ pub(super) const STRINGS: Strings = Strings { codex_token_expired_title: "Codex 驗證錯誤", codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。", codex_window_title: "Codex 使用量監控", + show_etd: "顯示 ETD", + etd_suffix: "ETD", second_suffix: "秒", }; From 221f9f41abdacd45dd9543cf5a4f861515ac039c Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:52:50 +0800 Subject: [PATCH 05/13] feat: add ETD suffix formatter --- src/poller.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/poller.rs b/src/poller.rs index baae7f9..a6c6bf4 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1127,9 +1127,52 @@ fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option Option { + let reset = section.resets_at?; + let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); + let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; + let dur = format_countdown_from_secs(secs, strings); + Some(format!(" \u{00b7} {dur} {}", strings.etd_suffix)) +} + #[cfg(test)] mod tests { use super::*; + use crate::localization::LanguageId; + use std::time::Duration; + + fn section(pct: f64, remaining: Duration) -> UsageSection { + UsageSection { + percentage: pct, + resets_at: Some(SystemTime::now() + remaining), + } + } + + #[test] + fn etd_suffix_present_when_at_risk() { + // 60% used, 1h remaining of a 2h window → at risk. + let s = section(60.0, Duration::from_secs(3600)); + let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); + let out = out.expect("expected a suffix when at risk"); + assert!(out.contains("ETD"), "suffix was: {out}"); + assert!(out.starts_with(" \u{00b7} "), "suffix was: {out}"); + } + + #[test] + fn etd_suffix_absent_when_safe() { + // 10% used, 4h remaining of a 5h window → safe. + let s = section(10.0, Duration::from_secs(4 * 3600)); + assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None); + } + + #[test] + fn etd_suffix_absent_without_reset() { + let s = UsageSection { percentage: 60.0, resets_at: None }; + assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None); + } #[test] fn etd_none_when_on_safe_pace() { From 1d4502bf8b8dd5d6bc544a07df9411ab09272c36 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 16:57:37 +0800 Subject: [PATCH 06/13] feat: persist show_etd setting --- src/window.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/window.rs b/src/window.rs index 7ce1dea..6b9ef54 100644 --- a/src/window.rs +++ b/src/window.rs @@ -65,6 +65,7 @@ struct AppState { codex_weekly_text: String, show_claude_code: bool, show_codex: bool, + show_etd: bool, data: Option, @@ -215,6 +216,8 @@ struct SettingsFile { show_claude_code: bool, #[serde(default = "default_show_codex")] show_codex: bool, + #[serde(default)] + show_etd: bool, } impl Default for SettingsFile { @@ -227,6 +230,7 @@ impl Default for SettingsFile { widget_visible: true, show_claude_code: true, show_codex: false, + show_etd: false, } } } @@ -282,6 +286,7 @@ fn save_state_settings() { widget_visible: s.widget_visible, show_claude_code: s.show_claude_code, show_codex: s.show_codex, + show_etd: s.show_etd, }); } } @@ -1015,6 +1020,7 @@ pub fn run() { codex_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, + show_etd: settings.show_etd, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, From 97d26b080a5bbbb663ee0300389abac9598c2212 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 17:04:01 +0800 Subject: [PATCH 07/13] feat: add Show ETD toggle and wire ETD into usage cells --- src/window.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/window.rs b/src/window.rs index 6b9ef54..5c78210 100644 --- a/src/window.rs +++ b/src/window.rs @@ -124,6 +124,7 @@ const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; +const IDM_SHOW_ETD: u16 = 74; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -427,6 +428,18 @@ fn refresh_usage_texts(state: &mut AppState) { if let Some(claude_code) = data.claude_code.as_ref() { state.session_text = poller::format_line(&claude_code.session, strings); state.weekly_text = poller::format_line(&claude_code.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings) + { + state.session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.weekly_text.push_str(&s); + } + } } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); @@ -435,6 +448,18 @@ fn refresh_usage_texts(state: &mut AppState) { if let Some(codex) = data.codex.as_ref() { state.codex_session_text = poller::format_line(&codex.session, strings); state.codex_weekly_text = poller::format_line(&codex.weekly, strings); + if state.show_etd { + if let Some(s) = + poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings) + { + state.codex_session_text.push_str(&s); + } + if let Some(s) = + poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings) + { + state.codex_weekly_text.push_str(&s); + } + } } else if state.show_codex { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); @@ -2258,6 +2283,17 @@ unsafe extern "system" fn wnd_proc( do_poll(sh); }); } + IDM_SHOW_ETD => { + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.show_etd = !s.show_etd; + refresh_usage_texts(s); + } + } + save_state_settings(); + render_layered(); + } IDM_LANG_SYSTEM | IDM_LANG_ENGLISH | IDM_LANG_DUTCH @@ -2339,6 +2375,7 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, + show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -2352,6 +2389,7 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, + s.show_etd, ), None => ( POLL_15_MIN, @@ -2363,6 +2401,7 @@ fn show_context_menu(hwnd: HWND) { true, true, false, + false, ), } }; @@ -2468,6 +2507,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); + let etd_str = native_interop::wide_str(strings.show_etd); + let etd_flags = if show_etd { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + etd_flags, + IDM_SHOW_ETD as usize, + PCWSTR::from_raw(etd_str.as_ptr()), + ); + let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() { From 270f5c4226e385dbf0c97dda0a91b4ceaf760a3d Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 18:26:32 +0800 Subject: [PATCH 08/13] fix: widen usage cells when ETD is shown so the suffix is not clipped --- src/window.rs | 82 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/src/window.rs b/src/window.rs index 5c78210..872a876 100644 --- a/src/window.rs +++ b/src/window.rs @@ -847,7 +847,12 @@ const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; -const TEXT_WIDTH: i32 = 62; +const TEXT_WIDTH: i32 = 62; // fits the worst-case "100% · 59m" +/// Text column when the ETD suffix is shown. The ETD form is worst-case +/// "100% · 59m · 59m ETD" — the base "100% · 59m" plus a roughly equal-length +/// " · 59m ETD" suffix — so the column is sized to twice the base to fit the +/// widest possible remaining-time and ETD without clipping. +const ETD_TEXT_WIDTH: i32 = TEXT_WIDTH * 2; const MODEL_RIGHT_MARGIN: i32 = 5; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; @@ -856,6 +861,15 @@ fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } +/// Text-column width for a usage cell, widened when the ETD suffix is shown. +fn text_width(show_etd: bool) -> i32 { + if show_etd { + ETD_TEXT_WIDTH + } else { + TEXT_WIDTH + } +} + fn row_bar_segment_count(active_models: i32) -> i32 { if active_models > 1 { 5 @@ -864,11 +878,11 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } -fn total_widget_width_for(active_models: i32) -> i32 { +fn total_widget_width_for(active_models: i32, show_etd: bool) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH); + + sc(text_width(show_etd)); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -880,18 +894,21 @@ fn total_widget_width_for(active_models: i32) -> i32 { } fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for(active_model_count(state.show_claude_code, state.show_codex)) + total_widget_width_for( + active_model_count(state.show_claude_code, state.show_codex), + state.show_etd, + ) } fn total_widget_width() -> i32 { - let active_models = { + let (active_models, show_etd) = { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex)) - .unwrap_or(1) + .map(|s| (active_model_count(s.show_claude_code, s.show_codex), s.show_etd)) + .unwrap_or((1, false)) }; - total_widget_width_for(active_models) + total_widget_width_for(active_models, show_etd) } fn claude_accent_color() -> Color { @@ -992,7 +1009,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count), + total_widget_width_for(initial_model_count, settings.show_etd), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1185,6 +1202,7 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, + show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -1203,6 +1221,7 @@ fn render_layered() { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_etd, ), None => return, } @@ -1292,6 +1311,7 @@ fn render_layered() { &codex_weekly_text, show_claude_code, show_codex, + show_etd, &codex_accent, ); @@ -1362,6 +1382,7 @@ fn paint_content( codex_weekly_text: &str, show_claude_code: bool, show_codex: bool, + show_etd: bool, codex_accent: &Color, ) { unsafe { @@ -1452,6 +1473,7 @@ fn paint_content( codex_session_text, show_claude_code, show_codex, + show_etd, accent, codex_accent, track, @@ -1469,6 +1491,7 @@ fn paint_content( codex_weekly_text, show_claude_code, show_codex, + show_etd, accent, codex_accent, track, @@ -2643,6 +2666,7 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_weekly_text, show_claude_code, show_codex, + show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -2659,6 +2683,7 @@ fn paint(hdc: HDC, hwnd: HWND) { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, + s.show_etd, ), None => return, } @@ -2716,6 +2741,7 @@ fn paint(hdc: HDC, hwnd: HWND) { &codex_weekly_text, show_claude_code, show_codex, + show_etd, &codex_accent, ); @@ -2740,6 +2766,7 @@ fn draw_row( codex_text: &str, show_claude_code: bool, show_codex: bool, + show_etd: bool, claude_accent: &Color, codex_accent: &Color, track: &Color, @@ -2787,8 +2814,9 @@ fn draw_row( claude_accent, track, &claude_value_color, + show_etd, ); - model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + model_x += model_usage_width(segment_count, show_etd) + sc(MODEL_RIGHT_MARGIN); } if show_codex { draw_usage_bar( @@ -2801,15 +2829,16 @@ fn draw_row( codex_accent, track, &codex_value_color, + show_etd, ); } } } -fn model_usage_width(segment_count: i32) -> i32 { +fn model_usage_width(segment_count: i32, show_etd: bool) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(TEXT_WIDTH) + + sc(text_width(show_etd)) } fn draw_usage_bar( @@ -2822,6 +2851,7 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, + show_etd: bool, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2882,7 +2912,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(TEXT_WIDTH), + right: text_x + sc(text_width(show_etd)), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -2911,3 +2941,29 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) { let _ = DeleteObject(brush); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etd_text_column_is_twice_the_base() { + // Sized for the worst case "100% · 59m · 59m ETD" (~2x "100% · 59m"). + assert_eq!(ETD_TEXT_WIDTH, TEXT_WIDTH * 2); + assert_eq!(text_width(false), TEXT_WIDTH); + assert_eq!(text_width(true), ETD_TEXT_WIDTH); + } + + #[test] + fn widget_widens_by_text_delta_per_model_when_etd_on() { + // Enabling ETD must widen the widget by exactly the column growth, once + // per active model — so the layout calc matches the wider draw rect and + // nothing re-clips. + for models in 1..=2 { + let off = total_widget_width_for(models, false); + let on = total_widget_width_for(models, true); + let expected = (sc(ETD_TEXT_WIDTH) - sc(TEXT_WIDTH)) * models; + assert_eq!(on - off, expected, "models={models}"); + } + } +} From ae55c05622273ed22e6ae56fd3a2ed706e253b25 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 18:37:39 +0800 Subject: [PATCH 09/13] feat: label remaining time with 'rem' in ETD mode and widen column to fit --- src/localization/dutch.rs | 1 + src/localization/english.rs | 1 + src/localization/french.rs | 1 + src/localization/german.rs | 1 + src/localization/japanese.rs | 1 + src/localization/korean.rs | 1 + src/localization/mod.rs | 1 + src/localization/portuguese_brazil.rs | 1 + src/localization/russian.rs | 1 + src/localization/spanish.rs | 1 + src/localization/traditional_chinese.rs | 1 + src/poller.rs | 14 ++++++++++---- src/window.rs | 14 +++++++------- 13 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/localization/dutch.rs b/src/localization/dutch.rs index 112fb7b..7931b29 100644 --- a/src/localization/dutch.rs +++ b/src/localization/dutch.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-gebruiksmonitor", show_etd: "Toon ETD", etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/english.rs b/src/localization/english.rs index 99b232b..3290a05 100644 --- a/src/localization/english.rs +++ b/src/localization/english.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex Usage Monitor", show_etd: "Show ETD", etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/french.rs b/src/localization/french.rs index 8b4a0a9..6592c4c 100644 --- a/src/localization/french.rs +++ b/src/localization/french.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Moniteur d'utilisation Codex", show_etd: "Afficher l'ETD", etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/german.rs b/src/localization/german.rs index 7a7666f..179ebf2 100644 --- a/src/localization/german.rs +++ b/src/localization/german.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex-Nutzungsmonitor", show_etd: "ETD anzeigen", etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/japanese.rs b/src/localization/japanese.rs index 960d0f2..9c1702b 100644 --- a/src/localization/japanese.rs +++ b/src/localization/japanese.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量モニター", show_etd: "ETD を表示", etd_suffix: "ETD", + rem: "rem", second_suffix: "秒", }; diff --git a/src/localization/korean.rs b/src/localization/korean.rs index 5f7b374..80f5608 100644 --- a/src/localization/korean.rs +++ b/src/localization/korean.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 사용량 모니터", show_etd: "ETD 표시", etd_suffix: "ETD", + rem: "rem", second_suffix: "초", }; diff --git a/src/localization/mod.rs b/src/localization/mod.rs index 5f0a1f7..cb4d54d 100644 --- a/src/localization/mod.rs +++ b/src/localization/mod.rs @@ -179,6 +179,7 @@ pub struct Strings { pub codex_window_title: &'static str, pub show_etd: &'static str, pub etd_suffix: &'static str, + pub rem: &'static str, } pub fn resolve_language(language_override: Option) -> LanguageId { diff --git a/src/localization/portuguese_brazil.rs b/src/localization/portuguese_brazil.rs index 4a47489..c665d60 100644 --- a/src/localization/portuguese_brazil.rs +++ b/src/localization/portuguese_brazil.rs @@ -45,4 +45,5 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso do Codex", show_etd: "Mostrar ETD", etd_suffix: "ETD", + rem: "rem", }; diff --git a/src/localization/russian.rs b/src/localization/russian.rs index f26552d..8f31035 100644 --- a/src/localization/russian.rs +++ b/src/localization/russian.rs @@ -45,4 +45,5 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Монитор использования Codex", show_etd: "Показывать ETD", etd_suffix: "ETD", + rem: "rem", }; \ No newline at end of file diff --git a/src/localization/spanish.rs b/src/localization/spanish.rs index 8e1f4e9..0dbca7a 100644 --- a/src/localization/spanish.rs +++ b/src/localization/spanish.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Monitor de uso de Codex", show_etd: "Mostrar ETD", etd_suffix: "ETD", + rem: "rem", second_suffix: "s", }; diff --git a/src/localization/traditional_chinese.rs b/src/localization/traditional_chinese.rs index e976b00..84426a7 100644 --- a/src/localization/traditional_chinese.rs +++ b/src/localization/traditional_chinese.rs @@ -44,5 +44,6 @@ pub(super) const STRINGS: Strings = Strings { codex_window_title: "Codex 使用量監控", show_etd: "顯示 ETD", etd_suffix: "ETD", + rem: "rem", second_suffix: "秒", }; diff --git a/src/poller.rs b/src/poller.rs index a6c6bf4..e6017ce 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -1127,15 +1127,20 @@ fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option Option { let reset = section.resets_at?; let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; let dur = format_countdown_from_secs(secs, strings); - Some(format!(" \u{00b7} {dur} {}", strings.etd_suffix)) + Some(format!( + " {} \u{00b7} {dur} {}", + strings.rem, strings.etd_suffix + )) } #[cfg(test)] @@ -1158,7 +1163,8 @@ mod tests { let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); let out = out.expect("expected a suffix when at risk"); assert!(out.contains("ETD"), "suffix was: {out}"); - assert!(out.starts_with(" \u{00b7} "), "suffix was: {out}"); + // Labels the preceding countdown with "rem", then the ETD segment. + assert!(out.starts_with(" rem \u{00b7} "), "suffix was: {out}"); } #[test] diff --git a/src/window.rs b/src/window.rs index 872a876..4eb0125 100644 --- a/src/window.rs +++ b/src/window.rs @@ -849,10 +849,10 @@ const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; const TEXT_WIDTH: i32 = 62; // fits the worst-case "100% · 59m" /// Text column when the ETD suffix is shown. The ETD form is worst-case -/// "100% · 59m · 59m ETD" — the base "100% · 59m" plus a roughly equal-length -/// " · 59m ETD" suffix — so the column is sized to twice the base to fit the -/// widest possible remaining-time and ETD without clipping. -const ETD_TEXT_WIDTH: i32 = TEXT_WIDTH * 2; +/// "100% · 59m rem · 59m ETD" — the base "100% · 59m" plus a " rem · 59m ETD" +/// label-and-estimate of similar length — so the column is sized to fit the +/// widest possible remaining-time and ETD (~2.6x the base) without clipping. +const ETD_TEXT_WIDTH: i32 = 160; const MODEL_RIGHT_MARGIN: i32 = 5; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; @@ -2947,9 +2947,9 @@ mod tests { use super::*; #[test] - fn etd_text_column_is_twice_the_base() { - // Sized for the worst case "100% · 59m · 59m ETD" (~2x "100% · 59m"). - assert_eq!(ETD_TEXT_WIDTH, TEXT_WIDTH * 2); + fn etd_text_column_is_wider_than_base() { + // Sized for the worst case "100% · 59m rem · 59m ETD". + assert!(ETD_TEXT_WIDTH > TEXT_WIDTH); assert_eq!(text_width(false), TEXT_WIDTH); assert_eq!(text_width(true), ETD_TEXT_WIDTH); } From 9cc5c015b15e0fc4fb55b7e2a16cf034c79c9bc3 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 18:46:32 +0800 Subject: [PATCH 10/13] docs: document the ETD indicator --- .github/screenshots/etd.png | Bin 0 -> 6757 bytes README.md | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 .github/screenshots/etd.png diff --git a/.github/screenshots/etd.png b/.github/screenshots/etd.png new file mode 100644 index 0000000000000000000000000000000000000000..e200092c7f4265a7d656db4dbb8727e1dc99a90e GIT binary patch literal 6757 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 Date: Fri, 19 Jun 2026 18:51:59 +0800 Subject: [PATCH 11/13] chore: drop internal planning docs from the feature branch --- docs/superpowers/plans/2026-06-19-etd.md | 597 ------------------ .../specs/2026-06-19-etd-design.md | 122 ---- 2 files changed, 719 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-19-etd.md delete mode 100644 docs/superpowers/specs/2026-06-19-etd-design.md diff --git a/docs/superpowers/plans/2026-06-19-etd.md b/docs/superpowers/plans/2026-06-19-etd.md deleted file mode 100644 index c874cf5..0000000 --- a/docs/superpowers/plans/2026-06-19-etd.md +++ /dev/null @@ -1,597 +0,0 @@ -# Estimated Time to Depletion (ETD) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Append an "estimated time to depletion" suffix (e.g. `· 45m ETD`) to each usage cell when the user is on pace to deplete a quota before its window resets. - -**Architecture:** A pure `etd_secs()` math function plus an `etd_suffix()` formatter in `src/poller.rs`, fed by the data already available at the cell-formatting site. A `show_etd` setting is plumbed through `src/window.rs` exactly like the existing `show_codex` bool, exposed as a Settings-submenu checkbox (default OFF), and consumed in `refresh_usage_texts`. Two new localized strings. - -**Tech Stack:** Rust 2021, `serde`/`serde_json` for settings, the `windows` crate for the Win32 menu, a flat `Strings` struct of `&'static str` for localization. - -## Global Constraints - -- Branch off clean `main` (v1.4.3); do **not** depend on the detailed-remaining or pace-indicator branches. -- Menu ID for ETD is exactly `74` (avoids the 70–73 used by the other branches). -- ETD window constants live in `src/poller.rs` (NOT `src/window.rs`). -- ETD duration uses the existing `format_countdown_from_secs` (coarse, single-unit). Do not add a multi-unit formatter. -- `show_etd` defaults to `false` (OFF). -- Do not reference any upstream issue in commit messages or PR text. -- Middle-dot separator is the Unicode escape `\u{00b7}` (matches `format_line`). -- Each task ends green: `cargo build` succeeds, and where tests exist, `cargo test` passes. - ---- - -### Task 1: ETD core math + first test module - -**Files:** -- Modify: `src/poller.rs` (append at end of file, after `app_is_past_reset`, currently ending line 1099) -- Test: `src/poller.rs` (inline `#[cfg(test)] mod tests`) - -**Interfaces:** -- Produces: `pub const SESSION_WINDOW_SECS: u64`, `pub const WEEKLY_WINDOW_SECS: u64`, and `fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option` (module-private; tested inline). - -- [ ] **Step 1: Write the failing tests** - -Append to the end of `src/poller.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn etd_none_when_on_safe_pace() { - // 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h - // is 20%, so 10% is UNDER pace → would not deplete before reset. - // (50% in the first hour would be at-risk, not safe — see the invariant.) - assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None); - } - - #[test] - fn etd_some_when_at_risk() { - // 60% used, 1h elapsed of a 2h window (1h remaining). - // Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk. - assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400)); - } - - #[test] - fn etd_none_at_boundaries() { - assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used - assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full - assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0 - assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining - assert_eq!(etd_secs(50.0, 3600, 0), None); // no window - } - - #[test] - fn etd_invariant_matches_at_risk_rule() { - // etd_secs is Some iff burn rate exceeds steady pace: - // actual_pct > 100 * elapsed / window. - // Skip a small band around the exact boundary to avoid float flakiness. - let window = 5 * 3600u64; - for remaining in (0..=window).step_by(600) { - let elapsed = window - remaining; - for pct_x10 in 1..1000u64 { - let actual = pct_x10 as f64 / 10.0; - if elapsed == 0 || remaining == 0 { - assert_eq!(etd_secs(actual, remaining, window), None); - continue; - } - let boundary = 100.0 * elapsed as f64 / window as f64; - if (actual - boundary).abs() < 0.05 { - continue; // razor's edge — covered by explicit boundary test - } - let at_risk = actual > boundary; - assert_eq!( - etd_secs(actual, remaining, window).is_some(), - at_risk, - "actual={actual} remaining={remaining} window={window}" - ); - } - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test --lib etd_ 2>&1 | tail -20` (or `cargo test etd_`) -Expected: FAIL — `cannot find function `etd_secs` in this scope` (compile error). - -- [ ] **Step 3: Write the constants and `etd_secs`** - -Append to `src/poller.rs` *above* the `#[cfg(test)]` module: - -```rust -/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here, -/// next to the formatting that consumes them, so the ETD feature is -/// self-contained and does not depend on constants defined elsewhere. -pub const SESSION_WINDOW_SECS: u64 = 5 * 3600; -pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400; - -/// Estimated seconds until the quota is fully consumed, assuming the current -/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the -/// projection lands *before* the window resets — i.e. only when the user is -/// genuinely on pace to deplete early. -fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { - if actual_pct <= 0.0 || actual_pct >= 100.0 { - return None; - } - if remaining_secs == 0 || window_secs == 0 { - return None; - } - let elapsed_secs = window_secs.saturating_sub(remaining_secs); - if elapsed_secs == 0 { - return None; - } - let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; - if !secs.is_finite() || secs < 0.0 { - return None; - } - let secs = secs as u64; - (secs < remaining_secs).then_some(secs) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test etd_ 2>&1 | tail -20` -Expected: PASS — 4 tests pass. (A `dead_code` warning on `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS`/`etd_secs` is expected until later tasks consume them; it is not an error.) - -- [ ] **Step 5: Commit** - -```bash -git add src/poller.rs -git commit -m "feat: add ETD projection math with unit tests" -``` - ---- - -### Task 2: Localization strings - -**Files:** -- Modify: `src/localization/mod.rs:179` (add two fields to `Strings`, after `codex_window_title`) -- Modify all 10 language files: `src/localization/english.rs`, `dutch.rs`, `spanish.rs`, `french.rs`, `german.rs`, `japanese.rs`, `korean.rs`, `traditional_chinese.rs`, `russian.rs`, `portuguese_brazil.rs` - -**Interfaces:** -- Produces: `Strings::show_etd` and `Strings::etd_suffix` (both `&'static str`), available to later tasks. - -- [ ] **Step 1: Add the fields to the `Strings` struct** - -In `src/localization/mod.rs`, the `Strings` struct ends at line 180 with `pub codex_window_title: &'static str,` on line 179. Add immediately after it: - -```rust - pub show_etd: &'static str, - pub etd_suffix: &'static str, -``` - -- [ ] **Step 2: Verify it fails to compile** - -Run: `cargo build 2>&1 | tail -20` -Expected: FAIL — `missing field `show_etd` in initializer of `Strings`` (one error per language file). - -- [ ] **Step 3: Fill in every language file** - -Add these two lines to the `Strings { ... }` initializer in each file (place them next to the existing `codex_window_title` line). Use these values: - -`english.rs`: -```rust - show_etd: "Show ETD", - etd_suffix: "ETD", -``` -`dutch.rs`: -```rust - show_etd: "Toon ETD", - etd_suffix: "ETD", -``` -`spanish.rs`: -```rust - show_etd: "Mostrar ETD", - etd_suffix: "ETD", -``` -`french.rs`: -```rust - show_etd: "Afficher l'ETD", - etd_suffix: "ETD", -``` -`german.rs`: -```rust - show_etd: "ETD anzeigen", - etd_suffix: "ETD", -``` -`japanese.rs`: -```rust - show_etd: "ETD を表示", - etd_suffix: "ETD", -``` -`korean.rs`: -```rust - show_etd: "ETD 표시", - etd_suffix: "ETD", -``` -`traditional_chinese.rs`: -```rust - show_etd: "顯示 ETD", - etd_suffix: "ETD", -``` -`russian.rs`: -```rust - show_etd: "Показывать ETD", - etd_suffix: "ETD", -``` -`portuguese_brazil.rs`: -```rust - show_etd: "Mostrar ETD", - etd_suffix: "ETD", -``` - -- [ ] **Step 4: Verify it compiles** - -Run: `cargo build 2>&1 | tail -20` -Expected: SUCCESS (warnings about unused `show_etd`/`etd_suffix` are fine until later tasks). - -- [ ] **Step 5: Commit** - -```bash -git add src/localization/ -git commit -m "feat: add ETD localization strings" -``` - ---- - -### Task 3: ETD suffix formatter - -**Files:** -- Modify: `src/poller.rs` (add `etd_suffix` next to `etd_secs`) -- Test: `src/poller.rs` (extend the `tests` module) - -**Interfaces:** -- Consumes: `etd_secs` (Task 1), `format_countdown_from_secs` (existing private fn), `Strings::etd_suffix` (Task 2). -- Produces: `pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option` returning a string like `" \u{00b7} 45m ETD"`, or `None` when not at risk. - -- [ ] **Step 1: Write the failing tests** - -Add inside `mod tests` in `src/poller.rs`: - -```rust - use crate::localization::LanguageId; - use std::time::Duration; - - fn section(pct: f64, remaining: Duration) -> UsageSection { - UsageSection { - percentage: pct, - resets_at: Some(SystemTime::now() + remaining), - } - } - - #[test] - fn etd_suffix_present_when_at_risk() { - // 60% used, 1h remaining of a 2h window → at risk. - let s = section(60.0, Duration::from_secs(3600)); - let out = etd_suffix(&s, 2 * 3600, LanguageId::English.strings()); - let out = out.expect("expected a suffix when at risk"); - assert!(out.contains("ETD"), "suffix was: {out}"); - assert!(out.starts_with(" \u{00b7} "), "suffix was: {out}"); - } - - #[test] - fn etd_suffix_absent_when_safe() { - // 10% used, 4h remaining of a 5h window → safe. - let s = section(10.0, Duration::from_secs(4 * 3600)); - assert_eq!(etd_suffix(&s, 5 * 3600, LanguageId::English.strings()), None); - } - - #[test] - fn etd_suffix_absent_without_reset() { - let s = UsageSection { percentage: 60.0, resets_at: None }; - assert_eq!(etd_suffix(&s, 2 * 3600, LanguageId::English.strings()), None); - } -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test etd_suffix 2>&1 | tail -20` -Expected: FAIL — `cannot find function `etd_suffix``. - -- [ ] **Step 3: Implement `etd_suffix`** - -In `src/poller.rs`, immediately after `etd_secs`, add: - -```rust -/// The trailing " · 45m ETD" segment for a usage cell, or `None` when the -/// section is not on pace to deplete before reset. Reuses the same coarse, -/// single-unit duration format as the countdown. -pub fn etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option { - let reset = section.resets_at?; - let remaining_secs = reset.duration_since(SystemTime::now()).ok()?.as_secs(); - let secs = etd_secs(section.percentage, remaining_secs, window_secs)?; - let dur = format_countdown_from_secs(secs, strings); - Some(format!(" \u{00b7} {dur} {}", strings.etd_suffix)) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test etd 2>&1 | tail -20` -Expected: PASS — all ETD tests pass (`etd_secs` is now used, so its dead-code warning clears; `SESSION_WINDOW_SECS`/`WEEKLY_WINDOW_SECS` may still warn until Task 5). - -- [ ] **Step 5: Commit** - -```bash -git add src/poller.rs -git commit -m "feat: add ETD suffix formatter" -``` - ---- - -### Task 4: `show_etd` setting plumbing - -**Files:** -- Modify: `src/window.rs` — `AppState` struct (after line 67 `show_codex: bool,`), `SettingsFile` (after line 217), `SettingsFile::default` (after line 229), `save_state_settings` (after line 284), `AppState` init (after line 1017). - -**Interfaces:** -- Produces: `AppState.show_etd: bool` and `SettingsFile.show_etd: bool`, persisted to `settings.json`, defaulting to `false`. Consumed by Task 5. - -- [ ] **Step 1: Add the `AppState` field** - -In `src/window.rs`, after line 67 (` show_codex: bool,`) add: - -```rust - show_etd: bool, -``` - -- [ ] **Step 2: Add the `SettingsFile` field** - -After line 217 (` show_codex: bool,` inside `struct SettingsFile`) add: - -```rust - #[serde(default)] - show_etd: bool, -``` - -- [ ] **Step 3: Add to `SettingsFile::default`** - -After line 229 (` show_codex: false,`) add: - -```rust - show_etd: false, -``` - -- [ ] **Step 4: Add to `save_state_settings`** - -After line 284 (` show_codex: s.show_codex,`) add: - -```rust - show_etd: s.show_etd, -``` - -- [ ] **Step 5: Add to the `AppState` initializer** - -After line 1017 (` show_codex: settings.show_codex,`) add: - -```rust - show_etd: settings.show_etd, -``` - -- [ ] **Step 6: Verify it compiles** - -Run: `cargo build 2>&1 | tail -20` -Expected: SUCCESS (an unused-field warning on `show_etd` is expected until Task 5 reads it). - -- [ ] **Step 7: Commit** - -```bash -git add src/window.rs -git commit -m "feat: persist show_etd setting" -``` - ---- - -### Task 5: Menu toggle, command handler, and cell wiring - -**Files:** -- Modify: `src/window.rs` — menu ID const (after line 125), `show_context_menu` destructure (lines 2334–2335 and the `None` fallback 2357–2359), Settings-submenu item (after the reset-position item, line 2463), `WM_COMMAND` handler (after the `IDM_MODEL_*` arm, line 2254), and `refresh_usage_texts` (lines 422–436). - -**Interfaces:** -- Consumes: `poller::etd_suffix`, `poller::SESSION_WINDOW_SECS`, `poller::WEEKLY_WINDOW_SECS` (Tasks 1+3), `AppState.show_etd` (Task 4), `Strings::show_etd` (Task 2). - -- [ ] **Step 1: Add the menu ID constant** - -In `src/window.rs`, after line 125 (`const IDM_MODEL_CODEX: u16 = 61;`) add: - -```rust -const IDM_SHOW_ETD: u16 = 74; -``` - -- [ ] **Step 2: Append ETD to the cell text in `refresh_usage_texts`** - -Replace the body block at lines 422–436 (the two `if let Some(...)` blocks) with: - -```rust - if let Some(claude_code) = data.claude_code.as_ref() { - state.session_text = poller::format_line(&claude_code.session, strings); - state.weekly_text = poller::format_line(&claude_code.weekly, strings); - if state.show_etd { - if let Some(s) = - poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings) - { - state.session_text.push_str(&s); - } - if let Some(s) = - poller::etd_suffix(&claude_code.weekly, poller::WEEKLY_WINDOW_SECS, strings) - { - state.weekly_text.push_str(&s); - } - } - } else if state.show_claude_code { - state.session_text = "!".to_string(); - state.weekly_text = "!".to_string(); - } - - if let Some(codex) = data.codex.as_ref() { - state.codex_session_text = poller::format_line(&codex.session, strings); - state.codex_weekly_text = poller::format_line(&codex.weekly, strings); - if state.show_etd { - if let Some(s) = - poller::etd_suffix(&codex.session, poller::SESSION_WINDOW_SECS, strings) - { - state.codex_session_text.push_str(&s); - } - if let Some(s) = - poller::etd_suffix(&codex.weekly, poller::WEEKLY_WINDOW_SECS, strings) - { - state.codex_weekly_text.push_str(&s); - } - } - } else if state.show_codex { - state.codex_session_text = "!".to_string(); - state.codex_weekly_text = "!".to_string(); - } -``` - -- [ ] **Step 3: Surface `show_etd` in `show_context_menu`** - -(a) In the destructure tuple, after line 2335 (` show_codex,`) add: - -```rust - show_etd, -``` - -(b) In the `Some(s) => ( ... )` arm, after line 2348 (` s.show_codex,`) add: - -```rust - s.show_etd, -``` - -(c) In the `None => ( ... )` fallback, after line 2359 (` false,` — the `show_codex` fallback) add another: - -```rust - false, -``` - -- [ ] **Step 4: Add the Settings-submenu checkbox** - -In `show_context_menu`, after the reset-position `AppendMenuW` block (ends line 2463) add: - -```rust - let etd_str = native_interop::wide_str(strings.show_etd); - let etd_flags = if show_etd { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }; - let _ = AppendMenuW( - settings_menu, - etd_flags, - IDM_SHOW_ETD as usize, - PCWSTR::from_raw(etd_str.as_ptr()), - ); -``` - -- [ ] **Step 5: Add the `WM_COMMAND` handler arm** - -In the `WM_COMMAND` match, after the `IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX => { ... }` arm (ends line 2254) add: - -```rust - IDM_SHOW_ETD => { - { - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.show_etd = !s.show_etd; - refresh_usage_texts(s); - } - } - save_state_settings(); - render_layered(); - } -``` - -- [ ] **Step 6: Verify it compiles cleanly** - -Run: `cargo build 2>&1 | tail -20` -Expected: SUCCESS, no `dead_code` / unused warnings for any ETD symbol (all are now consumed). - -- [ ] **Step 7: Run the full test suite** - -Run: `cargo test 2>&1 | tail -20` -Expected: PASS — all ETD tests green. - -- [ ] **Step 8: Manual verification** - -Run: `cargo run` (or build a release and launch). Then: -1. Right-click the widget → Settings. Confirm a `Show ETD` item exists, unchecked (default OFF). -2. Click it → it becomes checked. -3. With a Claude/Codex cell currently over pace, confirm the cell text gains a ` · ETD` suffix; for cells on safe pace, confirm no suffix. -4. Uncheck `Show ETD` → suffixes disappear. -5. Restart the app → the toggle state persisted (read back from `settings.json`). - -Document the observed result (pass/fail per step) in the task notes. - -- [ ] **Step 9: Commit** - -```bash -git add src/window.rs -git commit -m "feat: add Show ETD toggle and wire ETD into usage cells" -``` - ---- - -### Task 6: README + screenshot - -**Files:** -- Modify: `README.md` -- Create: `.github/screenshots/etd.png` (a captured screenshot of an at-risk cell showing the suffix) - -**Interfaces:** none (docs only). - -- [ ] **Step 1: Capture a screenshot** - -With `Show ETD` enabled and a cell at risk, capture the widget showing `… · ETD` and save it to `.github/screenshots/etd.png`. (Match the dimensions/style of the existing screenshots in that folder.) - -- [ ] **Step 2: Add a README paragraph** - -Find the existing screenshots/features section in `README.md` (where the other feature screenshots are referenced) and add, in the same style: - -```markdown -### Estimated Time to Depletion (ETD) - -When you're on pace to use up a quota before its window resets, each affected -usage cell shows an estimate of how long that will take — e.g. `50% · 2h · 45m ETD`. -Enable it from the tray menu under **Settings → Show ETD** (off by default). - -![ETD](.github/screenshots/etd.png) -``` - -- [ ] **Step 3: Verify the link resolves** - -Run: `test -f .github/screenshots/etd.png && echo OK` -Expected: `OK`. - -- [ ] **Step 4: Commit** - -```bash -git add README.md .github/screenshots/etd.png -git commit -m "docs: document the ETD indicator" -``` - ---- - -## Self-Review - -**1. Spec coverage:** -- §4 "When shown / format / scope / setting" → Tasks 1, 3, 5. ✓ -- §5 computation + invariant → Task 1 (incl. invariant test). ✓ -- §6 architecture (poller helpers, window plumbing, localization) → Tasks 1–5. ✓ -- §7 changes-by-file → every listed file appears in a task (poller, mod.rs, 10 lang files, window.rs, README). ✓ -- §8 testing (first `#[cfg(test)]`, 5+ tests) → Tasks 1 & 3. ✓ -- Default OFF → Task 4 Step 3 + Task 5 Step 4 flags. ✓ -- Independence/compatibility constraints (ID 74, constants in poller, coarse format) → Global Constraints + Tasks 1, 5. ✓ - -**2. Placeholder scan:** No "TBD"/"add error handling"/"similar to" — all code shown inline. The only deferred artifact is the binary screenshot in Task 6, which is captured manually by definition. ✓ - -**3. Type consistency:** -- `etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option` — same signature in Task 1 def and Task 3 call. ✓ -- `etd_suffix(section: &UsageSection, window_secs: u64, strings: Strings) -> Option` — Task 3 def matches Task 5 call sites (`poller::etd_suffix(&claude_code.session, poller::SESSION_WINDOW_SECS, strings)`). ✓ -- `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` are `pub` (Task 1) so Task 5's `poller::` references resolve. ✓ -- `Strings::show_etd` / `Strings::etd_suffix` defined in Task 2, used in Tasks 3 & 5. ✓ -- `IDM_SHOW_ETD = 74` defined Task 5 Step 1, used Steps 4 & 5. ✓ diff --git a/docs/superpowers/specs/2026-06-19-etd-design.md b/docs/superpowers/specs/2026-06-19-etd-design.md deleted file mode 100644 index c4685f8..0000000 --- a/docs/superpowers/specs/2026-06-19-etd-design.md +++ /dev/null @@ -1,122 +0,0 @@ -# Estimated Time to Depletion (ETD) — Design - -- **Date:** 2026-06-19 -- **Branch:** `feat/etd`, branched off **clean `main`** (v1.4.3) -- **Status:** Approved (design); plan written -- **Independence:** Self-contained. Does **not** depend on the "Show detailed remaining time" or pace-indicator ("colored") branches, and is mergeable alongside them with only trivial textual conflicts. -- **PR policy:** Do not reference any upstream issue in PR title, description, or commit messages. - -## 1. Problem - -Users have to mentally extrapolate whether they will exhaust their session/weekly quota before the rolling window resets. ETD answers that quantitatively: "if you keep this rate, you run out in ~45m." - -## 2. Goal - -When the user is on pace to deplete a quota *before* its window resets, append a short estimate to that cell's usage text. When on safe pace, append nothing. - -## 3. Independence & compatibility - -This feature branches off clean `main`, where: - -- Cell text is built by `poller::format_line(section, strings)` → `"50% · 2h"` (percentage + **single-unit** countdown). -- There are **no** window-length constants, no `expected_pace_pct`, and no `show_detailed_remaining` / `show_pace_indicator` settings — those all live only on the author's other two feature branches. - -Consequences, chosen to keep ETD independent yet compatible: - -| Concern | Decision | -|---|---| -| Duration precision | Reuse `main`'s native **coarse single-unit** countdown formatter (`format_countdown_from_secs`). No dependency on the detailed-remaining toggle. | -| Window constants | ETD defines its own `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` in **`poller.rs`** (the pace branch defines same-named constants in `window.rs` — different modules, so no symbol clash on merge). | -| Menu ID | `IDM_SHOW_ETD = 74` (the other branches use 70–73; no numeric clash). | -| Struct/localization fields | Added additively; a three-way merge yields only trivial field-list conflicts. | - -## 4. User-facing behavior - -| Aspect | Behavior | -|---|---| -| **When shown** | Only when projected depletion is *before* the window resets (the at-risk case). | -| **What's shown** | A suffix appended to the cell's existing text, after a middle-dot separator: on base `main` an at-risk cell reads **`50% · 2h · 45m ETD`**. | -| **Format** | The ETD duration uses the same coarse single-unit formatter as the countdown (`45m`, `2h`, `3d`). The trailing word comes from a localized `etd_suffix` string (English `"ETD"`). | -| **Scope** | All four cells: Claude session, Claude weekly, Codex session, Codex weekly. | -| **Setting** | New `Show ETD` checkbox in the Settings submenu. **Default OFF** (opt-in). | -| **Compatibility note** | If the detailed-remaining branch is also merged, the left segment becomes richer (e.g. `50% · 4h 32m`) automatically; the ETD suffix is unaffected. | - -## 5. Computation - -Pure function over three numbers already available at the formatting site: - -```rust -/// Estimated seconds to full depletion at the current burn rate -/// (quota-so-far / time-so-far). `None` unless depletion lands before reset. -fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option { - if actual_pct <= 0.0 || actual_pct >= 100.0 { return None; } - if remaining_secs == 0 || window_secs == 0 { return None; } - let elapsed_secs = window_secs.saturating_sub(remaining_secs); - if elapsed_secs == 0 { return None; } - let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct; - if !secs.is_finite() || secs < 0.0 { return None; } - let secs = secs as u64; - (secs < remaining_secs).then_some(secs) -} -``` - -### Invariant - -`etd_secs` returns `Some` **iff** `actual_pct > 100 * elapsed / window` — i.e. the current burn rate exceeds steady-state pace. (Because `remaining` is an integer, the `as u64` truncation does not move the boundary versus the exact float comparison.) This is the "at-risk" rule, and a free correctness check. - -### Edge cases - -| Condition | Behavior | -|---|---| -| `show_etd` off | Helper not called; nothing appended. | -| `resets_at` is `None` | No reset timestamp → nothing appended. | -| `actual_pct <= 0` or `>= 100` | `None` → nothing appended. | -| `elapsed_secs == 0` (just started) | `None` → nothing appended. | -| Not at risk (`secs >= remaining_secs`) | `None` → nothing appended. | - -## 6. Architecture - -- **`poller.rs`** gains: `SESSION_WINDOW_SECS` / `WEEKLY_WINDOW_SECS` (pub), private `etd_secs`, and pub `etd_suffix(section, window_secs, strings) -> Option` returning `" · 45m ETD"`. `etd_suffix` reuses the existing private `format_countdown_from_secs`. A `#[cfg(test)] mod tests` (the repo's first) covers `etd_secs`. -- **`window.rs`** gains: the `show_etd` bool plumbed exactly like the existing `show_codex` / `widget_visible` (SettingsFile + Default + save_state_settings + AppState + AppState init), `IDM_SHOW_ETD = 74`, a Settings-submenu checkbox, a `WM_COMMAND` arm, and append calls in `refresh_usage_texts`. -- **`localization/`** gains two `Strings` fields — `show_etd` (menu label) and `etd_suffix` (the trailing word) — filled in all 10 language files. - -### Rejected alternatives - -- **Change `format_line`'s signature** to take `window_secs`/`show_etd`. Rejected: widens blast radius and couples the window-agnostic formatter to ETD; appending via `etd_suffix` at the 4 call sites is more isolated. -- **Reuse the pace branch's constants/helpers.** Rejected: that is exactly the dependency the independence requirement forbids. - -## 7. Changes by file - -| File | Change | -|---|---| -| `src/poller.rs` | `SESSION_WINDOW_SECS`, `WEEKLY_WINDOW_SECS`, `etd_secs`, `etd_suffix`, `#[cfg(test)] mod tests`. | -| `src/localization/mod.rs` | Two new `Strings` fields: `show_etd`, `etd_suffix`. | -| `src/localization/{english,dutch,spanish,french,german,japanese,korean,traditional_chinese,russian,portuguese_brazil}.rs` | Fill in the two fields (10 files). | -| `src/window.rs` | `show_etd` plumbing, `IDM_SHOW_ETD`, menu item, command handler, `refresh_usage_texts` append. | -| `README.md` | One paragraph + one screenshot, matching the existing screenshots section. | - -## 8. Testing (TDD) - -The repo currently has **no tests**; this introduces the first `#[cfg(test)]` module (no Cargo changes — `cargo test` picks up inline unit tests). The pure functions are platform-independent; `cargo test` compiles the whole crate, which builds on Windows (the dev platform). - -1. `etd_none_when_on_safe_pace` -2. `etd_some_when_at_risk` -3. `etd_none_at_boundaries` — 0%, 100%, elapsed=0, remaining=0, window=0 -4. `etd_invariant_matches_at_risk_rule` — property sweep, skipping the razor's-edge band to avoid float-boundary flakiness -5. `etd_suffix_present_when_at_risk` / `etd_suffix_absent_when_safe` — build a `UsageSection` with `resets_at = now + remaining` - -UI / Win32 menu code stays manually verified (build, run, toggle on, drive an at-risk cell, confirm the suffix appears and disappears). - -## 9. Risks - -- **Estimate jitter early in a window.** Suppressed by the at-risk gate; a minimum-elapsed gate is a possible future refinement (not in v1). -- **Translation literalness.** Missing a `Strings` field in any language file is a compile error, so nothing ships untranslated; initial translations may keep `"ETD"` verbatim until refined. -- **Default OFF → low discoverability.** Accepted; conservative first ship, flip later if desired. - -## 10. Out of scope - -- Configurable burn-rate smoothing window. -- Per-message rate (would need new poller data). -- Threshold notifications. -- Tooltip / hover variant. -- Absolute clock-time display. From ddee8d9f9b929553d756ed89eadd1f4f66899aee Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Fri, 19 Jun 2026 20:17:05 +0800 Subject: [PATCH 12/13] refactor: read show_etd from state for merge compatibility Revert shared window.rs draw/width signatures to base; read show_etd via a state-lock accessor and share a lock-free text-width slot mechanism with the detailed-remaining feature (identical block, distinct per-feature wiring). Place ETD's struct fields, menu ID, and Settings menu item at anchors that are non-adjacent to the other display features so any merge subset is conflict-free. Keeps poller's format_countdown_from_secs at its base 2-arg signature. --- src/window.rs | 171 +++++++++++++++++++++++++++----------------------- 1 file changed, 92 insertions(+), 79 deletions(-) diff --git a/src/window.rs b/src/window.rs index 4eb0125..545ba43 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; use std::sync::{Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -65,7 +65,6 @@ struct AppState { codex_weekly_text: String, show_claude_code: bool, show_codex: bool, - show_etd: bool, data: Option, @@ -85,6 +84,7 @@ struct AppState { drag_start_offset: i32, widget_visible: bool, + show_etd: bool, } #[derive(Clone, Debug)] @@ -123,8 +123,8 @@ const IDM_LANG_TRADITIONAL_CHINESE: u16 = 48; const IDM_LANG_RUSSIAN: u16 = 49; const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; -const IDM_MODEL_CODEX: u16 = 61; const IDM_SHOW_ETD: u16 = 74; +const IDM_MODEL_CODEX: u16 = 61; const DIVIDER_HIT_ZONE: i32 = 13; // LEFT_DIVIDER_W + DIVIDER_RIGHT_MARGIN @@ -213,12 +213,12 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default)] + show_etd: bool, #[serde(default = "default_show_claude_code")] show_claude_code: bool, #[serde(default = "default_show_codex")] show_codex: bool, - #[serde(default)] - show_etd: bool, } impl Default for SettingsFile { @@ -229,9 +229,9 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + show_etd: false, show_claude_code: true, show_codex: false, - show_etd: false, } } } @@ -285,9 +285,9 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + show_etd: s.show_etd, show_claude_code: s.show_claude_code, show_codex: s.show_codex, - show_etd: s.show_etd, }); } } @@ -847,27 +847,45 @@ const DIVIDER_RIGHT_MARGIN: i32 = 10; const LABEL_WIDTH: i32 = 18; const LABEL_RIGHT_MARGIN: i32 = 10; const BAR_RIGHT_MARGIN: i32 = 4; -const TEXT_WIDTH: i32 = 62; // fits the worst-case "100% · 59m" +const TEXT_WIDTH: i32 = 62; +const MODEL_RIGHT_MARGIN: i32 = 5; +const RIGHT_MARGIN: i32 = 1; +const WIDGET_HEIGHT: i32 = 46; /// Text column when the ETD suffix is shown. The ETD form is worst-case /// "100% · 59m rem · 59m ETD" — the base "100% · 59m" plus a " rem · 59m ETD" /// label-and-estimate of similar length — so the column is sized to fit the /// widest possible remaining-time and ETD (~2.6x the base) without clipping. const ETD_TEXT_WIDTH: i32 = 160; -const MODEL_RIGHT_MARGIN: i32 = 5; -const RIGHT_MARGIN: i32 = 1; -const WIDGET_HEIGHT: i32 = 46; fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } -/// Text-column width for a usage cell, widened when the ETD suffix is shown. -fn text_width(show_etd: bool) -> i32 { - if show_etd { - ETD_TEXT_WIDTH - } else { - TEXT_WIDTH - } +// Optional display features may widen the usage text column. Each feature +// stores its own extra width (in base 96-DPI px) in a dedicated slot, read +// lock-free by the width/layout helpers below. Slots are summed so multiple +// features stack. This block is identical across the display-feature branches +// so they merge without conflict; a branch only wires up its own slot. +#[allow(dead_code)] +static EXTRA_TEXT_WIDTH_DETAILED: AtomicI32 = AtomicI32::new(0); +#[allow(dead_code)] +static EXTRA_TEXT_WIDTH_ETD: AtomicI32 = AtomicI32::new(0); + +#[allow(dead_code)] +fn set_extra_text_width_detailed(px: i32) { + EXTRA_TEXT_WIDTH_DETAILED.store(px, Ordering::Relaxed); +} + +#[allow(dead_code)] +fn set_extra_text_width_etd(px: i32) { + EXTRA_TEXT_WIDTH_ETD.store(px, Ordering::Relaxed); +} + +/// Usage text-column width including any enabled display-feature widening. +fn effective_text_width() -> i32 { + TEXT_WIDTH + + EXTRA_TEXT_WIDTH_DETAILED.load(Ordering::Relaxed) + + EXTRA_TEXT_WIDTH_ETD.load(Ordering::Relaxed) } fn row_bar_segment_count(active_models: i32) -> i32 { @@ -878,11 +896,11 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } -fn total_widget_width_for(active_models: i32, show_etd: bool) -> i32 { +fn total_widget_width_for(active_models: i32) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(text_width(show_etd)); + + sc(effective_text_width()); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -893,22 +911,22 @@ fn total_widget_width_for(active_models: i32, show_etd: bool) -> i32 { + sc(RIGHT_MARGIN) } -fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for( - active_model_count(state.show_claude_code, state.show_codex), - state.show_etd, - ) -} - fn total_widget_width() -> i32 { - let (active_models, show_etd) = { + let active_models = { let state = lock_state(); state .as_ref() - .map(|s| (active_model_count(s.show_claude_code, s.show_codex), s.show_etd)) - .unwrap_or((1, false)) + .map(|s| active_model_count(s.show_claude_code, s.show_codex)) + .unwrap_or(1) }; - total_widget_width_for(active_models, show_etd) + total_widget_width_for(active_models) +} + +/// Whether the ETD suffix is enabled, read from shared state. Returns false +/// when state is not yet populated (startup) or the lock cannot be acquired. +/// Callers must not hold the state lock. +fn show_etd_enabled() -> bool { + lock_state().as_ref().map_or(false, |s| s.show_etd) } fn claude_accent_color() -> Color { @@ -1009,7 +1027,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count, settings.show_etd), + total_widget_width_for(initial_model_count), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1062,7 +1080,6 @@ pub fn run() { codex_weekly_text: "--".to_string(), show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, - show_etd: settings.show_etd, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1078,6 +1095,7 @@ pub fn run() { drag_start_mouse_x: 0, drag_start_offset: 0, widget_visible: settings.widget_visible, + show_etd: settings.show_etd, }); } @@ -1114,6 +1132,14 @@ pub fn run() { diagnose::log("taskbar not found; using fallback popup window"); } + // Seed the ETD text-column widening so the initial layout matches the + // wider draw rect when the ETD suffix is enabled at startup. + set_extra_text_width_etd(if settings.show_etd { + ETD_TEXT_WIDTH - TEXT_WIDTH + } else { + 0 + }); + // If not embedded, fall back to topmost popup with SetLayeredWindowAttributes if !embedded { let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA); @@ -1202,7 +1228,6 @@ fn render_layered() { codex_weekly_text, show_claude_code, show_codex, - show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -1221,7 +1246,6 @@ fn render_layered() { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, - s.show_etd, ), None => return, } @@ -1311,7 +1335,6 @@ fn render_layered() { &codex_weekly_text, show_claude_code, show_codex, - show_etd, &codex_accent, ); @@ -1382,7 +1405,6 @@ fn paint_content( codex_weekly_text: &str, show_claude_code: bool, show_codex: bool, - show_etd: bool, codex_accent: &Color, ) { unsafe { @@ -1473,7 +1495,6 @@ fn paint_content( codex_session_text, show_claude_code, show_codex, - show_etd, accent, codex_accent, track, @@ -1491,7 +1512,6 @@ fn paint_content( codex_weekly_text, show_claude_code, show_codex, - show_etd, accent, codex_accent, track, @@ -2068,6 +2088,9 @@ unsafe extern "system" fn wnd_proc( if is_dragging { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); + // Compute the widget width before locking; the width helpers read + // shared state themselves and must not run while the lock is held. + let widget_width = total_widget_width(); let move_target = { let mut state = lock_state(); let s = match state.as_mut() { @@ -2101,7 +2124,6 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width_for_state(s); let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); if new_offset > max_offset { new_offset = max_offset; @@ -2311,10 +2333,16 @@ unsafe extern "system" fn wnd_proc( let mut state = lock_state(); if let Some(s) = state.as_mut() { s.show_etd = !s.show_etd; + set_extra_text_width_etd(if s.show_etd { + ETD_TEXT_WIDTH - TEXT_WIDTH + } else { + 0 + }); refresh_usage_texts(s); } } save_state_settings(); + position_at_taskbar(); render_layered(); } IDM_LANG_SYSTEM @@ -2398,7 +2426,6 @@ fn show_context_menu(hwnd: HWND) { widget_visible, show_claude_code, show_codex, - show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -2412,7 +2439,6 @@ fn show_context_menu(hwnd: HWND) { s.widget_visible, s.show_claude_code, s.show_codex, - s.show_etd, ), None => ( POLL_15_MIN, @@ -2424,7 +2450,6 @@ fn show_context_menu(hwnd: HWND) { true, true, false, - false, ), } }; @@ -2530,19 +2555,6 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(reset_pos_str.as_ptr()), ); - let etd_str = native_interop::wide_str(strings.show_etd); - let etd_flags = if show_etd { - MF_CHECKED - } else { - MENU_ITEM_FLAGS(0) - }; - let _ = AppendMenuW( - settings_menu, - etd_flags, - IDM_SHOW_ETD as usize, - PCWSTR::from_raw(etd_str.as_ptr()), - ); - let language_menu = CreatePopupMenu().unwrap(); let system_label = native_interop::wide_str(strings.system_default); let system_flags = if language_override.is_none() { @@ -2592,6 +2604,19 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(language_label.as_ptr()), ); + let etd_str = native_interop::wide_str(strings.show_etd); + let etd_flags = if show_etd_enabled() { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + settings_menu, + etd_flags, + IDM_SHOW_ETD as usize, + PCWSTR::from_raw(etd_str.as_ptr()), + ); + let _ = AppendMenuW(settings_menu, MF_SEPARATOR, 0, PCWSTR::null()); let version_label = @@ -2666,7 +2691,6 @@ fn paint(hdc: HDC, hwnd: HWND) { codex_weekly_text, show_claude_code, show_codex, - show_etd, ) = { let state = lock_state(); match state.as_ref() { @@ -2683,7 +2707,6 @@ fn paint(hdc: HDC, hwnd: HWND) { s.codex_weekly_text.clone(), s.show_claude_code, s.show_codex, - s.show_etd, ), None => return, } @@ -2741,7 +2764,6 @@ fn paint(hdc: HDC, hwnd: HWND) { &codex_weekly_text, show_claude_code, show_codex, - show_etd, &codex_accent, ); @@ -2766,7 +2788,6 @@ fn draw_row( codex_text: &str, show_claude_code: bool, show_codex: bool, - show_etd: bool, claude_accent: &Color, codex_accent: &Color, track: &Color, @@ -2814,9 +2835,8 @@ fn draw_row( claude_accent, track, &claude_value_color, - show_etd, ); - model_x += model_usage_width(segment_count, show_etd) + sc(MODEL_RIGHT_MARGIN); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); } if show_codex { draw_usage_bar( @@ -2829,16 +2849,15 @@ fn draw_row( codex_accent, track, &codex_value_color, - show_etd, ); } } } -fn model_usage_width(segment_count: i32, show_etd: bool) -> i32 { +fn model_usage_width(segment_count: i32) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(text_width(show_etd)) + + sc(effective_text_width()) } fn draw_usage_bar( @@ -2851,7 +2870,6 @@ fn draw_usage_bar( accent: &Color, track: &Color, text_color: &Color, - show_etd: bool, ) { let seg_w = sc(SEGMENT_W); let seg_h = sc(SEGMENT_H); @@ -2912,7 +2930,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(text_width(show_etd)), + right: text_x + sc(effective_text_width()), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -2950,20 +2968,15 @@ mod tests { fn etd_text_column_is_wider_than_base() { // Sized for the worst case "100% · 59m rem · 59m ETD". assert!(ETD_TEXT_WIDTH > TEXT_WIDTH); - assert_eq!(text_width(false), TEXT_WIDTH); - assert_eq!(text_width(true), ETD_TEXT_WIDTH); } #[test] - fn widget_widens_by_text_delta_per_model_when_etd_on() { - // Enabling ETD must widen the widget by exactly the column growth, once - // per active model — so the layout calc matches the wider draw rect and - // nothing re-clips. - for models in 1..=2 { - let off = total_widget_width_for(models, false); - let on = total_widget_width_for(models, true); - let expected = (sc(ETD_TEXT_WIDTH) - sc(TEXT_WIDTH)) * models; - assert_eq!(on - off, expected, "models={models}"); - } + fn etd_width_delta_matches_text_column_growth() { + // When ETD is enabled the usage text column grows by exactly this delta, + // once per active model (see total_widget_width_for / model_usage_width), + // so the layout calc matches the wider draw rect and nothing re-clips. + let delta = ETD_TEXT_WIDTH - TEXT_WIDTH; + assert!(delta > 0); + assert_eq!(TEXT_WIDTH + delta, ETD_TEXT_WIDTH); } } From 9abf90b167609772fc4f2e83e60bd28bc5c6b973 Mon Sep 17 00:00:00 2001 From: "Carlos Miguel C. Resurreccion" Date: Mon, 22 Jun 2026 12:33:40 +0800 Subject: [PATCH 13/13] refactor: size usage-cell column to measured text width --- src/window.rs | 134 ++++++++++++++++++++++++++------------------------ 1 file changed, 69 insertions(+), 65 deletions(-) diff --git a/src/window.rs b/src/window.rs index 545ba43..ff87d6d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -464,6 +464,8 @@ fn refresh_usage_texts(state: &mut AppState) { state.codex_session_text = "!".to_string(); state.codex_weekly_text = "!".to_string(); } + + update_measured_text_width(state); } fn set_window_title(hwnd: HWND, strings: Strings) { @@ -851,41 +853,75 @@ const TEXT_WIDTH: i32 = 62; const MODEL_RIGHT_MARGIN: i32 = 5; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; -/// Text column when the ETD suffix is shown. The ETD form is worst-case -/// "100% · 59m rem · 59m ETD" — the base "100% · 59m" plus a " rem · 59m ETD" -/// label-and-estimate of similar length — so the column is sized to fit the -/// widest possible remaining-time and ETD (~2.6x the base) without clipping. -const ETD_TEXT_WIDTH: i32 = 160; fn active_model_count(show_claude_code: bool, show_codex: bool) -> i32 { (show_claude_code as i32 + show_codex as i32).max(1) } -// Optional display features may widen the usage text column. Each feature -// stores its own extra width (in base 96-DPI px) in a dedicated slot, read -// lock-free by the width/layout helpers below. Slots are summed so multiple -// features stack. This block is identical across the display-feature branches -// so they merge without conflict; a branch only wires up its own slot. -#[allow(dead_code)] -static EXTRA_TEXT_WIDTH_DETAILED: AtomicI32 = AtomicI32::new(0); -#[allow(dead_code)] -static EXTRA_TEXT_WIDTH_ETD: AtomicI32 = AtomicI32::new(0); +/// Small trailing padding (device px, unscaled) added after measured text. +const TEXT_MEASURE_PAD: i32 = 6; + +/// Width (device px, already DPI-scaled) of the widest usage-cell text actually +/// shown, recomputed whenever the texts change. The cell column sizes to real +/// content (detailed time / ETD suffix only when present) instead of a fixed +/// worst-case reservation. Falls back to the base column before first measure. +static MEASURED_TEXT_WIDTH: AtomicI32 = AtomicI32::new(0); -#[allow(dead_code)] -fn set_extra_text_width_detailed(px: i32) { - EXTRA_TEXT_WIDTH_DETAILED.store(px, Ordering::Relaxed); +fn current_text_width() -> i32 { + MEASURED_TEXT_WIDTH.load(Ordering::Relaxed).max(sc(TEXT_WIDTH)) } -#[allow(dead_code)] -fn set_extra_text_width_etd(px: i32) { - EXTRA_TEXT_WIDTH_ETD.store(px, Ordering::Relaxed); +/// Measure a string's pixel width in the same font the widget renders with. +fn measure_text_px(text: &str) -> i32 { + if text.is_empty() { + return 0; + } + unsafe { + let hdc = GetDC(HWND::default()); + let mem = CreateCompatibleDC(hdc); + let font_name = native_interop::wide_str("Segoe UI"); + let font = CreateFontW( + sc(-12), + 0, + 0, + 0, + FW_MEDIUM.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + CLEARTYPE_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old = SelectObject(mem, font); + let wide: Vec = text.encode_utf16().collect(); + let mut size = SIZE::default(); + let _ = GetTextExtentPoint32W(mem, &wide, &mut size); + SelectObject(mem, old); + let _ = DeleteObject(font); + let _ = DeleteDC(mem); + ReleaseDC(HWND::default(), hdc); + size.cx + } } -/// Usage text-column width including any enabled display-feature widening. -fn effective_text_width() -> i32 { - TEXT_WIDTH - + EXTRA_TEXT_WIDTH_DETAILED.load(Ordering::Relaxed) - + EXTRA_TEXT_WIDTH_ETD.load(Ordering::Relaxed) +/// Recompute the measured cell-text width from the currently-visible texts. +fn update_measured_text_width(state: &AppState) { + let mut max_w = 0; + if state.show_claude_code { + max_w = max_w.max(measure_text_px(&state.session_text)); + max_w = max_w.max(measure_text_px(&state.weekly_text)); + } + if state.show_codex { + max_w = max_w.max(measure_text_px(&state.codex_session_text)); + max_w = max_w.max(measure_text_px(&state.codex_weekly_text)); + } + if max_w > 0 { + MEASURED_TEXT_WIDTH.store(max_w + sc(TEXT_MEASURE_PAD), Ordering::Relaxed); + } } fn row_bar_segment_count(active_models: i32) -> i32 { @@ -900,7 +936,7 @@ fn total_widget_width_for(active_models: i32) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(effective_text_width()); + + current_text_width(); sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) @@ -911,6 +947,10 @@ fn total_widget_width_for(active_models: i32) -> i32 { + sc(RIGHT_MARGIN) } +fn total_widget_width_for_state(state: &AppState) -> i32 { + total_widget_width_for(active_model_count(state.show_claude_code, state.show_codex)) +} + fn total_widget_width() -> i32 { let active_models = { let state = lock_state(); @@ -1132,14 +1172,6 @@ pub fn run() { diagnose::log("taskbar not found; using fallback popup window"); } - // Seed the ETD text-column widening so the initial layout matches the - // wider draw rect when the ETD suffix is enabled at startup. - set_extra_text_width_etd(if settings.show_etd { - ETD_TEXT_WIDTH - TEXT_WIDTH - } else { - 0 - }); - // If not embedded, fall back to topmost popup with SetLayeredWindowAttributes if !embedded { let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), 255, LWA_ALPHA); @@ -2088,9 +2120,6 @@ unsafe extern "system" fn wnd_proc( if is_dragging { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - // Compute the widget width before locking; the width helpers read - // shared state themselves and must not run while the lock is held. - let widget_width = total_widget_width(); let move_target = { let mut state = lock_state(); let s = match state.as_mut() { @@ -2124,6 +2153,7 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } + let widget_width = total_widget_width_for_state(s); let max_offset = (tray_left - taskbar_rect.left - widget_width).max(0); if new_offset > max_offset { new_offset = max_offset; @@ -2333,11 +2363,6 @@ unsafe extern "system" fn wnd_proc( let mut state = lock_state(); if let Some(s) = state.as_mut() { s.show_etd = !s.show_etd; - set_extra_text_width_etd(if s.show_etd { - ETD_TEXT_WIDTH - TEXT_WIDTH - } else { - 0 - }); refresh_usage_texts(s); } } @@ -2857,7 +2882,7 @@ fn draw_row( fn model_usage_width(segment_count: i32) -> i32 { (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * segment_count - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) - + sc(effective_text_width()) + + current_text_width() } fn draw_usage_bar( @@ -2930,7 +2955,7 @@ fn draw_usage_bar( let mut text_rect = RECT { left: text_x, top: y, - right: text_x + sc(effective_text_width()), + right: text_x + current_text_width(), bottom: y + seg_h, }; let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -2959,24 +2984,3 @@ fn draw_rounded_rect(hdc: HDC, rect: &RECT, color: &Color, radius: i32) { let _ = DeleteObject(brush); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn etd_text_column_is_wider_than_base() { - // Sized for the worst case "100% · 59m rem · 59m ETD". - assert!(ETD_TEXT_WIDTH > TEXT_WIDTH); - } - - #[test] - fn etd_width_delta_matches_text_column_growth() { - // When ETD is enabled the usage text column grows by exactly this delta, - // once per active model (see total_widget_width_for / model_usage_width), - // so the layout calc matches the wider draw rect and nothing re-clips. - let delta = ETD_TEXT_WIDTH - TEXT_WIDTH; - assert!(delta > 0); - assert_eq!(TEXT_WIDTH + delta, ETD_TEXT_WIDTH); - } -}