1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Outcome {
31 Consumed,
33 Close,
35 Save,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Mode {
42 Normal,
44 Edit,
46 Find,
48}
49
50#[derive(Clone)]
52struct Snapshot {
53 rows: Vec<Vec<String>>,
54 row: usize,
55 col: usize,
56}
57
58const HISTORY_CAP: usize = 200;
60
61pub struct Grid {
63 rows: Vec<Vec<String>>,
65 tsv: bool,
67 row: usize,
69 col: usize,
71 row_scroll: usize,
73 col_scroll: usize,
75 dirty: bool,
77 mode: Mode,
79 edit_buf: String,
81 find_buf: String,
83 last_query: String,
85 undo: Vec<Snapshot>,
87 redo: Vec<Snapshot>,
89}
90
91impl Grid {
92 #[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 #[must_use]
123 pub fn row_count(&self) -> usize {
124 self.rows.len()
125 }
126
127 #[must_use]
129 pub fn col_count(&self) -> usize {
130 self.rows.first().map_or(0, Vec::len)
131 }
132
133 #[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 #[must_use]
144 pub fn row(&self) -> usize {
145 self.row
146 }
147
148 #[must_use]
150 pub fn col(&self) -> usize {
151 self.col
152 }
153
154 #[must_use]
156 pub fn row_scroll(&self) -> usize {
157 self.row_scroll
158 }
159
160 #[must_use]
162 pub fn col_scroll(&self) -> usize {
163 self.col_scroll
164 }
165
166 #[must_use]
168 pub fn is_dirty(&self) -> bool {
169 self.dirty
170 }
171
172 #[must_use]
174 pub fn is_editing(&self) -> bool {
175 matches!(self.mode, Mode::Edit)
176 }
177
178 #[must_use]
180 pub fn is_finding(&self) -> bool {
181 matches!(self.mode, Mode::Find)
182 }
183
184 #[must_use]
186 pub fn edit_buffer(&self) -> &str {
187 &self.edit_buf
188 }
189
190 #[must_use]
192 pub fn find_buffer(&self) -> &str {
193 &self.find_buf
194 }
195
196 #[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 pub fn mark_saved(&mut self) {
208 self.dirty = false;
209 }
210
211 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 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 pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
241 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 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 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 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 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 fn move_up(&mut self) {
350 self.row = self.row.saturating_sub(1);
351 }
352
353 fn move_down(&mut self) {
355 if self.row + 1 < self.row_count() {
356 self.row += 1;
357 }
358 }
359
360 fn move_left(&mut self) {
362 self.col = self.col.saturating_sub(1);
363 }
364
365 fn move_right(&mut self) {
367 if self.col + 1 < self.col_count() {
368 self.col += 1;
369 }
370 }
371
372 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 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 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 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 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 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 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 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 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 fn push_undo(&mut self) {
517 let snap = self.snapshot();
520 vix_capped_stack::push_capped(&mut self.undo, snap, HISTORY_CAP);
521 self.redo.clear();
522 }
523
524 fn snapshot(&self) -> Snapshot {
526 Snapshot {
527 rows: self.rows.clone(),
528 row: self.row,
529 col: self.col,
530 }
531 }
532
533 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 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 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
560fn 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
574fn 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); g.handle_key(code(KeyCode::Enter), 1); 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); 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); assert_eq!(g.row_count(), before + 1);
670 g.handle_key(alt(KeyCode::Delete), 1); 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); 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); 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); 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 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 g.ensure_row_visible(3);
757 assert_eq!(g.row_scroll(), 1, "body never shows row 0 (pinned header)");
758 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}