Skip to main content

vix_edit_table/
lib.rs

1//! The table editor: a spreadsheet-like grid for viewing and editing delimited
2//! data (CSV/TSV) as rows and columns.
3//!
4//! Vix's Tools menu offers an *Edit Table* command that parses the active buffer as
5//! CSV or TSV (per the file extension) into a rectangular grid. The first row is
6//! treated as the header. The user navigates a cell cursor with the arrow keys
7//! (or `h`/`j`/`k`/`l`), edits a cell in place, inserts and deletes rows and
8//! columns, sorts by the current column, and searches across all cells. Saving
9//! serializes the grid back to delimited text.
10//!
11//! This crate is self-contained and host-agnostic: it owns the grid data,
12//! the cursor, the edit/find buffers, and an undo/redo history, and it
13//! interprets key events itself (returning an [`Outcome`] telling the host
14//! when to close or save). The host (`App` in the root `vix` crate) only
15//! renders the grid, syncs the visible scroll window, and acts on the
16//! returned outcome.
17
18#![warn(clippy::pedantic)]
19#![forbid(unsafe_code)]
20#![deny(missing_docs)]
21
22use std::cmp::Ordering;
23
24use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
25
26use vix_convert_tabular::{parse_csv, parse_tsv, write_csv, write_tsv};
27
28/// What the host should do after the grid handled a key.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31    /// The key was handled internally; nothing further for the host to do.
32    Consumed,
33    /// The user asked to close the table editor (Esc/`q` in normal mode).
34    Close,
35    /// The user asked to save (Ctrl+S); the host should persist the grid.
36    Save,
37}
38
39/// The current interaction mode.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Mode {
42    /// Navigating the cell cursor and issuing commands.
43    Normal,
44    /// Editing the current cell; keystrokes go to the edit buffer.
45    Edit,
46    /// Typing a search query; keystrokes go to the find buffer.
47    Find,
48}
49
50/// A point-in-time snapshot of the grid for undo/redo.
51#[derive(Clone)]
52struct Snapshot {
53    rows: Vec<Vec<String>>,
54    row: usize,
55    col: usize,
56}
57
58/// Maximum number of undo steps retained.
59const HISTORY_CAP: usize = 200;
60
61/// A spreadsheet-like grid of string cells with a cell cursor and edit history.
62pub struct Grid {
63    /// Cells, rectangular (every row has the same length). Row 0 is the header.
64    rows: Vec<Vec<String>>,
65    /// Whether the source was TSV (tab-separated); otherwise CSV.
66    tsv: bool,
67    /// Selected row index, `0..rows.len()`.
68    row: usize,
69    /// Selected column index, `0..cols`.
70    col: usize,
71    /// First visible row (vertical scroll), synced by [`Grid::ensure_row_visible`].
72    row_scroll: usize,
73    /// First visible column (horizontal scroll), synced by the host renderer.
74    col_scroll: usize,
75    /// Whether the grid has unsaved changes.
76    dirty: bool,
77    /// Current interaction mode.
78    mode: Mode,
79    /// Edit buffer, valid while editing a cell.
80    edit_buf: String,
81    /// Find buffer, valid while typing a query.
82    find_buf: String,
83    /// The last committed search query, reused by "find next".
84    last_query: String,
85    /// Undo history (most recent last).
86    undo: Vec<Snapshot>,
87    /// Redo history (most recent last).
88    redo: Vec<Snapshot>,
89}
90
91impl Grid {
92    /// Build a grid from delimited `text`. `tsv` selects the tab-separated
93    /// parser; otherwise RFC 4180 CSV is used. The grid is normalized to a
94    /// rectangle (short rows are padded with empty cells) and always has at
95    /// least one row and one column.
96    #[must_use]
97    pub fn from_text(text: &str, tsv: bool) -> Self {
98        let mut rows = if tsv {
99            parse_tsv(text)
100        } else {
101            parse_csv(text)
102        };
103        normalize(&mut rows);
104        Grid {
105            rows,
106            tsv,
107            row: 0,
108            col: 0,
109            row_scroll: 0,
110            col_scroll: 0,
111            dirty: false,
112            mode: Mode::Normal,
113            edit_buf: String::new(),
114            find_buf: String::new(),
115            last_query: String::new(),
116            undo: Vec::new(),
117            redo: Vec::new(),
118        }
119    }
120
121    /// Number of rows (including the header).
122    #[must_use]
123    pub fn row_count(&self) -> usize {
124        self.rows.len()
125    }
126
127    /// Number of columns.
128    #[must_use]
129    pub fn col_count(&self) -> usize {
130        self.rows.first().map_or(0, Vec::len)
131    }
132
133    /// The cell at `(r, c)`, or `""` when out of range.
134    #[must_use]
135    pub fn cell(&self, r: usize, c: usize) -> &str {
136        self.rows
137            .get(r)
138            .and_then(|row| row.get(c))
139            .map_or("", String::as_str)
140    }
141
142    /// The selected row index.
143    #[must_use]
144    pub fn row(&self) -> usize {
145        self.row
146    }
147
148    /// The selected column index.
149    #[must_use]
150    pub fn col(&self) -> usize {
151        self.col
152    }
153
154    /// The first visible row (vertical scroll offset).
155    #[must_use]
156    pub fn row_scroll(&self) -> usize {
157        self.row_scroll
158    }
159
160    /// The first visible column (horizontal scroll offset).
161    #[must_use]
162    pub fn col_scroll(&self) -> usize {
163        self.col_scroll
164    }
165
166    /// Whether the grid has unsaved edits.
167    #[must_use]
168    pub fn is_dirty(&self) -> bool {
169        self.dirty
170    }
171
172    /// Whether a cell is currently being edited.
173    #[must_use]
174    pub fn is_editing(&self) -> bool {
175        matches!(self.mode, Mode::Edit)
176    }
177
178    /// Whether a search query is currently being typed.
179    #[must_use]
180    pub fn is_finding(&self) -> bool {
181        matches!(self.mode, Mode::Find)
182    }
183
184    /// The in-progress edit text (valid while [`Grid::is_editing`]).
185    #[must_use]
186    pub fn edit_buffer(&self) -> &str {
187        &self.edit_buf
188    }
189
190    /// The in-progress find text (valid while [`Grid::is_finding`]).
191    #[must_use]
192    pub fn find_buffer(&self) -> &str {
193        &self.find_buf
194    }
195
196    /// Serialize the grid back to delimited text (TSV or CSV per the source).
197    #[must_use]
198    pub fn to_text(&self) -> String {
199        if self.tsv {
200            write_tsv(&self.rows)
201        } else {
202            write_csv(&self.rows)
203        }
204    }
205
206    /// Mark the grid as saved (called by the host after a successful write).
207    pub fn mark_saved(&mut self) {
208        self.dirty = false;
209    }
210
211    /// Adjust the vertical scroll so the selected data row stays within the body
212    /// window of `height` rows. Row 0 is a pinned header rendered separately, so
213    /// the body always starts at row 1 or later. Called before drawing.
214    pub fn ensure_row_visible(&mut self, height: usize) {
215        let height = height.max(1);
216        let count = self.row_count();
217        if count <= 1 {
218            self.row_scroll = count;
219            return;
220        }
221        if self.row >= 1 {
222            if self.row < self.row_scroll {
223                self.row_scroll = self.row;
224            } else if self.row >= self.row_scroll + height {
225                self.row_scroll = self.row + 1 - height;
226            }
227        }
228        let max_scroll = count.saturating_sub(height).max(1);
229        self.row_scroll = self.row_scroll.clamp(1, max_scroll);
230    }
231
232    /// Set the horizontal scroll so the selected column is visible. The renderer
233    /// computes `first_visible` from the variable column widths and the viewport.
234    pub fn set_col_scroll(&mut self, first_visible: usize) {
235        self.col_scroll = first_visible.min(self.col.min(self.col_count().saturating_sub(1)));
236    }
237
238    /// Interpret a key event, mutating the grid, and report what the host should
239    /// do next. `page` is the number of rows to move for `PageUp`/`PageDown`.
240    pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
241        // Ctrl+S saves from any mode, committing an in-progress edit first.
242        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
243            if self.mode == Mode::Edit {
244                self.commit_edit();
245            }
246            return Outcome::Save;
247        }
248        match self.mode {
249            Mode::Edit => self.edit_key(key),
250            Mode::Find => self.find_key(key),
251            Mode::Normal => return self.normal_key(key, page),
252        }
253        Outcome::Consumed
254    }
255
256    /// Handle a key while editing a cell.
257    fn edit_key(&mut self, key: KeyEvent) {
258        match key.code {
259            KeyCode::Enter => {
260                self.commit_edit();
261                self.move_down();
262            }
263            KeyCode::Tab => {
264                self.commit_edit();
265                self.move_right();
266            }
267            KeyCode::Esc => self.mode = Mode::Normal,
268            KeyCode::Backspace => {
269                self.edit_buf.pop();
270            }
271            KeyCode::Char(c) => self.edit_buf.push(c),
272            _ => {}
273        }
274    }
275
276    /// Handle a key while typing a search query.
277    fn find_key(&mut self, key: KeyEvent) {
278        match key.code {
279            KeyCode::Enter => {
280                self.last_query = std::mem::take(&mut self.find_buf);
281                self.mode = Mode::Normal;
282                self.find_from(self.row, self.col, true);
283            }
284            KeyCode::Esc => {
285                self.find_buf.clear();
286                self.mode = Mode::Normal;
287            }
288            KeyCode::Backspace => {
289                self.find_buf.pop();
290            }
291            KeyCode::Char(c) => self.find_buf.push(c),
292            _ => {}
293        }
294    }
295
296    /// Handle a key in normal (navigation/command) mode.
297    fn normal_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
298        let alt = key.modifiers.contains(KeyModifiers::ALT);
299        if alt {
300            self.structural_key(key.code);
301            return Outcome::Consumed;
302        }
303        match key.code {
304            KeyCode::Up | KeyCode::Char('k') => self.move_up(),
305            KeyCode::Down | KeyCode::Char('j') => self.move_down(),
306            KeyCode::Left | KeyCode::Char('h') => self.move_left(),
307            KeyCode::Right | KeyCode::Char('l') => self.move_right(),
308            KeyCode::PageUp => self.row = self.row.saturating_sub(page.max(1)),
309            KeyCode::PageDown => {
310                self.row = (self.row + page.max(1)).min(self.row_count().saturating_sub(1));
311            }
312            KeyCode::Home => self.col = 0,
313            KeyCode::End => self.col = self.col_count().saturating_sub(1),
314            KeyCode::Char('g') => self.row = 0,
315            KeyCode::Char('G') => self.row = self.row_count().saturating_sub(1),
316            KeyCode::Enter | KeyCode::F(2) => self.begin_edit(),
317            KeyCode::Delete => self.clear_cell(),
318            KeyCode::Char('s') => self.sort(true),
319            KeyCode::Char('S') => self.sort(false),
320            KeyCode::Char('/') => {
321                self.mode = Mode::Find;
322                self.find_buf.clear();
323            }
324            KeyCode::Char('n') => self.find_from(self.row, self.col, true),
325            KeyCode::Char('u') => self.undo(),
326            KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.redo(),
327            KeyCode::Esc | KeyCode::Char('q') => return Outcome::Close,
328            _ => {}
329        }
330        Outcome::Consumed
331    }
332
333    /// Handle an Alt+key structural command (insert/delete rows and columns).
334    fn structural_key(&mut self, code: KeyCode) {
335        match code {
336            KeyCode::Up => self.insert_row(self.row),
337            KeyCode::Down => self.insert_row(self.row + 1),
338            KeyCode::Left => self.insert_col(self.col),
339            KeyCode::Right => self.insert_col(self.col + 1),
340            KeyCode::Delete => self.delete_row(),
341            KeyCode::Backspace => self.delete_col(),
342            _ => {}
343        }
344    }
345
346    // ----- cursor movement --------------------------------------------------
347
348    /// Move the cursor up one row, stopping at the top.
349    fn move_up(&mut self) {
350        self.row = self.row.saturating_sub(1);
351    }
352
353    /// Move the cursor down one row, stopping at the bottom.
354    fn move_down(&mut self) {
355        if self.row + 1 < self.row_count() {
356            self.row += 1;
357        }
358    }
359
360    /// Move the cursor left one column, stopping at the first.
361    fn move_left(&mut self) {
362        self.col = self.col.saturating_sub(1);
363    }
364
365    /// Move the cursor right one column, stopping at the last.
366    fn move_right(&mut self) {
367        if self.col + 1 < self.col_count() {
368            self.col += 1;
369        }
370    }
371
372    // ----- editing ----------------------------------------------------------
373
374    /// Begin editing the current cell, seeding the buffer with its contents.
375    fn begin_edit(&mut self) {
376        self.edit_buf = self.cell(self.row, self.col).to_string();
377        self.mode = Mode::Edit;
378    }
379
380    /// Commit the edit buffer into the current cell as one undo step.
381    fn commit_edit(&mut self) {
382        let text = std::mem::take(&mut self.edit_buf);
383        self.mode = Mode::Normal;
384        if self.cell(self.row, self.col) == text {
385            return;
386        }
387        self.push_undo();
388        if let Some(cell) = self
389            .rows
390            .get_mut(self.row)
391            .and_then(|r| r.get_mut(self.col))
392        {
393            *cell = text;
394            self.dirty = true;
395        }
396    }
397
398    /// Clear the current cell (set it to empty) as one undo step.
399    fn clear_cell(&mut self) {
400        if self.cell(self.row, self.col).is_empty() {
401            return;
402        }
403        self.push_undo();
404        if let Some(cell) = self
405            .rows
406            .get_mut(self.row)
407            .and_then(|r| r.get_mut(self.col))
408        {
409            cell.clear();
410            self.dirty = true;
411        }
412    }
413
414    // ----- structural edits -------------------------------------------------
415
416    /// Insert an empty row at index `at` (clamped), as one undo step.
417    fn insert_row(&mut self, at: usize) {
418        self.push_undo();
419        let cols = self.col_count().max(1);
420        let at = at.min(self.row_count());
421        self.rows.insert(at, vec![String::new(); cols]);
422        self.row = at;
423        self.dirty = true;
424    }
425
426    /// Delete the current row, keeping at least one row, as one undo step.
427    fn delete_row(&mut self) {
428        if self.row_count() <= 1 {
429            return;
430        }
431        self.push_undo();
432        self.rows.remove(self.row);
433        self.row = self.row.min(self.row_count().saturating_sub(1));
434        self.dirty = true;
435    }
436
437    /// Insert an empty column at index `at` (clamped), as one undo step.
438    fn insert_col(&mut self, at: usize) {
439        self.push_undo();
440        let at = at.min(self.col_count());
441        for row in &mut self.rows {
442            row.insert(at.min(row.len()), String::new());
443        }
444        self.col = at;
445        self.dirty = true;
446    }
447
448    /// Delete the current column, keeping at least one column, as one undo step.
449    fn delete_col(&mut self) {
450        if self.col_count() <= 1 {
451            return;
452        }
453        self.push_undo();
454        let c = self.col;
455        for row in &mut self.rows {
456            if c < row.len() {
457                row.remove(c);
458            }
459        }
460        self.col = self.col.min(self.col_count().saturating_sub(1));
461        self.dirty = true;
462    }
463
464    // ----- sort -------------------------------------------------------------
465
466    /// Sort the data rows (keeping the header fixed) by the current column.
467    /// `ascending` selects the direction. Numeric cells compare numerically.
468    fn sort(&mut self, ascending: bool) {
469        if self.row_count() <= 2 {
470            return;
471        }
472        self.push_undo();
473        let c = self.col;
474        let header = self.rows.remove(0);
475        self.rows.sort_by(|a, b| {
476            let ord = cmp_cells(
477                a.get(c).map_or("", String::as_str),
478                b.get(c).map_or("", String::as_str),
479            );
480            if ascending { ord } else { ord.reverse() }
481        });
482        self.rows.insert(0, header);
483        self.dirty = true;
484    }
485
486    // ----- find -------------------------------------------------------------
487
488    /// Move the cursor to the next cell containing the last query (case
489    /// insensitive), scanning row-major from just after `(from_row, from_col)`
490    /// and wrapping. Does nothing if there is no query or no match.
491    fn find_from(&mut self, from_row: usize, from_col: usize, _forward: bool) {
492        if self.last_query.is_empty() {
493            return;
494        }
495        let needle = self.last_query.to_lowercase();
496        let cols = self.col_count();
497        let total = self.row_count() * cols;
498        if total == 0 {
499            return;
500        }
501        let start = from_row * cols + from_col;
502        for step in 1..=total {
503            let idx = (start + step) % total;
504            let (r, c) = (idx / cols, idx % cols);
505            if self.cell(r, c).to_lowercase().contains(&needle) {
506                self.row = r;
507                self.col = c;
508                return;
509            }
510        }
511    }
512
513    // ----- undo/redo --------------------------------------------------------
514
515    /// Capture the current grid state onto the undo stack and clear redo.
516    fn push_undo(&mut self) {
517        // Delegates to `vix-capped-stack` (Run H, T525) -- this crate and
518        // four siblings used to each hand-roll this identical clamp.
519        let snap = self.snapshot();
520        vix_capped_stack::push_capped(&mut self.undo, snap, HISTORY_CAP);
521        self.redo.clear();
522    }
523
524    /// A snapshot of the current grid + cursor.
525    fn snapshot(&self) -> Snapshot {
526        Snapshot {
527            rows: self.rows.clone(),
528            row: self.row,
529            col: self.col,
530        }
531    }
532
533    /// Restore `snap` into the grid and return the prior state for the other stack.
534    fn restore(&mut self, snap: Snapshot) -> Snapshot {
535        let prior = self.snapshot();
536        self.rows = snap.rows;
537        self.row = snap.row.min(self.row_count().saturating_sub(1));
538        self.col = snap.col.min(self.col_count().saturating_sub(1));
539        self.dirty = true;
540        prior
541    }
542
543    /// Undo the most recent change.
544    fn undo(&mut self) {
545        if let Some(snap) = self.undo.pop() {
546            let prior = self.restore(snap);
547            self.redo.push(prior);
548        }
549    }
550
551    /// Redo the most recently undone change.
552    fn redo(&mut self) {
553        if let Some(snap) = self.redo.pop() {
554            let prior = self.restore(snap);
555            self.undo.push(prior);
556        }
557    }
558}
559
560/// Pad every row to the width of the widest row so the grid is rectangular, and
561/// guarantee at least one row and one column.
562fn normalize(rows: &mut Vec<Vec<String>>) {
563    let width = rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
564    if rows.is_empty() {
565        rows.push(Vec::new());
566    }
567    for row in rows.iter_mut() {
568        if row.len() < width {
569            row.resize(width, String::new());
570        }
571    }
572}
573
574/// Compare two cells: numerically when both parse as numbers, else as strings.
575fn cmp_cells(a: &str, b: &str) -> Ordering {
576    match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
577        (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
578        _ => a.cmp(b),
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    fn key(c: char) -> KeyEvent {
587        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
588    }
589
590    fn code(code: KeyCode) -> KeyEvent {
591        KeyEvent::new(code, KeyModifiers::NONE)
592    }
593
594    fn alt(code: KeyCode) -> KeyEvent {
595        KeyEvent::new(code, KeyModifiers::ALT)
596    }
597
598    fn ctrl(c: char) -> KeyEvent {
599        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
600    }
601
602    fn grid() -> Grid {
603        Grid::from_text("a,b,c\n1,2,3\n4,5,6\n", false)
604    }
605
606    #[test]
607    fn parses_into_a_rectangle() {
608        let g = Grid::from_text("a,b,c\n1,2\n", false);
609        assert_eq!(g.row_count(), 2);
610        assert_eq!(g.col_count(), 3);
611        assert_eq!(g.cell(1, 2), "", "short row is padded");
612    }
613
614    #[test]
615    fn empty_input_has_one_cell() {
616        let g = Grid::from_text("", false);
617        assert_eq!(g.row_count(), 1);
618        assert_eq!(g.col_count(), 1);
619    }
620
621    #[test]
622    fn navigation_moves_and_clamps() {
623        let mut g = grid();
624        assert_eq!((g.row(), g.col()), (0, 0));
625        g.handle_key(code(KeyCode::Up), 1);
626        assert_eq!(g.row(), 0, "up at top stays");
627        g.handle_key(code(KeyCode::Down), 1);
628        g.handle_key(key('l'), 1);
629        assert_eq!((g.row(), g.col()), (1, 1));
630        g.handle_key(code(KeyCode::End), 1);
631        assert_eq!(g.col(), 2);
632        g.handle_key(key('G'), 1);
633        assert_eq!(g.row(), 2);
634        g.handle_key(code(KeyCode::Down), 1);
635        assert_eq!(g.row(), 2, "down at bottom stays");
636    }
637
638    #[test]
639    fn edits_a_cell() {
640        let mut g = grid();
641        g.handle_key(code(KeyCode::Down), 1); // row 1
642        g.handle_key(code(KeyCode::Enter), 1); // begin edit
643        assert!(g.is_editing());
644        assert_eq!(g.edit_buffer(), "1", "seeded with current value");
645        g.handle_key(code(KeyCode::Backspace), 1);
646        g.handle_key(key('9'), 1);
647        g.handle_key(code(KeyCode::Enter), 1); // commit, move down
648        assert!(!g.is_editing());
649        assert_eq!(g.cell(1, 0), "9");
650        assert!(g.is_dirty());
651        assert_eq!(g.row(), 2, "Enter commits and moves down");
652    }
653
654    #[test]
655    fn escape_cancels_an_edit() {
656        let mut g = grid();
657        g.handle_key(code(KeyCode::Enter), 1);
658        g.handle_key(key('z'), 1);
659        g.handle_key(code(KeyCode::Esc), 1);
660        assert_eq!(g.cell(0, 0), "a", "cell unchanged");
661        assert!(!g.is_dirty());
662    }
663
664    #[test]
665    fn inserts_and_deletes_rows() {
666        let mut g = grid();
667        let before = g.row_count();
668        g.handle_key(alt(KeyCode::Down), 1); // insert row below
669        assert_eq!(g.row_count(), before + 1);
670        g.handle_key(alt(KeyCode::Delete), 1); // delete it
671        assert_eq!(g.row_count(), before);
672    }
673
674    #[test]
675    fn inserts_and_deletes_columns() {
676        let mut g = grid();
677        let before = g.col_count();
678        g.handle_key(alt(KeyCode::Right), 1);
679        assert_eq!(g.col_count(), before + 1);
680        g.handle_key(alt(KeyCode::Backspace), 1);
681        assert_eq!(g.col_count(), before);
682    }
683
684    #[test]
685    fn keeps_at_least_one_row_and_column() {
686        let mut g = Grid::from_text("x", false);
687        g.handle_key(alt(KeyCode::Delete), 1);
688        g.handle_key(alt(KeyCode::Backspace), 1);
689        assert_eq!(g.row_count(), 1);
690        assert_eq!(g.col_count(), 1);
691    }
692
693    #[test]
694    fn sorts_by_column_keeping_header() {
695        let mut g = Grid::from_text("n\n3\n1\n2\n", false);
696        g.handle_key(key('s'), 1); // ascending
697        assert_eq!(g.cell(0, 0), "n", "header stays first");
698        assert_eq!(g.cell(1, 0), "1");
699        assert_eq!(g.cell(3, 0), "3");
700        g.handle_key(key('S'), 1); // descending
701        assert_eq!(g.cell(1, 0), "3");
702    }
703
704    #[test]
705    fn sort_is_numeric_when_possible() {
706        let mut g = Grid::from_text("n\n10\n9\n", false);
707        g.handle_key(key('s'), 1);
708        assert_eq!(g.cell(1, 0), "9", "9 < 10 numerically, not lexically");
709    }
710
711    #[test]
712    fn finds_a_cell() {
713        let mut g = grid();
714        g.handle_key(key('/'), 1);
715        for c in "5".chars() {
716            g.handle_key(key(c), 1);
717        }
718        g.handle_key(code(KeyCode::Enter), 1);
719        assert_eq!((g.row(), g.col()), (2, 1), "jumped to the cell holding 5");
720    }
721
722    #[test]
723    fn undo_and_redo_round_trip() {
724        let mut g = grid();
725        g.handle_key(code(KeyCode::Enter), 1);
726        g.handle_key(key('Z'), 1);
727        g.handle_key(code(KeyCode::Enter), 1); // commit -> cell becomes "aZ"? seeded "a"+"Z"
728        assert_eq!(g.cell(0, 0), "aZ");
729        g.handle_key(key('u'), 1);
730        assert_eq!(g.cell(0, 0), "a", "undo restores");
731        g.handle_key(ctrl('r'), 1);
732        assert_eq!(g.cell(0, 0), "aZ", "redo reapplies");
733    }
734
735    #[test]
736    fn round_trips_to_text() {
737        let g = grid();
738        assert_eq!(g.to_text(), "a,b,c\n1,2,3\n4,5,6\n");
739    }
740
741    #[test]
742    fn save_and_close_outcomes() {
743        let mut g = grid();
744        assert_eq!(g.handle_key(ctrl('s'), 1), Outcome::Save);
745        assert_eq!(g.handle_key(key('q'), 1), Outcome::Close);
746        assert_eq!(g.handle_key(code(KeyCode::Esc), 1), Outcome::Close);
747        // Esc while editing cancels rather than closing.
748        g.handle_key(code(KeyCode::Enter), 1);
749        assert_eq!(g.handle_key(code(KeyCode::Esc), 1), Outcome::Consumed);
750    }
751
752    #[test]
753    fn vertical_scroll_pins_the_header_and_follows_the_cursor() {
754        let mut g = Grid::from_text("h\n1\n2\n3\n4\n5\n6\n", false);
755        // Header selected: body starts at the first data row.
756        g.ensure_row_visible(3);
757        assert_eq!(g.row_scroll(), 1, "body never shows row 0 (pinned header)");
758        // Move to the last row; the window follows but stays >= 1.
759        g.handle_key(key('G'), 3);
760        g.ensure_row_visible(3);
761        assert!(g.row_scroll() >= 1);
762        assert!(g.row() < g.row_scroll() + 3 && g.row() >= g.row_scroll());
763    }
764
765    #[test]
766    fn tsv_round_trips_with_tabs() {
767        let g = Grid::from_text("a\tb\n1\t2\n", true);
768        assert_eq!(g.col_count(), 2);
769        assert_eq!(g.to_text(), "a\tb\n1\t2\n");
770    }
771}