Files
improvise/src/draw.rs
T
Claude (agent-faces) 3583359a9c fix(faces): minibuffer faces inherit mode.text-entry, as ADR 004 says (improvise-nn4s.2)
Answers ChatGPT Codex's P2 on PR #319.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 23:02:55 -07:00

1322 lines
48 KiB
Rust

use std::io::{self, Stdout};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::command::key;
use crate::protocol::EditorSession;
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::formula_panel::FormulaContent;
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 improvise_command::ui::project;
use unicode_width::UnicodeWidthStr;
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub fn run_tui(
workbook: crate::workbook::Workbook,
file_path: Option<PathBuf>,
import: Option<crate::import::wizard::ImportPipeline>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(workbook, file_path);
if let Ok((w, h)) = crossterm::terminal::size() {
app.term_width = w;
app.term_height = h;
}
if let Some(pipeline) = import {
app.start_import(pipeline);
} else if app.is_empty_model() {
app.view_state.mode = AppMode::Help;
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
press(&mut app, convert_key_event(key))?;
}
Event::Resize(w, h) => {
app.term_width = w;
app.term_height = h;
}
_ => {}
}
}
app.autosave_if_needed();
if matches!(app.view_state.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
/// One keypress in the TUI. A message lasts until the next key (improvise-cq3d),
/// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`.
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
let before = EditorSession::of(&app.view_state);
let stale = app.view_state.status_msg.clone();
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
app.handle_key(key)?;
let still_editing = before.is_some() && before == EditorSession::of(&app.view_state);
if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() {
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]);
}
Ok(())
}
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear.
// Its three rules are in `context/code/tui.md`.
/// Convert a crossterm key event into the crate's own `KeyEvent`.
fn convert_key_event(ev: event::KeyEvent) -> key::KeyEvent {
key::KeyEvent::new(convert_key_code(ev.code), convert_modifiers(ev.modifiers))
}
/// Map a crossterm key code onto the crate's `KeyCode`. Codes the command
/// layer does not model become `KeyCode::Null`, which no keymap binds.
fn convert_key_code(code: event::KeyCode) -> key::KeyCode {
match code {
event::KeyCode::Char(c) => key::KeyCode::Char(c),
event::KeyCode::Esc => key::KeyCode::Esc,
event::KeyCode::Enter => key::KeyCode::Enter,
event::KeyCode::Up => key::KeyCode::Up,
event::KeyCode::Down => key::KeyCode::Down,
event::KeyCode::Left => key::KeyCode::Left,
event::KeyCode::Right => key::KeyCode::Right,
event::KeyCode::Tab => key::KeyCode::Tab,
event::KeyCode::BackTab => key::KeyCode::BackTab,
event::KeyCode::Backspace => key::KeyCode::Backspace,
event::KeyCode::Delete => key::KeyCode::Delete,
event::KeyCode::Home => key::KeyCode::Home,
event::KeyCode::End => key::KeyCode::End,
event::KeyCode::PageUp => key::KeyCode::PageUp,
event::KeyCode::PageDown => key::KeyCode::PageDown,
event::KeyCode::F(n) => key::KeyCode::F(n),
_ => key::KeyCode::Null,
}
}
/// Keep SHIFT/CONTROL/ALT; drop any other modifier bits (SUPER, HYPER, META).
fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
let mut out = key::KeyModifiers::NONE;
if mods.contains(event::KeyModifiers::SHIFT) {
out = out | key::KeyModifiers::SHIFT;
}
if mods.contains(event::KeyModifiers::CONTROL) {
out = out | key::KeyModifiers::CONTROL;
}
if mods.contains(event::KeyModifiers::ALT) {
out = out | key::KeyModifiers::ALT;
}
out
}
// ── Drawing ──────────────────────────────────────────────────────────────────
/// The one place a face becomes a terminal style (ADR 004): the theme resolves
/// it, and only this last translation is the TUI's. `context/code/tui.md`.
pub(crate) fn face_style(face: Face) -> Style {
static SHIPPED: OnceLock<Theme> = OnceLock::new();
let face = SHIPPED.get_or_init(Theme::shipped).resolve(face);
let mut style = Style::default();
if let Some(fg) = terminal_colour(face.foreground) {
style = style.fg(fg);
}
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))
}
/// `None` is "state nothing", so the terminal's own colour shows through.
fn terminal_colour(colour: Colour) -> Option<Color> {
match colour {
Colour::Default => None,
Colour::Named(slot) => Some(match slot {
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)),
}
}
/// 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}")
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w, h)
}
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup);
f.render_widget(block, popup);
inner
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
if matches!(app.view_state.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.view_state.help_page), size);
}
if matches!(app.view_state.mode, AppMode::ImportWizard)
&& let Some(wizard) = project::wizard_of(app)
{
// Built rather than sent: session-local, no `Projection`
// (improvise-764). Still through `project`, so the shape is shared.
f.render_widget(ImportWizardWidget::new(&wizard), size);
}
// ExportPrompt now uses the minibuffer at the bottom bar.
if app.is_empty_model()
&& matches!(
app.view_state.mode,
AppMode::Normal | AppMode::CommandMode { .. }
)
{
draw_welcome(f, main_chunks[1]);
}
// Which-key popup. The armed prefix is stored as the key that opened it;
// the keymap set resolves it (improvise-jc8).
if let Some(km) = app.armed_prefix_keymap() {
let hints = crate::protocol::help::which_key(&km);
f.render_widget(WhichKeyWidget::new(&hints), size);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
// Off the cache the browser's title bar folds, not off `model_state`:
// one derivation, both front ends (improvise-z754).
let doc = project::document_of(app);
let dirty = if doc.dirty { " [+]" } else { "" };
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, Keeps::Right);
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
// Split on `App::grid_width`, which the keypress path scrolls by too.
let grid_w = app.grid_width().min(area.width);
let grid_area = Rect {
width: grid_w,
..area
};
if app.side_panel_open() {
let side = Rect {
x: area.x + grid_w,
width: area.width - grid_w,
..area
};
let panel_count = [
app.view_state.formula_panel_open,
app.view_state.category_panel_open,
app.view_state.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.view_state.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
// Built per frame from the authoritative `App` — the temporary
// half of improvise-35e (`context/code/tui.md`).
let cache = project::formulas_of(app);
let content = FormulaContent::new(&cache, &app.view_state.mode);
f.render_widget(
Panel::new(content, &app.view_state.mode, app.view_state.formula_cursor),
a,
);
y += ph;
}
if app.view_state.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let cache = project::category_tree_of(app);
let content = CategoryContent::new(&cache, &app.view_state.expanded_cats);
f.render_widget(
Panel::new(
content,
&app.view_state.mode,
app.view_state.cat_panel_cursor,
),
a,
);
y += ph;
}
if app.view_state.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
let cache = project::view_list_of(app);
let content = ViewContent::new(&cache, &app.view_state.active_view);
f.render_widget(
Panel::new(
content,
&app.view_state.mode,
app.view_state.view_panel_cursor,
),
a,
);
}
}
// The viewport covers the WHOLE grid, so a cache position is an absolute
// position (gotcha 44). Only the viewport narrows for a client.
let grid_cache = project::grid_of(
app,
project::Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
},
);
f.render_widget(
GridWidget::new(
&grid_cache,
app.cursor(),
&app.view_state.active_view,
&app.layout,
&app.view_state.mode,
&app.view_state.search_query,
&app.view_state.buffers,
app.drill_state.as_ref(),
),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
let cache = project::tile_bar_of(app);
f.render_widget(
TileBar::new(&cache, &app.view_state.mode, app.view_state.tile_cat_idx),
area,
);
}
/// The minibuffer row, with any pending status message riding on its right.
/// The minibuffer owns the bottom row and used to hide it — tui.md §3.
fn minibuffer_line(prompt: &str, buf: &str, status_msg: &str, width: u16) -> String {
let left = format!("{prompt}{buf}▌");
let right = match status_msg.is_empty() {
true => String::new(),
false => format!(" {status_msg} "),
};
fill_line(left, &right, width, Keeps::Left)
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
if let Some(mb) = app.view_state.mode.minibuffer() {
let buf = app
.view_state
.buffers
.get(&mb.buffer_key)
.map(|s| s.as_str())
.unwrap_or("");
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
f.render_widget(
Paragraph::new(text).style(face_style(
Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::ModeTextEntry),
)),
area,
);
} else {
draw_status(f, area, app);
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.view_state.search_mode {
format!(" /{}▌", app.view_state.search_query)
} else {
String::new()
};
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(),
};
let yank_indicator = if app.view_state.yanked.is_some() {
" [yank]"
} else {
""
};
let view_badge = format!(" {}{} ", app.view_state.active_view, yank_indicator);
let left = format!(
" {}{search_part} {msg}",
crate::protocol::mode_label(&app.view_state.mode)
);
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
f.render_widget(
Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))),
area,
);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> \"<Name> = <expr>\" Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" :formula _Measure \"Profit = Revenue - Cost\"",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}
#[cfg(test)]
mod bottom_bar_tests {
use super::{draw_bottom_bar, minibuffer_line, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode};
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
/// The bottom row as the terminal really receives it, so the assertion is
/// about `draw_bottom_bar`'s wiring and not about a helper it may not call.
fn bottom_row(app: &App, width: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("test backend");
terminal
.draw(|f| draw_bottom_bar(f, Rect::new(0, 0, width, 1), app))
.expect("draw");
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
/// A message lasts until the next key, then the derived hint is back
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
#[test]
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let mut app = App::new(Workbook::new("T"), None);
app.view_state.status_msg = "Saved to x.improv".to_string();
press(&mut app, key(KeyCode::Char('T'))).unwrap();
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
let row = bottom_row(&app, 240);
assert!(
!row.contains("Saved to"),
"a stale message outlived a key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint in TileSelect: {row:?}"
);
press(&mut app, key(KeyCode::Char(' '))).unwrap();
let said = app.view_state.status_msg.clone();
assert!(!said.is_empty(), "the axis cycle must say what it did");
assert!(
bottom_row(&app, 240).contains(&said),
"a message was wiped before it was drawn"
);
press(&mut app, key(KeyCode::Esc)).unwrap();
let row = bottom_row(&app, 240);
assert!(
!row.contains(&said),
"the axis message outlived the next key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint back in Normal: {row:?}"
);
}
/// 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
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
#[test]
fn a_minibuffer_refusal_stands_while_the_user_fixes_it() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let refused = || {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.formula_panel_open = true;
app.view_state.mode = AppMode::FormulaPanel;
press(&mut app, key(KeyCode::Char('n'))).unwrap();
for c in "Total = (".chars() {
press(&mut app, key(KeyCode::Char(c))).unwrap();
}
press(&mut app, key(KeyCode::Enter)).unwrap();
assert!(
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
"a bad formula keeps the editor open"
);
assert!(bottom_row(&app, 240).contains("Formula error"));
app
};
let mut app = refused();
press(&mut app, key(KeyCode::Backspace)).unwrap();
let row = bottom_row(&app, 240);
assert!(row.contains("Total = "), "still editing: {row:?}");
assert!(
row.contains("Formula error"),
"the refusal vanished on the first correcting key: {row:?}"
);
let first = app.view_state.status_msg.clone();
press(&mut app, key(KeyCode::Enter)).unwrap();
let second = app.view_state.status_msg.clone();
assert_ne!(second, first, "a new refusal names the new text");
assert!(
bottom_row(&app, 240).contains(second.lines().next().unwrap_or_default()),
"a key's own message must beat the stale one"
);
press(&mut app, key(KeyCode::Char('1'))).unwrap();
press(&mut app, key(KeyCode::Enter)).unwrap();
assert!(!matches!(app.view_state.mode, AppMode::FormulaEdit { .. }));
let row = bottom_row(&app, 240);
assert!(
!row.contains("Formula error"),
"the refusal outlived its fix: {row:?}"
);
let mut app = refused();
press(&mut app, key(KeyCode::Esc)).unwrap();
let row = bottom_row(&app, 240);
assert!(
!row.contains("Formula error"),
"the refusal outlived leaving the editor: {row:?}"
);
}
/// Region{North, South}, two measures, one cell: the reviewer's records fixture
/// for improvise-sx7h, plus a second row for the plain grid's Enter.
fn one_record() -> App {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(1.0),
);
App::new(wb, None)
}
/// A commit that re-opens the editor on another cell starts another editor, so
/// a message from the last one must not follow it (improvise-sx7h). Evidence file.
#[test]
fn a_message_does_not_follow_the_editor_onto_the_next_cell() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let ch = |app: &mut App, c: char| press(app, key(KeyCode::Char(c))).unwrap();
// Records: `o` says it added a row and opens an editor on it.
let mut app = one_record();
ch(&mut app, 'R');
ch(&mut app, 'o');
let added = app.view_state.status_msg.clone();
assert!(!added.is_empty(), "o must say what it did");
assert!(matches!(
app.view_state.mode,
AppMode::RecordsEditing { .. }
));
ch(&mut app, '5');
assert!(
bottom_row(&app, 240).contains(&added),
"typing in the same editor keeps the message"
);
let cell = app.cursor().selected;
press(&mut app, key(KeyCode::Tab)).unwrap();
assert!(matches!(
app.view_state.mode,
AppMode::RecordsEditing { .. }
));
assert_ne!(app.cursor().selected, cell, "Tab moved the editor");
let row = bottom_row(&app, 240);
assert!(
!row.contains(&added),
"the message followed the editor to the next cell: {row:?}"
);
// Records: a second `o` on the blank row is refused, and filling it in ends it.
let mut app = one_record();
ch(&mut app, 'R');
ch(&mut app, 'o');
press(&mut app, key(KeyCode::Esc)).unwrap();
ch(&mut app, 'o');
let refusal = app.view_state.status_msg.clone();
assert!(refusal.contains("already here"), "{refusal:?}");
ch(&mut app, '5');
assert!(bottom_row(&app, 240).contains("already here"));
press(&mut app, key(KeyCode::Tab)).unwrap();
let row = bottom_row(&app, 240);
assert!(
!row.contains("already here"),
"the refusal outlived the Tab that answered it: {row:?}"
);
// A plain grid: Enter commits and re-opens the editor one row down.
let mut app = one_record();
app.cursor_mut().selected = (0, 0);
ch(&mut app, 'i');
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
app.view_state.status_msg = "a note about this cell".into();
ch(&mut app, '7');
assert!(bottom_row(&app, 240).contains("a note about this cell"));
let cell = app.cursor().selected;
press(&mut app, key(KeyCode::Enter)).unwrap();
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
assert_ne!(app.cursor().selected, cell, "Enter moved the editor");
let row = bottom_row(&app, 240);
assert!(
!row.contains("a note about this cell"),
"the note followed the editor down a row: {row:?}"
);
// The last column: Tab commits and re-opens the editor on the SAME cell, so
// only the new seed says it is another editor.
let mut app = one_record();
let last = app.layout.col_count() - 1;
app.cursor_mut().selected = (0, last);
ch(&mut app, 'i');
app.view_state.status_msg = "a note about this cell".into();
ch(&mut app, '7');
press(&mut app, key(KeyCode::Tab)).unwrap();
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
assert_eq!(app.cursor().selected, (0, last), "Tab could not advance");
let row = bottom_row(&app, 240);
assert!(
!row.contains("a note about this cell"),
"the note outlived the commit on the same cell: {row:?}"
);
}
/// Equal modes are the test, not equal variants: an editor adding to another
/// category is another editor (improvise-sx7h). Evidence file.
#[test]
fn an_editor_on_another_category_is_another_editor() {
let mut app = App::new(Workbook::new("T"), None);
let item_add = |category: &str| AppMode::item_add(category.into());
app.view_state.mode = item_add("Region");
let region = EditorSession::of(&app.view_state);
assert!(region.is_some(), "ItemAdd is a text-entry mode");
app.view_state.mode = item_add("Month");
assert_ne!(region, EditorSession::of(&app.view_state));
app.view_state.mode = item_add("Region");
assert_eq!(
region,
EditorSession::of(&app.view_state),
"and the same one is the same"
);
}
/// 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();
let key = app
.view_state
.mode
.minibuffer()
.expect("CategoryAdd carries a MinibufferConfig")
.buffer_key
.clone();
app.view_state.buffers.insert(key, "North".to_string());
app.view_state.status_msg = "category already exists".to_string();
app
}
/// improvise-mai9, and the ONE assertion that can fail for the real defect
/// — see context/test-evidence/tui.md.
#[test]
fn a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open() {
let app = app_with_minibuffer_and_message();
let row = bottom_row(&app, 80);
assert!(
row.contains("North"),
"the minibuffer must still own the left of the row: {row:?}"
);
assert!(
row.contains("category already exists"),
"the status message is never drawn — the bug is in draw_bottom_bar, \
which used to render the minibuffer INSTEAD of the status line, so \
a test that calls the composing helper directly cannot see it: {row:?}"
);
}
/// The minibuffer is still the thing that owns the row: a fix that dropped
/// to `draw_status` would pass the assertion above on the message alone.
#[test]
fn the_minibuffer_prompt_is_not_displaced_by_the_message() {
let app = app_with_minibuffer_and_message();
let row = bottom_row(&app, 80);
let prompt_at = row.find("North").expect("the buffer is drawn");
let message_at = row.find("category already exists").expect("the message");
assert!(
prompt_at < message_at,
"the prompt leads and the message follows it: {row:?}"
);
}
/// The guard's ONLY observable effect, in the narrow band where the two
/// arms differ at all. Why the obvious negative arm cannot: tui.md.
#[test]
fn an_empty_message_never_pushes_a_row_that_fits_past_its_width() {
// Spelled here, NOT taken from `minibuffer_line`: a bound read off the
// function under test moves with the fault and skips the failing band.
let fits_from = "Category: North\u{258c}".chars().count() as u16;
for width in fits_from..fits_from + 24 {
let line = minibuffer_line("Category: ", "North", "", width);
assert!(
line.chars().count() <= width as usize,
"width {width}: an empty status still composed {} columns \
from {line:?} — an unconditional append pads for a message \
that is not there",
line.chars().count()
);
}
}
}
#[cfg(test)]
mod key_conversion_tests {
use super::*;
use crossterm::event::{KeyEventKind, KeyEventState};
#[test]
fn char_with_control_maps_code_and_modifier() {
let ev = event::KeyEvent::new(event::KeyCode::Char('d'), event::KeyModifiers::CONTROL);
let got = convert_key_event(ev);
assert_eq!(got.code, key::KeyCode::Char('d'));
assert_eq!(got.modifiers, key::KeyModifiers::CONTROL);
}
#[test]
fn backtab_maps() {
let ev = event::KeyEvent::new(event::KeyCode::BackTab, event::KeyModifiers::SHIFT);
let got = convert_key_event(ev);
assert_eq!(got.code, key::KeyCode::BackTab);
assert_eq!(got.modifiers, key::KeyModifiers::SHIFT);
}
#[test]
fn unknown_code_maps_to_null() {
for code in [
event::KeyCode::Insert,
event::KeyCode::CapsLock,
event::KeyCode::Menu,
] {
let got = convert_key_event(event::KeyEvent::new(code, event::KeyModifiers::NONE));
assert_eq!(got.code, key::KeyCode::Null);
}
}
/// All `KeyEventKind`s convert alike, as the pre-conversion loop
/// dispatched them — `context/test-evidence/tui.md`.
#[test]
fn release_kind_converts_like_press() {
let ev = event::KeyEvent {
code: event::KeyCode::Enter,
modifiers: event::KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
let got = convert_key_event(ev);
assert_eq!(
got,
key::KeyEvent::new(key::KeyCode::Enter, key::KeyModifiers::NONE)
);
}
#[test]
fn unknown_modifier_bits_are_dropped() {
let mods =
event::KeyModifiers::SUPER | event::KeyModifiers::META | event::KeyModifiers::ALT;
let got = convert_modifiers(mods);
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"
);
}
}
#[cfg(test)]
mod content_tests {
use super::{draw, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::App;
use crate::view::Axis;
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, style::Color};
const COLS: usize = 12;
/// One row, twelve columns, and a value per column that names it.
fn wide_grid() -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Row").unwrap();
wb.add_category("Col").unwrap();
wb.add_item("Row", "R1").unwrap();
for i in 0..COLS {
let col = format!("Column_{i:02}");
wb.add_item("Col", &col).unwrap();
let key = CellKey::new(vec![("Row".into(), "R1".into()), ("Col".into(), col)]);
wb.model.set_cell(key, CellValue::Number((101 + i) as f64));
}
wb.active_view_mut().set_axis("Row", Axis::Row);
wb.active_view_mut().set_axis("Col", Axis::Column);
wb
}
/// The text the whole frame draws in the selected cell's style.
fn selected_text(app: &App) -> String {
let (w, h) = (app.term_width, app.term_height);
let mut terminal = Terminal::new(TestBackend::new(w, h)).expect("test backend");
terminal.draw(|f| draw(f, app)).expect("draw");
terminal
.backend()
.buffer()
.content()
.iter()
.filter(|c| c.bg == Color::Cyan && c.fg == Color::Black)
.map(|c| c.symbol())
.collect()
}
/// improvise-gtn7.1: the cursor went behind a side panel —
/// `context/test-evidence/tui.md`.
#[test]
fn moving_right_keeps_the_selected_cell_drawn_beside_any_side_panel() {
for panel in ["no", "formula", "category", "view"] {
let mut app = App::new(wide_grid(), None);
(app.term_width, app.term_height) = (80, 24);
// The category panel is open at launch, so each run sets all three.
let vs = &mut app.view_state;
vs.formula_panel_open = panel == "formula";
vs.category_panel_open = panel == "category";
vs.view_panel_open = panel == "view";
for step in 1..COLS {
press(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)).unwrap();
let col = app.cursor().selected.1;
assert_eq!(col, step, "setup: {panel} panel, the cursor moved");
let drawn = selected_text(&app);
assert!(
drawn.contains(&(101 + col).to_string()),
"{panel} panel: column {col} is selected (offset {}) but not \
drawn; the selection drew {drawn:?}",
app.cursor().col_offset
);
}
}
}
}
#[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()
);
}
}
}