4 Commits

Author SHA1 Message Date
Ed Langley 78a7bbf4f9 Merge pull request #149 from fiddlerwoaroof/fix/rr8n-one-hint-table
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (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
fix(tui): one key table on screen, and it is the derived one (improvise-rr8n)
2026-09-22 18:59:49 -07:00
Ed Langley 84c73e8739 Merge pull request #145 from fiddlerwoaroof/fix/t14d-title-bar-escape-hatch
fix(tui): the title bar's escape hatch outranks the document name (improvise-t14d)
2026-09-22 18:59:14 -07:00
Edward Langley 915d0fefeb fix(tui): one key table on screen, and it is the derived one (improvise-rr8n)
Two hand-written key tables were drawn BESIDE derived ones, in a different
notation, and one of them was false.

The tile bar's non-TileSelect hint read " Ctrl+^v<-> to move tiles" while
Ctrl-Left/Right/Up/Down are bound in Normal to enter-tile-select -- pressing one
ENTERS tile mode and moves nothing. It was the only persistent on-screen
documentation of those keys, and it spelt Ctrl+ where key_label, and therefore
every derived hint and help row, spells Ctrl-.

The fix DELETES both of the tile bar's hints rather than rewriting one, which
departs from the ticket's acceptance criteria and does so on the ticket's own
evidence. rr8n proposed rewriting the string and suppressing the STATUS BAR's
hint in TileSelect. But the derived status line is a strict superset of the tile
bar's literal -- it carries Space, h/l and Tab, which the tile bar's omits, as
the ticket's own finding 2 records -- and it cannot go stale, being read off the
keymap. Suppressing it would have deleted information and kept the table that had
just been shown to rot. In Normal the derived line already says T:tiles, so only
the Ctrl-arrow spelling is lost, and it was never documented correctly.

The wizard goes the other way and there the ticket is right. Its overlay draws
correct per-step hints; the status bar drew one static line under it, wrong on
four of eight steps, including telling the user Esc cancels on Done where Esc
goes BACK. That line is derived too, but from the wrong input -- the wizard's
real keys are resolved per step inside HandleWizardKey. So draw_status suppresses
its hint fallback for that one mode.

The rule, stated in context/code/tui.md: keep whichever table is derived from the
thing it describes.

Five tests. Four faults injected, each caught by a distinct test; the sharpest is
suppressing the hint fallback unconditionally, which passes both tests aimed at
the suppression and is caught only by the counterweight asserting TileSelect
still advertises its keys. Two things that let the false hint survive are
recorded: every_hint_key_resolves_in_its_mode cannot see a literal in a widget,
and the widget's own characterization test renders at 80 columns, where the hint
does not fit and is never drawn.

Evidence: context/test-evidence/tui.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:51:12 -07:00
Edward Langley 6bd6cb5771 fix(tui): the title bar's escape hatch outranks the document name (improvise-t14d)
`fill_line` composed the title bar's two segments by concatenating them and
letting ratatui clip. ratatui clips the RIGHT, and the right segment is
" ?:help  :q quit " -- the only thing on screen telling a stuck user how to get
help or get out. The left segment is decoration whose length is unbounded, so
the affordance was the first thing sacrificed: measured through a TestBackend, a
document named "Quarterly Regional Sales Analysis" stored as
quarterly-regional-sales-2026.improv loses ":q quit" at 100 columns.

Two smaller faults in the same three lines. The padding was `left.len()` --
BYTES -- and the title's own format string spells a "·", two bytes and one cell,
so every document's right segment sat one cell in from the edge it is anchored
to, before any non-ASCII name made it worse. And there was no truncation at all,
so the failure was a mid-token clip of "?:" rather than a graceful drop.

`Keeps` makes the priority a per-call-site decision rather than a global rule,
because it genuinely differs: the title bar keeps the RIGHT (the hatch outranks
the name), while `minibuffer_line` and `draw_status` keep the LEFT, which is what
they already did -- there the left is the user's own typing plus the cursor, or
the status message, and right priority would let a long message eat the text
being edited. Truncation goes through ui::text::truncate, so widths are display
cells throughout and an overflow is an ellipsis.

Five tests, each rendered through a real TestBackend because the loss happens in
ratatui's clip and a helper's return value cannot see it. Three faults injected,
each caught by a distinct test; the reasoning and the injections are in
context/test-evidence/tui.md and context/code/tui.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 18:51:07 -07:00
4 changed files with 499 additions and 18 deletions
+66
View File
@@ -154,6 +154,72 @@ composes both: the prompt and buffer on the left, any pending `status_msg`
right-aligned. The alternatives and why they lost are in
`context/test-evidence/tui.md`.
### `fill_line` — which segment yields is a per-call-site decision
Three rows in the TUI are one left segment and one right segment sharing a line:
the title bar, `minibuffer_line` and `draw_status`. `fill_line` composes them,
and it owes two things that it did not originally do — it must measure in
**display cells** (it padded with `left.len()`, bytes, and the title's own `·`
is two of them, so every document's right segment sat one cell in from its edge)
and it must **truncate**, because concatenating and letting ratatui clip clips
the *right*.
What clipping the right costs depends on the row, which is why `Keeps` is a
parameter and not a rule:
- **The title bar keeps the right.** Its left segment is decoration whose length
is unbounded — a document name plus a filename — and its right segment is the
fixed `" ?:help :q quit "`, the only thing on screen telling a stuck user how
to get out. Unconditional left priority made the escape hatch the first thing
sacrificed, at 100 columns with an ordinary document name (improvise-t14d).
- **Both bottom-bar rows keep the left**, which is what they already did. There
the left is the user's own typing plus the cursor (`minibuffer_line`) or the
status message (`draw_status`), and the right is a view badge or the message's
own margin. Right priority there would let a long message eat the text being
edited — the same mistake in the other direction.
So the rule is not "the right wins" but "the affordance wins over the
decoration", and which is which is local. Truncation goes through
`ui::text::truncate` (improvise-8l0c), so an overflow is a `…` rather than a
mid-token clip of `?:`.
### One key table on screen, and it is the one derived from the keymap
The TUI stacks the tile bar (row 3) directly above the status bar (row 4), and
the import wizard's overlay sits above the status bar too, so a mode can put
**two** key tables on screen at once. When it does, they disagree — different
notation, different content, and in one case a flat contradiction.
The rule is not "the overlay wins" or "the status bar wins". It is **keep
whichever table is derived from the thing it describes** (improvise-rr8n):
- **Tile select: the status bar wins.** `hint_line(&mode)` is read off the
keymap, so it cannot go stale, and it is a strict superset of what the tile
bar's literal carried — `Space`, `h`/`l` and `Tab` were only ever on the
derived line. The tile bar's own hints are therefore **deleted**, both of
them, and `TileBar::render` now advertises no keys at all. The one it drew in
Normal was false: it named `Ctrl+↑↓←→` as "to move tiles" while those four
keys are bound to `enter-tile-select`, which moves nothing.
- **The import wizard: the overlay wins.** `hint_line(&AppMode::ImportWizard)`
is derived too, but from the *wrong input* — the wizard's real keys are
resolved per step inside `HandleWizardKey`, and one static line off the mode's
keymap is wrong on four of its eight steps, including telling the user `Esc`
cancels on `Done` where `Esc` goes back. So `draw_status` suppresses its hint
fallback for that one mode and lets the overlay's per-step hints stand alone.
Two things worth keeping from how this survived. `every_hint_key_resolves_in_its_mode`
checks `AppMode::hint_text()` against the keymaps and **cannot see a string
literal in a widget**, which is the same blind spot improvise-wk18 names for
`help.rs`. And the widget's own characterization test renders at 80 columns,
where the hint does not fit and is never drawn — so the test that pins this
widget's output was structurally incapable of showing either hint. A fixture
width is part of what a test can see.
The longer-term shape the ticket names is still open: the wizard's per-step keys
belong in `advertised()` keyed by step, which would make them derived like
everything else and let the status bar carry them correctly instead of being
switched off.
### The caches are built here, per frame
`project::formulas_of`, `category_tree_of`, `view_list_of`, `tile_bar_of`,
+192
View File
@@ -644,3 +644,195 @@ Fault injected 2026-09-21: restoring the name-only comparison in
`AxisFrame::group_runs` fails this with `left: [(Some("Q1"), 4)]` while the five
existing agreement tests all pass — the ungrouped `Sep` in their fixture is
precisely why they cannot see it.
---
## `src/draw.rs::title_bar_tests` — the title bar's escape hatch
improvise-t14d. `fill_line` composed a left and a right segment into one row and
did **neither** of the two things that composition needs. It padded with
`left.len() + right.len()` — **bytes** — and it never truncated, so on overflow
it simply concatenated and left ratatui to clip. ratatui clips the *right*, and
the right segment of the title bar is `" ?:help :q quit "`: the only thing on
screen telling a stuck user how to get help or get out.
**The priority was backwards.** The left segment is decoration — the product
name, the document name, the filename, a dirty marker — and its length is
unbounded because a document name is. The right segment is a fixed 17 cells and
is the escape hatch. Giving the unbounded decoration unconditional priority over
the fixed affordance means the affordance is the first thing sacrificed, and it
is sacrificed at ordinary widths: measured through a real `TestBackend`, a
document named `Quarterly Regional Sales Analysis` stored as
`quarterly-regional-sales-2026.improv` loses `:q quit` **at 100 columns**.
The byte fault is separate and universal. The title's own format string spells
`·`, two bytes and one cell, so `left.len()` over-counts by one for *every*
document — the padding is one column short and the right segment sits one cell
in from the edge it is anchored to, before any non-ASCII document name makes it
worse.
`Keeps` is the fix's shape: which segment survives is a per-call-site decision,
not a global rule. The title bar keeps the **right** (the hatch outranks the
name). The two bottom-bar call sites keep the **left**, which is what they did
before — there the left is the user's own typing (`minibuffer_line`) or the
status message (`draw_status`), and the right is a view badge, so right-priority
would have let a long message eat the text the user is editing along with the
cursor. Widths are `ui::text::truncate`'s display cells throughout, so the
overflow is a graceful `…` rather than a mid-token clip of `?:`.
### the_escape_hatch_outlives_a_long_document_name
The defect itself, at the five widths the ticket measured (36, 44, 52, 60, 100).
Rendered through a `TestBackend` and read off the terminal buffer, not off
`fill_line`'s return value: the loss happens in ratatui's clip, so a helper's
return value cannot see it. **A test at 80 columns with a short fixture name
passes against the broken code** — title length is the variable, not width.
Fault injected: `Keeps::Right` → `Keeps::Left` at the `draw_title` call site.
Fails at width 36 with `" improvise · Quarterly Regional S"`; the other four
tests in the module pass, so this one and only this one names the priority.
### the_document_name_still_leads_the_row
The other direction, which the escape-hatch assertion alone cannot supply: a fix
that dropped the left segment entirely, or that put the hatch first, satisfies
it. At 80 columns with a short name nothing is truncated, so this pins that the
name leads, the hatch follows, and the filename survives intact.
### a_multibyte_title_does_not_shift_the_right_segment
The byte-versus-cell fault. Asserts the row *ends with* the right segment — the
edge it is anchored to — for an ASCII name, an accented one and a CJK one. The
ASCII case is the discriminating one and the surprise: it fails against the
broken code too, because the `·` is in the format string rather than in the
document's name.
Fault injected: `width - left.width() - right.width()` →
`width.saturating_sub(left.len() + right.len())`, keeping the truncation. Fails
on `"Sales"` with the right segment one cell in from the edge, and takes
`a_composed_line_is_exactly_its_width` with it. The escape-hatch test does not
see it, which is why the two faults need two tests.
### a_composed_line_is_exactly_its_width
The property both directions owe, over 0..40 columns, three segment pairs and
both `Keeps` arms: a wide-character left segment, an empty left, and an empty
right. Width 0 and the band where the two segments cannot both fit are the cases
`fill_line`'s arithmetic can get wrong — it subtracts rather than saturating, so
an off-by-one here is a panic and not a cosmetic slip.
### the_kept_side_is_the_side_that_survives
`Keeps::Left` has no other witness in this module: every rendering test above
drives the title bar, which keeps the right. Without this, both arms could be
the same code and the suite would stay green — which is what the enum exists to
prevent.
Fault injected: the `Keeps::Left` arm rewritten to the body of the `Keeps::Right`
arm. This test fails; the other four pass.
---
## `src/ui/tile_bar.rs` + `src/draw.rs` — one hint table on screen, and it is the derived one
improvise-rr8n. Two hand-written key tables were drawn **beside** derived ones,
in a different notation, and one of them was outright false.
**The tile bar's non-TileSelect hint named keys that do something else.** It read
`" Ctrl+↑↓←→ to move tiles"`, while `Ctrl-Left/Right/Up/Down` are bound in Normal
to `enter-tile-select` — pressing one *enters* tile mode and moves nothing. It
was also the only persistent on-screen documentation of those keys, and it spelt
`Ctrl+` where `keymap::key_label`, and therefore every derived hint and help row,
spells `Ctrl-`.
**Nothing could have caught it.** `every_hint_key_resolves_in_its_mode` checks
`AppMode::hint_text()` against the keymaps; a string literal in a widget is
invisible to it. And the shipped fixture `renders_each_tile_with_its_axis_label`
renders at **80 columns**, where the hint does not fit (`x + hint.width() <=
area.x + area.width` is false) and is never drawn — so the one test that pins
this widget's output could not see either hint at all. That is why the new tests
render at 140.
**The fix deletes rather than rewrites, and that departs from the ticket's AC.**
improvise-rr8n proposed rewriting the string to `" Ctrl-↑↓←→ or T to pick a
tile"` and suppressing the *status bar's* hint in TileSelect. Measured against
the ticket's own second finding, that is backwards:
```
tile bar (hand-written): [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel
status bar (derived): hl←→:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back
```
The derived line is a strict **superset** — it carries `Space`, `h`/`l` and
`Tab`, which the tile bar's omits — and it cannot go stale, being read off the
keymap. Suppressing it would have deleted information and kept the table that
had just been shown to rot. So the tile bar now advertises nothing, and in
Normal the derived line already says `T:tiles`; only the `Ctrl-`arrow spelling
is lost, and it was never documented correctly.
**The wizard goes the other way, and there the ticket is right.** Its overlay
draws correct per-step hints, and the status bar drew one static
`Space:toggle c:cycle Enter:next Esc:cancel` under it — wrong on four of eight
steps, including an outright contradiction on `Done`, where the bar says `Esc`
cancels and `Esc` goes *back*. It is static because it is derived from the
`ImportWizard` keymap, while the wizard's real keys are resolved per step inside
`HandleWizardKey`; so the derivation is sound and its *input* is the wrong thing.
`draw_status` therefore suppresses the hint fallback for that one mode.
The asymmetry is the point: **keep whichever table is derived from the thing it
describes.** For tile select that is the status line; for the wizard it is the
overlay.
### the_tile_bar_advertises_no_keys_of_its_own
Renders the bar in Normal and TileSelect at 140 columns and requires that neither
row names `Ctrl`, `Enter`, `Esc`, `←→` or `move tiles`. An absence assertion, so
it is paired with the two below that require the information to still exist
elsewhere.
Two faults injected, both caught here and by nothing else:
- The original `else` branch restored — fails in Normal on `"Ctrl"`, quoting the
whole false line.
- Only the `else` branch deleted, keeping the TileSelect literal (the narrow
reading of finding 1) — fails in TileSelect on `"Enter"`. This is the rival
that matters, because it is the fix a reader of finding 1 alone would write,
and it leaves two disagreeing dialects stacked on adjacent rows.
Note it would also fail against the ticket's own proposed replacement string,
which still contains `Ctrl`. That is deliberate rather than accidental: the
property asserted is "this widget names no keys", not "this widget's keys are
currently right".
### the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to
The counterweight. Deleting a `set_string` at the end of a render function is
the kind of edit that takes a neighbour with it, so this pins the tiles and the
`Tiles:` prefix at 80 and 140. It also covers the dead `x += 1` the deletion
orphaned — clippy caught that one, but the test is what says the drawing still
lands.
### the_wizard_overlay_is_the_only_key_hint_on_screen
The wizard half. Requires the bottom row in `ImportWizard` to name none of
`Space:toggle`, `c:cycle`, `Esc:cancel`. Fails against the unfixed code quoting
the full row, including the `Esc:cancel` that contradicts the overlay.
### suppressing_the_wizard_hint_leaves_the_rest_of_the_bar
The direction the suppression could overshoot: the mode label `IMPORT` and a real
`status_msg` are not hints and must survive. A fix that blanked the bar in that
mode, or that suppressed the *message* along with the hint, fails here.
### the_status_bar_still_advertises_the_tile_keys
The counterweight to the tile bar deletion, and the test that rejects the
ticket's own AC: Normal must still say `T:tiles`, and TileSelect must still carry
`Enter/Space:cycle`, `r:row` and `Esc/Tab:back`.
Fault injected: suppressing the hint fallback unconditionally
(`true => String::new()`) rather than for `ImportWizard` alone. All three wizard
tests pass — including both of the ones *aimed* at the suppression — and only
this one fails. That is the shape design-principles §7 names: the assertion next
door is satisfied by the wrong fix, and the discriminating input is the mode the
change was not supposed to touch.
+198 -5
View File
@@ -24,11 +24,13 @@ use crate::ui::grid::GridWidget;
use crate::ui::help::HelpWidget;
use crate::ui::import_wizard_ui::ImportWizardWidget;
use crate::ui::panel::Panel;
use crate::ui::text::truncate;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewContent;
use crate::ui::which_key::WhichKeyWidget;
use crate::view::AxisColor;
use improvise_command::ui::project;
use unicode_width::UnicodeWidthStr;
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
@@ -169,8 +171,29 @@ fn minibuf_fg(color: MinibufColor) -> Color {
}
}
fn fill_line(left: String, right: &str, width: u16) -> String {
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
/// Which segment survives when the two cannot both fit. The title bar's escape
/// hatch outranks the document name (improvise-t14d) — `context/code/tui.md`.
#[derive(Clone, Copy, Debug)]
enum Keeps {
Left,
Right,
}
fn fill_line(left: String, right: &str, width: u16, keeps: Keeps) -> String {
let width = width as usize;
let (left, right) = match keeps {
Keeps::Right => {
let right = truncate(right, width);
let left = truncate(&left, width - right.width());
(left, right)
}
Keeps::Left => {
let left = truncate(&left, width);
let right = truncate(right, width - left.width());
(left, right)
}
};
let pad = " ".repeat(width - left.width() - right.width());
format!("{left}{pad}{right}")
}
@@ -272,7 +295,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let file = doc.stored_as.map(|n| format!(" ({n})")).unwrap_or_default();
let title = format!(" improvise · {}{}{} ", doc.name, file, dirty);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
let line = fill_line(title, right, area.width, Keeps::Right);
f.render_widget(
Paragraph::new(line).style(
Style::default()
@@ -396,7 +419,7 @@ fn minibuffer_line(prompt: &str, buf: &str, status_msg: &str, width: u16) -> Str
true => String::new(),
false => format!(" {status_msg} "),
};
fill_line(left, &right, width)
fill_line(left, &right, width, Keeps::Left)
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
@@ -431,6 +454,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let msg = match app.view_state.status_msg.is_empty() {
false => app.view_state.status_msg.clone(),
// The wizard overlay draws its own per-step hints; one static table
// under it contradicts them on four of eight steps (improvise-rr8n).
true if matches!(app.view_state.mode, AppMode::ImportWizard) => String::new(),
true => app.hint_text(),
};
@@ -445,7 +471,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
" {}{search_part} {msg}",
crate::protocol::mode_label(&app.view_state.mode)
);
let line = fill_line(left, &view_badge, area.width);
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
f.render_widget(
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
@@ -561,6 +587,61 @@ mod bottom_bar_tests {
.collect()
}
/// The wizard overlay draws correct per-step hints; the status bar stacked a
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
#[test]
fn the_wizard_overlay_is_the_only_key_hint_on_screen() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::ImportWizard;
let row = bottom_row(&app, 100);
for token in ["Space:toggle", "c:cycle", "Esc:cancel"] {
assert!(
!row.contains(token),
"the status bar names {token:?} under an overlay that contradicts \
it on four of eight steps: {row:?}"
);
}
}
/// Suppressing the hint must not blank the bar: the mode label and any
/// status message still belong there. improvise-rr8n.
#[test]
fn suppressing_the_wizard_hint_leaves_the_rest_of_the_bar() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::ImportWizard;
assert!(
bottom_row(&app, 100).contains("IMPORT"),
"the mode label is not a hint and must survive"
);
app.view_state.status_msg = "no array found in that JSON".to_string();
assert!(
bottom_row(&app, 100).contains("no array found in that JSON"),
"a real status message must still reach the bar"
);
}
/// The counterweight to deleting the tile bar's own hint: the tile keys are
/// still advertised, by the derived line. improvise-rr8n.
#[test]
fn the_status_bar_still_advertises_the_tile_keys() {
let mut app = App::new(Workbook::new("T"), None);
assert!(
bottom_row(&app, 200).contains("T:tiles"),
"Normal must still say how to reach tile select"
);
app.view_state.mode = AppMode::TileSelect;
let row = bottom_row(&app, 200);
for token in ["Enter/Space:cycle", "r:row", "Esc/Tab:back"] {
assert!(
row.contains(token),
"TileSelect lost {token:?}, which the tile bar's own hint never \
carried in full: {row:?}"
);
}
}
fn app_with_minibuffer_and_message() -> App {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::category_add();
@@ -687,3 +768,115 @@ mod key_conversion_tests {
assert_eq!(got, key::KeyModifiers::ALT);
}
}
#[cfg(test)]
mod title_bar_tests {
use super::{Keeps, draw_title, fill_line};
use crate::ui::app::App;
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
/// The title row as the terminal really receives it: ratatui clips the
/// right, so a helper's return value cannot see the defect.
fn title_row(app: &App, width: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("test backend");
terminal
.draw(|f| draw_title(f, Rect::new(0, 0, width, 1), app))
.expect("draw");
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
fn app_named(name: &str, file: &str) -> App {
App::new(Workbook::new(name), Some(PathBuf::from(file)))
}
/// improvise-t14d — `context/test-evidence/tui.md`.
#[test]
fn the_escape_hatch_outlives_a_long_document_name() {
let app = app_named(
"Quarterly Regional Sales Analysis",
"quarterly-regional-sales-2026.improv",
);
for width in [36u16, 44, 52, 60, 100] {
let row = title_row(&app, width);
assert!(
row.contains("?:help") && row.contains(":q quit"),
"the escape hatch is gone at width {width}: {row:?}"
);
}
}
/// The name must still lead where it fits, or a fix that simply dropped it
/// would satisfy the assertion above. improvise-t14d.
#[test]
fn the_document_name_still_leads_the_row() {
let app = app_named("Sales", "sales.improv");
let row = title_row(&app, 80);
let name_at = row.find("Sales").expect("the name is drawn");
let hatch_at = row.find("?:help").expect("the hatch is drawn");
assert!(
name_at < hatch_at,
"the name leads and the hatch follows: {row:?}"
);
assert!(
row.contains("(sales.improv)"),
"a name that fits is not truncated: {row:?}"
);
}
/// Display cells, not bytes — the ASCII case is the discriminating one.
/// improvise-t14d, `context/test-evidence/tui.md`.
#[test]
fn a_multibyte_title_does_not_shift_the_right_segment() {
for name in ["Sales", "Ventes régionales", "日本語"] {
let app = app_named(name, "m.improv");
let row = title_row(&app, 80);
assert!(
row.ends_with(":q quit "),
"the right segment must sit on the right edge for {name:?}: {row:?}"
);
}
}
/// Whichever segment yields, the composed line is exactly the width it was
/// given — the property both call-site directions owe. improvise-t14d.
#[test]
fn a_composed_line_is_exactly_its_width() {
for width in 0u16..40 {
for (left, right) in [
(" improvise · Ünïcödé ", " ?:help :q quit "),
("", " badge "),
("日本語 is a wide left segment", ""),
] {
for keeps in [Keeps::Left, Keeps::Right] {
let line = fill_line(left.to_string(), right, width, keeps);
assert_eq!(
line.width(),
width as usize,
"fill_line({left:?}, {right:?}, {width}, {keeps:?}) = {line:?}"
);
}
}
}
}
/// `Keeps::Left` is the other direction, and nothing else in this module
/// separates it from `Keeps::Right`. improvise-t14d.
#[test]
fn the_kept_side_is_the_side_that_survives() {
let (left, right) = ("LEFTLEFTLEFT", "RIGHTRIGHT");
assert_eq!(fill_line(left.to_string(), right, 12, Keeps::Left), left);
assert!(
fill_line(left.to_string(), right, 12, Keeps::Right).ends_with(right),
"the right segment is kept whole when it is the one that wins"
);
}
}
+43 -13
View File
@@ -126,21 +126,10 @@ impl<'a> Widget for TileBar<'a> {
// Draw overflow indicator if tiles remain after the visible area
if last_drawn + 1 < labels.len() && x < area.x + area.width {
buf.set_string(x, area.y, "▶", Style::default().fg(Color::DarkGray));
x += 1;
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}
// The bar draws tiles and advertises no keys: the status line's hint is
// derived from the keymap and this one was not (improvise-rr8n).
}
}
@@ -218,4 +207,45 @@ mod tests {
" Tiles: [_Index ·] [_Dim ·] [_Measure Col] [Region Row] [Product Col]"
);
}
/// The tile bar advertised keys that do something else (improvise-rr8n) —
/// `context/test-evidence/tui.md`.
#[test]
fn the_tile_bar_advertises_no_keys_of_its_own() {
let app = app_with_two_categories();
let cache = project::tile_bar_of(&app);
for mode in [AppMode::Normal, AppMode::TileSelect] {
// Wide enough that a hint would fit: the shipped 80-column fixture
// is too narrow to draw one, which is how the false claim survived.
let area = Rect::new(0, 0, 140, 1);
let mut buf = Buffer::empty(area);
TileBar::new(&cache, &mode, 0).render(area, &mut buf);
let text = buf_text(&buf);
for token in ["Ctrl", "Enter", "Esc", "←→", "move tiles"] {
assert!(
!text.contains(token),
"the tile bar names {token:?} in {mode:?}, and nothing checks \
that claim against the keymap: {text:?}"
);
}
}
}
/// The tiles themselves are what the bar is for, and deleting the hint must
/// not delete them. improvise-rr8n.
#[test]
fn the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to() {
let app = app_with_two_categories();
let cache = project::tile_bar_of(&app);
for width in [80u16, 140] {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
TileBar::new(&cache, &AppMode::TileSelect, 0).render(area, &mut buf);
let text = buf_text(&buf);
assert!(
text.contains("[Region Row]") && text.contains("Tiles:"),
"at width {width} the bar lost its tiles: {text:?}"
);
}
}
}