Merge pull request #319 from fiddlerwoaroof/feat/nn4s-faces
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / Nix build (crate2nix) and the repo-stats pipeline (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / Nix build (crate2nix) and the repo-stats pipeline (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
feat(faces): Face, Theme and resolve in improvise-protocol; the TUI draws through face_style (improvise-nn4s)
This commit is contained in:
@@ -59,7 +59,8 @@ they mean, rather than testing for the variant
|
|||||||
**Layering.** `forbidden/2` in `kb.pl` is the authority:
|
**Layering.** `forbidden/2` in `kb.pl` is the authority:
|
||||||
`improvise-core` no UI/IO, `improvise-io` no UI/commands, `improvise-command`
|
`improvise-core` no UI/IO, `improvise-io` no UI/commands, `improvise-command`
|
||||||
no ratatui/crossterm. A ratatui type in core is the usual violation — the fix
|
no ratatui/crossterm. A ratatui type in core is the usual violation — the fix
|
||||||
is a semantic enum in core that the render layer maps (see `AxisColor`).
|
is a semantic value the render layer maps; for appearance that is a `Face` in
|
||||||
|
`improvise-protocol`, resolved by `draw.rs::face_style` (ADR 004).
|
||||||
|
|
||||||
**`Option`/`Result`/`Box` at the boundaries.** Wrap at parse/lookup/IO, unwrap
|
**`Option`/`Result`/`Box` at the boundaries.** Wrap at parse/lookup/IO, unwrap
|
||||||
early, keep core logic on concrete values. Heap-container plumbing should be
|
early, keep core logic on concrete values. Heap-container plumbing should be
|
||||||
|
|||||||
@@ -1652,7 +1652,7 @@ through the server's own types; with one definition there is nothing left for
|
|||||||
them to compare, which is what improvise-xks6 was for.
|
them to compare, which is what improvise-xks6 was for.
|
||||||
|
|
||||||
**`rejection_message` is spelled here rather than on `Rejection`** for the
|
**`rejection_message` is spelled here rather than on `Rejection`** for the
|
||||||
reason `AxisColor` is not a ratatui colour: the protocol carries the *fact*, and
|
reason a `Face` is not a ratatui colour: the protocol carries the *fact*, and
|
||||||
each front end words it. The wording tracks ADR 001's force table, because "try
|
each front end words it. The wording tracks ADR 001's force table, because "try
|
||||||
again" and "this cannot be forced" are the two different things a user must be
|
again" and "this cannot be forced" are the two different things a user must be
|
||||||
able to tell apart — a `RecordGone` has nothing to write to, and offering force
|
able to tell apart — a `RecordGone` has nothing to write to, and offering force
|
||||||
@@ -1877,8 +1877,8 @@ Four smaller decisions that would be re-derived wrongly:
|
|||||||
`improvise_protocol::read_buffer` so the `"search"` special case keeps its one
|
`improvise_protocol::read_buffer` so the `"search"` special case keeps its one
|
||||||
home (gotcha 11); `None` is "no text-entry mode is open", so the page asks
|
home (gotcha 11); `None` is "no text-entry mode is open", so the page asks
|
||||||
nothing about the mode. The colour stays semantic — `MinibufColor` crosses and
|
nothing about the mode. The colour stays semantic — `MinibufColor` crosses and
|
||||||
the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg`
|
the CSS class is the page's last translation. (The TUI no longer reads it: it
|
||||||
is the TUI's (gotcha 26). It is drawn as a surface of its own rather than *in
|
draws a face per text-entry mode, and improvise-nn4s.4 moves the page there.) It is drawn as a surface of its own rather than *in
|
||||||
place of* `#status` as the TUI's bottom bar does, because `#status` also
|
place of* `#status` as the TUI's bottom bar does, because `#status` also
|
||||||
carries the transport's failures and a repaint must not erase one (gotcha 48).
|
carries the transport's failures and a repaint must not erase one (gotcha 48).
|
||||||
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
|
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
|
||||||
@@ -1920,7 +1920,7 @@ home for it.
|
|||||||
The rows themselves are `improvise_protocol::help::pages()`, the same ones
|
The rows themselves are `improvise_protocol::help::pages()`, the same ones
|
||||||
`src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string
|
`src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string
|
||||||
(`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a
|
(`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a
|
||||||
CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can
|
CSS class — the `Face → draw.rs::face_style` shape again, and the reason the page can
|
||||||
style a heading without knowing what makes a line a heading. Where the content
|
style a heading without knowing what makes a line a heading. Where the content
|
||||||
comes from and what is derived from the keymap is `context/code/protocol.md` §8.
|
comes from and what is derived from the keymap is `context/code/protocol.md` §8.
|
||||||
|
|
||||||
@@ -2353,7 +2353,7 @@ truncation. A browser has no column to fit, so the abbreviation buys nothing and
|
|||||||
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
|
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
|
||||||
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
|
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
|
||||||
vocabulary already on `Axis`: the decision stays in the shared layer and only
|
vocabulary already on `Axis`: the decision stays in the shared layer and only
|
||||||
the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color`
|
the *choice* of vocabulary is local, exactly the `Face → ratatui::Style`
|
||||||
shape §5 blesses. `short_label` is untouched, because the TUI's width is
|
shape §5 blesses. `short_label` is untouched, because the TUI's width is
|
||||||
load-bearing.
|
load-bearing.
|
||||||
|
|
||||||
@@ -2439,8 +2439,8 @@ So it is now a raised bar: fixed, centred over the grid, larger, bordered in the
|
|||||||
mode's own colour and shadowed. **Every part of that is paint.** The content is
|
mode's own colour and shadowed. **Every part of that is paint.** The content is
|
||||||
`RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` —
|
`RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` —
|
||||||
the same semantic value the TUI reads — and the colour is `MinibufColor`
|
the same semantic value the TUI reads — and the colour is `MinibufColor`
|
||||||
resolved to a concrete colour exactly as `draw.rs::minibuf_fg` resolves it for
|
resolved to a concrete colour by `style.css`. (The TUI now draws a face per
|
||||||
the terminal. Nothing here asks which mode is open, which buffer is being typed
|
text-entry mode instead; improvise-nn4s.4 moves the page onto the same faces.) Nothing here asks which mode is open, which buffer is being typed
|
||||||
into, or what the prompt says; a second answer to any of those is the disease
|
into, or what the prompt says; a second answer to any of those is the disease
|
||||||
design-principles §5 is about. The caret is drawn as a CSS `::after` rather than
|
design-principles §5 is about. The caret is drawn as a CSS `::after` rather than
|
||||||
appended to the text for the same reason: the text is the host's, and the page
|
appended to the text for the same reason: the text is the host's, and the page
|
||||||
|
|||||||
@@ -359,12 +359,14 @@ browser symptom and silently break drill.
|
|||||||
`Axis` also owns four presentation vocabularies — `plain_label()`, `glyph()`
|
`Axis` also owns four presentation vocabularies — `plain_label()`, `glyph()`
|
||||||
(which `Display` appends, giving "Row ↕"), `short_label()` (three columns for
|
(which `Display` appends, giving "Row ↕"), `short_label()` (three columns for
|
||||||
the tile bar; `None` is "·", **not** a truncation of "None") and `initial()`
|
the tile bar; `None` is "·", **not** a truncation of "None") and `initial()`
|
||||||
(one letter for the view panel; `None` is empty) — plus `color() -> AxisColor`.
|
(one letter for the view panel; `None` is empty). Its **colour is no longer
|
||||||
`AxisColor` is a *semantic* palette because `improvise-core` must not name a
|
here**. It was `color() -> AxisColor` (improvise-rml), a semantic palette that
|
||||||
ratatui type; `draw.rs::axis_fg` does the one mapping, exactly as `minibuf_fg`
|
lived in core only because it was a method on a core type; ADR 004 clause 6
|
||||||
does for `MinibufColor` (improvise-rml). The tile bar and the category panel each
|
moved it to `improvise-protocol` as `Face::Axis(axis)`, because a face is UI and
|
||||||
carried a copy of the colour table before, with nothing keeping them in
|
this crate is the one that must have none. The TUI resolves it through
|
||||||
agreement.
|
`draw.rs::face_style` (`context/code/tui.md`). The tile bar and the category
|
||||||
|
panel each carried a copy of the colour table before improvise-rml, with nothing
|
||||||
|
keeping them in agreement, and they still share one: the face.
|
||||||
|
|
||||||
### `on_category_added`: a virtual on an axis is a configuration, not a vacancy
|
### `on_category_added`: a virtual on an axis is a configuration, not a vacancy
|
||||||
|
|
||||||
|
|||||||
@@ -1311,8 +1311,8 @@ re-derived from `kind`, so the two ends cannot disagree about what "Measure
|
|||||||
rather than re-exported, because this crate must not depend on `improvise-io`
|
rather than re-exported, because this crate must not depend on `improvise-io`
|
||||||
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
|
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
|
||||||
still be able to name the step it is on. `WizardFieldKind` is the semantic
|
still be able to name the step it is on. `WizardFieldKind` is the semantic
|
||||||
half of a colour decision — the terminal mapping lives in `draw.rs`, exactly
|
half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the
|
||||||
as `AxisColor`/`axis_fg` does (gotcha 26).
|
terminal mapping is `draw.rs::face_style`.
|
||||||
|
|
||||||
### `Projection` carries no multi-widget variant
|
### `Projection` carries no multi-widget variant
|
||||||
|
|
||||||
@@ -2374,7 +2374,7 @@ deserializes now carries no English at all.
|
|||||||
`Hint { keys, command, caption }` is the unit: `keys` come off
|
`Hint { keys, command, caption }` is the unit: `keys` come off
|
||||||
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
|
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
|
||||||
non-terminal front end keys off, and `caption` is the default English — exactly
|
non-terminal front end keys off, and `caption` is the default English — exactly
|
||||||
the `AxisColor`/`draw.rs::axis_fg` shape, with the decision shared and only the
|
the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the
|
||||||
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
|
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
|
||||||
|
|
||||||
### What that bought, and what it cost
|
### What that bought, and what it cost
|
||||||
@@ -2462,3 +2462,85 @@ the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
|
|||||||
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
|
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
|
||||||
~310 lines of content that is now shared. Neither end restates what the keys
|
~310 lines of content that is now shared. Neither end restates what the keys
|
||||||
are.
|
are.
|
||||||
|
|
||||||
|
## 10. `face.rs` — appearance named by role (ADR 004)
|
||||||
|
|
||||||
|
ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section
|
||||||
|
is where its first two migration steps landed and what they settled that the
|
||||||
|
ADR left open (improvise-nn4s.1, improvise-nn4s.2).
|
||||||
|
|
||||||
|
### What is here
|
||||||
|
|
||||||
|
- `Face`: one variant per role. Where a domain enum already exists the face is
|
||||||
|
**parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`,
|
||||||
|
`Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`,
|
||||||
|
`Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by
|
||||||
|
construction (clause 2). `Face::name` spells each one as a theme file keys it
|
||||||
|
(`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those
|
||||||
|
names are a compatibility surface once a theme can be downloaded (clause 9).
|
||||||
|
- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated
|
||||||
|
by the theme**. A theme that omits a face must still get the face's parent's
|
||||||
|
values ("a theme written today keeps working when a face is added tomorrow",
|
||||||
|
clause 9), and that is only true if the chain does not depend on what the
|
||||||
|
theme says.
|
||||||
|
- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`,
|
||||||
|
and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and
|
||||||
|
`Some(Colour::Default)` states "the surface" and stops inheritance. The two
|
||||||
|
are different answers and the type keeps them apart.
|
||||||
|
- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the
|
||||||
|
chain once and returns every attribute concrete. **No front end walks the
|
||||||
|
chain** (clause 3). `resolve` takes a face and nothing else.
|
||||||
|
- `Theme::shipped()`: today's terminal rendering, palette-relative throughout
|
||||||
|
(clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line.
|
||||||
|
- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both
|
||||||
|
exhaustive over `AppMode`, so a new mode fails the build here as it used to
|
||||||
|
in `draw.rs::mode_style` (improvise-zx8t).
|
||||||
|
|
||||||
|
### The JSON shape
|
||||||
|
|
||||||
|
Face names are the keys and a colour is spelled for a person: `"default"`, a
|
||||||
|
slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`),
|
||||||
|
`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not
|
||||||
|
ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown
|
||||||
|
name): a file the user edits by hand should say where it is wrong rather than
|
||||||
|
silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s
|
||||||
|
derived order, so a re-download after an edit is a reviewable diff (clause 9).
|
||||||
|
The protocol crate derives and the front ends encode, so `serde_json` stays a
|
||||||
|
dev-dependency here.
|
||||||
|
|
||||||
|
### Choices the ADR did not make, made here
|
||||||
|
|
||||||
|
- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and
|
||||||
|
the shipped theme pays for it.** The mode indicator is black text on an
|
||||||
|
accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under
|
||||||
|
this chain every `minibuffer.<entry>` in the shipped theme states all three of
|
||||||
|
its attributes and inherits none of them from its parent. A first version gave
|
||||||
|
them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a
|
||||||
|
published parent is compatibility behaviour, so a theme written to the
|
||||||
|
accepted ADR that restyles `mode.text-entry` would have silently missed every
|
||||||
|
prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the
|
||||||
|
accepted chain. Changing it is the user's call and belongs in the ADR, not in
|
||||||
|
this file.
|
||||||
|
- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition
|
||||||
|
covers the thirteen DarkGray modes and the text-entry chain, and does not
|
||||||
|
place `TileSelect`.
|
||||||
|
- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve
|
||||||
|
`fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is
|
||||||
|
not a no-op on a `TestBackend` buffer, and clause 8 names only one exception.
|
||||||
|
So it waits for improvise-nn4s.6 and a ruling.
|
||||||
|
- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they
|
||||||
|
are the browser's substitute for a terminal palette (clause 4), and the first
|
||||||
|
consumer is improvise-nn4s.5. Adding them then is a field with a serde
|
||||||
|
default, not a format break.
|
||||||
|
- **Only the faces some call site draws with exist.** The ADR's full vocabulary
|
||||||
|
(`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the
|
||||||
|
call sites that use it, because a face with no caller is a published name
|
||||||
|
with no evidence behind it.
|
||||||
|
|
||||||
|
### The category-add exception
|
||||||
|
|
||||||
|
`minibuffer.category-add` resolves to green in the shipped theme, where the old
|
||||||
|
literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it
|
||||||
|
is clause 8's one declared exception. `AppMode::category_add` now carries
|
||||||
|
`MinibufColor::Green` too, so the browser, which still reads the wire colour
|
||||||
|
until improvise-nn4s.4, agrees with the TUI rather than drifting from it.
|
||||||
|
|||||||
+38
-35
@@ -104,46 +104,49 @@ appear. Three rules:
|
|||||||
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
|
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
|
||||||
`META`) is dropped.
|
`META`) is dropped.
|
||||||
|
|
||||||
### Semantic-to-terminal colour, and the one that is wrong
|
### `face_style` — the one place a face becomes a terminal style
|
||||||
|
|
||||||
This file holds the mapping design-principles §5 names as the shape everything
|
ADR 004 replaced the four per-family maps this section used to describe
|
||||||
else should imitate: **the decision lives in the shared layer as a semantic
|
(`axis_fg`, `minibuf_fg`, `field_kind_fg`, `mode_style`) with one function:
|
||||||
enum, and only the last translation is local.**
|
`face_style(Face) -> Style` resolves the face against the shipped theme
|
||||||
|
(`Theme::resolve`, in `improvise-protocol`) and translates the result. That is
|
||||||
|
design-principles §5's shape with nothing left over: **the decision is shared,
|
||||||
|
and only the last translation is local** — `Colour` → `ratatui::Color` and the
|
||||||
|
six attributes → `Modifier`. The chain is never walked here (ADR 004 clause 3).
|
||||||
|
What the vocabulary is and why is `context/code/protocol.md` §10.
|
||||||
|
|
||||||
- `axis_fg(AxisColor) -> Color` — `Axis::color` is the single place each axis's
|
Three things to know.
|
||||||
accent is decided; this is the single place axis colouring is *rendered*
|
|
||||||
(improvise-rml, gotcha 26). Before it, the tile bar and the category panel
|
|
||||||
each carried their own copy of the colour table with nothing keeping them in
|
|
||||||
agreement.
|
|
||||||
- `minibuf_fg(MinibufColor) -> Color` — the same shape for the minibuffer.
|
|
||||||
- `src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color` — the
|
|
||||||
same shape again. `improvise-protocol` must not name a ratatui `Color`.
|
|
||||||
|
|
||||||
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
|
- **`Colour::Default` sets nothing.** It maps to "no `fg`/`bg` on the `Style`",
|
||||||
`Color` are ratatui types and `improvise-command`, where `AppMode` lives,
|
so the terminal's own colour shows through. That is also why a face with no
|
||||||
carries no rendering-framework dependency), and since `improvise-zx8t` it is
|
stated colour produces `Style::default()` and not a `Reset`.
|
||||||
**exhaustive**: the thirteen variants that take the default chrome are spelled
|
- **The slot names follow ANSI, not ratatui.** ANSI slot 7 is `White`, which
|
||||||
out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the
|
ratatui calls `Gray`; slot 8 is `BrightBlack`, ratatui's `DarkGray`; slot 15
|
||||||
build here until someone chooses its colour. The arithmetic is the check a
|
is `BrightWhite`, ratatui's `White`. `terminal_colour` is the one place that
|
||||||
prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` +
|
translation is written.
|
||||||
`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until
|
- **The shipped theme is resolved once**, into a `OnceLock`, because there is no
|
||||||
2026-09-22, and nothing could have caught that — a count written in prose is
|
theme picker yet (ADR 004 "Deferred"). When one arrives the theme becomes the
|
||||||
checked by no one.
|
TUI's own state rather than a static, and a theme is still never on `App`'s
|
||||||
|
`ViewState` (clause 7).
|
||||||
|
|
||||||
It is still not quite the same shape as `axis_fg` and its two siblings, and the
|
**The mode indicator is still exhaustive over `AppMode`**, now in
|
||||||
difference is deliberate rather than pending. Those three map a *semantic enum*
|
`Face::of_mode` rather than here: adding a variant fails the build there
|
||||||
the shared layer owns; `mode_style` matches `AppMode` itself, because there is
|
(improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants
|
||||||
no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here
|
are no longer one arm: they are the five faces ADR 004 derives
|
||||||
for two reasons: the colour is the only thing at stake, and the ticket says to
|
(`mode.resting`, `mode.text-entry`, `mode.panel.*`, `mode.overlay`,
|
||||||
decide it together with `improvise-k099`, which asks the same question of
|
`mode.transient`), which all still inherit the same colours from `mode`.
|
||||||
`hint_text`/`label` and is not in this unit. Exhaustiveness is what the
|
|
||||||
repo map's "the compiler names every one you must fill in" promise needed; a
|
|
||||||
semantic enum is a separate, larger answer and is still open.
|
|
||||||
|
|
||||||
**The guard is the compiler, not a test.** Nothing asserts the colours, and a
|
**No-op evidence.** `face_tests::the_shipped_theme_reproduces_every_migrated_literal`
|
||||||
test enumerating the variants would not fail when a fifteenth is added — only
|
holds the old literals as a table and asserts the shipped theme draws each one,
|
||||||
the missing match arm does. Verified by deleting the `AppMode::Help` arm:
|
which is clause 8's property. The one deliberate difference, the category-add
|
||||||
`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`.
|
prompt turning green, is asserted by
|
||||||
|
`the_category_add_prompt_draws_as_the_item_add_prompt_does` rather than being
|
||||||
|
left out of the table silently.
|
||||||
|
|
||||||
|
**Still literal**: every other `Color::` in `src/` (the selection sites, the
|
||||||
|
panels' accents, the grid's headers, help, the welcome popup). They move as
|
||||||
|
their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch
|
||||||
|
`src/ui/grid.rs` for this while improvise-felo.6 is in flight.
|
||||||
|
|
||||||
### The minibuffer shares the bottom row with the status line
|
### The minibuffer shares the bottom row with the status line
|
||||||
|
|
||||||
|
|||||||
@@ -269,8 +269,8 @@ why `doctrine(mirror_the_cmd_line_for_line)` is a rule about *resolvers* — the
|
|||||||
narrow, unavoidable residue — and never a licence to restate a table.
|
narrow, unavoidable residue — and never a licence to restate a table.
|
||||||
|
|
||||||
**What legitimately stays per-framework.** Painting, event wiring, and the
|
**What legitimately stays per-framework.** Painting, event wiring, and the
|
||||||
mapping from a semantic value to a framework type: `AxisColor → ratatui::Color`
|
mapping from a semantic value to a framework type: a `Face`, resolved by the
|
||||||
in `draw.rs::axis_fg`, `MinibufColor → minibuf_fg`. That mapping is the shape
|
shared `Theme`, becomes a `ratatui::Style` in `draw.rs::face_style` (ADR 004). That mapping is the shape
|
||||||
to imitate — the *decision* is in the shared layer as a semantic enum, and only
|
to imitate — the *decision* is in the shared layer as a semantic enum, and only
|
||||||
the last translation is local. Anything larger than a lookup table is a
|
the last translation is local. Anything larger than a lookup table is a
|
||||||
candidate for the store.
|
candidate for the store.
|
||||||
|
|||||||
@@ -710,11 +710,6 @@ Applying `next` four times is the identity from every starting point and no
|
|||||||
navigable axis is a fixed point, so no variant is stranded or skipped. Together
|
navigable axis is a fixed point, so no variant is stranded or skipped. Together
|
||||||
with the entry above this pins the rotation without enumerating it twice.
|
with the entry above this pins the rotation without enumerating it twice.
|
||||||
|
|
||||||
### every_axis_gets_its_own_color
|
|
||||||
|
|
||||||
Distinct axes must be distinguishable at a glance; `color()` is the only reason
|
|
||||||
`AxisColor` exists, so a collision defeats it.
|
|
||||||
|
|
||||||
### a_routing_formula_over_a_group_sums_the_members_answers
|
### a_routing_formula_over_a_group_sums_the_members_answers
|
||||||
|
|
||||||
improvise-4zl7, and the hand-computed half of its evidence. Every number in
|
improvise-4zl7, and the hand-computed half of its evidence. Every number in
|
||||||
|
|||||||
@@ -1260,6 +1260,46 @@ the Enter commit.
|
|||||||
`Enter`), run across the whole workspace: fails **only** this test, with
|
`Enter`), run across the whole workspace: fails **only** this test, with
|
||||||
`["Later"]` against `["Other"]`.
|
`["Later"]` against `["Other"]`.
|
||||||
|
|
||||||
|
### resolution_takes_each_attribute_from_the_nearest_face_that_states_it
|
||||||
|
|
||||||
|
ADR 004 clause 3: inheritance resolves once, in shared code. The fixture states
|
||||||
|
a different attribute at each of three levels — `default` (foreground White,
|
||||||
|
italic), `mode` (foreground Black, background Blue), and
|
||||||
|
`mode.text-entry.save` (background `Colour::Default`, underline) — so the one
|
||||||
|
resolved face has to take its foreground from the middle, its slant from the
|
||||||
|
root and its background from itself. `Some(Colour::Default)` is the case that
|
||||||
|
matters: it is a stated value and must not fall through to `mode`'s Blue, which
|
||||||
|
the sibling `mode.text-entry.export` (stating nothing) does inherit.
|
||||||
|
|
||||||
|
**Injected fault**: fold the chain so the parent wins. This test fails, and so
|
||||||
|
does the TUI's `the_shipped_theme_reproduces_every_migrated_literal`, because
|
||||||
|
`mode.text-entry.edit` then draws its parent's DarkGray.
|
||||||
|
|
||||||
|
### every_axis_gets_its_own_accent_in_the_shipped_theme
|
||||||
|
|
||||||
|
Moved from `improvise-core`'s `every_axis_gets_its_own_color` when `AxisColor`
|
||||||
|
left core (ADR 004 clause 6). Distinct axes must be distinguishable at a
|
||||||
|
glance, and the axis faces exist for exactly that, so two axes resolving alike
|
||||||
|
defeats them. It now asks the shipped theme rather than an enum's arms, which
|
||||||
|
is the stronger question: two distinct `AxisColor` arms could always have been
|
||||||
|
mapped to one terminal colour.
|
||||||
|
|
||||||
|
### a_theme_that_restyles_text_entry_reaches_every_minibuffer
|
||||||
|
|
||||||
|
ADR 004 says `minibuffer.*` inherits `mode.text-entry` directly. PR #319's first
|
||||||
|
head gave the minibuffer faces a `minibuffer` base face instead, and ChatGPT
|
||||||
|
Codex flagged it (P2): face parents are compatibility behaviour once a theme
|
||||||
|
can be downloaded, so a theme that restyles `mode.text-entry` must reach every
|
||||||
|
prompt. The test overrides only `mode.text-entry` with italic, an attribute no
|
||||||
|
minibuffer face states in the shipped theme, and asserts every
|
||||||
|
`minibuffer.<entry>` resolves italic. A test that sets a colour instead would
|
||||||
|
be vacuous, because each minibuffer face states its own colours.
|
||||||
|
|
||||||
|
**Failed first**, against the `minibuffer` base parent: `Edit` resolved to
|
||||||
|
`Normal`. It passes under the accepted chain. The no-op oracle
|
||||||
|
`the_shipped_theme_reproduces_every_migrated_literal` passed both before and
|
||||||
|
after the change, which shows the change moved no pixel.
|
||||||
|
|
||||||
### a_quarters_end_leaves_its_year_open_on_the_same_level
|
### a_quarters_end_leaves_its_year_open_on_the_same_level
|
||||||
|
|
||||||
improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct`
|
improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct`
|
||||||
|
|||||||
@@ -1430,6 +1430,39 @@ with a three-line item and a second item after it. It asserts the first item
|
|||||||
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
|
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
|
||||||
every item shown** fails it.
|
every item shown** fails it.
|
||||||
|
|
||||||
|
### the_category_add_prompt_draws_as_the_item_add_prompt_does
|
||||||
|
|
||||||
|
ADR 004 clause 8's one declared exception, decided by the user (Q1 on PR #122:
|
||||||
|
the green/yellow split between item-add and category-add is an accident). It
|
||||||
|
draws both prompts through the real `draw_bottom_bar` into a `TestBackend` and
|
||||||
|
compares the style of the prompt's first cell, after checking that cell really
|
||||||
|
holds the prompt's `n`.
|
||||||
|
|
||||||
|
**Failed first**, at `gh/main` `b639266b` before any face existed: `left:
|
||||||
|
...yellow().bg(Indexed(235)).bold()`, `right: ...green()...` — the category-add
|
||||||
|
prompt was yellow. **Injected fault** after the migration: set the shipped
|
||||||
|
theme's `minibuffer.category-add` back to `Named(Yellow)`; this test fails and
|
||||||
|
nothing else in the TUI does, which is the point of naming the exception in a
|
||||||
|
test of its own rather than leaving it out of the no-op table.
|
||||||
|
|
||||||
|
### the_shipped_theme_reproduces_every_migrated_literal
|
||||||
|
|
||||||
|
ADR 004 clause 8's property: migrating a call site onto a face is a no-op. The
|
||||||
|
oracle is the literal tables as they stood before the migration — axis
|
||||||
|
accents, wizard field kinds, `mode_style`'s exhaustive match (kept verbatim as
|
||||||
|
`mode_bg_before_faces`) and the minibuffer's accent-on-`Indexed(235)`-bold —
|
||||||
|
and every one is compared with `face_style` of its face. Category-add is left
|
||||||
|
out on purpose (the entry above). It was first written against the old
|
||||||
|
functions themselves and passed, then the functions were deleted and their
|
||||||
|
tables moved into the test.
|
||||||
|
|
||||||
|
**Injected faults**, each failing here by the case name in the message:
|
||||||
|
`axis.column` changed from Blue to Cyan (fails at `Column`); `face_style` made
|
||||||
|
to add `BOLD` for `Weight::Normal` instead of `Bold`; `Theme::resolve` folded so
|
||||||
|
that the parent wins over the child (fails at `Editing`, which draws DarkGray
|
||||||
|
instead of its own Green); and `Face::of_mode(TileSelect)` returning
|
||||||
|
`mode.resting` (also caught by `the_status_line_is_drawn_in_the_mode_face`).
|
||||||
|
|
||||||
### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows
|
### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows
|
||||||
|
|
||||||
improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer
|
improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Axis {
|
pub enum Axis {
|
||||||
Row,
|
Row,
|
||||||
@@ -12,17 +12,6 @@ pub enum Axis {
|
|||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accent color for an axis. Semantic palette only; `draw.rs::axis_fg` maps
|
|
||||||
/// each variant to a terminal color, so `Axis` names no ratatui type.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AxisColor {
|
|
||||||
Row,
|
|
||||||
Column,
|
|
||||||
Page,
|
|
||||||
Filter,
|
|
||||||
Inactive,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Axis {
|
impl Axis {
|
||||||
/// The next axis in the Row → Column → Page → None → Row rotation.
|
/// The next axis in the Row → Column → Page → None → Row rotation.
|
||||||
/// `Filter` is outside it and cycling one is a no-op (improvise-dz8).
|
/// `Filter` is outside it and cycling one is a no-op (improvise-dz8).
|
||||||
@@ -48,18 +37,6 @@ impl Axis {
|
|||||||
!matches!(self, Axis::Filter)
|
!matches!(self, Axis::Filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accent color. The single source for axis coloring: the tile bar and the
|
|
||||||
/// category panel each carried a copy before (improvise-rml).
|
|
||||||
pub fn color(self) -> AxisColor {
|
|
||||||
match self {
|
|
||||||
Axis::Row => AxisColor::Row,
|
|
||||||
Axis::Column => AxisColor::Column,
|
|
||||||
Axis::Page => AxisColor::Page,
|
|
||||||
Axis::Filter => AxisColor::Filter,
|
|
||||||
Axis::None => AxisColor::Inactive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Name with no decoration: "Row", "Col", "Page", "None".
|
/// Name with no decoration: "Row", "Col", "Page", "None".
|
||||||
pub fn plain_label(self) -> &'static str {
|
pub fn plain_label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -176,15 +153,4 @@ mod tests {
|
|||||||
assert!(!Axis::Column.filters_data());
|
assert!(!Axis::Column.filters_data());
|
||||||
assert!(!Axis::None.filters_data());
|
assert!(!Axis::None.filters_data());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A collision defeats the only reason `AxisColor` exists.
|
|
||||||
#[test]
|
|
||||||
fn every_axis_gets_its_own_color() {
|
|
||||||
let colors = ALL.map(Axis::color);
|
|
||||||
for (i, a) in colors.iter().enumerate() {
|
|
||||||
for b in &colors[i + 1..] {
|
|
||||||
assert_ne!(a, b, "two axes share an accent color");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ pub mod axis;
|
|||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use axis::{Axis, AxisColor};
|
pub use axis::Axis;
|
||||||
pub use layout::{
|
pub use layout::{
|
||||||
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
|
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
|
||||||
ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine,
|
ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine,
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
//! Faces: appearance named by role, and a theme that binds roles to values
|
||||||
|
//! (ADR 004). The reasoning is `context/code/protocol.md` §10.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::de::{self, Deserializer, Visitor};
|
||||||
|
use serde::{Deserialize, Serialize, Serializer};
|
||||||
|
|
||||||
|
use crate::render::WizardFieldKind;
|
||||||
|
use crate::{AppMode, Panel};
|
||||||
|
use improvise_core::view::Axis;
|
||||||
|
|
||||||
|
/// One of the sixteen palette slots: "whatever THIS display calls that slot".
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum AnsiSlot {
|
||||||
|
Black,
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Yellow,
|
||||||
|
Blue,
|
||||||
|
Magenta,
|
||||||
|
Cyan,
|
||||||
|
White,
|
||||||
|
BrightBlack,
|
||||||
|
BrightRed,
|
||||||
|
BrightGreen,
|
||||||
|
BrightYellow,
|
||||||
|
BrightBlue,
|
||||||
|
BrightMagenta,
|
||||||
|
BrightCyan,
|
||||||
|
BrightWhite,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiSlot {
|
||||||
|
pub const ALL: [AnsiSlot; 16] = [
|
||||||
|
AnsiSlot::Black,
|
||||||
|
AnsiSlot::Red,
|
||||||
|
AnsiSlot::Green,
|
||||||
|
AnsiSlot::Yellow,
|
||||||
|
AnsiSlot::Blue,
|
||||||
|
AnsiSlot::Magenta,
|
||||||
|
AnsiSlot::Cyan,
|
||||||
|
AnsiSlot::White,
|
||||||
|
AnsiSlot::BrightBlack,
|
||||||
|
AnsiSlot::BrightRed,
|
||||||
|
AnsiSlot::BrightGreen,
|
||||||
|
AnsiSlot::BrightYellow,
|
||||||
|
AnsiSlot::BrightBlue,
|
||||||
|
AnsiSlot::BrightMagenta,
|
||||||
|
AnsiSlot::BrightCyan,
|
||||||
|
AnsiSlot::BrightWhite,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The slot's number in the 256-colour palette, 0–15.
|
||||||
|
pub fn index(self) -> u8 {
|
||||||
|
self as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spelling a theme file uses.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AnsiSlot::Black => "black",
|
||||||
|
AnsiSlot::Red => "red",
|
||||||
|
AnsiSlot::Green => "green",
|
||||||
|
AnsiSlot::Yellow => "yellow",
|
||||||
|
AnsiSlot::Blue => "blue",
|
||||||
|
AnsiSlot::Magenta => "magenta",
|
||||||
|
AnsiSlot::Cyan => "cyan",
|
||||||
|
AnsiSlot::White => "white",
|
||||||
|
AnsiSlot::BrightBlack => "bright-black",
|
||||||
|
AnsiSlot::BrightRed => "bright-red",
|
||||||
|
AnsiSlot::BrightGreen => "bright-green",
|
||||||
|
AnsiSlot::BrightYellow => "bright-yellow",
|
||||||
|
AnsiSlot::BrightBlue => "bright-blue",
|
||||||
|
AnsiSlot::BrightMagenta => "bright-magenta",
|
||||||
|
AnsiSlot::BrightCyan => "bright-cyan",
|
||||||
|
AnsiSlot::BrightWhite => "bright-white",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|slot| slot.name() == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A colour attribute. `Default`, `Named` and `Indexed` are palette-relative;
|
||||||
|
/// `Rgb` is absolute, and the shipped theme never uses it (ADR 004 clause 4).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Colour {
|
||||||
|
Default,
|
||||||
|
Named(AnsiSlot),
|
||||||
|
Indexed(u8),
|
||||||
|
Rgb(u8, u8, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Colour {
|
||||||
|
/// True for the one arm that opts a viewer out of their own palette.
|
||||||
|
pub fn is_absolute(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Colour::Rgb(..) => true,
|
||||||
|
Colour::Default | Colour::Named(_) | Colour::Indexed(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Colour {
|
||||||
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
match *self {
|
||||||
|
Colour::Default => s.serialize_str("default"),
|
||||||
|
Colour::Named(slot) => s.serialize_str(slot.name()),
|
||||||
|
Colour::Indexed(n) => s.serialize_u8(n),
|
||||||
|
Colour::Rgb(r, g, b) => s.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ColourVisitor;
|
||||||
|
|
||||||
|
impl Visitor<'_> for ColourVisitor {
|
||||||
|
type Value = Colour;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
f.write_str("\"default\", a slot name such as \"green\", 0-255, or \"#rrggbb\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E: de::Error>(self, n: u64) -> Result<Colour, E> {
|
||||||
|
u8::try_from(n)
|
||||||
|
.map(Colour::Indexed)
|
||||||
|
.map_err(|_| E::custom(format!("colour index {n} is outside 0-255")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, s: &str) -> Result<Colour, E> {
|
||||||
|
parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_colour(s: &str) -> Option<Colour> {
|
||||||
|
match s.strip_prefix('#') {
|
||||||
|
Some(hex) if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) => {
|
||||||
|
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||||
|
Some(Colour::Rgb(byte(0)?, byte(2)?, byte(4)?))
|
||||||
|
}
|
||||||
|
Some(_) => None,
|
||||||
|
None if s == "default" => Some(Colour::Default),
|
||||||
|
None => AnsiSlot::from_name(s).map(Colour::Named),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Colour {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
d.deserialize_any(ColourVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Weight {
|
||||||
|
Normal,
|
||||||
|
Bold,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Slant {
|
||||||
|
Normal,
|
||||||
|
Italic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text-entry modes, one per `MinibufferConfig`-carrying `AppMode` arm
|
||||||
|
/// except that cell editing is one role over two layouts.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum TextEntry {
|
||||||
|
Edit,
|
||||||
|
Formula,
|
||||||
|
Command,
|
||||||
|
CategoryAdd,
|
||||||
|
ItemAdd,
|
||||||
|
Export,
|
||||||
|
Save,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEntry {
|
||||||
|
pub const ALL: [TextEntry; 7] = [
|
||||||
|
TextEntry::Edit,
|
||||||
|
TextEntry::Formula,
|
||||||
|
TextEntry::Command,
|
||||||
|
TextEntry::CategoryAdd,
|
||||||
|
TextEntry::ItemAdd,
|
||||||
|
TextEntry::Export,
|
||||||
|
TextEntry::Save,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The text-entry role of `mode`, or `None` for a mode that takes no text.
|
||||||
|
pub fn of(mode: &AppMode) -> Option<Self> {
|
||||||
|
match mode {
|
||||||
|
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Some(TextEntry::Edit),
|
||||||
|
AppMode::FormulaEdit { .. } => Some(TextEntry::Formula),
|
||||||
|
AppMode::CommandMode { .. } => Some(TextEntry::Command),
|
||||||
|
AppMode::CategoryAdd { .. } => Some(TextEntry::CategoryAdd),
|
||||||
|
AppMode::ItemAdd { .. } => Some(TextEntry::ItemAdd),
|
||||||
|
AppMode::ExportPrompt { .. } => Some(TextEntry::Export),
|
||||||
|
AppMode::SavePrompt { .. } => Some(TextEntry::Save),
|
||||||
|
AppMode::Normal
|
||||||
|
| AppMode::RecordsNormal
|
||||||
|
| AppMode::FormulaPanel
|
||||||
|
| AppMode::CategoryPanel
|
||||||
|
| AppMode::ViewPanel
|
||||||
|
| AppMode::TileSelect
|
||||||
|
| AppMode::ImportWizard
|
||||||
|
| AppMode::Help
|
||||||
|
| AppMode::Quit => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TextEntry::Edit => "edit",
|
||||||
|
TextEntry::Formula => "formula",
|
||||||
|
TextEntry::Command => "command",
|
||||||
|
TextEntry::CategoryAdd => "category-add",
|
||||||
|
TextEntry::ItemAdd => "item-add",
|
||||||
|
TextEntry::Export => "export",
|
||||||
|
TextEntry::Save => "save",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AXES: [Axis; 5] = [
|
||||||
|
Axis::Row,
|
||||||
|
Axis::Column,
|
||||||
|
Axis::Page,
|
||||||
|
Axis::Filter,
|
||||||
|
Axis::None,
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELD_KINDS: [WizardFieldKind; 4] = [
|
||||||
|
WizardFieldKind::Category,
|
||||||
|
WizardFieldKind::Measure,
|
||||||
|
WizardFieldKind::TimeCategory,
|
||||||
|
WizardFieldKind::Label,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn axis_name(axis: Axis) -> &'static str {
|
||||||
|
match axis {
|
||||||
|
Axis::Row => "row",
|
||||||
|
Axis::Column => "column",
|
||||||
|
Axis::Page => "page",
|
||||||
|
Axis::Filter => "filter",
|
||||||
|
Axis::None => "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_kind_name(kind: WizardFieldKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
WizardFieldKind::Category => "category",
|
||||||
|
WizardFieldKind::Measure => "measure",
|
||||||
|
WizardFieldKind::TimeCategory => "time",
|
||||||
|
WizardFieldKind::Label => "label",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A role something is drawn in. Its `name` is a compatibility surface once a
|
||||||
|
/// theme file carries it (ADR 004 clause 9), so never rename one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum Face {
|
||||||
|
Default,
|
||||||
|
Dim,
|
||||||
|
Axis(Axis),
|
||||||
|
WizardField(WizardFieldKind),
|
||||||
|
Mode,
|
||||||
|
ModeResting,
|
||||||
|
ModeTextEntry,
|
||||||
|
ModeTextEntryOf(TextEntry),
|
||||||
|
ModePanel(Panel),
|
||||||
|
ModeOverlay,
|
||||||
|
ModeTransient,
|
||||||
|
ModeTileSelect,
|
||||||
|
MinibufferOf(TextEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Face {
|
||||||
|
/// Every face, parents before children.
|
||||||
|
pub fn all() -> Vec<Face> {
|
||||||
|
let mut out = vec![Face::Default, Face::Dim];
|
||||||
|
out.extend(AXES.map(Face::Axis));
|
||||||
|
out.extend(FIELD_KINDS.map(Face::WizardField));
|
||||||
|
out.extend([Face::Mode, Face::ModeResting, Face::ModeTextEntry]);
|
||||||
|
out.extend(TextEntry::ALL.map(Face::ModeTextEntryOf));
|
||||||
|
out.extend(Panel::ALL.map(Face::ModePanel));
|
||||||
|
out.extend([Face::ModeOverlay, Face::ModeTransient, Face::ModeTileSelect]);
|
||||||
|
out.extend(TextEntry::ALL.map(Face::MinibufferOf));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face this one inherits every attribute it does not state from.
|
||||||
|
pub fn parent(self) -> Option<Face> {
|
||||||
|
match self {
|
||||||
|
Face::Default => None,
|
||||||
|
Face::Dim | Face::Mode => Some(Face::Default),
|
||||||
|
Face::Axis(Axis::None) | Face::WizardField(WizardFieldKind::Label) => Some(Face::Dim),
|
||||||
|
Face::Axis(_) | Face::WizardField(_) => Some(Face::Default),
|
||||||
|
Face::ModeResting
|
||||||
|
| Face::ModeTextEntry
|
||||||
|
| Face::ModePanel(_)
|
||||||
|
| Face::ModeOverlay
|
||||||
|
| Face::ModeTransient
|
||||||
|
| Face::ModeTileSelect => Some(Face::Mode),
|
||||||
|
Face::ModeTextEntryOf(_) | Face::MinibufferOf(_) => Some(Face::ModeTextEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spelling a theme file keys this face by.
|
||||||
|
pub fn name(self) -> String {
|
||||||
|
match self {
|
||||||
|
Face::Default => "default".into(),
|
||||||
|
Face::Dim => "dim".into(),
|
||||||
|
Face::Axis(axis) => format!("axis.{}", axis_name(axis)),
|
||||||
|
Face::WizardField(kind) => format!("wizard.field.{}", field_kind_name(kind)),
|
||||||
|
Face::Mode => "mode".into(),
|
||||||
|
Face::ModeResting => "mode.resting".into(),
|
||||||
|
Face::ModeTextEntry => "mode.text-entry".into(),
|
||||||
|
Face::ModeTextEntryOf(entry) => format!("mode.text-entry.{}", entry.name()),
|
||||||
|
Face::ModePanel(panel) => format!("mode.panel.{}", panel.name()),
|
||||||
|
Face::ModeOverlay => "mode.overlay".into(),
|
||||||
|
Face::ModeTransient => "mode.transient".into(),
|
||||||
|
Face::ModeTileSelect => "mode.tile-select".into(),
|
||||||
|
Face::MinibufferOf(entry) => format!("minibuffer.{}", entry.name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_name(name: &str) -> Option<Face> {
|
||||||
|
Face::all().into_iter().find(|face| face.name() == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face the mode indicator is drawn in.
|
||||||
|
pub fn of_mode(mode: &AppMode) -> Face {
|
||||||
|
match mode {
|
||||||
|
AppMode::Normal | AppMode::RecordsNormal => Face::ModeResting,
|
||||||
|
AppMode::FormulaPanel => Face::ModePanel(Panel::Formula),
|
||||||
|
AppMode::CategoryPanel => Face::ModePanel(Panel::Category),
|
||||||
|
AppMode::ViewPanel => Face::ModePanel(Panel::View),
|
||||||
|
AppMode::ImportWizard | AppMode::Help => Face::ModeOverlay,
|
||||||
|
AppMode::Quit => Face::ModeTransient,
|
||||||
|
AppMode::TileSelect => Face::ModeTileSelect,
|
||||||
|
AppMode::Editing { .. }
|
||||||
|
| AppMode::RecordsEditing { .. }
|
||||||
|
| AppMode::FormulaEdit { .. }
|
||||||
|
| AppMode::CommandMode { .. }
|
||||||
|
| AppMode::CategoryAdd { .. }
|
||||||
|
| AppMode::ItemAdd { .. }
|
||||||
|
| AppMode::ExportPrompt { .. }
|
||||||
|
| AppMode::SavePrompt { .. } => TextEntry::of(mode)
|
||||||
|
.map(Face::ModeTextEntryOf)
|
||||||
|
.unwrap_or(Face::ModeTextEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The face a text-entry mode's minibuffer is drawn in.
|
||||||
|
pub fn of_minibuffer(mode: &AppMode) -> Option<Face> {
|
||||||
|
TextEntry::of(mode).map(Face::MinibufferOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Face {
|
||||||
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(&self.name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Face {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let name = String::deserialize(d)?;
|
||||||
|
Face::from_name(&name).ok_or_else(|| de::Error::custom(format!("unknown face {name:?}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a theme states about one face. `None` inherits from the parent;
|
||||||
|
/// `Some(Colour::Default)` states "the surface" and does not inherit.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct FaceSpec {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub foreground: Option<Colour>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub background: Option<Colour>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub weight: Option<Weight>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slant: Option<Slant>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub underline: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reverse: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FaceSpec {
|
||||||
|
fn or(self, parent: FaceSpec) -> FaceSpec {
|
||||||
|
FaceSpec {
|
||||||
|
foreground: self.foreground.or(parent.foreground),
|
||||||
|
background: self.background.or(parent.background),
|
||||||
|
weight: self.weight.or(parent.weight),
|
||||||
|
slant: self.slant.or(parent.slant),
|
||||||
|
underline: self.underline.or(parent.underline),
|
||||||
|
reverse: self.reverse.or(parent.reverse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fg(colour: Colour) -> FaceSpec {
|
||||||
|
FaceSpec {
|
||||||
|
foreground: Some(colour),
|
||||||
|
..FaceSpec::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bg(colour: Colour) -> FaceSpec {
|
||||||
|
FaceSpec {
|
||||||
|
background: Some(colour),
|
||||||
|
..FaceSpec::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A face with every attribute concrete: what a front end translates.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ResolvedFace {
|
||||||
|
pub foreground: Colour,
|
||||||
|
pub background: Colour,
|
||||||
|
pub weight: Weight,
|
||||||
|
pub slant: Slant,
|
||||||
|
pub underline: bool,
|
||||||
|
pub reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faces bound to values. Client-local: never a `Command`, never in
|
||||||
|
/// `ViewState`, never projected, never in `.improv` (ADR 004 clause 7).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub faces: BTreeMap<Face, FaceSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
/// The theme improvise ships: today's terminal rendering, palette-relative
|
||||||
|
/// throughout, so the user's terminal theme still applies.
|
||||||
|
pub fn shipped() -> Theme {
|
||||||
|
use AnsiSlot::*;
|
||||||
|
let named = Colour::Named;
|
||||||
|
let minibuffer = |accent| FaceSpec {
|
||||||
|
foreground: Some(named(accent)),
|
||||||
|
background: Some(Colour::Indexed(235)),
|
||||||
|
weight: Some(Weight::Bold),
|
||||||
|
..FaceSpec::default()
|
||||||
|
};
|
||||||
|
let faces = [
|
||||||
|
(Face::Dim, FaceSpec::fg(named(BrightBlack))),
|
||||||
|
(Face::Axis(Axis::Row), FaceSpec::fg(named(Green))),
|
||||||
|
(Face::Axis(Axis::Column), FaceSpec::fg(named(Blue))),
|
||||||
|
(Face::Axis(Axis::Page), FaceSpec::fg(named(Magenta))),
|
||||||
|
(Face::Axis(Axis::Filter), FaceSpec::fg(named(Yellow))),
|
||||||
|
(
|
||||||
|
Face::WizardField(WizardFieldKind::Category),
|
||||||
|
FaceSpec::fg(named(Green)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::WizardField(WizardFieldKind::Measure),
|
||||||
|
FaceSpec::fg(named(Cyan)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::WizardField(WizardFieldKind::TimeCategory),
|
||||||
|
FaceSpec::fg(named(Magenta)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::Mode,
|
||||||
|
FaceSpec {
|
||||||
|
foreground: Some(named(Black)),
|
||||||
|
background: Some(named(BrightBlack)),
|
||||||
|
..FaceSpec::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::ModeTextEntryOf(TextEntry::Edit),
|
||||||
|
FaceSpec::bg(named(Green)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::ModeTextEntryOf(TextEntry::Command),
|
||||||
|
FaceSpec::bg(named(Yellow)),
|
||||||
|
),
|
||||||
|
(Face::ModeTileSelect, FaceSpec::bg(named(Magenta))),
|
||||||
|
(Face::MinibufferOf(TextEntry::Edit), minibuffer(Green)),
|
||||||
|
(Face::MinibufferOf(TextEntry::ItemAdd), minibuffer(Green)),
|
||||||
|
(
|
||||||
|
Face::MinibufferOf(TextEntry::CategoryAdd),
|
||||||
|
minibuffer(Green),
|
||||||
|
),
|
||||||
|
(Face::MinibufferOf(TextEntry::Formula), minibuffer(Cyan)),
|
||||||
|
(Face::MinibufferOf(TextEntry::Command), minibuffer(Yellow)),
|
||||||
|
(Face::MinibufferOf(TextEntry::Export), minibuffer(Yellow)),
|
||||||
|
(Face::MinibufferOf(TextEntry::Save), minibuffer(Yellow)),
|
||||||
|
];
|
||||||
|
Theme {
|
||||||
|
faces: faces.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `face`'s inheritance chain once, so no front end ever does.
|
||||||
|
pub fn resolve(&self, face: Face) -> ResolvedFace {
|
||||||
|
let stated = std::iter::successors(Some(face), |f| f.parent())
|
||||||
|
.filter_map(|f| self.faces.get(&f).copied())
|
||||||
|
.fold(FaceSpec::default(), FaceSpec::or);
|
||||||
|
ResolvedFace {
|
||||||
|
foreground: stated.foreground.unwrap_or(Colour::Default),
|
||||||
|
background: stated.background.unwrap_or(Colour::Default),
|
||||||
|
weight: stated.weight.unwrap_or(Weight::Normal),
|
||||||
|
slant: stated.slant.unwrap_or(Slant::Normal),
|
||||||
|
underline: stated.underline.unwrap_or(false),
|
||||||
|
reverse: stated.reverse.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_face_name_is_distinct_and_reads_back_as_that_face() {
|
||||||
|
let all = Face::all();
|
||||||
|
for face in &all {
|
||||||
|
assert_eq!(Face::from_name(&face.name()), Some(*face), "{face:?}");
|
||||||
|
}
|
||||||
|
let mut names: Vec<String> = all.iter().map(|f| f.name()).collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
assert_eq!(names.len(), all.len(), "two faces share a name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_parent_is_a_face_listed_before_its_child() {
|
||||||
|
let all = Face::all();
|
||||||
|
for (i, face) in all.iter().enumerate() {
|
||||||
|
if let Some(parent) = face.parent() {
|
||||||
|
let at = all.iter().position(|f| *f == parent);
|
||||||
|
assert!(at.is_some_and(|p| p < i), "{face:?}'s parent {parent:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR 004 clause 4: the shipped theme keeps the user's terminal palette.
|
||||||
|
#[test]
|
||||||
|
fn the_shipped_theme_states_no_absolute_colour() {
|
||||||
|
let theme = Theme::shipped();
|
||||||
|
for face in Face::all() {
|
||||||
|
let resolved = theme.resolve(face);
|
||||||
|
assert!(!resolved.foreground.is_absolute(), "{}", face.name());
|
||||||
|
assert!(!resolved.background.is_absolute(), "{}", face.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR 004: `minibuffer.*` inherits `mode.text-entry` directly, so a theme
|
||||||
|
/// that restyles text entry reaches every prompt. `context/test-evidence/protocol.md`.
|
||||||
|
#[test]
|
||||||
|
fn a_theme_that_restyles_text_entry_reaches_every_minibuffer() {
|
||||||
|
let mut theme = Theme::shipped();
|
||||||
|
let italic = FaceSpec {
|
||||||
|
slant: Some(Slant::Italic),
|
||||||
|
..FaceSpec::default()
|
||||||
|
};
|
||||||
|
theme.faces.insert(Face::ModeTextEntry, italic);
|
||||||
|
for entry in TextEntry::ALL {
|
||||||
|
let got = theme.resolve(Face::MinibufferOf(entry));
|
||||||
|
assert_eq!(got.slant, Slant::Italic, "{entry:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collision defeats the reason the axis faces exist (improvise-rml).
|
||||||
|
#[test]
|
||||||
|
fn every_axis_gets_its_own_accent_in_the_shipped_theme() {
|
||||||
|
let theme = Theme::shipped();
|
||||||
|
let accents = AXES.map(|axis| theme.resolve(Face::Axis(axis)));
|
||||||
|
for (i, a) in accents.iter().enumerate() {
|
||||||
|
for b in &accents[i + 1..] {
|
||||||
|
assert_ne!(a, b, "two axes share an accent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child overrides what it states and inherits what it does not, from
|
||||||
|
/// the nearest ancestor that states it — `context/test-evidence/protocol.md`.
|
||||||
|
#[test]
|
||||||
|
fn resolution_takes_each_attribute_from_the_nearest_face_that_states_it() {
|
||||||
|
let named = Colour::Named;
|
||||||
|
let theme = Theme {
|
||||||
|
faces: [
|
||||||
|
(
|
||||||
|
Face::Default,
|
||||||
|
FaceSpec {
|
||||||
|
foreground: Some(named(AnsiSlot::White)),
|
||||||
|
slant: Some(Slant::Italic),
|
||||||
|
..FaceSpec::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::Mode,
|
||||||
|
FaceSpec {
|
||||||
|
foreground: Some(named(AnsiSlot::Black)),
|
||||||
|
background: Some(named(AnsiSlot::Blue)),
|
||||||
|
..FaceSpec::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Face::ModeTextEntryOf(TextEntry::Save),
|
||||||
|
FaceSpec {
|
||||||
|
background: Some(Colour::Default),
|
||||||
|
underline: Some(true),
|
||||||
|
..FaceSpec::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let got = theme.resolve(Face::ModeTextEntryOf(TextEntry::Save));
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
ResolvedFace {
|
||||||
|
foreground: named(AnsiSlot::Black),
|
||||||
|
background: Colour::Default,
|
||||||
|
weight: Weight::Normal,
|
||||||
|
slant: Slant::Italic,
|
||||||
|
underline: true,
|
||||||
|
reverse: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let sibling = theme.resolve(Face::ModeTextEntryOf(TextEntry::Export));
|
||||||
|
assert_eq!(sibling.background, named(AnsiSlot::Blue));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_theme_survives_its_own_json() {
|
||||||
|
let theme = Theme::shipped();
|
||||||
|
let text = serde_json::to_string_pretty(&theme).unwrap();
|
||||||
|
assert!(text.contains("\"minibuffer.category-add\""), "{text}");
|
||||||
|
assert_eq!(serde_json::from_str::<Theme>(&text).unwrap(), theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_colour_is_spelled_for_a_person_editing_the_file() {
|
||||||
|
let cases = [
|
||||||
|
(Colour::Default, "\"default\""),
|
||||||
|
(Colour::Named(AnsiSlot::BrightBlack), "\"bright-black\""),
|
||||||
|
(Colour::Indexed(235), "235"),
|
||||||
|
(Colour::Rgb(0x3f, 0x7a, 0x4d), "\"#3f7a4d\""),
|
||||||
|
];
|
||||||
|
for (colour, json) in cases {
|
||||||
|
assert_eq!(serde_json::to_string(&colour).unwrap(), json);
|
||||||
|
assert_eq!(serde_json::from_str::<Colour>(json).unwrap(), colour);
|
||||||
|
}
|
||||||
|
for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] {
|
||||||
|
assert!(serde_json::from_str::<Colour>(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_misspelt_face_or_attribute_is_refused_rather_than_ignored() {
|
||||||
|
let face = r#"{"faces":{"axis.rwo":{"foreground":"green"}}}"#;
|
||||||
|
assert!(serde_json::from_str::<Theme>(face).is_err());
|
||||||
|
let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#;
|
||||||
|
assert!(serde_json::from_str::<Theme>(attr).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_text_entry_mode_has_a_text_entry_role_and_no_other_mode_does() {
|
||||||
|
for mode in AppMode::all() {
|
||||||
|
assert_eq!(
|
||||||
|
TextEntry::of(&mode).is_some(),
|
||||||
|
mode.minibuffer().is_some(),
|
||||||
|
"{mode:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
pub mod basis;
|
pub mod basis;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod face;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod key;
|
pub mod key;
|
||||||
@@ -23,6 +24,7 @@ mod vocabulary_is_composed;
|
|||||||
|
|
||||||
pub use basis::{Basis, BasisCache, Rejection};
|
pub use basis::{Basis, BasisCache, Rejection};
|
||||||
pub use client::{ClientState, Sent};
|
pub use client::{ClientState, Sent};
|
||||||
|
pub use face::{Face, Theme};
|
||||||
pub use frame::{ClientFrame, ServerFrame};
|
pub use frame::{ClientFrame, ServerFrame};
|
||||||
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
||||||
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
@@ -103,7 +105,7 @@ pub struct MinibufferConfig {
|
|||||||
|
|
||||||
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
||||||
/// the panel a mode is focused in.
|
/// the panel a mode is focused in.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub enum Panel {
|
pub enum Panel {
|
||||||
Formula,
|
Formula,
|
||||||
Category,
|
Category,
|
||||||
@@ -394,7 +396,7 @@ impl AppMode {
|
|||||||
minibuf: MinibufferConfig {
|
minibuf: MinibufferConfig {
|
||||||
buffer_key: "category".into(),
|
buffer_key: "category".into(),
|
||||||
prompt: "new category: ".into(),
|
prompt: "new category: ".into(),
|
||||||
color: MinibufColor::Yellow,
|
color: MinibufColor::Green,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -933,7 +933,7 @@ pub enum WizardStepKind {
|
|||||||
|
|
||||||
/// What kind of thing a proposed field is. The semantic half of a colour
|
/// What kind of thing a proposed field is. The semantic half of a colour
|
||||||
/// decision; the terminal mapping lives in `draw.rs` (gotcha 26).
|
/// decision; the terminal mapping lives in `draw.rs` (gotcha 26).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub enum WizardFieldKind {
|
pub enum WizardFieldKind {
|
||||||
Category,
|
Category,
|
||||||
Measure,
|
Measure,
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ pub struct RenderModel {
|
|||||||
pub struct RenderMinibuffer {
|
pub struct RenderMinibuffer {
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
|
/// A colour, not a role: the TUI now draws a face per text-entry mode, and
|
||||||
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
|
/// improvise-nn4s.4 moves the page onto the same faces (ADR 004).
|
||||||
pub color: improvise_protocol::MinibufColor,
|
pub color: improvise_protocol::MinibufColor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+180
-48
@@ -1,5 +1,6 @@
|
|||||||
use std::io::{self, Stdout};
|
use std::io::{self, Stdout};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -18,7 +19,8 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::command::key;
|
use crate::command::key;
|
||||||
use crate::protocol::EditorSession;
|
use crate::protocol::EditorSession;
|
||||||
use crate::ui::app::{App, AppMode, MinibufColor};
|
use crate::protocol::face::{AnsiSlot, Colour, Face, Slant, Theme, Weight};
|
||||||
|
use crate::ui::app::{App, AppMode};
|
||||||
use crate::ui::category_panel::CategoryContent;
|
use crate::ui::category_panel::CategoryContent;
|
||||||
use crate::ui::formula_panel::FormulaContent;
|
use crate::ui::formula_panel::FormulaContent;
|
||||||
use crate::ui::grid::GridWidget;
|
use crate::ui::grid::GridWidget;
|
||||||
@@ -29,7 +31,6 @@ use crate::ui::text::truncate;
|
|||||||
use crate::ui::tile_bar::TileBar;
|
use crate::ui::tile_bar::TileBar;
|
||||||
use crate::ui::view_panel::ViewContent;
|
use crate::ui::view_panel::ViewContent;
|
||||||
use crate::ui::which_key::WhichKeyWidget;
|
use crate::ui::which_key::WhichKeyWidget;
|
||||||
use crate::view::AxisColor;
|
|
||||||
use improvise_command::ui::project;
|
use improvise_command::ui::project;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
@@ -165,24 +166,54 @@ fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
|
|||||||
|
|
||||||
// ── Drawing ──────────────────────────────────────────────────────────────────
|
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The one place `AxisColor` becomes a terminal color (improvise-rml); the
|
/// The one place a face becomes a terminal style (ADR 004): the theme resolves
|
||||||
/// accent itself is decided by `Axis::color`. See `context/code/tui.md`.
|
/// it, and only this last translation is the TUI's. `context/code/tui.md`.
|
||||||
pub(crate) fn axis_fg(color: AxisColor) -> Color {
|
pub(crate) fn face_style(face: Face) -> Style {
|
||||||
match color {
|
static SHIPPED: OnceLock<Theme> = OnceLock::new();
|
||||||
AxisColor::Row => Color::Green,
|
let face = SHIPPED.get_or_init(Theme::shipped).resolve(face);
|
||||||
AxisColor::Column => Color::Blue,
|
let mut style = Style::default();
|
||||||
AxisColor::Page => Color::Magenta,
|
if let Some(fg) = terminal_colour(face.foreground) {
|
||||||
AxisColor::Filter => Color::Yellow,
|
style = style.fg(fg);
|
||||||
AxisColor::Inactive => Color::DarkGray,
|
|
||||||
}
|
}
|
||||||
|
if let Some(bg) = terminal_colour(face.background) {
|
||||||
|
style = style.bg(bg);
|
||||||
|
}
|
||||||
|
let modifiers = [
|
||||||
|
(face.weight == Weight::Bold, Modifier::BOLD),
|
||||||
|
(face.slant == Slant::Italic, Modifier::ITALIC),
|
||||||
|
(face.underline, Modifier::UNDERLINED),
|
||||||
|
(face.reverse, Modifier::REVERSED),
|
||||||
|
];
|
||||||
|
modifiers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(on, _)| *on)
|
||||||
|
.fold(style, |style, (_, m)| style.add_modifier(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the semantic minibuffer accent to a concrete terminal color.
|
/// `None` is "state nothing", so the terminal's own colour shows through.
|
||||||
fn minibuf_fg(color: MinibufColor) -> Color {
|
fn terminal_colour(colour: Colour) -> Option<Color> {
|
||||||
match color {
|
match colour {
|
||||||
MinibufColor::Green => Color::Green,
|
Colour::Default => None,
|
||||||
MinibufColor::Cyan => Color::Cyan,
|
Colour::Named(slot) => Some(match slot {
|
||||||
MinibufColor::Yellow => Color::Yellow,
|
AnsiSlot::Black => Color::Black,
|
||||||
|
AnsiSlot::Red => Color::Red,
|
||||||
|
AnsiSlot::Green => Color::Green,
|
||||||
|
AnsiSlot::Yellow => Color::Yellow,
|
||||||
|
AnsiSlot::Blue => Color::Blue,
|
||||||
|
AnsiSlot::Magenta => Color::Magenta,
|
||||||
|
AnsiSlot::Cyan => Color::Cyan,
|
||||||
|
AnsiSlot::White => Color::Gray,
|
||||||
|
AnsiSlot::BrightBlack => Color::DarkGray,
|
||||||
|
AnsiSlot::BrightRed => Color::LightRed,
|
||||||
|
AnsiSlot::BrightGreen => Color::LightGreen,
|
||||||
|
AnsiSlot::BrightYellow => Color::LightYellow,
|
||||||
|
AnsiSlot::BrightBlue => Color::LightBlue,
|
||||||
|
AnsiSlot::BrightMagenta => Color::LightMagenta,
|
||||||
|
AnsiSlot::BrightCyan => Color::LightCyan,
|
||||||
|
AnsiSlot::BrightWhite => Color::White,
|
||||||
|
}),
|
||||||
|
Colour::Indexed(n) => Some(Color::Indexed(n)),
|
||||||
|
Colour::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,30 +262,6 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color
|
|||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new
|
|
||||||
/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`.
|
|
||||||
fn mode_style(mode: &AppMode) -> Style {
|
|
||||||
let bg = match mode {
|
|
||||||
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
|
|
||||||
AppMode::CommandMode { .. } => Color::Yellow,
|
|
||||||
AppMode::TileSelect => Color::Magenta,
|
|
||||||
AppMode::Normal
|
|
||||||
| AppMode::RecordsNormal
|
|
||||||
| AppMode::FormulaEdit { .. }
|
|
||||||
| AppMode::FormulaPanel
|
|
||||||
| AppMode::CategoryPanel
|
|
||||||
| AppMode::CategoryAdd { .. }
|
|
||||||
| AppMode::ItemAdd { .. }
|
|
||||||
| AppMode::ViewPanel
|
|
||||||
| AppMode::ImportWizard
|
|
||||||
| AppMode::ExportPrompt { .. }
|
|
||||||
| AppMode::SavePrompt { .. }
|
|
||||||
| AppMode::Help
|
|
||||||
| AppMode::Quit => Color::DarkGray,
|
|
||||||
};
|
|
||||||
Style::default().fg(Color::Black).bg(bg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(f: &mut Frame, app: &App) {
|
fn draw(f: &mut Frame, app: &App) {
|
||||||
let size = f.area();
|
let size = f.area();
|
||||||
|
|
||||||
@@ -442,12 +449,9 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
|
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(text).style(
|
Paragraph::new(text).style(face_style(
|
||||||
Style::default()
|
Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::ModeTextEntry),
|
||||||
.fg(minibuf_fg(mb.color))
|
)),
|
||||||
.bg(Color::Indexed(235))
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -484,7 +488,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
|
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
|
Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -639,6 +643,43 @@ mod bottom_bar_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first cell of the bottom row, where the prompt's first glyph lands.
|
||||||
|
fn prompt_cell(app: &App) -> ratatui::buffer::Cell {
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(40, 1)).expect("test backend");
|
||||||
|
terminal
|
||||||
|
.draw(|f| draw_bottom_bar(f, Rect::new(0, 0, 40, 1), app))
|
||||||
|
.expect("draw");
|
||||||
|
terminal.backend().buffer().content()[0].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR 004 clause 8's one declared exception (the user, Q1): the
|
||||||
|
/// category-add prompt draws as item-add does. `context/test-evidence/tui.md`.
|
||||||
|
#[test]
|
||||||
|
fn the_category_add_prompt_draws_as_the_item_add_prompt_does() {
|
||||||
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
|
app.view_state.mode = AppMode::item_add("Region".into());
|
||||||
|
let item = prompt_cell(&app);
|
||||||
|
app.view_state.mode = AppMode::category_add();
|
||||||
|
let category = prompt_cell(&app);
|
||||||
|
assert_eq!(
|
||||||
|
category.symbol(),
|
||||||
|
"n",
|
||||||
|
"the prompt is not where it was looked for"
|
||||||
|
);
|
||||||
|
assert_eq!(item.fg, ratatui::style::Color::Green);
|
||||||
|
assert_eq!(category.style(), item.style());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status line wears the mode's face, not the minibuffer's.
|
||||||
|
#[test]
|
||||||
|
fn the_status_line_is_drawn_in_the_mode_face() {
|
||||||
|
let mut app = App::new(Workbook::new("T"), None);
|
||||||
|
app.view_state.mode = AppMode::TileSelect;
|
||||||
|
let cell = prompt_cell(&app);
|
||||||
|
assert_eq!(cell.bg, ratatui::style::Color::Magenta);
|
||||||
|
assert_eq!(cell.fg, ratatui::style::Color::Black);
|
||||||
|
}
|
||||||
|
|
||||||
/// A refusal that keeps the user in a minibuffer stands while they fix it, and
|
/// A refusal that keeps the user in a minibuffer stands while they fix it, and
|
||||||
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
|
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1187,3 +1228,94 @@ mod content_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod face_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::protocol::face::TextEntry;
|
||||||
|
use crate::protocol::render::WizardFieldKind;
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
/// `mode_style`'s table as it stood before faces (improvise-zx8t).
|
||||||
|
fn mode_bg_before_faces(mode: &AppMode) -> Color {
|
||||||
|
match mode {
|
||||||
|
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
|
||||||
|
AppMode::CommandMode { .. } => Color::Yellow,
|
||||||
|
AppMode::TileSelect => Color::Magenta,
|
||||||
|
AppMode::Normal
|
||||||
|
| AppMode::RecordsNormal
|
||||||
|
| AppMode::FormulaEdit { .. }
|
||||||
|
| AppMode::FormulaPanel
|
||||||
|
| AppMode::CategoryPanel
|
||||||
|
| AppMode::CategoryAdd { .. }
|
||||||
|
| AppMode::ItemAdd { .. }
|
||||||
|
| AppMode::ViewPanel
|
||||||
|
| AppMode::ImportWizard
|
||||||
|
| AppMode::ExportPrompt { .. }
|
||||||
|
| AppMode::SavePrompt { .. }
|
||||||
|
| AppMode::Help
|
||||||
|
| AppMode::Quit => Color::DarkGray,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR 004 clause 8: the shipped theme draws every migrated site exactly as
|
||||||
|
/// its literal did. `context/test-evidence/tui.md`.
|
||||||
|
#[test]
|
||||||
|
fn the_shipped_theme_reproduces_every_migrated_literal() {
|
||||||
|
let fg = |c| Style::default().fg(c);
|
||||||
|
let axes = [
|
||||||
|
(Axis::Row, Color::Green),
|
||||||
|
(Axis::Column, Color::Blue),
|
||||||
|
(Axis::Page, Color::Magenta),
|
||||||
|
(Axis::Filter, Color::Yellow),
|
||||||
|
(Axis::None, Color::DarkGray),
|
||||||
|
];
|
||||||
|
for (axis, color) in axes {
|
||||||
|
assert_eq!(face_style(Face::Axis(axis)), fg(color), "{axis:?}");
|
||||||
|
}
|
||||||
|
let kinds = [
|
||||||
|
(WizardFieldKind::Category, Color::Green),
|
||||||
|
(WizardFieldKind::Measure, Color::Cyan),
|
||||||
|
(WizardFieldKind::TimeCategory, Color::Magenta),
|
||||||
|
(WizardFieldKind::Label, Color::DarkGray),
|
||||||
|
];
|
||||||
|
for (kind, color) in kinds {
|
||||||
|
assert_eq!(face_style(Face::WizardField(kind)), fg(color), "{kind:?}");
|
||||||
|
}
|
||||||
|
for mode in AppMode::all() {
|
||||||
|
let before = fg(Color::Black).bg(mode_bg_before_faces(&mode));
|
||||||
|
assert_eq!(face_style(Face::of_mode(&mode)), before, "{mode:?}");
|
||||||
|
}
|
||||||
|
let prompts = [
|
||||||
|
(TextEntry::Edit, Color::Green),
|
||||||
|
(TextEntry::ItemAdd, Color::Green),
|
||||||
|
(TextEntry::Formula, Color::Cyan),
|
||||||
|
(TextEntry::Command, Color::Yellow),
|
||||||
|
(TextEntry::Export, Color::Yellow),
|
||||||
|
(TextEntry::Save, Color::Yellow),
|
||||||
|
];
|
||||||
|
for (entry, color) in prompts {
|
||||||
|
let before = fg(color)
|
||||||
|
.bg(Color::Indexed(235))
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
assert_eq!(face_style(Face::MinibufferOf(entry)), before, "{entry:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_face_reaches_the_terminal_with_every_attribute_it_states() {
|
||||||
|
let resolved = |face| Theme::shipped().resolve(face);
|
||||||
|
for face in Face::all() {
|
||||||
|
let r = resolved(face);
|
||||||
|
let style = face_style(face);
|
||||||
|
assert_eq!(style.fg, terminal_colour(r.foreground), "{}", face.name());
|
||||||
|
assert_eq!(style.bg, terminal_colour(r.background), "{}", face.name());
|
||||||
|
assert_eq!(
|
||||||
|
style.add_modifier.contains(Modifier::BOLD),
|
||||||
|
r.weight == Weight::Bold,
|
||||||
|
"{}",
|
||||||
|
face.name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ use ratatui::{
|
|||||||
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::protocol::face::Face;
|
||||||
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
|
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
|
||||||
use crate::ui::app::Panel as PanelKind;
|
use crate::ui::app::Panel as PanelKind;
|
||||||
use crate::ui::panel::PanelContent;
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
fn axis_display(axis: Axis) -> (String, Color) {
|
fn axis_display(axis: Axis) -> (String, Style) {
|
||||||
(axis.to_string(), crate::draw::axis_fg(axis.color()))
|
(axis.to_string(), crate::draw::face_style(Face::Axis(axis)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The category panel draws one flattened tree and nothing else: the axis
|
/// The category panel draws one flattened tree and nothing else: the axis
|
||||||
@@ -78,7 +79,7 @@ impl PanelContent for CategoryContent {
|
|||||||
axis,
|
axis,
|
||||||
} => {
|
} => {
|
||||||
let indicator = if *expanded { "▼" } else { "▶" };
|
let indicator = if *expanded { "▼" } else { "▶" };
|
||||||
let (axis_str, axis_color) = axis_display(*axis);
|
let (axis_str, axis_style) = axis_display(*axis);
|
||||||
let name_part = format!("{indicator} {name} ({item_count})");
|
let name_part = format!("{indicator} {name} ({item_count})");
|
||||||
let axis_part = format!(" [{axis_str}]");
|
let axis_part = format!(" [{axis_str}]");
|
||||||
|
|
||||||
@@ -88,11 +89,7 @@ impl PanelContent for CategoryContent {
|
|||||||
inner.x + name_part.width() as u16,
|
inner.x + name_part.width() as u16,
|
||||||
y,
|
y,
|
||||||
&axis_part,
|
&axis_part,
|
||||||
if is_selected {
|
if is_selected { base_style } else { axis_style },
|
||||||
base_style
|
|
||||||
} else {
|
|
||||||
Style::default().fg(axis_color)
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use ratatui::{
|
|||||||
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind};
|
use crate::protocol::face::Face;
|
||||||
|
use crate::protocol::{WizardCache, WizardStepKind};
|
||||||
use crate::ui::text::wrap;
|
use crate::ui::text::wrap;
|
||||||
|
|
||||||
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
|
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
|
||||||
@@ -22,17 +23,6 @@ impl<'a> ImportWizardWidget<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a proposed field's kind to its accent, as `axis_fg` does for
|
|
||||||
/// `AxisColor` (gotcha 26). The decision is the protocol's; the color is not.
|
|
||||||
fn field_kind_fg(kind: WizardFieldKind) -> Color {
|
|
||||||
match kind {
|
|
||||||
WizardFieldKind::Category => Color::Green,
|
|
||||||
WizardFieldKind::Measure => Color::Cyan,
|
|
||||||
WizardFieldKind::TimeCategory => Color::Magenta,
|
|
||||||
WizardFieldKind::Label => Color::DarkGray,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Widget for ImportWizardWidget<'a> {
|
impl<'a> Widget for ImportWizardWidget<'a> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
let popup_w = area.width.min(80);
|
let popup_w = area.width.min(80);
|
||||||
@@ -139,7 +129,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
}
|
}
|
||||||
let is_sel = i == self.wizard.cursor;
|
let is_sel = i == self.wizard.cursor;
|
||||||
|
|
||||||
let kind_color = field_kind_fg(proposal.kind);
|
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
|
||||||
|
|
||||||
let accept_str = if proposal.accepted {
|
let accept_str = if proposal.accepted {
|
||||||
"[\u{2713}]"
|
"[\u{2713}]"
|
||||||
@@ -159,7 +149,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
.bg(Color::Cyan)
|
.bg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else if proposal.accepted {
|
} else if proposal.accepted {
|
||||||
Style::default().fg(kind_color)
|
kind_style
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-4
@@ -7,6 +7,7 @@ use ratatui::{
|
|||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::protocol::TileBarCache;
|
use crate::protocol::TileBarCache;
|
||||||
|
use crate::protocol::face::Face;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
@@ -44,8 +45,11 @@ impl<'a> TileBar<'a> {
|
|||||||
tile_cat_idx,
|
tile_cat_idx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
fn axis_display(axis: Axis) -> (&'static str, Style) {
|
||||||
(axis.short_label(), crate::draw::axis_fg(axis.color()))
|
(
|
||||||
|
axis.short_label(),
|
||||||
|
crate::draw::face_style(Face::Axis(axis)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ impl<'a> Widget for TileBar<'a> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_, axis_color) = TileBar::axis_display(tiles[i].axis);
|
let (_, axis_style) = TileBar::axis_display(tiles[i].axis);
|
||||||
let is_selected = selected_cat_idx == Some(i);
|
let is_selected = selected_cat_idx == Some(i);
|
||||||
let style = if is_selected {
|
let style = if is_selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -109,7 +113,7 @@ impl<'a> Widget for TileBar<'a> {
|
|||||||
.bg(Color::Cyan)
|
.bg(Color::Cyan)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(axis_color)
|
axis_style
|
||||||
};
|
};
|
||||||
|
|
||||||
buf.set_string(x, area.y, &labels[i], style);
|
buf.set_string(x, area.y, &labels[i], style);
|
||||||
|
|||||||
Reference in New Issue
Block a user