Files
improvise/src/ui/app.rs
T
Edward Langley 7249facf94 docs(ui): classify App-resident runtime/derived fields (improvise-99k)
Step 4 of vb4. After steps 1–3, App owns model_state, view_state, and a
residue of five fields that don't belong to either slice. Add a top-level
doc on App explaining the slice structure and tag each residue field as
derived cache / runtime metadata / transient / config with a one-line
rationale. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:33:27 -07:00

1476 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::rc::Rc;
use ratatui::style::Color;
use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue;
use crate::persistence;
use crate::ui::grid::{
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
};
use crate::view::GridLayout;
use crate::workbook::Workbook;
/// A saved view+mode pair for the navigation stack.
#[derive(Debug, Clone, PartialEq)]
pub struct ViewFrame {
pub view_name: String,
pub mode: AppMode,
}
/// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model.
#[derive(Debug, Clone, Default)]
pub struct DrillState {
/// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
/// Pending edits keyed by (record_idx, column_name) → new string value.
/// column_name is either "Value" or a category name.
pub pending_edits: std::collections::HashMap<(usize, String), String>,
}
/// Display configuration for the bottom-bar minibuffer.
/// Carried structurally by text-entry `AppMode` variants.
#[derive(Debug, Clone, PartialEq)]
pub struct MinibufferConfig {
pub buffer_key: &'static str,
pub prompt: String,
pub color: Color,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppMode {
Normal,
Editing {
minibuf: MinibufferConfig,
},
FormulaEdit {
minibuf: MinibufferConfig,
},
FormulaPanel,
CategoryPanel,
/// Quick-add a new category: Enter adds and stays open, Esc closes.
CategoryAdd {
minibuf: MinibufferConfig,
},
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
ItemAdd {
category: String,
minibuf: MinibufferConfig,
},
ViewPanel,
TileSelect,
ImportWizard,
ExportPrompt {
minibuf: MinibufferConfig,
},
/// Vim-style `:` command line
CommandMode {
minibuf: MinibufferConfig,
},
Help,
Quit,
/// Records-mode normal: inherits from Normal with records-specific bindings.
RecordsNormal,
/// Records-mode editing: inherits from Editing with boundary-aware Tab/Enter.
RecordsEditing {
minibuf: MinibufferConfig,
},
}
impl AppMode {
/// Extract the minibuffer config from text-entry modes, if present.
pub fn minibuffer(&self) -> Option<&MinibufferConfig> {
match self {
Self::Editing { minibuf, .. }
| Self::RecordsEditing { minibuf, .. }
| Self::FormulaEdit { minibuf, .. }
| Self::CommandMode { minibuf, .. }
| Self::CategoryAdd { minibuf, .. }
| Self::ItemAdd { minibuf, .. }
| Self::ExportPrompt { minibuf, .. } => Some(minibuf),
_ => None,
}
}
/// True for any cell-editing mode (normal or records).
pub fn is_editing(&self) -> bool {
matches!(self, Self::Editing { .. } | Self::RecordsEditing { .. })
}
pub fn editing() -> Self {
Self::Editing {
minibuf: MinibufferConfig {
buffer_key: "edit",
prompt: "edit: ".into(),
color: Color::Green,
},
}
}
pub fn records_editing() -> Self {
Self::RecordsEditing {
minibuf: MinibufferConfig {
buffer_key: "edit",
prompt: "edit: ".into(),
color: Color::Green,
},
}
}
/// True when this mode is a records-mode variant.
pub fn is_records(&self) -> bool {
matches!(self, Self::RecordsNormal | Self::RecordsEditing { .. })
}
pub fn formula_edit() -> Self {
Self::FormulaEdit {
minibuf: MinibufferConfig {
buffer_key: "formula",
prompt: "formula: ".into(),
color: Color::Cyan,
},
}
}
pub fn command_mode() -> Self {
Self::CommandMode {
minibuf: MinibufferConfig {
buffer_key: "command",
prompt: ":".into(),
color: Color::Yellow,
},
}
}
pub fn category_add() -> Self {
Self::CategoryAdd {
minibuf: MinibufferConfig {
buffer_key: "category",
prompt: "new category: ".into(),
color: Color::Yellow,
},
}
}
pub fn item_add(category: String) -> Self {
let prompt = format!("add item to {category}: ");
Self::ItemAdd {
category,
minibuf: MinibufferConfig {
buffer_key: "item",
prompt,
color: Color::Green,
},
}
}
pub fn export_prompt() -> Self {
Self::ExportPrompt {
minibuf: MinibufferConfig {
buffer_key: "export",
prompt: "export path: ".into(),
color: Color::Yellow,
},
}
}
}
/// Document state slice: the workbook and its IO bookkeeping. Distinct from
/// `Workbook` itself (which is pure document semantics in `improvise-core`)
/// because `file_path` and `dirty` are persistence-layer concerns.
#[derive(Debug)]
pub struct ModelState {
pub workbook: Workbook,
pub file_path: Option<PathBuf>,
pub dirty: bool,
}
impl Default for ModelState {
fn default() -> Self {
Self {
workbook: Workbook::new("Untitled"),
file_path: None,
dirty: false,
}
}
}
/// UI session-state slice: mode, cursors, panels, buffers, navigation stacks,
/// and other per-session state that does not persist to disk.
#[derive(Debug)]
pub struct ViewState {
pub mode: AppMode,
pub status_msg: String,
pub wizard: Option<ImportWizard>,
pub search_query: String,
pub search_mode: bool,
pub formula_panel_open: bool,
pub category_panel_open: bool,
pub view_panel_open: bool,
pub cat_panel_cursor: usize,
pub view_panel_cursor: usize,
pub formula_cursor: usize,
/// Yanked cell value for `p` paste
pub yanked: Option<CellValue>,
/// Tile select cursor (which category index is highlighted)
pub tile_cat_idx: usize,
/// View navigation history: views visited before the current one.
/// Pushed on SwitchView, popped by `<` (back).
pub view_back_stack: Vec<ViewFrame>,
/// Views that were "back-ed" from, available for forward navigation (`>`).
pub view_forward_stack: Vec<ViewFrame>,
/// Frozen records list for the drill view. When present, this is the
/// snapshot that records-mode layouts iterate — records don't disappear
/// when filters would change. Pending edits are stored alongside and
/// applied to the model on commit/navigate-away.
pub drill_state: Option<DrillState>,
/// Current page index in the Help screen (0-based).
pub help_page: usize,
/// Categories expanded in the category panel tree view.
pub expanded_cats: std::collections::HashSet<String>,
/// Named text buffers for text-entry modes
pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
pub transient_keymap: Option<Arc<Keymap>>,
}
impl Default for ViewState {
fn default() -> Self {
Self {
mode: AppMode::Normal,
status_msg: String::new(),
wizard: None,
search_query: String::new(),
search_mode: false,
formula_panel_open: false,
category_panel_open: false,
view_panel_open: false,
cat_panel_cursor: 0,
view_panel_cursor: 0,
formula_cursor: 0,
yanked: None,
tile_cat_idx: 0,
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
drill_state: None,
help_page: 0,
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_keymap: None,
}
}
}
/// Top-level TUI application state. Composed of two named slices and a
/// residue of fields that don't belong to either:
///
/// - `model_state` — document state (Workbook + IO bookkeeping). Persists.
/// - `view_state` — UI session state (mode, cursors, panels, buffers, …).
/// Does not persist.
/// - The remaining direct fields are runtime / derived / config — see
/// per-field tags below. Each of them is documented as belonging to one
/// of these residue categories so the slice boundary stays explicit:
///
/// - **derived cache**: a pure function of the slices, recomputed on
/// demand. Owned by `App` because rebuilding belongs to the host.
/// - **runtime metadata**: data the runtime feeds in (terminal dims,
/// wall-clock instants). Not part of model or view state.
/// - **transient**: a flag whose lifetime is bounded to one method call
/// on `App`. Reset on entry, never observed across calls.
/// - **config**: data loaded once at startup and read but not written
/// during a session.
pub struct App {
pub model_state: ModelState,
pub view_state: ViewState,
/// **Runtime metadata**: wall-clock instant of the last autosave. Used
/// by `autosave_if_needed` to debounce writes. Not part of either slice
/// because it's about IO timing, not document or session state.
pub last_autosave: Instant,
/// **Runtime metadata**: terminal dimensions, updated on resize events
/// and at startup. Fed in by the host loop in `draw.rs`.
pub term_width: u16,
pub term_height: u16,
/// **Derived cache**: pure function of `model_state.workbook`,
/// `view_state.drill_state`, and the active view. Rebuilt via
/// `rebuild_layout()` after any state change. Owned by `App` because
/// it's the host's job to coordinate the rebuild, not the slices'.
pub layout: GridLayout,
/// **Transient**: when an effect sets this to `true` during
/// `apply_effects`, the remaining effects in the batch are skipped.
/// Reset to `false` at the start of every `apply_effects` call. Use
/// via the `AbortChain` effect — this is the mechanism by which e.g.
/// "advance at bottom-right" short-circuits the trailing
/// `EnterEditAtCursor` in a `CommitAndAdvance` chain.
pub abort_effects: bool,
/// **Config**: keymap configuration assembled at startup by
/// `KeymapSet::default_keymaps()`. Read every keypress but never
/// mutated during a session.
keymap_set: KeymapSet,
}
impl App {
pub fn new(mut workbook: Workbook, file_path: Option<PathBuf>) -> Self {
// Recompute formula cache before building the initial layout so
// formula-derived values are available on the first frame. The
// cache is keyed by the active view's None-axis categories, so
// the caller must gather them explicitly.
let none_cats = workbook.active_view().none_cats();
workbook.model.recompute_formulas(&none_cats);
let layout = {
let view = workbook.active_view();
GridLayout::with_frozen_records(&workbook.model, view, None)
};
Self {
model_state: ModelState {
workbook,
file_path,
dirty: false,
},
view_state: ViewState::default(),
last_autosave: Instant::now(),
term_width: crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80),
term_height: crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24),
layout,
abort_effects: false,
keymap_set: KeymapSet::default_keymaps(),
}
}
/// Rebuild the grid layout from current workbook, active view, and drill
/// state. Note: `with_frozen_records` already handles pruning internally.
pub fn rebuild_layout(&mut self) {
let none_cats = self.model_state.workbook.active_view().none_cats();
self.model_state.workbook.model.recompute_formulas(&none_cats);
let view = self.model_state.workbook.active_view();
let frozen = self.view_state.drill_state.as_ref().map(|s| Rc::clone(&s.records));
self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
}
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
let view = self.model_state.workbook.active_view();
let layout = &self.layout;
let (sel_row, sel_col) = view.selected;
CmdContext {
model: &self.model_state.workbook.model,
workbook: &self.model_state.workbook,
view,
layout,
registry: self.keymap_set.registry(),
mode: &self.view_state.mode,
selected: view.selected,
row_offset: view.row_offset,
col_offset: view.col_offset,
search_query: &self.view_state.search_query,
yanked: &self.view_state.yanked,
dirty: self.model_state.dirty,
search_mode: self.view_state.search_mode,
formula_panel_open: self.view_state.formula_panel_open,
category_panel_open: self.view_state.category_panel_open,
view_panel_open: self.view_state.view_panel_open,
buffers: &self.view_state.buffers,
formula_cursor: self.view_state.formula_cursor,
cat_panel_cursor: self.view_state.cat_panel_cursor,
view_panel_cursor: self.view_state.view_panel_cursor,
tile_cat_idx: self.view_state.tile_cat_idx,
view_back_stack: &self.view_state.view_back_stack,
view_forward_stack: &self.view_state.view_forward_stack,
has_drill_state: self.view_state.drill_state.is_some(),
display_value: {
let key = layout.cell_key(sel_row, sel_col);
if let Some(k) = &key {
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
self.view_state.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
.or_else(|| layout.resolve_display(k))
.unwrap_or_default()
} else {
self.model_state.workbook
.model
.get_cell(k)
.map(|v| v.to_string())
.unwrap_or_default()
}
} else {
String::new()
}
},
visible_rows: (self.term_height as usize).saturating_sub(8),
visible_cols: {
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
let col_widths =
compute_col_widths(&self.model_state.workbook.model, layout, fmt_comma, fmt_decimals);
let row_header_width = compute_row_header_width(layout);
compute_visible_cols(
&col_widths,
row_header_width,
self.term_width,
view.col_offset,
)
},
expanded_cats: &self.view_state.expanded_cats,
key_code: key,
}
}
pub fn apply_effects(&mut self, effects: Vec<Box<dyn super::effect::Effect>>) {
self.abort_effects = false;
for effect in effects {
effect.apply(self);
if self.abort_effects {
// AbortChain (or another abort-setting effect) requested
// that the rest of this batch be skipped. Reset the flag so
// the next dispatch starts clean.
self.abort_effects = false;
break;
}
}
self.rebuild_layout();
}
/// True when the model has no user-defined categories (show welcome/help).
/// Virtual categories (_Index, _Dim, _Measure) are always present and don't count.
pub fn is_empty_model(&self) -> bool {
use crate::model::category::CategoryKind;
self.model_state.workbook.model.categories.values().all(|c| {
matches!(
c.kind,
CategoryKind::VirtualIndex
| CategoryKind::VirtualDim
| CategoryKind::VirtualMeasure
)
})
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
self.rebuild_layout();
// Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.view_state.transient_keymap.take() {
let effects = {
let ctx = self.cmd_context(key.code, key.modifiers);
self.keymap_set
.dispatch_transient(&transient, &ctx, key.code, key.modifiers)
};
if let Some(effects) = effects {
self.apply_effects(effects);
}
// Whether matched or not, transient is consumed
return Ok(());
}
// Try mode keymap — if a binding matches, apply effects and return
let effects = {
let ctx = self.cmd_context(key.code, key.modifiers);
self.keymap_set.dispatch(&ctx, key.code, key.modifiers)
};
if let Some(effects) = effects {
self.apply_effects(effects);
}
Ok(())
}
pub fn autosave_if_needed(&mut self) {
if self.model_state.dirty
&& self.last_autosave.elapsed() > Duration::from_secs(30)
&& let Some(path) = &self.model_state.file_path.clone()
{
let ap = persistence::autosave_path(path);
let _ = persistence::save(&self.model_state.workbook, &ap);
self.last_autosave = Instant::now();
}
}
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
self.view_state.wizard = Some(ImportWizard::new(json));
self.view_state.mode = AppMode::ImportWizard;
}
/// Hint text for the status bar (context-sensitive)
pub fn hint_text(&self) -> &'static str {
match &self.view_state.mode {
AppMode::Normal => {
"hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd"
}
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
"Enter:commit Tab:commit+right Esc:cancel"
}
AppMode::RecordsNormal => {
"hjkl:nav i:edit o:add-row R:pivot P:prune <:back ::cmd"
}
AppMode::FormulaPanel => "n:new d:delete jk:nav Esc:back",
AppMode::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => {
"jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back"
}
AppMode::CategoryAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type a category name"
}
AppMode::ItemAdd { .. } => {
"Enter:add & continue Tab:same Esc:done — type an item name"
}
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
AppMode::TileSelect => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
AppMode::CommandMode { .. } => {
":q quit :w save :import :add-cat :formula :show-item :help"
}
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
AppMode::Help => "h/l:pages q/Esc:close",
_ => "",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// improvise-3vr: ModelState and ViewState are the named slices of App
/// state introduced by the vb4 refactor. Step 1 only requires that the
/// types exist and are constructible; subsequent steps move fields in.
#[test]
fn model_state_and_view_state_are_constructible() {
let _: ModelState = ModelState::default();
let _: ViewState = ViewState::default();
}
/// improvise-x2c: ModelState owns the document slice — workbook,
/// file_path, and dirty. App accesses them through model_state.
#[test]
fn app_model_state_owns_workbook_file_path_and_dirty() {
let app = App::new(Workbook::new("T"), Some(PathBuf::from("/tmp/x")));
let _: &Workbook = &app.model_state.workbook;
let _: &Option<PathBuf> = &app.model_state.file_path;
let _: bool = app.model_state.dirty;
}
/// improvise-ew0: ViewState owns the UI session slice — mode, status,
/// search, panels, navigation, drill, yanked, buffers, etc. App
/// accesses them through view_state.
#[test]
fn app_view_state_owns_ui_session_fields() {
let app = App::new(Workbook::new("T"), None);
let _: &AppMode = &app.view_state.mode;
let _: &str = &app.view_state.status_msg;
let _: &Option<ImportWizard> = &app.view_state.wizard;
let _: &str = &app.view_state.search_query;
let _: bool = app.view_state.search_mode;
let _: bool = app.view_state.formula_panel_open;
let _: bool = app.view_state.category_panel_open;
let _: bool = app.view_state.view_panel_open;
let _: usize = app.view_state.cat_panel_cursor;
let _: usize = app.view_state.view_panel_cursor;
let _: usize = app.view_state.formula_cursor;
let _: &Option<CellValue> = &app.view_state.yanked;
let _: usize = app.view_state.tile_cat_idx;
let _: &Vec<ViewFrame> = &app.view_state.view_back_stack;
let _: &Vec<ViewFrame> = &app.view_state.view_forward_stack;
let _: &Option<DrillState> = &app.view_state.drill_state;
let _: usize = app.view_state.help_page;
let _: &std::collections::HashSet<String> = &app.view_state.expanded_cats;
let _: &HashMap<String, String> = &app.view_state.buffers;
let _: &Option<Arc<Keymap>> = &app.view_state.transient_keymap;
}
fn two_col_model() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Row").unwrap(); // → Row axis
wb.add_category("Col").unwrap(); // → Column axis
wb.model.category_mut("Row").unwrap().add_item("A");
wb.model.category_mut("Row").unwrap().add_item("B");
wb.model.category_mut("Row").unwrap().add_item("C");
wb.model.category_mut("Col").unwrap().add_item("X");
wb.model.category_mut("Col").unwrap().add_item("Y");
App::new(wb, None)
}
fn run_cmd(app: &mut App, cmd: &dyn crate::command::cmd::Cmd) {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let effects = cmd.execute(&ctx);
drop(ctx);
app.apply_effects(effects);
}
fn enter_advance_cmd(app: &App) -> crate::command::cmd::navigation::EnterAdvance {
use crate::command::cmd::navigation::CursorState;
let view = app.model_state.workbook.active_view();
let cursor = CursorState {
row: view.selected.0,
col: view.selected.1,
row_count: 3,
col_count: 2,
row_offset: 0,
col_offset: 0,
visible_rows: 20,
visible_cols: 8,
};
crate::command::cmd::navigation::EnterAdvance { cursor }
}
#[test]
fn enter_advance_moves_down_within_column() {
let mut app = two_col_model();
app.model_state.workbook.active_view_mut().selected = (0, 0);
let cmd = enter_advance_cmd(&app);
run_cmd(&mut app, &cmd);
assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
}
#[test]
fn enter_advance_wraps_to_top_of_next_column() {
let mut app = two_col_model();
// row_max = 2 (A,B,C), col 0 → should wrap to (0, 1)
app.model_state.workbook.active_view_mut().selected = (2, 0);
let cmd = enter_advance_cmd(&app);
run_cmd(&mut app, &cmd);
assert_eq!(app.model_state.workbook.active_view().selected, (0, 1));
}
#[test]
fn enter_advance_stays_at_bottom_right() {
let mut app = two_col_model();
app.model_state.workbook.active_view_mut().selected = (2, 1);
let cmd = enter_advance_cmd(&app);
run_cmd(&mut app, &cmd);
assert_eq!(app.model_state.workbook.active_view().selected, (2, 1));
}
#[test]
fn import_command_switches_to_import_wizard_mode() {
// Regression: execute_command was resetting mode to Normal after
// :import set it to ImportWizard, so the wizard never appeared.
let mut app = two_col_model();
let json: serde_json::Value = serde_json::json!([{"cat": "A", "val": 1}]);
app.start_import_wizard(json);
assert!(
matches!(app.view_state.mode, AppMode::ImportWizard),
"mode should be ImportWizard after start_import_wizard"
);
}
#[test]
fn execute_import_command_leaves_mode_as_import_wizard() {
let mut app = two_col_model();
// Inject JSON via start_import_wizard to simulate what :import does
app.start_import_wizard(serde_json::json!([{"x": 1}]));
// After the command the mode must NOT be reset to Normal
assert!(
!matches!(app.view_state.mode, AppMode::Normal),
"mode must not be Normal after import wizard is opened"
);
}
#[test]
fn command_mode_typing_appends_to_buffer() {
use crossterm::event::KeyEvent;
let mut app = two_col_model();
// Enter command mode with ':'
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.view_state.mode, AppMode::CommandMode { .. }));
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
// Type 'q'
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("q"));
}
#[test]
fn col_offset_scrolls_when_cursor_moves_past_visible_columns() {
use crate::model::cell::{CellKey, CellValue};
// Create a model with 8 wide columns. Column item names are 30 chars
// each → column widths ~31 chars. With term_width=80, row header ~4,
// data area ~76 → only ~2 columns actually fit. But the rough estimate
// (8030)/12 = 4 over-counts, so viewport_effects never scrolls.
let mut wb = Workbook::new("T");
wb.add_category("Row").unwrap();
wb.add_category("Col").unwrap();
wb.model.category_mut("Row").unwrap().add_item("R1");
for i in 0..8 {
let name = format!("VeryLongColumnItemName_{i:03}");
wb.model.category_mut("Col").unwrap().add_item(&name);
}
// Populate a value so the workbook isn't empty
let key = CellKey::new(vec![
("Row".to_string(), "R1".to_string()),
("Col".to_string(), "VeryLongColumnItemName_000".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(1.0));
let mut app = App::new(wb, None);
app.term_width = 80;
// Press 'l' (right) 3 times to move cursor to column 3.
// Only ~2 columns fit in 76 chars of data area (each col ~26 chars wide),
// so column 3 is well off-screen. The buggy estimate (8030)/12 = 4
// thinks 4 columns fit, so it won't scroll until col 4.
for _ in 0..3 {
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
}
assert_eq!(
app.model_state.workbook.active_view().selected.1,
3,
"cursor should be at column 3"
);
assert!(
app.model_state.workbook.active_view().col_offset > 0,
"col_offset should scroll when cursor moves past visible area (only ~2 cols fit \
in 80-char terminal with 26-char-wide columns), but col_offset is {}",
app.model_state.workbook.active_view().col_offset
);
}
#[test]
fn home_jumps_to_first_col() {
let mut app = two_col_model();
app.model_state.workbook.active_view_mut().selected = (1, 1);
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected, (1, 0));
}
#[test]
fn end_jumps_to_last_col() {
let mut app = two_col_model();
app.model_state.workbook.active_view_mut().selected = (1, 0);
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected, (1, 1));
}
#[test]
fn page_down_scrolls_by_three_quarters_visible() {
let mut app = two_col_model();
// Add enough rows
for i in 0..30 {
app.model_state.workbook
.model
.category_mut("Row")
.unwrap()
.add_item(format!("R{i}"));
}
app.term_height = 28; // ~20 visible rows → delta = 15
app.model_state.workbook.active_view_mut().selected = (0, 0);
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected.1, 0, "column preserved");
assert!(
app.model_state.workbook.active_view().selected.0 > 0,
"row should advance on PageDown"
);
// 3/4 of ~20 = 15
assert_eq!(app.model_state.workbook.active_view().selected.0, 15);
}
#[test]
fn page_up_scrolls_backward() {
let mut app = two_col_model();
for i in 0..30 {
app.model_state.workbook
.model
.category_mut("Row")
.unwrap()
.add_item(format!("R{i}"));
}
app.term_height = 28;
app.model_state.workbook.active_view_mut().selected = (20, 0);
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
}
#[test]
fn jump_last_row_scrolls_with_small_terminal() {
let mut app = two_col_model();
// Total rows: A, B, C + R0..R9 = 13 rows. Last row = 12.
for i in 0..10 {
app.model_state.workbook
.model
.category_mut("Row")
.unwrap()
.add_item(format!("R{i}"));
}
app.term_height = 13; // ~5 visible rows
app.model_state.workbook.active_view_mut().selected = (0, 0);
// G jumps to last row (row 12)
app.handle_key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE))
.unwrap();
let last = app.model_state.workbook.active_view().selected.0;
assert_eq!(last, 12, "should be at last row");
// With only ~5 visible rows and 13 rows, offset should scroll.
// Bug: hardcoded 20 means `12 >= 0 + 20` is false → no scroll.
let offset = app.model_state.workbook.active_view().row_offset;
assert!(
offset > 0,
"row_offset should scroll when last row is beyond visible area, but is {offset}"
);
}
#[test]
fn ctrl_d_scrolls_viewport_with_small_terminal() {
let mut app = two_col_model();
for i in 0..30 {
app.model_state.workbook
.model
.category_mut("Row")
.unwrap()
.add_item(format!("R{i}"));
}
app.term_height = 13; // ~5 visible rows
app.model_state.workbook.active_view_mut().selected = (0, 0);
// Ctrl+d scrolls by 5 rows
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected.0, 5);
// Press Ctrl+d again — now at row 10 with only 5 visible rows,
// row_offset should have scrolled (not stay at 0 due to hardcoded 20)
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(app.model_state.workbook.active_view().selected.0, 10);
assert!(
app.model_state.workbook.active_view().row_offset > 0,
"row_offset should scroll with small terminal, but is {}",
app.model_state.workbook.active_view().row_offset
);
}
#[test]
fn tab_in_edit_mode_commits_and_moves_right() {
let mut app = two_col_model();
app.model_state.workbook.active_view_mut().selected = (0, 0);
// Enter edit mode
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(matches!(app.view_state.mode, AppMode::Editing { .. }));
// Type a digit
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
.unwrap();
// Press Tab — should commit, move right, re-enter edit mode
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
// Should be in edit mode on column 1
assert!(
matches!(app.view_state.mode, AppMode::Editing { .. }),
"should be in edit mode after Tab, but mode is {:?}",
app.view_state.mode
);
assert_eq!(
app.model_state.workbook.active_view().selected.1,
1,
"should have moved to column 1"
);
}
/// Regression: pressing `o` in an empty records view should create the
/// Pressing R to enter records mode should sort existing data by CellKey
/// so display order is deterministic regardless of insertion order.
#[test]
fn entering_records_mode_sorts_existing_data() {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("North");
wb.model.category_mut("Region").unwrap().add_item("East");
// Insert in reverse-alphabetical order
wb.model.set_cell(
CellKey::new(vec![("Region".into(), "North".into())]),
CellValue::Number(1.0),
);
wb.model.set_cell(
CellKey::new(vec![("Region".into(), "East".into())]),
CellValue::Number(2.0),
);
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode());
let region_col = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Region")
.unwrap();
let row0 = app.layout.records_display(0, region_col).unwrap();
let row1 = app.layout.records_display(1, region_col).unwrap();
assert_eq!(
row0, "East",
"R should sort existing data: first row should be East"
);
assert_eq!(
row1, "North",
"R should sort existing data: second row should be North"
);
}
/// first synthetic row instead of only entering edit mode on empty space.
#[test]
fn add_record_row_in_empty_records_view_creates_first_row() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(app.layout.is_records_mode(), "R should enter records mode");
assert_eq!(app.layout.row_count(), 0, "fresh records view starts empty");
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.layout.row_count(),
1,
"o should create the first record row in an empty records view"
);
assert!(
app.view_state.mode.is_editing(),
"o should leave the app in edit mode, got {:?}",
app.view_state.mode
);
}
/// Regression: editing the first row in a blank model's records view
/// should persist the typed value even though plain records mode does not
/// use drill state. With _Measure as the first column, `o` lands on it;
/// type a measure name, Tab to Value, type the number, Enter to commit.
#[test]
fn edit_record_row_in_blank_model_persists_value() {
use crate::model::cell::CellKey;
let mut app = App::new(Workbook::new("T"), None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
// `o` adds a record row and enters edit at (0, 0) = _Measure column
app.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))
.unwrap();
// Type a measure name
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE))
.unwrap();
// Tab to commit _Measure and move to Value column
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
// Type the value
app.handle_key(KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE))
.unwrap();
// Enter to commit
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.model_state.workbook.model.get_cell(&CellKey::new(vec![(
"_Measure".to_string(),
"Rev".to_string(),
)])),
Some(&crate::model::cell::CellValue::Number(5.0)),
"editing a synthetic row in plain records mode should write the value"
);
}
/// Build a records-mode app with two data rows for testing Tab/Enter
/// behavior at boundaries. Row 0 has _Measure=meas2, row 1 has _Measure=meas1.
fn records_model_with_two_rows() -> App {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("North");
wb.model.category_mut("_Measure").unwrap().add_item("meas1");
wb.model.category_mut("_Measure").unwrap().add_item("meas2");
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "meas2".into()),
]),
CellValue::Number(10.0),
);
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "meas1".into()),
]),
CellValue::Number(20.0),
);
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(
app.layout.is_records_mode(),
"setup: should be records mode"
);
assert_eq!(app.layout.row_count(), 2, "setup: should have 2 records");
let cols: Vec<String> = (0..app.layout.col_count())
.map(|i| app.layout.col_label(i))
.collect();
assert!(
cols.contains(&"Region".to_string()),
"setup: should have Region column; got {:?}",
cols
);
assert!(
cols.contains(&"_Measure".to_string()),
"setup: should have _Measure column; got {:?}",
cols
);
assert_eq!(
cols.last().unwrap(),
"Value",
"setup: Value must be last column; got {:?}",
cols
);
app
}
/// improvise-3zq (bug #2): `AddRecordRow` creates a cell with an empty
/// `CellKey` when no Page-axis categories supply coords — that cell
/// serialises as ` = 0` in .improv and re-appears on every records
/// toggle. Leaving records mode must clean up any such meaningless
/// records (inverse of the `SortData` that runs on entry).
#[test]
fn leaving_records_mode_cleans_empty_key_cells() {
use crate::model::cell::{CellKey, CellValue};
let mut app = records_model_with_two_rows();
// Simulate Tab-at-bottom-right having produced an empty-key cell.
app.model_state.workbook
.model
.set_cell(CellKey::new(vec![]), CellValue::Number(0.0));
assert!(
app.model_state.workbook
.model
.data
.iter_cells()
.any(|(k, _)| k.0.is_empty()),
"setup: empty-key cell should be present"
);
// Leave records mode via R.
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
assert!(
!app.layout.is_records_mode(),
"setup: should have left records mode"
);
assert!(
!app.model_state.workbook
.model
.data
.iter_cells()
.any(|(k, _)| k.0.is_empty()),
"empty-key records should be cleaned when leaving records mode"
);
}
/// improvise-3zq (bug #1): Enter on the bottom-right cell of records
/// view should commit and leave edit mode. Previously `CommitAndAdvance`
/// pushed an `EnterEditAtCursor` effect unconditionally, so the cursor
/// stayed put and we re-entered editing on the same cell.
#[test]
fn enter_at_bottom_right_of_records_view_exits_editing() {
let mut app = records_model_with_two_rows();
let last_row = app.layout.row_count() - 1;
let last_col = app.layout.col_count() - 1;
app.model_state.workbook.active_view_mut().selected = (last_row, last_col);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(app.view_state.mode.is_editing(), "setup: should be editing");
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
!app.view_state.mode.is_editing(),
"Enter at bottom-right should exit editing, got {:?}",
app.view_state.mode
);
assert!(
matches!(app.view_state.mode, AppMode::RecordsNormal),
"should return to RecordsNormal, got {:?}",
app.view_state.mode
);
}
/// improvise-hmu: TAB on the bottom-right cell of records view should
/// insert a new record below and move to the first cell of the new row
/// in edit mode.
#[test]
fn tab_on_bottom_right_of_records_inserts_below() {
let mut app = records_model_with_two_rows();
let initial_rows = app.layout.row_count();
assert!(initial_rows >= 1, "setup: need at least 1 record");
let last_row = initial_rows - 1;
let last_col = app.layout.col_count() - 1;
app.model_state.workbook.active_view_mut().selected = (last_row, last_col);
// Enter edit mode on the bottom-right cell
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(app.view_state.mode.is_editing(), "setup: should be editing");
// TAB should commit, insert below, move to first cell of new row
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.layout.row_count(),
initial_rows + 1,
"TAB on bottom-right should insert a record below"
);
assert_eq!(
app.model_state.workbook.active_view().selected,
(initial_rows, 0),
"TAB should move to first cell of the new row"
);
assert!(
app.view_state.mode.is_editing(),
"should enter edit mode on the new cell, got {:?}",
app.view_state.mode
);
}
/// Drill-view edits should stay staged in drill state until the user
/// navigates back, at which point ApplyAndClearDrill writes them through.
#[test]
fn drill_edit_is_staged_until_view_back() {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
wb.model.category_mut("Month").unwrap().add_item("Jan");
let record_key = CellKey::new(vec![
("Month".to_string(), "Jan".to_string()),
("Region".to_string(), "East".to_string()),
]);
wb.model
.set_cell(record_key.clone(), CellValue::Number(1.0));
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE))
.unwrap();
assert!(app.view_state.drill_state.is_some(), "drill should create drill state");
let value_col = (0..app.layout.col_count())
.find(|&col| app.layout.col_label(col) == "Value")
.expect("drill view should include a Value column");
app.model_state.workbook.active_view_mut().selected = (0, value_col);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.model_state.workbook.model.get_cell(&record_key),
Some(&CellValue::Number(1.0)),
"drill edit should remain staged until leaving the drill view"
);
assert_eq!(
app.view_state.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(0, "Value".to_string()))),
Some(&"9".to_string()),
"drill edit should be recorded in pending_edits"
);
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.model_state.workbook.model.get_cell(&record_key),
Some(&CellValue::Number(9.0)),
"leaving drill view should apply the staged edit"
);
}
/// Suspected bug: blanking a records-mode category coordinate should not
/// create an item with an empty name.
#[test]
fn blanking_records_category_does_not_create_empty_item() {
use crate::model::cell::{CellKey, CellValue};
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.model.category_mut("Region").unwrap().add_item("East");
wb.model.set_cell(
CellKey::new(vec![("Region".to_string(), "East".to_string())]),
CellValue::Number(1.0),
);
let mut app = App::new(wb, None);
app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
for _ in 0..4 {
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE))
.unwrap();
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
assert!(
!app.model_state.workbook
.model
.category("Region")
.unwrap()
.items
.contains_key(""),
"records-mode edits should not create empty category items"
);
}
#[test]
fn command_mode_buffer_cleared_on_reentry() {
use crossterm::event::KeyEvent;
let mut app = two_col_model();
// Enter command mode, type something, escape
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some("x"));
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
// Re-enter command mode — buffer should be cleared
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.buffers.get("command").map(|s| s.as_str()), Some(""));
}
// ── is_empty_model ──────────────────────────────────────────────────
#[test]
fn fresh_model_is_empty() {
let app = App::new(Workbook::new("T"), None);
assert!(
app.is_empty_model(),
"a brand-new model with only virtual categories should be empty"
);
}
#[test]
fn model_with_user_category_is_not_empty() {
let mut wb = Workbook::new("T");
wb.add_category("Sales").unwrap();
let app = App::new(wb, None);
assert!(
!app.is_empty_model(),
"a model with a user-defined category should not be empty"
);
}
// ── Help mode navigation ────────────────────────────────────────────
#[test]
fn help_page_next_advances_page() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 0;
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.help_page, 1, "l should advance to page 1");
}
#[test]
fn help_page_prev_goes_back() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 2;
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.help_page, 1, "h should go back to page 1");
}
#[test]
fn help_page_clamps_at_zero() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.view_state.help_page = 0;
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
.unwrap();
assert_eq!(app.view_state.help_page, 0, "page should not go below 0");
}
#[test]
fn help_page_clamps_at_max() {
use crate::ui::help::HELP_PAGE_COUNT;
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.view_state.help_page = HELP_PAGE_COUNT - 1;
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.view_state.help_page,
HELP_PAGE_COUNT - 1,
"page should not exceed the last page"
);
}
// ── Help mode exits ─────────────────────────────────────────────────
#[test]
fn help_q_returns_to_normal() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::Normal),
"q should return to Normal mode"
);
}
#[test]
fn help_esc_returns_to_normal() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::Normal),
"Esc should return to Normal mode"
);
}
#[test]
fn help_colon_enters_command_mode() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::Help;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in Help mode should enter CommandMode, got {:?}",
app.view_state.mode
);
}
// ── Effect error feedback ───────────────────────────────────────────
#[test]
fn add_item_to_nonexistent_category_sets_status() {
use crate::ui::effect::Effect;
let mut app = App::new(Workbook::new("T"), None);
let effect = crate::ui::effect::AddItem {
category: "Nonexistent".to_string(),
item: "x".to_string(),
};
effect.apply(&mut app);
assert!(
app.view_state.status_msg.contains("Unknown category"),
"should report unknown category, got: {:?}",
app.view_state.status_msg
);
}
#[test]
fn add_formula_with_bad_syntax_sets_status() {
use crate::ui::effect::Effect;
let mut app = App::new(Workbook::new("T"), None);
let effect = crate::ui::effect::AddFormula {
raw: "!!!invalid".to_string(),
target_category: "X".to_string(),
};
effect.apply(&mut app);
assert!(
app.view_state.status_msg.contains("Formula error"),
"should report formula error, got: {:?}",
app.view_state.status_msg
);
}
// ── Tile select stays in mode ───────────────────────────────────────
#[test]
fn tile_axis_change_stays_in_tile_select() {
let mut app = two_col_model();
app.view_state.mode = AppMode::TileSelect;
app.view_state.tile_cat_idx = 0;
// Press 'r' to set axis to Row — should stay in TileSelect
app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::TileSelect),
"should stay in TileSelect after axis change, got {:?}",
app.view_state.mode
);
assert!(
!app.view_state.status_msg.is_empty(),
"should show status feedback after axis change"
);
}
// ── Panel colon bindings ────────────────────────────────────────────
#[test]
fn category_panel_colon_enters_command_mode() {
let mut app = two_col_model();
app.view_state.mode = AppMode::CategoryPanel;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in CategoryPanel should enter CommandMode, got {:?}",
app.view_state.mode
);
}
#[test]
fn view_panel_colon_enters_command_mode() {
let mut app = two_col_model();
app.view_state.mode = AppMode::ViewPanel;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in ViewPanel should enter CommandMode, got {:?}",
app.view_state.mode
);
}
#[test]
fn tile_select_colon_enters_command_mode() {
let mut app = two_col_model();
app.view_state.mode = AppMode::TileSelect;
app.handle_key(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE))
.unwrap();
assert!(
matches!(app.view_state.mode, AppMode::CommandMode { .. }),
"colon in TileSelect should enter CommandMode, got {:?}",
app.view_state.mode
);
}
}