3583359a9c
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
1322 lines
48 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|
|
}
|