Compare commits
4 Commits
6e10bb3afa
...
78a7bbf4f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 78a7bbf4f9 | |||
| 84c73e8739 | |||
| 915d0fefeb | |||
| 6bd6cb5771 |
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user