Skip to content

Commit 38f4479

Browse files
authored
feat(settings): Left / Right strip position selector with fallback (#10) (#122)
* docs(spec): design for the strip next to the system tray (#10) * docs(plan): implementation plan for the strip next to the tray (#10) * feat(config): placement setting (auto, left, right) (#10) * feat(taskbar): right-side gap geometry (#10) * feat(ui): placement watch for settled failures (#10) * feat(taskbar): read app and tray edges for right placement (#10) * feat(ui): place the strip next to the tray on Windows 10 (#10) * feat(ui): no taskbar hints when the strip sits next to the tray (#10) * fix(taskbar): back off failed app-edge reads, right side on horizontal bars only (#10) * fix(ui): settle placement on time, re-read app edges, deliver the notice only while it applies (#10) * feat(layout): drop hardware items before usage limits when space runs out (#10) * feat(layout): keep labels; drop items instead of switching to a compact mode (#10) * feat(layout): draw the ellipsis where items were dropped (#10) * refactor(i18n): drop the Windows 10 hidden-strip note (#10) * docs: Windows 10 shows the strip next to the tray (#10) * docs: translated site pages and README match the tray placement (#10) * docs: explain the pace tick on the usage bars * docs(spec): Left/Right strip position selector with fallback * docs(plan): implementation plan for the strip position selector * feat(taskbar): resolve the strip side by preference and free space * feat(ui): one placement notice per settled state, explicit choices only * feat(ui): fall back to the other side and notify when an explicit choice is not honored * feat(settings): Left / Right strip position selector * docs: Strip position selector in Settings * fix(ui): placement watch keeps the grace on reset, debounces returns, drops stale notices A selector click no longer starts a new 30 s grace. A return to the preferred side re-arms the notice only after it holds SETTLE_SECS, so a brief return does not notify again. A silent settle drops a pending notice kept across a restart for a different state. * fix(ui): leave a left-side overflow to the edge pass instead of hiding When the left side stops fitting and the right-side edges were not read, the first pass neither hides the strip nor observes the placement; the second pass decides. Pulled into placement::needs_second_pass. * fix(settings): Tab lands on the checked position radio, in row order Only the checked radio is a tab stop (moved on every selection with SetWindowLongPtrW), so Tab no longer changes the setting. The radios follow Start with Windows in z-order, and the switch after them starts a new group. * docs: say when the placement notice fires, not that it fires once * feat(settings): one compact row for the refresh intervals
1 parent 72410df commit 38f4479

21 files changed

Lines changed: 1501 additions & 479 deletions

‎CHANGELOG.md‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ All notable changes to this project are documented here. The format follows
77
## [Unreleased]
88

99
### Added
10-
- **Next to the system tray (Windows 10, and `"placement": "right"`).** On Windows 10 the strip
11-
now shows next to the system tray, flush with its left edge, and follows it as tray icons come
12-
and go. Windows 11 does the same when taskbar icons are left-aligned. On any Windows, set
13-
`"placement": "right"` in `%APPDATA%\claude-codex-taskbar\config.json` (`auto` is the default;
14-
`left` and `right` are the others; no Settings option yet). When the strip cannot be placed
15-
there (no room, or the taskbar lookup keeps failing), it stays hidden, Windows shows one
16-
notification per run, and clicking it opens Settings (at startup Settings opens by itself).
17-
Vertical taskbars keep the old behavior (#10).
10+
- **Strip position selector, with fallback (Windows 10 and 11).** Settings → General has a new
11+
*Strip position* choice, *Left* (default) or *Right*, applied at once. Left uses the left
12+
corner; Right sits next to the system tray, flush with its left edge, and follows it as tray
13+
icons come and go. If the chosen side has no room (Windows 10, or Windows 11 with left-aligned
14+
icons) the strip moves to the other side, and it hides only when neither side fits.
15+
Windows tells you when the side you chose can't be used (moving to the right without ever
16+
choosing is silent; hiding is always notified), once the situation lasts a few seconds.
17+
Clicking a notice opens Settings, which opens by itself only when the strip cannot be shown at
18+
startup. You no longer need to center taskbar icons; the Widgets button
19+
hint applies only to the left corner on Windows 11 with centered icons. The `placement` key
20+
in `config.json` (`auto`, `left`, `right`) still works. Vertical taskbars keep the old
21+
behavior (#10).
1822
- **Check for updates, on click only.** Settings → General shows the running version with a
1923
*Check for updates* button. Only when you press it, CCT asks GitHub's releases API once and
2024
says whether you have the latest version or which one is available, with a button to open

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Each entry shows the plan and the usage last seen on that account. `⚠ login ex
9292

9393
**Requirements:** Windows 11 or Windows 10 (x64), and a logged-in [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and/or Codex CLI on the same Windows user.
9494

95-
On Windows 11 with centered icons, turn off the Windows Widgets button (*Settings → Personalization → Taskbar → Widgets*) to free the left corner, where the strip lives. On Windows 10, and on Windows 11 with left-aligned icons, the strip sits next to the system tray and follows it as tray icons come and go. On any Windows you can force that spot with `"placement": "right"` in `%APPDATA%\claude-codex-taskbar\config.json` (`auto` is the default; `left` and `right` are the other values). If there is no room next to the tray, the strip stays hidden, Windows shows one notification per run (clicking it opens Settings), and at startup Settings also opens by itself. When space runs out, hardware items are dropped first (disks, GPU, RAM, CPU), then usage limits from the end; labels always stay and a "…" marks where items were dropped. The tooltip lists what is hidden.
95+
Choose the side in *Settings → General → Strip position* (*Left* or *Right*; Left is the default, and the choice applies at once). Left puts the strip in the left corner; if there is no room there (Windows 10, or Windows 11 with left-aligned icons) it moves next to the system tray. Right puts it next to the tray, following it as tray icons come and go; if there is no room there it moves to the left corner. If neither side has room, it hides. Windows tells you when the side you chose can't be used (moving to the right without ever choosing is silent; hiding is always notified), once the situation lasts a few seconds; clicking the notice opens Settings. Settings opens by itself only when the strip cannot be shown at startup. You no longer need to center your taskbar icons; on Windows 11 with centered icons, turn off the Widgets button (*Settings → Personalization → Taskbar → Widgets*) to free the left corner. When space runs out, hardware items are dropped first (disks, GPU, RAM, CPU), then usage limits from the end; labels always stay and a "…" marks where items were dropped. The tooltip lists what is hidden.
9696

9797
Releases are not code-signed yet, so SmartScreen may warn: **More info → Run anyway**. Verify the file against `SHA256SUMS.txt` or its signed build provenance:
9898

@@ -148,7 +148,7 @@ Under 16 MB of memory and near-zero CPU, with no disk or network activity betwee
148148
![Task Manager row for claude-codex-taskbar: 0% CPU, 14.5 MB memory](docs/screenshot-pc-usage.png)
149149

150150
### Does it work on macOS, Linux or Windows 10?
151-
CCT started on Windows 11, where no tool like it existed. A macOS version is coming soon; Linux is not supported yet. On Windows 10 the strip sits next to the system tray ([#10](https://github.com/chiarorosa/claude-codex-taskbar/issues/10)).
151+
CCT started on Windows 11, where no tool like it existed. A macOS version is coming soon; Linux is not supported yet. On Windows 10 the strip sits next to the system tray when the left corner has no room; you can pick the side in Settings ([#10](https://github.com/chiarorosa/claude-codex-taskbar/issues/10)).
152152

153153
## Disclaimer
154154

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Strip Position Selector Implementation Plan
2+
3+
> **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.
4+
5+
**Goal:** A Left / Right selector in Settings → General; the choice is a preference with
6+
fallback to the other side by free space, hiding when neither fits, with a notice whenever an
7+
explicit choice is not honored.
8+
9+
**Architecture:** Pure side resolution (`preference order` × `which sides fit`) replaces the
10+
version-based `resolve_side`. The placement watch is reworked from "one settled failure per
11+
run" to "one notice per settled non-preferred state". The Settings window gets a radio-chip
12+
row writing `cfg.placement`.
13+
14+
**Tech Stack:** Rust stable, `windows` crate (Win32 controls, UI Automation), Direct2D.
15+
16+
**Spec:** `docs/superpowers/specs/2026-11-12-placement-selector-design.md`
17+
18+
## Global Constraints
19+
20+
- Gate: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
21+
- Pure logic in testable functions; failing test first.
22+
- All user-facing text through `i18n::t` (English, pt-BR, Spanish), exact strings from the spec.
23+
- DESIGN.md: real Win32 controls with our paint, documented APIs, no new crates.
24+
- Never cover taskbar icons; never show `0%` for errors.
25+
- Config values stay `auto | left | right`; `auto` = Left → Right with **silent** fallback.
26+
- Conventional Commits; no attribution trailers.
27+
28+
## Review Focus
29+
30+
1. Side ping-pong while windows open/close near the limit: settle rule must keep it to at most one notice per settled state. (Task 2 test `fallback_then_back_then_fallback_notifies_twice_only_when_settled`.)
31+
2. A user who never touched the selector on Windows 10: strip goes right with **no** notice. (Task 2 test `auto_fallback_is_silent`.)
32+
3. Changing the selector while the strip is on the fallback side or hidden: the new preference applies on the next render, and the watch starts over so no stale notice fires. (Task 3 step: `PlacementWatch::reset` on placement change.)
33+
4. Explorer restart while on the fallback side: grace period, no spurious notice. (Existing `restart()` semantics kept; Task 2 test `restart_opens_new_grace`.)
34+
5. Keyboard: arrow keys move between Left/Right radios and apply immediately. (Task 4 manual check + model test `selector_writes_explicit_placement`.)
35+
36+
---
37+
38+
### Task 1: Side resolution by preference and room
39+
40+
**Files:** Modify `src/ui/taskbar.rs` (replace `resolve_side`, tests).
41+
42+
**Interfaces:**
43+
- Produces: `pub fn side_order(placement: Placement) -> [Side; 2]` (`Auto|Left` → `[Left, Right]`, `Right` → `[Right, Left]`).
44+
- Produces: `pub enum Placed { Preferred(Side), Fallback(Side), Hidden }` and
45+
`pub fn place(order: [Side; 2], fits: impl Fn(Side) -> bool) -> Placed`.
46+
- Produces: `pub fn is_explicit(placement: Placement) -> bool` (false only for `Auto`).
47+
48+
- [ ] **Step 1: Failing tests** `place_table`: for each order and every fits combination: `[Left,Right]` + (L✓) → `Preferred(Left)`; (L✗,R✓) → `Fallback(Right)`; (L✗,R✗) → `Hidden`; `[Right,Left]` + (R✓) → `Preferred(Right)`; (R✗,L✓) → `Fallback(Left)`; both ✗ → `Hidden`. `side_order_table`, `auto_is_not_explicit`.
49+
- [ ] **Step 2:** run → FAIL.
50+
- [ ] **Step 3:** implement; remove `resolve_side` and its test; keep `TaskbarInfo.side` (now set by the caller in Task 3, see there).
51+
- [ ] **Step 4:** gate → PASS. **Step 5:** commit `feat(taskbar): resolve the strip side by preference and free space`.
52+
53+
### Task 2: Placement watch per settled state
54+
55+
**Files:** Modify `src/ui/placement.rs`.
56+
57+
**Interfaces:**
58+
- Consumes: `Placed`, `Side` (Task 1).
59+
- Produces:
60+
```rust
61+
pub enum NoticeKind { MovedRight, MovedLeft, Hidden }
62+
pub struct Notice { pub kind: NoticeKind, pub open_settings: bool }
63+
impl PlacementWatch {
64+
pub fn observe(&mut self, placed: Placed, explicit: bool, now_secs: u64);
65+
pub fn pending_notice(&self) -> Option<Notice>;
66+
pub fn notice_sent(&mut self);
67+
pub fn restart(&mut self, now_secs: u64); // Explorer restart: new grace, keep sent history
68+
pub fn reset(&mut self, now_secs: u64); // placement changed in Settings: forget state, new grace
69+
pub fn hidden_settled(&self) -> bool; // replaces settled_failure()
70+
}
71+
```
72+
Rules: a non-preferred state (`Fallback(_)` or `Hidden`) must hold `SETTLE_SECS` (outside the `GRACE_SECS` grace) to settle; settling a state different from the last notified one raises one pending notice (`Fallback(Right)` → `MovedRight`, `Fallback(Left)` → `MovedLeft`, `Hidden` → `Hidden`); `Preferred(_)` clears the current state, the pending notice and the "last notified" memory (so a later fallback notifies again). With `explicit == false`, `Fallback(_)` never notifies (`Hidden` still does). `open_settings` is true only for a `Hidden` notice settled before the strip was ever shown this run. `pending_notice` returns the notice only while its state is still the settled one. `outcome_of` is replaced by the caller passing `Placed` directly (vertical taskbars: caller passes nothing, see Task 3).
73+
74+
- [ ] **Step 1: Failing tests** (rewrite the module's tests to the new API, keeping the grace/settle/restart ones): `fallback_settles_after_10s_and_notifies_moved_right`, `auto_fallback_is_silent` (explicit=false, Fallback(Right) 60 s → no notice; Hidden → notice), `return_to_preferred_is_silent_and_clears`, `fallback_then_back_then_fallback_notifies_twice_only_when_settled`, `fallback_to_hidden_notifies_again`, `staying_in_state_does_not_repeat`, `hidden_at_startup_opens_settings_runtime_does_not`, `restart_opens_new_grace`, `reset_forgets_state`.
75+
- [ ] **Step 2:** run → FAIL. **Step 3:** implement. **Step 4:** gate → PASS.
76+
- [ ] **Step 5:** commit `feat(ui): one placement notice per settled state, explicit choices only`.
77+
78+
### Task 3: Render wiring and notices
79+
80+
**Files:** Modify `src/ui/app.rs`, `src/ui/strip.rs`, `src/ui/strip/notifications.rs`, `src/ui/taskbar.rs` (`query`), `src/i18n.rs`.
81+
82+
**Interfaces:**
83+
- Consumes: Tasks 1–2.
84+
- Produces: `taskbar::query(with_icons: bool, with_apps: bool, with_edges: bool) -> Option<TaskbarInfo>` (edges read only when `with_edges` and the bar is horizontal); `App::pending_placement_notice() -> Option<Notice>`; i18n keys `NotifyMovedRight`, `NotifyMovedLeft` (+ reworded `NotifyPlacementBody`), exact texts in the spec.
85+
86+
- [ ] **Step 1: Failing tests** (pure helper in `src/ui/placement.rs` or `app.rs`): `fn wants_edges(placement: Placement, left_fitted_last: Option<bool>, left_aligned: bool) -> bool` — true when order starts with Right, or Left did not fit at the last render, or (no render yet and `left_aligned`). Table test `wants_edges_table`.
87+
- [ ] **Step 2:** run → FAIL.
88+
- [ ] **Step 3: Implement.**
89+
- `strip::render` passes `with_edges = wants_edges(cfg.placement, app.left_fitted_last, …)` (`left_aligned` from the last `TaskbarInfo`, false when none).
90+
- `App::render`: compute the left layout (`available_width`) and, when edges are present on a horizontal bar, the right layout (`right_gap_width`); `fits(side) = layout width > 0` (right without edges = false); `place(side_order(cfg.placement), fits)`; draw the chosen side (`Preferred`/`Fallback`) at its position (left: `rect.left + margin`; right: `right_strip_left`), hide on `Hidden`. Store `left_fitted_last`. Vertical bar: left logic only, no `observe`. Covered (fullscreen/auto-hide) and all-items-off: hide and no `observe`, as before. Otherwise `observe(placed, is_explicit(cfg.placement), now)`. `info.side` = drawn side (for hints/FrameKey).
91+
- `App::apply_config`: when `placement` changed, `placement.reset(now)`, invalidate frame and edges.
92+
- Notices: `strip/notifications.rs` sends the body for the notice kind (`NotifyMovedRight` / `NotifyMovedLeft` / `NotifyPlacementBody`) with `NotifyPlacementTitle`; Settings auto-opens only when `open_settings`; click opens Settings (unchanged). Keep fullscreen/closing/shell gating.
93+
- Remove the startup `left_side_blocked()` Settings opening (Left now falls back); if the old hints for left-aligned taskbars become unreachable, leave `setup_hints` unchanged (Task 4 handles hints).
94+
- [ ] **Step 4:** gate → PASS; `cargo build --release`; `cargo test -- --ignored live_edges`.
95+
- [ ] **Step 5:** commit `feat(ui): fall back to the other side and notify when an explicit choice is not honored`.
96+
97+
### Task 4: Settings selector
98+
99+
**Files:** Modify `src/ui/settings.rs`, `src/ui/settings/layout.rs`, `src/ui/settings/model.rs`, the Settings painter in `src/ui/settings/`, `src/i18n.rs`, `src/ui/view.rs` (hints).
100+
101+
**Interfaces:**
102+
- Produces: `Row::Choice { title: String, options: Vec<(usize, String, bool)> }` (id, label, selected) in the General card right after "Start with Windows"; model fn `pub fn with_placement(cfg: &Config, side: Side) -> Config` writing `Placement::Left` / `Placement::Right`; i18n keys `SettingsPlacement`, `PlacementLeft`, `PlacementRight` (exact texts in the spec). Selected option: `Right` iff `cfg.placement == Right`, else `Left`.
103+
104+
- [ ] **Step 1: Failing tests:** layout test `choice_row_sits_right_like_chips` (two option rects right-aligned in the row, chip tokens 11×4 padding, 6 gap); model tests `selector_writes_explicit_placement` (Auto + pick Left → `Left`; pick Right → `Right`; other fields unchanged), `auto_shows_left_selected`.
105+
- [ ] **Step 2:** run → FAIL.
106+
- [ ] **Step 3: Implement.** Real `BUTTON` controls `BS_AUTORADIOBUTTON` (first with `WS_GROUP | WS_TABSTOP`), painted like chips via the existing custom-draw path (accent fill when checked, focus ring per DESIGN.md). `BN_CLICKED` → `with_placement` → existing apply path (config saved, strip re-rendered at once). `setup_hints`: drop `HintCenterTaskbar` (Left now falls back to the right, so centering is no longer required); keep `HintWidgetsButton` only when the strip is drawn on the left.
107+
- [ ] **Step 4:** gate → PASS; `cargo build --release`.
108+
- [ ] **Step 5:** commit `feat(settings): Left / Right strip position selector`.
109+
110+
### Task 5: Docs
111+
112+
**Files:** `README.md`, `CHANGELOG.md` (Unreleased), `site/pages/index.html`, `site/pages/pt-br/index.html`, `site/pages/claude-code-usage-windows.html`, `site/pages/pt-br/uso-claude-code-windows.html`.
113+
114+
- [ ] **Step 1:** Replace the `"placement"` config instructions with: Settings → General → *Strip position* (Left / Right). Left is the default; if the chosen side has no room the strip moves to the other one, and hides only if neither fits. A notification tells you when your choice could not be honored. Mention `HintCenterTaskbar` removal implicitly (no need to center icons). EN + pt-BR site match README.
115+
- [ ] **Step 2:** `python site/build.py` + `python site/test_build.py` → OK.
116+
- [ ] **Step 3:** commit `docs: Strip position selector in Settings`.

0 commit comments

Comments
 (0)