use std::{
cell::Cell,
io,
path::{Path, PathBuf},
sync::mpsc,
time::Duration,
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Layout},
style::Style,
widgets::Paragraph,
};
use crate::clipboard;
use crate::complete::{self, Completion};
use crate::data::Store;
use crate::data::lake_db::{self, LakeDb};
use crate::lake::{Lake, Level, Scope};
use crate::picker::Picker;
use crate::search::{SearchQuery, SearchState, SearchStatus};
use crate::ui::{
self, Browser, CellView, CellWindow, DataTable, Help, Panel, Prompt, Section, SelectionMode,
StatusBar, Theme,
};
use crate::view;
use polars::prelude::DataType;
use regex::Regex;
enum AppMode {
Normal,
Search,
Edit,
Command,
Picker,
}
type Block = ((usize, usize), (usize, usize));
const MAX_BLOCK: usize = 100_000;
#[derive(Clone, Copy)]
struct Fill {
block: Block,
mode: FillMode,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum FillMode {
Replace,
Prepend,
Append,
}
impl FillMode {
fn verb(self) -> &'static str {
match self {
FillMode::Replace => "fill",
FillMode::Prepend => "prepend to",
FillMode::Append => "append to",
}
}
fn combine(self, typed: &str, current: &str) -> String {
match self {
FillMode::Replace => typed.to_string(),
FillMode::Prepend => format!("{typed}{current}"),
FillMode::Append => format!("{current}{typed}"),
}
}
}
#[derive(Clone, Copy)]
enum Page {
Half,
Whole,
}
#[derive(Clone, Copy)]
enum EditStart {
Front,
End,
Empty,
}
struct Inspect {
scroll: usize,
rows: usize,
content: ui::cell::Content,
hunt: Option<Hunt>,
}
struct Hunt {
pattern: String,
regex: Regex,
hits: Vec<ui::cell::Hit>,
starts: Vec<usize>,
current: Option<usize>,
at: Option<(u16, bool)>,
}
impl Hunt {
fn new(pattern: String, regex: Regex) -> Self {
Self {
pattern,
regex,
hits: Vec::new(),
starts: Vec::new(),
current: None,
at: None,
}
}
fn step(&mut self, top: usize, rows: usize, forward: bool) -> Option<usize> {
let total = self.starts.len();
if total == 0 {
return None;
}
let showing = |line: usize| line >= top && line < top + rows;
let at = match self.current.filter(|&at| showing(self.starts[at])) {
Some(at) if forward => (at + 1) % total,
Some(at) => (at + total - 1) % total,
None if forward => self.starts.partition_point(|&line| line < top) % total,
None => self
.starts
.iter()
.rposition(|&line| line < top)
.unwrap_or(total - 1),
};
self.current = Some(at);
Some(self.starts[at])
}
}
#[derive(PartialEq)]
enum Screen {
Browser,
Viewer,
}
pub struct App {
file_path: Option<PathBuf>,
store: Option<Store>,
col_offset: usize,
cursor_row: usize,
pending_num: String,
pending_prefix: Option<char>,
message: Option<String>,
exit: bool,
error: Option<String>,
theme: Theme,
selection_mode: SelectionMode,
cursor_col: usize,
last_vis_col: usize,
last_frame_width: u16,
mode: AppMode,
edit_cell: Option<(usize, usize)>,
edit_fill: Option<Fill>,
visual_anchor: Option<(usize, usize)>,
register: Vec<Vec<String>>,
edit_buf: String,
edit_cursor: usize,
command_buf: String,
completion: Option<Completion>,
completion_index: Option<usize>,
search_buf: String,
search_state: Option<SearchState>,
search_rx: Option<mpsc::Receiver<Vec<usize>>>,
sort_rx: Option<mpsc::Receiver<polars::prelude::DataFrame>>,
filter_rx: Option<mpsc::Receiver<Vec<usize>>>,
spinner_tick: usize,
lake: Option<Lake>,
screen: Screen,
last_vp: usize,
help_visible: bool,
relative_rows: bool,
cell_view: bool,
widths: ui::Widths,
pinned: std::collections::BTreeSet<usize>,
picker: Option<Picker>,
inspect: Option<Inspect>,
}
impl App {
pub fn new(file: Option<PathBuf>) -> Self {
Self {
file_path: file,
store: None,
col_offset: 0,
cursor_row: 0,
pending_num: String::new(),
pending_prefix: None,
message: None,
exit: false,
error: None,
theme: Theme::catppuccin_mocha(),
selection_mode: SelectionMode::default(),
cursor_col: 0,
last_vis_col: 0,
last_frame_width: 0,
mode: AppMode::Normal,
edit_cell: None,
edit_fill: None,
visual_anchor: None,
register: Vec::new(),
edit_buf: String::new(),
edit_cursor: 0,
command_buf: String::new(),
completion: None,
completion_index: None,
search_buf: String::new(),
search_state: None,
search_rx: None,
sort_rx: None,
filter_rx: None,
spinner_tick: 0,
lake: None,
screen: Screen::Viewer,
last_vp: 20,
help_visible: false,
relative_rows: true,
cell_view: false,
widths: ui::Widths::new(),
pinned: std::collections::BTreeSet::new(),
picker: None,
inspect: None,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
if let Some(path) = self.file_path.clone() {
let size = terminal.size()?;
let vp = Self::viewport_rows(size.height, 0);
self.last_vp = vp;
if let Some(lake_path) = lake_db::detect(&path) {
match LakeDb::open(&lake_path, None).and_then(Lake::new) {
Ok(lake) => {
self.lake = Some(lake);
self.screen = Screen::Browser;
}
Err(e) => self.error = Some(e.to_string()),
}
} else {
match Store::open_file(&path, vp) {
Ok(store) => {
self.message = store.notes().map(str::to_string);
self.store = Some(store);
}
Err(e) => self.error = Some(e.to_string()),
}
}
}
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn viewport_rows(terminal_height: u16, panel: u16) -> usize {
(terminal_height as usize).saturating_sub(4 + panel as usize)
}
fn panel_height(&self, width: u16, height: u16) -> u16 {
if let Some(completion) = &self.completion {
return ui::panel_height(&completion.options, width);
}
if self.cell_view {
let room = (height / 2).max(2);
return match self.cell_under_cursor() {
Some((_, value)) => ui::cell_height(&value, width, room),
None => 0,
};
}
0
}
fn cell_under_cursor(&self) -> Option<(String, String)> {
let store = self.store.as_ref()?;
let (name, _) = store.column_info(self.cursor_col)?;
let value = store.cell_text(self.cursor_row, self.cursor_col)?;
Some((name, value))
}
fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
self.last_frame_width = area.width;
let panel = self.panel_height(area.width, area.height);
let vp = Self::viewport_rows(area.height, panel);
self.last_vp = vp;
if let Some(s) = &mut self.store
&& s.viewport_rows != vp
{
let _ = s.resize(vp);
}
if self.screen == Screen::Browser {
self.draw_browser(frame, area);
self.draw_help(frame, area);
return;
}
if matches!(self.selection_mode, SelectionMode::Row) {
self.col_offset = self.col_offset.min(self.max_col_offset());
}
if !matches!(self.selection_mode, SelectionMode::Row) {
let held = self.display_pins().contains(&self.cursor_col);
if !held && self.cursor_col < self.col_offset {
self.col_offset = self.cursor_col;
} else if !held && self.cursor_col > self.last_vis_col {
self.col_offset = self.col_offset_to_show_at_right(self.cursor_col);
}
}
let [table_area, panel_area, status_area] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(panel),
Constraint::Length(1),
])
.areas(area);
if let Some(picker) = &mut self.picker {
let len = picker.len();
picker.state.go_to(picker.state.selected, len);
picker
.state
.clamp_scroll(Browser::viewport_rows(table_area.height));
}
let file_name = self
.lake
.as_ref()
.and_then(|l| l.scope_label())
.or_else(|| {
self.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "plv".to_string());
let col_offset = self.col_offset;
let cursor_row = self.cursor_row;
let vis_col_cell = Cell::new(self.col_offset);
if let Some(store) = &self.store {
if self.search_rx.is_some() || self.sort_rx.is_some() || self.filter_rx.is_some() {
self.spinner_tick = self.spinner_tick.wrapping_add(1);
}
let cursor_col = match self.selection_mode {
SelectionMode::Row => col_offset,
_ => self.cursor_col,
};
let edited = store.edited_cells();
let sort_display = store.sort_display();
if let Some(picker) = &self.picker {
let rows = picker.rows();
frame.render_widget(
Browser {
title: picker.title(),
headers: Picker::headers(),
widths: Picker::widths(),
rows: &rows,
selected: picker.state.selected,
offset: picker.state.offset,
theme: &self.theme,
},
table_area,
);
} else {
frame.render_widget(
DataTable {
df: &store.current_view,
col_offset,
cursor_col,
row_offset: store.row_offset,
cursor_row,
selection_mode: self.selection_mode,
theme: &self.theme,
search: self.search_state.as_ref(),
search_col: self.search_state.as_ref().and_then(|s| s.col_idx),
last_vis_col_out: &vis_col_cell,
sort: &sort_display,
sort_tick: self.sort_rx.as_ref().map(|_| self.spinner_tick),
edited: &edited,
selection: self.visual_range(),
relative_rows: self.relative_rows,
widths: &self.widths,
pinned: &self.display_pins(),
},
table_area,
);
}
match self.mode {
AppMode::Search => {
frame.render_widget(
Prompt {
prefix: "/",
buffer: &self.search_buf,
cursor: self.search_buf.chars().count(),
theme: &self.theme,
},
status_area,
);
}
AppMode::Command => {
frame.render_widget(
Prompt {
prefix: ":",
buffer: &self.command_buf,
cursor: self.command_buf.chars().count(),
theme: &self.theme,
},
status_area,
);
}
AppMode::Edit => {
let label = match self.edit_fill {
Some(fill) => format!(
" {} {} cells: ",
fill.mode.verb(),
Self::block_size(fill.block)
),
None => self
.edit_cell
.and_then(|(_, col)| store.column_info(col))
.map_or_else(|| " ".to_string(), |(name, _)| format!(" {name}: ")),
};
frame.render_widget(
Prompt {
prefix: &label,
buffer: &self.edit_buf,
cursor: self.edit_cursor,
theme: &self.theme,
},
status_area,
);
}
AppMode::Normal | AppMode::Picker => {
let search_info = self.search_state.as_ref().map(|s| {
let (cur, total, complete) = s.match_info();
(s.query.raw.clone(), cur, total, complete)
});
frame.render_widget(
StatusBar {
file_name,
cursor_row,
total_rows: store.row_count(),
view: store.view.describe(&store.schema).map(|text| {
if store.filter_truncated() {
format!("{text} (first {})", store.row_count())
} else {
text
}
}),
col_position: self.col_position(),
total_cols: store.column_count(),
message: self.message.clone(),
dirty: store.dirty(),
selection: self
.visual_range()
.map(|((r0, r1), (c0, c1))| (r1 - r0 + 1, c1 - c0 + 1)),
pending_num: self.pending_num.clone(),
pending_prefix: self.pending_prefix,
theme: &self.theme,
search_info,
spinner_tick: self.spinner_tick,
sort_tick: self.sort_rx.as_ref().map(|_| self.spinner_tick),
filtering: store.filtering(),
help: if let Some(inspect) = &self.inspect {
match (inspect.content.switchable(), inspect.content.raw) {
(false, _) => " j/k:↕ g/G:start/end q:close ",
(true, false) => " j/k:↕ g/G:start/end r:raw q:close ",
(true, true) => " j/k:↕ g/G:start/end r:formatted q:close ",
}
} else if self.picker.is_some() {
" -:show a/A:all/one p:pin ⏎:apply esc "
} else if self.lake.is_some() {
" f:partitions T:snapshots b:back ?:help "
} else {
" j/k:↕ h/l:←→ /:search ?:help q:quit "
},
},
status_area,
);
}
}
} else if let Some(err) = &self.error {
frame.render_widget(
Paragraph::new(format!("Error: {err}")).style(Style::new().red()),
table_area,
);
} else {
frame.render_widget(
Paragraph::new(
"Usage: plv <file.csv|file.jsonl|file.parquet|lake.ducklake|bundle-dir>",
)
.centered(),
table_area,
);
}
if let Some(completion) = &self.completion {
frame.render_widget(
Panel {
items: &completion.options,
selected: self.completion_index,
theme: &self.theme,
},
panel_area,
);
} else if self.cell_view
&& let Some((name, value)) = self.cell_under_cursor()
{
frame.render_widget(
CellView {
name: &name,
value: &value,
theme: &self.theme,
},
panel_area,
);
}
self.draw_inspect(frame, area);
self.draw_help(frame, area);
self.last_vis_col = vis_col_cell.get();
}
fn draw_inspect(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
if self.inspect.is_none() {
return;
}
let Some((name, _)) = self.cell_under_cursor() else {
self.inspect = None;
return;
};
let dtype = self
.store
.as_ref()
.and_then(|store| store.column_info(self.cursor_col))
.map_or_else(String::new, |(_, dtype)| dtype.to_string());
let Some(inspect) = &self.inspect else {
return;
};
let title = ui::cell::title(&name, &dtype, &inspect.content);
let layout = ui::cell::window(&inspect.content.lines, &title, area);
let (popup, lines) = (layout.rect, &layout.lines);
let rows = ui::cell::text_rows(popup);
let under = (popup.width, inspect.content.raw);
if let Some(inspect) = &mut self.inspect
&& let Some(hunt) = &mut inspect.hunt
&& hunt.at != Some(under)
{
hunt.hits = ui::cell::find(
&inspect.content.lines,
&layout.lines,
&layout.origins,
&hunt.regex,
);
hunt.starts = ui::cell::match_lines(&hunt.hits);
hunt.at = Some(under);
hunt.current = None;
let top = inspect.scroll;
if let Some(line) = hunt.step(top, rows, true) {
inspect.scroll = line.saturating_sub(rows / 3);
}
}
let Some(inspect) = &self.inspect else {
return;
};
let scroll = inspect.scroll.min(ui::cell::max_scroll(lines.len(), rows));
if let Some(inspect) = &mut self.inspect {
inspect.rows = rows;
inspect.scroll = scroll;
}
let Some(inspect) = &self.inspect else {
return;
};
let (hits, current, pattern) = match &inspect.hunt {
Some(hunt) => (
hunt.hits.as_slice(),
hunt.current,
Some(hunt.pattern.as_str()),
),
None => ([].as_slice(), None, None),
};
frame.render_widget(
CellWindow {
name: &name,
dtype: &dtype,
content: &inspect.content,
scroll,
hits,
current,
pattern,
theme: &self.theme,
},
area,
);
}
fn draw_help(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
if !self.help_visible {
return;
}
frame.render_widget(
Help {
sections: self.help_sections(),
theme: &self.theme,
},
area,
);
}
fn help_sections(&self) -> &'static [Section<'static>] {
const MOVE: &[(&str, &str)] = &[
("j / \u{2193}", "Move down"),
("k / \u{2191}", "Move up"),
(
"Ctrl+d / Ctrl+u",
"Half a screen down / up, view and cursor",
),
("Ctrl+f / Ctrl+b", "A whole screen down / up"),
("gg / G", "First / last row"),
("{n}gg / {n}G", "Jump to row n"),
("zz / zt / zb", "Centre / top / bottom"),
("z> / z<", "Widen / narrow the cursor column"),
("z_", "Fit the column to what is on screen"),
("z=", "Put every column width back"),
("K", "Open the cursor cell in a window"),
("zk", "Show the cursor cell above the status bar"),
("#", "Relative or absolute row numbers"),
];
const COLUMNS: &[(&str, &str)] = &[
("h / l", "Scroll columns left / right"),
("{n}h / {n}l", "Jump n columns"),
("H", "First column"),
("0 / $", "Scroll to the first / last column"),
("Tab", "Cycle row \u{2192} column \u{2192} cell"),
("s", "Sort by cursor column"),
("-", "Hide the cursor column (:reset select brings it back)"),
("C", "The column picker: show, hide and pin from a list"),
("zp", "Pin / unpin the cursor column at the left edge"),
("z|", "Unpin every column"),
];
const SEARCH: &[(&str, &str)] = &[
("/", "Search (regex)"),
("n / N", "Next / previous match"),
("Esc", "Clear search and sorts"),
];
const EDIT: &[(&str, &str)] = &[
("i / a", "Edit cell, caret at start / end"),
("c", "Replace cell"),
("x", "Clear cell"),
("dd / {n}dd", "Delete the row, or n rows"),
("o / O", "Open a new row below / above"),
("u / Ctrl+r", "Undo / redo"),
("y", "Yank, to the register and the system clipboard"),
("p", "Paste the register at the cursor"),
("", "Your terminal's paste key pastes from outside"),
(
"Tab / Shift+Tab",
"While editing: commit, next / previous cell",
),
(":w :w! :w path", "Write (force / elsewhere)"),
(":q :q! :wq", "Quit (discarding / writing)"),
];
const VIEWS: &[(&str, &str)] = &[
(":select a b", "Show only these columns"),
(":hide a b", "Drop these columns"),
(":expand payload", "Lift a JSONL document into columns"),
(":filter c > 10", "Keep matching rows; `~` is a regex"),
(":filter a = x and b ~ y", "Conditions join with `and`"),
(":sort a b-", "Sort by columns, `-` for descending"),
(":select :sort", "The verb alone puts it back"),
(":reset [slot]", "Clear select, filter, sort, or all"),
(
"Tab / Shift+Tab",
"Complete, then step through the candidates",
),
];
const VISUAL: &[(&str, &str)] = &[
("v", "Start or cancel a selection"),
("", "Its shape follows the Tab mode"),
("c", "Replace every cell with one value"),
("i / a", "Prepend / append text to every cell"),
("x", "Clear the selected cells"),
("d", "Delete the selected rows"),
("y", "Yank the selection"),
];
const GENERAL: &[(&str, &str)] = &[("?", "This help"), ("q", "Quit")];
const LAKE: &[(&str, &str)] = &[
("f", "Partitions of this table"),
("b", "Back to the catalog"),
("T", "Snapshots (time travel)"),
];
const BROWSE: &[(&str, &str)] = &[
("j / k", "Move selection"),
("g / G", "First / last entry"),
("Ctrl+d / Ctrl+u", "Half page down / up"),
("Enter", "Open the selection"),
("l / f", "Partitions of this table"),
("T", "Snapshots (time travel)"),
("a", "Whole table (from partitions)"),
("h / Esc", "Back"),
];
const VIEWER: &[Section<'static>] = &[
("Rows", MOVE),
("Columns", COLUMNS),
("Search", SEARCH),
("Views", VIEWS),
("General", GENERAL),
];
const VIEWER_LAKE: &[Section<'static>] = &[
("Rows", MOVE),
("Columns", COLUMNS),
("Search", SEARCH),
("Lake", LAKE),
("Views", VIEWS),
("General", GENERAL),
];
const VIEWER_EDIT: &[Section<'static>] = &[
("Rows", MOVE),
("Columns", COLUMNS),
("Search", SEARCH),
("Edit", EDIT),
("Visual", VISUAL),
("Views", VIEWS),
("General", GENERAL),
];
const PICK: &[(&str, &str)] = &[
("j / k", "Move down the list"),
("g / G", "First / last column"),
("Ctrl+d / Ctrl+u", "Half page down / up"),
("-", "Show or hide this column"),
("a / A", "Show every column / hide all but this one"),
("p", "Pin or unpin this column"),
("Enter", "Apply"),
("Esc", "Cancel, changing nothing"),
];
const READ: &[(&str, &str)] = &[
("j / k", "Down / up a line"),
("Ctrl+d / Ctrl+u", "Half a window"),
("Ctrl+f / Ctrl+b", "A whole window"),
("g / G", "First / last line"),
("/", "Find in this value (regex)"),
("n / N", "Next / previous match"),
("", "Its own search: the table's is left alone"),
("r", "Raw ↔ formatted, where a format was recognised"),
("q / Esc / K", "Close"),
];
const BROWSER: &[Section<'static>] = &[("Catalog", BROWSE), ("General", GENERAL)];
const PICKER: &[Section<'static>] = &[("Columns", PICK)];
const CELL: &[Section<'static>] = &[("Cell", READ)];
if self.inspect.is_some() {
CELL
} else if self.picker.is_some() {
PICKER
} else if self.screen == Screen::Browser {
BROWSER
} else if self.lake.is_some() {
VIEWER_LAKE
} else if self.store.as_ref().is_some_and(Store::is_editable) {
VIEWER_EDIT
} else {
VIEWER
}
}
fn draw_browser(&mut self, frame: &mut Frame, area: ratatui::layout::Rect) {
let [list_area, status_area] =
Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(area);
let Some(lake) = &mut self.lake else { return };
let len = lake.list_len();
lake.state.go_to(lake.state.selected, len);
lake.state
.clamp_scroll(Browser::viewport_rows(list_area.height));
let rows = lake.rows();
frame.render_widget(
Browser {
title: lake.title(),
headers: lake.headers(),
widths: lake.widths(),
rows: &rows,
selected: lake.state.selected,
offset: lake.state.offset,
theme: &self.theme,
},
list_area,
);
let help = match lake.level {
Level::Tables => " Enter:open l:partitions T:snapshots ?:help q:quit ",
Level::Partitions { .. } => " Enter:open partition a:whole table h:back ?:help ",
Level::Snapshots => " Enter:travel to snapshot h:back ?:help ",
};
let line = match &self.message {
Some(msg) => format!(" {msg}"),
None => format!("{help} [{}/{}]", lake.state.selected + 1, len.max(1)),
};
let style = if self.message.is_some() {
Style::new()
.bg(self.theme.message_bg)
.fg(self.theme.message_fg)
} else {
Style::new()
.bg(self.theme.status_bg)
.fg(self.theme.status_fg)
};
frame.render_widget(Paragraph::new(line).style(style), status_area);
}
fn handle_browser_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(lake) = &mut self.lake else {
return Ok(());
};
let len = lake.list_len();
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => self.exit = true,
KeyCode::Char('j') | KeyCode::Down => lake.state.move_by(1, len),
KeyCode::Char('k') | KeyCode::Up => lake.state.move_by(-1, len),
KeyCode::Char('d') if ctrl => lake.state.move_by(self.last_vp as isize / 2, len),
KeyCode::Char('u') if ctrl => lake.state.move_by(-(self.last_vp as isize) / 2, len),
KeyCode::Char('g') | KeyCode::Home => lake.state.go_to(0, len),
KeyCode::Char('G') | KeyCode::End => lake.state.go_to(len.saturating_sub(1), len),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Char('f') => {
if let Level::Tables = lake.level {
let table = lake.state.selected;
if lake
.table(table)
.is_some_and(|t| !t.partition_cols.is_empty())
{
self.show_partitions(table, 0)?;
} else {
self.message = Some("Table is not partitioned".to_string());
}
}
}
KeyCode::Char('T') => {
lake.level = Level::Snapshots;
lake.state = Default::default();
let current = lake.current_snapshot_index();
lake.state.go_to(current, lake.list_len());
}
KeyCode::Char('h') | KeyCode::Left | KeyCode::Esc => {
if let Level::Partitions { table } = lake.level {
lake.level = Level::Tables;
lake.state = Default::default();
lake.state.go_to(table, lake.list_len());
} else if let Level::Snapshots = lake.level {
lake.level = Level::Tables;
lake.state = Default::default();
} else if self.store.is_some() {
self.screen = Screen::Viewer;
}
}
KeyCode::Char('a') => {
if let Level::Partitions { table } = lake.level {
self.open_scope(Scope {
table,
partition: None,
})?;
}
}
KeyCode::Enter => match lake.level {
Level::Tables => {
let table = lake.state.selected;
self.open_scope(Scope {
table,
partition: None,
})?;
}
Level::Partitions { table } => {
let partition = lake.state.selected;
self.open_scope(Scope {
table,
partition: Some(partition),
})?;
}
Level::Snapshots => {
let Some(snapshot) = lake.snapshots.get(lake.state.selected) else {
return Ok(());
};
let (id, time) = (snapshot.id, snapshot.short_time());
self.switch_snapshot(id, &time)?;
}
},
_ => {}
}
Ok(())
}
fn switch_snapshot(&mut self, snapshot: i64, time: &str) -> anyhow::Result<()> {
let Some(lake) = &self.lake else {
return Ok(());
};
let path = lake.db.path.clone();
let previous = lake
.scope
.and_then(|s| lake.table(s.table))
.map(|t| t.qualified_name());
let reopened = match LakeDb::open(&path, Some(snapshot)).and_then(Lake::new) {
Ok(lake) => lake,
Err(e) => {
self.message = Some(format!("Cannot read snapshot {snapshot}: {e}"));
return Ok(());
}
};
let target = previous.and_then(|name| {
reopened
.tables
.iter()
.position(|t| t.qualified_name() == name)
});
self.store = None;
self.reset_view();
self.lake = Some(reopened);
if let Some(table) = target {
self.open_scope(Scope {
table,
partition: None,
})?;
if let Some(lake) = &mut self.lake {
lake.state.go_to(table, lake.list_len());
}
}
self.message = Some(format!("Snapshot {snapshot} ({time})"));
Ok(())
}
fn show_partitions(&mut self, table: usize, select: usize) -> anyhow::Result<()> {
let Some(lake) = &mut self.lake else {
return Ok(());
};
if let Err(e) = lake.load_partitions(table) {
self.message = Some(format!("Cannot list partitions: {e}"));
return Ok(());
}
lake.level = Level::Partitions { table };
lake.state = Default::default();
lake.state.go_to(select, lake.list_len());
self.screen = Screen::Browser;
Ok(())
}
fn open_scope(&mut self, scope: Scope) -> anyhow::Result<()> {
let vp = self.last_vp;
let Some(lake) = &self.lake else {
return Ok(());
};
let Some(table) = lake.table(scope.table) else {
return Ok(());
};
let (partition, rows) = match scope.partition {
Some(i) => match lake.partitions.get(i) {
Some(p) => (Some(p), p.rows),
None => return Ok(()),
},
None => (None, table.rows),
};
let source = lake.db.source(table, partition);
let reader = lake.db.reader(Some(table), &source);
let opened = lake
.db
.try_clone()
.and_then(|conn| Store::new_lake(conn, source, reader, vp, rows as usize));
match opened {
Ok(store) => {
self.store = Some(store);
self.reset_view();
if let Some(lake) = &mut self.lake {
lake.scope = Some(scope);
}
self.screen = Screen::Viewer;
}
Err(e) => self.message = Some(format!("Cannot open: {e}")),
}
Ok(())
}
fn reset_view(&mut self) {
self.col_offset = 0;
self.cursor_row = 0;
self.cursor_col = 0;
self.pending_num.clear();
self.search_state = None;
self.search_rx = None;
self.sort_rx = None;
self.filter_rx = None;
}
fn handle_events(&mut self) -> io::Result<()> {
if self.poll_sort() || self.poll_search() || self.poll_filter() {
return Ok(());
}
if (self.search_rx.is_some() || self.sort_rx.is_some() || self.filter_rx.is_some())
&& !event::poll(Duration::from_millis(50))?
{
return Ok(());
}
let outcome = match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key_event(key),
Event::Paste(text) => self.handle_paste(text),
_ => Ok(()),
};
if let Err(e) = outcome {
self.error = Some(e.to_string());
}
Ok(())
}
fn handle_paste(&mut self, text: String) -> anyhow::Result<()> {
if !matches!(self.mode, AppMode::Normal | AppMode::Picker) {
let flat = text.replace(['\n', '\r', '\t'], " ");
match self.mode {
AppMode::Edit => {
let at = self.edit_byte_index(self.edit_cursor);
self.edit_buf.insert_str(at, &flat);
self.edit_cursor += flat.chars().count();
}
AppMode::Command => {
self.command_buf.push_str(&flat);
self.completion = None;
self.completion_index = None;
}
AppMode::Search => self.search_buf.push_str(&flat),
_ => {}
}
return Ok(());
}
if matches!(self.mode, AppMode::Picker) {
return Ok(());
}
let block = clipboard::parse(&text);
if block.is_empty() {
return Ok(());
}
self.paste_block(&block)
}
fn poll_search(&mut self) -> bool {
let mut changed = false;
loop {
let result = match &self.search_rx {
None => break,
Some(rx) => rx.try_recv(),
};
match result {
Ok(rows) => {
let shown: Vec<usize> = match &self.store {
Some(store) => rows
.into_iter()
.filter_map(|r| store.search_row_to_display(r))
.collect(),
None => rows,
};
if let Some(state) = &mut self.search_state {
state.matching_rows.extend(shown);
changed = true;
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.search_rx = None;
if let Some(state) = &mut self.search_state {
state.status = SearchStatus::Complete;
}
changed = true;
break;
}
}
}
let needs_jump = self
.search_state
.as_ref()
.is_some_and(|s| !s.initial_jump_done && !s.matching_rows.is_empty());
if needs_jump {
let cursor = self.cursor_row;
let row = self.search_state.as_mut().and_then(|s| {
s.initial_jump_done = true;
s.next_from(cursor)
});
if let Some(row) = row {
let _ = self.cursor_to(row);
}
}
changed
}
fn poll_filter(&mut self) -> bool {
let Some(rx) = &self.filter_rx else {
return false;
};
let mut batches = Vec::new();
let mut finished = false;
loop {
match rx.try_recv() {
Ok(batch) => batches.push(batch),
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
finished = true;
break;
}
}
}
if batches.is_empty() && !finished {
return false;
}
if let Some(store) = &mut self.store {
for batch in batches {
if !store.extend_filter(batch).unwrap_or(false) {
finished = true;
break;
}
}
if finished {
let _ = store.finish_filter();
}
}
if finished {
self.filter_rx = None;
}
let last = self
.store
.as_ref()
.map_or(0, |s| s.row_count().saturating_sub(1));
self.cursor_row = self.cursor_row.min(last);
true
}
fn poll_sort(&mut self) -> bool {
let result = match &self.sort_rx {
None => return false,
Some(rx) => rx.try_recv(),
};
match result {
Ok(df) => {
self.sort_rx = None;
if let Some(store) = &mut self.store {
let _ = store.adopt_sorted(df);
}
true
}
Err(mpsc::TryRecvError::Empty) => false,
Err(mpsc::TryRecvError::Disconnected) => {
self.sort_rx = None;
true
}
}
}
fn handle_key_event(&mut self, key: KeyEvent) -> anyhow::Result<()> {
self.message = None;
if self.help_visible {
self.help_visible = false;
return Ok(());
}
if key.code == KeyCode::Char('?') && matches!(self.mode, AppMode::Normal | AppMode::Picker)
{
self.help_visible = true;
return Ok(());
}
if self.screen == Screen::Browser {
return self.handle_browser_key(key);
}
if self.inspect.is_some() && matches!(self.mode, AppMode::Normal) {
return self.handle_inspect_key(key);
}
match self.mode {
AppMode::Search => self.handle_search_key(key),
AppMode::Edit => self.handle_edit_key(key),
AppMode::Command => self.handle_command_key(key),
AppMode::Picker => self.handle_picker_key(key),
AppMode::Normal => self.handle_normal_key(key),
}
}
fn handle_search_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
match key.code {
KeyCode::Esc => {
self.mode = AppMode::Normal;
self.search_buf.clear();
}
KeyCode::Enter => {
let raw = std::mem::take(&mut self.search_buf);
self.mode = AppMode::Normal;
if let Some(inspect) = &mut self.inspect {
if raw.is_empty() {
inspect.hunt = None;
return Ok(());
}
match Regex::new(&raw) {
Err(e) => self.message = Some(format!("Bad regex: {e}")),
Ok(regex) => inspect.hunt = Some(Hunt::new(raw, regex)),
}
return Ok(());
}
if raw.is_empty() {
self.search_state = None;
self.search_rx = None;
return Ok(());
}
match SearchQuery::new(raw) {
Err(e) => {
self.message = Some(format!("Bad regex: {e}"));
}
Ok(query) => {
if let Some(store) = &self.store {
let col_name = match self.selection_mode {
SelectionMode::Column | SelectionMode::Cell => {
store.column_info(self.cursor_col).map(|(name, _)| name)
}
SelectionMode::Row => None,
};
let (tx, rx) = mpsc::channel();
store.search_async(query.raw.clone(), col_name.clone(), tx);
self.search_rx = Some(rx);
let col_idx = col_name.map(|_| self.cursor_col);
self.search_state = Some(SearchState::new(query, col_idx));
}
}
}
}
KeyCode::Backspace => {
self.search_buf.pop();
}
KeyCode::Char(c) => {
self.search_buf.push(c);
}
_ => {}
}
Ok(())
}
fn handle_inspect_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(inspect) = &mut self.inspect else {
return Ok(());
};
let rows = inspect.rows.max(1);
match key.code {
KeyCode::Char('j') | KeyCode::Down => inspect.scroll += 1,
KeyCode::Char('k') | KeyCode::Up => inspect.scroll = inspect.scroll.saturating_sub(1),
KeyCode::Char('d') if ctrl => inspect.scroll += (rows / 2).max(1),
KeyCode::Char('u') if ctrl => {
inspect.scroll = inspect.scroll.saturating_sub((rows / 2).max(1))
}
KeyCode::Char('f') if ctrl => inspect.scroll += rows,
KeyCode::PageDown => inspect.scroll += rows,
KeyCode::Char('b') if ctrl => inspect.scroll = inspect.scroll.saturating_sub(rows),
KeyCode::PageUp => inspect.scroll = inspect.scroll.saturating_sub(rows),
KeyCode::Char('g') | KeyCode::Home => inspect.scroll = 0,
KeyCode::Char('G') | KeyCode::End => inspect.scroll = usize::MAX,
KeyCode::Char('q') | KeyCode::Char('K') | KeyCode::Esc => self.inspect = None,
KeyCode::Char('/') if !ctrl => {
self.search_buf.clear();
self.mode = AppMode::Search;
}
KeyCode::Char('n') | KeyCode::Char('N') if !ctrl => {
let forward = key.code == KeyCode::Char('n');
let top = inspect.scroll;
let rows = inspect.rows.max(1);
if let Some(hunt) = &mut inspect.hunt
&& let Some(line) = hunt.step(top, rows, forward)
{
inspect.scroll = line.saturating_sub(rows / 3);
}
}
KeyCode::Char('r') if !ctrl && inspect.content.switchable() => {
let raw = !inspect.content.raw;
if let Some((_, value)) = self.cell_under_cursor() {
let content = ui::cell::Content::new(&value, raw, &self.theme);
if let Some(inspect) = &mut self.inspect {
inspect.content = content;
}
}
}
_ => {}
}
Ok(())
}
fn handle_normal_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if let Some(prefix) = self.pending_prefix.take() {
return self.resolve_prefix(prefix, key.code);
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => self.quit(false),
KeyCode::Char('0') if !ctrl && self.pending_num.is_empty() => {
self.col_offset = 0;
self.cursor_col = 0;
}
KeyCode::Char(c) if c.is_ascii_digit() && !ctrl => {
self.pending_num.push(c);
}
KeyCode::Char('j') | KeyCode::Down => {
let n = self.take_count(1);
self.cursor_down(n)?;
}
KeyCode::Char('k') | KeyCode::Up => {
let n = self.take_count(1);
self.cursor_up(n)?;
}
KeyCode::Char('d') if ctrl => self.scroll_page(true, Page::Half)?,
KeyCode::Char('u') if ctrl => self.scroll_page(false, Page::Half)?,
KeyCode::Char('f') if ctrl => self.scroll_page(true, Page::Whole)?,
KeyCode::Char('b') if ctrl => self.scroll_page(false, Page::Whole)?,
KeyCode::PageDown => self.scroll_page(true, Page::Whole)?,
KeyCode::PageUp => self.scroll_page(false, Page::Whole)?,
KeyCode::Char('g') => self.pending_prefix = Some('g'),
KeyCode::Char('d') if !ctrl && self.visual_anchor.is_none() => {
self.pending_prefix = Some('d')
}
KeyCode::Home => {
self.pending_num.clear();
self.cursor_to(0)?;
}
KeyCode::Char('G') | KeyCode::End => {
if self.pending_num.is_empty() {
let last = self
.store
.as_ref()
.map_or(0, |s| s.row_count().saturating_sub(1));
self.cursor_to(last)?;
} else {
let s = std::mem::take(&mut self.pending_num);
self.jump_to_line(&s)?;
}
}
KeyCode::Char('z' | 'Z') => {
self.pending_num.clear();
self.pending_prefix = Some('z');
}
KeyCode::Char('h') | KeyCode::Left => {
let n = self.take_count(1);
self.column_left(n);
}
KeyCode::Char('l') | KeyCode::Right => {
let n = self.take_count(1);
self.column_right(n);
}
KeyCode::Char('H') => {
self.pending_num.clear();
self.col_offset = 0;
self.cursor_col = 0;
}
KeyCode::Char('$') => {
self.pending_num.clear();
self.cursor_col = self
.store
.as_ref()
.map_or(0, |s| s.column_count().saturating_sub(1));
self.col_offset = self.max_col_offset();
}
KeyCode::Char('C') => {
self.pending_num.clear();
self.open_picker();
}
KeyCode::Char('-') => {
self.pending_num.clear();
self.adopt_column_cursor();
self.hide_column()?;
}
KeyCode::Char('s') => {
self.visual_anchor = None;
if let Some(reason) = self.store.as_ref().and_then(Store::sort_blocked) {
self.message = Some(reason);
return Ok(());
}
self.adopt_column_cursor();
if let Some(store) = &mut self.store {
self.sort_rx = Some(store.begin_sort(self.cursor_col));
self.cursor_row = 0;
self.search_state = None;
self.search_rx = None;
}
}
KeyCode::Tab => {
self.visual_anchor = None;
self.selection_mode = self.selection_mode.cycle();
if !matches!(self.selection_mode, SelectionMode::Row) {
self.cursor_col = self.col_offset;
}
self.search_state = None;
self.search_rx = None;
}
KeyCode::Char('f') if self.lake.is_some() => {
self.pending_num.clear();
if let Some(scope) = self.lake.as_ref().and_then(|l| l.scope) {
self.show_partitions(scope.table, scope.partition.unwrap_or(0))?;
}
}
KeyCode::Char('b') if self.lake.is_some() => {
self.pending_num.clear();
self.screen = Screen::Browser;
}
KeyCode::Char('T') if self.lake.is_some() => {
self.pending_num.clear();
if let Some(lake) = &mut self.lake {
lake.level = Level::Snapshots;
lake.state = Default::default();
let current = lake.current_snapshot_index();
lake.state.go_to(current, lake.list_len());
self.screen = Screen::Browser;
}
}
KeyCode::Char('/') => {
self.pending_num.clear();
self.search_buf.clear();
self.mode = AppMode::Search;
}
KeyCode::Char('n') => {
self.pending_num.clear();
if let Some(state) = &mut self.search_state {
if let Some(ci) = state.col_idx {
self.cursor_col = ci;
}
if let Some(row) = state.next_from(self.cursor_row) {
self.cursor_to(row)?;
}
}
}
KeyCode::Char('N') => {
self.pending_num.clear();
if let Some(state) = &mut self.search_state {
if let Some(ci) = state.col_idx {
self.cursor_col = ci;
}
if let Some(row) = state.prev_from(self.cursor_row) {
self.cursor_to(row)?;
}
}
}
KeyCode::Char('v') => {
self.pending_num.clear();
self.visual_anchor = match self.visual_anchor {
Some(_) => None,
None => Some((self.cursor_row, self.cursor_col)),
};
}
KeyCode::Char('c') if self.visual_anchor.is_some() => {
self.begin_fill(FillMode::Replace)?
}
KeyCode::Char('i') if self.visual_anchor.is_some() => {
self.begin_fill(FillMode::Prepend)?
}
KeyCode::Char('a') if self.visual_anchor.is_some() => {
self.begin_fill(FillMode::Append)?
}
KeyCode::Char('x') if self.visual_anchor.is_some() => self.clear_selection()?,
KeyCode::Char('d') if self.visual_anchor.is_some() => {
let rows = self.visual_range().map(|((first, last), _)| (first, last));
if let Some((first, last)) = rows {
self.delete_rows(first, last - first + 1)?;
}
}
KeyCode::Char('K') => {
self.pending_num.clear();
self.adopt_column_cursor();
if let Some((_, value)) = self.cell_under_cursor() {
self.inspect = Some(Inspect {
scroll: 0,
rows: 0,
content: ui::cell::Content::new(&value, false, &self.theme),
hunt: None,
});
}
}
KeyCode::Char('#') => {
self.pending_num.clear();
self.relative_rows = !self.relative_rows;
}
KeyCode::Char('o') => self.open_row(true)?,
KeyCode::Char('O') => self.open_row(false)?,
KeyCode::Char('y') => self.yank()?,
KeyCode::Char('p') => self.paste()?,
KeyCode::Char('i') => self.begin_edit(EditStart::Front)?,
KeyCode::Char('a') => self.begin_edit(EditStart::End)?,
KeyCode::Char('c') => self.begin_edit(EditStart::Empty)?,
KeyCode::Char('x') => self.clear_cell()?,
KeyCode::Char('u') => self.undo()?,
KeyCode::Char('r') if ctrl => self.redo()?,
KeyCode::Char(':') => {
self.pending_num.clear();
self.command_buf.clear();
self.mode = AppMode::Command;
}
KeyCode::Esc if self.cell_view => {
self.pending_num.clear();
self.cell_view = false;
}
KeyCode::Esc if self.visual_anchor.is_some() => {
self.pending_num.clear();
self.visual_anchor = None;
}
KeyCode::Esc => {
self.search_state = None;
self.search_rx = None; self.pending_num.clear();
if let Some(store) = &mut self.store
&& !store.view.sort.is_empty()
{
let _ = store.clear_sort();
self.cursor_row = 0;
}
}
_ => {
self.pending_num.clear();
}
}
Ok(())
}
fn adopt_column_cursor(&mut self) {
if matches!(self.selection_mode, SelectionMode::Row) {
self.selection_mode = SelectionMode::Cell;
self.cursor_col = self.col_offset;
}
}
fn ready_to_edit(&mut self) -> bool {
let blocked = match &self.store {
None => Some("no file open"),
Some(store) => store.edit_blocked(),
};
if let Some(reason) = blocked {
self.message = Some(reason.to_string());
return false;
}
if self.visual_anchor.is_none() {
self.adopt_column_cursor();
}
true
}
fn begin_edit(&mut self, start: EditStart) -> anyhow::Result<()> {
self.pending_num.clear();
if !self.ready_to_edit() {
return Ok(());
}
let cell = (self.cursor_row, self.cursor_col);
let Some(current) = self
.store
.as_ref()
.and_then(|s| s.cell_text(cell.0, cell.1))
else {
return Ok(());
};
self.edit_buf = match start {
EditStart::Empty => String::new(),
EditStart::Front | EditStart::End => current,
};
self.edit_cursor = match start {
EditStart::Front => 0,
EditStart::End | EditStart::Empty => self.edit_buf.chars().count(),
};
self.edit_cell = Some(cell);
self.mode = AppMode::Edit;
Ok(())
}
fn clear_cell(&mut self) -> anyhow::Result<()> {
self.pending_num.clear();
if !self.ready_to_edit() {
return Ok(());
}
let cell = (self.cursor_row, self.cursor_col);
if let Some(store) = &mut self.store {
store.edit([(cell, String::new())])?;
}
Ok(())
}
fn visual_range(&self) -> Option<Block> {
self.block_from(self.visual_anchor?)
}
fn cursor_block(&self) -> Option<Block> {
self.block_from((self.cursor_row, self.cursor_col))
}
fn block_from(&self, (anchor_row, anchor_col): (usize, usize)) -> Option<Block> {
let store = self.store.as_ref()?;
let rows = (
anchor_row.min(self.cursor_row),
anchor_row.max(self.cursor_row),
);
let cols = (
anchor_col.min(self.cursor_col),
anchor_col.max(self.cursor_col),
);
let all_rows = (0, store.row_count().saturating_sub(1));
let all_cols = (0, store.column_count().saturating_sub(1));
Some(match self.selection_mode {
SelectionMode::Row => (rows, all_cols),
SelectionMode::Column => (all_rows, cols),
SelectionMode::Cell => (rows, cols),
})
}
fn block_size(((r0, r1), (c0, c1)): Block) -> usize {
(r1 - r0 + 1).saturating_mul(c1 - c0 + 1)
}
fn cells_in(((r0, r1), (c0, c1)): Block) -> impl Iterator<Item = (usize, usize)> {
(r0..=r1).flat_map(move |row| (c0..=c1).map(move |col| (row, col)))
}
fn within_limit(&mut self, block: Block) -> bool {
let size = Self::block_size(block);
if size > MAX_BLOCK {
self.message = Some(format!(
"{size} cells is too many to handle at once (limit {MAX_BLOCK})"
));
return false;
}
true
}
fn operable_range(&mut self) -> Option<Block> {
self.pending_num.clear();
if !self.ready_to_edit() {
return None;
}
let block = self.visual_range()?;
self.within_limit(block).then_some(block)
}
fn yank(&mut self) -> anyhow::Result<()> {
self.pending_num.clear();
let block = match self.visual_anchor {
Some(_) => self.visual_range(),
None => self.cursor_block(),
};
let Some(block) = block.filter(|&b| self.within_limit(b)) else {
return Ok(());
};
let Some(store) = self.store.as_ref() else {
return Ok(());
};
self.register = store.block_text(block.0, block.1)?;
let cells = Self::block_size(block);
if let Some((row, col)) = self.visual_anchor.take() {
self.cursor_col = col;
self.cursor_to(row)?;
}
let shared = clipboard::copy(&clipboard::tsv(&self.register)).unwrap_or(false);
self.message = Some(match shared {
true => format!("yanked {cells} cells to the clipboard"),
false => format!("yanked {cells} cells"),
});
Ok(())
}
fn paste(&mut self) -> anyhow::Result<()> {
self.pending_num.clear();
if self.register.is_empty() {
self.message = Some("nothing to paste".to_string());
return Ok(());
}
let block = std::mem::take(&mut self.register);
let result = self.paste_block(&block);
self.register = block;
result
}
fn paste_block(&mut self, block: &[Vec<String>]) -> anyhow::Result<()> {
if !self.ready_to_edit() {
return Ok(());
}
let Some(store) = self.store.as_ref() else {
return Ok(());
};
let last_row = store.row_count().saturating_sub(1);
let last_col = store.column_count().saturating_sub(1);
let mut edits = Vec::new();
let mut clipped = 0usize;
for (down, row) in block.iter().enumerate() {
for (across, value) in row.iter().enumerate() {
let cell = (self.cursor_row + down, self.cursor_col + across);
if cell.0 > last_row || cell.1 > last_col {
clipped += 1;
} else {
edits.push((cell, value.clone()));
}
}
}
let pasted = edits.len();
if let Some(store) = &mut self.store {
store.edit(edits)?;
}
self.visual_anchor = None;
self.message = Some(match clipped {
0 => format!("pasted {pasted} cells"),
n => format!("pasted {pasted} cells, {n} past the edge"),
});
Ok(())
}
fn begin_fill(&mut self, mode: FillMode) -> anyhow::Result<()> {
let Some(block) = self.operable_range() else {
return Ok(());
};
self.edit_buf.clear();
self.edit_cursor = 0;
self.edit_cell = Some((self.cursor_row, self.cursor_col));
self.edit_fill = Some(Fill { block, mode });
self.mode = AppMode::Edit;
Ok(())
}
fn clear_selection(&mut self) -> anyhow::Result<()> {
let Some(block) = self.operable_range() else {
return Ok(());
};
let edits: Vec<_> = Self::cells_in(block)
.map(|cell| (cell, String::new()))
.collect();
let cleared = edits.len();
if let Some(store) = &mut self.store {
store.edit(edits)?;
}
self.visual_anchor = None;
self.message = Some(format!("cleared {cleared} cells"));
Ok(())
}
fn delete_rows(&mut self, first: usize, count: usize) -> anyhow::Result<()> {
self.pending_num.clear();
let blocked = match &self.store {
None => Some("no file open"),
Some(store) => store.delete_blocked(),
};
if let Some(reason) = blocked {
self.message = Some(reason.to_string());
return Ok(());
}
let deleted = match &mut self.store {
Some(store) => store.delete_rows(first..first + count)?,
None => 0,
};
self.visual_anchor = None;
let last = self
.store
.as_ref()
.map_or(0, |s| s.row_count().saturating_sub(1));
if self.cursor_row > last {
self.cursor_to(last)?;
}
self.message = Some(format!(
"deleted {deleted} row{}",
if deleted == 1 { "" } else { "s" }
));
Ok(())
}
fn open_row(&mut self, below: bool) -> anyhow::Result<()> {
self.pending_num.clear();
let blocked = match &self.store {
None => Some("no file open"),
Some(store) => store.insert_blocked(),
};
if let Some(reason) = blocked {
self.message = Some(reason.to_string());
return Ok(());
}
let at = self.cursor_row;
let landed = match &mut self.store {
Some(store) => store.add_row(at, below)?,
None => at,
};
self.visual_anchor = None;
self.cursor_to(landed)?;
if matches!(self.selection_mode, SelectionMode::Row) {
self.selection_mode = SelectionMode::Cell;
}
self.cursor_col = 0;
self.col_offset = 0;
self.begin_edit(EditStart::Empty)
}
fn undo(&mut self) -> anyhow::Result<()> {
self.pending_num.clear();
let Some(store) = &mut self.store else {
return Ok(());
};
if !store.undo()? {
self.message = Some("nothing to undo".to_string());
}
Ok(())
}
fn redo(&mut self) -> anyhow::Result<()> {
self.pending_num.clear();
let Some(store) = &mut self.store else {
return Ok(());
};
if !store.redo()? {
self.message = Some("nothing to redo".to_string());
}
Ok(())
}
fn handle_edit_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => self.end_edit(),
KeyCode::Enter => {
self.commit_edit()?;
self.end_edit();
}
KeyCode::Tab => self.commit_and_step(1)?,
KeyCode::BackTab => self.commit_and_step(-1)?,
KeyCode::Left => self.edit_cursor = self.edit_cursor.saturating_sub(1),
KeyCode::Right => {
self.edit_cursor = (self.edit_cursor + 1).min(self.edit_buf.chars().count());
}
KeyCode::Home => self.edit_cursor = 0,
KeyCode::End => self.edit_cursor = self.edit_buf.chars().count(),
KeyCode::Backspace => {
if self.edit_cursor > 0 {
self.edit_cursor -= 1;
self.remove_edit_char(self.edit_cursor);
}
}
KeyCode::Delete => self.remove_edit_char(self.edit_cursor),
KeyCode::Char(c) if !ctrl => {
let at = self.edit_byte_index(self.edit_cursor);
self.edit_buf.insert(at, c);
self.edit_cursor += 1;
}
_ => {}
}
Ok(())
}
fn commit_edit(&mut self) -> anyhow::Result<()> {
let Some(cell) = self.edit_cell else {
return Ok(());
};
let value = std::mem::take(&mut self.edit_buf);
let edits = match self.edit_fill {
Some(fill) => self.fill_edits(fill, &value)?,
None => {
let unchanged = self
.store
.as_ref()
.and_then(|s| s.cell_text(cell.0, cell.1))
.is_some_and(|current| current == value);
if unchanged {
return Ok(());
}
vec![(cell, value)]
}
};
let warning = edits
.iter()
.find_map(|((_, col), written)| self.type_warning(*col, written));
let filled = edits.len();
if let Some(store) = &mut self.store {
store.edit(edits)?;
}
self.visual_anchor = None;
self.message = warning.or_else(|| (filled > 1).then(|| format!("filled {filled} cells")));
Ok(())
}
fn fill_edits(&self, fill: Fill, typed: &str) -> anyhow::Result<Vec<((usize, usize), String)>> {
if fill.mode == FillMode::Replace {
return Ok(Self::cells_in(fill.block)
.map(|cell| (cell, typed.to_string()))
.collect());
}
let Some(store) = self.store.as_ref() else {
return Ok(Vec::new());
};
let ((first_row, _), (first_col, _)) = fill.block;
let current = store.block_text(fill.block.0, fill.block.1)?;
Ok(Self::cells_in(fill.block)
.map(|(row, col)| {
let existing = current
.get(row - first_row)
.and_then(|cells| cells.get(col - first_col))
.map_or("", String::as_str);
((row, col), fill.mode.combine(typed, existing))
})
.collect())
}
fn commit_and_step(&mut self, delta: isize) -> anyhow::Result<()> {
if self.edit_fill.is_some() {
self.commit_edit()?;
self.end_edit();
return Ok(());
}
self.commit_edit()?;
let last = self
.store
.as_ref()
.map_or(0, |s| s.column_count().saturating_sub(1));
let next = self.cursor_col as isize + delta;
self.end_edit();
if next < 0 || next as usize > last {
return Ok(());
}
self.cursor_col = next as usize;
self.begin_edit(EditStart::End)
}
fn type_warning(&self, col: usize, value: &str) -> Option<String> {
if value.is_empty() {
return None; }
let (name, dtype) = self.store.as_ref()?.column_info(col)?;
let fits = match &dtype {
d if d.is_integer() => value.parse::<i64>().is_ok(),
d if d.is_float() => value.parse::<f64>().is_ok(),
DataType::Boolean => matches!(value, "true" | "false"),
_ => true,
};
(!fits).then(|| format!("\"{value}\" is not {dtype} — column \"{name}\" becomes text"))
}
fn end_edit(&mut self) {
self.mode = AppMode::Normal;
self.edit_cell = None;
self.edit_fill = None;
self.edit_buf.clear();
self.edit_cursor = 0;
}
fn edit_byte_index(&self, n: usize) -> usize {
self.edit_buf
.char_indices()
.nth(n)
.map_or(self.edit_buf.len(), |(i, _)| i)
}
fn remove_edit_char(&mut self, at: usize) {
if at < self.edit_buf.chars().count() {
let i = self.edit_byte_index(at);
self.edit_buf.remove(i);
}
}
fn handle_command_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
self.mode = AppMode::Normal;
self.command_buf.clear();
self.forget_completion();
}
KeyCode::Enter => {
let line = std::mem::take(&mut self.command_buf);
self.mode = AppMode::Normal;
self.forget_completion();
self.run_command(line.trim())?;
}
KeyCode::Backspace => {
self.forget_completion();
if self.command_buf.pop().is_none() {
self.mode = AppMode::Normal;
}
}
KeyCode::Tab => return self.complete_command(false),
KeyCode::BackTab => return self.complete_command(true),
KeyCode::Char(c) if !ctrl => {
self.command_buf.push(c);
self.forget_completion();
}
_ => {}
}
Ok(())
}
fn complete_command(&mut self, backwards: bool) -> anyhow::Result<()> {
if let Some(completion) = &self.completion
&& completion.options.len() > 1
{
let count = completion.options.len();
let next = match self.completion_index {
Some(index) if backwards => (index + count - 1) % count,
Some(index) => (index + 1) % count,
None if backwards => count - 1,
None => 0,
};
self.command_buf = completion.with(next);
self.completion_index = Some(next);
return Ok(());
}
let Some(store) = &self.store else {
return Ok(());
};
let Some(found) = complete::complete(&self.command_buf, &store.schema) else {
self.forget_completion();
return Ok(());
};
self.command_buf = found.extended();
self.completion_index = None;
self.completion = (found.options.len() > 1).then_some(found);
Ok(())
}
fn forget_completion(&mut self) {
self.completion = None;
self.completion_index = None;
}
fn run_command(&mut self, line: &str) -> anyhow::Result<()> {
let (word, rest) = match line.split_once(char::is_whitespace) {
Some((word, rest)) => (word, rest.trim()),
None => (line, ""),
};
let force = word.ends_with('!');
let path = (!rest.is_empty()).then(|| PathBuf::from(rest));
match word.trim_end_matches('!') {
"" => {}
"w" => {
self.write(path.as_deref(), force)?;
}
"wq" | "x" => {
if self.write(path.as_deref(), force)? {
self.exit = true;
}
}
"q" => self.quit(force),
_ => self.run_view_command(line)?,
}
Ok(())
}
fn run_view_command(&mut self, line: &str) -> anyhow::Result<()> {
let Some(store) = &self.store else {
self.message = Some("no file open".to_string());
return Ok(());
};
let command = match view::parse(line, &store.schema) {
Ok(command) => command,
Err(e) => {
self.message = Some(e.message);
return Ok(());
}
};
self.apply_view_command(command)
}
fn apply_view_command(&mut self, command: view::Command) -> anyhow::Result<()> {
let Some(store) = &self.store else {
self.message = Some("no file open".to_string());
return Ok(());
};
let refiltered = matches!(
command,
view::Command::Filter(_)
| view::Command::Reset(None)
| view::Command::Reset(Some(view::Slot::Filter))
);
if let view::Command::Expand(cols) = &command {
let cols = cols.clone();
let sorted = !store.view.sort.is_empty();
let mut said = Vec::new();
for col in cols {
let Some(store) = &mut self.store else { break };
match store.expand(col) {
Ok(note) => said.push(note),
Err(e) => said.push(e.to_string()),
}
}
self.message = Some(said.join("; "));
if sorted {
self.rebuild_view();
}
self.after_view_change(false)?;
return Ok(());
}
if let view::Command::Sort(keys) = &command
&& !keys.is_empty()
&& let Some(reason) = store.sort_blocked()
{
self.message = Some(reason);
return Ok(());
}
let mut next = store.view.clone();
let sort_before = next.sort.clone();
if let Err(e) = next.apply(command, store.schema.len()) {
self.message = Some(e);
return Ok(());
}
let reordered = next.sort != sort_before;
if let Some(store) = &mut self.store
&& let Err(e) = store.apply_view(next)
{
self.message = Some(e.to_string());
return Ok(());
}
if reordered || refiltered {
self.rebuild_view();
}
self.after_view_change(reordered || refiltered)?;
Ok(())
}
fn rebuild_view(&mut self) {
self.sort_rx = None;
self.filter_rx = None;
let Some(store) = &mut self.store else {
return;
};
if store.view.sort.is_empty() {
match store.begin_filter() {
Ok(rx) => self.filter_rx = rx,
Err(e) => self.message = Some(e.to_string()),
}
} else {
self.sort_rx = Some(store.resort());
}
}
fn after_view_change(&mut self, reordered: bool) -> anyhow::Result<()> {
let last = self
.store
.as_ref()
.map_or(0, |s| s.column_count().saturating_sub(1));
self.cursor_col = self.cursor_col.min(last);
self.col_offset = self.col_offset.min(last);
self.visual_anchor = None;
self.search_state = None;
self.search_rx = None;
if reordered {
self.cursor_to(0)?;
}
Ok(())
}
fn write(&mut self, dst: Option<&Path>, force: bool) -> anyhow::Result<bool> {
let Some(store) = &mut self.store else {
self.message = Some("no file open".to_string());
return Ok(false);
};
match store.save(dst, force) {
Ok(path) => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
self.message = Some(format!("wrote {name}"));
Ok(true)
}
Err(e) => {
self.message = Some(e.to_string());
Ok(false)
}
}
}
fn quit(&mut self, force: bool) {
let dirty = self.store.as_ref().map_or(0, |s| s.dirty());
if dirty > 0 && !force {
self.message = Some(format!(
"{dirty} unsaved edit{} — :w to write, :q! to discard",
if dirty == 1 { "" } else { "s" }
));
return;
}
self.exit = true;
}
fn resolve_prefix(&mut self, prefix: char, code: KeyCode) -> anyhow::Result<()> {
let code = match code {
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
other => other,
};
match (prefix, code) {
('z', KeyCode::Char('z')) => self.scroll_center(),
('z', KeyCode::Char('t')) => self.scroll_cursor_top(),
('z', KeyCode::Char('b')) => self.scroll_cursor_bottom(),
('z', KeyCode::Char('>')) => self.resize_column(1),
('z', KeyCode::Char('<')) => self.resize_column(-1),
('z', KeyCode::Char('_')) => self.fit_column(),
('z', KeyCode::Char('p')) => self.toggle_pin(),
('z', KeyCode::Char('k')) => {
self.adopt_column_cursor();
self.cell_view = !self.cell_view;
Ok(())
}
('z', KeyCode::Char('|')) => {
self.pinned.clear();
Ok(())
}
('z', KeyCode::Char('=')) => {
self.widths.clear();
self.message = Some("column widths reset".to_string());
Ok(())
}
('d', KeyCode::Char('d')) => {
let count = self.take_count(1);
self.delete_rows(self.cursor_row, count)
}
('g', KeyCode::Char('g')) => {
if self.pending_num.is_empty() {
self.cursor_to(0)
} else {
let count = std::mem::take(&mut self.pending_num);
self.jump_to_line(&count)
}
}
_ => {
self.pending_num.clear();
Ok(())
}
}
}
fn scroll_page(&mut self, down: bool, page: Page) -> anyhow::Result<()> {
let Some(store) = &self.store else {
return Ok(());
};
let viewport = store.viewport_rows.max(1);
let step = match page {
Page::Half => (viewport / 2).max(1),
Page::Whole => viewport.max(1),
};
let last_row = store.row_count().saturating_sub(1);
let offset = store.row_offset;
let height_in_view = self.cursor_row.saturating_sub(offset);
let new_offset = if down {
(offset + step).min(store.row_count().saturating_sub(viewport))
} else {
offset.saturating_sub(step)
};
if let Some(store) = &mut self.store {
store.scroll_to_offset(new_offset)?;
}
let settled = self.store.as_ref().map_or(0, |s| s.row_offset);
self.cursor_row = if settled == offset {
if down {
(self.cursor_row + step).min(last_row)
} else {
self.cursor_row.saturating_sub(step)
}
} else {
(settled + height_in_view).min(last_row)
};
Ok(())
}
fn cursor_to(&mut self, row: usize) -> anyhow::Result<()> {
let (total, vp, offset) = match &self.store {
Some(s) => (s.row_count(), s.viewport_rows, s.row_offset),
None => return Ok(()),
};
if total == 0 {
return Ok(());
}
let row = row.min(total - 1);
self.cursor_row = row;
let new_offset = if row < offset {
row
} else if row >= offset + vp {
(row + 1).saturating_sub(vp)
} else {
return Ok(()); };
if let Some(s) = &mut self.store {
s.scroll_to_offset(new_offset)?;
}
Ok(())
}
fn cursor_down(&mut self, n: usize) -> anyhow::Result<()> {
let total = self.store.as_ref().map_or(0, |s| s.row_count());
if total == 0 {
return Ok(());
}
let new_row = (self.cursor_row + n).min(total - 1);
self.cursor_to(new_row)
}
fn cursor_up(&mut self, n: usize) -> anyhow::Result<()> {
self.cursor_to(self.cursor_row.saturating_sub(n))
}
fn scroll_center(&mut self) -> anyhow::Result<()> {
let (total, vp) = match &self.store {
Some(s) => (s.row_count(), s.viewport_rows),
None => return Ok(()),
};
let offset = self
.cursor_row
.saturating_sub(vp / 2)
.min(total.saturating_sub(vp));
if let Some(s) = &mut self.store {
s.scroll_to_offset(offset)?;
}
Ok(())
}
fn scroll_cursor_top(&mut self) -> anyhow::Result<()> {
if let Some(s) = &mut self.store {
s.scroll_to_offset(self.cursor_row)?;
}
Ok(())
}
fn scroll_cursor_bottom(&mut self) -> anyhow::Result<()> {
let (total, vp) = match &self.store {
Some(s) => (s.row_count(), s.viewport_rows),
None => return Ok(()),
};
let offset = self
.cursor_row
.saturating_sub(vp.saturating_sub(1))
.min(total.saturating_sub(vp));
if let Some(s) = &mut self.store {
s.scroll_to_offset(offset)?;
}
Ok(())
}
fn jump_to_line(&mut self, num_str: &str) -> anyhow::Result<()> {
let total = self.store.as_ref().map_or(0, |s| s.row_count());
match num_str.parse::<usize>() {
Ok(0) => {
self.message = Some(format!("Invalid line: 0 (valid: 1–{total})"));
}
Ok(n) if n > total => {
self.message = Some(format!("Line {n} out of range (file has {total} rows)"));
}
Ok(n) => self.cursor_to(n - 1)?,
Err(_) => {
self.message = Some(format!("Not a valid line number: \"{num_str}\""));
}
}
Ok(())
}
fn take_count(&mut self, default: usize) -> usize {
if self.pending_num.is_empty() {
default
} else {
let n = self.pending_num.parse::<usize>().unwrap_or(default);
self.pending_num.clear();
n.max(1)
}
}
fn col_offset_to_show_at_right(&self, cursor_col: usize) -> usize {
match &self.store {
Some(store) => ui::col_offset_showing(
&store.current_view,
store.row_offset,
self.last_frame_width,
cursor_col,
&self.widths,
&self.display_pins(),
),
None => 0,
}
}
fn column_left(&mut self, n: usize) {
match self.selection_mode {
SelectionMode::Row => self.col_offset = self.col_offset.saturating_sub(n),
SelectionMode::Column | SelectionMode::Cell => {
self.cursor_col = self.cursor_col.saturating_sub(n);
if !self.display_pins().contains(&self.cursor_col) {
self.col_offset = self.col_offset.min(self.cursor_col);
}
}
}
}
fn column_right(&mut self, n: usize) {
match self.selection_mode {
SelectionMode::Row => {
self.col_offset = (self.col_offset + n).min(self.max_col_offset());
}
SelectionMode::Column | SelectionMode::Cell => {
let last = self
.store
.as_ref()
.map_or(0, |s| s.column_count().saturating_sub(1));
self.cursor_col = (self.cursor_col + n).min(last);
}
}
}
fn open_picker(&mut self) {
let Some(store) = &self.store else {
return;
};
let names: Vec<String> = store
.schema
.iter_names()
.map(|name| name.to_string())
.collect();
let order = store.view.columns(names.len());
self.picker = Some(Picker::new(names, order, self.pinned.clone()));
self.mode = AppMode::Picker;
}
fn close_picker(&mut self) {
self.picker = None;
self.mode = AppMode::Normal;
}
fn handle_picker_key(&mut self, key: KeyEvent) -> anyhow::Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let half = self.last_vp as isize / 2;
let Some(picker) = &mut self.picker else {
self.mode = AppMode::Normal;
return Ok(());
};
let len = picker.len();
match key.code {
KeyCode::Esc => self.close_picker(),
KeyCode::Enter => return self.apply_picker(),
KeyCode::Char('j') | KeyCode::Down => picker.state.move_by(1, len),
KeyCode::Char('k') | KeyCode::Up => picker.state.move_by(-1, len),
KeyCode::Char('d') if ctrl => picker.state.move_by(half, len),
KeyCode::Char('u') if ctrl => picker.state.move_by(-half, len),
KeyCode::Char('g') | KeyCode::Home => picker.state.go_to(0, len),
KeyCode::Char('G') | KeyCode::End => picker.state.go_to(len.saturating_sub(1), len),
KeyCode::Char('-') => {
if let Err(refusal) = picker.toggle_shown() {
self.message = Some(refusal);
}
}
KeyCode::Char('p') => picker.toggle_pinned(),
KeyCode::Char('a') => picker.show_all(),
KeyCode::Char('A') => picker.show_only_cursor(),
_ => {}
}
Ok(())
}
fn apply_picker(&mut self) -> anyhow::Result<()> {
let Some(picker) = self.picker.take() else {
self.mode = AppMode::Normal;
return Ok(());
};
self.mode = AppMode::Normal;
let select = picker.selection();
if select.is_empty() {
self.message = Some("that would hide every column".to_string());
return Ok(());
}
self.apply_view_command(view::Command::Select(select))?;
self.adopt_pins(picker.pins());
Ok(())
}
fn adopt_pins(&mut self, wanted: std::collections::BTreeSet<usize>) {
self.pinned = wanted;
let mut dropped = 0;
while !self.pins_fit() {
let Some(&rightmost) = self.display_pins().iter().next_back() else {
break;
};
let Some(source) = self
.store
.as_ref()
.and_then(|store| store.source_column(rightmost))
else {
break;
};
self.pinned.remove(&source);
dropped += 1;
}
if dropped > 0 {
self.message = Some(format!("no room for {dropped} of the pins"));
}
}
fn pins_fit(&self) -> bool {
match &self.store {
Some(store) => ui::pin_fits(
&store.current_view,
store.row_offset,
self.last_frame_width,
&self.widths,
&self.display_pins(),
),
None => true,
}
}
fn hide_column(&mut self) -> anyhow::Result<()> {
let Some(store) = &self.store else {
return Ok(());
};
let Some(source) = store.source_column(self.cursor_col) else {
return Ok(());
};
let name = store
.current_view
.columns()
.get(self.cursor_col)
.map(|column| column.name().to_string());
self.apply_view_command(view::Command::Hide(vec![source]))?;
if let Some(name) = name
&& self.message.is_none()
{
self.message = Some(format!("hid {name} — :reset select brings it back"));
}
Ok(())
}
fn resize_column(&mut self, steps: isize) -> anyhow::Result<()> {
const STEP: isize = 4;
let Some(source) = self
.store
.as_ref()
.and_then(|store| store.source_column(self.cursor_col))
else {
return Ok(());
};
let current = match self.widths.get(&source) {
Some(&width) => width as isize,
None => self.drawn_width(source) as isize,
};
let want = (current + steps * STEP).max(ui::MIN_COLUMN as isize) as usize;
self.widths.insert(source, want);
Ok(())
}
fn fit_column(&mut self) -> anyhow::Result<()> {
let Some(store) = &self.store else {
return Ok(());
};
let Some(source) = store.source_column(self.cursor_col) else {
return Ok(());
};
let Some(column) = store.current_view.columns().get(self.cursor_col) else {
return Ok(());
};
let widest = ui::natural_width(column);
self.widths.insert(source, widest);
Ok(())
}
fn display_pins(&self) -> ui::Pinned {
let Some(store) = &self.store else {
return ui::Pinned::new();
};
(0..store.column_count())
.filter(|&display| {
store
.source_column(display)
.is_some_and(|source| self.pinned.contains(&source))
})
.collect()
}
fn toggle_pin(&mut self) -> anyhow::Result<()> {
let Some(source) = self
.store
.as_ref()
.and_then(|store| store.source_column(self.cursor_col))
else {
return Ok(());
};
if self.pinned.remove(&source) {
return Ok(());
}
self.pinned.insert(source);
if !self.pins_fit() {
self.pinned.remove(&source);
self.message = Some("no room to pin: unpin one, or narrow one with z<".into());
}
Ok(())
}
fn drawn_width(&self, source: usize) -> usize {
self.store
.as_ref()
.and_then(|store| store.current_view.columns().get(self.cursor_col))
.map(|column| ui::drawn_width(column, source, self.last_frame_width, &self.widths))
.unwrap_or(ui::MIN_COLUMN)
}
fn col_position(&self) -> usize {
match self.selection_mode {
SelectionMode::Row => self.col_offset,
_ => self.cursor_col,
}
}
fn max_col_offset(&self) -> usize {
let last = self
.store
.as_ref()
.map_or(0, |s| s.column_count().saturating_sub(1));
self.col_offset_to_show_at_right(last)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "name,count\na,1\nb,2\nc,3\n";
fn app_with(name: &str, contents: &str) -> (App, PathBuf) {
let dir = std::env::temp_dir().join("plv-app-tests");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
let mut app = App::new(Some(path.clone()));
app.store = Some(Store::open_file(&path, 10).unwrap());
(app, path)
}
fn key(app: &mut App, event: impl IntoKeyEvent) {
app.handle_key_event(event.into_key_event()).unwrap();
}
trait IntoKeyEvent {
fn into_key_event(self) -> KeyEvent;
}
impl IntoKeyEvent for KeyCode {
fn into_key_event(self) -> KeyEvent {
KeyEvent::new(self, KeyModifiers::NONE)
}
}
impl IntoKeyEvent for KeyEvent {
fn into_key_event(self) -> KeyEvent {
self
}
}
fn press(app: &mut App, c: char) {
key(app, KeyCode::Char(c));
}
fn typed(app: &mut App, text: &str) {
for c in text.chars() {
press(app, c);
}
}
fn command(app: &mut App, line: &str) {
press(app, ':');
typed(app, line);
key(app, KeyCode::Enter);
}
fn shown(app: &App, col: usize, row: usize) -> Option<String> {
app.store.as_ref()?.cell_text(row, col)
}
#[test]
fn typing_a_cell_and_writing_it_reaches_the_file() {
let (mut app, path) = app_with("edit.csv", SAMPLE);
press(&mut app, 'i');
assert!(matches!(app.mode, AppMode::Edit));
assert_eq!(app.selection_mode, SelectionMode::Cell);
assert_eq!(app.edit_buf, "a");
typed(&mut app, "bc");
key(&mut app, KeyCode::Enter);
assert!(matches!(app.mode, AppMode::Normal));
assert_eq!(shown(&app, 0, 0).as_deref(), Some("bca"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 1);
assert_eq!(std::fs::read_to_string(&path).unwrap(), SAMPLE);
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\nbca,1\nb,2\nc,3\n"
);
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
}
#[test]
fn i_and_a_put_the_caret_at_opposite_ends() {
let (mut app, _) = app_with("caret.csv", SAMPLE);
press(&mut app, 'i');
assert_eq!(app.edit_cursor, 0);
typed(&mut app, "x");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("xa"));
press(&mut app, 'a');
assert_eq!(app.edit_buf, "xa");
assert_eq!(app.edit_cursor, 2);
typed(&mut app, "z");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("xaz"));
}
#[test]
fn c_replaces_the_value_and_escape_abandons_it() {
let (mut app, _) = app_with("abandon.csv", SAMPLE);
press(&mut app, 'c');
assert_eq!(app.edit_buf, "", "c starts from an empty field");
typed(&mut app, "gone");
key(&mut app, KeyCode::Esc);
assert!(matches!(app.mode, AppMode::Normal));
assert_eq!(shown(&app, 0, 0).as_deref(), Some("a"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
}
#[test]
fn x_clears_a_cell_and_u_takes_it_back() {
let (mut app, _) = app_with("clear.csv", SAMPLE);
press(&mut app, 'x');
assert_eq!(shown(&app, 0, 0).as_deref(), Some(""));
assert_eq!(app.store.as_ref().unwrap().dirty(), 1);
press(&mut app, 'u');
assert_eq!(shown(&app, 0, 0).as_deref(), Some("a"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
press(&mut app, 'u');
assert_eq!(app.message.as_deref(), Some("nothing to undo"));
}
#[test]
fn retyping_the_same_value_is_not_an_edit() {
let (mut app, _) = app_with("noop.csv", SAMPLE);
press(&mut app, 'i');
key(&mut app, KeyCode::Enter);
assert_eq!(
app.store.as_ref().unwrap().dirty(),
0,
"an unchanged cell must not dirty the file"
);
}
#[test]
fn a_value_that_breaks_the_column_type_warns_but_is_still_taken() {
let (mut app, _) = app_with("warn.csv", SAMPLE);
press(&mut app, 'i');
key(&mut app, KeyCode::Esc);
press(&mut app, 'l');
press(&mut app, 'c');
typed(&mut app, "n/a");
key(&mut app, KeyCode::Enter);
let message = app.message.clone().expect("a type mismatch should warn");
assert!(message.contains("becomes text"), "{message}");
assert_eq!(shown(&app, 1, 0).as_deref(), Some("n/a"));
}
#[test]
fn tab_commits_and_moves_along_the_row() {
let (mut app, _) = app_with("tab.csv", SAMPLE);
press(&mut app, 'c');
typed(&mut app, "first");
key(&mut app, KeyCode::Tab);
assert!(matches!(app.mode, AppMode::Edit), "still editing");
assert_eq!(app.cursor_col, 1);
assert_eq!(app.edit_buf, "1", "the next cell's value is loaded");
typed(&mut app, "0");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("first"));
assert_eq!(shown(&app, 1, 0).as_deref(), Some("10"));
}
#[test]
fn tab_off_the_last_column_leaves_the_editor() {
let (mut app, _) = app_with("tabend.csv", SAMPLE);
press(&mut app, 'i');
key(&mut app, KeyCode::Esc);
press(&mut app, 'l');
press(&mut app, 'c');
typed(&mut app, "9");
key(&mut app, KeyCode::Tab);
assert!(matches!(app.mode, AppMode::Normal));
assert_eq!(shown(&app, 1, 0).as_deref(), Some("9"));
}
#[test]
fn quitting_refuses_while_edits_are_unwritten() {
let (mut app, _) = app_with("quit.csv", SAMPLE);
press(&mut app, 'x');
press(&mut app, 'q');
assert!(!app.exit);
let message = app.message.clone().unwrap();
assert!(message.contains("1 unsaved edit"), "{message}");
command(&mut app, "q");
assert!(!app.exit, ":q is guarded too");
command(&mut app, "q!");
assert!(app.exit);
}
#[test]
fn wq_writes_before_quitting_and_stays_put_when_the_write_fails() {
let (mut app, path) = app_with("wq.csv", SAMPLE);
press(&mut app, 'x');
std::fs::write(&path, "name,count\nz,9\n").unwrap();
command(&mut app, "wq");
assert!(!app.exit, "a refused write must not quit");
assert!(app.message.clone().unwrap().contains("changed on disk"));
std::fs::write(&path, SAMPLE).unwrap();
command(&mut app, "wq");
assert!(!app.exit, "same bytes, new mtime — still guarded");
command(&mut app, "wq!");
assert!(app.exit);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\n,1\nb,2\nc,3\n"
);
}
fn cell_mode(app: &mut App) {
key(app, KeyCode::Tab);
key(app, KeyCode::Tab);
assert_eq!(app.selection_mode, SelectionMode::Cell);
}
const WIDE: &str = "a,b,c,d,e,f,g,h\n1,2,3,4,5,6,7,8\n";
fn app_sized(name: &str, contents: &str, width: u16) -> App {
let (mut app, _) = app_with(name, contents);
app.last_frame_width = width;
app
}
#[test]
fn row_mode_stops_where_the_last_column_reaches_the_right_edge() {
let mut app = app_sized("wide.csv", WIDE, 40);
assert_eq!(app.selection_mode, SelectionMode::Row);
for _ in 0..20 {
press(&mut app, 'l');
}
let last = 7;
assert_eq!(app.col_offset, app.max_col_offset());
assert!(
app.col_offset < last,
"row mode padded the view with empty columns: offset {} of {last}",
app.col_offset
);
}
#[test]
fn dollar_and_zero_move_the_viewport_in_every_mode() {
for enter_cell_mode in [false, true] {
let mut app = app_sized("ends.csv", WIDE, 40);
if enter_cell_mode {
cell_mode(&mut app);
}
press(&mut app, '$');
assert_eq!(
app.col_offset,
app.max_col_offset(),
"cell={enter_cell_mode}"
);
assert_eq!(app.cursor_col, 7, "cell={enter_cell_mode}");
press(&mut app, '0');
assert_eq!(app.col_offset, 0, "cell={enter_cell_mode}");
assert_eq!(app.cursor_col, 0, "cell={enter_cell_mode}");
}
}
#[test]
fn zero_stays_a_digit_while_a_count_is_being_typed() {
let mut rows = String::from("a,b\n");
for i in 0..20 {
rows.push_str(&format!("{i},{i}\n"));
}
let mut app = app_sized("count.csv", &rows, 40);
cell_mode(&mut app);
press(&mut app, '1');
press(&mut app, '0');
assert_eq!(app.pending_num, "10", "'0' continued the count");
press(&mut app, 'j');
assert_eq!(app.cursor_row, 10);
press(&mut app, 'l');
press(&mut app, '0');
assert_eq!(app.cursor_col, 0);
}
#[test]
fn h_and_l_take_a_count_like_j_and_k() {
let mut app = app_sized("countcol.csv", WIDE, 40);
cell_mode(&mut app);
press(&mut app, '5');
press(&mut app, 'l');
assert_eq!(app.cursor_col, 5);
assert!(app.pending_num.is_empty(), "the count is spent");
press(&mut app, '3');
press(&mut app, 'h');
assert_eq!(app.cursor_col, 2);
press(&mut app, 'l');
assert_eq!(app.cursor_col, 3);
}
#[test]
fn a_counted_column_jump_stops_at_the_ends() {
let mut app = app_sized("countclamp.csv", WIDE, 40);
cell_mode(&mut app);
press(&mut app, '9');
press(&mut app, '9');
press(&mut app, 'l');
assert_eq!(app.cursor_col, 7, "eight columns, so the last is 7");
press(&mut app, '9');
press(&mut app, '9');
press(&mut app, 'h');
assert_eq!(app.cursor_col, 0);
}
#[test]
fn a_counted_jump_scrolls_row_mode_without_overshooting() {
let mut app = app_sized("countrow.csv", WIDE, 40);
assert_eq!(app.selection_mode, SelectionMode::Row);
press(&mut app, '2');
press(&mut app, 'l');
assert_eq!(app.col_offset, 2);
press(&mut app, '9');
press(&mut app, 'l');
assert_eq!(app.col_offset, app.max_col_offset());
}
#[test]
fn the_column_readout_follows_whatever_is_moving() {
let mut app = app_sized("readout.csv", WIDE, 40);
press(&mut app, 'l');
assert_eq!(app.col_offset, 1);
assert_eq!(app.col_position(), 1);
cell_mode(&mut app);
let before = app.col_position();
press(&mut app, 'l');
assert_eq!(app.col_position(), app.cursor_col);
assert_ne!(app.col_position(), before);
}
fn tall_app(name: &str) -> App {
let mut rows = String::from("n\n");
for i in 0..40 {
rows.push_str(&format!("{i}\n"));
}
let (mut app, _) = app_with(name, &rows);
app.last_frame_width = 40;
app
}
fn offset(app: &App) -> usize {
app.store.as_ref().unwrap().row_offset
}
#[test]
fn it_takes_two_gs_to_reach_the_top() {
let mut app = tall_app("gg.csv");
app.cursor_to(20).unwrap();
press(&mut app, 'g');
assert_eq!(app.cursor_row, 20, "one g is only half a motion");
assert_eq!(app.pending_prefix, Some('g'));
press(&mut app, 'g');
assert_eq!(app.cursor_row, 0);
assert_eq!(app.pending_prefix, None);
}
#[test]
fn a_count_survives_the_first_g() {
let mut app = tall_app("countgg.csv");
press(&mut app, '1');
press(&mut app, '2');
press(&mut app, 'g');
assert_eq!(app.pending_num, "12", "the count outlives the prefix");
press(&mut app, 'g');
assert_eq!(app.cursor_row, 11, "12gg is the twelfth row, 1-based");
}
#[test]
fn an_unfinished_g_is_abandoned_rather_than_acted_on() {
let mut app = tall_app("gcancel.csv");
app.cursor_to(20).unwrap();
press(&mut app, 'g');
press(&mut app, 'x'); assert_eq!(app.pending_prefix, None);
assert_eq!(app.cursor_row, 20);
assert_eq!(app.store.as_ref().unwrap().dirty(), 0, "and x did not fire");
}
#[test]
fn a_half_page_moves_the_view_even_when_the_cursor_has_room() {
let mut app = tall_app("halfpage.csv");
let viewport = app.store.as_ref().unwrap().viewport_rows;
assert_eq!(viewport, 10);
assert_eq!((app.cursor_row, offset(&app)), (0, 0));
app.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(offset(&app), 5, "the view moved half a screen");
assert_eq!(app.cursor_row, 5, "and the cursor kept its place in it");
app.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!((app.cursor_row, offset(&app)), (0, 0));
}
#[test]
fn a_whole_page_moves_twice_as_far_as_half_of_one() {
let mut app = tall_app("wholepage.csv");
let viewport = app.store.as_ref().unwrap().viewport_rows;
assert_eq!(viewport, 10);
app.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(offset(&app), viewport, "a whole screen");
assert_eq!(app.cursor_row, viewport, "the cursor came with it");
app.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!((offset(&app), app.cursor_row), (0, 0));
key(&mut app, KeyCode::PageDown);
assert_eq!(offset(&app), viewport);
key(&mut app, KeyCode::PageUp);
assert_eq!(offset(&app), 0);
}
#[test]
fn at_the_end_of_the_file_the_cursor_carries_on_alone() {
let mut app = tall_app("halfend.csv");
press(&mut app, 'G');
let settled = offset(&app);
assert_eq!(app.cursor_row, 39);
app.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
.unwrap();
assert_eq!(offset(&app), settled, "the view has nowhere left to go");
assert_eq!(app.cursor_row, 39, "and the cursor is already at the end");
}
#[test]
fn z_commands_place_the_cursor_row_in_the_viewport() {
let mut app = tall_app("zcmd.csv");
assert_eq!(app.store.as_ref().unwrap().viewport_rows, 10);
app.cursor_to(20).unwrap();
press(&mut app, 'z');
press(&mut app, 't');
assert_eq!(offset(&app), 20, "zt puts the cursor at the top");
press(&mut app, 'z');
press(&mut app, 'z');
assert_eq!(offset(&app), 15, "zz centres it");
press(&mut app, 'z');
press(&mut app, 'b');
assert_eq!(offset(&app), 11, "zb puts it at the bottom");
assert_eq!(app.cursor_row, 20, "and none of them move the cursor");
}
const FOURCOL: &str = "a,b,c,d\n1,2,3,4\n5,6,7,8\n";
fn shown_columns(app: &App) -> Vec<String> {
let store = app.store.as_ref().unwrap();
store
.current_view
.get_column_names()
.iter()
.map(|n| n.to_string())
.collect()
}
#[test]
fn select_narrows_the_view_to_the_named_columns_in_order() {
let mut app = app_sized("select.csv", FOURCOL, 60);
command(&mut app, "select c a");
assert_eq!(shown_columns(&app), ["c", "a"]);
assert_eq!(app.store.as_ref().unwrap().column_count(), 2);
command(&mut app, "select b");
assert_eq!(shown_columns(&app), ["b"]);
command(&mut app, "reset");
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
}
const CATS: &str = "id,cat\n0,a\n1,b\n2,a\n3,b\n4,a\n";
fn settle(app: &mut App) {
for _ in 0..2000 {
if app.filter_rx.is_none() {
return;
}
app.poll_filter();
std::thread::sleep(std::time::Duration::from_millis(1));
}
panic!("the filter scan never finished");
}
fn filter(app: &mut App, line: &str) {
command(app, line);
settle(app);
}
#[test]
fn a_filter_narrows_the_rows_on_show() {
let mut app = app_sized("filter.csv", CATS, 60);
filter(&mut app, "filter cat = a");
let store = app.store.as_ref().unwrap();
assert_eq!(store.row_count(), 3, "three rows say a");
assert_eq!(store.total_rows, 5, "the file still has five");
assert_eq!(shown(&app, 0, 0).as_deref(), Some("0"));
assert_eq!(shown(&app, 0, 1).as_deref(), Some("2"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("4"));
}
#[test]
fn a_bare_filter_puts_the_rows_back() {
let mut app = app_sized("unfilter.csv", CATS, 60);
filter(&mut app, "filter cat = a");
assert_eq!(app.store.as_ref().unwrap().row_count(), 3);
filter(&mut app, "filter");
assert_eq!(app.store.as_ref().unwrap().row_count(), 5);
assert_eq!(shown(&app, 0, 1).as_deref(), Some("1"));
}
#[test]
fn editing_a_filtered_row_writes_the_right_line() {
let (mut app, path) = app_with("filteredit.csv", CATS);
app.last_frame_width = 60;
filter(&mut app, "filter cat = a");
press(&mut app, 'j');
cell_mode(&mut app);
press(&mut app, 'c');
typed(&mut app, "99");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 1).as_deref(), Some("99"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"id,cat\n0,a\n1,b\n99,a\n3,b\n4,a\n",
"the edit belongs to the third line, not the second"
);
}
#[test]
fn a_filtered_view_can_still_be_edited() {
let mut app = app_sized("filteditable.csv", CATS, 60);
filter(&mut app, "filter cat = a");
assert_eq!(
app.store.as_ref().unwrap().edit_blocked(),
None,
"a filter keeps row identity, so it need not block edits"
);
}
#[test]
fn a_filter_and_a_sort_hold_at_the_same_time() {
let mut app = app_sized("filtersort.csv", CATS, 60);
filter(&mut app, "filter cat = a");
assert_eq!(app.store.as_ref().unwrap().row_count(), 3);
command(&mut app, "sort id-");
settle_sort(&mut app);
let store = app.store.as_ref().unwrap();
assert_eq!(store.row_count(), 3, "still only the rows that matched");
assert_eq!(store.view.sort, [(0, false)]);
assert!(store.view.filter.is_some());
assert_eq!(shown(&app, 0, 0).as_deref(), Some("4"));
assert_eq!(shown(&app, 0, 1).as_deref(), Some("2"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("0"));
assert_eq!(store.edit_blocked(), None, "and it is still editable");
}
#[test]
fn editing_through_a_filtered_and_sorted_view_writes_the_right_line() {
let (mut app, path) = app_with("filtersortedit.csv", CATS);
app.last_frame_width = 60;
filter(&mut app, "filter cat = a");
command(&mut app, "sort id-");
settle_sort(&mut app);
cell_mode(&mut app);
press(&mut app, 'c');
typed(&mut app, "99");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("99"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"id,cat\n0,a\n1,b\n2,a\n3,b\n99,a\n"
);
}
#[test]
fn clearing_one_of_them_leaves_the_other_working() {
let mut app = app_sized("filtersortclear.csv", CATS, 60);
filter(&mut app, "filter cat = a");
command(&mut app, "sort id-");
settle_sort(&mut app);
command(&mut app, "sort");
settle(&mut app);
let store = app.store.as_ref().unwrap();
assert_eq!(store.row_count(), 3);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("0"), "file order again");
command(&mut app, "filter");
settle(&mut app);
assert_eq!(app.store.as_ref().unwrap().row_count(), 5);
}
#[test]
fn a_filter_that_matches_nothing_shows_nothing() {
let mut app = app_sized("nomatch.csv", CATS, 60);
filter(&mut app, "filter cat = zzz");
let store = app.store.as_ref().unwrap();
assert_eq!(store.row_count(), 0);
assert_eq!(store.current_view.height(), 0);
assert_eq!(
store.current_view.width(),
2,
"still the right columns to draw a header from"
);
}
#[test]
fn a_filter_composes_with_a_projection() {
let mut app = app_sized("filterselect.csv", CATS, 60);
filter(&mut app, "filter cat = a");
command(&mut app, "select cat");
assert_eq!(shown_columns(&app), ["cat"]);
assert_eq!(app.store.as_ref().unwrap().row_count(), 3);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("a"));
}
fn tab(app: &mut App) {
key(app, KeyCode::Tab);
}
#[test]
fn tab_completes_a_verb_then_a_column() {
let mut app = app_sized("tabcomplete.csv", FOURCOL, 60);
press(&mut app, ':');
typed(&mut app, "sel");
tab(&mut app);
assert_eq!(app.command_buf, "select ");
assert!(app.completion.is_none(), "one candidate needs no panel");
typed(&mut app, "c");
tab(&mut app);
assert_eq!(app.command_buf, "select c ");
}
#[test]
fn several_candidates_open_the_panel_and_tab_steps_through_them() {
let mut app = app_sized("tabcycle.csv", "alpha,beta,gamma\n1,2,3\n", 60);
press(&mut app, ':');
typed(&mut app, "select ");
tab(&mut app);
let options = app.completion.as_ref().expect("a panel").options.clone();
assert_eq!(options, ["alpha", "beta", "gamma"]);
assert_eq!(app.panel_height(60, 24), 1, "the panel takes a row");
assert_eq!(app.command_buf, "select ");
tab(&mut app);
assert_eq!(app.command_buf, "select alpha ");
tab(&mut app);
assert_eq!(app.command_buf, "select beta ");
key(&mut app, KeyCode::BackTab);
assert_eq!(app.command_buf, "select alpha ");
}
#[test]
fn the_panel_takes_its_rows_from_the_table() {
let mut app = app_sized("panelroom.csv", "alpha,beta,gamma\n1,2,3\n", 60);
let before = App::viewport_rows(24, 0);
press(&mut app, ':');
typed(&mut app, "select ");
tab(&mut app);
let after = App::viewport_rows(24, app.panel_height(60, 24));
assert_eq!(
after,
before - 1,
"the table gives up exactly the panel's row"
);
}
#[test]
fn typing_or_leaving_puts_the_panel_away() {
let mut app = app_sized("panelgone.csv", "alpha,beta,gamma\n1,2,3\n", 60);
press(&mut app, ':');
typed(&mut app, "select ");
tab(&mut app);
assert!(app.completion.is_some());
typed(&mut app, "a");
assert!(app.completion.is_none(), "a keystroke changes the answer");
tab(&mut app);
assert_eq!(app.command_buf, "select alpha ");
key(&mut app, KeyCode::Esc);
assert!(app.completion.is_none());
assert_eq!(app.panel_height(60, 24), 0);
}
#[test]
fn a_completed_command_actually_runs() {
let mut app = app_sized("tabrun.csv", FOURCOL, 60);
press(&mut app, ':');
typed(&mut app, "sel");
tab(&mut app);
typed(&mut app, "d");
tab(&mut app);
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["d"], "msg: {:?}", app.message);
}
#[test]
fn dd_takes_out_the_cursor_row_and_writes_it_out() {
let (mut app, path) = app_with("dd.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'j');
press(&mut app, 'd');
assert_eq!(app.pending_prefix, Some('d'), "one d is half a command");
assert_eq!(shown(&app, 0, 1).as_deref(), Some("b"), "nothing yet");
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 2);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("a"));
assert_eq!(shown(&app, 0, 1).as_deref(), Some("c"), "c moved up");
assert_eq!(std::fs::read_to_string(&path).unwrap(), SAMPLE);
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\nc,3\n"
);
}
#[test]
fn a_count_deletes_that_many_rows() {
let (mut app, path) = app_with("countdd.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, '2');
press(&mut app, 'd');
assert_eq!(app.pending_num, "2", "the count outlives the prefix");
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 1);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("c"));
command(&mut app, "w");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "name,count\nc,3\n");
}
#[test]
fn deleting_is_one_undoable_step() {
let (mut app, _) = app_with("undodd.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, '2');
press(&mut app, 'd');
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 1);
press(&mut app, 'u');
assert_eq!(
app.store.as_ref().unwrap().row_count(),
3,
"two rows, one undo"
);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("a"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
}
#[test]
fn a_visual_selection_deletes_its_rows() {
let (mut app, path) = app_with("visualdd.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 1);
assert!(app.visual_anchor.is_none(), "the selection is spent");
command(&mut app, "w");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "name,count\nc,3\n");
}
#[test]
fn deleting_past_the_end_takes_what_is_there_and_moves_the_cursor_back() {
let (mut app, _) = app_with("ddend.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'j');
press(&mut app, '9');
press(&mut app, 'd');
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 1);
assert_eq!(app.cursor_row, 0, "the cursor cannot sit past the end");
assert!(app.message.clone().unwrap().contains("deleted 2 rows"));
}
#[test]
fn an_edited_cell_below_a_deleted_row_still_belongs_to_its_own_line() {
let (mut app, path) = app_with("ddshift.csv", SAMPLE);
app.last_frame_width = 60;
cell_mode(&mut app);
press(&mut app, 'd');
press(&mut app, 'd');
press(&mut app, 'c');
typed(&mut app, "B");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("B"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\nB,2\nc,3\n",
"the edit belongs to b's line, not a's"
);
}
#[test]
fn a_sorted_or_filtered_view_says_why_it_will_not_delete() {
let mut app = app_sized("ddblocked.csv", CATS, 60);
filter(&mut app, "filter cat = a");
press(&mut app, 'd');
press(&mut app, 'd');
let message = app.message.clone().unwrap();
assert!(message.contains("filtered or sorted"), "{message}");
assert_eq!(app.store.as_ref().unwrap().row_count(), 3, "untouched");
}
#[test]
fn o_opens_a_row_below_ready_to_type_into() {
let (mut app, path) = app_with("open.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'o');
assert!(matches!(app.mode, AppMode::Edit), "typing starts at once");
assert_eq!(app.cursor_row, 1, "below the row it was on");
assert_eq!(app.store.as_ref().unwrap().row_count(), 4);
typed(&mut app, "new");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 1).as_deref(), Some("new"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("b"), "b moved down");
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\nnew,\nb,2\nc,3\n"
);
}
#[test]
fn shift_o_opens_a_row_above() {
let (mut app, path) = app_with("openabove.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'j');
press(&mut app, 'O');
assert_eq!(app.cursor_row, 1, "where the old row 1 was");
typed(&mut app, "mid");
key(&mut app, KeyCode::Enter);
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\nmid,\nb,2\nc,3\n"
);
}
#[test]
fn a_row_opened_at_the_top_lands_above_everything() {
let (mut app, path) = app_with("opentop.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'O');
typed(&mut app, "first");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("first"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\nfirst,\na,1\nb,2\nc,3\n",
"under the header, above the first row"
);
}
#[test]
fn a_row_opened_at_the_end_lands_after_everything() {
let (mut app, path) = app_with("openend.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'G');
press(&mut app, 'o');
typed(&mut app, "last");
key(&mut app, KeyCode::Enter);
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\nb,2\nc,3\nlast,\n"
);
}
#[test]
fn opening_below_a_new_row_keeps_them_in_order() {
let (mut app, path) = app_with("openorder.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'o');
typed(&mut app, "one");
key(&mut app, KeyCode::Enter);
press(&mut app, 'o');
typed(&mut app, "two");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 1).as_deref(), Some("one"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("two"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\none,\ntwo,\nb,2\nc,3\n"
);
}
#[test]
fn opening_a_row_is_one_undoable_step() {
let (mut app, _) = app_with("openundo.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'o');
key(&mut app, KeyCode::Esc);
assert_eq!(app.store.as_ref().unwrap().row_count(), 4);
press(&mut app, 'u');
assert_eq!(app.store.as_ref().unwrap().row_count(), 3);
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
}
#[test]
fn a_new_row_can_be_deleted_again() {
let (mut app, path) = app_with("openthendelete.csv", SAMPLE);
app.last_frame_width = 60;
press(&mut app, 'o');
typed(&mut app, "gone");
key(&mut app, KeyCode::Enter);
press(&mut app, 'd');
press(&mut app, 'd');
assert_eq!(app.store.as_ref().unwrap().row_count(), 3);
command(&mut app, "w");
assert_eq!(app.message.as_deref(), Some("wrote openthendelete.csv"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
assert_eq!(std::fs::read_to_string(&path).unwrap(), SAMPLE, "as it was");
}
#[test]
fn a_bare_select_puts_the_columns_back() {
let mut app = app_sized("bareselect.csv", FOURCOL, 60);
command(&mut app, "select c");
assert_eq!(shown_columns(&app), ["c"]);
command(&mut app, "select");
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
assert!(app.message.is_none(), "no complaint: {:?}", app.message);
command(&mut app, "hide a");
command(&mut app, "select *");
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
}
#[test]
fn hide_drops_columns_from_what_is_on_show() {
let mut app = app_sized("hide.csv", FOURCOL, 60);
command(&mut app, "hide b d");
assert_eq!(shown_columns(&app), ["a", "c"]);
}
#[test]
fn a_bad_view_command_is_reported_and_changes_nothing() {
let mut app = app_sized("badview.csv", FOURCOL, 60);
command(&mut app, "select nope");
assert!(app.message.clone().unwrap().contains("no column called"));
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"], "untouched");
command(&mut app, "hide a b c d");
assert!(app.message.clone().unwrap().contains("hide every column"));
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
}
#[test]
fn editing_through_a_narrowed_view_writes_the_right_column() {
let (mut app, path) = app_with("viewedit.csv", FOURCOL);
app.last_frame_width = 60;
command(&mut app, "select d a");
cell_mode(&mut app);
press(&mut app, 'c');
typed(&mut app, "X");
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["d", "a"]);
assert_eq!(shown(&app, 0, 0).as_deref(), Some("X"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"a,b,c,d\n1,2,3,X\n5,6,7,8\n",
"the edit belongs to column d, not to column a"
);
}
#[test]
fn an_edit_hidden_by_a_view_is_still_pending_and_still_written() {
let (mut app, path) = app_with("viewhidden.csv", FOURCOL);
app.last_frame_width = 60;
cell_mode(&mut app);
press(&mut app, 'c');
typed(&mut app, "Z");
key(&mut app, KeyCode::Enter);
command(&mut app, "select c d");
assert!(
app.store.as_ref().unwrap().edited_cells().is_empty(),
"nowhere on screen to mark it"
);
assert_eq!(app.store.as_ref().unwrap().dirty(), 1, "but still pending");
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"a,b,c,d\nZ,2,3,4\n5,6,7,8\n"
);
}
#[test]
fn a_narrowing_view_brings_the_cursor_back_inside_it() {
let mut app = app_sized("clampview.csv", FOURCOL, 60);
cell_mode(&mut app);
press(&mut app, '$');
assert_eq!(app.cursor_col, 3);
command(&mut app, "select a b");
assert_eq!(app.cursor_col, 1, "the cursor cannot point past the view");
}
#[test]
fn sort_from_the_command_line_agrees_with_the_s_key() {
let mut app = app_sized("viewsort.csv", FOURCOL, 60);
command(&mut app, "sort b-");
settle_sort(&mut app);
let store = app.store.as_ref().unwrap();
assert_eq!(store.view.sort, [(1, false)]);
assert_eq!(store.sort_display(), [(1, false)]);
assert_eq!(
app.cursor_row, 0,
"a reorder puts the cursor back at the top"
);
assert_eq!(store.edit_blocked(), None);
}
#[test]
fn a_sort_key_on_a_hidden_column_keeps_working_but_is_not_drawn() {
let mut app = app_sized("hiddensort.csv", FOURCOL, 60);
command(&mut app, "sort d");
command(&mut app, "select a b");
let store = app.store.as_ref().unwrap();
assert_eq!(store.view.sort, [(3, true)], "the sort still applies");
assert!(store.sort_display().is_empty(), "but has no header to mark");
}
#[test]
fn a_selection_grows_with_the_motions() {
let (mut app, _) = app_with("visual.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'v');
assert_eq!(app.visual_range(), Some(((0, 0), (0, 0))));
press(&mut app, 'j');
press(&mut app, 'l');
assert_eq!(app.visual_range(), Some(((0, 1), (0, 1))));
press(&mut app, 'v');
assert_eq!(app.visual_range(), None, "v again cancels");
}
#[test]
fn filling_a_selection_is_one_edit_and_one_undo() {
let (mut app, _) = app_with("fill.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'l');
press(&mut app, 'c');
assert!(matches!(app.mode, AppMode::Edit));
typed(&mut app, "z");
key(&mut app, KeyCode::Enter);
assert_eq!(app.store.as_ref().unwrap().dirty(), 4);
assert!(app.visual_anchor.is_none(), "the selection is spent");
for row in 0..2 {
for col in 0..2 {
assert_eq!(shown(&app, col, row).as_deref(), Some("z"), "{row},{col}");
}
}
assert_eq!(shown(&app, 0, 2).as_deref(), Some("c"));
press(&mut app, 'u');
assert_eq!(
app.store.as_ref().unwrap().dirty(),
0,
"one fill, one undo — not four"
);
}
#[test]
fn i_and_a_build_on_what_each_cell_already_holds() {
let (mut app, _) = app_with("prepend.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'i');
typed(&mut app, ">");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some(">a"));
assert_eq!(shown(&app, 0, 1).as_deref(), Some(">b"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("c"), "outside the block");
press(&mut app, 'g');
press(&mut app, 'g');
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'a');
typed(&mut app, "!");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 0, 0).as_deref(), Some(">a!"));
assert_eq!(shown(&app, 0, 1).as_deref(), Some(">b!"));
}
#[test]
fn an_append_reads_rows_below_the_viewport() {
let (mut app, path) = app_with("offpage.csv", SAMPLE);
app.store = Some(Store::open_file(&path, 1).unwrap());
key(&mut app, KeyCode::Tab);
press(&mut app, 'v');
press(&mut app, 'a');
typed(&mut app, "_x");
key(&mut app, KeyCode::Enter);
assert_eq!(app.store.as_ref().unwrap().dirty(), 3);
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na_x,1\nb_x,2\nc_x,3\n"
);
}
#[test]
fn a_fill_over_numbers_warns_from_the_value_it_will_write() {
let (mut app, _) = app_with("fillwarn.csv", SAMPLE);
key(&mut app, KeyCode::Tab); press(&mut app, 'l'); press(&mut app, 'v');
press(&mut app, 'a');
typed(&mut app, "kg");
key(&mut app, KeyCode::Enter);
let message = app.message.clone().expect("appending to a number warns");
assert!(message.contains("becomes text"), "{message}");
assert_eq!(shown(&app, 1, 0).as_deref(), Some("1kg"));
}
#[test]
fn a_row_selection_covers_every_column() {
let (mut app, _) = app_with("rowsel.csv", SAMPLE);
assert_eq!(app.selection_mode, SelectionMode::Row);
press(&mut app, 'v');
press(&mut app, 'j');
assert_eq!(app.visual_range(), Some(((0, 1), (0, 1))));
press(&mut app, 'x');
assert_eq!(app.store.as_ref().unwrap().dirty(), 4);
assert_eq!(app.selection_mode, SelectionMode::Row);
}
#[test]
fn a_column_selection_covers_every_row() {
let (mut app, _) = app_with("colsel.csv", SAMPLE);
key(&mut app, KeyCode::Tab);
assert_eq!(app.selection_mode, SelectionMode::Column);
press(&mut app, 'v');
assert_eq!(app.visual_range(), Some(((0, 2), (0, 0))), "all three rows");
press(&mut app, 'x');
assert_eq!(app.store.as_ref().unwrap().dirty(), 3);
for row in 0..3 {
assert_eq!(shown(&app, 0, row).as_deref(), Some(""), "row {row}");
}
}
#[test]
fn escape_gives_up_the_selection_before_the_search() {
let (mut app, _) = app_with("esc.csv", SAMPLE);
let query = SearchQuery::new("a".to_string()).unwrap();
app.search_state = Some(SearchState::new(query, None));
press(&mut app, 'v');
key(&mut app, KeyCode::Esc);
assert!(app.visual_anchor.is_none());
assert!(app.search_state.is_some(), "one Esc, one thing given up");
key(&mut app, KeyCode::Esc);
assert!(app.search_state.is_none());
}
#[test]
fn tab_gives_up_the_selection_rather_than_reshaping_it() {
let (mut app, _) = app_with("tabsel.csv", SAMPLE);
press(&mut app, 'v');
key(&mut app, KeyCode::Tab);
assert!(app.visual_anchor.is_none());
}
#[test]
fn a_yanked_block_pastes_at_the_cursor() {
let (mut app, path) = app_with("yank.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'y');
assert_eq!(app.message.as_deref(), Some("yanked 2 cells"));
assert!(app.visual_anchor.is_none());
assert_eq!(app.store.as_ref().unwrap().dirty(), 0, "yanking reads only");
press(&mut app, 'j');
press(&mut app, 'p');
assert_eq!(shown(&app, 0, 1).as_deref(), Some("a"));
assert_eq!(shown(&app, 0, 2).as_deref(), Some("b"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\na,2\nb,3\n"
);
}
#[test]
fn a_visual_yank_leaves_the_cursor_where_the_selection_began() {
let (mut app, _) = app_with("yankcursor.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'j');
press(&mut app, 'v');
press(&mut app, 'j'); press(&mut app, 'y');
assert_eq!(app.cursor_row, 1, "back to the anchor, not the far end");
}
#[test]
fn a_paste_from_the_terminal_lands_as_a_block() {
let (mut app, _) = app_with("extpaste.csv", "a,b,c\n1,2,3\n4,5,6\n7,8,9\n");
cell_mode(&mut app);
app.cursor_to(1).unwrap();
press(&mut app, 'l');
app.handle_paste("x\ty\nz\tw\n".to_string()).unwrap();
assert_eq!(shown(&app, 1, 1).as_deref(), Some("x"));
assert_eq!(shown(&app, 2, 1).as_deref(), Some("y"));
assert_eq!(shown(&app, 1, 2).as_deref(), Some("z"));
assert_eq!(app.message.as_deref(), Some("pasted 4 cells"));
}
#[test]
fn a_paste_wider_than_the_table_says_what_fell_off() {
let (mut app, _) = app_with("extclip.csv", "a,b\n1,2\n");
cell_mode(&mut app);
app.handle_paste("x\ty\tz".to_string()).unwrap();
assert_eq!(
app.message.as_deref(),
Some("pasted 2 cells, 1 past the edge")
);
}
#[test]
fn a_paste_of_a_quoted_value_keeps_its_shape() {
let (mut app, _) = app_with("quotedpaste.csv", "a,b\n1,2\n");
cell_mode(&mut app);
app.handle_paste("\"one\ttwo\"\tplain".to_string()).unwrap();
assert_eq!(shown(&app, 0, 0).as_deref(), Some("one\ttwo"));
assert_eq!(shown(&app, 1, 0).as_deref(), Some("plain"));
}
#[test]
fn a_paste_from_outside_leaves_the_register_alone() {
let (mut app, _) = app_with("regkeep.csv", "a,b\n1,2\n3,4\n");
cell_mode(&mut app);
press(&mut app, 'y');
let yanked = app.register.clone();
app.handle_paste("outside".to_string()).unwrap();
assert_eq!(app.register, yanked);
}
#[test]
fn a_paste_into_a_prompt_goes_into_the_line() {
let (mut app, _) = app_with("promptpaste.csv", SAMPLE);
press(&mut app, ':');
app.handle_paste("select name".to_string()).unwrap();
assert_eq!(app.command_buf, "select name");
key(&mut app, KeyCode::Esc);
press(&mut app, '/');
app.handle_paste("two\nlines".to_string()).unwrap();
assert_eq!(app.search_buf, "two lines");
}
#[test]
fn yanking_without_a_selection_follows_the_mode() {
let (mut app, _) = app_with("yankmode.csv", SAMPLE);
press(&mut app, 'y');
assert_eq!(app.message.as_deref(), Some("yanked 2 cells"));
cell_mode(&mut app);
press(&mut app, 'y');
assert_eq!(app.message.as_deref(), Some("yanked 1 cells"));
}
#[test]
fn a_paste_running_off_the_edge_is_clipped_and_says_so() {
let (mut app, _) = app_with("clip.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'v');
press(&mut app, 'j');
press(&mut app, 'y');
press(&mut app, 'G');
press(&mut app, 'p');
let message = app.message.clone().unwrap();
assert_eq!(message, "pasted 1 cells, 1 past the edge");
assert_eq!(shown(&app, 0, 2).as_deref(), Some("a"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 1);
}
#[test]
fn pasting_an_empty_register_says_so() {
let (mut app, _) = app_with("noreg.csv", SAMPLE);
press(&mut app, 'p');
assert_eq!(app.message.as_deref(), Some("nothing to paste"));
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
}
#[test]
fn the_register_holds_pending_edits_not_what_is_on_disk() {
let (mut app, _) = app_with("regedit.csv", SAMPLE);
cell_mode(&mut app);
press(&mut app, 'c');
typed(&mut app, "edited");
key(&mut app, KeyCode::Enter);
press(&mut app, 'y');
press(&mut app, 'j');
press(&mut app, 'p');
assert_eq!(shown(&app, 0, 1).as_deref(), Some("edited"));
}
#[test]
fn a_fill_larger_than_the_buffer_should_hold_is_refused() {
let rows = MAX_BLOCK / 2 + 1000;
let mut csv = String::from("name,count\n");
for i in 0..rows {
csv.push_str(&format!("r{i},{i}\n"));
}
let (mut app, _) = app_with("huge.csv", &csv);
key(&mut app, KeyCode::Tab); press(&mut app, 'v');
press(&mut app, 'l');
press(&mut app, 'c');
assert!(matches!(app.mode, AppMode::Normal), "no editor opens");
assert_eq!(app.store.as_ref().unwrap().dirty(), 0);
let message = app.message.clone().unwrap();
assert!(message.contains("too many"), "{message}");
}
#[test]
fn escape_leaves_the_picker_changing_nothing() {
let mut app = app_sized("pick.csv", FOURCOL, 60);
press(&mut app, 'C');
assert!(matches!(app.mode, AppMode::Picker));
press(&mut app, 'j');
press(&mut app, '-'); press(&mut app, 'p'); key(&mut app, KeyCode::Esc);
assert!(matches!(app.mode, AppMode::Normal));
assert!(app.picker.is_none());
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"], "untouched");
assert!(app.pinned.is_empty(), "and the pin never happened");
}
#[test]
fn the_picker_applies_its_selection_as_a_select() {
let mut app = app_sized("pickapply.csv", FOURCOL, 60);
press(&mut app, 'C');
press(&mut app, 'j');
press(&mut app, '-'); key(&mut app, KeyCode::Enter);
assert!(matches!(app.mode, AppMode::Normal));
assert_eq!(shown_columns(&app), ["a", "c", "d"]);
command(&mut app, "reset select");
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
}
#[test]
fn opening_and_applying_the_picker_preserves_the_view_order() {
let mut app = app_sized("pickorder.csv", FOURCOL, 60);
command(&mut app, "select c a");
assert_eq!(shown_columns(&app), ["c", "a"]);
press(&mut app, 'C');
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["c", "a"], "unchanged");
}
#[test]
fn a_column_ticked_back_on_lands_where_it_was_listed() {
let mut app = app_sized("pickback.csv", FOURCOL, 60);
command(&mut app, "select a c");
press(&mut app, 'C');
press(&mut app, 'j');
press(&mut app, 'j'); press(&mut app, '-');
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["a", "c", "b"]);
}
#[test]
fn the_picker_carries_pins_in_and_back_out() {
let mut app = app_sized("pickpin.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p');
press(&mut app, 'C');
press(&mut app, 'j');
press(&mut app, 'p'); key(&mut app, KeyCode::Enter);
assert_eq!(app.pinned.iter().copied().collect::<Vec<_>>(), [0, 1]);
press(&mut app, 'C');
press(&mut app, 'p');
key(&mut app, KeyCode::Enter);
assert_eq!(app.pinned.iter().copied().collect::<Vec<_>>(), [1]);
}
#[test]
fn pins_that_do_not_fit_are_trimmed_not_refused() {
let mut app = app_sized("picktrim.csv", FOURCOL, 24);
press(&mut app, 'C');
press(&mut app, 'p'); press(&mut app, 'j');
press(&mut app, 'p'); press(&mut app, 'j');
press(&mut app, '-'); key(&mut app, KeyCode::Enter);
assert_eq!(
shown_columns(&app),
["a", "b", "d"],
"the view still applied"
);
assert!(
app.pinned.len() < 2,
"and the pins were trimmed to what fits: {:?}",
app.pinned
);
let said = app.message.clone().unwrap();
assert!(said.contains("no room for"), "{said}");
}
#[test]
fn the_picker_refuses_to_untick_the_last_column() {
let mut app = app_sized("picklast.csv", FOURCOL, 60);
command(&mut app, "select a");
press(&mut app, 'C');
press(&mut app, '-');
let refusal = app.message.clone().unwrap();
assert!(refusal.contains("hide every column"), "{refusal}");
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["a"]);
}
#[test]
fn the_picker_opens_in_row_mode() {
let mut app = app_sized("pickrow.csv", FOURCOL, 60);
assert_eq!(app.selection_mode, SelectionMode::Row);
press(&mut app, 'C');
assert!(matches!(app.mode, AppMode::Picker));
press(&mut app, 'j');
press(&mut app, '-');
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["a", "c", "d"]);
}
#[test]
fn a_and_shift_a_pick_a_handful_out_of_the_whole_list() {
let mut app = app_sized("pickall.csv", FOURCOL, 60);
press(&mut app, 'C');
press(&mut app, 'j'); press(&mut app, 'A'); press(&mut app, 'j');
press(&mut app, 'j'); press(&mut app, '-'); key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["b", "d"]);
press(&mut app, 'C');
press(&mut app, 'a');
key(&mut app, KeyCode::Enter);
assert_eq!(
shown_columns(&app),
["b", "d", "a", "c"],
"all back, listed order"
);
}
#[test]
fn q_is_not_a_picker_key() {
let mut app = app_sized("pickq.csv", FOURCOL, 60);
press(&mut app, 'C');
press(&mut app, 'j');
press(&mut app, '-');
press(&mut app, 'q');
assert!(matches!(app.mode, AppMode::Picker), "still open");
assert!(!app.exit, "and it is not the app's q either");
key(&mut app, KeyCode::Enter);
assert_eq!(shown_columns(&app), ["a", "c", "d"], "the tick survived it");
}
#[test]
fn the_help_overlay_follows_the_picker() {
let mut app = app_sized("pickhelp.csv", FOURCOL, 60);
press(&mut app, 'C');
press(&mut app, '?');
assert!(app.help_visible);
let sections = app.help_sections();
assert_eq!(sections.len(), 1, "just the picker's own keys");
let keys: Vec<&str> = sections[0].1.iter().map(|(key, _)| *key).collect();
assert!(keys.contains(&"-") && keys.contains(&"p"), "{keys:?}");
}
#[test]
fn minus_hides_the_cursor_column() {
let mut app = app_sized("hide.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab); press(&mut app, 'l');
press(&mut app, '-');
assert_eq!(shown_columns(&app), ["a", "c", "d"]);
}
#[test]
fn hiding_accumulates_rather_than_replacing() {
let mut app = app_sized("hidetwice.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, '-');
press(&mut app, '-');
assert_eq!(shown_columns(&app), ["c", "d"]);
}
#[test]
fn the_cursor_stays_where_the_hidden_column_was() {
let mut app = app_sized("hidecursor.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'l'); press(&mut app, '-');
assert_eq!(app.cursor_col, 1, "still position 1");
assert_eq!(shown_columns(&app)[app.cursor_col], "c", "now showing c");
press(&mut app, '$');
let last = shown_columns(&app).len() - 1;
assert_eq!(app.cursor_col, last);
press(&mut app, '-');
assert_eq!(app.cursor_col, shown_columns(&app).len() - 1);
}
#[test]
fn hiding_the_last_column_is_refused() {
let mut app = app_sized("hideall.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
for _ in 0..3 {
press(&mut app, '-');
}
assert_eq!(shown_columns(&app).len(), 1);
press(&mut app, '-');
assert_eq!(shown_columns(&app).len(), 1, "the last one stays");
let refusal = app.message.clone().unwrap();
assert!(refusal.contains("hide every column"), "{refusal}");
}
#[test]
fn hiding_says_how_to_get_the_column_back() {
let mut app = app_sized("hideback.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, '-');
let said = app.message.clone().unwrap();
assert!(said.contains("hid a"), "{said}");
assert!(said.contains("reset select"), "{said}");
command(&mut app, "reset select");
assert_eq!(shown_columns(&app), ["a", "b", "c", "d"]);
}
#[test]
fn sorting_in_row_mode_adopts_a_column_cursor() {
let mut app = app_sized("sortrow.csv", FOURCOL, 60);
assert_eq!(app.selection_mode, SelectionMode::Row);
press(&mut app, 's');
assert_eq!(app.selection_mode, SelectionMode::Cell, "as Tab would");
settle_sort(&mut app);
assert_eq!(
app.store.as_ref().unwrap().view.sort,
[(0, true)],
"and sorted by the column it landed on"
);
}
#[test]
fn hiding_in_row_mode_adopts_a_column_cursor() {
let mut app = app_sized("hiderow.csv", FOURCOL, 60);
assert_eq!(app.selection_mode, SelectionMode::Row);
press(&mut app, '-');
assert_eq!(app.selection_mode, SelectionMode::Cell, "as Tab would");
assert_eq!(shown_columns(&app), ["b", "c", "d"], "and it hid one");
}
#[test]
fn row_mode_hides_the_column_it_would_have_landed_on() {
let mut app = app_sized("hidescroll.csv", FOURCOL, 24);
press(&mut app, 'l'); press(&mut app, 'l');
assert_eq!(app.col_offset, 2);
press(&mut app, '-');
assert_eq!(
shown_columns(&app),
["a", "b", "d"],
"c, where Tab would land"
);
}
#[test]
fn a_hidden_column_keeps_its_pin_for_when_it_comes_back() {
let mut app = app_sized("hidepin.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p'); press(&mut app, '-');
assert_eq!(shown_columns(&app), ["b", "c", "d"]);
assert!(app.display_pins().is_empty(), "nowhere to draw it");
assert_eq!(app.pinned.iter().copied().collect::<Vec<_>>(), [0]);
command(&mut app, "reset select");
assert_eq!(app.display_pins().iter().copied().collect::<Vec<_>>(), [0]);
}
#[test]
fn zp_pins_the_cursor_column_and_pressing_it_again_lets_go() {
let mut app = app_sized("pin.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab); press(&mut app, 'l');
press(&mut app, 'z');
press(&mut app, 'p');
assert_eq!(app.pinned.iter().copied().collect::<Vec<_>>(), [1]);
press(&mut app, 'z');
press(&mut app, 'p');
assert!(app.pinned.is_empty(), "the same key lets it go");
}
#[test]
fn z_bar_unpins_everything_at_once() {
let mut app = app_sized("unpinall.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p');
press(&mut app, 'l');
press(&mut app, 'z');
press(&mut app, 'p');
assert_eq!(app.pinned.len(), 2);
press(&mut app, 'z');
press(&mut app, '|');
assert!(app.pinned.is_empty());
press(&mut app, 'z');
press(&mut app, 'P');
assert_eq!(app.pinned.len(), 1);
}
#[test]
fn a_pin_follows_its_column_through_a_select() {
let mut app = app_sized("pinselect.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p'); assert_eq!(app.display_pins().iter().copied().collect::<Vec<_>>(), [0]);
command(&mut app, "select c a");
assert_eq!(shown_columns(&app), ["c", "a"]);
assert_eq!(
app.pinned.iter().copied().collect::<Vec<_>>(),
[0],
"still `a`"
);
assert_eq!(
app.display_pins().iter().copied().collect::<Vec<_>>(),
[1],
"which `select` has moved to the second position"
);
}
#[test]
fn a_pin_on_a_hidden_column_waits_rather_than_being_dropped() {
let mut app = app_sized("pinhide.csv", FOURCOL, 60);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p');
command(&mut app, "select b c");
assert!(app.display_pins().is_empty(), "nowhere to draw it");
assert_eq!(app.pinned.iter().copied().collect::<Vec<_>>(), [0]);
command(&mut app, "reset select");
assert_eq!(app.display_pins().iter().copied().collect::<Vec<_>>(), [0]);
}
#[test]
fn a_pin_that_would_leave_nothing_to_scroll_in_is_refused() {
let mut app = app_sized("pinfull.csv", FOURCOL, 24);
key(&mut app, KeyCode::Tab);
let mut pinned = 0;
for _ in 0..4 {
press(&mut app, 'z');
press(&mut app, 'p');
if app.message.is_some() {
break;
}
pinned += 1;
press(&mut app, 'l');
}
assert!(pinned > 0, "some of them fit");
assert!(pinned < 4, "not all of them");
assert_eq!(app.pinned.len(), pinned, "the refused one is not kept");
let refusal = app.message.clone().unwrap();
assert!(refusal.contains("no room to pin"), "{refusal}");
}
#[test]
fn the_cursor_on_a_pinned_column_does_not_scroll_the_view() {
let mut app = app_sized("pinscroll.csv", WIDE, 40);
key(&mut app, KeyCode::Tab);
press(&mut app, 'z');
press(&mut app, 'p');
for _ in 0..5 {
press(&mut app, 'l');
}
app.col_offset = 4;
let before = app.col_offset;
app.cursor_col = 0; app.column_left(0);
assert_eq!(app.col_offset, before, "the view stays where it was");
}
#[test]
fn an_unknown_command_says_so() {
let (mut app, _) = app_with("unknown.csv", SAMPLE);
command(&mut app, "nope");
assert_eq!(app.message.as_deref(), Some("not a command: :nope"));
assert!(!app.exit);
}
fn strip(app: &mut App) {
press(app, 'z');
press(app, 'k');
}
#[test]
fn zk_shows_the_cursor_cell_and_follows_it() {
let long = "a,note\n1,\"a value far too long for any column to show\"\n2,short\n";
let mut app = app_sized("cellview.csv", long, 40);
assert_eq!(app.panel_height(40, 24), 0, "nothing showing yet");
strip(&mut app);
assert!(app.cell_view);
assert_eq!(
app.selection_mode,
SelectionMode::Cell,
"a cell view needs a cell cursor, as an edit does"
);
let (name, value) = app.cell_under_cursor().unwrap();
assert_eq!(name, "a");
assert_eq!(value, "1");
press(&mut app, 'l');
let (name, value) = app.cell_under_cursor().unwrap();
assert_eq!(name, "note");
assert!(value.starts_with("a value far too long"));
assert!(app.panel_height(40, 24) > 1, "and takes room to show it");
key(&mut app, KeyCode::Esc);
assert!(!app.cell_view);
assert_eq!(app.panel_height(40, 24), 0);
}
#[test]
fn the_cell_view_gives_up_rows_to_show_itself() {
let long = "a\n\"".to_string() + &"x".repeat(500) + "\"\n";
let mut app = app_sized("cellroom.csv", &long, 40);
let before = App::viewport_rows(24, 0);
strip(&mut app);
let panel = app.panel_height(40, 24);
assert!(panel > 0);
assert_eq!(
App::viewport_rows(24, panel),
before - panel as usize,
"the table gives up exactly what the panel takes"
);
assert!(panel <= 12, "and never more than half the screen");
}
fn windowed(name: &str) -> App {
let value: String = (1..=200).map(|n| format!("line {n}. ")).collect();
let file = format!("a,note\n1,\"{value}\"\n");
let mut app = app_sized(name, &file, 60);
cell_mode(&mut app);
press(&mut app, 'l'); app
}
#[test]
fn k_opens_a_window_on_the_cursor_cell() {
let mut app = windowed("cellwindow.csv");
assert!(app.inspect.is_none(), "nothing showing yet");
press(&mut app, 'K');
assert!(app.inspect.is_some());
assert_eq!(
app.selection_mode,
SelectionMode::Cell,
"a cell window needs a cell cursor, as an edit does"
);
let (name, value) = app.cell_under_cursor().unwrap();
assert_eq!(name, "note");
assert!(value.starts_with("line 1."));
assert_eq!(
app.panel_height(60, 24),
0,
"and takes nothing from the table"
);
press(&mut app, 'q');
assert!(app.inspect.is_none(), "q closes the window");
assert!(!app.exit, "and does not quit plv");
}
#[test]
fn the_window_holds_the_keys_while_it_is_up() {
let mut app = windowed("cellkeys.csv");
let row = app.cursor_row;
press(&mut app, 'K');
app.inspect.as_mut().unwrap().rows = 10;
press(&mut app, 'j');
assert_eq!(app.inspect.as_ref().unwrap().scroll, 1);
assert_eq!(app.cursor_row, row, "the table did not move under it");
press(&mut app, 'k');
press(&mut app, 'k');
assert_eq!(
app.inspect.as_ref().unwrap().scroll,
0,
"and stops at the top"
);
key(
&mut app,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
);
assert_eq!(app.inspect.as_ref().unwrap().scroll, 5, "half a window");
press(&mut app, 'g');
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
press(&mut app, 'G');
assert_eq!(app.inspect.as_ref().unwrap().scroll, usize::MAX);
key(&mut app, KeyCode::Esc);
assert!(app.inspect.is_none());
assert!(app.search_state.is_none(), "and the Esc stopped there");
}
fn drawn(app: &mut App, width: u16, height: u16) -> Vec<String> {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let buf = terminal.backend().buffer().clone();
(0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn the_window_is_drawn_over_the_table() {
let mut app = windowed("celldraw.csv");
let plain = drawn(&mut app, 60, 16);
assert!(plain.iter().any(|line| line.contains("line 1.")));
press(&mut app, 'K');
let over = drawn(&mut app, 60, 16);
let middle = over.iter().filter(|line| line.contains('│')).count();
assert!(middle > 3, "the window is on screen: {over:?}");
assert!(
over.iter().any(|line| line.contains("note —")),
"titled with the column: {over:?}"
);
assert!(app.inspect.as_ref().unwrap().rows > 0);
press(&mut app, 'q');
assert_eq!(drawn(&mut app, 60, 16), plain, "and the table is back");
}
#[test]
fn slash_finds_inside_the_value() {
let mut app = windowed("cellfind.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
assert!(
matches!(app.mode, AppMode::Search),
"the prompt is the same prompt"
);
typed(&mut app, "line 137");
key(&mut app, KeyCode::Enter);
assert!(matches!(app.mode, AppMode::Normal));
drawn(&mut app, 60, 16);
let hunt = app.inspect.as_ref().unwrap().hunt.as_ref().unwrap();
assert_eq!(hunt.hits.len(), 1);
assert_eq!(hunt.current, Some(0));
assert!(
app.inspect.as_ref().unwrap().scroll > 0,
"and the window went to it"
);
}
#[test]
fn the_windows_search_leaves_the_tables_alone() {
let mut app = windowed("cellfind2.csv");
let row = app.cursor_row;
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line 3");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 60, 16);
assert!(app.search_state.is_none(), "the table was not searched");
assert_eq!(app.cursor_row, row, "and did not move");
assert!(app.inspect.as_ref().unwrap().hunt.is_some());
}
#[test]
fn n_and_shift_n_step_through_the_matches() {
let mut app = windowed("cellstep.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line 1[0-9][0-9]");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 60, 16);
let hunt = app.inspect.as_ref().unwrap().hunt.as_ref().unwrap();
assert_eq!(hunt.starts.len(), 100, "line 100 through line 199");
assert!(
hunt.hits.len() > hunt.starts.len(),
"{} pieces for {} matches",
hunt.hits.len(),
hunt.starts.len()
);
let first = app.inspect.as_ref().unwrap().scroll;
press(&mut app, 'n');
drawn(&mut app, 60, 16);
let second = app.inspect.as_ref().unwrap().scroll;
assert!(second > first, "n went forward: {first} -> {second}");
press(&mut app, 'N');
drawn(&mut app, 60, 16);
assert_eq!(
app.inspect.as_ref().unwrap().scroll,
first,
"and N came back"
);
}
#[test]
fn n_goes_on_from_where_the_reader_is_looking() {
let mut app = windowed("cellresume.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line 1[0-9][0-9]");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 60, 16);
let first = app.inspect.as_ref().unwrap().hunt.as_ref().unwrap().current;
assert_eq!(first, Some(0));
press(&mut app, 'G');
drawn(&mut app, 60, 16);
press(&mut app, 'n');
drawn(&mut app, 60, 16);
let after = app.inspect.as_ref().unwrap().hunt.as_ref().unwrap();
assert!(
after.current.unwrap() > 50,
"resumed from the end, not from match 1: {:?}",
after.current
);
}
#[test]
fn closing_the_window_ends_its_search() {
let mut app = windowed("cellend.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line 2");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 60, 16);
assert!(app.inspect.as_ref().unwrap().hunt.is_some());
press(&mut app, 'q');
press(&mut app, 'K');
drawn(&mut app, 60, 16);
assert!(
app.inspect.as_ref().unwrap().hunt.is_none(),
"a new window opens with no search"
);
}
#[test]
fn an_empty_pattern_clears_the_search() {
let mut app = windowed("cellclear.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line 2");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 60, 16);
press(&mut app, '/');
key(&mut app, KeyCode::Enter);
assert!(app.inspect.as_ref().unwrap().hunt.is_none());
}
#[test]
fn a_bad_pattern_is_reported_rather_than_swallowed() {
let mut app = windowed("cellbad.csv");
press(&mut app, 'K');
drawn(&mut app, 60, 16);
press(&mut app, '/');
typed(&mut app, "line [");
key(&mut app, KeyCode::Enter);
assert!(app.inspect.as_ref().unwrap().hunt.is_none());
assert!(
app.message.as_deref().unwrap_or_default().contains("regex"),
"{:?}",
app.message
);
}
#[test]
fn switching_the_view_finds_the_matches_again() {
let json = r#"{""alpha"":4821,""beta"":[""a"",""b""]}"#;
let file = format!("id,note\n1,\"{json}\"\n");
let mut app = app_sized("cellrefind.csv", &file, 80);
cell_mode(&mut app);
press(&mut app, 'l');
press(&mut app, 'K');
drawn(&mut app, 80, 20);
press(&mut app, '/');
typed(&mut app, "beta");
key(&mut app, KeyCode::Enter);
drawn(&mut app, 80, 20);
let formatted = app
.inspect
.as_ref()
.unwrap()
.hunt
.as_ref()
.unwrap()
.hits
.clone();
assert_eq!(formatted.len(), 1);
press(&mut app, 'r');
drawn(&mut app, 80, 20);
let raw = app.inspect.as_ref().unwrap().hunt.as_ref().unwrap();
assert_eq!(raw.hits.len(), 1, "still found in the bytes");
assert!(
raw.at.is_some(),
"and found again under the view it is now in"
);
}
#[test]
fn r_switches_between_the_formatting_and_the_value() {
let json = r#"{""id"":4821,""tags"":[""a"",""b""]}"#;
let file = format!("id,note\n1,\"{json}\"\n");
let mut app = app_sized("celljson.csv", &file, 80);
cell_mode(&mut app);
press(&mut app, 'l');
press(&mut app, 'K');
let content = &app.inspect.as_ref().unwrap().content;
assert_eq!(content.format, ui::cell::Format::Json);
assert!(!content.raw);
assert!(content.lines.len() > 1, "broken out over several lines");
press(&mut app, 'r');
let content = &app.inspect.as_ref().unwrap().content;
assert!(content.raw);
assert_eq!(content.lines.len(), 1, "the cell as it is written");
assert_eq!(
content.format,
ui::cell::Format::Json,
"still known for what it is, so there is a way back"
);
press(&mut app, 'r');
assert!(!app.inspect.as_ref().unwrap().content.raw);
}
#[test]
fn r_does_nothing_where_no_format_was_recognised() {
let mut app = windowed("cellnojson.csv");
press(&mut app, 'K');
assert_eq!(
app.inspect.as_ref().unwrap().content.format,
ui::cell::Format::Text
);
press(&mut app, 'r');
assert!(!app.inspect.as_ref().unwrap().content.raw);
}
#[test]
fn a_log_opens_and_its_nested_cells_are_documents() {
let path = std::path::PathBuf::from("samples/log.jsonl");
let mut app = App::new(Some(path.clone()));
app.store = Some(Store::open_file(&path, 12).unwrap());
app.last_frame_width = 120;
let store = app.store.as_ref().unwrap();
assert_eq!(store.row_count(), 10);
assert!(
store.notes().unwrap().contains("keys shown"),
"and says what it left out"
);
command(&mut app, "reset select");
cell_mode(&mut app);
while app
.store
.as_ref()
.unwrap()
.column_info(app.cursor_col)
.is_some_and(|(name, _)| name != "job")
{
press(&mut app, 'l');
}
app.cursor_to(2).unwrap();
press(&mut app, 'K');
let content = &app.inspect.as_ref().unwrap().content;
assert_eq!(
content.format,
ui::cell::Format::Json,
"the file's own bytes reached the window"
);
assert!(content.lines.len() > 1, "and were laid out as a document");
}
#[test]
fn expand_types_a_documents_keys_into_columns() {
let path = std::path::PathBuf::from("samples/log.jsonl");
let mut app = App::new(Some(path.clone()));
app.store = Some(Store::open_file(&path, 12).unwrap());
app.last_frame_width = 160;
command(&mut app, "reset select");
command(&mut app, "expand err");
let said = app.message.clone().unwrap();
assert!(said.contains("err expanded into"), "{said}");
let shown = shown_columns(&app);
assert!(shown.contains(&"err.code".to_string()), "{shown:?}");
assert!(shown.contains(&"err.retryable".to_string()), "{shown:?}");
assert!(
!shown.contains(&"err".to_string()),
"the parent gave up its place"
);
let store = app.store.as_ref().unwrap();
assert_eq!(store.schema.get("err.code"), Some(&DataType::Int64));
}
#[test]
fn expand_names_the_column_it_cannot_open() {
let path = std::path::PathBuf::from("samples/log.jsonl");
let mut app = App::new(Some(path.clone()));
app.store = Some(Store::open_file(&path, 12).unwrap());
app.last_frame_width = 160;
command(&mut app, "expand level");
assert!(
app.message
.clone()
.unwrap()
.contains("no documents in level"),
"{:?}",
app.message
);
command(&mut app, "expand nosuchkey");
assert!(
app.message.clone().unwrap().contains("no column"),
"{:?}",
app.message
);
}
#[test]
fn the_help_for_a_read_only_file_still_names_the_view_commands() {
let (_, path) = app_with("helpviews.csv", SAMPLE);
let mut app = App::new(Some(path.clone()));
let lf = crate::data::loader::load(&path).unwrap();
app.store = Some(Store::new(lf, 12).unwrap());
assert!(!app.store.as_ref().unwrap().is_editable());
let headings: Vec<&str> = app.help_sections().iter().map(|(name, _)| *name).collect();
assert!(headings.contains(&"Views"), "{headings:?}");
assert!(
!headings.contains(&"Edit"),
"but not the ones it refuses: {headings:?}"
);
}
#[test]
fn the_window_and_the_strip_are_different_keys() {
let mut app = windowed("cellboth.csv");
strip(&mut app);
assert!(app.cell_view);
assert!(app.inspect.is_none(), "zk is the strip and only the strip");
press(&mut app, 'K');
assert!(app.inspect.is_some());
assert!(app.cell_view, "which does not take the strip away");
}
#[test]
fn a_column_can_be_widened_narrowed_and_put_back() {
let mut app = app_sized("widths.csv", FOURCOL, 60);
cell_mode(&mut app);
let start = app.drawn_width(0);
press(&mut app, 'z');
press(&mut app, '>');
let wider = *app.widths.get(&0).expect("a width was set");
assert!(wider > start, "{wider} should be wider than {start}");
press(&mut app, 'z');
press(&mut app, '<');
assert_eq!(*app.widths.get(&0).unwrap(), start, "back where it began");
press(&mut app, 'z');
press(&mut app, '=');
assert!(app.widths.is_empty(), "and z= clears the lot");
}
#[test]
fn shift_held_through_a_z_command_still_works() {
let mut app = app_sized("shiftz.csv", FOURCOL, 60);
cell_mode(&mut app);
let start = app.drawn_width(0);
key(
&mut app,
KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
);
assert_eq!(app.pending_prefix, Some('z'), "`Z` opens the prefix too");
key(
&mut app,
KeyEvent::new(KeyCode::Char('>'), KeyModifiers::SHIFT),
);
assert!(app.widths.get(&0).is_some_and(|&w| w > start));
}
#[test]
fn a_shifted_second_key_still_reaches_the_unshifted_command() {
let mut app = tall_app("shiftzz.csv");
app.cursor_to(20).unwrap();
key(
&mut app,
KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
);
key(
&mut app,
KeyEvent::new(KeyCode::Char('T'), KeyModifiers::SHIFT),
);
assert_eq!(
app.store.as_ref().unwrap().row_offset,
20,
"zt put the cursor row at the top"
);
}
#[test]
fn a_width_belongs_to_the_column_not_to_its_place_on_screen() {
let mut app = app_sized("widthview.csv", FOURCOL, 60);
cell_mode(&mut app);
press(&mut app, 'l'); press(&mut app, 'z');
press(&mut app, '>');
let set = *app.widths.get(&1).expect("keyed by source column");
command(&mut app, "select d b");
assert_eq!(
app.widths.get(&1),
Some(&set),
"`b` keeps its width at its new position"
);
}
#[test]
fn fitting_a_column_uses_the_widest_value_on_screen() {
let mut app = app_sized("fit.csv", "a,b\nshort,x\nmuch longer value,y\n", 60);
cell_mode(&mut app);
press(&mut app, 'z');
press(&mut app, '_');
assert_eq!(
app.widths.get(&0),
Some(&"much longer value".len()),
"the widest value on the page, not a guess"
);
}
#[test]
fn a_column_cannot_be_narrowed_away_entirely() {
let mut app = app_sized("narrow.csv", FOURCOL, 60);
cell_mode(&mut app);
for _ in 0..20 {
press(&mut app, 'z');
press(&mut app, '<');
}
assert_eq!(*app.widths.get(&0).unwrap(), ui::MIN_COLUMN);
}
#[test]
fn hash_switches_between_relative_and_absolute_row_numbers() {
let (mut app, _) = app_with("gutter.csv", SAMPLE);
assert!(app.relative_rows, "counting from the cursor is the default");
press(&mut app, '#');
assert!(!app.relative_rows);
press(&mut app, '#');
assert!(app.relative_rows);
}
#[test]
fn a_question_mark_is_a_character_while_typing() {
let (mut app, _) = app_with("help.csv", SAMPLE);
press(&mut app, 'c');
press(&mut app, '?');
assert!(!app.help_visible, "'?' belongs to the cell being typed");
assert_eq!(app.edit_buf, "?");
key(&mut app, KeyCode::Esc);
press(&mut app, '?');
assert!(app.help_visible, "and opens the overlay in normal mode");
}
fn settle_sort(app: &mut App) {
for _ in 0..2000 {
if app.sort_rx.is_none() {
return;
}
if !app.poll_sort() {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
panic!("the sort never finished");
}
#[test]
fn a_sorted_view_can_be_edited_once_the_sort_is_held() {
let (mut app, path) = app_with("sorted.csv", SAMPLE);
app.last_frame_width = 60;
cell_mode(&mut app);
press(&mut app, 's'); settle_sort(&mut app);
press(&mut app, 's'); settle_sort(&mut app);
press(&mut app, 'l');
press(&mut app, 'c');
typed(&mut app, "99");
key(&mut app, KeyCode::Enter);
assert_eq!(shown(&app, 1, 0).as_deref(), Some("99"));
command(&mut app, "w");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"name,count\na,1\nb,2\nc,99\n",
"the top row of a descending sort is the file's last line"
);
}
#[test]
fn editing_the_caret_inside_the_buffer() {
let (mut app, _) = app_with("caretmove.csv", SAMPLE);
press(&mut app, 'c');
typed(&mut app, "abc");
key(&mut app, KeyCode::Left);
typed(&mut app, "X");
assert_eq!(app.edit_buf, "abXc");
key(&mut app, KeyCode::Home);
key(&mut app, KeyCode::Delete);
assert_eq!(app.edit_buf, "bXc");
key(&mut app, KeyCode::End);
key(&mut app, KeyCode::Backspace);
assert_eq!(app.edit_buf, "bX");
}
}