From e2585d53d05d2191aac60d40dc1106f807c2163a Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 23:41:47 -0400 Subject: [PATCH 01/13] docs(review): lock M6.5 usability design (ADR-034/035) --- .../034-review-git-native-config-schema.md | 96 ++++++++++++ docs/adr/035-review-theming-base16-hybrid.md | 100 ++++++++++++ docs/plans/review-usability-pass.md | 147 ++++++++++++++++++ docs/rfc/workon-review.md | 1 + 4 files changed, 344 insertions(+) create mode 100644 docs/adr/034-review-git-native-config-schema.md create mode 100644 docs/adr/035-review-theming-base16-hybrid.md create mode 100644 docs/plans/review-usability-pass.md diff --git a/docs/adr/034-review-git-native-config-schema.md b/docs/adr/034-review-git-native-config-schema.md new file mode 100644 index 00000000..f1399d66 --- /dev/null +++ b/docs/adr/034-review-git-native-config-schema.md @@ -0,0 +1,96 @@ +# 034 — Review TUI Config: Git-Native Per-View Namespaces + +## Context + +The review TUI (`git-workon-review`) grew its keybindings and colors as hardcoded +values during M3–M5: a `match` in `tui.rs` for keys, a block of `const … Color::Rgb(…)` +atop `render.rs` for theming. Making either user-configurable needs a config home, and +the review binary reads no config today (`struct Cli {}` is empty). + +[ADR-006](006-git-native-config.md) already commits the tool to git-native config under +the `workon.*` namespace — no bespoke file format, git's layered precedence +(local → global → system), multivar for lists. The open question was whether a *keymap* +fits that model, since a keymap is many key→action entries. Three shapes were considered: + +1. A dedicated `review.toml` (nested keymap syntax, in-tree shareable) — but a second + config system, against ADR-006's one-config-system principle, needs a new loader and + precedence layer. +2. Value-side multivar `workon.review.bind = "key=action"` — git-native, but multivar + *accumulates* across layers, forcing us to reimplement override precedence and + last-wins dedup by hand and invent an unbind sentinel. +3. Action-as-key, per-view namespaces (chosen). + +The keymap is also context-dependent: the same key differs by view (`j` is cursor-down in +the diff pane, outline-move-down in the outline), and some bindings are two-key chords +(`]f`). Whitespace and special keys (Tab, Enter, Esc, arrows, **space**) have no safe +literal form. + +## Decision + +All review config lives under `workon.review.*` in git config, extending ADR-006. Config +is stored **action-as-key** in **per-view subsections**: + +``` +workon.review.theme = dark ; global, non-view +workon.review..bind. = "" ; a keymap entry +workon.review.. = ; view config +``` + +- **View** ∈ `diff`, `outline`; a bare `workon.review.bind.` is the **global** + keymap (active in every view). Git parses `workon.review.diff.bind.stage-hunk` as + section `workon`, subsection `review.diff.bind`, name `stage-hunk` — dotted subsections + are legal and case-sensitive (always lowercase here). +- **The action is the config variable; the keys are the value.** Each binding is therefore + an ordinary *single-valued* variable, so git's native precedence does all override work: + setting it replaces (local beats global beats system via `config.get_string()`), and an + empty value unbinds. No custom layering, no sentinel. Defaults live in code; a git entry + overrides that action's default. Action names qualify as git variable names (alphanumeric + + `-`, alpha-initial): `stage-hunk`, `next-file`, `toggle-outline`, … +- **Value = space-separated key tokens** (an action may have several keys, e.g. + `cursor-down = "j down"`). Replace, not append: setting a binding states exactly what + triggers it. Token grammar: + - **Reserved symbolic names (win over literals):** `space tab enter esc up down left + right home end pageup pagedown backspace delete backtab f1`–`f12`. + - **Modifier prefix:** `ctrl-`, `alt-`, `shift-` on any token (`ctrl-d`, `ctrl-space`). + - **Literal:** otherwise printable chars — length 1 is one key (`s`, `=`), length >1 is a + chord (`]f`). A token is matched against reserved words and the modifier grammar first, + literal only if neither matches, so `space` is always the spacebar. +- **View config** (non-binding) shares the view namespace: `workon.review.outline.width`, + `workon.review.outline.mode`, `workon.review.diff.layout`, `workon.review.diff.zoom`. + The `.bind.` marker is what distinguishes a keymap entry from a view setting. +- **Load-time inversion:** on startup, walk every `workon.review.*.bind.*` variable, split + values into key tokens, and build the per-view key→action dispatch maps. This pass + validates (unknown `bind.` → warning; the action set is enumerable) and detects + collisions (one key claimed by two actions in a view → footer warning + deterministic + winner; defaults never collide, so this only fires on user config). +- **Not rebindable:** the confirm modal (`y`/`n`/`Esc`) and the whole `Esc` precedence + cascade (confirm > outline-unfocus > selection-cancel > quit) stay hardcoded — they are + conventional, safety-sensitive, and the Esc cascade's documented precedence would break + if rebound. + +## Consequences + +- One config system across the whole tool; users already know `git config`. Global + preferences in `~/.gitconfig`, per-repo in `.git/config`, standard layering — inherited + from ADR-006 for free. +- Override and unbind require **no resolver logic** — they are native git-config semantics. + This is the primary reason action-as-key beat value-side `key=action`. +- Key names and action names become a **compatibility surface**: once users write + `workon.review.diff.bind.stage-hunk`, renaming that action or restructuring the namespace + breaks their config. Action names are therefore part of the stable API, and the help + overlay renders from the same enumerable action set. +- Like all git-native config (ADR-006), review config is **not checked into the repo**, so a + team cannot ship a shared review keymap/theme in-tree. Accepted: this is a + personal-productivity TUI. +- The per-view namespace gives previously-hardcoded view settings (outline width — M5 + deferred narrow-terminal handling — outline mode, diff layout/zoom defaults) a natural + home without a second design pass. +- Adding a rebindable action = adding it to the enumerable action set (code default + + dispatch + help entry); it is automatically configurable, validated, and documented. + +## References + +- [ADR-006](006-git-native-config.md) — git-native config under `workon.*` this extends +- `docs/rfc/workon-review.md` — RFC; this is the everyday-usability pass inserted ahead of M7 +- `git-workon-review/src/tui.rs` — current hardcoded keymap (`map_key`) being replaced +- `git-workon-review/src/render.rs` — current hardcoded palette (`const … Color::Rgb`) — see the theming decision diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md new file mode 100644 index 00000000..bf6c484f --- /dev/null +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -0,0 +1,100 @@ +# 035 — Review TUI Theming: Hybrid base16, Render-Time Resolution, Terminal-Derived `auto` + +## Context + +The review TUI's colors were hardcoded during M3–M5: a `const … Color::Rgb(…)` block +atop `render.rs` (dark-only) and a parallel `HIGHLIGHT_NAMES`/`HIGHLIGHT_COLORS` pair in +`highlight.rs`. The everyday-usability pass (ahead of M7, see [ADR-034](034-review-git-native-config-schema.md)) +adds built-in light/dark theming and terminal adaptivity. Four things had to be resolved: +the color *philosophy* (respect the terminal's 16 ANSI colors vs. ship tuned truecolor), +the theme *primitive*, the *mechanism* by which a theme reaches syntax highlighting, and +what "adapt to the terminal" concretely means. + +Key constraint: diff readability depends on a **truecolor gradient** — `BG_*_SUBTLE` vs +`BG_*_STRONG` and their staged variants sit a few RGB shades apart, and that gradient is +how word-level emphasis and staged-vs-unstaged attribution read at a glance. The 16-color +ANSI palette has no equivalent, so pure "inherit the terminal's ANSI colors" (which would +self-adapt for free) was rejected — it regresses the readability that is the tool's point. + +A second discovery shaped the primitive: `highlight.rs` is *already* a base16 template. +Its comment says the palette is "in the same family as base16-eighties.dark," and the +`C_RED/ORANGE/YELLOW/GREEN/CYAN/BLUE/PURPLE` consts are base08–base0E, mapped to captures +per the base16 spec's role conventions (`keyword → base0E`, `string → base0B`, +`function → base0D`, `comment → base03`, …). The capture→slot template already exists and +is spec-conformant. + +## Decision + +**Philosophy — hybrid, split on "does this color sit on a tinted background?"** +- **On a tint → base16 truecolor (theme-controlled):** diff add/del subtle/strong + staged + variants, cursor, selection, and **syntax**. Contrast is guaranteed because foreground and + background come from the *same* scheme. +- **Chrome, not on a tint → ANSI-named (`Color::Gray`/`DarkGray`/…):** gutter, borders, + footer, dim labels, status. These inherit the terminal palette, self-adapt light/dark, and + are **probe-independent** (work even when terminal-derivation fails). Half already are + ANSI-named today. + +**Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots +(base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing +capture→slot template. Diff-bg tints are **derived**, not authored: blend base08 +(red / spec "Diff Deleted") and base0B (green / spec "Diff Inserted") toward base00 (bg) +using the existing `tint_toward` helper (`render.rs`). Syntax and diff tints therefore come +from one scheme and stay coordinated by construction. + +**Mechanism — resolve color at render time, not in the highlight phase.** +- `HIGHLIGHT_NAMES` stays global/const: it defines the capture *index space* bound by + `config.configure()` and is theme-invariant. +- `FgSpan` carries the **capture index** (semantic role), not a resolved `Color`. The + highlight phase (`highlight.rs:283`) records the index instead of looking up a color. +- Render resolves `index → Color` against the active `Theme` (`theme.slot[idx]`), in the + same place it resolves diff tints and cursor/selection. One theme-application site; + syntax and background contrast are reasoned about together. +- Consequence: the expensive tree-sitter pass is theme-free and cacheable — a theme switch + recolors by re-rendering, without re-parsing. + +**Selection — `workon.review.theme = auto | dark | light`** (git config, per ADR-034; +`auto` is the default). +- **`auto` = terminal-derived.** Probe the terminal for its palette (`OSC 4;n;?` for + n=0–15, `OSC 10/11` for fg/bg), populate the 16 slots from the real RGB, and derive tints + from the probed base00/08/0B. `auto` *means* terminal-derivation and nothing else — it is + not a placeholder for a curated pick (an earlier `COLORFGBG`-picks-curated design was + rejected precisely because it would change `auto`'s meaning once the probe landed). +- **`dark` / `light` = curated base16 schemes** — explicit overrides and the probe-failure + fallback. `dark` is the current eighties.dark values; `light` is a published base16 light + scheme's 16 hexes (pasted, not hand-invented). + +**Terminal derivation specifics.** +- ANSI-16 cannot fill 6 base16 slots (base01, base02, base04, base06, base09, base0F), so + those are **synthesized**: ramp intermediates by interpolation (base01/02 from base00→03, + base04/06 from base03→05→07), base09 (orange) by blending base08+base0A, base0F from + base09/base08. The diff-critical slots (base00/08/0B) are always real, so tint quality is + preserved; the loss is secondary accents. +- The probe runs at startup on the controlling `/dev/tty` (the TUI already renders there — + see `tui.rs`), in raw mode, reading replies with a short timeout. **Failure degrades + gracefully:** per-slot fallback to the curated scheme's slot; total failure falls back to + the curated scheme chosen by background luminance if `OSC 11` answered, else `dark`. + tmux/screen/ssh non-response is handled by the timeout, never a hang. + +## Consequences + +- Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from + the start**, not deferred. `auto` never has to change meaning later. +- Because color resolves late as `theme.slot[idx]`, the slot *source* is pluggable — a future + user-supplied base16 scheme (`theme = ` / a scheme file, the deferred + "user-configurable colors" tier) is additive, no renderer change. +- The OSC probe is the single most terminal-fragile component; its blast radius is contained + by the timeout + curated fallback, so a hostile terminal yields a correct curated theme, + never a hang or a broken palette. +- Adding a syntax capture = adding it to `HIGHLIGHT_NAMES` + the capture→slot template; it is + automatically themed by every scheme. +- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Theme` threaded + to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render + tests that assert concrete colors must resolve through a fixed test `Theme`. + +## References + +- [ADR-034](034-review-git-native-config-schema.md) — `workon.review.theme` config key +- [ADR-006](006-git-native-config.md) — git-native config this builds on +- `git-workon-review/src/highlight.rs` — existing base16-conformant capture→slot template +- `git-workon-review/src/render.rs` — `const` palette + `tint_toward` blend helper being generalized +- base16 styling spec — slot role conventions (base08 red/Diff-Deleted, base0B green/Diff-Inserted, base0E keywords, …) diff --git a/docs/plans/review-usability-pass.md b/docs/plans/review-usability-pass.md new file mode 100644 index 00000000..6508aa74 --- /dev/null +++ b/docs/plans/review-usability-pass.md @@ -0,0 +1,147 @@ +# Plan — Review TUI Everyday-Usability Pass (M6.5) + +Design locked 2026-07-07. Decisions live in **[ADR-034](../adr/034-review-git-native-config-schema.md)** +(config schema + keymap) and **[ADR-035](../adr/035-review-theming-base16-hybrid.md)** +(theming). This doc is the *execution* plan: what lands, in what order, how each unit is +verified. Read both ADRs before implementing — this plan does not restate their rationale. + +Comments (M7) are deprioritized behind this pass. Goal: make the review TUI usable for +everyday review work — configurable keybindings, discoverable help, real theming. + +## Scope (four tracks) + +1. **Keybindings** — action registry (action → default keys, description, view); git-config + loading of `workon.review..bind.`; token-grammar parser; per-view + resolution with validation + collision detection; **defaults unchanged** (decided: + configurability + discoverability is the fix, not a keymap redesign). +2. **Help surface** — persistent curated per-view footer + `?` overlay (focused view + global + bindings), new `toggle-help` action. +3. **Theming** — base16 `Theme` primitive; render-time color resolution (`FgSpan` carries a + capture index, not a `Color`); hybrid boundary (on-tint = base16 truecolor, chrome = + ANSI-named); derived diff tints; `workon.review.theme = auto|dark|light`; + terminal-derivation OSC probe for `auto` with curated fallback; curated dark + light. +4. **View-config** — `workon.review.outline.width|mode`, `workon.review.diff.layout|zoom` + read from config with current values as defaults. + +## Changeset partition (Graphite stack) + +Two independent tracks fan out from the shared config reader (CS1), plus view-config off CS1. +Each unit is land-alone (green + valuable on `main` by itself) and standalone-review. + +``` +main + └─ uc-review-config CS1 ── shared git-config reader + ├─ uc-keymap CS2 ── configurable per-view keymaps (keybinding track) + │ └─ uc-help CS3 ── footer + ? overlay + ├─ uc-theme-base16 CS4 ── Theme primitive + render-time resolution (dark only, no visible change) + │ └─ uc-theme-light CS5 ── curated light + theme=dark|light + │ └─ uc-theme-auto CS6 ── terminal-derivation probe + theme=auto default + └─ uc-view-config CS7 ── outline.width/mode, diff.layout/zoom +``` + +Order of landing: CS1 → (CS2 → CS3) and (CS4 → CS5 → CS6) and CS7. The keymap and theming +subtrees are independent after CS1; land in either interleaving. Main-thread diff-read each +before the next lands (per the working style). + +### CS1 — `ReviewConfig` reader +- **Decision:** the review binary reads git config for the first time. Mirror + `git-workon-lib/src/config.rs`'s `WorkonConfig` pattern: read via `repo.config()` (the + `App` already owns a `Repository` — see `app.rs`). New module `git-workon-review/src/config.rs`. +- Provide typed getters for the keys this pass introduces (bindings, theme, view settings). + Reuse git2 `Config::get_string`/`get_bool`/`get_i64`/`multivar` as `WorkonConfig` does. +- **No behavior change yet** — just the reader + tests against a fixture repo config. +- Verify: unit tests reading `workon.review.*` from a `FixtureBuilder` repo (both a set and + an unset/default case). Load `/docs testing` first; use FixtureBuilder + predicates. + +### CS2 — Action registry + configurable keymaps +- **Decision:** ADR-034. Replace the hardcoded `map_key` match (`tui.rs`) with a + registry-driven dispatch. +- Build the **action registry**: one table `action → (default keys, human description, view ∈ + {global, diff, outline})`. This is the single source of truth for defaults, validation, + help text. The existing `Action` enum is the action set; extend, don't fork it. +- **Token-grammar parser** (ADR-034): reserved symbolic names (incl. `space`, `tab`, `enter`, + `esc`, arrows, `backtab`, `f1`–`f12`), modifier prefixes (`ctrl-`/`alt-`/`shift-`), literal + chars, chords (`]f`). Reserved-word-wins disambiguation. +- **Load + invert:** read every `workon.review.*.bind.*` var (via CS1), split values into key + tokens, build per-view `key → action` maps. A git entry overrides that action's default + (native single-value precedence — no custom layering). Empty value = unbind. +- **Validation + collisions:** unknown `bind.` → footer warning (action set is + enumerable); a key claimed by two actions in one view → footer warning + deterministic + winner. Defaults never collide. +- **Not rebindable, keep hardcoded:** confirm modal (`y`/`n`/`Esc`) and the whole `Esc` + precedence cascade (`tui.rs` `update`). Do not route these through the registry. +- Verify: parser unit tests (each token class incl. `space`, a chord, an unbind, an unknown + action, a collision); a dispatch test asserting a rebind takes effect. `map_key`'s existing + behavior tests must still pass (defaults unchanged). + +### CS3 — Help surface +- **Decision:** persistent curated per-view footer + `?` overlay targeting the focused view. +- **Footer:** always-visible one line of ~5–7 **hand-curated** keys for the focused + context (diff vs outline), rendered from the resolved map + registry descriptions. Updates + on focus/mode change. A transient notice **temporarily replaces** it (notices already clear + on next keypress — `tui.rs` `update`), so no second line. +- **`?` overlay:** new global action `toggle-help` bound to `?`. Centered modal listing the + **focused view's** bindings **+ global** bindings (what's live right now), grouped, from the + resolved registry. Renders the *active* map so user rebinds show. +- Curation: pick the footer key set per view deliberately (this is the "feels learnable" + lever). Diff: nav + stage/discard + outline + help. Outline: nav + open + mode + back. +- Verify: overlay renders resolved (rebound) keys; footer swaps with a notice and returns; + instrument via a log-file + expect harness, NOT ratatui frame grepping (see the TUI-dogfood + memory). + +### CS4 — base16 `Theme` primitive + render-time resolution +- **Decision:** ADR-035. Largest mechanical unit; **behavior-preserving** (dark stays + pixel-identical), so land-alone with no user-visible change. +- Introduce `struct Base16 { base00..base0F }` / `Theme`. Re-express the current `render.rs` + `const` palette + `highlight.rs` accents as the **dark** base16 instance (the existing + values ARE base08–0E + a ramp — see ADR-035). Derive diff tints via the existing + `tint_toward` from base08/base0B toward base00. +- **Hybrid boundary:** on-tint colors resolve from `Theme` slots; chrome stays ANSI-named + (`Color::Gray`/`DarkGray`/…) — several already are. +- **Mechanism:** change `FgSpan` to carry the **capture index** (not a `Color`); + `highlight.rs:283` records `idx`; render resolves `theme.slot[idx]` alongside tint/cursor. + `HIGHLIGHT_NAMES` stays const (index space). Thread `&Theme` into render. +- Verify: existing render/highlight tests that assert concrete colors now resolve through a + fixed test `Theme` (dark) — same asserted colors. Full workspace green. This is the + regression gate that the refactor changed nothing. + +### CS5 — Curated light scheme + `theme = dark|light` +- Add the **light** base16 instance (paste a published base16 light scheme's 16 hexes — do + NOT hand-invent; ADR-035). Wire `workon.review.theme` (via CS1) to select dark/light; + derived tints recompute for light automatically. +- Verify: `theme=light` selects the light instance; tints derive; a render test at light. + +### CS6 — `theme = auto` terminal-derivation probe +- **Decision:** ADR-035. The single most terminal-fragile unit — isolated on purpose. +- OSC probe on the controlling `/dev/tty` (TUI already renders there — `tui.rs`) at startup, + raw mode, short timeout: `OSC 4;n;?` (n=0–15) + `OSC 10/11`. Populate slots from real RGB. +- **Synthesize the 6 slots ANSI lacks** (base01/02/04/06/09/0F) per ADR-035's rules. +- **Fallback chain:** per-slot fallback to curated; total failure → curated by bg luminance + (if `OSC 11` answered) else `dark`. **Never hang** — timeout is the backstop. Make `auto` + the default `theme` value. +- Verify: probe parses a synthetic OSC reply into slots; timeout path falls back to curated + (no hang) — drive with a fake tty/reader, do not depend on the test terminal answering. + +### CS7 — View-config settings +- Read `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (via CS1), + current hardcoded values as defaults. `outline.width` also addresses M5's deferred + narrow-terminal papercut. +- Verify: each setting overrides its default from a fixture config; unset = current default. + +## Cross-cutting notes / gotchas + +- **Testing:** load `/docs testing` before writing any test; FixtureBuilder + custom + predicates, extend predicates before tests. Pin color off in output-asserting tests + (`NO_COLOR`/`no_color()`) — the user env sets `FORCE_COLOR=3` (see memory). +- **TUI verification:** instrument via log-file + `expect`, never grep ratatui frames (memory). +- **Errors:** any new error types follow ADR-008 (concrete enums, `#[derive(Error, Diagnostic)]`); + load `/docs errors` first. +- **Commit style:** Conventional Commits, single line, scope `review`. No body/footer. +- **Gate before landing each CS:** `cargo test --workspace` + clippy + `-D warnings --all-targets --all-features`, then main-thread diff-read. + +## Deferred (explicitly not this pass) +- `theme = ` / user-supplied base16 scheme files (the "user-configurable colors" tier). + Additive later — the slot *source* is pluggable behind render-time resolution (ADR-035). +- Post-subcommand completion delegation (M6 note), git-inference stack model, ref-range + sources (M5), and all of M7 comments onward. diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 7e409412..2aca28f7 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -133,6 +133,7 @@ evidence, not to the conclusion. - **M4 — staging verbs + zoom states.** Queue, hunk/file/line ops (visual-style line selection), the `_gate` zoom matrix, attributed rendering. Acceptance: prototype staging parity, index watcher stable under external writes. Design locked 2026-07-06 (plan artifact `iron-lattice`): (1) staging = prototype parity — verbs act only in unstaged/staged panes, combined refuses, direction = pane role (combined-native toggle deferred); (2) cursor-primary nav in all views, scroll derived; (3) full 4-state zoom (`split→combined→unstaged→staged`) with per-file `_gate` downgrade and stacked split panes (per-pane cursor, `w` focus), no collapse debounce; (4) runtime stays sync — poll `IndexSignature` on Tick, synchronous re-diff (no threads/notify dep); (5) queue enqueue+drain same beat, refresh, re-snapshot; (6) footer-swap for refusals/errors + discard confirm; (7) attribution via a new pure `attribute.rs` (membership sets keyed by lnum); (8) line selection in both layouts (inline one-sided, SBS row-pair). — DONE (2026-07-07): shipped as EIGHT changesets `m4-cursor → m4-zoom → m4-attribute → m4-notify → m4-refresh → m4-stage → m4-select → m4-watch` (staging split into hunk/file vs line selection; refresh pulled out as shared infra for stage + watch). Stack-reviewed continuously on the main thread; two real bugs caught by review, not by agent tests: (a) m4-zoom sub-view panes rendered worktree text where index text belonged — fixed with per-role blob sourcing (`read_index_blob`); (b) m4-select applied a multi-hunk line selection as N independent patches, which libgit2 rejects because each per-hunk patch's line numbering assumes the others are present — fixed by merging into ONE `PatchText` (`ops::apply_line_selections`), pinned by a line-shift tripwire test. Acceptance met: staging parity dogfooded against real git (stage/unstage/discard hunk/file/line, partial-hunk selection); index watcher confirmed live (external `git add` auto-refreshes on the next Tick — the watcher polls `.git/index`'s signature, so it catches index writes, not bare worktree edits, matching its name). Runtime stayed sync (no threads); combined-native staging toggle and spike `--dump`/`--bench` modes remain deferred. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. - **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). +- **M6.5 — everyday-usability pass (keybindings + theming + view-config).** Inserted ahead of M7 (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during M3–M5 (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-034](../adr/034-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-035](../adr/035-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (M7) resume after. - **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). **Comment-store home is a first-class M7 fork, not just its schema:** M9's `git workon mcp` (in the `git-workon` crate) must read comments, making git-workon a *second consumer* of the store — so it cannot live inside the review binary. It belongs in a lib both the review crate and git-workon can depend on (git-workon-lib, or a new shared crate). This reopens the RFC's deferred "no separate core crate until a second consumer exists" decision — resolve it here. - **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. - **M9 — MCP agent loop (`git workon mcp`).** A **first-class `mcp` subcommand of the main `git-workon` binary** (not a review subcommand) starting one stdio MCP server that **bridges both domains**: git-workon-lib worktree tools (`agent-integration.md` Model C — `worktree_create`/`list`/`find`/`remove`/`create_from_pr`) *and* the review comment store (list comments, mark addressed; the TUI reflects changes). One server, one config entry, both capabilities — the unified direction (superseding the earlier "review-comment-only vs unified" fork and the RFC's original `git-workon-review mcp` framing). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once across both surfaces, and because it depends on the M7 comment store living in a shared lib (see M7). Consequence: `git-workon` gains a dependency on the comment-store lib; the worktree-MCP no longer wants a separate `git-workon-mcp` crate. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review — plus worktree tools served from the same `git workon mcp`. From 232c8fabfbb04665e996536a701689d42450a09c Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Tue, 7 Jul 2026 23:54:49 -0400 Subject: [PATCH 02/13] feat(review): add ReviewConfig git-native config reader --- git-workon-review/src/config.rs | 416 ++++++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + 2 files changed, 417 insertions(+) create mode 100644 git-workon-review/src/config.rs diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs new file mode 100644 index 00000000..712c6a7e --- /dev/null +++ b/git-workon-review/src/config.rs @@ -0,0 +1,416 @@ +//! `ReviewConfig` — git-native config reader for the review TUI. +//! +//! Mirrors `git-workon-lib/src/config.rs`'s `WorkonConfig` pattern: reads via +//! `repo.config()` (git2's layered config: local `.git/config` > global `~/.gitconfig` > +//! system), typed getters over `get_string`/`get_i64`/[`git2::Config::entries`]. See +//! [ADR-006](../../../docs/adr/006-git-native-config.md) for the git-native config decision +//! this extends, and [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md) for +//! the `workon.review.*` schema this reads. +//! +//! ## Status +//! +//! CS1 of the everyday-usability pass (see `docs/plans/review-usability-pass.md`): reader +//! infrastructure + typed getters only. Nothing here is wired into rendering or dispatch yet +//! — that's CS2 (keymaps), CS4/CS5/CS6 (theming), and CS7 (view settings). +//! +//! ## Configuration keys +//! +//! ```gitconfig +//! [workon "review"] +//! theme = dark ; auto | dark | light (default: auto) +//! +//! [workon "review.diff.bind"] +//! stage-hunk = s x ; action = key tokens (space-separated) +//! +//! [workon "review.outline.bind"] +//! open = enter +//! +//! [workon "review.bind"] +//! quit = q esc ; bare `review.bind` = global view (active in every view) +//! +//! [workon "review.outline"] +//! width = 32 +//! mode = tree +//! +//! [workon "review.diff"] +//! layout = split +//! zoom = combined +//! ``` + +use git2::Repository; + +/// Which view a keybinding or view-setting applies to. +/// +/// `Global` is the bare `workon.review.bind.` / has no view segment in the config +/// key — active in every view. `Diff` and `Outline` are the per-view namespaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum View { + Global, + Diff, + Outline, +} + +impl View { + /// The config key segment for this view, or `None` for [`View::Global`], which has no + /// segment (`workon.review.bind.`, not `workon.review.global.bind.`). + fn as_key_segment(self) -> Option<&'static str> { + match self { + View::Global => None, + View::Diff => Some("diff"), + View::Outline => Some("outline"), + } + } + + fn parse_segment(segment: &str) -> Option { + match segment { + "diff" => Some(View::Diff), + "outline" => Some(View::Outline), + _ => None, + } + } +} + +/// `workon.review.theme` — see [ADR-035](../../../docs/adr/035-review-theming-base16-hybrid.md). +/// +/// `auto` (terminal-derived) is the spec default; the terminal-derivation probe itself is +/// CS6. Until CS6 lands, callers of [`ReviewConfig::theme`] decide how to treat `Auto`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Theme { + #[default] + Auto, + Dark, + Light, +} + +/// One decomposed `workon.review..bind.` (or bare `workon.review.bind.`) +/// config entry: the raw, unparsed value string. Token-grammar parsing (space/reserved-word/ +/// modifier/chord) is CS2's job — see [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawBinding { + pub view: View, + pub action: String, + /// Space-separated key tokens, unparsed (e.g. `"j down"`, `"]f"`, `""` for an explicit + /// unbind). + pub keys: String, +} + +/// Decompose a fully-qualified config variable name (as returned by +/// [`git2::ConfigEntry::name`]) into its (view, action) components, per ADR-034's grammar: +/// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` +/// is a per-view keymap entry. Returns `None` for anything else under `workon.review.*` +/// (`theme`, a view setting, or an unrecognized shape) — not this reader's job to +/// validate/warn on unknown bind shapes; that's CS2's collision/unknown-action validation +/// pass. View settings and `theme` are read directly by their own getters, not through this. +fn parse_bind_key(name: &str) -> Option<(View, String)> { + let rest = name.strip_prefix("workon.review.")?; + let parts: Vec<&str> = rest.split('.').collect(); + match parts.as_slice() { + ["bind", action] => Some((View::Global, (*action).to_string())), + [view, "bind", action] => { + View::parse_segment(view).map(|view| (view, (*action).to_string())) + } + _ => None, + } +} + +/// Configuration reader for `workon.review.*` settings stored in git config. +/// +/// Mirrors `git-workon-lib`'s `WorkonConfig`: opens the repository's layered config (local > +/// global > system) and exposes typed getters. Unlike `WorkonConfig`, there is no CLI-override +/// precedence here — `git-workon-review`'s CLI takes no relevant flags yet. +pub struct ReviewConfig<'repo> { + repo: &'repo Repository, +} + +impl<'repo> ReviewConfig<'repo> { + /// Create a new config reader for the given repository. + pub fn new(repo: &'repo Repository) -> Self { + Self { repo } + } + + /// Get `workon.review.theme`, parsed into a [`Theme`]. Defaults to [`Theme::Auto`] if + /// unset or unrecognized. + pub fn theme(&self) -> Result { + let config = self.repo.config()?; + let theme = match config.get_string("workon.review.theme") { + Ok(raw) => match raw.as_str() { + "dark" => Theme::Dark, + "light" => Theme::Light, + _ => Theme::Auto, + }, + Err(_) => Theme::Auto, + }; + Ok(theme) + } + + /// Read every `workon.review.*.bind.*` (and bare `workon.review.bind.*`) variable, raw and + /// unparsed — **one [`RawBinding`] per (view, action)**. `git2`'s `entries()` surfaces the + /// same key once per config layer it's set in (a global default AND a local override BOTH + /// appear as separate entries — unlike `get_string`, which honors precedence). So we dedup by + /// (view, action) and read the winning value via `get_string`, giving one binding per pair + /// with git's native precedence (local > global > system) applied. + /// + /// Token-grammar parsing (space/reserved-word/modifier/chord), unknown-action validation, + /// and collision detection are CS2's job — this is the raw read only. + pub fn bindings(&self) -> Result, git2::Error> { + let config = self.repo.config()?; + // Gather each (view, action) once with its fully-qualified key name. The `entries()` + // iterator borrows `config`, so collect names first (deduping shadowed layers), then read + // precedence-correct values via `get_string` after the iterator is dropped. + let mut pairs: Vec<(View, String, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + { + let mut entries = config.entries(Some("workon.review.*"))?; + while let Some(entry) = entries.next() { + let entry = entry?; + let Ok(name) = entry.name() else { + continue; + }; + if let Some((view, action)) = parse_bind_key(name) { + if seen.insert((view, action.clone())) { + pairs.push((view, action, name.to_string())); + } + } + } + } + let mut out = Vec::with_capacity(pairs.len()); + for (view, action, name) in pairs { + let keys = config.get_string(&name)?; + out.push(RawBinding { view, action, keys }); + } + Ok(out) + } + + /// Get `workon.review.outline.width`, raw. `None` if unset — callers apply the current + /// hardcoded default (CS7). + pub fn outline_width(&self) -> Result, git2::Error> { + self.get_view_i64(View::Outline, "width") + } + + /// Get `workon.review.outline.mode`, raw. `None` if unset. + pub fn outline_mode(&self) -> Result, git2::Error> { + self.get_view_string(View::Outline, "mode") + } + + /// Get `workon.review.diff.layout`, raw. `None` if unset. + pub fn diff_layout(&self) -> Result, git2::Error> { + self.get_view_string(View::Diff, "layout") + } + + /// Get `workon.review.diff.zoom`, raw. `None` if unset. + pub fn diff_zoom(&self) -> Result, git2::Error> { + self.get_view_string(View::Diff, "zoom") + } + + /// Build the `workon.review..` key for a view setting (never a `.bind.` + /// entry — [`View::Global`] has no setting namespace, only callers reading `Diff`/`Outline` + /// use this). + fn setting_key(view: View, setting: &str) -> String { + let segment = view + .as_key_segment() + .expect("view settings are only read for Diff/Outline, never Global"); + format!("workon.review.{segment}.{setting}") + } + + fn get_view_string(&self, view: View, setting: &str) -> Result, git2::Error> { + let config = self.repo.config()?; + match config.get_string(&Self::setting_key(view, setting)) { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } + } + + fn get_view_i64(&self, view: View, setting: &str) -> Result, git2::Error> { + let config = self.repo.config()?; + match config.get_i64(&Self::setting_key(view, setting)) { + Ok(val) => Ok(Some(val)), + Err(_) => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use git_workon_fixture::prelude::*; + + use super::*; + + #[test] + fn theme_defaults_to_auto_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let config = ReviewConfig::new(repo); + assert_eq!(config.theme().expect("theme"), Theme::Auto); + } + + #[test] + fn theme_reads_dark_and_light() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!(ReviewConfig::new(repo).theme().expect("theme"), Theme::Dark); + + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "light") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!( + ReviewConfig::new(repo).theme().expect("theme"), + Theme::Light + ); + } + + #[test] + fn theme_falls_back_to_auto_on_unrecognized_value() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "solarized") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert_eq!(ReviewConfig::new(repo).theme().expect("theme"), Theme::Auto); + } + + #[test] + fn bindings_is_empty_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + assert!(ReviewConfig::new(repo) + .bindings() + .expect("bindings") + .is_empty()); + } + + #[test] + fn bindings_decomposes_view_and_global_keys() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.bind.stage-hunk", "s x") + .config("workon.review.outline.bind.open", "enter") + .config("workon.review.bind.quit", "q esc") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + let mut bindings = ReviewConfig::new(repo).bindings().expect("bindings"); + bindings.sort_by(|a, b| a.action.cmp(&b.action)); + + assert_eq!( + bindings, + vec![ + RawBinding { + view: View::Outline, + action: "open".to_string(), + keys: "enter".to_string(), + }, + RawBinding { + view: View::Global, + action: "quit".to_string(), + keys: "q esc".to_string(), + }, + RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "s x".to_string(), + }, + ] + ); + } + + #[test] + fn bindings_ignores_non_binding_keys() { + let fixture = FixtureBuilder::new() + .config("workon.review.theme", "dark") + .config("workon.review.outline.width", "32") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + + assert!(ReviewConfig::new(repo) + .bindings() + .expect("bindings") + .is_empty()); + } + + #[test] + fn bindings_dedups_a_key_set_in_multiple_layers_to_the_winning_value() { + // `git2`'s entries() surfaces a key once per config layer it's set in; bindings() must + // emit ONE RawBinding per (view, action), carrying the precedence-correct (get_string) + // value — not one-per-layer with a shadowed value. Simulate multiple layers with a + // multivar on the local config (two values for one key), which entries() likewise yields + // as two entries and get_string resolves to the last. + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let cfg_path = repo.path().join("config"); + for v in ["q", "x"] { + let status = std::process::Command::new("git") + .args([ + "config", + "--file", + cfg_path.to_str().expect("config path utf8"), + "--add", + "workon.review.bind.quit", + v, + ]) + .status() + .expect("git config --add"); + assert!(status.success(), "git config --add failed"); + } + + let bindings = ReviewConfig::new(repo).bindings().expect("bindings"); + let quit: Vec<_> = bindings + .iter() + .filter(|b| b.view == View::Global && b.action == "quit") + .collect(); + assert_eq!( + quit.len(), + 1, + "one binding per (view, action), not one per config layer; got {bindings:?}" + ); + assert_eq!( + quit[0].keys, "x", + "get_string resolves the multivar to its last/winning value" + ); + } + + #[test] + fn view_settings_read_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "40") + .config("workon.review.outline.mode", "tree") + .config("workon.review.diff.layout", "split") + .config("workon.review.diff.zoom", "staged") + .build() + .expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert_eq!(config.outline_width().expect("width"), Some(40)); + assert_eq!( + config.outline_mode().expect("mode"), + Some("tree".to_string()) + ); + assert_eq!( + config.diff_layout().expect("layout"), + Some("split".to_string()) + ); + assert_eq!( + config.diff_zoom().expect("zoom"), + Some("staged".to_string()) + ); + } + + #[test] + fn view_settings_default_to_none_when_unset() { + let fixture = FixtureBuilder::new().build().expect("fixture build"); + let repo = fixture.repo().expect("repo"); + let config = ReviewConfig::new(repo); + + assert_eq!(config.outline_width().expect("width"), None); + assert_eq!(config.outline_mode().expect("mode"), None); + assert_eq!(config.diff_layout().expect("layout"), None); + assert_eq!(config.diff_zoom().expect("zoom"), None); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 0a62bc4d..1f78c899 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -17,6 +17,7 @@ pub mod align; pub mod app; pub mod apply; pub mod attribute; +pub mod config; pub mod error; pub mod file_ops; pub mod highlight; From 0435e4125f67cc9d2454264e757a025401228dcc Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 00:24:54 -0400 Subject: [PATCH 03/13] feat(review): make keybindings git-config configurable --- git-workon-review/src/keymap.rs | 924 ++++++++++++++++++++++++++++++++ git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 20 +- git-workon-review/src/tui.rs | 350 +++++++----- 4 files changed, 1147 insertions(+), 148 deletions(-) create mode 100644 git-workon-review/src/keymap.rs diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs new file mode 100644 index 00000000..aa7f16c7 --- /dev/null +++ b/git-workon-review/src/keymap.rs @@ -0,0 +1,924 @@ +//! Action registry, token-grammar parser, and per-view keymap resolution for the review TUI. +//! +//! Replaces `tui.rs`'s formerly-hardcoded `map_key` match with a registry-driven, git-config +//! overridable dispatch (see [ADR-034](../../../docs/adr/034-review-git-native-config-schema.md)). +//! +//! The pieces: +//! - [`Command`] — the enumerable set of *rebindable* actions. This is the compatibility surface +//! ADR-034 calls out: each variant maps to a stable git-config action name (`stage-hunk`, +//! `next-file`, …) in a [`View`] namespace, plus a default key-token string and a human +//! description. The static [`REGISTRY`] table is the single source of truth for all three. +//! - [`parse_value`] — the token-grammar parser: a space-separated config value becomes a set of +//! alternative key *sequences* (a chord like `]f` is one two-key sequence; `j down` is two +//! single-key alternatives). Reserved symbolic names win over literals. +//! - [`Keymap`] — resolves the registry defaults against a repo's [`RawBinding`]s (a git entry +//! overrides that action's default; an empty value unbinds), builds per-view +//! sequence→command lookup lists, and drives dispatch through [`Keymap::advance`]. Unknown +//! action names and same-view key collisions are collected as [`Keymap::warnings`]. +//! +//! **Not handled here** (stays hardcoded in `tui.rs`): the confirm modal (`y`/`n`/`Esc`) and the +//! whole `Esc`-precedence cascade (confirm > outline-unfocus > selection-cancel > quit). Per +//! ADR-034 those are conventional and safety-sensitive; they are never routed through the +//! registry, so `Esc` is not a registry token. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::config::{RawBinding, View}; + +/// One rebindable action. The action *identity* — distinct from `tui.rs`'s `Action`, which is the +/// concrete effect applied to the `App` (and carries runtime data like a half-page scroll delta +/// that depends on the pane height at dispatch time). `tui.rs` converts a resolved [`Command`] +/// into its `Action`. +/// +/// Every variant appears exactly once in [`REGISTRY`], which pins its config name, view, default +/// keys, and description. Renaming a variant's config name is a breaking change to users' git +/// config (ADR-034's compatibility-surface consequence). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Command { + // Global (active in every view). + Quit, + ToggleOutline, + // Diff view. + CursorDown, + CursorUp, + HalfPageDown, + HalfPageUp, + ScrollTop, + ScrollBottom, + ToggleLayout, + CycleZoom, + ToggleSplitFocus, + Refresh, + StageHunk, + StageFile, + DiscardHunk, + DiscardFile, + StartSelection, + NextFile, + PrevFile, + NextHunk, + PrevHunk, + NextChangeset, + PrevChangeset, + // Outline view. + OutlineDown, + OutlineUp, + OutlineConfirm, + OutlineCycleMode, +} + +/// One row of the action registry: a [`Command`] with its stable config identity (`view` + +/// `name`), the default key tokens that reproduce the pre-config hardcoded binding, and a human +/// description (the help overlay in CS3 renders from this). +#[derive(Debug, Clone, Copy)] +pub struct Registered { + pub command: Command, + pub view: View, + pub name: &'static str, + pub default_keys: &'static str, + pub description: &'static str, +} + +/// The action registry — the single source of truth for defaults, config names, and help text. +/// +/// Order is load-bearing in two ways: **global entries come first** so that on a key collision +/// between a global and a per-view action the global wins (preserving `o`/`q` always working), and +/// within a view earlier entries win later ones (the documented deterministic collision rule). The +/// `default_keys` strings reproduce `tui.rs`'s exact pre-ADR-034 bindings — `Esc` is deliberately +/// absent (it stays hardcoded, see the module doc). +pub static REGISTRY: &[Registered] = &[ + // ── Global ─────────────────────────────────────────────────────────────── + Registered { + command: Command::Quit, + view: View::Global, + name: "quit", + default_keys: "q", + description: "Quit the review", + }, + Registered { + command: Command::ToggleOutline, + view: View::Global, + name: "toggle-outline", + default_keys: "o", + description: "Toggle the outline pane / focus", + }, + // ── Diff view ──────────────────────────────────────────────────────────── + Registered { + command: Command::CursorDown, + view: View::Diff, + name: "cursor-down", + default_keys: "j down", + description: "Move cursor down one line", + }, + Registered { + command: Command::CursorUp, + view: View::Diff, + name: "cursor-up", + default_keys: "k up", + description: "Move cursor up one line", + }, + Registered { + command: Command::HalfPageDown, + view: View::Diff, + name: "half-page-down", + default_keys: "ctrl-d", + description: "Scroll down half a page", + }, + Registered { + command: Command::HalfPageUp, + view: View::Diff, + name: "half-page-up", + default_keys: "ctrl-u", + description: "Scroll up half a page", + }, + Registered { + command: Command::ScrollTop, + view: View::Diff, + name: "scroll-top", + default_keys: "g", + description: "Jump to the top", + }, + Registered { + command: Command::ScrollBottom, + view: View::Diff, + name: "scroll-bottom", + default_keys: "G", + description: "Jump to the bottom", + }, + Registered { + command: Command::ToggleLayout, + view: View::Diff, + name: "toggle-layout", + default_keys: "L", + description: "Toggle side-by-side / inline layout", + }, + Registered { + command: Command::CycleZoom, + view: View::Diff, + name: "cycle-zoom", + default_keys: "z", + description: "Cycle the staged/unstaged zoom", + }, + Registered { + command: Command::ToggleSplitFocus, + view: View::Diff, + name: "toggle-split-focus", + default_keys: "w", + description: "Switch focus between split panes", + }, + Registered { + command: Command::Refresh, + view: View::Diff, + name: "refresh", + default_keys: "r", + description: "Refresh the review", + }, + Registered { + command: Command::StageHunk, + view: View::Diff, + name: "stage-hunk", + default_keys: "s", + description: "Stage the hunk (or selection) under the cursor", + }, + Registered { + command: Command::StageFile, + view: View::Diff, + name: "stage-file", + default_keys: "S", + description: "Stage the whole file", + }, + Registered { + command: Command::DiscardHunk, + view: View::Diff, + name: "discard-hunk", + default_keys: "d", + description: "Discard the hunk (or selection) under the cursor", + }, + Registered { + command: Command::DiscardFile, + view: View::Diff, + name: "discard-file", + default_keys: "D", + description: "Discard the whole file", + }, + Registered { + command: Command::StartSelection, + view: View::Diff, + name: "start-selection", + default_keys: "v", + description: "Start a line selection", + }, + Registered { + command: Command::NextFile, + view: View::Diff, + name: "next-file", + default_keys: "tab ]f", + description: "Go to the next file", + }, + Registered { + command: Command::PrevFile, + view: View::Diff, + name: "prev-file", + default_keys: "backtab [f", + description: "Go to the previous file", + }, + Registered { + command: Command::NextHunk, + view: View::Diff, + name: "next-hunk", + default_keys: "]h", + description: "Go to the next hunk", + }, + Registered { + command: Command::PrevHunk, + view: View::Diff, + name: "prev-hunk", + default_keys: "[h", + description: "Go to the previous hunk", + }, + Registered { + command: Command::NextChangeset, + view: View::Diff, + name: "next-changeset", + default_keys: "]c", + description: "Go to the next changeset", + }, + Registered { + command: Command::PrevChangeset, + view: View::Diff, + name: "prev-changeset", + default_keys: "[c", + description: "Go to the previous changeset", + }, + // ── Outline view ───────────────────────────────────────────────────────── + Registered { + command: Command::OutlineDown, + view: View::Outline, + name: "cursor-down", + default_keys: "j down", + description: "Move the outline cursor down", + }, + Registered { + command: Command::OutlineUp, + view: View::Outline, + name: "cursor-up", + default_keys: "k up", + description: "Move the outline cursor up", + }, + Registered { + command: Command::OutlineConfirm, + view: View::Outline, + name: "open", + default_keys: "enter", + description: "Jump to the selected outline entry", + }, + Registered { + command: Command::OutlineCycleMode, + view: View::Outline, + name: "cycle-mode", + default_keys: "i", + description: "Cycle the outline mode", + }, +]; + +/// One matchable key press: a [`KeyCode`] plus whether Ctrl/Alt are required. **Shift is +/// deliberately not tracked** — an uppercase literal (`G`, `S`) already carries the shift in its +/// char, and crossterm is inconsistent about setting the modifier for it (and for `BackTab`), so +/// matching ignores it. Ctrl/Alt are the only load-bearing modifiers in the token grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeyPress { + pub code: KeyCode, + pub ctrl: bool, + pub alt: bool, +} + +impl KeyPress { + /// Normalize an incoming crossterm [`KeyEvent`] into a matchable [`KeyPress`], dropping Shift + /// (see the type's doc comment). + pub fn from_event(event: KeyEvent) -> Self { + KeyPress { + code: event.code, + ctrl: event.modifiers.contains(KeyModifiers::CONTROL), + alt: event.modifiers.contains(KeyModifiers::ALT), + } + } +} + +/// A single trigger: one key ([`j`]) or an ordered multi-key chord (`]f` → `]` then `f`). An +/// action may have several alternatives (`j` *or* `down`), each its own [`KeySeq`]. +pub type KeySeq = Vec; + +/// Map a reserved symbolic token name to its [`KeyCode`]. These win over literal interpretation +/// (ADR-034: `space` is always the spacebar, never a chord of `s p a c e`). `None` for anything +/// not a reserved name. +fn reserved_code(name: &str) -> Option { + let code = match name { + "space" => KeyCode::Char(' '), + "tab" => KeyCode::Tab, + "enter" => KeyCode::Enter, + "esc" => KeyCode::Esc, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "backtab" => KeyCode::BackTab, + _ => { + // f1..=f12 + if let Some(n) = name.strip_prefix('f').and_then(|d| d.parse::().ok()) { + if (1..=12).contains(&n) { + return Some(KeyCode::F(n)); + } + } + return None; + } + }; + Some(code) +} + +/// Parse one whitespace-delimited token into a key sequence, per ADR-034's grammar: +/// strip `ctrl-`/`alt-`/`shift-` modifier prefixes, then interpret the remainder as a reserved +/// symbolic name (wins), a single literal char, or — failing both — a multi-char chord (each char +/// one key press). Returns `None` for an empty or all-prefix token (`""`, `ctrl-`). +fn parse_token(token: &str) -> Option { + let mut rest = token; + let mut ctrl = false; + let mut alt = false; + loop { + if let Some(r) = rest.strip_prefix("ctrl-") { + ctrl = true; + rest = r; + } else if let Some(r) = rest.strip_prefix("alt-") { + alt = true; + rest = r; + } else if let Some(r) = rest.strip_prefix("shift-") { + // Shift is not tracked in matching (see [`KeyPress`]); accept the prefix so a user's + // `shift-` token parses, but it contributes no modifier bit. + rest = r; + } else { + break; + } + } + + if rest.is_empty() { + return None; + } + + // Reserved word wins over any literal interpretation. + if let Some(code) = reserved_code(rest) { + return Some(vec![KeyPress { code, ctrl, alt }]); + } + + let chars: Vec = rest.chars().collect(); + if chars.len() == 1 { + return Some(vec![KeyPress { + code: KeyCode::Char(chars[0]), + ctrl, + alt, + }]); + } + // A chord: each char becomes one press in the sequence. A modifier prefix (rare on a chord) + // applies to every press. + Some( + chars + .into_iter() + .map(|c| KeyPress { + code: KeyCode::Char(c), + ctrl, + alt, + }) + .collect(), + ) +} + +/// Parse a full config value (space-separated tokens) into its alternative key sequences. An empty +/// or whitespace-only value yields no alternatives — an explicit *unbind* (ADR-034). Unparseable +/// tokens are skipped. +pub fn parse_value(value: &str) -> Vec { + value.split_whitespace().filter_map(parse_token).collect() +} + +/// Render a key sequence back to a human-readable token (for warnings and the help overlay). +pub fn render_seq(seq: &[KeyPress]) -> String { + seq.iter().map(render_press).collect() +} + +fn render_press(press: &KeyPress) -> String { + let mut out = String::new(); + if press.ctrl { + out.push_str("ctrl-"); + } + if press.alt { + out.push_str("alt-"); + } + let name = match press.code { + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::BackTab => "backtab".to_string(), + KeyCode::F(n) => format!("f{n}"), + other => format!("{other:?}"), + }; + out.push_str(&name); + out +} + +/// The outcome of feeding one key to the keymap (see [`Keymap::advance`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dispatch { + /// The keys so far are a strict prefix of a bound sequence — buffer retained, await more. + Pending, + /// A bound sequence matched exactly — buffer cleared. + Command(Command), + /// Nothing matched — buffer cleared. `mid_sequence` is `true` when this ended a partial chord + /// (so the caller does NOT re-process the key, matching the old bracket-drop behavior); `false` + /// for a fresh single key that matched nothing (where the caller may apply a hardcoded + /// fallback, e.g. `Esc`). + Unmatched { mid_sequence: bool }, +} + +enum MatchResult { + Pending, + Fire(Command), + NoMatch, +} + +/// A resolved, per-view keymap: the registry defaults with a repo's git-config overrides applied, +/// inverted into sequence→command lookup lists for the diff and outline contexts (each includes +/// the always-active global bindings). +pub struct Keymap { + /// Active bindings when the diff has focus: global ∪ diff, in registry order (global first, so + /// a global binding wins a collision). Scanned by [`Self::match_keys`]. + diff: Vec<(KeySeq, Command)>, + /// Active bindings when the outline has focus: global ∪ outline. + outline: Vec<(KeySeq, Command)>, + /// Resolved key sequences per registry row (parallel to [`REGISTRY`]) — the source for the + /// help overlay's "current keys for this action" (CS3). + resolved: Vec>, + /// Config problems collected during resolution (unknown action names, key collisions) — the + /// caller surfaces these through the footer-notice mechanism at startup. + warnings: Vec, +} + +impl Keymap { + /// The registry defaults with no config applied — the pre-ADR-034 hardcoded keymap. Used by + /// the binary when config reading is unavailable, and by dispatch tests. + pub fn defaults() -> Self { + Self::from_bindings(&[]) + } + + /// Resolve the registry defaults against `bindings` (from [`crate::config::ReviewConfig::bindings`]). + /// Each [`RawBinding`] replaces its action's default keys (empty value = unbind); an unknown + /// action name is collected as a warning rather than panicking. Then invert into the per-view + /// lookup lists, warning on (and deterministically resolving) any same-context key collision. + pub fn from_bindings(bindings: &[RawBinding]) -> Self { + let mut warnings = Vec::new(); + + // 1. Seed each registry row with its parsed default keys. + let mut resolved: Vec> = REGISTRY + .iter() + .map(|entry| parse_value(entry.default_keys)) + .collect(); + + // 2. Apply git-config overrides. `bindings` already carries git's native single-value + // precedence per (view, action), so each just replaces that row. + for rb in bindings { + match REGISTRY + .iter() + .position(|entry| entry.view == rb.view && entry.name == rb.action) + { + Some(idx) => resolved[idx] = parse_value(&rb.keys), + None => warnings.push(format!( + "unknown review keybinding action '{}' in {} view (ignored)", + rb.action, + view_label(rb.view) + )), + } + } + + // 3. Invert into the two context lookup lists, detecting collisions. + let diff = build_context(&resolved, View::Diff, &mut warnings); + let outline = build_context(&resolved, View::Outline, &mut warnings); + + warnings.dedup(); + + Self { + diff, + outline, + resolved, + warnings, + } + } + + /// Config problems found during resolution (empty for a clean/default config). + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// The resolved key sequences currently bound to `command` (for the help overlay). Empty when + /// the action is unbound. + pub fn keys_for(&self, command: Command) -> &[KeySeq] { + REGISTRY + .iter() + .position(|entry| entry.command == command) + .map(|idx| self.resolved[idx].as_slice()) + .unwrap_or(&[]) + } + + /// Feed one key press, advancing `buffer` (the in-flight sequence) and reporting the outcome. + /// Owns all buffer bookkeeping: retained on [`Dispatch::Pending`], cleared otherwise. + pub fn advance( + &self, + outline_focused: bool, + buffer: &mut Vec, + key: KeyEvent, + ) -> Dispatch { + buffer.push(KeyPress::from_event(key)); + match self.match_keys(outline_focused, buffer) { + MatchResult::Pending => Dispatch::Pending, + MatchResult::Fire(command) => { + buffer.clear(); + Dispatch::Command(command) + } + MatchResult::NoMatch => { + let mid_sequence = buffer.len() > 1; + buffer.clear(); + Dispatch::Unmatched { mid_sequence } + } + } + } + + /// Match the current `buffer` against the active context's bindings. A strict-prefix match + /// takes precedence over an exact one (so a multi-key sequence is never cut short by a shorter + /// binding — the defaults never have both for the same buffer anyway). + fn match_keys(&self, outline_focused: bool, buffer: &[KeyPress]) -> MatchResult { + let list = if outline_focused { + &self.outline + } else { + &self.diff + }; + let mut exact: Option = None; + let mut has_prefix = false; + for (seq, command) in list { + if seq.len() > buffer.len() && seq[..buffer.len()] == *buffer { + has_prefix = true; + } else if seq.as_slice() == buffer { + exact.get_or_insert(*command); + } + } + if has_prefix { + MatchResult::Pending + } else if let Some(command) = exact { + MatchResult::Fire(command) + } else { + MatchResult::NoMatch + } + } +} + +/// Build one context's active binding list: every global row plus every row of `view`, in +/// registry order (global first). On a key sequence already claimed in this context, the +/// first-seen (registry-order) command wins and a collision warning is recorded. +fn build_context( + resolved: &[Vec], + view: View, + warnings: &mut Vec, +) -> Vec<(KeySeq, Command)> { + let mut out: Vec<(KeySeq, Command)> = Vec::new(); + for (idx, entry) in REGISTRY.iter().enumerate() { + if entry.view != View::Global && entry.view != view { + continue; + } + for seq in &resolved[idx] { + if let Some((_, winner)) = out.iter().find(|(existing, _)| existing == seq) { + warnings.push(format!( + "key '{}' is bound to both {} and {} in the {} view; {} wins", + render_seq(seq), + command_label(*winner), + command_label(entry.command), + view_label(view), + command_label(*winner), + )); + } else { + out.push((seq.clone(), entry.command)); + } + } + } + out +} + +/// The config action name for a command (for collision warnings) — its registry `name`. +fn command_label(command: Command) -> &'static str { + REGISTRY + .iter() + .find(|entry| entry.command == command) + .map(|entry| entry.name) + .unwrap_or("?") +} + +fn view_label(view: View) -> &'static str { + match view { + View::Global => "global", + View::Diff => "diff", + View::Outline => "outline", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn press(c: char) -> KeyPress { + KeyPress { + code: KeyCode::Char(c), + ctrl: false, + alt: false, + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Feed a whole sequence of key events, returning the terminal [`Dispatch`]. + fn feed(km: &Keymap, outline_focused: bool, events: &[KeyEvent]) -> Dispatch { + let mut buffer = Vec::new(); + let mut last = Dispatch::Unmatched { + mid_sequence: false, + }; + for &ev in events { + last = km.advance(outline_focused, &mut buffer, ev); + } + last + } + + // ── Parser ─────────────────────────────────────────────────────────────── + + #[test] + fn parses_a_literal_single_char() { + assert_eq!(parse_value("s"), vec![vec![press('s')]]); + } + + #[test] + fn parses_a_reserved_word() { + assert_eq!( + parse_value("enter"), + vec![vec![KeyPress { + code: KeyCode::Enter, + ctrl: false, + alt: false, + }]] + ); + } + + #[test] + fn space_reserved_word_wins_over_a_chord_of_its_letters() { + // "space" must be the spacebar, not a five-key chord of s, p, a, c, e. + assert_eq!( + parse_value("space"), + vec![vec![KeyPress { + code: KeyCode::Char(' '), + ctrl: false, + alt: false, + }]] + ); + } + + #[test] + fn parses_a_ctrl_modifier() { + assert_eq!( + parse_value("ctrl-d"), + vec![vec![KeyPress { + code: KeyCode::Char('d'), + ctrl: true, + alt: false, + }]] + ); + } + + #[test] + fn parses_a_two_key_chord() { + assert_eq!(parse_value("]f"), vec![vec![press(']'), press('f')]]); + } + + #[test] + fn parses_multiple_alternatives() { + assert_eq!( + parse_value("j down"), + vec![ + vec![press('j')], + vec![KeyPress { + code: KeyCode::Down, + ctrl: false, + alt: false, + }], + ] + ); + } + + #[test] + fn empty_value_is_an_unbind() { + assert!(parse_value("").is_empty()); + assert!(parse_value(" ").is_empty()); + } + + #[test] + fn f_keys_and_arrows_parse() { + assert_eq!( + parse_value("f5"), + vec![vec![KeyPress { + code: KeyCode::F(5), + ctrl: false, + alt: false, + }]] + ); + } + + // ── Resolution ───────────────────────────────────────────────────────────── + + #[test] + fn defaults_reproduce_the_hardcoded_bindings() { + let km = Keymap::defaults(); + assert!(km.warnings().is_empty(), "defaults never collide"); + // A representative spread across token classes. + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Command(Command::StageHunk) + ); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char(']')), key(KeyCode::Char('f'))] + ), + Dispatch::Command(Command::NextFile) + ); + assert_eq!( + feed( + &km, + false, + &[KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)] + ), + Dispatch::Command(Command::HalfPageDown) + ); + assert_eq!( + feed(&km, false, &[key(KeyCode::Tab)]), + Dispatch::Command(Command::NextFile) + ); + // Global works from the outline context too. + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('o'))]), + Dispatch::Command(Command::ToggleOutline) + ); + assert_eq!( + feed(&km, true, &[key(KeyCode::Char('j'))]), + Dispatch::Command(Command::OutlineDown) + ); + } + + #[test] + fn a_config_rebind_overrides_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + assert!(km.warnings().is_empty()); + // The new key fires the action… + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('x'))]), + Dispatch::Command(Command::StageHunk) + ); + // …and the old default no longer does (it's now unbound). + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Unmatched { + mid_sequence: false + } + ); + } + + #[test] + fn an_empty_value_unbinds_the_action() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + assert!(km.keys_for(Command::StageHunk).is_empty()); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('s'))]), + Dispatch::Unmatched { + mid_sequence: false + } + ); + } + + #[test] + fn an_unknown_action_warns_without_panicking() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "frobnicate".to_string(), + keys: "x".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert!(km.warnings()[0].contains("frobnicate")); + } + + #[test] + fn a_collision_warns_and_resolves_deterministically() { + // Rebind stage-hunk onto `j`, which already means cursor-down in the diff view. + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "j".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert!(km.warnings()[0].contains("cursor-down")); + assert!(km.warnings()[0].contains("stage-hunk")); + // Registry order: cursor-down is declared before stage-hunk, so it wins. + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('j'))]), + Dispatch::Command(Command::CursorDown) + ); + } + + #[test] + fn a_global_binding_wins_a_collision_with_a_view_binding() { + // Rebind diff's refresh onto `o`, the global toggle-outline key. + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "refresh".to_string(), + keys: "o".to_string(), + }]); + assert_eq!(km.warnings().len(), 1); + assert_eq!( + feed(&km, false, &[key(KeyCode::Char('o'))]), + Dispatch::Command(Command::ToggleOutline) + ); + } + + // ── Dispatch / sequences ────────────────────────────────────────────────── + + #[test] + fn a_partial_chord_reports_pending_then_fires() { + let km = Keymap::defaults(); + let mut buffer = Vec::new(); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char(']'))), + Dispatch::Pending + ); + assert_eq!(buffer.len(), 1, "the prefix key is retained"); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char('h'))), + Dispatch::Command(Command::NextHunk) + ); + assert!(buffer.is_empty(), "the buffer clears once the chord fires"); + } + + #[test] + fn an_unrecognized_chord_suffix_drops_the_buffer() { + let km = Keymap::defaults(); + let mut buffer = Vec::new(); + km.advance(false, &mut buffer, key(KeyCode::Char(']'))); + assert_eq!( + km.advance(false, &mut buffer, key(KeyCode::Char('x'))), + Dispatch::Unmatched { mid_sequence: true } + ); + assert!(buffer.is_empty()); + } + + #[test] + fn a_rebound_chord_still_works() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "gs".to_string(), + }]); + assert_eq!( + feed( + &km, + false, + &[key(KeyCode::Char('g')), key(KeyCode::Char('s'))] + ), + Dispatch::Command(Command::StageHunk) + ); + } +} diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 1f78c899..bf6e9951 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -21,6 +21,7 @@ pub mod config; pub mod error; pub mod file_ops; pub mod highlight; +pub mod keymap; pub mod model; pub mod ops; pub mod outline; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 49607ce4..b2737078 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -5,7 +5,9 @@ use clap_complete::env::CompleteEnv; use git2::Repository; use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changeset, resolve_changesets}; -use workon_review::app::{App, ChangesetView}; +use workon_review::app::{App, ChangesetView, Severity}; +use workon_review::config::ReviewConfig; +use workon_review::keymap::Keymap; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -44,13 +46,27 @@ fn main() -> Result<()> { return Ok(()); } + // Resolve the keymap from git config once at startup, BEFORE `repo` moves into `App` + // (ADR-034). A failed config read degrades to the registry defaults rather than aborting the + // review. Collision/unknown-action warnings surface through the footer notice below. + let keymap = match ReviewConfig::new(&repo).bindings() { + Ok(bindings) => Keymap::from_bindings(&bindings), + Err(_) => Keymap::defaults(), + }; + // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here after // acquisition is done borrowing it. `App::from_changesets` opens on whichever changeset the // lib marked `current` (locked decision #6). let mut app = App::from_changesets(repo, views); app.open_current(); - tui::run(&mut app).into_diagnostic()?; + // A misconfigured keybinding is non-fatal: show the collected warnings as a startup notice + // (cleared on the first keypress, like any notice) and run with the defaults for those keys. + if !keymap.warnings().is_empty() { + app.notify(keymap.warnings().join("; "), Severity::Error); + } + + tui::run(&mut app, &keymap).into_diagnostic()?; Ok(()) } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 212d5c2b..7b9fa908 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -14,7 +14,7 @@ use std::fs::File; use std::io::{self, Write}; use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -22,6 +22,7 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use workon_review::app::App; +use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the @@ -81,84 +82,80 @@ enum Action { None, } -/// Map one key press to an [`Action`], given `pending` (a `]` or `[` seen on the previous call, -/// awaiting its `f`/`h` suffix), the current pane height (for `Ctrl-d`/`Ctrl-u` half-page -/// deltas), and whether the outline pane currently has focus. Unrecognized suffixes drop the -/// pending bracket rather than re-processing the key. +/// Convert a resolved rebindable [`Command`] into the concrete [`Action`] the loop applies, +/// supplying the runtime context the registry can't hold — here, the pane height that sizes a +/// half-page scroll (`Ctrl-d`/`Ctrl-u`). This is the seam between the config-driven keymap and the +/// hardcoded action effects. +fn command_to_action(command: Command, pane_height: usize) -> Action { + let half_page = (pane_height / 2).max(1) as i64; + match command { + Command::Quit => Action::Quit, + Command::ToggleOutline => Action::ToggleOutline, + Command::CursorDown => Action::MoveCursorBy(1), + Command::CursorUp => Action::MoveCursorBy(-1), + Command::HalfPageDown => Action::MoveCursorBy(half_page), + Command::HalfPageUp => Action::MoveCursorBy(-half_page), + Command::ScrollTop => Action::ScrollTop, + Command::ScrollBottom => Action::ScrollBottom, + Command::ToggleLayout => Action::ToggleLayout, + Command::CycleZoom => Action::CycleZoom, + Command::ToggleSplitFocus => Action::ToggleSplitFocus, + Command::Refresh => Action::Refresh, + Command::StageHunk => Action::StageHunk, + Command::StageFile => Action::StageFile, + Command::DiscardHunk => Action::DiscardHunk, + Command::DiscardFile => Action::DiscardFile, + Command::StartSelection => Action::StartSelection, + Command::NextFile => Action::NextFile, + Command::PrevFile => Action::PrevFile, + Command::NextHunk => Action::NextHunk, + Command::PrevHunk => Action::PrevHunk, + Command::NextChangeset => Action::NextChangeset, + Command::PrevChangeset => Action::PrevChangeset, + Command::OutlineDown => Action::OutlineMoveBy(1), + Command::OutlineUp => Action::OutlineMoveBy(-1), + Command::OutlineConfirm => Action::OutlineConfirm, + Command::OutlineCycleMode => Action::OutlineCycleMode, + } +} + +/// Map one key press to an [`Action`] through the resolved [`Keymap`], given `pending` (the +/// in-flight multi-key sequence buffer — generalized from the old `]`/`[` bracket chord to ANY +/// bound sequence), the current pane height (for the half-page deltas), and whether the outline +/// pane currently has focus. /// -/// `outline_focused` re-routes the plain single-key map (NOT the bracket-pending path, which is -/// diff-only chording that can't be mid-flight while the outline has focus) to the outline's own -/// small key set (locked design: "only outline-relevant keys — `j k Enter i o Esc` — act" while -/// it has focus). `o` always toggles regardless of focus (checked before the split) since it's -/// the one key that must work from EITHER side to move focus between panes; `q` still quits from -/// either side too — the locked design only enumerates outline-focused keys, it doesn't say `q` -/// should stop working. +/// Dispatch order: +/// 1. The keymap ([`Keymap::advance`]) consumes the key. A bound sequence fires its command; a +/// strict prefix reports [`Dispatch::Pending`] and holds the buffer for the next key; an +/// unrecognized suffix mid-sequence drops the buffer without re-processing (the old +/// bracket-drop behavior, now general). +/// 2. `Esc` stays HARDCODED (ADR-034: the whole `Esc`-precedence cascade is never routed through +/// the registry). Reached only as a fresh, otherwise-unbound key: it unfocuses the outline when +/// the outline has focus, else quits — the terminal leaf of the cascade `update` enforces. +/// +/// `outline_focused` selects the keymap's outline vs diff context; the global bindings (`q`/`o`) +/// are active in both, so `o` toggles and `q` quits from either pane. fn map_key( - pending: &mut Option, + keymap: &Keymap, + pending: &mut Vec, key: KeyEvent, pane_height: usize, outline_focused: bool, ) -> Action { - if let Some(bracket) = pending.take() { - return match (bracket, key.code) { - (']', KeyCode::Char('f')) => Action::NextFile, - ('[', KeyCode::Char('f')) => Action::PrevFile, - (']', KeyCode::Char('h')) => Action::NextHunk, - ('[', KeyCode::Char('h')) => Action::PrevHunk, - (']', KeyCode::Char('c')) => Action::NextChangeset, - ('[', KeyCode::Char('c')) => Action::PrevChangeset, - _ => Action::None, - }; - } - - if key.code == KeyCode::Char('o') { - return Action::ToggleOutline; - } - - if outline_focused { - return match key.code { - KeyCode::Char('q') => Action::Quit, - KeyCode::Char('j') | KeyCode::Down => Action::OutlineMoveBy(1), - KeyCode::Char('k') | KeyCode::Up => Action::OutlineMoveBy(-1), - KeyCode::Enter => Action::OutlineConfirm, - KeyCode::Char('i') => Action::OutlineCycleMode, - KeyCode::Esc => Action::OutlineUnfocus, - _ => Action::None, - }; - } - - match key.code { - KeyCode::Char('q') | KeyCode::Esc => Action::Quit, - KeyCode::Char('j') | KeyCode::Down => Action::MoveCursorBy(1), - KeyCode::Char('k') | KeyCode::Up => Action::MoveCursorBy(-1), - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::MoveCursorBy((pane_height / 2).max(1) as i64) - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Action::MoveCursorBy(-((pane_height / 2).max(1) as i64)) - } - KeyCode::Char('g') => Action::ScrollTop, - KeyCode::Char('G') => Action::ScrollBottom, - KeyCode::Char('L') => Action::ToggleLayout, - KeyCode::Char('z') => Action::CycleZoom, - KeyCode::Char('w') => Action::ToggleSplitFocus, - KeyCode::Char('r') => Action::Refresh, - KeyCode::Char('s') => Action::StageHunk, - KeyCode::Char('S') => Action::StageFile, - KeyCode::Char('d') => Action::DiscardHunk, - KeyCode::Char('D') => Action::DiscardFile, - KeyCode::Char('v') => Action::StartSelection, - KeyCode::Tab => Action::NextFile, - KeyCode::BackTab => Action::PrevFile, - KeyCode::Char(']') => { - *pending = Some(']'); - Action::None - } - KeyCode::Char('[') => { - *pending = Some('['); - Action::None + match keymap.advance(outline_focused, pending, key) { + Dispatch::Command(command) => command_to_action(command, pane_height), + Dispatch::Pending => Action::None, + Dispatch::Unmatched { mid_sequence } => { + if !mid_sequence && key.code == KeyCode::Esc { + if outline_focused { + Action::OutlineUnfocus + } else { + Action::Quit + } + } else { + Action::None + } } - _ => Action::None, } } @@ -220,7 +217,7 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// /// A `Key` event clears any showing footer notice before applying its own action (cases 2-4); the /// confirm modal (case 1) deliberately does not. -fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { +fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { match key.code { @@ -245,7 +242,7 @@ fn update(app: &mut App, pending: &mut Option, event: AppEvent) -> bool { app.clear_notice(); apply_action( app, - map_key(pending, key, app.pane_height, app.outline_focused()), + map_key(keymap, pending, key, app.pane_height, app.outline_focused()), ) } AppEvent::Tick => { @@ -288,7 +285,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -296,7 +293,7 @@ pub fn run(app: &mut App) -> io::Result<()> { let backend = CrosstermBackend::new(out); let mut terminal = Terminal::new(backend)?; - let result = event_loop(&mut terminal, app); + let result = event_loop(&mut terminal, app, keymap); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -308,8 +305,9 @@ pub fn run(app: &mut App) -> io::Result<()> { fn event_loop( terminal: &mut Terminal>, app: &mut App, + keymap: &Keymap, ) -> io::Result<()> { - let mut pending: Option = None; + let mut pending: Vec = Vec::new(); let mut quit = false; loop { @@ -320,13 +318,15 @@ fn event_loop( } if let Some(event) = next_event(Duration::from_millis(200))? { - quit = update(app, &mut pending, event); + quit = update(app, keymap, &mut pending, event); } } } #[cfg(test)] mod tests { + use crossterm::event::KeyModifiers; + use super::*; fn key(code: KeyCode) -> KeyEvent { @@ -339,163 +339,175 @@ mod tests { #[test] fn quit_keys_map_to_quit() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('q')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('q')), 20, false), Action::Quit ); assert_eq!( - map_key(&mut pending, key(KeyCode::Esc), 20, false), + map_key(&km, &mut pending, key(KeyCode::Esc), 20, false), Action::Quit ); } #[test] fn scroll_keys_map_by_one_line() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('j')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('j')), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Down), 20, false), + map_key(&km, &mut pending, key(KeyCode::Down), 20, false), Action::MoveCursorBy(1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('k')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('k')), 20, false), Action::MoveCursorBy(-1) ); assert_eq!( - map_key(&mut pending, key(KeyCode::Up), 20, false), + map_key(&km, &mut pending, key(KeyCode::Up), 20, false), Action::MoveCursorBy(-1) ); } #[test] fn ctrl_d_u_scroll_by_half_the_pane_height() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, ctrl_key('d'), 21, false), + map_key(&km, &mut pending, ctrl_key('d'), 21, false), Action::MoveCursorBy(10) ); assert_eq!( - map_key(&mut pending, ctrl_key('u'), 21, false), + map_key(&km, &mut pending, ctrl_key('u'), 21, false), Action::MoveCursorBy(-10) ); // A pane height of 1 still scrolls by at least one line. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 1, false), + map_key(&km, &mut pending, ctrl_key('d'), 1, false), Action::MoveCursorBy(1) ); } #[test] fn g_and_shift_g_map_to_top_and_bottom() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('g')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('g')), 20, false), Action::ScrollTop ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('G')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('G')), 20, false), Action::ScrollBottom ); } #[test] fn shift_l_maps_to_toggle_layout() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('L')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('L')), 20, false), Action::ToggleLayout ); } #[test] fn z_and_w_map_to_zoom_and_split_focus() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('z')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('z')), 20, false), Action::CycleZoom ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('w')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('w')), 20, false), Action::ToggleSplitFocus ); } #[test] fn r_maps_to_refresh() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('r')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('r')), 20, false), Action::Refresh ); } #[test] fn tab_and_backtab_map_to_file_nav() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Tab), 20, false), + map_key(&km, &mut pending, key(KeyCode::Tab), 20, false), Action::NextFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::BackTab), 20, false), + map_key(&km, &mut pending, key(KeyCode::BackTab), 20, false), Action::PrevFile ); } #[test] fn bracket_f_maps_to_file_nav() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char(']')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false), Action::None ); - assert_eq!(pending, Some(']')); + // The buffer holds the in-flight chord prefix (generalized from the old `Option`). + assert_eq!(pending, vec![KeyPress::from_event(key(KeyCode::Char(']')))]); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), Action::NextFile ); - assert_eq!(pending, None); + assert!(pending.is_empty()); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('[')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false), Action::None ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('f')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('f')), 20, false), Action::PrevFile ); } #[test] fn bracket_h_maps_to_hunk_nav() { - let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20, false); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), Action::NextHunk ); - map_key(&mut pending, key(KeyCode::Char('[')), 20, false); + map_key(&km, &mut pending, key(KeyCode::Char('[')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('h')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('h')), 20, false), Action::PrevHunk ); } #[test] fn unrecognized_bracket_suffix_drops_pending_without_side_effect() { - let mut pending = None; - map_key(&mut pending, key(KeyCode::Char(']')), 20, false); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + map_key(&km, &mut pending, key(KeyCode::Char(']')), 20, false); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('x')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('x')), 20, false), Action::None ); - assert_eq!( - pending, None, + assert!( + pending.is_empty(), "pending bracket must be cleared, not left dangling" ); } @@ -526,7 +538,8 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); app.notify("something happened", Severity::Info); assert!(app.notice.is_some()); @@ -534,6 +547,7 @@ mod tests { // Any key — even one that maps to no action — dismisses the notice. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('x'))), ); @@ -554,10 +568,12 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('r'))), ); @@ -578,14 +594,15 @@ mod tests { .build() .unwrap(); let mut app = app_from_fixture(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); app.notify("something happened", Severity::Info); - update(&mut app, &mut pending, AppEvent::Tick); + update(&mut app, &km, &mut pending, AppEvent::Tick); assert!(app.notice.is_some(), "a Tick event must not clear a notice"); - update(&mut app, &mut pending, AppEvent::Resize(80, 24)); + update(&mut app, &km, &mut pending, AppEvent::Resize(80, 24)); assert!( app.notice.is_some(), "a Resize event must not clear a notice" @@ -603,13 +620,14 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // A plain Tick with nothing changed externally must be a safe no-op wired all the way // through `update` — the smoke test for M4's index-watcher hookup (the substantive // signature-change/echo-suppression assertions live in `app.rs`'s own `on_tick` tests, // which have direct access to its private state). - let quit = update(&mut app, &mut pending, AppEvent::Tick); + let quit = update(&mut app, &km, &mut pending, AppEvent::Tick); assert!(!quit, "Tick must never quit the loop"); assert_eq!(app.files().len(), 1); @@ -618,35 +636,37 @@ mod tests { #[test] fn staging_keys_map_to_their_actions() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('s')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('s')), 20, false), Action::StageHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('S')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('S')), 20, false), Action::StageFile ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('d')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('d')), 20, false), Action::DiscardHunk ); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('D')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('D')), 20, false), Action::DiscardFile ); // Ctrl-d keeps its half-page meaning — the plain-`d` staging arm must not shadow it. assert_eq!( - map_key(&mut pending, ctrl_key('d'), 20, false), + map_key(&km, &mut pending, ctrl_key('d'), 20, false), Action::MoveCursorBy(10) ); } #[test] fn v_maps_to_start_selection() { - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); assert_eq!( - map_key(&mut pending, key(KeyCode::Char('v')), 20, false), + map_key(&km, &mut pending, key(KeyCode::Char('v')), 20, false), Action::StartSelection ); } @@ -663,18 +683,29 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // Lowest precedence: with neither a confirm nor a selection up, Esc quits. assert!( - update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))), + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)) + ), "Esc quits when nothing modal is active" ); // Middle precedence: an active selection makes Esc cancel the selection (not quit). app.start_selection(); assert!(app.selection_anchor.is_some()); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while a selection is active"); assert!( app.selection_anchor.is_none(), @@ -684,7 +715,12 @@ mod tests { // Highest precedence: a pending confirm captures Esc as a cancel, even with a selection up. app.start_selection(); app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while a confirm is pending"); assert!( app.pending_confirm.is_none(), @@ -704,7 +740,8 @@ mod tests { .unwrap(); let mut app = app_from_fixture(&fixture); app.open_current(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // A pending confirm makes every non-answer key a no-op — the cursor doesn't move and the // confirm stays up. @@ -712,6 +749,7 @@ mod tests { let cursor_before = app.cursor; update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('j'))), ); @@ -727,6 +765,7 @@ mod tests { // `n` cancels it. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('n'))), ); @@ -736,6 +775,7 @@ mod tests { app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('y'))), ); @@ -808,12 +848,14 @@ mod tests { .build() .unwrap(); let mut app = two_committed_changesets_app(&fixture); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // Default: open, unfocused. assert!(app.outline_open() && !app.outline_focused()); update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -821,6 +863,7 @@ mod tests { update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -831,6 +874,7 @@ mod tests { update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('o'))), ); @@ -855,12 +899,14 @@ mod tests { let diff_cursor_before = app.cursor; let diff_file_before = app.current; let outline_cursor_before = app.outline_cursor(); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); // `k` (not `j`): the outline cursor starts on the last row (cs-b's file, since it's the // active/current changeset), so `j` would clamp in place — `k` has room to move. update( &mut app, + &km, &mut pending, AppEvent::Key(key(KeyCode::Char('k'))), ); @@ -891,9 +937,15 @@ mod tests { app.toggle_outline(); // close app.toggle_outline(); // open + focus assert!(app.outline_focused()); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); - let quit = update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Esc))); + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Esc)), + ); assert!(!quit, "Esc must not quit while the outline has focus"); assert!( @@ -920,9 +972,15 @@ mod tests { assert!(app.outline_focused()); // Move the outline cursor up onto cs-a's header row. app.outline_move_by(-3); - let mut pending = None; + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); - update(&mut app, &mut pending, AppEvent::Key(key(KeyCode::Enter))); + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Enter)), + ); assert_eq!( app.current_cs(), From a2db9541e64ff7951110b5aad1dabfab04ca2447 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 00:41:34 -0400 Subject: [PATCH 04/13] feat(review): add help footer and ? overlay --- git-workon-review/src/app.rs | 24 ++++ git-workon-review/src/keymap.rs | 240 ++++++++++++++++++++++++++++++++ git-workon-review/src/render.rs | 164 +++++++++++++++++++--- git-workon-review/src/tui.rs | 160 +++++++++++++++++++-- 4 files changed, 562 insertions(+), 26 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0c5f851c..9dcd901f 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -659,6 +659,10 @@ pub struct App { /// rebuilt-from-scratch — `open`/`focused`/`mode` persist, like [`Self::layout`]/ /// [`Self::zoom`]) by every diff-initiated nav and by [`Self::refresh`]. outline: OutlineState, + /// Whether the `?` help overlay is showing (CS3). While `true`, `tui::update` intercepts + /// every key as a modal (mirroring [`Self::pending_confirm`]'s capture) — see its doc comment + /// for the precedence between the two modals. + pub help_visible: bool, } /// A destructive staging op deferred behind a [`Confirm`], identified by index into [`App::files`] @@ -791,6 +795,7 @@ impl App { selection_anchor: None, refresh_coordinator, outline, + help_visible: false, }; // Position the outline cursor on the changeset/file the lib marked `current` (the same // row `sync_outline_to_current` would reposition to after any diff-initiated nav) rather @@ -1435,6 +1440,13 @@ impl App { } } + /// `?`: toggle the help overlay (CS3). A plain flip — the overlay always renders whatever + /// view currently has keyboard focus (see `render::render_help_overlay`), so there is no + /// extra state to reposition here, unlike [`Self::toggle_outline`]'s three-state cycle. + pub fn toggle_help(&mut self) { + self.help_visible = !self.help_visible; + } + /// Return focus to the diff without closing the outline (`Esc` while the outline has focus — /// `tui::update` routes it here instead of quitting, per the locked design's "Esc must still /// not quit when the outline has focus"). @@ -4753,6 +4765,18 @@ mod tests { ); } + #[test] + fn toggle_help_flips_help_visible() { + let mut app = two_committed_changesets_two_and_one_files(); + assert!(!app.help_visible, "help is closed by default"); + + app.toggle_help(); + assert!(app.help_visible, "toggle_help opens it"); + + app.toggle_help(); + assert!(!app.help_visible, "toggle_help closes it again"); + } + #[test] fn outline_cycle_mode_round_trips_all_four_modes() { let mut app = two_committed_changesets_two_and_one_files(); diff --git a/git-workon-review/src/keymap.rs b/git-workon-review/src/keymap.rs index aa7f16c7..1250927b 100644 --- a/git-workon-review/src/keymap.rs +++ b/git-workon-review/src/keymap.rs @@ -38,6 +38,7 @@ pub enum Command { // Global (active in every view). Quit, ToggleOutline, + ToggleHelp, // Diff view. CursorDown, CursorUp, @@ -102,6 +103,13 @@ pub static REGISTRY: &[Registered] = &[ default_keys: "o", description: "Toggle the outline pane / focus", }, + Registered { + command: Command::ToggleHelp, + view: View::Global, + name: "toggle-help", + default_keys: "?", + description: "Toggle the help overlay", + }, // ── Diff view ──────────────────────────────────────────────────────────── Registered { command: Command::CursorDown, @@ -624,6 +632,137 @@ fn build_context( out } +/// One row of the `?` help overlay: an action's resolved key label (space-joined alternatives, +/// e.g. `"tab ]f"`) and its registry description. Built by [`help_sections`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelpEntry { + pub keys: String, + pub description: &'static str, +} + +/// One titled group of [`HelpEntry`] rows in the help overlay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelpSection { + pub title: &'static str, + pub entries: Vec, +} + +/// Build the help overlay's content for `focused` (the view with keyboard focus — [`View::Diff`] +/// or [`View::Outline`]; never [`View::Global`]): a "Global" section, then the focused view's own +/// section, each listing only BOUND actions (an action with no resolved keys — user-unbound — is +/// skipped, per CS3). Pure and `Keymap`-driven — the display never hardcodes a key string, so a +/// rebind shows here automatically. +pub fn help_sections(keymap: &Keymap, focused: View) -> Vec { + vec![ + HelpSection { + title: "Global", + entries: entries_for_view(keymap, View::Global), + }, + HelpSection { + title: view_label_title(focused), + entries: entries_for_view(keymap, focused), + }, + ] +} + +fn entries_for_view(keymap: &Keymap, view: View) -> Vec { + REGISTRY + .iter() + .filter(|entry| entry.view == view) + .filter_map(|entry| { + let seqs = keymap.keys_for(entry.command); + if seqs.is_empty() { + return None; + } + let keys = seqs + .iter() + .map(|seq| render_seq(seq)) + .collect::>() + .join(" "); + Some(HelpEntry { + keys, + description: entry.description, + }) + }) + .collect() +} + +fn view_label_title(view: View) -> &'static str { + match view { + View::Global => "Global", + View::Diff => "Diff", + View::Outline => "Outline", + } +} + +/// The resolved key label for the FIRST alternative bound to `command` (for the curated footer +/// hint, which only has room for one key per action), or `None` when unbound. +fn primary_key(keymap: &Keymap, command: Command) -> Option { + keymap.keys_for(command).first().map(|seq| render_seq(seq)) +} + +/// One curated footer entry: a resolved key label paired with a short verb, or an up/down PAIR +/// collapsed to a single `down/up verb` entry (e.g. `j/k move`) when both resolve. +enum HintItem { + One(Command, &'static str), + Pair(Command, Command, &'static str), +} + +fn render_hint_item(keymap: &Keymap, item: &HintItem) -> Option { + match item { + HintItem::One(command, label) => { + primary_key(keymap, *command).map(|k| format!("{k} {label}")) + } + HintItem::Pair(down, up, label) => { + match (primary_key(keymap, *down), primary_key(keymap, *up)) { + (Some(d), Some(u)) => Some(format!("{d}/{u} {label}")), + (Some(d), None) => Some(format!("{d} {label}")), + (None, Some(u)) => Some(format!("{u} {label}")), + (None, None) => None, + } + } + } +} + +/// The diff view's curated footer hint set (locked design in CS3): nav, stage/discard, outline, +/// help, quit — ~5-7 entries picked to make the tool feel learnable, not an exhaustive list. +const DIFF_HINTS: &[HintItem] = &[ + HintItem::Pair(Command::CursorDown, Command::CursorUp, "move"), + HintItem::One(Command::StageHunk, "stage"), + HintItem::One(Command::DiscardHunk, "discard"), + HintItem::One(Command::ToggleOutline, "outline"), + HintItem::One(Command::ToggleHelp, "help"), + HintItem::One(Command::Quit, "quit"), +]; + +/// The outline view's curated footer hint set (locked design in CS3). +const OUTLINE_HINTS: &[HintItem] = &[ + HintItem::Pair(Command::OutlineDown, Command::OutlineUp, "move"), + HintItem::One(Command::OutlineConfirm, "open"), + HintItem::One(Command::OutlineCycleMode, "mode"), + HintItem::One(Command::ToggleOutline, "outline"), + HintItem::One(Command::ToggleHelp, "help"), + HintItem::One(Command::Quit, "quit"), +]; + +/// Build the persistent, always-visible footer hint string for `focused` ([`View::Diff`] or +/// [`View::Outline`]; never [`View::Global`]) from the resolved `keymap` — never a hardcoded key +/// string, so a rebind shows here too. A notice temporarily replaces this in the footer (the +/// caller's job, see `render::render_footer`); an unbound curated action is simply dropped from +/// the string rather than leaving a stale/wrong key visible. +pub fn footer_hint(keymap: &Keymap, focused: View) -> String { + let items: &[HintItem] = match focused { + View::Diff => DIFF_HINTS, + View::Outline => OUTLINE_HINTS, + View::Global => &[], + }; + items + .iter() + .filter_map(|item| render_hint_item(keymap, item)) + .collect::>() + .join(" \u{b7} ") +} + /// The config action name for a command (for collision warnings) — its registry `name`. fn command_label(command: Command) -> &'static str { REGISTRY @@ -921,4 +1060,105 @@ mod tests { Dispatch::Command(Command::StageHunk) ); } + + // ── CS3: help overlay / footer hint builders ──────────────────────────── + + #[test] + fn help_sections_groups_global_and_the_focused_view_only() { + let km = Keymap::defaults(); + let sections = help_sections(&km, View::Diff); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].title, "Global"); + assert_eq!(sections[1].title, "Diff"); + // Outline-only actions never leak into the diff-focused overlay. + assert!(!sections.iter().any(|s| s + .entries + .iter() + .any(|e| e.description.contains("outline cursor")))); + + let outline_sections = help_sections(&km, View::Outline); + assert_eq!(outline_sections[1].title, "Outline"); + } + + #[test] + fn help_sections_skip_an_unbound_action() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + let sections = help_sections(&km, View::Diff); + let diff = §ions[1]; + assert!( + !diff + .entries + .iter() + .any(|e| e.description.contains("Stage the hunk")), + "an unbound action must not appear in the help overlay" + ); + } + + #[test] + fn help_sections_render_a_rebound_key_not_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + let sections = help_sections(&km, View::Diff); + let diff = §ions[1]; + let stage_row = diff + .entries + .iter() + .find(|e| e.description.contains("Stage the hunk")) + .expect("stage-hunk row present"); + assert_eq!(stage_row.keys, "x", "the overlay must show the REBOUND key"); + } + + #[test] + fn footer_hint_renders_the_curated_diff_entries() { + let km = Keymap::defaults(); + let hint = footer_hint(&km, View::Diff); + assert!(hint.contains("j/k move"), "got: {hint:?}"); + assert!(hint.contains("s stage"), "got: {hint:?}"); + assert!(hint.contains("d discard"), "got: {hint:?}"); + assert!(hint.contains("o outline"), "got: {hint:?}"); + assert!(hint.contains("? help"), "got: {hint:?}"); + assert!(hint.contains("q quit"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_renders_the_curated_outline_entries() { + let km = Keymap::defaults(); + let hint = footer_hint(&km, View::Outline); + assert!(hint.contains("j/k move"), "got: {hint:?}"); + assert!(hint.contains("enter open"), "got: {hint:?}"); + assert!(hint.contains("i mode"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_renders_a_rebound_key_not_the_default() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + let hint = footer_hint(&km, View::Diff); + assert!(hint.contains("x stage"), "got: {hint:?}"); + assert!(!hint.contains("s stage"), "got: {hint:?}"); + } + + #[test] + fn footer_hint_drops_an_unbound_curated_action_rather_than_a_stale_key() { + let km = Keymap::from_bindings(&[RawBinding { + view: View::Diff, + action: "stage-hunk".to_string(), + keys: String::new(), + }]); + let hint = footer_hint(&km, View::Diff); + assert!(!hint.contains("stage"), "got: {hint:?}"); + // The rest of the curated set is unaffected. + assert!(hint.contains("d discard"), "got: {hint:?}"); + } } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index d0087d95..19e36a00 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -9,13 +9,15 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span as TSpan}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::align::{CellKind, DisplayRow, InlineRow, Row}; use crate::app::{App, EffectiveZoom, FileView, Layout as AppLayout, Notice, Role, Severity}; use crate::attribute::Attribution; +use crate::config::View; use crate::highlight::FgSpan; +use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; use crate::wordiff::Span as WordSpan; @@ -341,8 +343,11 @@ fn build_pane_line( } } -/// Render one frame: header, SBS body, footer. -pub fn render(frame: &mut Frame, app: &mut App) { +/// Render one frame: header, SBS body, footer, and (when [`App::help_visible`]) the `?` overlay +/// on top of everything else. `keymap` is the resolved, possibly-rebound keymap — the footer hint +/// and help overlay render its ACTUAL bindings (see [`crate::keymap::footer_hint`]/ +/// [`crate::keymap::help_sections`]), never a hardcoded key string. +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -358,7 +363,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { let footer_area = vlayout[2]; render_header(frame, app, header_area); - render_footer(frame, app, footer_area); + render_footer(frame, app, footer_area, keymap); if app.outline_open() { let hlayout = Layout::default() @@ -383,6 +388,68 @@ pub fn render(frame: &mut Frame, app: &mut App) { // Closed: the diff takes the full body width — the exact M4 look (locked design). render_body(frame, app, body_area); } + + if app.help_visible { + render_help_overlay(frame, app, keymap, area); + } +} + +/// Compute a centered `percent_x` × `percent_y` sub-rect of `area` — the standard ratatui popup +/// pattern (two nested percentage splits). +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +/// The `?` help overlay (CS3): a centered, bordered modal listing the focused view's + global +/// bindings, from the resolved `keymap` (never hardcoded — see [`crate::keymap::help_sections`]). +/// Focused view = outline when the outline pane has focus, else diff. [`Clear`] wipes the popup +/// area first so the diff content underneath doesn't show through the gaps between glyphs. +fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect) { + let focused = if app.outline_focused() { + View::Outline + } else { + View::Diff + }; + let sections = help_sections(keymap, focused); + + let mut lines: Vec = Vec::new(); + for section in §ions { + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.push(Line::from(TSpan::styled( + section.title, + Style::default().add_modifier(Modifier::BOLD), + ))); + for entry in §ion.entries { + lines.push(Line::from(format!( + " {:<10} {}", + entry.keys, entry.description + ))); + } + } + + let popup_area = centered_rect(60, 60, area); + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .title(" Help (?/q/Esc to close) "); + frame.render_widget(Paragraph::new(lines).block(block), popup_area); } /// Render the outline side pane's rows into `area`: [`OutlineItem::Header`]s (Stack mode only) @@ -570,8 +637,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { } /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, -/// which wins over the dim hint line. -fn render_footer(frame: &mut Frame, app: &App, area: Rect) { +/// which wins over the curated hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather +/// than adding a second row; it clears on the user's next keypress (`tui::update`). +fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), @@ -592,17 +660,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } None => { // While the outline has focus, only outline-relevant keys act (locked design) — the - // diff-editing hint would be actively misleading, so show the outline's own hint - // instead. - let text = if app.outline_focused() { - "j/k move Enter jump i mode o unfocus Esc unfocus q quit" - } else if app.is_committed() { - // A committed changeset is locked to the combined view (locked decision #2) — `z` - // zoom and `w` split-focus have nothing to act on, so drop them from the hint. - "j/k scroll v select s/S stage d/D discard q quit" + // diff-editing hint would be actively misleading, so show the outline's own curated + // hint instead. Built from the resolved `keymap`, never a hardcoded key string, so a + // rebind shows here too (see [`crate::keymap::footer_hint`]). + let focused = if app.outline_focused() { + View::Outline } else { - "j/k scroll v select s/S stage d/D discard z zoom w focus q quit" + View::Diff }; + let text = footer_hint(keymap, focused); frame.render_widget( Paragraph::new(text).style(Style::default().fg(FG_DIM)), area, @@ -1098,11 +1164,16 @@ mod tests { use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; + use crate::keymap::Keymap; + /// Render one frame against the default (unrebound) keymap — the vast majority of `render.rs` + /// tests don't care about keybindings at all. Tests that DO (the footer/overlay content tests) + /// build their own [`Keymap`] and call [`render`] directly instead. fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| render(f, app)).unwrap(); + let keymap = Keymap::defaults(); + terminal.draw(|f| render(f, app, &keymap)).unwrap(); terminal.backend().buffer().clone() } @@ -1721,8 +1792,65 @@ mod tests { .map(|x| cell_text(&buf, x, footer_y)) .collect(); assert!( - footer.contains("j/k scroll"), - "expected the hint string in the footer, got: {footer:?}" + footer.contains("j/k move") && footer.contains("? help"), + "expected the curated diff hint string in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_shows_the_outline_hint_when_the_outline_has_focus() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + // A lone uncommitted changeset never auto-opens the outline (M4 default) — force it open + // + focused so `render_footer` takes the outline-focused branch. + app.toggle_outline(); + assert!(app.outline_focused()); + + let buf = render_once(&mut app, 80, 10); + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("open") && footer.contains("mode") && footer.contains("? help"), + "expected the curated outline hint string in the footer, got: {footer:?}" + ); + } + + #[test] + fn footer_renders_a_rebound_key_not_the_default() { + use crate::config::RawBinding; + use crate::config::View as CfgView; + use crate::keymap::Keymap; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + assert!(app.notice.is_none()); + + let keymap = Keymap::from_bindings(&[RawBinding { + view: CfgView::Diff, + action: "stage-hunk".to_string(), + keys: "x".to_string(), + }]); + + let backend = TestBackend::new(80, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &mut app, &keymap)).unwrap(); + let buf = terminal.backend().buffer().clone(); + + let footer_y = buf.area.height - 1; + let footer: String = (0..buf.area.width) + .map(|x| cell_text(&buf, x, footer_y)) + .collect(); + assert!( + footer.contains("x stage") && !footer.contains("s stage"), + "expected the REBOUND key in the footer, got: {footer:?}" ); } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 7b9fa908..d0460739 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -56,6 +56,7 @@ pub fn next_event(timeout: Duration) -> io::Result> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Action { Quit, + ToggleHelp, MoveCursorBy(i64), ScrollTop, ScrollBottom, @@ -91,6 +92,7 @@ fn command_to_action(command: Command, pane_height: usize) -> Action { match command { Command::Quit => Action::Quit, Command::ToggleOutline => Action::ToggleOutline, + Command::ToggleHelp => Action::ToggleHelp, Command::CursorDown => Action::MoveCursorBy(1), Command::CursorUp => Action::MoveCursorBy(-1), Command::HalfPageDown => Action::MoveCursorBy(half_page), @@ -163,6 +165,7 @@ fn map_key( fn apply_action(app: &mut App, action: Action) -> bool { match action { Action::Quit => return true, + Action::ToggleHelp => app.toggle_help(), Action::MoveCursorBy(delta) => app.move_cursor_by(delta), Action::ScrollTop => app.scroll_top(), Action::ScrollBottom => app.scroll_bottom(), @@ -200,23 +203,29 @@ fn apply_action(app: &mut App, action: Action) -> bool { /// message and performs its normal action. `Resize`/`Tick` do NOT clear it: a redraw or timer /// tick isn't the user acting on the message. /// -/// Esc precedence (highest first): a pending discard confirm > the outline having focus > an -/// active line selection > the normal key map (where Esc quits). Concretely: +/// Esc precedence (highest first): a pending discard confirm > the help overlay being open > the +/// outline having focus > an active line selection > the normal key map (where Esc quits). +/// Concretely: /// /// 1. A pending discard confirm captures the keyboard FIRST (before the notice clear and the /// normal key map): `y` accepts, `n`/`Esc` cancels, and every other key is swallowed — a modal /// that neither clears the notice nor runs a normal action while it's up. -/// 2. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal +/// 2. Otherwise, the help overlay (`?`) captures the keyboard next, mirroring the confirm modal's +/// swallow: `?`/`q`/`Esc` close it, every other key is a no-op (nothing on the diff behind it +/// reacts). Ranked just below the confirm modal — in practice the two are never up +/// together, since opening help doesn't run through a confirm, but the confirm winning keeps +/// a destructive prompt from ever being silently dismissed by a stray overlay key. +/// 3. Otherwise, while the outline pane has focus, Esc returns focus to the diff (via the normal /// map's `outline_focused` branch — see [`map_key`]) rather than quitting or falling into the /// selection-cancel case below (locked design: "Esc must still not quit when the outline has /// focus"). The selection-Esc arm below is guarded to defer to this case. -/// 3. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` +/// 4. Otherwise, with an active line selection, Esc CANCELS the selection instead of quitting (`q` /// still quits). Other keys fall through to the normal map — `j`/`k` extend the selection, /// `s`/`d` act on it. -/// 4. Otherwise the normal map applies, where Esc (like `q`) quits. +/// 5. Otherwise the normal map applies, where Esc (like `q`) quits. /// -/// A `Key` event clears any showing footer notice before applying its own action (cases 2-4); the -/// confirm modal (case 1) deliberately does not. +/// A `Key` event clears any showing footer notice before applying its own action (cases 3-5); the +/// confirm and help modals (cases 1-2) deliberately do not. fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: AppEvent) -> bool { match event { AppEvent::Key(key) if app.pending_confirm.is_some() => { @@ -229,6 +238,13 @@ fn update(app: &mut App, keymap: &Keymap, pending: &mut Vec, event: Ap } false } + AppEvent::Key(key) if app.help_visible => { + match key.code { + KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Esc => app.toggle_help(), + _ => {} + } + false + } AppEvent::Key(key) if app.selection_anchor.is_some() && key.code == KeyCode::Esc @@ -311,7 +327,7 @@ fn event_loop( let mut quit = false; loop { - terminal.draw(|f| render::render(f, app))?; + terminal.draw(|f| render::render(f, app, keymap))?; if quit { return Ok(()); @@ -993,4 +1009,132 @@ mod tests { "Enter returns focus to the diff after jumping" ); } + + // ── CS3: help overlay ─────────────────────────────────────────────────── + + #[test] + fn question_mark_opens_the_help_overlay() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + assert!(!app.help_visible); + + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('?'))), + ); + assert!(app.help_visible, "? opens the help overlay"); + } + + #[test] + fn while_help_is_open_other_keys_are_swallowed() { + use git_workon_fixture::prelude::*; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + assert!(app.help_visible); + let cursor_before = app.cursor; + + // `j` would normally move the cursor — while help is up it must be a pure no-op, exactly + // like the pending-confirm modal's swallow. + let quit = update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('j'))), + ); + + assert!(!quit); + assert!( + app.help_visible, + "an unrelated key must not close the overlay" + ); + assert_eq!( + app.cursor, cursor_before, + "a swallowed key must not run its normal action" + ); + } + + #[test] + fn question_mark_q_and_esc_all_close_the_help_overlay() { + use git_workon_fixture::prelude::*; + + for close_key in [ + key(KeyCode::Char('?')), + key(KeyCode::Char('q')), + key(KeyCode::Esc), + ] { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + assert!(app.help_visible); + + let quit = update(&mut app, &km, &mut pending, AppEvent::Key(close_key)); + + assert!(!quit, "closing help must not also quit the app"); + assert!( + !app.help_visible, + "{close_key:?} must close the help overlay" + ); + } + } + + #[test] + fn a_pending_confirm_still_wins_over_an_open_help_overlay() { + use git_workon_fixture::prelude::*; + use workon_review::app::PendingOp; + + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("a.txt", "one\ntwo\n", "one\nCHANGED\n") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + app.open_current(); + let km = Keymap::defaults(); + let mut pending: Vec = Vec::new(); + + app.toggle_help(); + app.request_confirm("Discard? (y/n)", PendingOp::DiscardFile { file_idx: 0 }); + + // `y` while BOTH modals are up must resolve the confirm (case 1 wins per `update`'s + // documented precedence), not close help or fall through to a normal action. + update( + &mut app, + &km, + &mut pending, + AppEvent::Key(key(KeyCode::Char('y'))), + ); + + assert!( + app.pending_confirm.is_none(), + "the confirm modal must capture y first" + ); + assert!( + app.help_visible, + "the confirm arm must not have touched help_visible" + ); + } } From 53da676aa41ca62c62f4ace3e67999821399165b Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:52:20 -0400 Subject: [PATCH 05/13] feat(review): configure outline width/mode and diff layout/zoom --- git-workon-review/src/app.rs | 315 +++++++++++++++++++++++++++++++- git-workon-review/src/config.rs | 27 +++ git-workon-review/src/main.rs | 23 ++- git-workon-review/src/render.rs | 4 +- 4 files changed, 361 insertions(+), 8 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 9dcd901f..0e9204b1 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -18,6 +18,7 @@ use workon::{Changeset, ChangesetSource}; use crate::acquire::{ChangesetDiff, WorktreeDiffs}; use crate::align::{align_file, collapse_gaps, inline_rows, CellKind, DisplayRow, InlineRow, Row}; use crate::apply::{Git2Applier, StageVerb}; +use crate::config::RawViewConfig; use crate::highlight::{FgSpan, TsHighlighter}; use crate::model::{DiffModel, FileChange, FileStatus, Hunk, LineKind}; use crate::ops; @@ -344,6 +345,19 @@ fn read_workdir_file(repo: &Repository, path: &str) -> String { .unwrap_or_default() } +/// Default outline pane width (locked design: "~35 cols") — the CS7 +/// (`workon.review.outline.width`) fallback when the setting is unset, out of range, or the +/// config read fails. Was a `render.rs`-local const before CS7; now App-owned state since it's +/// configurable per session (see [`OutlineState::width`]). +pub const DEFAULT_OUTLINE_WIDTH: u16 = 35; +/// Sane clamp bounds for `workon.review.outline.width` (CS7). Below `MIN_OUTLINE_WIDTH` the +/// pane can't show a useful path fragment; above `MAX_OUTLINE_WIDTH` it would swallow the diff +/// pane on any reasonable terminal. Also addresses M5's deferred narrow-terminal papercut: a +/// user on a narrow terminal can now set a smaller width instead of losing the diff pane +/// entirely to a fixed 35-col outline. +pub const MIN_OUTLINE_WIDTH: u16 = 10; +pub const MAX_OUTLINE_WIDTH: u16 = 200; + /// Which layout the renderer draws the current file's rows in — runtime-toggled via `L` /// (prototype analog: `rl`), and persists across file navigation (neither /// [`App::next_file`]/[`App::prev_file`] nor [`App::open_current`] touch it). @@ -440,6 +454,43 @@ pub fn effective_zoom( } } +/// Parse `workon.review.outline.mode` (CS7) into an [`OutlineMode`]. Canonical strings mirror +/// the variant names, kebab-cased: `flat`, `stack`, `tree`, `stack-tree`. `None` on anything +/// else — [`App::apply_view_config`] falls back to [`OutlineMode::default`] and warns. +fn parse_outline_mode(raw: &str) -> Option { + match raw { + "flat" => Some(OutlineMode::Flat), + "stack" => Some(OutlineMode::Stack), + "tree" => Some(OutlineMode::Tree), + "stack-tree" => Some(OutlineMode::StackTree), + _ => None, + } +} + +/// Parse `workon.review.diff.layout` (CS7) into a [`Layout`]. Canonical strings mirror the +/// variant names: `sbs`, `inline`. `None` on anything else — [`App::apply_view_config`] falls +/// back to [`Layout::default`] and warns. +fn parse_diff_layout(raw: &str) -> Option { + match raw { + "sbs" => Some(Layout::Sbs), + "inline" => Some(Layout::Inline), + _ => None, + } +} + +/// Parse `workon.review.diff.zoom` (CS7) into a [`Zoom`]. Canonical strings mirror the variant +/// names: `split`, `combined`, `unstaged`, `staged`. `None` on anything else — +/// [`App::apply_view_config`] falls back to [`Zoom::default`] and warns. +fn parse_diff_zoom(raw: &str) -> Option { + match raw { + "split" => Some(Zoom::Split), + "combined" => Some(Zoom::Combined), + "unstaged" => Some(Zoom::Unstaged), + "staged" => Some(Zoom::Staged), + _ => None, + } +} + /// The outline side pane's own state (locked fork 3): whether it's showing, whether IT (rather /// than the diff) currently has keyboard focus, its own cursor (an index into /// [`App::outline_items`]'s row list — a wholly separate coordinate space from [`App::cursor`]), @@ -451,6 +502,9 @@ pub struct OutlineState { pub focused: bool, pub cursor: usize, pub mode: OutlineMode, + /// The outline pane's column width — `workon.review.outline.width` (CS7), defaulting to + /// [`DEFAULT_OUTLINE_WIDTH`]. Read by `render.rs` in place of the old fixed const. + pub width: u16, } /// Which of a split's two panes has focus — the top pane renders the unstaged role, the bottom the @@ -759,6 +813,7 @@ impl App { focused: false, cursor: 0, mode: OutlineMode::default(), + width: DEFAULT_OUTLINE_WIDTH, }; let mut refresh_coordinator = RefreshCoordinator::new(); // Seed the coordinator with the index signature as it stands right after this initial @@ -1269,6 +1324,18 @@ impl App { self.open_current(); } + /// Set the requested zoom directly — the config-startup (CS7) counterpart to + /// [`Self::cycle_zoom`]. Skips `cycle_zoom`'s committed-changeset guard: that guard exists + /// only to give interactive feedback when a cycle would be a no-op, not to enforce the + /// invariant itself — [`Self::effective_zoom_for`] (driven from [`Self::open_current`]'s + /// `reset_panes`, which [`Self::apply_view_config`]'s caller runs right after this) already + /// collapses a non-stageable changeset to [`Role::Combined`] regardless of the requested + /// zoom, so setting the raw value here can never bypass the gate. Does NOT call + /// `open_current` itself — the caller applies every CS7 setting first, then opens once. + pub fn set_zoom(&mut self, zoom: Zoom) { + self.zoom = zoom; + } + /// Swap focus between the two split panes (`w`) — swaps `cursor`/`scroll`/`pane_height` with /// the stashed unfocused pane so the existing cursor methods keep driving the focused pane, and /// re-derives the newly focused pane's scroll against its own (just-swapped-in) height. A no-op @@ -1419,6 +1486,13 @@ impl App { self.outline.cursor } + /// The outline pane's column width — `workon.review.outline.width` (CS7), or + /// [`DEFAULT_OUTLINE_WIDTH`] if never set. Read by `render.rs` in place of the old fixed + /// const. + pub fn outline_width(&self) -> u16 { + self.outline.width + } + pub fn outline_mode(&self) -> OutlineMode { self.outline.mode } @@ -1462,6 +1536,23 @@ impl App { self.sync_outline_to_current(); } + /// Set the outline pane width directly (CS7: `workon.review.outline.width`, applied by + /// [`Self::apply_view_config`] at startup — there's no interactive key for this today). The + /// caller is responsible for clamping into `[MIN_OUTLINE_WIDTH, MAX_OUTLINE_WIDTH]` + /// (`apply_view_config` does); this setter trusts its input. + pub fn set_outline_width(&mut self, width: u16) { + self.outline.width = width; + } + + /// Set the outline mode directly — the config-startup (CS7) counterpart to + /// [`Self::outline_cycle_mode`]. Unlike the interactive cycle, this does NOT call + /// [`Self::sync_outline_to_current`]: [`Self::apply_view_config`] runs before the first + /// [`Self::open_current`], matching how [`Self::from_changesets`] seeds + /// [`OutlineState::mode`] today (the outline cursor starts at `0` either way). + pub fn set_outline_mode(&mut self, mode: OutlineMode) { + self.outline.mode = mode; + } + /// Move the outline's own cursor by `delta` rows (`j`/`k` while the outline has focus), /// clamped into the current row list. Landing on a FILE row jumps the diff there /// immediately (outline -> diff, per the locked design); landing on a HEADER row does NOT @@ -1718,6 +1809,83 @@ impl App { self.derive_scroll(); } + /// Set the render layout directly — the config-startup (CS7) counterpart to + /// [`Self::toggle_layout`]. Called before the first [`Self::open_current`], whose + /// `reset_panes` derives `cursor`/`scroll` fresh for whichever layout is active, so — + /// unlike `toggle_layout`, which must clamp an EXISTING cursor into the new layout's row + /// count — no separate clamp is needed here. Does NOT call `open_current` itself — the + /// caller applies every CS7 setting first, then opens once. + pub fn set_layout(&mut self, layout: Layout) { + self.layout = layout; + } + + /// Apply `workon.review.outline.width|mode` and `workon.review.diff.layout|zoom` (CS7) as + /// the App's initial view-config state, via the same setters the interactive keys drive + /// (see each setter's doc comment for why that's enough to stay on the gated path). Call + /// once, right after construction and before [`Self::open_current`] (see `main.rs`) — the + /// setters here don't themselves re-derive `cursor`/`scroll`, and the caller's + /// `open_current` is what does that for whichever settings just landed. + /// + /// `raw` is read via [`crate::config::ReviewConfig::view_config`] BEFORE `repo` moves into + /// `App` (see `main.rs`) — its fields already collapsed an unset setting and a config-read + /// error to the same `None` (CS7 applies the current hardcoded default for either case, no + /// warning). Each setting additionally falls back to the default when SET but invalid — out + /// of range (width), or an unrecognized string (mode/layout/zoom) — collecting a warning for + /// those cases, same non-fatal posture as the keymap/theme resolution (ADR-034). + pub fn apply_view_config(&mut self, raw: &RawViewConfig) -> Vec { + let mut warnings = Vec::new(); + + let width = match raw.outline_width { + Some(w) => match u16::try_from(w) { + Ok(w) if (MIN_OUTLINE_WIDTH..=MAX_OUTLINE_WIDTH).contains(&w) => w, + _ => { + warnings.push(format!( + "workon.review.outline.width = {w} out of range \ + ({MIN_OUTLINE_WIDTH}-{MAX_OUTLINE_WIDTH}); using default" + )); + DEFAULT_OUTLINE_WIDTH + } + }, + None => DEFAULT_OUTLINE_WIDTH, + }; + self.set_outline_width(width); + + let mode = match &raw.outline_mode { + Some(m) => parse_outline_mode(m).unwrap_or_else(|| { + warnings.push(format!( + "workon.review.outline.mode = '{m}' unrecognized; using default" + )); + OutlineMode::default() + }), + None => OutlineMode::default(), + }; + self.set_outline_mode(mode); + + let layout = match &raw.diff_layout { + Some(l) => parse_diff_layout(l).unwrap_or_else(|| { + warnings.push(format!( + "workon.review.diff.layout = '{l}' unrecognized; using default" + )); + Layout::default() + }), + None => Layout::default(), + }; + self.set_layout(layout); + + let zoom = match &raw.diff_zoom { + Some(z) => parse_diff_zoom(z).unwrap_or_else(|| { + warnings.push(format!( + "workon.review.diff.zoom = '{z}' unrecognized; using default" + )); + Zoom::default() + }), + None => Zoom::default(), + }; + self.set_zoom(zoom); + + warnings + } + /// Set a transient footer notice (see [`Self::notice`]'s doc comment). Overwrites any /// currently-showing notice rather than queuing — only one message is ever on screen. pub fn notify(&mut self, text: impl Into, severity: Severity) { @@ -2423,8 +2591,12 @@ mod tests { use workon::{Changeset, ChangesetSource}; use super::test_support::app_from_fixture; - use super::{find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Role}; + use super::{ + find_next_hunk_row, find_prev_hunk_row, App, ChangesetView, EffectiveZoom, Layout, Role, + Zoom, DEFAULT_OUTLINE_WIDTH, + }; use crate::align::{AlignedRow, CellKind, DisplayRow, InlineRow, Row}; + use crate::config::ReviewConfig; use crate::model::FileStatus; use crate::outline::{OutlineItem, OutlineMode, StagedStatus}; @@ -5119,4 +5291,145 @@ mod tests { app.toggle_outline(); assert!(!app.outline_open()); } + + // ── CS7: view-config (`apply_view_config`) ───────────────────────────────── + + #[test] + fn unset_view_config_keeps_current_defaults() { + let fixture = FixtureBuilder::new().build().unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); + assert_eq!(app.outline_mode(), OutlineMode::default()); + assert_eq!(app.layout, Layout::default()); + assert_eq!(app.zoom, Zoom::default()); + } + + #[test] + fn outline_width_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "40") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.outline_width(), 40); + } + + #[test] + fn outline_width_out_of_range_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.width", "9999") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert_eq!(app.outline_width(), DEFAULT_OUTLINE_WIDTH); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.width")); + } + + #[test] + fn outline_mode_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.mode", "tree") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.outline_mode(), OutlineMode::Tree); + } + + #[test] + fn outline_mode_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.outline.mode", "bogus") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert_eq!(app.outline_mode(), OutlineMode::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("outline.mode")); + } + + #[test] + fn diff_layout_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.layout", "inline") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.layout, Layout::Inline); + } + + #[test] + fn diff_layout_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.layout", "bogus") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert_eq!(app.layout, Layout::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("diff.layout")); + } + + #[test] + fn diff_zoom_overrides_default_when_set() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.zoom", "staged") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert!(warnings.is_empty()); + assert_eq!(app.zoom, Zoom::Staged); + } + + #[test] + fn diff_zoom_invalid_falls_back_to_default_with_warning() { + let fixture = FixtureBuilder::new() + .config("workon.review.diff.zoom", "bogus") + .build() + .unwrap(); + let config = ReviewConfig::new(fixture.repo().unwrap()).view_config(); + let mut app = app_from_fixture(&fixture); + + let warnings = app.apply_view_config(&config); + + assert_eq!(app.zoom, Zoom::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("diff.zoom")); + } } diff --git a/git-workon-review/src/config.rs b/git-workon-review/src/config.rs index 712c6a7e..b7361c3e 100644 --- a/git-workon-review/src/config.rs +++ b/git-workon-review/src/config.rs @@ -94,6 +94,17 @@ pub struct RawBinding { pub keys: String, } +/// The four CS7 view-config settings, read raw (unset → `None`) and owned — see +/// [`ReviewConfig::view_config`]. Validation (range/enum checks) and default fallback are +/// [`crate::app::App::apply_view_config`]'s job, same division as [`RawBinding`]/CS2. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RawViewConfig { + pub outline_width: Option, + pub outline_mode: Option, + pub diff_layout: Option, + pub diff_zoom: Option, +} + /// Decompose a fully-qualified config variable name (as returned by /// [`git2::ConfigEntry::name`]) into its (view, action) components, per ADR-034's grammar: /// bare `workon.review.bind.` is the global keymap; `workon.review..bind.` @@ -202,6 +213,22 @@ impl<'repo> ReviewConfig<'repo> { self.get_view_string(View::Diff, "zoom") } + /// Read all four CS7 view-config settings at once into an owned [`RawViewConfig`], + /// collapsing a config-read error to `None` — same as every other getter here, `App`'s + /// resolution (`App::apply_view_config`) treats an unset setting and a failed read + /// identically (both apply the current hardcoded default). Exists so `main.rs` can read + /// view config into an owned value BEFORE `repo` moves into `App` (mirroring how the + /// keymap/theme are resolved before the move), rather than holding a `ReviewConfig<'repo>` + /// (which borrows `repo`) alongside the `App` that owns it. + pub fn view_config(&self) -> RawViewConfig { + RawViewConfig { + outline_width: self.outline_width().ok().flatten(), + outline_mode: self.outline_mode().ok().flatten(), + diff_layout: self.diff_layout().ok().flatten(), + diff_zoom: self.diff_zoom().ok().flatten(), + } + } + /// Build the `workon.review..` key for a view setting (never a `.bind.` /// entry — [`View::Global`] has no setting namespace, only callers reading `Diff`/`Outline` /// use this). diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index b2737078..25d08663 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -54,16 +54,31 @@ fn main() -> Result<()> { Err(_) => Keymap::defaults(), }; + // Resolve the view-config settings (outline width/mode, diff layout/zoom) the same way, + // before `repo` moves — CS7. `view_config` reads into an owned `RawViewConfig`, so no + // borrow of `repo` survives past this statement (unlike a bare `ReviewConfig<'repo>`, which + // would still be borrowing `repo` when `App::from_changesets` tries to move it below). + let view_config = ReviewConfig::new(&repo).view_config(); + // `App` owns its own `Repository` handle (see `app.rs`'s doc comment) — moved in here after // acquisition is done borrowing it. `App::from_changesets` opens on whichever changeset the // lib marked `current` (locked decision #6). let mut app = App::from_changesets(repo, views); + + // Apply CS7's view-config settings BEFORE `open_current`: `App::apply_view_config`'s setters + // only set the raw layout/zoom/mode/width fields, and `open_current` is what derives + // `cursor`/`scroll` fresh from whichever settings just landed (see each setter's doc + // comment). + let view_config_warnings = app.apply_view_config(&view_config); app.open_current(); - // A misconfigured keybinding is non-fatal: show the collected warnings as a startup notice - // (cleared on the first keypress, like any notice) and run with the defaults for those keys. - if !keymap.warnings().is_empty() { - app.notify(keymap.warnings().join("; "), Severity::Error); + // A misconfigured keybinding or view-config setting is non-fatal: show the collected + // warnings as a startup notice (cleared on the first keypress, like any notice) and run with + // the defaults for those keys/settings. + let mut warnings = keymap.warnings().to_vec(); + warnings.extend(view_config_warnings); + if !warnings.is_empty() { + app.notify(warnings.join("; "), Severity::Error); } tui::run(&mut app, &keymap).into_diagnostic()?; diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 19e36a00..cc98b2b5 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -60,8 +60,6 @@ const FG_CURRENT: Color = Color::Rgb(96, 200, 128); /// [`BG_CURSOR`] so the outline's remembered position stays legible without competing with the /// diff's own (focused) cursor row for visual weight. const BG_OUTLINE_CURSOR_UNFOCUSED: Color = Color::Rgb(35, 38, 55); -/// Fixed column width of the outline side pane (locked design: "~35 cols"). -const OUTLINE_WIDTH: u16 = 35; /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -369,7 +367,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let hlayout = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(OUTLINE_WIDTH), + Constraint::Length(app.outline_width()), Constraint::Length(1), Constraint::Min(1), ]) From 0a06e2d4ec2cea8219f899d9b62d274439d8dce5 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 08:56:15 -0400 Subject: [PATCH 06/13] fix(review): read committed changeset new side from head tree --- git-workon-review/src/app.rs | 131 +++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/git-workon-review/src/app.rs b/git-workon-review/src/app.rs index 0e9204b1..eda9a929 100644 --- a/git-workon-review/src/app.rs +++ b/git-workon-review/src/app.rs @@ -97,11 +97,14 @@ impl FileView { /// render one revision on one side and a different one on the other: /// - old side: [`Role::Combined`]/[`Role::Staged`] read the `HEAD` blob; [`Role::Unstaged`] /// reads the INDEX blob (unstaged is index ↔ worktree). - /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file; - /// [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). + /// - new side: [`Role::Combined`]/[`Role::Unstaged`] read the worktree file when `new_tree` + /// is `None` (the uncommitted layer); for a committed changeset `new_tree` is the changeset's + /// `head` commit tree, whose blob is read instead (its new side is `base..head`, not the + /// current worktree). [`Role::Staged`] reads the INDEX blob (staged is `HEAD` ↔ index). fn load( repo: &Repository, head_tree: &git2::Tree<'_>, + new_tree: Option<&git2::Tree<'_>>, file: &FileChange, role: Role, ts: &mut TsHighlighter, @@ -118,7 +121,10 @@ impl FileView { let new_text = match file.status { FileStatus::Deleted => String::new(), _ => match role { - Role::Combined | Role::Unstaged => read_workdir_file(repo, &file.path), + Role::Combined | Role::Unstaged => match new_tree { + Some(tree) => read_head_blob(repo, tree, &file.path), + None => read_workdir_file(repo, &file.path), + }, Role::Staged => read_index_blob(repo, &file.path), }, }; @@ -315,6 +321,24 @@ fn old_side_tree_for(repo: &Repository, source: ChangesetSource) -> Option Option> { + match source { + ChangesetSource::Committed { head, .. } => { + repo.find_commit(head).and_then(|c| c.tree()).ok() + } + ChangesetSource::Uncommitted => None, + } +} + fn read_head_blob(repo: &Repository, tree: &git2::Tree<'_>, path: &str) -> String { tree.get_path(Path::new(path)) .and_then(|entry| entry.to_object(repo)) @@ -1213,7 +1237,17 @@ impl App { let Ok(head_tree) = self.repo.head().and_then(|h| h.peel_to_tree()) else { return; }; - FileView::load(&self.repo, &head_tree, &file, role, &mut self.highlighter) + // Non-Combined roles are uncommitted-only (committed changesets have empty + // staged/unstaged sub-models), so the new side always stays worktree/index — + // `None` here preserves that exactly. + FileView::load( + &self.repo, + &head_tree, + None, + &file, + role, + &mut self.highlighter, + ) }; self.views_for_mut(role)[idx] = Some(view); return; @@ -1229,15 +1263,22 @@ impl App { let Some(head_tree) = old_side_tree_for(&self.repo, self.cur().cs.source) else { return; }; + // New-side source mirrors the old side: `None` (worktree) for the uncommitted layer, + // the changeset's `head` tree for a committed changeset. Same free-fn borrow dance as + // `old_side_tree_for` — both trees borrow only `self.repo`, so `&mut self.highlighter` + // stays free for `FileView::load`. + let new_tree = new_side_tree_for(&self.repo, self.cur().cs.source); let file = self.cur().diff.files[idx].clone(); let view = FileView::load( &self.repo, &head_tree, + new_tree.as_ref(), &file, Role::Combined, &mut self.highlighter, ); drop(head_tree); + drop(new_tree); self.cur_mut().views_combined[idx] = Some(view); } @@ -4721,6 +4762,88 @@ mod tests { assert_eq!(app.current, 0); } + /// Regression: navigating to an OLDER committed changeset and loading its combined view must + /// source the new side from that changeset's `head` commit tree, not the current worktree. The + /// same file `f.txt` is touched by both changesets, so `cs-a`'s head (`mid`) content differs + /// from the worktree (which holds `head`'s content). Before the `new_side_tree_for` fix the new + /// side read the worktree, whose line count disagreed with `cs-a`'s `base..head` hunks and + /// tripped the align invariant (align.rs:165 "trailing context ... must be equal length"). No + /// color pinning needed: `new_text()` returns the raw blob text, not highlighted spans. + #[test] + fn older_committed_changesets_new_side_reads_its_head_tree_not_the_worktree() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let root = fixture + .commit("main") + .file("f.txt", "one\n") + .create("root") + .unwrap(); + // cs-a (root..mid) adds "two" to f.txt — its head-tree copy is "one\ntwo\n". + let mid = fixture + .commit("main") + .file("f.txt", "one\ntwo\n") + .create("mid") + .unwrap(); + // cs-b (mid..head) adds "three" — so the checked-out worktree copy is "one\ntwo\nthree\n", + // three lines, which must NOT be what cs-a's combined new side reads. + let head = fixture + .commit("main") + .file("f.txt", "one\ntwo\nthree\n") + .create("head") + .unwrap(); + let repo = fixture.repo().unwrap(); + + let cs_a = Changeset { + name: "cs-a".to_string(), + source: ChangesetSource::Committed { + base: root, + head: mid, + }, + title: None, + current: false, + needs_restack: false, + }; + let cs_b = Changeset { + name: "cs-b".to_string(), + source: ChangesetSource::Committed { base: mid, head }, + title: None, + current: true, + needs_restack: false, + }; + let view_a = ChangesetView::from_changeset_diff( + cs_a.clone(), + crate::acquire::diff_changeset(repo, &cs_a).unwrap(), + ); + let view_b = ChangesetView::from_changeset_diff( + cs_b.clone(), + crate::acquire::diff_changeset(repo, &cs_b).unwrap(), + ); + + let owned = Repository::open(repo.workdir().unwrap()).unwrap(); + let mut app = App::from_changesets(owned, vec![view_a, view_b]); + app.open_current(); + assert_eq!(app.current_cs(), 1, "opens on cs-b (its current: true)"); + + // Navigate back to the older changeset and load its combined view. Pre-fix this panics at + // align.rs:165; post-fix it loads cleanly. + app.prev_changeset(); + assert_eq!(app.current_cs(), 0, "prev lands on cs-a"); + let view = app.current_view().expect("cs-a's combined view must load"); + + assert_eq!( + view.new_text(), + "one\ntwo\n", + "new side must read cs-a's head (mid) blob, not the worktree copy" + ); + assert_ne!( + view.new_text(), + "one\ntwo\nthree\n", + "new side must NOT read the worktree (which holds cs-b's head content)" + ); + } + #[test] fn bracket_c_jumps_to_the_adjacent_changesets_first_file() { let mut app = two_committed_changesets_two_and_one_files(); From 247911eca82f7ece13d3a669f8db05195b6253dd Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 11:55:51 -0400 Subject: [PATCH 07/13] =?UTF-8?q?docs(review):=20reprioritize=20roadmap=20?= =?UTF-8?q?=E2=80=94=20daily-driver=20first,=20M7=20review-any-source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rfc/workon-review.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/rfc/workon-review.md b/docs/rfc/workon-review.md index 2aca28f7..be0c3a96 100644 --- a/docs/rfc/workon-review.md +++ b/docs/rfc/workon-review.md @@ -13,7 +13,7 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ | Decision | Outcome | |---|---| -| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. | +| Positioning | Changeset review tool; not a lazygit competitor. Comments-to-agent is a first-class capability, not a stretch. **Reprioritized 2026-07-08 (direction B):** near-term goal is the author's own daily diff-review + git driver; the agent-loop/comments become the eventual payoff, not the next work. See "Roadmap reprioritized" under Milestones. | | Home | This workspace, as sibling crate `git-workon-review`. | | Crate layout | ONE crate, lib+bin targets. lib = review domain (diff parse, word-diff, staging, changeset views); bin = TUI + `mcp` subcommand. No separate core crate until a second consumer exists. | | Name | Package == binary == `git-workon-review`. `git workon-review` works via git's native `git-*` dispatch. (`git-review` is squatted on crates.io + Gerrit-loaded; `docket` too docker-adjacent; bare `review` superseded by suite framing; `signoff` was the free runner-up.) | @@ -25,7 +25,7 @@ It is the productization of a working Neovim prototype (`~/.config/nvim/lua/app/ | Fixture | `git-workon-fixture` is the test substrate for both crates. Extend it: SQLite-format graphite metadata mode (the sqlite read path is currently fixture-untested — builder only writes legacy refs blobs) and index-state builders (staged/unstaged/untracked combos). | | Highlighting | tree-sitter (tree-sitter-highlight), syntect as long-tail fallback. Measured: ts ~0.01ms/line vs syntect ~0.19ms/line, and better output. Grammar set + gotchas are in the spike. | | View model | Full parity with the prototype's four zoom states (split/combined/unstaged/staged + attributed rendering). If v1 must shrink, cut zoom states — never the comments loop. | -| v1 sources | uncommitted, stack, ref/range. PR deferred (git-workon-lib's `pr.rs` covers much of it later). | +| v1 sources | uncommitted, stack, ref/range, **PR** — all folded into **M7 "review any source"** (PR was deferred; now first, via git-workon-lib's `pr.rs`). | | Comments | MCP: on-disk comment store (`.review/` JSON or sqlite) + `git-workon-review mcp` stdio subcommand serving get/resolve tools; TUI watches the store. Degrades to a plain file convention for non-MCP harnesses. | | Edit flow | Embedded: `nvim --server $NVIM --remote + `. Standalone: `$EDITOR`. File watcher refreshes on save. | | Completions | Full clap_complete (unstable-dynamic, already a workspace dep) on the direct binary. Work item: git-workon's dynamic completer enumerates `git-workon-*` on PATH and delegates post-subcommand completion via `COMPLETE= git-workon-review -- `. Git-level shims: on demand only. | @@ -134,9 +134,25 @@ evidence, not to the conclusion. - **M5 — stack + ref sources, outline.** Changeset navigation, outline panel, needs-restack markers, focus semantics (open at current branch; uncommitted adjacent-after, focused when present). Design locked 2026-07-07 (plan artifact `cairn-ledger`, 9 forks): (1) source = per-changeset `ChangesetView`, committed changesets built via `DiffState::from_committed` (empty staged/unstaged sub-models); (2) mode = derived `is_committed` + targeted guards, leaning on the existing `effective_zoom` collapse (empty sub-diffs → combined-only for free); (3) outline = left side pane, all four modes (flat/tree/stack/stack-tree); (4) load = hybrid (eager per-changeset `DiffState`, lazy per-file `FileView`); (5) nav = continuous `]f`/`[f` across the stack + `]c`/`[c` changeset jumps; (6) open-at = honor the lib's `current` flag; (7) source scope = auto-detect Graphite else single uncommitted changeset (M2–M4 preserved, backward-compatible); (8) changeset indicator = new top winbar; (9) needs-restack = first-class glyph + amber color (the lib gives a real boolean, unlike the prototype's title-string suffix). — DONE (2026-07-07): shipped as FOUR changesets `m5-stack-source → m5-changeset-nav → m5-outline-core → m5-outline-tree`, each delegated to an `implementer` subagent and main-thread diff-read before the next landed. The M1 lib already provided `assemble_changesets` + the `diff_changeset` router, so M5 was almost entirely review-App wiring; the uncommitted layer becomes one changeset *inside* the stack, keeping all of M4's staging/zoom/attribution working on it while committed changesets render read-only. Two correctness fixes surfaced during implementation, neither in the plan: (a) a committed changeset's combined-role old side must read its `base` commit's tree, not live `HEAD` (`old_side_tree_for`); (b) skipping attribution for committed changesets is not just a guard — without it `Attribution::build(None, None)`'s empty sets miscolored every Add cell as "already staged" (dim), pinned by a render test. Acceptance met: dogfooded against this repo's own live 33-changeset Graphite stack via a PTY harness (winbar changeset counter, `]c`/`[c` nav, outline flat/stack/tree/stack-tree modes with correct tree guides, open-on-uncommitted-layer focus) — a clean exit, no panic, exercising the real `resolve_changesets`→`assemble_graphite` path the hand-built unit tests don't. Full workspace green (41 suites, 804 tests, 0 fail), clippy `-D warnings --all-targets --all-features` clean. Deferred: Git-inference (`StackModel::Git`) and explicit ref-range review (the broader "ref sources") — auto-detect ships Graphite-or-uncommitted only; a fixed 35-col outline with no narrow-terminal handling. - **M6 — git-workon CLI integration.** Ordered first: dependency-free, lowest-risk, and it unlocks dogfooding every later milestone through the real `git workon review` entry point (not `cargo run`). Cargo-style external-subcommand dispatch — `git-workon`'s unknown subcommand execs `git-workon-` on PATH with args passed through (none exists today; `Cmd` is a closed enum), so `git workon review` works via git's native `git-*` dispatch. Plus completion: the review binary gains `CompleteEnv` (its `Cli` is currently empty) so it is a `COMPLETE=` responder, and git-workon's dynamic completer enumerates `git-workon-*` on PATH and surfaces them as top-level subcommand candidates (so `git workon ` offers `review`). **Post-subcommand sub-delegation** (`git workon review ` → shell out to the review binary's completer) is **deferred, not built**: the review binary's `Cli` is currently empty (zero candidates), and MCP lands as `git workon mcp` (not a review subcommand — see M9), so there is nothing to delegate today. Its real trigger is *not* MCP — it's whenever the review binary gains its source-selector arg (`stack | uncommitted | | | pr-####`, the deferred v1 sources), whose values (refs, ranges, PR numbers) are genuinely completion-worthy. Wire delegation then, against that real surface; the review binary is already a `COMPLETE=` responder, so only the git-workon-side shell-out remains. Acceptance: `git workon review` dispatches with args through; `git workon ` lists external subcommands including `review`. DONE (2026-07-07): shipped as THREE changesets `m6-dispatch → m6-review-complete → m6-complete-enum` — (1) manual pre-parse PATH intercept (`dispatch.rs`), NOT clap `allow_external_subcommands` (which would break the flattened-`find.name` default-command routing); (2) review binary as `COMPLETE=` responder; (3) top-level external enumeration in the completer. Two seam facts surfaced: the clap_complete bash protocol needs `_CLAP_COMPLETE_INDEX` (word position) or it emits "no completion generated", and an empty `Cli` yields zero candidates (which is what made sub-delegation pointless to build). - **M6.5 — everyday-usability pass (keybindings + theming + view-config).** Inserted ahead of M7 (2026-07-07): comments are deprioritized until the tool is usable for the author's own everyday review work. Keybindings and theming were never milestones — they were baked in as hardcoded values during M3–M5 (a `match` in `tui.rs`, a `const … Color::Rgb` block in `render.rs`). This pass makes both user-configurable and adds discoverability, plus gives previously-hardcoded view settings a config home. Design locked 2026-07-07; two ADRs: [ADR-034](../adr/034-review-git-native-config-schema.md) (git-native config schema — `workon.review.*`, action-as-key per-view keymaps, token grammar) and [ADR-035](../adr/035-review-theming-base16-hybrid.md) (hybrid base16 theming, render-time color resolution, terminal-derived `auto`). Scope: (1) `ReviewConfig` reader — the review binary reads git config for the first time; (2) action registry + configurable per-view keymaps, defaults unchanged; (3) help surface (persistent curated per-view footer + `?` overlay); (4) base16 `Theme` primitive + render-time resolution refactor (`FgSpan` carries capture index); (5) curated dark+light schemes + `theme=dark|light`; (6) `theme=auto` terminal-derivation OSC probe with curated fallback; (7) view-config (`outline.width`/`mode`, `diff.layout`/`zoom`). Full plan: `docs/plans/review-usability-pass.md`. Acceptance: rebind any diff/outline/global action via `git config`; `?` overlay + footer render the resolved map; `theme` selects auto/dark/light with terminal-derived `auto` degrading to curated on probe failure; view defaults honored from config. Comments (M7) resume after. -- **M7 — review comments.** On-disk comment store (`.review/`, JSON-or-sqlite; both deps already in the workspace) keyed to changeset/path/side/line, with a **rebase-survival anchoring strategy** — the central greenfield fork (the frozen prototype has *no* comment store, MCP, or editor-jump: all three are designed from scratch; it only hands us the `(changeset_id, path, side, lnum)` location model with `head_ref ∈ {SHA, WORKTREE, INDEX}` and no re-anchoring precedent). Plus TUI comment UX: create a comment on a diff line, view inline/in a pane, mark resolved, store-watch refresh. Acceptance: a human reviews a changeset, leaves comments pinned to lines, and they persist + re-anchor across a diff refresh (manual `r` / Tick). **Comment-store home is a first-class M7 fork, not just its schema:** M9's `git workon mcp` (in the `git-workon` crate) must read comments, making git-workon a *second consumer* of the store — so it cannot live inside the review binary. It belongs in a lib both the review crate and git-workon can depend on (git-workon-lib, or a new shared crate). This reopens the RFC's deferred "no separate core crate until a second consumer exists" decision — resolve it here. -- **M8 — edit flow.** Editor-jump from a diff line to the file on disk — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff (and re-anchors comments) on external save — port the prototype's debounced repo-root watcher behavior (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). Ordered right after comments so watch-refresh and comment re-anchoring co-develop and stress-test the M7 anchor model immediately. Acceptance: jump opens the right file+line; saving refreshes the diff without losing viewport or comment anchors. -- **M9 — MCP agent loop (`git workon mcp`).** A **first-class `mcp` subcommand of the main `git-workon` binary** (not a review subcommand) starting one stdio MCP server that **bridges both domains**: git-workon-lib worktree tools (`agent-integration.md` Model C — `worktree_create`/`list`/`find`/`remove`/`create_from_pr`) *and* the review comment store (list comments, mark addressed; the TUI reflects changes). One server, one config entry, both capabilities — the unified direction (superseding the earlier "review-comment-only vs unified" fork and the RFC's original `git-workon-review mcp` framing). Deliberately last so the cross-cutting MCP-stack commitment (crate — `rmcp` vs hand-rolled JSON-RPC-over-stdio — transport, error mapping) is made once across both surfaces, and because it depends on the M7 comment store living in a shared lib (see M7). Consequence: `git-workon` gains a dependency on the comment-store lib; the worktree-MCP no longer wants a separate `git-workon-mcp` crate. Acceptance: full agent loop — review, comment, agent addresses via MCP, re-review — plus worktree tools served from the same `git workon mcp`. +### Roadmap reprioritized 2026-07-08 — personal daily-driver first (direction B) + +The remaining roadmap is resequenced around the tool being **the author's own everyday diff-review + git surface**, not the agent-review loop (which becomes the eventual payoff once the tool is lived-in). This **supersedes the "comments next" ordering** and the decision-log **Positioning** / **v1 sources** rows above. Nothing past M6.5 is built, so renumbering is free. The old M7 (comments)/M8 (edit)/M9 (MCP) content is *relocated*, not dropped: edit-flow graduates into the daily-core (new **M10**); comments + MCP defer together into the agent-loop milestone (new **M13**). Ordering rationale is inline per bullet. + +- **Prerequisite — Land M3–M6.5** (process, parallel to features; not a numbered milestone). QA the unmerged M3→M6.5 tower → merge to `main` → reliable install (a local build on PATH is enough to dogfood; the [ADR-033](../adr/033-review-crate-workspace-placement.md) release/homebrew "M3 flip" is a deferrable sub-decision). Gates real daily use regardless of features. QA checklist in memory `review-tui-priority-everyday-use` (`theme=auto` responsiveness, `theme=light` canvas, committed-changeset nav). + +- **M7 — review any source.** A source selector — `stack | uncommitted | | | pr-####` — so the tool reviews *anything*, not just the auto-detected stack/uncommitted state. **Ordered first:** it is the tool's core *read* identity, read-only (low-risk), independent of the write verbs, and the M1/M5 lib already provides `assemble_changesets` + the `diff_changeset` router — mostly source-arg parse → resolve to changeset(s) → existing pipeline. PR support reuses git-workon-lib's `pr.rs`. Also **completes M6's deferred completion sub-delegation** (its trigger was exactly this arg gaining completion-worthy values). Acceptance: `git workon review ` / `` / `pr-123` renders the right changeset(s); `git workon review ` completes sources. + +- **M8 — commit operations.** Commit the staged changes without leaving the TUI — message editor (inline vs `$EDITOR`), Conventional-Commit-aware (enforced by `git-hooks/commit-msg`); **amend** the current commit; **fixup/absorb** staged changes into an earlier changeset in the stack. Closes the review→stage→**commit** loop — the acute daily-driver gap. Acceptance: stage in the TUI, commit/amend/fixup, verified against real git. + +- **M9 — stack operations.** Graphite stack verbs from the TUI: create a changeset/branch from staged (`gt create`), restack (`gt restack`), submit → PRs (`gt submit`), checkout/switch to a changeset (nav exists; actual checkout does not). Advanced reorder/fold/split deferred within. Builds on M8 — commit → create → submit is the shipping spine of a stacked workflow. Acceptance: create/restack/submit/checkout a changeset from the TUI against a real gt stack. + +- **M10 — editor jump / edit flow** *(was M8)*. Jump from a diff line to `file:line` — embedded `nvim --server $NVIM --remote + `, standalone `$EDITOR + ` (detect via `$NVIM`); file watcher refreshes the diff on external save — port the prototype's debounced repo-root watcher (`FocusGained` fallback, viewport-preserving refresh, selection clamp; the Neovim mechanism doesn't translate, the behavior does). **Graduated from agent-loop into daily-core:** under (B) you review and want to *fix* the thing. Acceptance: jump opens the right file+line; saving refreshes without losing viewport. + +- **M11 — polish.** Worktree-switch hub in the TUI (surface git-workon's create/find/prune/switch so the TUI is a hub — vs staying review-only; decide during design) + in-diff navigation (fuzzy jump-to-file, search-in-diff, context expand/collapse, ignore-whitespace toggle, copy `path:line`). Acceptance: per the design cut. + +- **M12 — conflict resolution** *(stretch)*. Resolve merge/rebase conflicts in the SBS view. Large surface; may not make v1. + +- **M13 — agent loop** *(the eventual north star; was M7 comments + M9 MCP)*. On-disk comment store keyed to `(changeset_id, path, side, lnum)` with a rebase-survival anchoring strategy + TUI comment UX (create/view/resolve, store-watch refresh), and a **unified `git workon mcp`** stdio server bridging git-workon-lib worktree tools (`agent-integration.md` Model C) *and* the comment store. **Open forks (unchanged, resolve at design time):** comment-store home — a lib both the review crate and `git-workon` depend on, since `git workon mcp` is a second consumer (reopens the "no separate core crate" decision); the anchoring strategy; MCP crate/transport (`rmcp` vs hand-rolled JSON-RPC-over-stdio). Deferred behind the daily-driver work. ## Orchestration notes From dcf370b6cf5e8b645cbc4bbffffaf975665785c1 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:17:48 -0400 Subject: [PATCH 08/13] refactor(review): base16 Theme with render-time color resolution --- docs/adr/035-review-theming-base16-hybrid.md | 22 +- git-workon-review/src/highlight.rs | 107 +++----- git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 8 +- git-workon-review/src/render.rs | 254 +++++++++++-------- git-workon-review/src/theme.rs | 211 +++++++++++++++ git-workon-review/src/tui.rs | 8 +- 7 files changed, 423 insertions(+), 188 deletions(-) create mode 100644 git-workon-review/src/theme.rs diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index bf6c484f..bb5287ad 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -36,10 +36,24 @@ is spec-conformant. **Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing -capture→slot template. Diff-bg tints are **derived**, not authored: blend base08 -(red / spec "Diff Deleted") and base0B (green / spec "Diff Inserted") toward base00 (bg) -using the existing `tint_toward` helper (`render.rs`). Syntax and diff tints therefore come -from one scheme and stay coordinated by construction. +capture→slot template. + +Diff-bg tints ideally come from base08 (red / spec "Diff Deleted") and base0B (green / spec +"Diff Inserted") and the scheme background, so syntax and tints stay coordinated. **But the +derivation is luminance-dependent, not a single "blend toward base00" (corrected in CS4):** +- **Dark (base00 dark):** the shipped M3–M5 tints are more saturated/darker than *any* convex + blend of an accent toward a dark base00 can produce (their green/blue channels sit *below* + base00's). A blend toward a dark base00 also yields muddy mid-tones, not punchy washes. So + the **dark tints are held explicit** in `Theme::dark()` (byte-identical to M3–M5, per the + pixel-identity gate). Deriving them would require scaling the accent toward *black* plus a + desaturation step, not a base00 blend — not worth reverse-engineering the hand-tuned values. +- **Light (base00 light) and terminal-derived:** blending an accent toward a *light* base00 + gives the correct pale tint, so the `tint_toward` derivation applies there (CS5/CS6). A + terminal-derived theme on a *dark* background hits the same problem as dark and needs the + toward-black+desaturate construction — a CS6 concern. + +Net: the scheme-coordinated derivation is real but must branch on background luminance; dark +stays authored. **Mechanism — resolve color at render time, not in the highlight phase.** - `HIGHLIGHT_NAMES` stays global/const: it defines the capture *index space* bound by diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index 403d86e4..291ec56b 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -7,25 +7,29 @@ use std::collections::HashMap; -use ratatui::style::Color; use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; /// Files with more lines than this are skipped (plain fg) to keep /// highlighting fast. pub const MAX_HIGHLIGHT_LINES: usize = 20_000; -/// Foreground color spans for a single line: byte range + color. +/// Foreground syntax span for a single line: a byte range and the semantic *capture index* — +/// the position in [`HIGHLIGHT_NAMES`] of the capture that covers it. The color is resolved at +/// render time against the active [`crate::theme::Theme`] (ADR-035), NOT baked in here: the +/// tree-sitter pass is theme-free and cacheable, and a theme switch recolors by re-rendering. #[derive(Debug, Clone)] pub struct FgSpan { pub start: usize, pub end: usize, - pub color: Color, + /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Theme::syntax`]. + pub capture: usize, } /// The standard highlight-capture names we recognize. `configure()` matches /// dotted capture names by longest prefix, so e.g. `keyword.control` maps to -/// `keyword`. Parallel with `HIGHLIGHT_COLORS`. -const HIGHLIGHT_NAMES: &[&str] = &[ +/// `keyword`. This is the capture *index space* — theme-invariant (see ADR-035); the +/// per-capture colors live in [`crate::theme`]'s `SYNTAX_SLOTS` template. +pub(crate) const HIGHLIGHT_NAMES: &[&str] = &[ "attribute", "comment", "constant", @@ -56,56 +60,11 @@ const HIGHLIGHT_NAMES: &[&str] = &[ "variable.parameter", ]; -// A small dark theme in the same family as syntect's base16-eighties.dark so -// the two engines look comparable side by side. -const C_RED: Color = Color::Rgb(0xf2, 0x77, 0x7a); -const C_ORANGE: Color = Color::Rgb(0xf9, 0x91, 0x57); -const C_YELLOW: Color = Color::Rgb(0xff, 0xcc, 0x66); -const C_GREEN: Color = Color::Rgb(0x99, 0xcc, 0x99); -const C_CYAN: Color = Color::Rgb(0x66, 0xcc, 0xcc); -const C_BLUE: Color = Color::Rgb(0x66, 0x99, 0xcc); -const C_PURPLE: Color = Color::Rgb(0xcc, 0x99, 0xcc); -const C_FG: Color = Color::Rgb(0xd3, 0xd0, 0xc8); -const C_COMMENT: Color = Color::Rgb(0x74, 0x73, 0x69); - -const HIGHLIGHT_COLORS: &[Color] = &[ - C_ORANGE, // attribute - C_COMMENT, // comment - C_ORANGE, // constant - C_ORANGE, // constant.builtin - C_YELLOW, // constructor - C_FG, // embedded - C_CYAN, // escape - C_BLUE, // function - C_BLUE, // function.builtin - C_BLUE, // function.macro - C_BLUE, // function.method - C_PURPLE, // keyword - C_RED, // label - C_ORANGE, // number - C_FG, // operator - C_CYAN, // property - C_FG, // punctuation - C_FG, // punctuation.bracket - C_FG, // punctuation.delimiter - C_CYAN, // punctuation.special - C_GREEN, // string - C_CYAN, // string.special - C_RED, // tag - C_YELLOW, // type - C_YELLOW, // type.builtin - C_FG, // variable - C_RED, // variable.builtin - C_FG, // variable.parameter -]; - -/// Color for a highlight-capture name, for tests and debugging. -#[cfg(test)] -pub fn color_of(name: &str) -> Option { - HIGHLIGHT_NAMES - .iter() - .position(|n| *n == name) - .map(|i| HIGHLIGHT_COLORS[i]) +/// The capture index for a highlight-capture name — its position in [`HIGHLIGHT_NAMES`], which is +/// exactly what an [`FgSpan::capture`] holds. Used by [`crate::theme`] and tests to relate a named +/// capture to the index the highlighter records. `None` for an unrecognized name. +pub fn capture_index(name: &str) -> Option { + HIGHLIGHT_NAMES.iter().position(|n| *n == name) } fn lang_key_for_ext(ext: &str) -> Option<&'static str> { @@ -279,8 +238,9 @@ impl TsHighlighter { stack.pop(); } HighlightEvent::Source { start, end } => { - let Some(&idx) = stack.last() else { continue }; - let color = HIGHLIGHT_COLORS[idx]; + let Some(&capture) = stack.last() else { + continue; + }; let mut pos = start; while pos < end { let line_idx = line_starts.partition_point(|&s| s <= pos) - 1; @@ -298,7 +258,7 @@ impl TsHighlighter { out[line_idx].push(FgSpan { start: pos - line_start, end: seg_end - line_start, - color, + capture, }); } pos = match line_starts.get(line_idx + 1) { @@ -325,8 +285,10 @@ mod tests { use super::*; #[test] - fn names_and_colors_are_parallel() { - assert_eq!(HIGHLIGHT_NAMES.len(), HIGHLIGHT_COLORS.len()); + fn names_and_syntax_template_are_parallel() { + // The capture index space (`HIGHLIGHT_NAMES`) and the theme's per-capture syntax template + // must stay the same length — a capture with no slot (or vice versa) would panic at render. + assert_eq!(HIGHLIGHT_NAMES.len(), crate::theme::syntax_slot_count()); } #[test] @@ -338,30 +300,33 @@ mod tests { .expect("rust grammar available"); assert_eq!(hl.len(), 3); - // Line 0: `fn` at bytes 0..2 should be keyword-colored. - let kw = color_of("keyword").unwrap(); + // Spans now carry the semantic capture INDEX (color is resolved at render time against the + // theme — see `FgSpan`), so these assert on the capture, not a baked color. + + // Line 0: `fn` at bytes 0..2 should be a keyword capture. + let kw = capture_index("keyword").unwrap(); assert!( hl[0] .iter() - .any(|s| s.start == 0 && s.end >= 2 && s.color == kw), + .any(|s| s.start == 0 && s.end >= 2 && s.capture == kw), "expected keyword span over `fn` on line 0, got {:?}", hl[0] ); - // Line 0: `main` should be function-colored. - let func = color_of("function").unwrap(); + // Line 0: `main` should be a function capture. + let func = capture_index("function").unwrap(); assert!( hl[0] .iter() - .any(|s| { s.color == func && &src[..11][s.start..s.end.min(11)] == "main" }), + .any(|s| { s.capture == func && &src[..11][s.start..s.end.min(11)] == "main" }), "expected function span over `main` on line 0, got {:?}", hl[0] ); - // Line 1: string literal should be string-colored. - let string = color_of("string").unwrap(); + // Line 1: string literal should be a string capture. + let string = capture_index("string").unwrap(); assert!( - hl[1].iter().any(|s| s.color == string), + hl[1].iter().any(|s| s.capture == string), "expected string span on line 1, got {:?}", hl[1] ); @@ -386,10 +351,10 @@ mod tests { } } // The multiline comment should produce comment spans on all 3 lines. - let comment = color_of("comment").unwrap(); + let comment = capture_index("comment").unwrap(); for (i, spans) in hl.iter().enumerate() { assert!( - spans.iter().any(|s| s.color == comment), + spans.iter().any(|s| s.capture == comment), "expected comment span on line {i}" ); } diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index bf6e9951..34fa5e18 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -30,4 +30,5 @@ pub mod refresh; pub mod render; pub mod stage_op; pub mod synthesis; +pub mod theme; pub mod wordiff; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 25d08663..bece0d89 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,6 +8,7 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::ReviewConfig; use workon_review::keymap::Keymap; +use workon_review::theme::Theme; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -81,7 +82,12 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } - tui::run(&mut app, &keymap).into_diagnostic()?; + // CS4 is dark-only and unconditional — a pure refactor with no user-visible change. CS5 wires + // `ReviewConfig::theme()` (config `Theme::{Auto,Dark,Light}`) to pick the palette here; CS6 + // adds the terminal-derivation probe for `auto`. + let theme = Theme::dark(); + + tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) } diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index cc98b2b5..fc73f27e 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -20,35 +20,23 @@ use crate::highlight::FgSpan; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; +use crate::theme::Theme; use crate::wordiff::Span as WordSpan; -const BG_DEL_SUBTLE: Color = Color::Rgb(60, 24, 24); -const BG_DEL_STRONG: Color = Color::Rgb(120, 40, 40); -const BG_ADD_SUBTLE: Color = Color::Rgb(20, 48, 24); -const BG_ADD_STRONG: Color = Color::Rgb(32, 100, 48); -/// Dim/desaturated variants of the del/add pair, for staged-ness attribution (locked decision -/// #7): visibly less vivid than the plain pair but still red-tinted, so a staged change reads as -/// "already handled" without disappearing into plain context. -const BG_DEL_STAGED_SUBTLE: Color = Color::Rgb(42, 26, 28); -const BG_DEL_STAGED_STRONG: Color = Color::Rgb(64, 38, 40); -/// Dim/desaturated variants of the add pair — green-tinted counterpart of -/// [`BG_DEL_STAGED_SUBTLE`]/[`BG_DEL_STAGED_STRONG`]. -const BG_ADD_STAGED_SUBTLE: Color = Color::Rgb(24, 34, 26); -const BG_ADD_STAGED_STRONG: Color = Color::Rgb(34, 50, 38); +// The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax +// foreground) now come from a [`Theme`] threaded through render (ADR-035). The chrome colors below +// stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and +// self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). + +/// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits +/// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the +/// [`Theme`] instead (see [`compose_segments`]). const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on /// both light and dark terminal themes. const FG_ERROR: Color = Color::Rgb(220, 60, 60); const FG_GUTTER: Color = Color::DarkGray; -/// Tint blended into the cursor row's background (see [`blend_bg`]) — a cool slate-blue, chosen -/// to read as "cursor here" without competing with the warm del/add hues above. -const BG_CURSOR: Color = Color::Rgb(45, 50, 90); -/// Tint blended into a SELECTED row's background (line selection, `v`) — a muted teal, distinct -/// from [`BG_CURSOR`]'s slate-blue so a selected-but-not-cursor row reads apart from the cursor -/// row. The cursor row inside a selection keeps the cursor tint (cursor wins on its own row — see -/// [`render_pane_sbs`]). -const BG_SELECTION: Color = Color::Rgb(30, 66, 66); /// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct /// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. const FG_WARN: Color = Color::Rgb(214, 158, 46); @@ -56,10 +44,6 @@ const FG_WARN: Color = Color::Rgb(214, 158, 46); /// #9's outline half) — a green, distinct from every other marker color in this module so /// "current" reads unambiguously at a glance. const FG_CURRENT: Color = Color::Rgb(96, 200, 128); -/// Cursor tint for the outline pane while it is OPEN but NOT focused — a dimmer wash than -/// [`BG_CURSOR`] so the outline's remembered position stays legible without competing with the -/// diff's own (focused) cursor row for visual weight. -const BG_OUTLINE_CURSOR_UNFOCUSED: Color = Color::Rgb(35, 38, 55); /// Blend the cursor row's tint into an existing background, so the cursor highlight composites /// with (rather than replaces) del/add/word-diff emphasis on the same row — the row highlight is @@ -100,14 +84,14 @@ fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'sta line } -/// Wash the cursor row with [`BG_CURSOR`]. -fn apply_cursor_row(line: Line<'static>, width: u16) -> Line<'static> { - apply_row_tint(line, width, BG_CURSOR) +/// Wash the cursor row with the theme's cursor tint. +fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { + apply_row_tint(line, width, theme.cursor_bg) } -/// Wash a selected (line-selection) row with [`BG_SELECTION`]. -fn apply_selection_row(line: Line<'static>, width: u16) -> Line<'static> { - apply_row_tint(line, width, BG_SELECTION) +/// Wash a selected (line-selection) row with the theme's selection tint. +fn apply_selection_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { + apply_row_tint(line, width, theme.selection_bg) } /// One resolved (bg, fg) pair for a byte range of a line. @@ -119,11 +103,14 @@ struct Segment { } /// Merge background-role spans and syntax fg spans into a flat list of non-overlapping -/// segments covering `[0, len)`. +/// segments covering `[0, len)`. A syntax span carries only its capture index; its color is +/// resolved HERE against `theme` (ADR-035's render-time resolution) — a segment with no covering +/// syntax span falls back to [`FG_DEFAULT`]. fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], fg_spans: Option<&Vec>, + theme: &Theme, ) -> Vec { let mut boundaries: Vec = vec![0, len]; for (s, e, _) in bg_spans { @@ -157,7 +144,7 @@ fn compose_segments( .map(|(_, _, c)| *c); let fg = fg_spans .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) - .map(|s| s.color) + .map(|s| theme.syntax(s.capture)) .unwrap_or(FG_DEFAULT); segments.push(Segment { start, end, bg, fg }); } @@ -214,31 +201,37 @@ fn attribution_mode(role: Role, attribution: &Option) -> Attributio } } -/// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`. -fn del_bg_pair(mode: AttributionMode, old_lnum: u32) -> (Color, Color) { +/// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`, resolved from +/// `theme`'s bright vs. staged Del tints. +fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Theme) -> (Color, Color) { + let bright = (theme.del_subtle, theme.del_strong); + let staged = (theme.del_staged_subtle, theme.del_staged_strong); match mode { - AttributionMode::Plain => (BG_DEL_SUBTLE, BG_DEL_STRONG), - AttributionMode::StagedUniform => (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG), + AttributionMode::Plain => bright, + AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.del_is_staged(old_lnum) { - (BG_DEL_STAGED_SUBTLE, BG_DEL_STAGED_STRONG) + staged } else { - (BG_DEL_SUBTLE, BG_DEL_STRONG) + bright } } } } -/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`. -fn add_bg_pair(mode: AttributionMode, new_lnum: u32) -> (Color, Color) { +/// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`, resolved from +/// `theme`'s bright vs. staged Add tints. +fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Theme) -> (Color, Color) { + let bright = (theme.add_subtle, theme.add_strong); + let staged = (theme.add_staged_subtle, theme.add_staged_strong); match mode { - AttributionMode::Plain => (BG_ADD_SUBTLE, BG_ADD_STRONG), - AttributionMode::StagedUniform => (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG), + AttributionMode::Plain => bright, + AttributionMode::StagedUniform => staged, AttributionMode::Attributed(attribution) => { if attribution.add_is_unstaged(new_lnum) { - (BG_ADD_SUBTLE, BG_ADD_STRONG) + bright } else { - (BG_ADD_STAGED_SUBTLE, BG_ADD_STAGED_STRONG) + staged } } } @@ -266,6 +259,7 @@ fn content_spans( emphasis: Option<(Color, Color)>, word_spans: &[WordSpan], is_word_pair: bool, + theme: &Theme, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -280,7 +274,7 @@ fn content_spans( } } - let segments = compose_segments(text.len(), &bg_spans, hl); + let segments = compose_segments(text.len(), &bg_spans, hl, theme); let mut spans = Vec::with_capacity(segments.len().max(1)); if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( @@ -310,6 +304,7 @@ fn build_pane_line( mode: AttributionMode, gutter_w: usize, content_w: usize, + theme: &Theme, ) -> Line<'static> { match row { Row::Filler => { @@ -331,11 +326,18 @@ fn build_pane_line( let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; let emphasis = match kind { - CellKind::Del => Some(del_bg_pair(mode, n as u32)), - CellKind::Add => Some(add_bg_pair(mode, n as u32)), + CellKind::Del => Some(del_bg_pair(mode, n as u32, theme)), + CellKind::Add => Some(add_bg_pair(mode, n as u32, theme)), CellKind::Context | CellKind::Filler => None, }; - spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); + spans.extend(content_spans( + text, + hl, + emphasis, + word_spans, + is_word_pair, + theme, + )); Line::from(spans) } } @@ -344,8 +346,10 @@ fn build_pane_line( /// Render one frame: header, SBS body, footer, and (when [`App::help_visible`]) the `?` overlay /// on top of everything else. `keymap` is the resolved, possibly-rebound keymap — the footer hint /// and help overlay render its ACTUAL bindings (see [`crate::keymap::footer_hint`]/ -/// [`crate::keymap::help_sections`]), never a hardcoded key string. -pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { +/// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved +/// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, +/// and cursor/selection washes all resolve their colors against it at paint time. +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Theme) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -375,16 +379,16 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap) { let outline_area = hlayout[0]; let div_area = hlayout[1]; let diff_area = hlayout[2]; - render_outline(frame, app, outline_area); + render_outline(frame, app, outline_area, theme); for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); } - render_body(frame, app, diff_area); + render_body(frame, app, diff_area, theme); } else { // Closed: the diff takes the full body width — the exact M4 look (locked design). - render_body(frame, app, body_area); + render_body(frame, app, body_area, theme); } if app.help_visible { @@ -456,10 +460,10 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// indent, a one-character staged-ness glyph (blank for a committed changeset's files — see /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the -/// diff's [`App::cursor`]) gets [`BG_CURSOR`] while the outline has focus, or the dimmer -/// [`BG_OUTLINE_CURSOR_UNFOCUSED`] while it's merely open (so the remembered position stays +/// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer +/// [`Theme::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). -fn render_outline(frame: &mut Frame, app: &App, area: Rect) { +fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) { let items = app.outline_items(); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -483,9 +487,9 @@ fn render_outline(frame: &mut Frame, app: &App, area: Rect) { let is_cursor = item_idx == cursor; let line = build_outline_line(item); let line = if is_cursor && focused { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_cursor { - apply_row_tint(line, area.width, BG_OUTLINE_CURSOR_UNFOCUSED) + apply_row_tint(line, area.width, theme.outline_cursor_unfocused_bg) } else { line }; @@ -685,21 +689,22 @@ fn render_gap_row( skipped: usize, is_cursor: bool, is_selected: bool, + theme: &Theme, ) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); // Cursor wins over selection on the same row. let line = if is_cursor { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_selected { - apply_selection_row(line, area.width) + apply_selection_row(line, area.width, theme) } else { line }; buf.set_line(area.x, y, &line, area.width); } -fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { +fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; @@ -724,15 +729,15 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { // The single pane is the focused one, so it shows any active selection. let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => { - render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) - } - AppLayout::Inline => { - render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) - } + AppLayout::Sbs => render_pane_sbs( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), + AppLayout::Inline => render_pane_inline( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), } } - EffectiveZoom::Split => render_body_split(frame, app, area, idx), + EffectiveZoom::Split => render_body_split(frame, app, area, idx, theme), } } @@ -741,7 +746,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect) { /// the cursor highlight draws only in the focused pane. The body area splits caption(1) + /// unstaged-content + caption(1) + staged-content, with the remainder halved between the two /// content panes (even split). -fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { +fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Theme) { // Too short to fit two captions plus a content line each: fall back to the focused pane alone, // rendered over the whole area, so the user still sees SOMETHING navigable. if area.height < 4 { @@ -750,12 +755,12 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { let (scroll, cursor) = app.pane_render_state(role); let selection = app.selection_range(); match app.layout { - AppLayout::Sbs => { - render_pane_sbs(frame, app, area, idx, role, scroll, cursor, selection) - } - AppLayout::Inline => { - render_pane_inline(frame, app, area, idx, role, scroll, cursor, selection) - } + AppLayout::Sbs => render_pane_sbs( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), + AppLayout::Inline => render_pane_inline( + frame, app, area, idx, role, scroll, cursor, selection, theme, + ), } return; } @@ -802,6 +807,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { u_scroll, u_cursor, u_selection, + theme, ); render_pane_sbs( frame, @@ -812,6 +818,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { s_scroll, s_cursor, s_selection, + theme, ); } AppLayout::Inline => { @@ -824,6 +831,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { u_scroll, u_cursor, u_selection, + theme, ); render_pane_inline( frame, @@ -834,6 +842,7 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize) { s_scroll, s_cursor, s_selection, + theme, ); } } @@ -860,6 +869,7 @@ fn render_pane_sbs( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, + theme: &Theme, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -927,6 +937,7 @@ fn render_pane_sbs( *skipped, is_cursor, is_selected, + theme, ); } DisplayRow::Row(row) => { @@ -947,6 +958,7 @@ fn render_pane_sbs( mode, old_gutter_w, old_area.width as usize, + theme, ); let new_line = build_pane_line( view, @@ -958,17 +970,18 @@ fn render_pane_sbs( mode, new_gutter_w, new_area.width as usize, + theme, ); - // Cursor wins over selection on the same row (see [`BG_SELECTION`]). + // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). let (old_line, new_line) = if is_cursor { ( - apply_cursor_row(old_line, old_area.width), - apply_cursor_row(new_line, new_area.width), + apply_cursor_row(old_line, old_area.width, theme), + apply_cursor_row(new_line, new_area.width, theme), ) } else if is_selected { ( - apply_selection_row(old_line, old_area.width), - apply_selection_row(new_line, new_area.width), + apply_selection_row(old_line, old_area.width, theme), + apply_selection_row(new_line, new_area.width, theme), ) } else { (old_line, new_line) @@ -987,7 +1000,7 @@ fn render_pane_sbs( div_area.x, y, "│", - Style::default().fg(FG_DIM).bg(BG_CURSOR), + Style::default().fg(FG_DIM).bg(theme.cursor_bg), ); } } @@ -1018,6 +1031,7 @@ fn build_inline_line( mode: AttributionMode, old_gutter_w: usize, new_gutter_w: usize, + theme: &Theme, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1057,11 +1071,18 @@ fn build_inline_line( // `kind` is always Del/Add/Context here — inline has no Filler rows. `old_opt`/`new_opt` // carry the exact lineno each kind is documented to have (see this fn's own match above). let emphasis = match kind { - CellKind::Del => old_opt.map(|n| del_bg_pair(mode, n as u32)), - CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32)), + CellKind::Del => old_opt.map(|n| del_bg_pair(mode, n as u32, theme)), + CellKind::Add => new_opt.map(|n| add_bg_pair(mode, n as u32, theme)), CellKind::Context | CellKind::Filler => None, }; - spans.extend(content_spans(text, hl, emphasis, word_spans, is_word_pair)); + spans.extend(content_spans( + text, + hl, + emphasis, + word_spans, + is_word_pair, + theme, + )); Line::from(spans) } @@ -1078,6 +1099,7 @@ fn render_pane_inline( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, + theme: &Theme, ) { let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -1118,6 +1140,7 @@ fn render_pane_inline( *skipped, is_cursor, is_selected, + theme, ); } row => { @@ -1131,13 +1154,20 @@ fn render_pane_inline( InlineRow::Add { .. } => &new_spans, _ => &[], }; - let line = - build_inline_line(view, row, word_spans, mode, old_gutter_w, new_gutter_w); - // Cursor wins over selection on the same row (see [`BG_SELECTION`]). + let line = build_inline_line( + view, + row, + word_spans, + mode, + old_gutter_w, + new_gutter_w, + theme, + ); + // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). let line = if is_cursor { - apply_cursor_row(line, area.width) + apply_cursor_row(line, area.width, theme) } else if is_selected { - apply_selection_row(line, area.width) + apply_selection_row(line, area.width, theme) } else { line }; @@ -1155,23 +1185,24 @@ mod tests { use git_workon_fixture::prelude::*; - use super::{ - render, BG_ADD_STAGED_STRONG, BG_ADD_STAGED_SUBTLE, BG_ADD_STRONG, BG_ADD_SUBTLE, - BG_DEL_STAGED_STRONG, BG_DEL_STAGED_SUBTLE, BG_DEL_STRONG, BG_DEL_SUBTLE, - }; + use super::render; use crate::align::{DisplayRow, Row}; use crate::app::test_support::app_from_fixture; use crate::app::App; use crate::keymap::Keymap; + use crate::theme::Theme; - /// Render one frame against the default (unrebound) keymap — the vast majority of `render.rs` - /// tests don't care about keybindings at all. Tests that DO (the footer/overlay content tests) - /// build their own [`Keymap`] and call [`render`] directly instead. + /// Render one frame against the default (unrebound) keymap and the dark theme — the vast + /// majority of `render.rs` tests don't care about keybindings and only ever ran dark. Tests + /// that DO care about bindings (the footer/overlay content tests) build their own [`Keymap`] + /// and call [`render`] directly instead. Color assertions resolve through [`Theme::dark`], so + /// they pin the exact dark values the refactor must preserve (ADR-035's pixel-identity gate). fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); let keymap = Keymap::defaults(); - terminal.draw(|f| render(f, app, &keymap)).unwrap(); + let theme = Theme::dark(); + terminal.draw(|f| render(f, app, &keymap, &theme)).unwrap(); terminal.backend().buffer().clone() } @@ -1484,7 +1515,7 @@ mod tests { ); assert_eq!( buf.cell((divider_x, cursor_y)).unwrap().style().bg, - Some(super::BG_CURSOR), + Some(Theme::dark().cursor_bg), "expected the cursor row's DIVIDER cell to carry the cursor background, not the \ default — otherwise the highlight has a seam through the middle" ); @@ -1548,7 +1579,7 @@ mod tests { // has no bg) — i.e. the raw tint, since blend_bg(None, tint) == tint. assert_eq!( bg(1, sel_y), - Some(super::BG_SELECTION), + Some(Theme::dark().selection_bg), "a selected plain-context row shows the raw selection tint" ); } @@ -1730,8 +1761,9 @@ mod tests { .style() .bg; - let dim_dels = [Some(BG_DEL_STAGED_SUBTLE), Some(BG_DEL_STAGED_STRONG)]; - let bright_dels = [Some(BG_DEL_SUBTLE), Some(BG_DEL_STRONG)]; + let t = Theme::dark(); + let dim_dels = [Some(t.del_staged_subtle), Some(t.del_staged_strong)]; + let bright_dels = [Some(t.del_subtle), Some(t.del_strong)]; assert!( dim_dels.contains(&staged_del_bg), "expected the staged row's Del side to use the dim pair, got {staged_del_bg:?}" @@ -1759,8 +1791,8 @@ mod tests { .style() .bg; - let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; - let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; + let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; + let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; assert!( dim_adds.contains(&staged_add_bg), "expected the staged row's Add side to use the dim pair, got {staged_add_bg:?}" @@ -1839,7 +1871,10 @@ mod tests { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| render(f, &mut app, &keymap)).unwrap(); + let theme = Theme::dark(); + terminal + .draw(|f| render(f, &mut app, &keymap, &theme)) + .unwrap(); let buf = terminal.backend().buffer().clone(); let footer_y = buf.area.height - 1; @@ -2123,8 +2158,9 @@ mod tests { let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; - let bright_adds = [Some(BG_ADD_SUBTLE), Some(BG_ADD_STRONG)]; - let dim_adds = [Some(BG_ADD_STAGED_SUBTLE), Some(BG_ADD_STAGED_STRONG)]; + let t = Theme::dark(); + let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; + let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; assert!( bright_adds.contains(&add_bg), "expected a committed changeset's Add cell to render the plain (bright) pair, \ @@ -2268,8 +2304,8 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); assert_eq!( buf.cell((2, cursor_y)).unwrap().style().bg, - Some(super::BG_CURSOR), - "expected the outline's cursor row to carry BG_CURSOR while focused" + Some(Theme::dark().cursor_bg), + "expected the outline's cursor row to carry the cursor tint while focused" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs new file mode 100644 index 00000000..62be986d --- /dev/null +++ b/git-workon-review/src/theme.rs @@ -0,0 +1,211 @@ +//! The base16 color-scheme primitive and the colors the renderer resolves against it (ADR-035). +//! +//! This is the theming *primitive* — the resolved palette a frame is painted with — distinct from +//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 is +//! dark-only and behavior-preserving: [`Theme::dark`] reproduces M3–M5's hardcoded colors exactly. +//! CS5 adds a light instance and wires [`crate::config::Theme`] to pick between them; CS6 adds the +//! terminal-derivation probe for `auto`. +//! +//! ## Hybrid boundary (ADR-035) +//! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the +//! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live +//! here. Chrome that is NOT on a tint (gutter, dividers, footer, dim labels, status markers) stays +//! ANSI-named / const in [`crate::render`] so it self-adapts to the terminal palette and is +//! probe-independent. This module deliberately holds only the on-tint half. + +use ratatui::style::Color; + +/// A 16-slot base16 palette: `base00`–`base07` are the monochrome ramp (background → foreground), +/// `base08`–`base0F` the accents. Slot roles follow the base16 styling spec (base08 red, base0B +/// green, base0E keyword, …). Indexed 0–15 by slot number. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Base16 { + pub slots: [Color; 16], +} + +impl Base16 { + /// base16-eighties.dark (Chris Kempson) — the scheme M3–M5's syntax accents were already drawn + /// from (`highlight.rs`'s `C_*` consts ARE these slots; see ADR-035). Reproduced here in full + /// so `Theme::dark` is a faithful re-expression of the shipped dark colors. + const EIGHTIES_DARK: Base16 = Base16 { + slots: [ + Color::Rgb(0x2d, 0x2d, 0x2d), // base00 background + Color::Rgb(0x39, 0x39, 0x39), // base01 + Color::Rgb(0x51, 0x51, 0x51), // base02 + Color::Rgb(0x74, 0x73, 0x69), // base03 comments + Color::Rgb(0xa0, 0x9f, 0x93), // base04 + Color::Rgb(0xd3, 0xd0, 0xc8), // base05 foreground + Color::Rgb(0xe8, 0xe6, 0xdf), // base06 + Color::Rgb(0xf2, 0xf0, 0xec), // base07 + Color::Rgb(0xf2, 0x77, 0x7a), // base08 red / diff deleted + Color::Rgb(0xf9, 0x91, 0x57), // base09 orange + Color::Rgb(0xff, 0xcc, 0x66), // base0A yellow + Color::Rgb(0x99, 0xcc, 0x99), // base0B green / diff inserted + Color::Rgb(0x66, 0xcc, 0xcc), // base0C cyan + Color::Rgb(0x66, 0x99, 0xcc), // base0D blue + Color::Rgb(0xcc, 0x99, 0xcc), // base0E purple / keyword + Color::Rgb(0xd2, 0x7b, 0x53), // base0F brown + ], + }; + + fn slot(&self, i: usize) -> Color { + self.slots[i] + } +} + +/// Per-capture syntax template: each entry is the base16 slot index that the parallel +/// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions +/// (ADR-035). Theme-invariant — every scheme applies this same template to its own slots — so it +/// lives with the primitive, not on any one [`Theme`]. A theme switch re-colors by re-rendering: +/// the tree-sitter pass records only the capture index (see [`crate::highlight::FgSpan`]), and the +/// color is resolved here at paint time. +const SYNTAX_SLOTS: [usize; 28] = [ + 9, // attribute → base09 orange + 3, // comment → base03 + 9, // constant → base09 + 9, // constant.builtin → base09 + 10, // constructor → base0A yellow + 5, // embedded → base05 fg + 12, // escape → base0C cyan + 13, // function → base0D blue + 13, // function.builtin → base0D + 13, // function.macro → base0D + 13, // function.method → base0D + 14, // keyword → base0E purple + 8, // label → base08 red + 9, // number → base09 + 5, // operator → base05 + 12, // property → base0C + 5, // punctuation → base05 + 5, // punctuation.bracket → base05 + 5, // punctuation.delimiter→ base05 + 12, // punctuation.special → base0C + 11, // string → base0B green + 12, // string.special → base0C + 8, // tag → base08 + 10, // type → base0A + 10, // type.builtin → base0A + 5, // variable → base05 + 8, // variable.builtin → base08 + 5, // variable.parameter → base05 +]; + +/// The number of entries in the per-capture syntax template — must equal +/// [`crate::highlight::HIGHLIGHT_NAMES`]'s length (asserted in `highlight`'s tests). Exposed so +/// that invariant can be checked without making [`SYNTAX_SLOTS`] itself public. +pub fn syntax_slot_count() -> usize { + SYNTAX_SLOTS.len() +} + +/// The resolved on-tint palette a frame is painted with (ADR-035's theme-controlled half). +/// +/// Syntax foreground is looked up per capture index via [`Theme::syntax`]; the diff-background +/// gradient, its staged variants, and the cursor/selection/outline washes are read directly. All +/// values in [`Theme::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a +/// behavior-preserving refactor). +pub struct Theme { + /// Per-capture syntax fg, indexed by the same capture index as + /// [`crate::highlight::HIGHLIGHT_NAMES`] (see [`SYNTAX_SLOTS`]). + syntax: Vec, + + /// Whole-line subtle / word-level strong background for an unstaged (bright) Del cell. + pub del_subtle: Color, + pub del_strong: Color, + /// Bright Add-cell background pair (counterpart of [`Theme::del_subtle`]). + pub add_subtle: Color, + pub add_strong: Color, + /// Dim/desaturated Del pair for staged-ness attribution (locked decision #7) — a staged change + /// reads as "already handled" without disappearing into plain context. + pub del_staged_subtle: Color, + pub del_staged_strong: Color, + /// Dim Add pair — green-tinted counterpart of the staged Del pair. + pub add_staged_subtle: Color, + pub add_staged_strong: Color, + + /// Tint blended into the cursor row's background — a cool slate-blue. + pub cursor_bg: Color, + /// Tint blended into a selected (line-selection) row — a muted teal, distinct from + /// [`Theme::cursor_bg`]. + pub selection_bg: Color, + /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Theme::cursor_bg`]. + pub outline_cursor_unfocused_bg: Color, +} + +impl Theme { + /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor + /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-035). + /// + /// The diff-bg tints are held explicit rather than derived: a clean base08/base0B → base00 + /// blend cannot reproduce these particular hand-tuned constants (their green/blue channels sit + /// *below* base00, so no convex blend toward base00 reaches them). ADR-035's derivation is + /// therefore deferred to CS5, where the light scheme defines its own tints; dark keeps the + /// shipped values verbatim. + pub fn dark() -> Self { + let base = Base16::EIGHTIES_DARK; + Theme { + syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), + del_subtle: Color::Rgb(60, 24, 24), + del_strong: Color::Rgb(120, 40, 40), + add_subtle: Color::Rgb(20, 48, 24), + add_strong: Color::Rgb(32, 100, 48), + del_staged_subtle: Color::Rgb(42, 26, 28), + del_staged_strong: Color::Rgb(64, 38, 40), + add_staged_subtle: Color::Rgb(24, 34, 26), + add_staged_strong: Color::Rgb(34, 50, 38), + cursor_bg: Color::Rgb(45, 50, 90), + selection_bg: Color::Rgb(30, 66, 66), + outline_cursor_unfocused_bg: Color::Rgb(35, 38, 55), + } + } + + /// The syntax foreground for a capture index (position in + /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole + /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves + /// the color here. Panics on an out-of-range index, exactly as the former direct + /// `HIGHLIGHT_COLORS[idx]` lookup did — the index always comes from the bound capture space. + pub fn syntax(&self, capture: usize) -> Color { + self.syntax[capture] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::highlight::{capture_index, HIGHLIGHT_NAMES}; + + #[test] + fn syntax_template_is_parallel_with_the_capture_names() { + assert_eq!(SYNTAX_SLOTS.len(), HIGHLIGHT_NAMES.len()); + } + + #[test] + fn dark_syntax_resolves_representative_captures_to_the_historical_colors() { + let theme = Theme::dark(); + let color = |name: &str| theme.syntax(capture_index(name).unwrap()); + // The exact C_* consts highlight.rs shipped in M3 (base16-eighties.dark accents). + assert_eq!(color("keyword"), Color::Rgb(0xcc, 0x99, 0xcc)); // C_PURPLE / base0E + assert_eq!(color("string"), Color::Rgb(0x99, 0xcc, 0x99)); // C_GREEN / base0B + assert_eq!(color("comment"), Color::Rgb(0x74, 0x73, 0x69)); // C_COMMENT / base03 + assert_eq!(color("function"), Color::Rgb(0x66, 0x99, 0xcc)); // C_BLUE / base0D + assert_eq!(color("number"), Color::Rgb(0xf9, 0x91, 0x57)); // C_ORANGE / base09 + assert_eq!(color("variable"), Color::Rgb(0xd3, 0xd0, 0xc8)); // C_FG / base05 + } + + #[test] + fn dark_diff_tints_match_the_historical_constants() { + // The pixel-identity gate: `Theme::dark` must reproduce M3–M5's hand-tuned tints exactly. + // Pinned to the literals so a future refactor can't silently drift dark. + let t = Theme::dark(); + assert_eq!(t.del_subtle, Color::Rgb(60, 24, 24)); + assert_eq!(t.del_strong, Color::Rgb(120, 40, 40)); + assert_eq!(t.add_subtle, Color::Rgb(20, 48, 24)); + assert_eq!(t.add_strong, Color::Rgb(32, 100, 48)); + assert_eq!(t.del_staged_subtle, Color::Rgb(42, 26, 28)); + assert_eq!(t.del_staged_strong, Color::Rgb(64, 38, 40)); + assert_eq!(t.add_staged_subtle, Color::Rgb(24, 34, 26)); + assert_eq!(t.add_staged_strong, Color::Rgb(34, 50, 38)); + assert_eq!(t.cursor_bg, Color::Rgb(45, 50, 90)); + assert_eq!(t.selection_bg, Color::Rgb(30, 66, 66)); + assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); + } +} diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index d0460739..32105095 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -24,6 +24,7 @@ use ratatui::Terminal; use workon_review::app::App; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; +use workon_review::theme::Theme; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the /// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay @@ -301,7 +302,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap, theme: &Theme) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -309,7 +310,7 @@ pub fn run(app: &mut App, keymap: &Keymap) -> io::Result<()> { let backend = CrosstermBackend::new(out); let mut terminal = Terminal::new(backend)?; - let result = event_loop(&mut terminal, app, keymap); + let result = event_loop(&mut terminal, app, keymap, theme); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -322,12 +323,13 @@ fn event_loop( terminal: &mut Terminal>, app: &mut App, keymap: &Keymap, + theme: &Theme, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; loop { - terminal.draw(|f| render::render(f, app, keymap))?; + terminal.draw(|f| render::render(f, app, keymap, theme))?; if quit { return Ok(()); From 375923d0c48255ae6f56def168505e35dae1dfa7 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 01:34:16 -0400 Subject: [PATCH 09/13] feat(review): add light theme and theme selection --- docs/adr/035-review-theming-base16-hybrid.md | 12 +- git-workon-review/src/highlight.rs | 4 +- git-workon-review/src/main.rs | 15 +- git-workon-review/src/render.rs | 60 ++--- git-workon-review/src/theme.rs | 233 +++++++++++++++++-- git-workon-review/src/tui.rs | 6 +- 6 files changed, 266 insertions(+), 64 deletions(-) diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index bb5287ad..35b0abd2 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -34,7 +34,7 @@ is spec-conformant. are **probe-independent** (work even when terminal-derivation fails). Half already are ANSI-named today. -**Primitive — the theme is a base16 scheme.** A `Theme` holds the 16 slots +**Primitive — the theme is a base16 scheme.** A `Palette` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing capture→slot template. @@ -44,7 +44,7 @@ derivation is luminance-dependent, not a single "blend toward base00" (corrected - **Dark (base00 dark):** the shipped M3–M5 tints are more saturated/darker than *any* convex blend of an accent toward a dark base00 can produce (their green/blue channels sit *below* base00's). A blend toward a dark base00 also yields muddy mid-tones, not punchy washes. So - the **dark tints are held explicit** in `Theme::dark()` (byte-identical to M3–M5, per the + the **dark tints are held explicit** in `Palette::dark()` (byte-identical to M3–M5, per the pixel-identity gate). Deriving them would require scaling the accent toward *black* plus a desaturation step, not a base00 blend — not worth reverse-engineering the hand-tuned values. - **Light (base00 light) and terminal-derived:** blending an accent toward a *light* base00 @@ -60,7 +60,7 @@ stays authored. `config.configure()` and is theme-invariant. - `FgSpan` carries the **capture index** (semantic role), not a resolved `Color`. The highlight phase (`highlight.rs:283`) records the index instead of looking up a color. -- Render resolves `index → Color` against the active `Theme` (`theme.slot[idx]`), in the +- Render resolves `index → Color` against the active `Palette` (`palette.slot[idx]`), in the same place it resolves diff tints and cursor/selection. One theme-application site; syntax and background contrast are reasoned about together. - Consequence: the expensive tree-sitter pass is theme-free and cacheable — a theme switch @@ -93,7 +93,7 @@ stays authored. - Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from the start**, not deferred. `auto` never has to change meaning later. -- Because color resolves late as `theme.slot[idx]`, the slot *source* is pluggable — a future +- Because color resolves late as `palette.slot[idx]`, the slot *source* is pluggable — a future user-supplied base16 scheme (`theme = ` / a scheme file, the deferred "user-configurable colors" tier) is additive, no renderer change. - The OSC probe is the single most terminal-fragile component; its blast radius is contained @@ -101,9 +101,9 @@ stays authored. never a hang or a broken palette. - Adding a syntax capture = adding it to `HIGHLIGHT_NAMES` + the capture→slot template; it is automatically themed by every scheme. -- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Theme` threaded +- `render.rs` and `highlight.rs` both change: the `const` palette becomes a `Palette` threaded to render; `FgSpan` loses its `Color` field in favor of a capture index. Existing render - tests that assert concrete colors must resolve through a fixed test `Theme`. + tests that assert concrete colors must resolve through a fixed test `Palette`. ## References diff --git a/git-workon-review/src/highlight.rs b/git-workon-review/src/highlight.rs index 291ec56b..ba6067e6 100644 --- a/git-workon-review/src/highlight.rs +++ b/git-workon-review/src/highlight.rs @@ -15,13 +15,13 @@ pub const MAX_HIGHLIGHT_LINES: usize = 20_000; /// Foreground syntax span for a single line: a byte range and the semantic *capture index* — /// the position in [`HIGHLIGHT_NAMES`] of the capture that covers it. The color is resolved at -/// render time against the active [`crate::theme::Theme`] (ADR-035), NOT baked in here: the +/// render time against the active [`crate::theme::Palette`] (ADR-035), NOT baked in here: the /// tree-sitter pass is theme-free and cacheable, and a theme switch recolors by re-rendering. #[derive(Debug, Clone)] pub struct FgSpan { pub start: usize, pub end: usize, - /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Theme::syntax`]. + /// Index into [`HIGHLIGHT_NAMES`]; resolve via [`crate::theme::Palette::syntax`]. pub capture: usize, } diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index bece0d89..1ebe6d57 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -8,7 +8,7 @@ use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; use workon_review::config::ReviewConfig; use workon_review::keymap::Keymap; -use workon_review::theme::Theme; +use workon_review::theme::Palette; /// A TUI for reviewing changesets #[derive(Debug, Parser)] @@ -55,6 +55,14 @@ fn main() -> Result<()> { Err(_) => Keymap::defaults(), }; + // Resolve the palette selection the same way, before `repo` moves — a config-read error + // degrades to dark rather than aborting the review (CS5); `Palette::for_theme` handles the + // parsed-selection cases (including `Auto`'s CS6-deferred fallback to dark). + let theme = ReviewConfig::new(&repo) + .theme() + .map(Palette::for_theme) + .unwrap_or_else(|_| Palette::dark()); + // Resolve the view-config settings (outline width/mode, diff layout/zoom) the same way, // before `repo` moves — CS7. `view_config` reads into an owned `RawViewConfig`, so no // borrow of `repo` survives past this statement (unlike a bare `ReviewConfig<'repo>`, which @@ -82,11 +90,6 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } - // CS4 is dark-only and unconditional — a pure refactor with no user-visible change. CS5 wires - // `ReviewConfig::theme()` (config `Theme::{Auto,Dark,Light}`) to pick the palette here; CS6 - // adds the terminal-derivation probe for `auto`. - let theme = Theme::dark(); - tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index fc73f27e..5e4e6ee1 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -20,17 +20,17 @@ use crate::highlight::FgSpan; use crate::keymap::{footer_hint, help_sections, Keymap}; use crate::model::FileStatus; use crate::outline::OutlineItem; -use crate::theme::Theme; +use crate::theme::Palette; use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax -// foreground) now come from a [`Theme`] threaded through render (ADR-035). The chrome colors below +// foreground) now come from a [`Palette`] threaded through render (ADR-035). The chrome colors below // stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and // self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). /// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits /// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the -/// [`Theme`] instead (see [`compose_segments`]). +/// [`Palette`] instead (see [`compose_segments`]). const FG_DEFAULT: Color = Color::Gray; const FG_DIM: Color = Color::DarkGray; /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on @@ -85,12 +85,12 @@ fn apply_row_tint(mut line: Line<'static>, width: u16, tint: Color) -> Line<'sta } /// Wash the cursor row with the theme's cursor tint. -fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { +fn apply_cursor_row(line: Line<'static>, width: u16, theme: &Palette) -> Line<'static> { apply_row_tint(line, width, theme.cursor_bg) } /// Wash a selected (line-selection) row with the theme's selection tint. -fn apply_selection_row(line: Line<'static>, width: u16, theme: &Theme) -> Line<'static> { +fn apply_selection_row(line: Line<'static>, width: u16, theme: &Palette) -> Line<'static> { apply_row_tint(line, width, theme.selection_bg) } @@ -110,7 +110,7 @@ fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], fg_spans: Option<&Vec>, - theme: &Theme, + theme: &Palette, ) -> Vec { let mut boundaries: Vec = vec![0, len]; for (s, e, _) in bg_spans { @@ -203,7 +203,7 @@ fn attribution_mode(role: Role, attribution: &Option) -> Attributio /// The (subtle, strong) background pair for a Del cell at `old_lnum`, given `mode`, resolved from /// `theme`'s bright vs. staged Del tints. -fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Theme) -> (Color, Color) { +fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Palette) -> (Color, Color) { let bright = (theme.del_subtle, theme.del_strong); let staged = (theme.del_staged_subtle, theme.del_staged_strong); match mode { @@ -221,7 +221,7 @@ fn del_bg_pair(mode: AttributionMode, old_lnum: u32, theme: &Theme) -> (Color, C /// The (subtle, strong) background pair for an Add cell at `new_lnum`, given `mode`, resolved from /// `theme`'s bright vs. staged Add tints. -fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Theme) -> (Color, Color) { +fn add_bg_pair(mode: AttributionMode, new_lnum: u32, theme: &Palette) -> (Color, Color) { let bright = (theme.add_subtle, theme.add_strong); let staged = (theme.add_staged_subtle, theme.add_staged_strong); match mode { @@ -259,7 +259,7 @@ fn content_spans( emphasis: Option<(Color, Color)>, word_spans: &[WordSpan], is_word_pair: bool, - theme: &Theme, + theme: &Palette, ) -> Vec> { let mut bg_spans: Vec<(usize, usize, Color)> = Vec::new(); if let Some((subtle_bg, strong_bg)) = emphasis { @@ -304,7 +304,7 @@ fn build_pane_line( mode: AttributionMode, gutter_w: usize, content_w: usize, - theme: &Theme, + theme: &Palette, ) -> Line<'static> { match row { Row::Filler => { @@ -349,7 +349,7 @@ fn build_pane_line( /// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved /// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, /// and cursor/selection washes all resolve their colors against it at paint time. -pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Theme) { +pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); let vlayout = Layout::default() .direction(Direction::Vertical) @@ -461,9 +461,9 @@ fn render_help_overlay(frame: &mut Frame, app: &App, keymap: &Keymap, area: Rect /// [`crate::outline::StagedStatus`]'s doc comment for why no special-casing is needed here), and /// the path. The cursor row (the outline's OWN cursor — a separate coordinate space from the /// diff's [`App::cursor`]) gets the theme's cursor tint while the outline has focus, or the dimmer -/// [`Theme::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays +/// [`Palette::outline_cursor_unfocused_bg`] while it's merely open (so the remembered position stays /// legible even after focus returns to the diff). -fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) { +fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let items = app.outline_items(); let cursor = app.outline_cursor(); let focused = app.outline_focused(); @@ -689,7 +689,7 @@ fn render_gap_row( skipped: usize, is_cursor: bool, is_selected: bool, - theme: &Theme, + theme: &Palette, ) { let msg = format!("··· {skipped} unchanged lines ···"); let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); @@ -704,7 +704,7 @@ fn render_gap_row( buf.set_line(area.x, y, &line, area.width); } -fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { +fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { if app.files().is_empty() { frame.render_widget(Paragraph::new("(no changes)"), area); return; @@ -746,7 +746,7 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) { /// the cursor highlight draws only in the focused pane. The body area splits caption(1) + /// unstaged-content + caption(1) + staged-content, with the remainder halved between the two /// content panes (even split). -fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Theme) { +fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, theme: &Palette) { // Too short to fit two captions plus a content line each: fall back to the focused pane alone, // rendered over the whole area, so the user still sees SOMETHING navigable. if area.height < 4 { @@ -869,7 +869,7 @@ fn render_pane_sbs( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, - theme: &Theme, + theme: &Palette, ) { let left_w = area.width.saturating_sub(1) / 2; let right_w = area.width.saturating_sub(1).saturating_sub(left_w); @@ -972,7 +972,7 @@ fn render_pane_sbs( new_area.width as usize, theme, ); - // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). + // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let (old_line, new_line) = if is_cursor { ( apply_cursor_row(old_line, old_area.width, theme), @@ -1031,7 +1031,7 @@ fn build_inline_line( mode: AttributionMode, old_gutter_w: usize, new_gutter_w: usize, - theme: &Theme, + theme: &Palette, ) -> Line<'static> { let (old_opt, new_opt, text, hl, kind) = match *row { InlineRow::Context { old, new } => ( @@ -1099,7 +1099,7 @@ fn render_pane_inline( scroll: usize, cursor: Option, selection: Option<(usize, usize)>, - theme: &Theme, + theme: &Palette, ) { let Some(view) = app.role_view_ref(idx, role) else { frame.render_widget(Paragraph::new("(failed to load file)"), area); @@ -1163,7 +1163,7 @@ fn render_pane_inline( new_gutter_w, theme, ); - // Cursor wins over selection on the same row (see [`Theme::selection_bg`]). + // Cursor wins over selection on the same row (see [`Palette::selection_bg`]). let line = if is_cursor { apply_cursor_row(line, area.width, theme) } else if is_selected { @@ -1190,18 +1190,18 @@ mod tests { use crate::app::test_support::app_from_fixture; use crate::app::App; use crate::keymap::Keymap; - use crate::theme::Theme; + use crate::theme::Palette; /// Render one frame against the default (unrebound) keymap and the dark theme — the vast /// majority of `render.rs` tests don't care about keybindings and only ever ran dark. Tests /// that DO care about bindings (the footer/overlay content tests) build their own [`Keymap`] - /// and call [`render`] directly instead. Color assertions resolve through [`Theme::dark`], so + /// and call [`render`] directly instead. Color assertions resolve through [`Palette::dark`], so /// they pin the exact dark values the refactor must preserve (ADR-035's pixel-identity gate). fn render_once(app: &mut App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); let keymap = Keymap::defaults(); - let theme = Theme::dark(); + let theme = Palette::dark(); terminal.draw(|f| render(f, app, &keymap, &theme)).unwrap(); terminal.backend().buffer().clone() } @@ -1515,7 +1515,7 @@ mod tests { ); assert_eq!( buf.cell((divider_x, cursor_y)).unwrap().style().bg, - Some(Theme::dark().cursor_bg), + Some(Palette::dark().cursor_bg), "expected the cursor row's DIVIDER cell to carry the cursor background, not the \ default — otherwise the highlight has a seam through the middle" ); @@ -1579,7 +1579,7 @@ mod tests { // has no bg) — i.e. the raw tint, since blend_bg(None, tint) == tint. assert_eq!( bg(1, sel_y), - Some(Theme::dark().selection_bg), + Some(Palette::dark().selection_bg), "a selected plain-context row shows the raw selection tint" ); } @@ -1761,7 +1761,7 @@ mod tests { .style() .bg; - let t = Theme::dark(); + let t = Palette::dark(); let dim_dels = [Some(t.del_staged_subtle), Some(t.del_staged_strong)]; let bright_dels = [Some(t.del_subtle), Some(t.del_strong)]; assert!( @@ -1871,7 +1871,7 @@ mod tests { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend).unwrap(); - let theme = Theme::dark(); + let theme = Palette::dark(); terminal .draw(|f| render(f, &mut app, &keymap, &theme)) .unwrap(); @@ -2158,7 +2158,7 @@ mod tests { let new_content_x = left_w + 1 + 4; // divider + gutter width 3 + 1 space let add_bg = buf.cell((new_content_x, row_y)).unwrap().style().bg; - let t = Theme::dark(); + let t = Palette::dark(); let bright_adds = [Some(t.add_subtle), Some(t.add_strong)]; let dim_adds = [Some(t.add_staged_subtle), Some(t.add_staged_strong)]; assert!( @@ -2304,7 +2304,7 @@ mod tests { let buf = render_once(&mut app, OUTLINE_TEST_WIDTH, 20); assert_eq!( buf.cell((2, cursor_y)).unwrap().style().bg, - Some(Theme::dark().cursor_bg), + Some(Palette::dark().cursor_bg), "expected the outline's cursor row to carry the cursor tint while focused" ); } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 62be986d..2a5703c3 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -1,9 +1,9 @@ //! The base16 color-scheme primitive and the colors the renderer resolves against it (ADR-035). //! //! This is the theming *primitive* — the resolved palette a frame is painted with — distinct from -//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 is -//! dark-only and behavior-preserving: [`Theme::dark`] reproduces M3–M5's hardcoded colors exactly. -//! CS5 adds a light instance and wires [`crate::config::Theme`] to pick between them; CS6 adds the +//! [`crate::config::Theme`], which is the git-config *selection* (`auto`/`dark`/`light`). CS4 was +//! dark-only and behavior-preserving: [`Palette::dark`] reproduces M3–M5's hardcoded colors exactly. +//! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! //! ## Hybrid boundary (ADR-035) @@ -26,7 +26,7 @@ pub struct Base16 { impl Base16 { /// base16-eighties.dark (Chris Kempson) — the scheme M3–M5's syntax accents were already drawn /// from (`highlight.rs`'s `C_*` consts ARE these slots; see ADR-035). Reproduced here in full - /// so `Theme::dark` is a faithful re-expression of the shipped dark colors. + /// so `Palette::dark` is a faithful re-expression of the shipped dark colors. const EIGHTIES_DARK: Base16 = Base16 { slots: [ Color::Rgb(0x2d, 0x2d, 0x2d), // base00 background @@ -48,15 +48,57 @@ impl Base16 { ], }; + /// base16-one-light (Daniel Pfeifer, http://github.com/purpleKarrot) — a published base16 + /// LIGHT scheme (tinted-theming/schemes, `base16/one-light.yaml`), pasted verbatim per + /// ADR-035 ("do NOT hand-invent accent colors"). base00 is near-white (the light background); + /// base07 is the darkest ramp step (high-contrast fg on a light bg — base16's ramp direction + /// is background→foreground, and "foreground" on a light scheme means dark). + const ONE_LIGHT: Base16 = Base16 { + slots: [ + Color::Rgb(0xfa, 0xfa, 0xfa), // base00 background + Color::Rgb(0xf0, 0xf0, 0xf1), // base01 + Color::Rgb(0xe5, 0xe5, 0xe6), // base02 + Color::Rgb(0xa0, 0xa1, 0xa7), // base03 comments + Color::Rgb(0x69, 0x6c, 0x77), // base04 + Color::Rgb(0x38, 0x3a, 0x42), // base05 foreground + Color::Rgb(0x20, 0x22, 0x27), // base06 + Color::Rgb(0x09, 0x0a, 0x0b), // base07 + Color::Rgb(0xca, 0x12, 0x43), // base08 red / diff deleted + Color::Rgb(0xd7, 0x5f, 0x00), // base09 orange + Color::Rgb(0xc1, 0x84, 0x01), // base0A yellow + Color::Rgb(0x50, 0xa1, 0x4f), // base0B green / diff inserted + Color::Rgb(0x01, 0x84, 0xbc), // base0C cyan + Color::Rgb(0x40, 0x78, 0xf2), // base0D blue + Color::Rgb(0xa6, 0x26, 0xa4), // base0E purple / keyword + Color::Rgb(0x98, 0x68, 0x01), // base0F brown + ], + }; + fn slot(&self, i: usize) -> Color { self.slots[i] } } +/// Blend `color` toward `base` by `ratio` (`0.0` = `color` unchanged, `1.0` = `base`) — linear +/// interpolation per RGB channel. This is the "convex blend toward base00" derivation ADR-035 +/// describes for a LIGHT base00: blending an accent toward a light background yields a pale, +/// correctly-hued wash (the dark scheme can't use this — see [`Palette::dark`]'s doc comment for +/// why dark tints are held explicit instead). Non-RGB colors pass through unblended. +fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { + match (color, base) { + (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { + let lerp = + |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * ratio).round() as u8 }; + Color::Rgb(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2)) + } + _ => color, + } +} + /// Per-capture syntax template: each entry is the base16 slot index that the parallel /// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions -/// (ADR-035). Theme-invariant — every scheme applies this same template to its own slots — so it -/// lives with the primitive, not on any one [`Theme`]. A theme switch re-colors by re-rendering: +/// (ADR-035). Palette-invariant — every scheme applies this same template to its own slots — so it +/// lives with the primitive, not on any one [`Palette`]. A theme switch re-colors by re-rendering: /// the tree-sitter pass records only the capture index (see [`crate::highlight::FgSpan`]), and the /// color is resolved here at paint time. const SYNTAX_SLOTS: [usize; 28] = [ @@ -99,11 +141,11 @@ pub fn syntax_slot_count() -> usize { /// The resolved on-tint palette a frame is painted with (ADR-035's theme-controlled half). /// -/// Syntax foreground is looked up per capture index via [`Theme::syntax`]; the diff-background +/// Syntax foreground is looked up per capture index via [`Palette::syntax`]; the diff-background /// gradient, its staged variants, and the cursor/selection/outline washes are read directly. All -/// values in [`Theme::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a +/// values in [`Palette::dark`] reproduce the M3–M5 hardcoded colors exactly (CS4 is a /// behavior-preserving refactor). -pub struct Theme { +pub struct Palette { /// Per-capture syntax fg, indexed by the same capture index as /// [`crate::highlight::HIGHLIGHT_NAMES`] (see [`SYNTAX_SLOTS`]). syntax: Vec, @@ -111,7 +153,7 @@ pub struct Theme { /// Whole-line subtle / word-level strong background for an unstaged (bright) Del cell. pub del_subtle: Color, pub del_strong: Color, - /// Bright Add-cell background pair (counterpart of [`Theme::del_subtle`]). + /// Bright Add-cell background pair (counterpart of [`Palette::del_subtle`]). pub add_subtle: Color, pub add_strong: Color, /// Dim/desaturated Del pair for staged-ness attribution (locked decision #7) — a staged change @@ -125,13 +167,13 @@ pub struct Theme { /// Tint blended into the cursor row's background — a cool slate-blue. pub cursor_bg: Color, /// Tint blended into a selected (line-selection) row — a muted teal, distinct from - /// [`Theme::cursor_bg`]. + /// [`Palette::cursor_bg`]. pub selection_bg: Color, - /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Theme::cursor_bg`]. + /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Palette::cursor_bg`]. pub outline_cursor_unfocused_bg: Color, } -impl Theme { +impl Palette { /// The curated dark scheme: base16-eighties.dark accents + the M3–M5 hand-tuned diff/cursor /// tints, reproduced byte-for-byte (the pixel-identity gate — see the module doc and ADR-035). /// @@ -142,7 +184,7 @@ impl Theme { /// shipped values verbatim. pub fn dark() -> Self { let base = Base16::EIGHTIES_DARK; - Theme { + Palette { syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), del_subtle: Color::Rgb(60, 24, 24), del_strong: Color::Rgb(120, 40, 40), @@ -158,6 +200,50 @@ impl Theme { } } + /// The curated light scheme: base16-one-light accents (see [`Base16::ONE_LIGHT`]) with the + /// diff/cursor tints DERIVED via [`tint_toward`], per ADR-035's corrected primitive section — + /// blending an accent toward a *light* base00 gives the correct pale wash (unlike dark, which + /// must hold its tints explicit; see [`Palette::dark`]'s doc comment). + /// + /// Ratios were hand-tuned against four requirements: subtle vs strong must read as visibly + /// distinct steps, add vs green must be distinguishable at a glance, staged must read dimmer + /// (more washed-out) than unstaged, and every wash must stay legible under the scheme's dark + /// base05 foreground and accent text. The del/add pair (base08/base0B → base00) derived + /// cleanly at those ratios; cursor/selection reuse the same mechanism against base0D/base0C + /// (blue/cyan) for a cool wash appropriate on a light background. + pub fn light() -> Self { + let base = Base16::ONE_LIGHT; + let base00 = base.slot(0); + let red = base.slot(8); // base08 + let green = base.slot(11); // base0B + let blue = base.slot(13); // base0D + let cyan = base.slot(12); // base0C + + // Unstaged: a light wash (subtle) and a more saturated wash (strong) a reader's eye can + // pick out at a glance; staged pushes further toward base00 (less saturated → dimmer). + const SUBTLE: f32 = 0.88; + const STRONG: f32 = 0.65; + const STAGED_SUBTLE: f32 = 0.94; + const STAGED_STRONG: f32 = 0.80; + const CURSOR: f32 = 0.82; + const OUTLINE_CURSOR_UNFOCUSED: f32 = 0.90; + + Palette { + syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), + del_subtle: tint_toward(red, base00, SUBTLE), + del_strong: tint_toward(red, base00, STRONG), + add_subtle: tint_toward(green, base00, SUBTLE), + add_strong: tint_toward(green, base00, STRONG), + del_staged_subtle: tint_toward(red, base00, STAGED_SUBTLE), + del_staged_strong: tint_toward(red, base00, STAGED_STRONG), + add_staged_subtle: tint_toward(green, base00, STAGED_SUBTLE), + add_staged_strong: tint_toward(green, base00, STAGED_STRONG), + cursor_bg: tint_toward(blue, base00, CURSOR), + selection_bg: tint_toward(cyan, base00, CURSOR), + outline_cursor_unfocused_bg: tint_toward(blue, base00, OUTLINE_CURSOR_UNFOCUSED), + } + } + /// The syntax foreground for a capture index (position in /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves @@ -166,6 +252,18 @@ impl Theme { pub fn syntax(&self, capture: usize) -> Color { self.syntax[capture] } + + /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-035/CS5). `Auto` + /// falls back to dark for now — CS6 adds the terminal-derivation probe that gives `Auto` its + /// real meaning. A config-read error is the caller's concern (see `main.rs`): this function + /// only handles a successfully-parsed selection. + pub fn for_theme(theme: crate::config::Theme) -> Self { + match theme { + crate::config::Theme::Light => Self::light(), + crate::config::Theme::Dark => Self::dark(), + crate::config::Theme::Auto => Self::dark(), // CS6: terminal-derive + } + } } #[cfg(test)] @@ -180,7 +278,7 @@ mod tests { #[test] fn dark_syntax_resolves_representative_captures_to_the_historical_colors() { - let theme = Theme::dark(); + let theme = Palette::dark(); let color = |name: &str| theme.syntax(capture_index(name).unwrap()); // The exact C_* consts highlight.rs shipped in M3 (base16-eighties.dark accents). assert_eq!(color("keyword"), Color::Rgb(0xcc, 0x99, 0xcc)); // C_PURPLE / base0E @@ -193,9 +291,9 @@ mod tests { #[test] fn dark_diff_tints_match_the_historical_constants() { - // The pixel-identity gate: `Theme::dark` must reproduce M3–M5's hand-tuned tints exactly. + // The pixel-identity gate: `Palette::dark` must reproduce M3–M5's hand-tuned tints exactly. // Pinned to the literals so a future refactor can't silently drift dark. - let t = Theme::dark(); + let t = Palette::dark(); assert_eq!(t.del_subtle, Color::Rgb(60, 24, 24)); assert_eq!(t.del_strong, Color::Rgb(120, 40, 40)); assert_eq!(t.add_subtle, Color::Rgb(20, 48, 24)); @@ -208,4 +306,105 @@ mod tests { assert_eq!(t.selection_bg, Color::Rgb(30, 66, 66)); assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + + fn rgb(color: Color) -> (u8, u8, u8) { + match color { + Color::Rgb(r, g, b) => (r, g, b), + other => panic!("expected an RGB color, got {other:?}"), + } + } + + fn luminance(color: Color) -> u32 { + let (r, g, b) = rgb(color); + r as u32 + g as u32 + b as u32 + } + + /// Euclidean-ish distance (sum of absolute channel deltas) from `base00` — used as a proxy for + /// "how washed-out toward the background is this tint," since [`tint_toward`] blends linearly. + fn distance_from_base00(color: Color) -> u32 { + let (r, g, b) = rgb(color); + let (r0, g0, b0) = rgb(Base16::ONE_LIGHT.slot(0)); + r.abs_diff(r0) as u32 + g.abs_diff(g0) as u32 + b.abs_diff(b0) as u32 + } + + #[test] + fn light_base00_is_high_luminance() { + // A light scheme's background must be near-white, unlike dark's near-black base00. + assert!(luminance(Base16::ONE_LIGHT.slot(0)) > luminance(Base16::EIGHTIES_DARK.slot(0))); + assert!(luminance(Base16::ONE_LIGHT.slot(0)) > 600); // out of a 765 (255*3) max + } + + #[test] + fn light_del_and_add_tints_are_distinct_from_each_other() { + let t = Palette::light(); + assert_ne!(t.del_subtle, t.add_subtle); + assert_ne!(t.del_strong, t.add_strong); + assert_ne!(t.del_staged_subtle, t.add_staged_subtle); + assert_ne!(t.del_staged_strong, t.add_staged_strong); + } + + #[test] + fn light_subtle_and_strong_are_visibly_distinct_steps() { + let t = Palette::light(); + assert_ne!(t.del_subtle, t.del_strong); + assert_ne!(t.add_subtle, t.add_strong); + // Strong sits further from base00 (more saturated / less washed-out) than subtle. + assert!(distance_from_base00(t.del_strong) > distance_from_base00(t.del_subtle)); + assert!(distance_from_base00(t.add_strong) > distance_from_base00(t.add_subtle)); + } + + #[test] + fn light_staged_reads_dimmer_than_unstaged() { + // "Dimmer" == more washed toward base00 == closer to base00 than the unstaged pair. + let t = Palette::light(); + assert!(distance_from_base00(t.del_staged_subtle) < distance_from_base00(t.del_subtle)); + assert!(distance_from_base00(t.del_staged_strong) < distance_from_base00(t.del_strong)); + assert!(distance_from_base00(t.add_staged_subtle) < distance_from_base00(t.add_subtle)); + assert!(distance_from_base00(t.add_staged_strong) < distance_from_base00(t.add_strong)); + } + + #[test] + fn light_cursor_and_selection_washes_are_distinct_and_outline_cursor_is_dimmer() { + let t = Palette::light(); + assert_ne!(t.cursor_bg, t.selection_bg); + // The unfocused outline cursor wash should read dimmer than the focused cursor wash. + assert!( + distance_from_base00(t.outline_cursor_unfocused_bg) < distance_from_base00(t.cursor_bg) + ); + } + + #[test] + fn light_syntax_resolves_representative_captures_to_the_one_light_accents() { + let theme = Palette::light(); + let color = |name: &str| theme.syntax(capture_index(name).unwrap()); + assert_eq!(color("keyword"), Color::Rgb(0xa6, 0x26, 0xa4)); // base0E purple + assert_eq!(color("string"), Color::Rgb(0x50, 0xa1, 0x4f)); // base0B green + assert_eq!(color("comment"), Color::Rgb(0xa0, 0xa1, 0xa7)); // base03 + assert_eq!(color("function"), Color::Rgb(0x40, 0x78, 0xf2)); // base0D blue + assert_eq!(color("number"), Color::Rgb(0xd7, 0x5f, 0x00)); // base09 orange + assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg + } + + #[test] + fn for_theme_selects_light_dark_and_falls_auto_back_to_dark() { + use crate::config::Theme; + + assert_eq!( + Palette::for_theme(Theme::Light).del_subtle, + Palette::light().del_subtle + ); + assert_ne!( + Palette::for_theme(Theme::Light).del_subtle, + Palette::dark().del_subtle + ); + assert_eq!( + Palette::for_theme(Theme::Dark).del_subtle, + Palette::dark().del_subtle + ); + // CS6: terminal-derive — Auto falls back to dark until the probe lands. + assert_eq!( + Palette::for_theme(Theme::Auto).del_subtle, + Palette::dark().del_subtle + ); + } } diff --git a/git-workon-review/src/tui.rs b/git-workon-review/src/tui.rs index 32105095..0390e552 100644 --- a/git-workon-review/src/tui.rs +++ b/git-workon-review/src/tui.rs @@ -24,7 +24,7 @@ use ratatui::Terminal; use workon_review::app::App; use workon_review::keymap::{Command, Dispatch, KeyPress, Keymap}; use workon_review::render; -use workon_review::theme::Theme; +use workon_review::theme::Palette; /// One event the review loop reacts to. `Tick` is now also the index-watcher's poll beat (see the /// module doc's note on locked decision #4) — `next_event`'s mapping and this enum otherwise stay @@ -302,7 +302,7 @@ fn install_panic_hook() { /// Run the review TUI's terminal lifecycle and main loop against `app`. Callers must have /// already loaded the initial file (`app.open_current()`) before calling this. -pub fn run(app: &mut App, keymap: &Keymap, theme: &Theme) -> io::Result<()> { +pub fn run(app: &mut App, keymap: &Keymap, theme: &Palette) -> io::Result<()> { install_panic_hook(); enable_raw_mode()?; let mut out = terminal_writer(); @@ -323,7 +323,7 @@ fn event_loop( terminal: &mut Terminal>, app: &mut App, keymap: &Keymap, - theme: &Theme, + theme: &Palette, ) -> io::Result<()> { let mut pending: Vec = Vec::new(); let mut quit = false; From 0fa1e7404306dc047698e1a3ba9d33fe15367e28 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 02:13:25 -0400 Subject: [PATCH 10/13] feat(review): derive theme from terminal for theme=auto --- Cargo.lock | 1 + docs/adr/035-review-theming-base16-hybrid.md | 16 + git-workon-review/Cargo.toml | 1 + git-workon-review/src/lib.rs | 1 + git-workon-review/src/main.rs | 18 +- git-workon-review/src/terminal_query.rs | 644 +++++++++++++++++++ git-workon-review/src/theme.rs | 124 +++- 7 files changed, 792 insertions(+), 13 deletions(-) create mode 100644 git-workon-review/src/terminal_query.rs diff --git a/Cargo.lock b/Cargo.lock index 5bd52a9f..f4e59d37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "git-workon-fixture", "git-workon-lib", "git2", + "libc", "miette", "predicates", "ratatui", diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index 35b0abd2..ef2e19b4 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -89,6 +89,22 @@ stays authored. the curated scheme chosen by background luminance if `OSC 11` answered, else `dark`. tmux/screen/ssh non-response is handled by the timeout, never a hang. +**CS6 refinement — the diff-bg tints stay curated, only the scheme is derived.** In +implementation, `auto` derives the base16 **scheme** (the 16 slots → syntax + monochrome ramp) +from the terminal, but the **diff/cursor/selection tints stay curated by luminance** rather than +derived from the probed accents (`Palette::from_terminal`: syntax = `SYNTAX_SLOTS` over the probed +`Base16`; tints = `Palette::dark()`'s or `Palette::light()`'s tint fields, chosen by the luminance +of the probed `base00`). Two reasons the earlier "derive tints from `base08`/`base0B`" plan was +narrowed: (1) dark-tint derivation is unsolved (see the corrected Primitive section — a convex +blend toward a dark `base00` can't reproduce the hand-tuned washes, and a probed *dark* terminal +hits exactly that), and (2) deriving washes from an arbitrary terminal's accent is unpredictable +across the range of real terminal palettes. The value of `auto` — **code colors matching the +terminal** — is fully delivered by the probed syntax slots, which curated tints don't compromise; +the diff washes were already hand-tuned per luminance, so borrowing them loses nothing. The six +ANSI-less slots are still synthesized as above; `parse` → `build_base16` → `from_terminal` → the +`palette_for_auto` fallback decision are all pure and unit-tested, with only the timed `/dev/tty` +read left untested (see `terminal_query.rs`). + ## Consequences - Light/dark ships as curated base16 schemes now; **terminal-derivation is first-class from diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index e1d25962..f8e44854 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -37,6 +37,7 @@ clap_complete.workspace = true crossterm.workspace = true git-workon-lib.workspace = true git2.workspace = true +libc.workspace = true miette.workspace = true ratatui.workspace = true similar.workspace = true diff --git a/git-workon-review/src/lib.rs b/git-workon-review/src/lib.rs index 34fa5e18..fb07145c 100644 --- a/git-workon-review/src/lib.rs +++ b/git-workon-review/src/lib.rs @@ -30,5 +30,6 @@ pub mod refresh; pub mod render; pub mod stage_op; pub mod synthesis; +pub mod terminal_query; pub mod theme; pub mod wordiff; diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 1ebe6d57..4d14b57e 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -6,8 +6,9 @@ use git2::Repository; use miette::{IntoDiagnostic, Result}; use workon_review::acquire::{diff_changeset, resolve_changesets}; use workon_review::app::{App, ChangesetView, Severity}; -use workon_review::config::ReviewConfig; +use workon_review::config::{self, ReviewConfig}; use workon_review::keymap::Keymap; +use workon_review::terminal_query; use workon_review::theme::Palette; /// A TUI for reviewing changesets @@ -56,12 +57,15 @@ fn main() -> Result<()> { }; // Resolve the palette selection the same way, before `repo` moves — a config-read error - // degrades to dark rather than aborting the review (CS5); `Palette::for_theme` handles the - // parsed-selection cases (including `Auto`'s CS6-deferred fallback to dark). - let theme = ReviewConfig::new(&repo) - .theme() - .map(Palette::for_theme) - .unwrap_or_else(|_| Palette::dark()); + // degrades to dark rather than aborting the review (CS5). `Auto` runs the terminal-derivation + // probe (CS6), which needs the controlling tty and so lives outside the pure `theme.rs`; it is + // bounded by a hard timeout and always yields a curated fallback on a silent/hostile terminal, + // never a hang. `Dark`/`Light` stay CS5's I/O-free `for_theme` path. + let theme = match ReviewConfig::new(&repo).theme() { + Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), + Ok(selection) => Palette::for_theme(selection), + Err(_) => Palette::dark(), + }; // Resolve the view-config settings (outline width/mode, diff layout/zoom) the same way, // before `repo` moves — CS7. `view_config` reads into an owned `RawViewConfig`, so no diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs new file mode 100644 index 00000000..4b7a639c --- /dev/null +++ b/git-workon-review/src/terminal_query.rs @@ -0,0 +1,644 @@ +//! The `theme = auto` terminal-derivation probe (ADR-035, CS6). +//! +//! `auto` derives the base16 *scheme* (syntax + monochrome ramp) from the terminal's own colors, +//! so code in the diff is highlighted in the same palette the user's terminal already uses. It +//! does this by querying the terminal over the controlling `/dev/tty` with OSC escape sequences +//! (`OSC 4;n;?` for the 16 ANSI colors, `OSC 11;?`/`OSC 10;?` for background/foreground), parsing +//! the RGB replies, and mapping ANSI-16 → the 16 base16 slots ([`crate::theme::Base16`]). The six +//! slots ANSI lacks are synthesized by interpolation (see [`build_base16`]). The diff/cursor +//! *tints* are NOT derived from the probe — [`crate::theme::Palette::from_terminal`] keeps them +//! curated by background luminance (the CS6 refinement of ADR-035). +//! +//! ## Robustness is the whole point +//! +//! The probe is the single most terminal-fragile component in the review TUI, so its blast radius +//! is contained to "return a curated theme instead": +//! - **Never hangs.** The tty is set **non-blocking** and the whole read ([`read_replies`]) is +//! bounded by a hard `Instant` deadline. `read()` therefore can never block on a terminal that +//! doesn't answer (tmux without passthrough, ssh, CI, a dumb terminal) — it returns `WouldBlock` +//! and the deadline is the backstop. (A blocking read guarded only by `poll(2)` is NOT safe: +//! `poll` on a tty is unreliable on macOS — it can report spurious readability — and +//! `cfmakeraw` sets `VMIN=1`, so a blocking `read` after a bad `poll` waits forever.) +//! - **Never corrupts the terminal.** The probe runs BEFORE `tui::run` installs its own raw mode / +//! alternate screen. It saves the tty's `termios`, sets raw for the duration of the read, and +//! **always restores** the saved `termios` before returning — leaving the tty exactly as it was +//! found. A trailing drain consumes any bytes the terminal still owed so none leak into +//! crossterm's later input reads. +//! - **Any failure → `None`.** Can't open `/dev/tty`, not a tty, a partial/malformed reply, a +//! missing color, or a timeout all collapse to an empty [`ProbeResult`], and the pure +//! [`palette_for_auto`] decision turns that into a curated fallback. +//! +//! Everything above the thin `#[cfg(unix)]` tty read — parsing, the ANSI→base16 build, the +//! fallback decision — is pure and unit-tested with injected bytes; the tests never touch a real +//! terminal. + +use std::time::Duration; + +use ratatui::style::Color; + +use crate::theme::{self, tint_toward, Base16, Palette}; + +/// The colors read back from a terminal OSC probe. `ansi16` is `Some` only if **all 16** ANSI +/// colors answered (a partial answer is treated as no answer — see the module doc); `background` +/// and `foreground` are the `OSC 11`/`OSC 10` replies, each independently optional. A fully-empty +/// result (every field `None`) is what a failed or timed-out probe produces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ProbeResult { + pub ansi16: Option<[Color; 16]>, + pub background: Option, + pub foreground: Option, +} + +/// Resolve the palette for `theme = auto` by probing the controlling terminal. Always returns a +/// usable [`Palette`] — a terminal-derived one when the probe succeeds, a curated fallback +/// otherwise. This is the entry point `main.rs` calls; the timeout is the non-negotiable backstop +/// against a silent terminal. +pub fn detect_auto_palette() -> Palette { + palette_for_auto(&probe_terminal(Duration::from_millis(120))) +} + +/// The pure decision that turns a [`ProbeResult`] into a [`Palette`] (unit-tested with injected +/// results — it performs no I/O): +/// - A complete probe (all 16 ANSI colors **and** a background) → a terminal-derived palette +/// ([`Palette::from_terminal`] over the [`build_base16`] scheme). +/// - Otherwise, if only the background answered → the curated scheme picked by its luminance. +/// - Otherwise (no background at all) → curated dark, the safe default. +pub fn palette_for_auto(probe: &ProbeResult) -> Palette { + match (probe.ansi16, probe.background) { + (Some(ansi), Some(bg)) => Palette::from_terminal(build_base16(&ansi, bg, probe.foreground)), + (_, Some(bg)) => { + if theme::is_light_background(bg) { + Palette::light() + } else { + Palette::dark() + } + } + (_, None) => Palette::dark(), + } +} + +/// Map a probed ANSI-16 palette + background (+ optional foreground) onto the 16 base16 slots, +/// synthesizing the six slots ANSI has no equivalent for (ADR-035). The diff-critical slots +/// (`base00`/`base08`/`base0B`) are always real; the synthesized slots are secondary accents and +/// ramp intermediates: +/// - **Ramp** (`base01`/`base02`): interpolated `base00 → base03`. +/// - **Ramp** (`base04`/`base06`): interpolated across `base03 → base05 → base07`. +/// - **`base09` (orange):** blend of `base08` (red) toward `base0A` (yellow). +/// - **`base0F` (brown):** blend of `base09` toward `base08`. +/// +/// ANSI role mapping (the standard base16 ↔ ANSI correspondence): `base00`=bg, `base03`=ANSI 8 +/// (bright black), `base05`=fg or ANSI 7 (white), `base07`=ANSI 15 (bright white), `base08`=ANSI 1 +/// (red), `base0A`=ANSI 3 (yellow), `base0B`=ANSI 2 (green), `base0C`=ANSI 6 (cyan), `base0D`=ANSI +/// 4 (blue), `base0E`=ANSI 5 (magenta). +pub fn build_base16(ansi: &[Color; 16], background: Color, foreground: Option) -> Base16 { + let base00 = background; + let base03 = ansi[8]; // bright black + let base05 = foreground.unwrap_or(ansi[7]); // fg, else white + let base07 = ansi[15]; // bright white + let base08 = ansi[1]; // red + let base0a = ansi[3]; // yellow + let base0b = ansi[2]; // green + let base0c = ansi[6]; // cyan + let base0d = ansi[4]; // blue + let base0e = ansi[5]; // magenta + + // Synthesized: ramp intermediates + the two accents ANSI has no slot for. + let base01 = tint_toward(base00, base03, 1.0 / 3.0); + let base02 = tint_toward(base00, base03, 2.0 / 3.0); + let base04 = tint_toward(base03, base05, 0.5); + let base06 = tint_toward(base05, base07, 0.5); + let base09 = tint_toward(base08, base0a, 0.5); // orange between red and yellow + let base0f = tint_toward(base09, base08, 0.5); // brown + + Base16 { + slots: [ + base00, base01, base02, base03, base04, base05, base06, base07, base08, base09, base0a, + base0b, base0c, base0d, base0e, base0f, + ], + } +} + +/// Run the tty probe, collapsing any failure to an empty [`ProbeResult`]. On non-unix platforms +/// (no `/dev/tty` / `termios`) it always reports empty, so `auto` degrades to curated dark there. +fn probe_terminal(timeout: Duration) -> ProbeResult { + #[cfg(unix)] + { + match query_terminal_raw(&build_query(), timeout) { + Some(bytes) => parse_osc_replies(&bytes), + None => ProbeResult::default(), + } + } + #[cfg(not(unix))] + { + let _ = timeout; + ProbeResult::default() + } +} + +/// The bytes we write to the terminal: `OSC 4;n;?` for each of the 16 ANSI colors, then +/// `OSC 11;?` (background) and `OSC 10;?` (foreground), then a primary Device Attributes query +/// (`ESC [ c`). Terminals answer in order, so the DA1 reply is a sentinel: once we see it, every +/// OSC reply that is going to arrive already has (see [`has_da1_terminator`]). Each OSC query is +/// String-Terminated with `ESC \` (ST). +fn build_query() -> Vec { + let mut q = Vec::new(); + for n in 0..16 { + q.extend_from_slice(format!("\x1b]4;{n};?\x1b\\").as_bytes()); + } + q.extend_from_slice(b"\x1b]11;?\x1b\\"); + q.extend_from_slice(b"\x1b]10;?\x1b\\"); + q.extend_from_slice(b"\x1b[c"); // DA1 sentinel + q +} + +/// Parse one OSC color-spec body of the form `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per channel, +/// per the XParseColor grammar terminals reply with) into an 8-bit [`Color::Rgb`]. Each channel is +/// scaled from its `0..=(16^digits - 1)` range to `0..=255`, so a 4-digit `cccc` yields `0xcc` +/// (the "take the high byte" the ADR describes) and a 2-digit `cc` yields `0xcc` too. Returns +/// `None` on any malformation (wrong prefix, missing channel, non-hex, empty/over-long channel). +fn parse_rgb_spec(spec: &str) -> Option { + let rest = spec.strip_prefix("rgb:")?; + let mut channels = rest.split('/'); + let r = parse_channel(channels.next()?)?; + let g = parse_channel(channels.next()?)?; + let b = parse_channel(channels.next()?)?; + if channels.next().is_some() { + return None; // more than three channels → malformed + } + Some(Color::Rgb(r, g, b)) +} + +/// Parse and 8-bit-scale one hex channel (`1..=4` digits). `None` on empty, over-long, or non-hex. +fn parse_channel(s: &str) -> Option { + if s.is_empty() || s.len() > 4 { + return None; + } + let value = u32::from_str_radix(s, 16).ok()?; + let max = (1u32 << (4 * s.len())) - 1; // 16^digits - 1 + // Round-to-nearest scale into 0..=255. + Some(((value * 255 + max / 2) / max) as u8) +} + +/// Slice out the payloads of every complete OSC sequence in `bytes`: the run between an `ESC ]` +/// introducer and its terminator (`BEL`, or `ESC \` ST). An unterminated trailing OSC is dropped. +fn osc_payloads(bytes: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut i = 0; + while i + 1 < bytes.len() { + if bytes[i] == 0x1b && bytes[i + 1] == b']' { + let start = i + 2; + let mut j = start; + let mut terminated = None; + while j < bytes.len() { + if bytes[j] == 0x07 { + terminated = Some((j, j + 1)); // BEL + break; + } + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + terminated = Some((j, j + 2)); // ESC \ (ST) + break; + } + j += 1; + } + match terminated { + Some((content_end, next)) => { + out.push(&bytes[start..content_end]); + i = next; + } + None => break, // incomplete trailing OSC + } + } else { + i += 1; + } + } + out +} + +/// Parse a raw terminal reply buffer into a [`ProbeResult`]: pick out every `OSC 4;n;`, +/// `OSC 11;`, and `OSC 10;` reply and decode its color. `ansi16` is `Some` only when +/// all 16 indices decoded; a duplicate or out-of-range index is ignored, and the DA1 reply (and +/// any other noise) is skipped since it carries no OSC color prefix. +fn parse_osc_replies(bytes: &[u8]) -> ProbeResult { + let mut ansi: [Option; 16] = [None; 16]; + let mut background = None; + let mut foreground = None; + + for payload in osc_payloads(bytes) { + let Ok(s) = std::str::from_utf8(payload) else { + continue; + }; + if let Some(rest) = s.strip_prefix("4;") { + if let Some((index, spec)) = rest.split_once(';') { + if let (Ok(idx), Some(color)) = (index.parse::(), parse_rgb_spec(spec)) { + if idx < 16 { + ansi[idx] = Some(color); + } + } + } + } else if let Some(spec) = s.strip_prefix("11;") { + background = parse_rgb_spec(spec); + } else if let Some(spec) = s.strip_prefix("10;") { + foreground = parse_rgb_spec(spec); + } + } + + let ansi16 = if ansi.iter().all(Option::is_some) { + Some(std::array::from_fn(|i| ansi[i].expect("all-some checked"))) + } else { + None + }; + ProbeResult { + ansi16, + background, + foreground, + } +} + +/// Whether the buffer contains a complete DA1 reply (`ESC [ ? … c`) — our "the terminal is done +/// answering" sentinel. A `c` following an `ESC [ ?` control sequence introducer. +fn has_da1_terminator(bytes: &[u8]) -> bool { + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == 0x1b && bytes[i + 1] == b'[' && bytes[i + 2] == b'?' { + // Scan for the final `c` of this CSI sequence. + let mut j = i + 3; + while j < bytes.len() { + if bytes[j] == b'c' { + return true; + } + // A different final byte (letter) ends the CSI without being DA1; keep scanning + // the buffer for another `ESC [ ?`. + if bytes[j].is_ascii_alphabetic() { + break; + } + j += 1; + } + } + i += 1; + } + false +} + +/// Open the controlling `/dev/tty`, put it in raw mode, write `query`, and read the reply with a +/// hard total `timeout` — restoring the saved `termios` before returning on **every** path. This +/// is the one function the unit tests do NOT call (it needs a real tty); everything it feeds +/// ([`parse_osc_replies`], [`build_base16`], [`palette_for_auto`]) is pure and tested directly. +/// +/// Returns the raw reply bytes, or `None` if `/dev/tty` can't be opened, isn't a tty, or the read +/// yields nothing before the timeout. `None` and an empty read both degrade to the curated +/// fallback upstream. +#[cfg(unix)] +fn query_terminal_raw(query: &[u8], timeout: Duration) -> Option> { + use std::os::unix::io::AsRawFd; + + let mut tty = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = tty.as_raw_fd(); + + // Save the current termios; bail (leaving the tty untouched) if this isn't a tty. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut saved) } != 0 { + return None; + } + + // Switch to raw so the OSC replies (terminated by ST/BEL, not newline) arrive uncooked and + // unechoed. Override cfmakeraw's `VMIN=1` with `VMIN=0, VTIME=1` (0.1s): a defensive backstop + // so that even if the `O_NONBLOCK` fcntl in `read_replies` were to fail, a blocking `read` + // still returns (empty) after 0.1s rather than hanging on a silent terminal. + let mut raw = saved; + unsafe { libc::cfmakeraw(&mut raw) }; + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 1; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; // termios unchanged — nothing to restore + } + + let outcome = read_replies(&mut tty, fd, query, timeout); + + // Discard anything still in the terminal's input queue before handing the tty back — a + // terminal that answered our OSC queries may have more reply bytes buffered than `read_replies` + // consumed (or that arrived just after it stopped at the DA1 sentinel). Left there, they leak + // into crossterm's input once the TUI starts and get parsed as a burst of spurious key events + // (the "unresponsive + refresh churn on launch" seen with `theme = auto`). The probe runs + // before any real keypress, so flushing pending input is safe. + unsafe { libc::tcflush(fd, libc::TCIFLUSH) }; + + // ALWAYS restore, on success or failure. + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &saved) }; + outcome +} + +/// The read half of [`query_terminal_raw`], factored out so `termios` restoration wraps it on +/// every exit. Writes `query`, then polls for replies until the DA1 sentinel arrives or the total +/// `timeout` elapses, then drains any straggler bytes so nothing leaks into later input reads. +#[cfg(unix)] +fn read_replies( + tty: &mut std::fs::File, + fd: std::os::unix::io::RawFd, + query: &[u8], + timeout: Duration, +) -> Option> { + use std::io::{Read, Write}; + use std::time::Instant; + + if tty.write_all(query).is_err() || tty.flush().is_err() { + return None; + } + + // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock`, never a + // blocked `read`. The `Instant` deadline (not `poll`) is the sole timing authority. + set_nonblocking(fd); + + let deadline = Instant::now() + timeout; + let mut buf = Vec::with_capacity(512); + let mut chunk = [0u8; 256]; + + while Instant::now() < deadline { + match tty.read(&mut chunk) { + Ok(0) => break, // EOF + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if has_da1_terminator(&buf) { + break; + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No data yet — yield briefly and let the deadline bound the wait. + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + + // Drain anything immediately available (e.g. a terminal that answered without a DA1) so it + // doesn't surface as spurious input once the TUI takes over the tty. Non-blocking, so this + // stops at the first `WouldBlock`. + loop { + match tty.read(&mut chunk) { + Ok(n) if n > 0 => buf.extend_from_slice(&chunk[..n]), + _ => break, + } + } + + if buf.is_empty() { + None + } else { + Some(buf) + } +} + +/// Set `O_NONBLOCK` on the fd so `read` returns `WouldBlock` instead of blocking when the terminal +/// has nothing (more) to say. Best-effort: a failed `fcntl` leaves the fd blocking, but the caller +/// only reaches here after a successful `tcgetattr`, and the deadline loop still bounds the wait in +/// the common case. The fd is closed when the `File` drops, so `O_NONBLOCK` needs no restoration. +#[cfg(unix)] +fn set_nonblocking(fd: std::os::unix::io::RawFd) { + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── OSC color-spec parsing ─────────────────────────────────────────────── + + #[test] + fn parse_rgb_spec_takes_the_high_byte_of_a_16_bit_channel() { + // The plan's worked example: cccc → cc, 9999 → 99. + assert_eq!( + parse_rgb_spec("rgb:cccc/9999/cccc"), + Some(Color::Rgb(0xcc, 0x99, 0xcc)) + ); + } + + #[test] + fn parse_rgb_spec_accepts_short_channels_and_scales_them() { + // 8-bit channels pass through unchanged. + assert_eq!( + parse_rgb_spec("rgb:ff/80/00"), + Some(Color::Rgb(0xff, 0x80, 0x00)) + ); + // A single hex digit f == 15/15 of full scale == 255. + assert_eq!(parse_rgb_spec("rgb:f/0/f"), Some(Color::Rgb(255, 0, 255))); + } + + #[test] + fn parse_rgb_spec_rejects_malformed_specs() { + assert_eq!(parse_rgb_spec("rgb:cccc/9999"), None); // too few channels + assert_eq!(parse_rgb_spec("rgb:cc/dd/ee/ff"), None); // too many channels + assert_eq!(parse_rgb_spec("cmyk:1/2/3"), None); // wrong prefix + assert_eq!(parse_rgb_spec("rgb:zz/00/00"), None); // non-hex + assert_eq!(parse_rgb_spec("rgb:/00/00"), None); // empty channel + assert_eq!(parse_rgb_spec("rgb:11111/00/00"), None); // over-long channel + } + + // ── Whole-reply parsing → ProbeResult ──────────────────────────────────── + + /// A complete, well-formed reply: 16 `OSC 4` colors + `OSC 11` bg + `OSC 10` fg + a DA1 tail. + fn full_reply() -> Vec { + let mut r = Vec::new(); + for n in 0..16u8 { + // A recognizable per-index color: R = n*16, so index 5 → 0x50…. + let hh = format!("{:02x}", n * 16); + r.extend_from_slice(format!("\x1b]4;{n};rgb:{hh}{hh}/2020/4040\x1b\\").as_bytes()); + } + r.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); // dark bg + r.extend_from_slice(b"\x1b]10;rgb:d3d3/d0d0/c8c8\x1b\\"); // fg + r.extend_from_slice(b"\x1b[?62;c"); // DA1 + r + } + + #[test] + fn parse_osc_replies_decodes_a_complete_reply() { + let result = parse_osc_replies(&full_reply()); + let ansi = result.ansi16.expect("all 16 colors present"); + assert_eq!(ansi[0], Color::Rgb(0x00, 0x20, 0x40)); + assert_eq!(ansi[5], Color::Rgb(0x50, 0x20, 0x40)); + assert_eq!(ansi[15], Color::Rgb(0xf0, 0x20, 0x40)); + assert_eq!(result.background, Some(Color::Rgb(0x1a, 0x1a, 0x1a))); + assert_eq!(result.foreground, Some(Color::Rgb(0xd3, 0xd0, 0xc8))); + } + + #[test] + fn parse_osc_replies_treats_a_missing_ansi_color_as_no_ansi() { + // Drop index 7's reply: the remaining 15 must NOT yield a partial ansi16. + let mut r = Vec::new(); + for n in 0..16u8 { + if n == 7 { + continue; + } + r.extend_from_slice(format!("\x1b]4;{n};rgb:1010/2020/3030\x1b\\").as_bytes()); + } + r.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); + let result = parse_osc_replies(&r); + assert_eq!(result.ansi16, None, "an incomplete ANSI set is no set"); + // ...but the background still parsed, so the fallback can use its luminance. + assert_eq!(result.background, Some(Color::Rgb(0x1a, 0x1a, 0x1a))); + } + + #[test] + fn parse_osc_replies_of_garbage_is_empty() { + assert_eq!(parse_osc_replies(b""), ProbeResult::default()); + assert_eq!( + parse_osc_replies(b"not an escape sequence"), + ProbeResult::default() + ); + // An unterminated OSC is dropped rather than mis-parsed. + assert_eq!( + parse_osc_replies(b"\x1b]11;rgb:1a1a/1a1a/1a1a"), + ProbeResult::default() + ); + } + + #[test] + fn osc_payloads_handles_both_bel_and_st_terminators() { + let bytes = b"\x1b]11;rgb:aa/bb/cc\x07\x1b]10;rgb:11/22/33\x1b\\"; + let payloads = osc_payloads(bytes); + assert_eq!(payloads.len(), 2); + assert_eq!(payloads[0], b"11;rgb:aa/bb/cc"); + assert_eq!(payloads[1], b"10;rgb:11/22/33"); + } + + #[test] + fn da1_terminator_detected_only_when_complete() { + assert!(has_da1_terminator(b"\x1b[?62;1;c")); + assert!(has_da1_terminator(b"prefix\x1b[?6c trailing")); + assert!(!has_da1_terminator(b"\x1b[?62;1")); // no final c yet + assert!(!has_da1_terminator(b"\x1b[c")); // DA1 request, not a DA1 reply (no ?) + assert!(!has_da1_terminator(b"")); + } + + // ── ANSI-16 → Base16 build + synthesis ─────────────────────────────────── + + /// A recognizable ANSI-16 array: each color's red channel is its index * 16. + fn sample_ansi() -> [Color; 16] { + std::array::from_fn(|i| Color::Rgb((i as u8) * 16, 0x40, 0x80)) + } + + #[test] + fn build_base16_maps_ansi_roles_onto_the_right_slots() { + let ansi = sample_ansi(); + let bg = Color::Rgb(0x20, 0x20, 0x20); + let base = build_base16(&ansi, bg, Some(Color::Rgb(0xd0, 0xd0, 0xd0))); + + assert_eq!(base.slots[0], bg); // base00 = background + assert_eq!(base.slots[3], ansi[8]); // base03 = bright black + assert_eq!(base.slots[5], Color::Rgb(0xd0, 0xd0, 0xd0)); // base05 = foreground + assert_eq!(base.slots[7], ansi[15]); // base07 = bright white + assert_eq!(base.slots[8], ansi[1]); // base08 = red + assert_eq!(base.slots[10], ansi[3]); // base0A = yellow + assert_eq!(base.slots[11], ansi[2]); // base0B = green + assert_eq!(base.slots[12], ansi[6]); // base0C = cyan + assert_eq!(base.slots[13], ansi[4]); // base0D = blue + assert_eq!(base.slots[14], ansi[5]); // base0E = magenta + } + + #[test] + fn build_base16_falls_back_to_ansi7_when_no_foreground_probed() { + let ansi = sample_ansi(); + let base = build_base16(&ansi, Color::Rgb(0x20, 0x20, 0x20), None); + assert_eq!(base.slots[5], ansi[7]); // base05 = white when OSC 10 didn't answer + } + + fn channels(color: Color) -> (i32, i32, i32) { + match color { + Color::Rgb(r, g, b) => (r as i32, g as i32, b as i32), + other => panic!("expected RGB, got {other:?}"), + } + } + + #[test] + fn build_base16_synthesizes_a_monotonic_dark_ramp() { + // base00 (dark) → base01 → base02 → base03 must climb in luminance. + let ansi = sample_ansi(); + let base = build_base16(&ansi, Color::Rgb(0x10, 0x10, 0x10), None); + let lum = |i: usize| { + let (r, g, b) = channels(base.slots[i]); + r + g + b + }; + assert!(lum(0) <= lum(1), "base00 ≤ base01"); + assert!(lum(1) <= lum(2), "base01 ≤ base02"); + assert!(lum(2) <= lum(3), "base02 ≤ base03"); + } + + #[test] + fn build_base16_synthesizes_orange_between_red_and_yellow() { + // base09 (orange) is the midpoint of base08 (red) and base0A (yellow), per channel. + let red = Color::Rgb(0xf0, 0x00, 0x00); + let yellow = Color::Rgb(0xf0, 0xf0, 0x00); + let mut ansi = sample_ansi(); + ansi[1] = red; // base08 + ansi[3] = yellow; // base0A + let base = build_base16(&ansi, Color::Rgb(0x20, 0x20, 0x20), None); + let (r, g, b) = channels(base.slots[9]); // base09 + let (rr, rg, _) = channels(red); + let (_, yg, _) = channels(yellow); + assert_eq!(r, rr, "orange keeps the shared red channel"); + assert!(g > rg && g < yg, "orange green sits between red and yellow"); + assert_eq!(b, 0, "orange blue stays at zero"); + } + + // ── palette_for_auto decision ──────────────────────────────────────────── + + #[test] + fn palette_for_auto_builds_a_terminal_palette_from_a_complete_probe() { + let probe = ProbeResult { + ansi16: Some(sample_ansi()), + background: Some(Color::Rgb(0x1a, 0x1a, 0x1a)), // dark + foreground: Some(Color::Rgb(0xd0, 0xd0, 0xd0)), + }; + let palette = palette_for_auto(&probe); + // Syntax comes from the PROBED scheme (keyword → base0E = ANSI magenta = ansi[5]). + let expected = build_base16( + &sample_ansi(), + Color::Rgb(0x1a, 0x1a, 0x1a), + Some(Color::Rgb(0xd0, 0xd0, 0xd0)), + ); + let keyword = crate::highlight::capture_index("keyword").unwrap(); + assert_eq!(palette.syntax(keyword), expected.slots[14]); + // A dark probed bg borrows dark's curated tints. + assert_eq!(palette.del_subtle, Palette::dark().del_subtle); + } + + #[test] + fn palette_for_auto_falls_back_to_curated_by_bg_luminance_when_ansi_is_missing() { + // No ANSI colors, but the background answered light → curated light. + let light_bg = ProbeResult { + ansi16: None, + background: Some(Color::Rgb(0xf5, 0xf5, 0xf5)), + foreground: None, + }; + assert_eq!( + palette_for_auto(&light_bg).del_subtle, + Palette::light().del_subtle + ); + + // Background answered dark → curated dark. + let dark_bg = ProbeResult { + ansi16: None, + background: Some(Color::Rgb(0x1a, 0x1a, 0x1a)), + foreground: None, + }; + assert_eq!( + palette_for_auto(&dark_bg).del_subtle, + Palette::dark().del_subtle + ); + } + + #[test] + fn palette_for_auto_falls_back_to_dark_when_nothing_answered() { + // The total-failure / timeout path: an empty result → curated dark, never a hang. + assert_eq!( + palette_for_auto(&ProbeResult::default()).del_subtle, + Palette::dark().del_subtle + ); + } +} diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 2a5703c3..237f33a3 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -84,7 +84,7 @@ impl Base16 { /// describes for a LIGHT base00: blending an accent toward a light background yields a pale, /// correctly-hued wash (the dark scheme can't use this — see [`Palette::dark`]'s doc comment for /// why dark tints are held explicit instead). Non-RGB colors pass through unblended. -fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { +pub(crate) fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { match (color, base) { (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { let lerp = @@ -95,6 +95,19 @@ fn tint_toward(color: Color, base: Color, ratio: f32) -> Color { } } +/// Whether a background color reads as "light" — a sum-of-channels luminance proxy (matching the +/// reasoning in this module's tests) with the midpoint of the `0..=765` range as the threshold. +/// Used to pick which curated scheme's diff/cursor tints a probed or fallback theme borrows +/// (CS6): a probed dark background reuses [`Palette::dark`]'s hand-tuned tints, a light one reuses +/// [`Palette::light`]'s derived washes. A non-RGB color (never produced by the OSC probe) reads as +/// dark. +pub(crate) fn is_light_background(color: Color) -> bool { + match color { + Color::Rgb(r, g, b) => r as u32 + g as u32 + b as u32 > 382, + _ => false, + } +} + /// Per-capture syntax template: each entry is the base16 slot index that the parallel /// [`crate::highlight::HIGHLIGHT_NAMES`] capture maps to, per the base16 role conventions /// (ADR-035). Palette-invariant — every scheme applies this same template to its own slots — so it @@ -244,6 +257,37 @@ impl Palette { } } + /// A scheme derived from the terminal's own colors (ADR-035's `auto`, CS6). The 16 base16 + /// slots come from the probed [`Base16`] (built from the terminal's ANSI palette + background; + /// see [`crate::terminal_query`]), so **syntax matches the terminal**. The diff/cursor tints, + /// however, stay **curated by background luminance** rather than derived from the probed + /// accents — the CS6 refinement of ADR-035: dark-tint derivation is unsolved (see + /// [`Palette::dark`]) and deriving washes from an arbitrary terminal's accent is + /// unpredictable, whereas the value of terminal-derivation — code colors matching the + /// terminal — is fully delivered by the probed syntax slots. A probed dark background borrows + /// [`Palette::dark`]'s tints, a light one [`Palette::light`]'s. + pub fn from_terminal(base: Base16) -> Self { + let curated = if is_light_background(base.slot(0)) { + Palette::light() + } else { + Palette::dark() + }; + Palette { + syntax: SYNTAX_SLOTS.iter().map(|&s| base.slot(s)).collect(), + del_subtle: curated.del_subtle, + del_strong: curated.del_strong, + add_subtle: curated.add_subtle, + add_strong: curated.add_strong, + del_staged_subtle: curated.del_staged_subtle, + del_staged_strong: curated.del_staged_strong, + add_staged_subtle: curated.add_staged_subtle, + add_staged_strong: curated.add_staged_strong, + cursor_bg: curated.cursor_bg, + selection_bg: curated.selection_bg, + outline_cursor_unfocused_bg: curated.outline_cursor_unfocused_bg, + } + } + /// The syntax foreground for a capture index (position in /// [`crate::highlight::HIGHLIGHT_NAMES`]). This is the render-time resolution the whole /// mechanism turns on: [`crate::highlight::FgSpan`] carries the index, the renderer resolves @@ -253,15 +297,17 @@ impl Palette { self.syntax[capture] } - /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-035/CS5). `Auto` - /// falls back to dark for now — CS6 adds the terminal-derivation probe that gives `Auto` its - /// real meaning. A config-read error is the caller's concern (see `main.rs`): this function - /// only handles a successfully-parsed selection. + /// Resolve the on-tint palette for a `workon.review.theme` selection (ADR-035/CS5) — the + /// **I/O-free** cases. `Light`/`Dark` return their curated schemes. `Auto` is the terminal + /// probe's job ([`crate::terminal_query::detect_auto_palette`], CS6), which needs tty access + /// this pure function can't have; `main.rs` routes `Auto` there and only falls through to this + /// function's dark result if it declines to probe. A config-read error is likewise the + /// caller's concern (see `main.rs`): this handles only a successfully-parsed selection. pub fn for_theme(theme: crate::config::Theme) -> Self { match theme { crate::config::Theme::Light => Self::light(), crate::config::Theme::Dark => Self::dark(), - crate::config::Theme::Auto => Self::dark(), // CS6: terminal-derive + crate::config::Theme::Auto => Self::dark(), // probe lives in main.rs/terminal_query } } } @@ -385,6 +431,72 @@ mod tests { assert_eq!(color("variable"), Color::Rgb(0x38, 0x3a, 0x42)); // base05 fg } + /// A synthetic probed scheme with a distinct value in every slot and the given `base00`, so a + /// test can assert `from_terminal`'s syntax slots came from the probed scheme (not a curated + /// one) and read the base00 luminance branch. + fn probed_base16(base00: Color) -> Base16 { + let mut slots = [Color::Rgb(0, 0, 0); 16]; + for (i, slot) in slots.iter_mut().enumerate() { + // A unique, recognizable color per slot: R channel = slot index * 16. + *slot = Color::Rgb((i as u8) * 16, 0x20, 0x40); + } + slots[0] = base00; + Base16 { slots } + } + + #[test] + fn from_terminal_takes_syntax_from_the_probed_scheme() { + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); // dark bg + let palette = Palette::from_terminal(probed); + // keyword → base0E (slot 14): the probed scheme's slot, NOT a curated palette's. + assert_eq!( + palette.syntax(capture_index("keyword").unwrap()), + probed.slot(14) + ); + assert_eq!( + palette.syntax(capture_index("string").unwrap()), + probed.slot(11) // base0B + ); + assert_ne!( + palette.syntax(capture_index("keyword").unwrap()), + Palette::dark().syntax(capture_index("keyword").unwrap()) + ); + } + + #[test] + fn from_terminal_with_a_dark_background_borrows_darks_curated_tints() { + let palette = Palette::from_terminal(probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a))); + let dark = Palette::dark(); + assert_eq!(palette.del_subtle, dark.del_subtle); + assert_eq!(palette.add_strong, dark.add_strong); + assert_eq!(palette.cursor_bg, dark.cursor_bg); + assert_eq!(palette.selection_bg, dark.selection_bg); + assert_eq!( + palette.outline_cursor_unfocused_bg, + dark.outline_cursor_unfocused_bg + ); + } + + #[test] + fn from_terminal_with_a_light_background_borrows_lights_curated_tints() { + let palette = Palette::from_terminal(probed_base16(Color::Rgb(0xf5, 0xf5, 0xf5))); + let light = Palette::light(); + assert_eq!(palette.del_subtle, light.del_subtle); + assert_eq!(palette.add_strong, light.add_strong); + assert_eq!(palette.cursor_bg, light.cursor_bg); + assert_eq!(palette.selection_bg, light.selection_bg); + // ...and NOT dark's, confirming the luminance branch flipped. + assert_ne!(palette.del_subtle, Palette::dark().del_subtle); + } + + #[test] + fn is_light_background_splits_on_the_luminance_midpoint() { + assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); + assert!(!is_light_background(Base16::EIGHTIES_DARK.slot(0))); + // A non-RGB color (never produced by the probe) reads as dark. + assert!(!is_light_background(Color::Gray)); + } + #[test] fn for_theme_selects_light_dark_and_falls_auto_back_to_dark() { use crate::config::Theme; From 69320e07a1557154ae16a75e06064a75af10e4f6 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 09:15:18 -0400 Subject: [PATCH 11/13] fix(review): paint themed canvas so light/dark control bg and fg --- docs/adr/035-review-theming-base16-hybrid.md | 19 +- git-workon-review/src/render.rs | 233 +++++++++++++++---- git-workon-review/src/theme.rs | 93 +++++++- 3 files changed, 294 insertions(+), 51 deletions(-) diff --git a/docs/adr/035-review-theming-base16-hybrid.md b/docs/adr/035-review-theming-base16-hybrid.md index ef2e19b4..4a93a1f9 100644 --- a/docs/adr/035-review-theming-base16-hybrid.md +++ b/docs/adr/035-review-theming-base16-hybrid.md @@ -29,10 +29,21 @@ is spec-conformant. - **On a tint → base16 truecolor (theme-controlled):** diff add/del subtle/strong + staged variants, cursor, selection, and **syntax**. Contrast is guaranteed because foreground and background come from the *same* scheme. -- **Chrome, not on a tint → ANSI-named (`Color::Gray`/`DarkGray`/…):** gutter, borders, - footer, dim labels, status. These inherit the terminal palette, self-adapt light/dark, and - are **probe-independent** (work even when terminal-derivation fails). Half already are - ANSI-named today. +- **Chrome (default text, dim labels, gutter/dividers) + the canvas background → + base16-ramp-controlled (revised post-CS6):** originally these were ANSI-named + (`Color::Gray`/`DarkGray`) and the canvas was never painted, on the theory that inheriting + the terminal's own bg/fg would self-adapt for free. In practice this broke explicit + `light`/`dark` selections outright — the terminal's own (often dark) bg/fg bled straight + through a "light" theme, since nothing ever painted over it. Fixed: `Palette::background` + (base00)/`foreground` (base05)/`dim` (base03)/`gutter` (base04) are now real palette + fields, and `render()` paints the whole frame with `background` first when + `Palette::paint_canvas` is set. `dark()`/`light()` set `paint_canvas: true` — a curated + theme now fully controls the look, canvas included. `from_terminal` (`auto`) still derives + these four straight from the probed terminal colors — so it matches the terminal exactly, + as before — but sets `paint_canvas: false`, since `auto`'s base00 *is* the terminal's own + background; painting over it would flatten terminal transparency/background images for no + gain. The probe-failure fallback (`dark()`/`light()`) paints normally. Chrome that is + never a theme knob (error/warn/current-marker) stays ANSI/const in `render.rs`, unchanged. **Primitive — the theme is a base16 scheme.** A `Palette` holds the 16 slots (base00–07 mono ramp + base08–0F accents). Syntax uses the accents via the existing diff --git a/git-workon-review/src/render.rs b/git-workon-review/src/render.rs index 5e4e6ee1..c909b51c 100644 --- a/git-workon-review/src/render.rs +++ b/git-workon-review/src/render.rs @@ -24,19 +24,15 @@ use crate::theme::Palette; use crate::wordiff::Span as WordSpan; // The on-tint colors (diff add/del gradient + staged variants, cursor/selection washes, and syntax -// foreground) now come from a [`Palette`] threaded through render (ADR-035). The chrome colors below -// stay ANSI-named / const here: they never sit on a tint, so they inherit the terminal palette and -// self-adapt light/dark, independent of the theme (the hybrid boundary — see the `theme` module). - -/// Default foreground for diff text that carries no syntax highlight — an ANSI gray that inherits -/// the terminal palette (chrome, not on-tint). Syntax-highlighted text resolves its fg from the -/// [`Palette`] instead (see [`compose_segments`]). -const FG_DEFAULT: Color = Color::Gray; -const FG_DIM: Color = Color::DarkGray; +// foreground) come from a [`Palette`] threaded through render (ADR-035). The canvas background and +// default/dim/gutter chrome foreground ALSO now come from the palette (`theme.background`/ +// `theme.foreground`/`theme.dim`/`theme.gutter`) — see the theme module's revised hybrid-boundary +// doc comment — so a curated theme fully controls the look. Only semantic chrome that is never a +// theme knob (error/warn/current-marker) stays ANSI-named / const below. + /// Footer text color for an [`Severity::Error`] [`Notice`] — a clearly-red tone that reads on /// both light and dark terminal themes. const FG_ERROR: Color = Color::Rgb(220, 60, 60); -const FG_GUTTER: Color = Color::DarkGray; /// Warning tone for the winbar's needs-restack marker (locked decision #9) — an amber, distinct /// from [`FG_ERROR`]'s red: a stale-parent changeset is a heads-up to `gt restack`, not a failure. const FG_WARN: Color = Color::Rgb(214, 158, 46); @@ -105,7 +101,7 @@ struct Segment { /// Merge background-role spans and syntax fg spans into a flat list of non-overlapping /// segments covering `[0, len)`. A syntax span carries only its capture index; its color is /// resolved HERE against `theme` (ADR-035's render-time resolution) — a segment with no covering -/// syntax span falls back to [`FG_DEFAULT`]. +/// syntax span falls back to [`Palette::foreground`]. fn compose_segments( len: usize, bg_spans: &[(usize, usize, Color)], @@ -145,7 +141,7 @@ fn compose_segments( let fg = fg_spans .and_then(|fgs| fgs.iter().find(|s| mid >= s.start && mid < s.end)) .map(|s| theme.syntax(s.capture)) - .unwrap_or(FG_DEFAULT); + .unwrap_or(theme.foreground); segments.push(Segment { start, end, bg, fg }); } segments @@ -279,7 +275,7 @@ fn content_spans( if segments.is_empty() && !text.is_empty() { spans.push(TSpan::styled( text.to_string(), - Style::default().fg(FG_DEFAULT), + Style::default().fg(theme.foreground), )); } for seg in segments { @@ -309,7 +305,7 @@ fn build_pane_line( match row { Row::Filler => { let pattern: String = "╱".repeat(content_w + gutter_w + 1); - Line::from(TSpan::styled(pattern, Style::default().fg(FG_DIM))) + Line::from(TSpan::styled(pattern, Style::default().fg(theme.dim))) } Row::Line(n) => { let text = match side { @@ -323,7 +319,7 @@ fn build_pane_line( .and_then(|v| v.get(n - 1)); let gutter = format!("{n:>gutter_w$} "); - let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; let emphasis = match kind { CellKind::Del => Some(del_bg_pair(mode, n as u32, theme)), @@ -347,10 +343,24 @@ fn build_pane_line( /// on top of everything else. `keymap` is the resolved, possibly-rebound keymap — the footer hint /// and help overlay render its ACTUAL bindings (see [`crate::keymap::footer_hint`]/ /// [`crate::keymap::help_sections`]), never a hardcoded key string. `theme` is the resolved -/// (CS4: always dark) on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, -/// and cursor/selection washes all resolve their colors against it at paint time. +/// on-tint palette — see [`crate::theme`]; the diff body, syntax foreground, and cursor/selection +/// washes all resolve their colors against it at paint time, as do the canvas background and the +/// default/dim/gutter chrome foreground (ADR-035, revised). pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette) { let area = frame.area(); + + // Paint the whole screen with the theme's background FIRST — a curated theme (light/dark) + // controls the canvas outright; `auto` leaves `paint_canvas` false so the terminal's own + // background (and any transparency) shows through instead. Everything drawn below only sets + // `fg` (never `bg`) unless it's specifically painting a tint, so this base coat survives under + // plain text and is overridden cleanly by the diff-tint/cursor/selection washes. + if theme.paint_canvas { + frame.render_widget( + Block::default().style(Style::default().bg(theme.background)), + area, + ); + } + let vlayout = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -364,8 +374,8 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette let body_area = vlayout[1]; let footer_area = vlayout[2]; - render_header(frame, app, header_area); - render_footer(frame, app, footer_area, keymap); + render_header(frame, app, header_area, theme); + render_footer(frame, app, footer_area, keymap, theme); if app.outline_open() { let hlayout = Layout::default() @@ -383,7 +393,7 @@ pub fn render(frame: &mut Frame, app: &mut App, keymap: &Keymap, theme: &Palette for y in div_area.y..div_area.y + div_area.height { frame .buffer_mut() - .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + .set_string(div_area.x, y, "│", Style::default().fg(theme.dim)); } render_body(frame, app, diff_area, theme); } else { @@ -485,7 +495,7 @@ fn render_outline(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { continue; }; let is_cursor = item_idx == cursor; - let line = build_outline_line(item); + let line = build_outline_line(item, theme); let line = if is_cursor && focused { apply_cursor_row(line, area.width, theme) } else if is_cursor { @@ -519,7 +529,7 @@ fn tree_prefix(guides: &[bool]) -> String { /// Build one outline row's rendered [`Line`] — see [`render_outline`]'s doc comment for the /// marker rules. -fn build_outline_line(item: &OutlineItem) -> Line<'static> { +fn build_outline_line(item: &OutlineItem, theme: &Palette) -> Line<'static> { match item { OutlineItem::Header { label, @@ -534,7 +544,9 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { )]; spans.push(TSpan::styled( label.clone(), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )); if *needs_restack { spans.push(TSpan::styled(" \u{26A0}", Style::default().fg(FG_WARN))); @@ -545,7 +557,9 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { let text = format!("{}{name}/", tree_prefix(guides)); Line::from(TSpan::styled( text, - Style::default().fg(FG_DIM).add_modifier(Modifier::ITALIC), + Style::default() + .fg(theme.dim) + .add_modifier(Modifier::ITALIC), )) } OutlineItem::File { @@ -564,7 +578,7 @@ fn build_outline_line(item: &OutlineItem) -> Line<'static> { tree_prefix(guides) }; let text = format!("{prefix}{glyph} {path}"); - Line::from(TSpan::styled(text, Style::default().fg(FG_DEFAULT))) + Line::from(TSpan::styled(text, Style::default().fg(theme.foreground))) } } } @@ -591,16 +605,20 @@ fn current_file_label(app: &App) -> String { /// changeset-aware winbar (locked decision #8) once the stack has more than one changeset — the /// winbar's own `[i/n]` is the CHANGESET counter, so showing both here would render two different /// counters under the same bracket notation. Never both at once. -fn render_header(frame: &mut Frame, app: &App, area: Rect) { +fn render_header(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { if app.changeset_count() > 1 { - render_winbar(frame, app, area); + render_winbar(frame, app, area, theme); return; } let idx = app.current + 1; let n = app.files().len(); let text = format!("[{idx}/{n}] {}", current_file_label(app)); frame.render_widget( - Paragraph::new(text).style(Style::default().add_modifier(Modifier::BOLD)), + Paragraph::new(text).style( + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + ), area, ); } @@ -610,7 +628,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { /// stack and `fidx/nfiles` the active file's position within it. Only reached when /// [`App::changeset_count`] > 1 (see [`render_header`]) — a lone uncommitted changeset never /// shows this, keeping the M4 full-width look. -fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { +fn render_winbar(frame: &mut Frame, app: &App, area: Rect, theme: &Palette) { let cs = app.current_changeset(); let i = app.current_cs() + 1; let n = app.changeset_count(); @@ -618,7 +636,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { let mut spans = vec![TSpan::styled( format!("[{i}/{n}] {title}"), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )]; // A boolean-driven glyph + color (locked decision #9), not a title-string suffix — distinct // from the plain title so a stale-parent changeset reads as a heads-up at a glance. @@ -632,7 +652,9 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { let nfiles = app.files().len(); spans.push(TSpan::styled( format!(" — {} ({fidx}/{nfiles})", current_file_label(app)), - Style::default().add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), )); frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -641,7 +663,7 @@ fn render_winbar(frame: &mut Frame, app: &App, area: Rect) { /// Footer priority: a pending discard confirm's prompt (warn-toned) wins over a transient notice, /// which wins over the curated hint line (CS3) — a notice TEMPORARILY REPLACES the hint rather /// than adding a second row; it clears on the user's next keypress (`tui::update`). -fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { +fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap, theme: &Palette) { if let Some(confirm) = &app.pending_confirm { frame.render_widget( Paragraph::new(confirm.prompt.as_str()).style(Style::default().fg(FG_ERROR)), @@ -653,7 +675,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { Some(Notice { text, severity }) => { let fg = match severity { Severity::Error => FG_ERROR, - Severity::Info => FG_DEFAULT, + Severity::Info => theme.foreground, }; frame.render_widget( Paragraph::new(text.as_str()).style(Style::default().fg(fg)), @@ -672,7 +694,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect, keymap: &Keymap) { }; let text = footer_hint(keymap, focused); frame.render_widget( - Paragraph::new(text).style(Style::default().fg(FG_DIM)), + Paragraph::new(text).style(Style::default().fg(theme.dim)), area, ); } @@ -692,7 +714,7 @@ fn render_gap_row( theme: &Palette, ) { let msg = format!("··· {skipped} unchanged lines ···"); - let line = Line::from(TSpan::styled(msg, Style::default().fg(FG_DIM))); + let line = Line::from(TSpan::styled(msg, Style::default().fg(theme.dim))); // Cursor wins over selection on the same row. let line = if is_cursor { apply_cursor_row(line, area.width, theme) @@ -713,7 +735,10 @@ fn render_body(frame: &mut Frame, app: &mut App, area: Rect, theme: &Palette) { let idx = app.current; if app.files()[idx].is_binary { let msg = format!("[Binary file: {}]", app.files()[idx].path); - frame.render_widget(Paragraph::new(msg).style(Style::default().fg(FG_DIM)), area); + frame.render_widget( + Paragraph::new(msg).style(Style::default().fg(theme.dim)), + area, + ); return; } @@ -786,8 +811,8 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t app.derive_scroll(); app.derive_alt_scroll(); - render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED"); - render_caption(frame.buffer_mut(), staged_caption, "STAGED"); + render_caption(frame.buffer_mut(), unstaged_caption, "UNSTAGED", theme); + render_caption(frame.buffer_mut(), staged_caption, "STAGED", theme); let (u_scroll, u_cursor) = app.pane_render_state(Role::Unstaged); let (s_scroll, s_cursor) = app.pane_render_state(Role::Staged); @@ -850,9 +875,9 @@ fn render_body_split(frame: &mut Frame, app: &mut App, area: Rect, idx: usize, t /// Write a split pane's role caption (`── LABEL ──`) across the pane width, styled like the dim /// gap-row markers. -fn render_caption(buf: &mut Buffer, area: Rect, label: &str) { +fn render_caption(buf: &mut Buffer, area: Rect, label: &str, theme: &Palette) { let text = format!("── {label} ──"); - let line = Line::from(TSpan::styled(text, Style::default().fg(FG_DIM))); + let line = Line::from(TSpan::styled(text, Style::default().fg(theme.dim))); buf.set_line(area.x, area.y, &line, area.width); } @@ -921,7 +946,7 @@ fn render_pane_sbs( for y in area.y..area.y + area.height { frame .buffer_mut() - .set_string(div_area.x, y, "│", Style::default().fg(FG_DIM)); + .set_string(div_area.x, y, "│", Style::default().fg(theme.dim)); } for (i, row_idx) in (scroll..end).enumerate() { @@ -1000,7 +1025,7 @@ fn render_pane_sbs( div_area.x, y, "│", - Style::default().fg(FG_DIM).bg(theme.cursor_bg), + Style::default().fg(theme.dim).bg(theme.cursor_bg), ); } } @@ -1065,7 +1090,7 @@ fn build_inline_line( gutter_field(old_opt, old_gutter_w), gutter_field(new_opt, new_gutter_w) ); - let mut spans = vec![TSpan::styled(gutter, Style::default().fg(FG_GUTTER))]; + let mut spans = vec![TSpan::styled(gutter, Style::default().fg(theme.gutter))]; let is_word_pair = row.is_word_diff_pair(); // `kind` is always Del/Add/Context here — inline has no Filler rows. `old_opt`/`new_opt` @@ -1206,6 +1231,16 @@ mod tests { terminal.backend().buffer().clone() } + /// Like [`render_once`] but with a caller-chosen theme — for the canvas-paint tests, which + /// need to compare `light` vs `dark` (not just always-dark). + fn render_once_themed(app: &mut App, width: u16, height: u16, theme: &Palette) -> Buffer { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let keymap = Keymap::defaults(); + terminal.draw(|f| render(f, app, &keymap, theme)).unwrap(); + terminal.backend().buffer().clone() + } + fn cell_text(buf: &Buffer, x: u16, y: u16) -> &str { buf.cell((x, y)).unwrap().symbol() } @@ -2421,4 +2456,116 @@ mod tests { content.join("\n") ); } + + // ── theming fix: canvas paint ──────────────────────────────────────────────── + + #[test] + fn light_theme_paints_the_canvas_with_the_light_background() { + // The bug this fix addresses: `workon.review.theme light` still showed the terminal's own + // (usually dark) bg/fg because the canvas was never painted. A body cell untouched by any + // diff/cursor/selection tint (e.g. a blank row past the end of a short file) must carry + // the theme's OWN background, not `None`/the terminal default. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::light(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + // Row 1 (below the header, no files loaded — "(no changes)" placeholder) is plain canvas: + // no tint should have painted over it. + let canvas_cell = buf.cell((30, 5)).unwrap(); + assert_eq!( + canvas_cell.style().bg, + Some(theme.background), + "expected an untinted body cell to carry the light theme's painted canvas background" + ); + } + + #[test] + fn header_text_carries_the_theme_foreground_not_the_terminal_default() { + // Regression (stack-review): render_header/render_winbar drew BOLD text with no `.fg()`, + // so on a curated theme whose polarity differs from the terminal the top bar rendered in + // the terminal's default fg over the painted canvas — invisible (light-on-light for + // `theme=light` in a dark terminal). The header must carry the theme's own foreground. + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::light(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + // Cell (0,0) is the header's leading '[' — a real glyph in the top status bar. + let header_cell = buf.cell((0, 0)).unwrap(); + assert_eq!( + header_cell.style().fg, + Some(theme.foreground), + "header text must use the theme foreground to stay visible on the painted canvas" + ); + } + + #[test] + fn dark_theme_paints_the_canvas_with_the_dark_background() { + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .build() + .unwrap(); + let mut app = app_from_fixture(&fixture); + let theme = Palette::dark(); + let buf = render_once_themed(&mut app, 40, 10, &theme); + + let canvas_cell = buf.cell((30, 5)).unwrap(); + assert_eq!( + canvas_cell.style().bg, + Some(theme.background), + "expected an untinted body cell to carry the dark theme's painted canvas background" + ); + } + + #[test] + fn cursor_row_tint_still_shows_over_a_painted_canvas() { + // The canvas paint must not mask the per-row tint compositing (cursor/diff washes) — + // a cursor row must still show the theme's cursor tint, not the flat canvas color. + let old = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nold word here\nl10\nl11\nl12\nl13\nl14\n"; + let new = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nnew word here\nl10\nl11\nl12\nl13\nl14\n"; + let fixture = FixtureBuilder::new() + .config("core.autocrlf", "false") + .unstaged_file("small.txt", old, new) + .build() + .unwrap(); + + let mut app = app_from_fixture(&fixture); + app.open_current(); + let theme = Palette::light(); + + let cursor_row = app + .current_view_ref() + .unwrap() + .display + .iter() + .position(|row| matches!(row, DisplayRow::Row(r) if r.old == Row::Line(10))) + .expect("l10 row present in the display vector"); + app.cursor = cursor_row; + + let buf = render_once_themed(&mut app, 60, 20, &theme); + let content = buf_lines(&buf); + let cursor_y = content + .iter() + .position(|line| line.contains("l10 ")) + .expect("cursor row (l10) visible") as u16; + + let cursor_bg = buf.cell((1, cursor_y)).unwrap().style().bg; + assert_eq!( + cursor_bg, + Some(theme.cursor_bg), + "expected the cursor row to carry the theme's cursor tint over the painted canvas" + ); + assert_ne!( + cursor_bg, + Some(theme.background), + "the cursor tint must be visually distinct from the flat painted canvas" + ); + } } diff --git a/git-workon-review/src/theme.rs b/git-workon-review/src/theme.rs index 237f33a3..a9b63ce2 100644 --- a/git-workon-review/src/theme.rs +++ b/git-workon-review/src/theme.rs @@ -6,12 +6,18 @@ //! CS5 adds [`Palette::light`] and wires [`crate::config::Theme`] to pick between them; CS6 adds the //! terminal-derivation probe for `auto`. //! -//! ## Hybrid boundary (ADR-035) +//! ## Hybrid boundary (ADR-035, revised) //! Colors that sit ON a tinted background — the diff add/del gradient, its staged variants, the //! cursor/selection washes, and syntax foreground — are theme-controlled base16 truecolor and live -//! here. Chrome that is NOT on a tint (gutter, dividers, footer, dim labels, status markers) stays -//! ANSI-named / const in [`crate::render`] so it self-adapts to the terminal palette and is -//! probe-independent. This module deliberately holds only the on-tint half. +//! here, as before. The canvas background and chrome FOREGROUND (default text, dim labels, the +//! gutter) are now ALSO palette-ramp-controlled ([`Palette::background`]/[`Palette::foreground`]/ +//! [`Palette::dim`]/[`Palette::gutter`]), so a curated (`light`/`dark`) theme fully controls the +//! look instead of bleeding the terminal's own bg/fg through. `auto` ([`Palette::from_terminal`]) +//! still derives these four from the probed terminal colors — so it matches the terminal exactly — +//! and leaves [`Palette::paint_canvas`] `false` so a transparent/backgrounded terminal isn't +//! painted over; the curated schemes and the probe's curated fallback set it `true`. Semantic +//! chrome that is never on a tint and never a theme knob — error/warn/current-marker colors — stays +//! ANSI/const in [`crate::render`] (`FG_ERROR`/`FG_WARN`/`FG_CURRENT`), unaffected by this boundary. use ratatui::style::Color; @@ -184,6 +190,23 @@ pub struct Palette { pub selection_bg: Color, /// Cursor wash for the outline pane while OPEN but NOT focused — dimmer than [`Palette::cursor_bg`]. pub outline_cursor_unfocused_bg: Color, + + /// The screen/canvas background (base00) — painted by [`crate::render::render`] when + /// [`Palette::paint_canvas`] is set, so a curated theme's background actually shows instead of + /// the terminal's own. + pub background: Color, + /// Default text foreground (base05) — resolved by [`crate::render`] wherever text carries no + /// syntax highlight. + pub foreground: Color, + /// Dim/comment-toned foreground (base03) — dim labels, gap markers, split captions. + pub dim: Color, + /// Gutter/divider foreground (base04) — line-number gutters and pane dividers. + pub gutter: Color, + /// Whether [`crate::render::render`] should paint the whole frame with [`Palette::background`] + /// before drawing panes. `true` for the curated [`Palette::dark`]/[`Palette::light`] schemes + /// (and the probe's curated fallback); `false` for [`Palette::from_terminal`], so `auto` + /// preserves the terminal's own background (transparency, images) rather than flattening it. + pub paint_canvas: bool, } impl Palette { @@ -210,6 +233,11 @@ impl Palette { cursor_bg: Color::Rgb(45, 50, 90), selection_bg: Color::Rgb(30, 66, 66), outline_cursor_unfocused_bg: Color::Rgb(35, 38, 55), + background: base.slot(0), + foreground: base.slot(5), + dim: base.slot(3), + gutter: base.slot(4), + paint_canvas: true, } } @@ -254,6 +282,11 @@ impl Palette { cursor_bg: tint_toward(blue, base00, CURSOR), selection_bg: tint_toward(cyan, base00, CURSOR), outline_cursor_unfocused_bg: tint_toward(blue, base00, OUTLINE_CURSOR_UNFOCUSED), + background: base.slot(0), + foreground: base.slot(5), + dim: base.slot(3), + gutter: base.slot(4), + paint_canvas: true, } } @@ -285,6 +318,17 @@ impl Palette { cursor_bg: curated.cursor_bg, selection_bg: curated.selection_bg, outline_cursor_unfocused_bg: curated.outline_cursor_unfocused_bg, + // Derived straight from the probed terminal scheme (NOT the curated fallback) — this + // is the whole point of `auto`: chrome that matches the terminal's own colors. + background: base.slot(0), + foreground: base.slot(5), + dim: base.slot(3), + gutter: base.slot(4), + // Unlike the curated schemes, `auto` must NOT paint over the terminal's own + // background — base00 here IS the probed terminal bg, so painting a solid canvas + // would defeat terminal transparency/background images for no benefit (the probed + // fg/dim/gutter already match the inherited bg, since they came from the same probe). + paint_canvas: false, } } @@ -353,6 +397,33 @@ mod tests { assert_eq!(t.outline_cursor_unfocused_bg, Color::Rgb(35, 38, 55)); } + #[test] + fn dark_chrome_fields_match_the_eighties_dark_ramp_and_paint_the_canvas() { + // `dark()`'s canvas/chrome must come from the SAME ramp `Palette::dark`'s syntax/tints + // already use (base00/base03/base04/base05), and must paint (a curated theme fully + // controls the look — see the theme module's revised hybrid-boundary doc comment). + let t = Palette::dark(); + assert_eq!(t.background, Color::Rgb(0x2d, 0x2d, 0x2d)); // base00 + assert_eq!(t.foreground, Color::Rgb(0xd3, 0xd0, 0xc8)); // base05 + assert_eq!(t.dim, Color::Rgb(0x74, 0x73, 0x69)); // base03 + assert_eq!(t.gutter, Color::Rgb(0xa0, 0x9f, 0x93)); // base04 + assert!(t.paint_canvas); + } + + #[test] + fn light_background_is_high_luminance_and_foreground_is_low_luminance() { + // A real light theme: a near-white canvas with dark text on it, and it must paint (an + // unpainted canvas would let the terminal's own dark bg bleed through, the exact bug this + // fix addresses). + let t = Palette::light(); + assert_eq!(t.background, Color::Rgb(0xfa, 0xfa, 0xfa)); // base00 + assert_eq!(t.foreground, Color::Rgb(0x38, 0x3a, 0x42)); // base05 + assert_eq!(t.dim, Color::Rgb(0xa0, 0xa1, 0xa7)); // base03 + assert_eq!(t.gutter, Color::Rgb(0x69, 0x6c, 0x77)); // base04 + assert!(luminance(t.background) > luminance(t.foreground)); + assert!(t.paint_canvas); + } + fn rgb(color: Color) -> (u8, u8, u8) { match color { Color::Rgb(r, g, b) => (r, g, b), @@ -489,6 +560,20 @@ mod tests { assert_ne!(palette.del_subtle, Palette::dark().del_subtle); } + #[test] + fn from_terminal_takes_chrome_from_the_probed_scheme_and_does_not_paint() { + // `auto`'s canvas/chrome must come from the PROBED scheme (so it matches the terminal), + // and must NOT paint — the terminal's own background stays, preserving transparency (see + // the theme module's revised hybrid-boundary doc comment). + let probed = probed_base16(Color::Rgb(0x1a, 0x1a, 0x1a)); + let palette = Palette::from_terminal(probed); + assert_eq!(palette.background, probed.slot(0)); + assert_eq!(palette.foreground, probed.slot(5)); + assert_eq!(palette.dim, probed.slot(3)); + assert_eq!(palette.gutter, probed.slot(4)); + assert!(!palette.paint_canvas); + } + #[test] fn is_light_background_splits_on_the_luminance_midpoint() { assert!(is_light_background(Base16::ONE_LIGHT.slot(0))); From 951816c500e6cefa2646ca47ed9af03df5d0a643 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 22:43:37 -0400 Subject: [PATCH 12/13] fix(review): wait out zero-byte tty reads in theme=auto probe --- git-workon-review/src/main.rs | 13 +- git-workon-review/src/terminal_query.rs | 186 ++++++++++++++++++++---- 2 files changed, 168 insertions(+), 31 deletions(-) diff --git a/git-workon-review/src/main.rs b/git-workon-review/src/main.rs index 4d14b57e..0db3e30e 100644 --- a/git-workon-review/src/main.rs +++ b/git-workon-review/src/main.rs @@ -61,7 +61,9 @@ fn main() -> Result<()> { // probe (CS6), which needs the controlling tty and so lives outside the pure `theme.rs`; it is // bounded by a hard timeout and always yields a curated fallback on a silent/hostile terminal, // never a hang. `Dark`/`Light` stay CS5's I/O-free `for_theme` path. - let theme = match ReviewConfig::new(&repo).theme() { + let selection = ReviewConfig::new(&repo).theme(); + let probed = matches!(selection, Ok(config::Theme::Auto)); + let theme = match selection { Ok(config::Theme::Auto) => terminal_query::detect_auto_palette(), Ok(selection) => Palette::for_theme(selection), Err(_) => Palette::dark(), @@ -94,6 +96,15 @@ fn main() -> Result<()> { app.notify(warnings.join("; "), Severity::Error); } + // After a probe, OSC replies from a slow terminal (e.g. one ssh round-trip away) may have + // straggled in while the changesets were being assembled above. Discard them now, right + // before crossterm takes the terminal — parsed as input they become phantom keystrokes + // (`r` fires refreshes; `d` opens the discard confirm, which then swallows every key until + // Esc/n: the "unresponsive for ~30s with theme=auto" startup). Un-probed launches skip this + // so legitimate type-ahead survives. + if probed { + terminal_query::flush_pending_tty_input(); + } tui::run(&mut app, &keymap, &theme).into_diagnostic()?; Ok(()) diff --git a/git-workon-review/src/terminal_query.rs b/git-workon-review/src/terminal_query.rs index 4b7a639c..0310adaf 100644 --- a/git-workon-review/src/terminal_query.rs +++ b/git-workon-review/src/terminal_query.rs @@ -13,12 +13,17 @@ //! //! The probe is the single most terminal-fragile component in the review TUI, so its blast radius //! is contained to "return a curated theme instead": -//! - **Never hangs.** The tty is set **non-blocking** and the whole read ([`read_replies`]) is -//! bounded by a hard `Instant` deadline. `read()` therefore can never block on a terminal that -//! doesn't answer (tmux without passthrough, ssh, CI, a dumb terminal) — it returns `WouldBlock` -//! and the deadline is the backstop. (A blocking read guarded only by `poll(2)` is NOT safe: -//! `poll` on a tty is unreliable on macOS — it can report spurious readability — and -//! `cfmakeraw` sets `VMIN=1`, so a blocking `read` after a bad `poll` waits forever.) +//! - **Never hangs, but always waits.** The tty is set **non-blocking** and the whole read +//! ([`read_replies`]) is bounded by a hard `Instant` deadline. `read()` therefore can never +//! block on a terminal that doesn't answer (tmux without passthrough, ssh, CI, a dumb +//! terminal) — a not-yet-answered read yields `WouldBlock` or, with the `VMIN=0` polling-read +//! semantics the probe sets, `Ok(0)`. BOTH mean "no data yet", never EOF: treating `Ok(0)` as +//! EOF made the deadline loop exit in microseconds, so the probe read nothing, the replies +//! arrived after the [`query_terminal_raw`] flush, leaked into crossterm, and froze input at +//! startup (the dogfood-round-2 wedge). Only the deadline and the DA1 sentinel end the wait. +//! (A blocking read guarded only by `poll(2)` is NOT safe: `poll` on a tty is unreliable on +//! macOS — it can report spurious readability — and `cfmakeraw` sets `VMIN=1`, so a blocking +//! `read` after a bad `poll` waits forever.) //! - **Never corrupts the terminal.** The probe runs BEFORE `tui::run` installs its own raw mode / //! alternate screen. It saves the tty's `termios`, sets raw for the duration of the read, and //! **always restores** the saved `termios` before returning — leaving the tty exactly as it was @@ -53,8 +58,31 @@ pub struct ProbeResult { /// usable [`Palette`] — a terminal-derived one when the probe succeeds, a curated fallback /// otherwise. This is the entry point `main.rs` calls; the timeout is the non-negotiable backstop /// against a silent terminal. +/// +/// The deadline is generous because it almost never bites: every interactive terminal answers the +/// DA1 sentinel (a VT100-era query), so the probe normally returns at the sentinel within a few +/// ms (or one network round-trip over ssh). Only a tty whose far end answers *nothing* waits the +/// full deadline — and giving up early on a merely-slow terminal is worse than the wait, because +/// replies that arrive after the probe stopped listening leak into crossterm as phantom +/// keystrokes (`r` → refresh storms, `d` → a discard confirm that captures the keyboard). pub fn detect_auto_palette() -> Palette { - palette_for_auto(&probe_terminal(Duration::from_millis(120))) + palette_for_auto(&probe_terminal(Duration::from_millis(800))) +} + +/// Discard any bytes pending on the controlling tty's input queue. `main.rs` calls this after the +/// `theme = auto` probe and immediately before the TUI takes over the terminal: OSC replies that +/// straggle in while the app is still assembling changesets (an ssh round-trip can outlast the +/// probe's deadline) would otherwise sit in the queue and reach crossterm as phantom keystrokes. +/// Only meaningful after a probe — an un-probed launch has no replies owed, and flushing would +/// discard legitimate type-ahead. +pub fn flush_pending_tty_input() { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + if let Ok(tty) = std::fs::File::options().read(true).open("/dev/tty") { + unsafe { libc::tcflush(tty.as_raw_fd(), libc::TCIFLUSH) }; + } + } } /// The pure decision that turns a [`ProbeResult`] into a [`Palette`] (unit-tested with injected @@ -348,34 +376,17 @@ fn read_replies( return None; } - // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock`, never a - // blocked `read`. The `Instant` deadline (not `poll`) is the sole timing authority. + // Switch to non-blocking for the read: a silent terminal must yield `WouldBlock` (or the + // `VMIN=0` polling-read `Ok(0)`), never a blocked `read`. The `Instant` deadline (not `poll`) + // is the sole timing authority. set_nonblocking(fd); - let deadline = Instant::now() + timeout; - let mut buf = Vec::with_capacity(512); + let mut buf = collect_replies(|chunk| tty.read(chunk), Instant::now() + timeout); let mut chunk = [0u8; 256]; - while Instant::now() < deadline { - match tty.read(&mut chunk) { - Ok(0) => break, // EOF - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if has_da1_terminator(&buf) { - break; - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // No data yet — yield briefly and let the deadline bound the wait. - std::thread::sleep(Duration::from_millis(2)); - } - Err(_) => break, - } - } - // Drain anything immediately available (e.g. a terminal that answered without a DA1) so it - // doesn't surface as spurious input once the TUI takes over the tty. Non-blocking, so this - // stops at the first `WouldBlock`. + // doesn't surface as spurious input once the TUI takes over the tty. Stops the moment nothing + // is pending (`Ok(0)` or `WouldBlock`), so it never waits. loop { match tty.read(&mut chunk) { Ok(n) if n > 0 => buf.extend_from_slice(&chunk[..n]), @@ -390,6 +401,40 @@ fn read_replies( } } +/// Accumulate terminal reply bytes from `read` until the DA1 sentinel arrives or `deadline` +/// passes — the read half of [`read_replies`], seamed on the reader so the loop's give-up +/// conditions are unit-testable without a tty. +/// +/// Both `Ok(0)` and `WouldBlock` mean "the terminal hasn't answered yet", NEVER end-of-file: +/// with the `VMIN=0` termios the probe sets, a tty `read` is a *polling read* that returns 0 +/// immediately when the queue is empty. Only a genuine read error ends the wait early — every +/// "no data yet" result just yields briefly and retries until the deadline. +#[cfg(unix)] +fn collect_replies( + mut read: impl FnMut(&mut [u8]) -> std::io::Result, + deadline: std::time::Instant, +) -> Vec { + let mut buf = Vec::with_capacity(512); + let mut chunk = [0u8; 256]; + + while std::time::Instant::now() < deadline { + match read(&mut chunk) { + Ok(n) if n > 0 => { + buf.extend_from_slice(&chunk[..n]); + if has_da1_terminator(&buf) { + break; + } + } + Ok(_) => std::thread::sleep(Duration::from_millis(2)), + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + buf +} + /// Set `O_NONBLOCK` on the fd so `read` returns `WouldBlock` instead of blocking when the terminal /// has nothing (more) to say. Best-effort: a failed `fcntl` leaves the fd blocking, but the caller /// only reaches here after a successful `tcgetattr`, and the deadline loop still bounds the wait in @@ -507,6 +552,87 @@ mod tests { assert_eq!(payloads[1], b"10;rgb:11/22/33"); } + // ── collect_replies give-up conditions ─────────────────────────────────── + + /// A reader that scripts each successive `read` call's result: `Ok(&[u8])` delivers bytes, + /// `Err(kind)` returns that error kind. Exhausting the script yields `Ok(0)` ("no data yet"). + #[cfg(unix)] + fn scripted_reader( + script: Vec>, + ) -> impl FnMut(&mut [u8]) -> std::io::Result { + let mut steps = script.into_iter(); + move |chunk: &mut [u8]| match steps.next() { + Some(Ok(bytes)) => { + chunk[..bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + Some(Err(kind)) => Err(kind.into()), + None => Ok(0), + } + } + + #[cfg(unix)] + fn soon() -> std::time::Instant { + std::time::Instant::now() + Duration::from_millis(200) + } + + #[cfg(unix)] + #[test] + fn collect_replies_treats_zero_byte_reads_as_pending_not_eof() { + // The dogfood-round-2 wedge: with `VMIN=0` a tty read returns `Ok(0)` while the terminal + // is still composing its answer. The loop must keep waiting — bailing here left the + // replies to arrive after the probe's flush and freeze crossterm's input at startup. + let read = scripted_reader(vec![ + Ok(b""), + Ok(b""), + Ok(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"), + Ok(b"\x1b[?62;22c"), + ]); + let buf = collect_replies(read, soon()); + assert!( + buf.starts_with(b"\x1b]11;"), + "replies after Ok(0) polling reads must still be collected" + ); + assert!(has_da1_terminator(&buf), "loop ran on to the DA1 sentinel"); + } + + #[cfg(unix)] + #[test] + fn collect_replies_stops_at_the_da1_sentinel() { + // Bytes offered after the DA1 reply must never be consumed — the sentinel ends the read + // so the probe returns promptly on a terminal that answered everything. + let read = scripted_reader(vec![Ok(b"\x1b[?62;22c"), Ok(b"leftover")]); + let buf = collect_replies(read, soon()); + assert_eq!(buf, b"\x1b[?62;22c"); + } + + #[cfg(unix)] + #[test] + fn collect_replies_waits_out_would_block_and_gives_up_at_the_deadline() { + // WouldBlock is the O_NONBLOCK "no data yet"; a terminal that never answers must yield + // an empty buffer once the deadline passes — bounded, not hung, and nothing invented. + let read = scripted_reader(vec![Err(std::io::ErrorKind::WouldBlock); 3]); + let buf = collect_replies(read, soon()); + assert!(buf.is_empty()); + } + + #[cfg(unix)] + #[test] + fn collect_replies_gives_up_on_a_real_read_error() { + // A genuine error (not WouldBlock) ends the wait early with whatever already arrived. + let start = std::time::Instant::now(); + let read = scripted_reader(vec![ + Ok(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"), + Err(std::io::ErrorKind::Other), + ]); + let buf = collect_replies(read, std::time::Instant::now() + Duration::from_secs(5)); + assert!(buf.starts_with(b"\x1b]11;")); + assert!( + start.elapsed() < Duration::from_secs(1), + "error must end the wait, not the deadline" + ); + } + #[test] fn da1_terminator_detected_only_when_complete() { assert!(has_da1_terminator(b"\x1b[?62;1;c")); From 743804b734bc47faf6fda93f328076c41a443513 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 8 Jul 2026 23:15:57 -0400 Subject: [PATCH 13/13] test(review): PTY smoke for theme=auto probe responsiveness --- Cargo.lock | 1 + Makefile | 8 +- git-workon-review/Cargo.toml | 1 + git-workon-review/tests/pty_smoke.rs | 132 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 git-workon-review/tests/pty_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index f4e59d37..e78c6c1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,6 +965,7 @@ dependencies = [ "clap", "clap_complete", "crossterm", + "expectrl", "git-workon-fixture", "git-workon-lib", "git2", diff --git a/Makefile b/Makefile index 2e19d17e..316c8aba 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-dev install-man install-hooks build test fmt clippy +.PHONY: install install-dev install-man install-hooks build test smoke fmt clippy PREFIX ?= /usr/local @@ -25,6 +25,12 @@ build: test: cargo test --workspace +# PTY smoke tests (ignored by default: wall-clock-bound and load-sensitive). +# Spawns the review binary under a pseudo-terminal and plays the terminal's +# side of the theme=auto probe conversation; see tests/pty_smoke.rs. +smoke: + cargo test -p git-workon-review --test pty_smoke -- --ignored + fmt: cargo fmt diff --git a/git-workon-review/Cargo.toml b/git-workon-review/Cargo.toml index f8e44854..6c4e3533 100644 --- a/git-workon-review/Cargo.toml +++ b/git-workon-review/Cargo.toml @@ -60,5 +60,6 @@ dist = false [dev-dependencies] assert_cmd.workspace = true assert_fs.workspace = true +expectrl.workspace = true git-workon-fixture.workspace = true predicates.workspace = true diff --git a/git-workon-review/tests/pty_smoke.rs b/git-workon-review/tests/pty_smoke.rs new file mode 100644 index 00000000..070b2a65 --- /dev/null +++ b/git-workon-review/tests/pty_smoke.rs @@ -0,0 +1,132 @@ +//! PTY smoke tests for the `theme = auto` terminal probe (dogfood round 2 regression). +//! +//! These spawn the real binary under a pseudo-terminal and play the *terminal's* side of the +//! OSC color-query conversation — the one scenario unit tests can't reach, and the one that hid +//! the round-2 wedge: a terminal that ANSWERS the probe. When the probe mishandled its replies +//! they leaked into crossterm as phantom keystrokes (`r` in `rgb:` fired refresh storms; `d` in +//! hex specs opened the discard confirm, which swallows every key but y/n/Esc), freezing startup +//! for ~30s. The assertion here is deliberately blunt: after startup settles, `q` must still +//! quit promptly. +//! +//! **Not run by default** (`#[ignore]`): PTY tests are wall-clock-bound (settle windows, probe +//! deadline) and load-sensitive — under heavy parallel CPU load a slow spawn can eat into the +//! responsiveness margin (same caveat as git-workon's `checkout_conflict_interactive_*` PTY +//! test: re-run solo before treating a failure as a regression). Run them explicitly: +//! +//! ```text +//! cargo test -p git-workon-review --test pty_smoke -- --ignored +//! ``` +//! +//! Color/SGR assertions are deliberately absent — capturing ratatui frames through a PTY is +//! unreliable; reply *parsing* is unit-tested in `terminal_query.rs`. + +#![cfg(unix)] + +use std::io::Write; +use std::time::{Duration, Instant}; + +use expectrl::{ + session::{OsProcess, OsStream}, + Expect, Session, +}; +use git_workon_fixture::prelude::*; + +/// How long `q` may take to terminate the app before we call startup unresponsive. Generous on +/// purpose: healthy is ~10ms, the regression was 30s–forever, and the slack absorbs CI load. +const RESPONSIVE: Duration = Duration::from_secs(5); + +/// A repo with one uncommitted change (so the TUI actually opens) and `theme = auto` (so the +/// probe runs). +fn auto_theme_fixture() -> Fixture { + FixtureBuilder::new() + .config("workon.review.theme", "auto") + .unstaged_file("file.txt", "a\nb\nc\n", "a\nCHANGED\nc\n") + .build() + .expect("fixture") +} + +/// Spawn the review binary in a PTY sized like a real terminal (an unsized PTY is 0×0 and +/// ratatui draws nothing), cwd'd into the fixture's worktree. +fn spawn_review(fixture: &Fixture) -> Session { + let repo = fixture.repo().expect("fixture repo"); + let workdir = repo.workdir().expect("fixture workdir").to_path_buf(); + + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_git-workon-review")); + cmd.current_dir(workdir).env("TERM", "xterm-256color"); + + let mut session = expectrl::Session::spawn(cmd).expect("spawn in PTY"); + session + .get_process_mut() + .set_window_size(120, 40) + .expect("size PTY"); + session.set_expect_timeout(Some(Duration::from_secs(15))); + session +} + +/// Play a well-behaved answering terminal: reply to all 16 `OSC 4` color queries plus +/// `OSC 11`/`OSC 10`, then the DA1 sentinel. The replies deliberately contain the poison bytes +/// of the round-2 wedge — `r`/`g`/`b` (refresh binding) and `d` hex digits (discard binding) — +/// so any regression that leaks them into crossterm trips the discard-confirm modal and fails +/// the responsiveness assertion below. +fn answer_probe(session: &mut Session) { + let mut replies = Vec::new(); + for n in 0..16 { + let level = n * 16; + replies.extend_from_slice( + format!("\x1b]4;{n};rgb:{level:02x}{level:02x}/2020/4040\x1b\\").as_bytes(), + ); + } + replies.extend_from_slice(b"\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\"); // background (dark) + replies.extend_from_slice(b"\x1b]10;rgb:d3d3/d0d0/c8c8\x1b\\"); // foreground ('d' poison) + replies.extend_from_slice(b"\x1b[?62;22c"); // DA1 reply — the probe's stop sentinel + session.write_all(&replies).expect("write probe replies"); + session.flush().expect("flush probe replies"); +} + +/// Wait for the TUI to be up (alternate screen entered), let any straggler reply bytes land, +/// then press `q` and require a prompt exit. +fn assert_q_quits_promptly(mut session: Session) { + session + .expect("\x1b[?1049h") // EnterAlternateScreen — tui::run has the terminal + .expect("TUI entered the alternate screen"); + + // Give leaked bytes (the regression case) time to reach crossterm before q, so a regressed + // binary deterministically has its discard-confirm modal up — and swallows the q. + std::thread::sleep(Duration::from_millis(500)); + + let pressed_q = Instant::now(); + session.send("q").expect("send q"); + session.expect(expectrl::Eof).expect("app exited on q"); + let latency = pressed_q.elapsed(); + assert!( + latency < RESPONSIVE, + "q took {latency:?} to quit the app — startup input is wedged" + ); +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --ignored"] +fn theme_auto_stays_responsive_when_the_terminal_answers() { + let fixture = auto_theme_fixture(); + let mut session = spawn_review(&fixture); + + // The probe's query burst ends with its DA1 request; seeing it means every OSC query has + // been written and the terminal may answer. + session + .expect("\x1b[c") + .expect("probe sent its query burst"); + answer_probe(&mut session); + + assert_q_quits_promptly(session); +} + +#[test] +#[ignore = "PTY smoke — run explicitly: cargo test -p git-workon-review --test pty_smoke -- --ignored"] +fn theme_auto_stays_responsive_when_the_terminal_is_silent() { + // The no-hang guarantee: a terminal that never answers (tmux without passthrough, CI) must + // cost at most the probe deadline, then fall back to a curated theme and run normally. + let fixture = auto_theme_fixture(); + let session = spawn_review(&fixture); + + assert_q_quits_promptly(session); +}