#![warn(clippy::pedantic)]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::cmp::Ordering;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use vix_convert_tabular::{parse_csv, parse_tsv, write_csv, write_tsv};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Consumed,
Close,
Save,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Edit,
Find,
}
#[derive(Clone)]
struct Snapshot {
rows: Vec<Vec<String>>,
row: usize,
col: usize,
}
const HISTORY_CAP: usize = 200;
pub struct Grid {
rows: Vec<Vec<String>>,
tsv: bool,
row: usize,
col: usize,
row_scroll: usize,
col_scroll: usize,
dirty: bool,
mode: Mode,
edit_buf: String,
find_buf: String,
last_query: String,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
}
impl Grid {
#[must_use]
pub fn from_text(text: &str, tsv: bool) -> Self {
let mut rows = if tsv {
parse_tsv(text)
} else {
parse_csv(text)
};
normalize(&mut rows);
Grid {
rows,
tsv,
row: 0,
col: 0,
row_scroll: 0,
col_scroll: 0,
dirty: false,
mode: Mode::Normal,
edit_buf: String::new(),
find_buf: String::new(),
last_query: String::new(),
undo: Vec::new(),
redo: Vec::new(),
}
}
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[must_use]
pub fn col_count(&self) -> usize {
self.rows.first().map_or(0, Vec::len)
}
#[must_use]
pub fn cell(&self, r: usize, c: usize) -> &str {
self.rows
.get(r)
.and_then(|row| row.get(c))
.map_or("", String::as_str)
}
#[must_use]
pub fn row(&self) -> usize {
self.row
}
#[must_use]
pub fn col(&self) -> usize {
self.col
}
#[must_use]
pub fn row_scroll(&self) -> usize {
self.row_scroll
}
#[must_use]
pub fn col_scroll(&self) -> usize {
self.col_scroll
}
#[must_use]
pub fn is_dirty(&self) -> bool {
self.dirty
}
#[must_use]
pub fn is_editing(&self) -> bool {
matches!(self.mode, Mode::Edit)
}
#[must_use]
pub fn is_finding(&self) -> bool {
matches!(self.mode, Mode::Find)
}
#[must_use]
pub fn edit_buffer(&self) -> &str {
&self.edit_buf
}
#[must_use]
pub fn find_buffer(&self) -> &str {
&self.find_buf
}
#[must_use]
pub fn to_text(&self) -> String {
if self.tsv {
write_tsv(&self.rows)
} else {
write_csv(&self.rows)
}
}
pub fn mark_saved(&mut self) {
self.dirty = false;
}
pub fn ensure_row_visible(&mut self, height: usize) {
let height = height.max(1);
let count = self.row_count();
if count <= 1 {
self.row_scroll = count;
return;
}
if self.row >= 1 {
if self.row < self.row_scroll {
self.row_scroll = self.row;
} else if self.row >= self.row_scroll + height {
self.row_scroll = self.row + 1 - height;
}
}
let max_scroll = count.saturating_sub(height).max(1);
self.row_scroll = self.row_scroll.clamp(1, max_scroll);
}
pub fn set_col_scroll(&mut self, first_visible: usize) {
self.col_scroll = first_visible.min(self.col.min(self.col_count().saturating_sub(1)));
}
pub fn handle_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
if self.mode == Mode::Edit {
self.commit_edit();
}
return Outcome::Save;
}
match self.mode {
Mode::Edit => self.edit_key(key),
Mode::Find => self.find_key(key),
Mode::Normal => return self.normal_key(key, page),
}
Outcome::Consumed
}
fn edit_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
self.commit_edit();
self.move_down();
}
KeyCode::Tab => {
self.commit_edit();
self.move_right();
}
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Backspace => {
self.edit_buf.pop();
}
KeyCode::Char(c) => self.edit_buf.push(c),
_ => {}
}
}
fn find_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
self.last_query = std::mem::take(&mut self.find_buf);
self.mode = Mode::Normal;
self.find_from(self.row, self.col, true);
}
KeyCode::Esc => {
self.find_buf.clear();
self.mode = Mode::Normal;
}
KeyCode::Backspace => {
self.find_buf.pop();
}
KeyCode::Char(c) => self.find_buf.push(c),
_ => {}
}
}
fn normal_key(&mut self, key: KeyEvent, page: usize) -> Outcome {
let alt = key.modifiers.contains(KeyModifiers::ALT);
if alt {
self.structural_key(key.code);
return Outcome::Consumed;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.move_down(),
KeyCode::Left | KeyCode::Char('h') => self.move_left(),
KeyCode::Right | KeyCode::Char('l') => self.move_right(),
KeyCode::PageUp => self.row = self.row.saturating_sub(page.max(1)),
KeyCode::PageDown => {
self.row = (self.row + page.max(1)).min(self.row_count().saturating_sub(1));
}
KeyCode::Home => self.col = 0,
KeyCode::End => self.col = self.col_count().saturating_sub(1),
KeyCode::Char('g') => self.row = 0,
KeyCode::Char('G') => self.row = self.row_count().saturating_sub(1),
KeyCode::Enter | KeyCode::F(2) => self.begin_edit(),
KeyCode::Delete => self.clear_cell(),
KeyCode::Char('s') => self.sort(true),
KeyCode::Char('S') => self.sort(false),
KeyCode::Char('/') => {
self.mode = Mode::Find;
self.find_buf.clear();
}
KeyCode::Char('n') => self.find_from(self.row, self.col, true),
KeyCode::Char('u') => self.undo(),
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.redo(),
KeyCode::Esc | KeyCode::Char('q') => return Outcome::Close,
_ => {}
}
Outcome::Consumed
}
fn structural_key(&mut self, code: KeyCode) {
match code {
KeyCode::Up => self.insert_row(self.row),
KeyCode::Down => self.insert_row(self.row + 1),
KeyCode::Left => self.insert_col(self.col),
KeyCode::Right => self.insert_col(self.col + 1),
KeyCode::Delete => self.delete_row(),
KeyCode::Backspace => self.delete_col(),
_ => {}
}
}
fn move_up(&mut self) {
self.row = self.row.saturating_sub(1);
}
fn move_down(&mut self) {
if self.row + 1 < self.row_count() {
self.row += 1;
}
}
fn move_left(&mut self) {
self.col = self.col.saturating_sub(1);
}
fn move_right(&mut self) {
if self.col + 1 < self.col_count() {
self.col += 1;
}
}
fn begin_edit(&mut self) {
self.edit_buf = self.cell(self.row, self.col).to_string();
self.mode = Mode::Edit;
}
fn commit_edit(&mut self) {
let text = std::mem::take(&mut self.edit_buf);
self.mode = Mode::Normal;
if self.cell(self.row, self.col) == text {
return;
}
self.push_undo();
if let Some(cell) = self
.rows
.get_mut(self.row)
.and_then(|r| r.get_mut(self.col))
{
*cell = text;
self.dirty = true;
}
}
fn clear_cell(&mut self) {
if self.cell(self.row, self.col).is_empty() {
return;
}
self.push_undo();
if let Some(cell) = self
.rows
.get_mut(self.row)
.and_then(|r| r.get_mut(self.col))
{
cell.clear();
self.dirty = true;
}
}
fn insert_row(&mut self, at: usize) {
self.push_undo();
let cols = self.col_count().max(1);
let at = at.min(self.row_count());
self.rows.insert(at, vec![String::new(); cols]);
self.row = at;
self.dirty = true;
}
fn delete_row(&mut self) {
if self.row_count() <= 1 {
return;
}
self.push_undo();
self.rows.remove(self.row);
self.row = self.row.min(self.row_count().saturating_sub(1));
self.dirty = true;
}
fn insert_col(&mut self, at: usize) {
self.push_undo();
let at = at.min(self.col_count());
for row in &mut self.rows {
row.insert(at.min(row.len()), String::new());
}
self.col = at;
self.dirty = true;
}
fn delete_col(&mut self) {
if self.col_count() <= 1 {
return;
}
self.push_undo();
let c = self.col;
for row in &mut self.rows {
if c < row.len() {
row.remove(c);
}
}
self.col = self.col.min(self.col_count().saturating_sub(1));
self.dirty = true;
}
fn sort(&mut self, ascending: bool) {
if self.row_count() <= 2 {
return;
}
self.push_undo();
let c = self.col;
let header = self.rows.remove(0);
self.rows.sort_by(|a, b| {
let ord = cmp_cells(
a.get(c).map_or("", String::as_str),
b.get(c).map_or("", String::as_str),
);
if ascending { ord } else { ord.reverse() }
});
self.rows.insert(0, header);
self.dirty = true;
}
fn find_from(&mut self, from_row: usize, from_col: usize, _forward: bool) {
if self.last_query.is_empty() {
return;
}
let needle = self.last_query.to_lowercase();
let cols = self.col_count();
let total = self.row_count() * cols;
if total == 0 {
return;
}
let start = from_row * cols + from_col;
for step in 1..=total {
let idx = (start + step) % total;
let (r, c) = (idx / cols, idx % cols);
if self.cell(r, c).to_lowercase().contains(&needle) {
self.row = r;
self.col = c;
return;
}
}
}
fn push_undo(&mut self) {
let snap = self.snapshot();
vix_capped_stack::push_capped(&mut self.undo, snap, HISTORY_CAP);
self.redo.clear();
}
fn snapshot(&self) -> Snapshot {
Snapshot {
rows: self.rows.clone(),
row: self.row,
col: self.col,
}
}
fn restore(&mut self, snap: Snapshot) -> Snapshot {
let prior = self.snapshot();
self.rows = snap.rows;
self.row = snap.row.min(self.row_count().saturating_sub(1));
self.col = snap.col.min(self.col_count().saturating_sub(1));
self.dirty = true;
prior
}
fn undo(&mut self) {
if let Some(snap) = self.undo.pop() {
let prior = self.restore(snap);
self.redo.push(prior);
}
}
fn redo(&mut self) {
if let Some(snap) = self.redo.pop() {
let prior = self.restore(snap);
self.undo.push(prior);
}
}
}
fn normalize(rows: &mut Vec<Vec<String>>) {
let width = rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
if rows.is_empty() {
rows.push(Vec::new());
}
for row in rows.iter_mut() {
if row.len() < width {
row.resize(width, String::new());
}
}
}
fn cmp_cells(a: &str, b: &str) -> Ordering {
match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
(Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
_ => a.cmp(b),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn code(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn alt(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn grid() -> Grid {
Grid::from_text("a,b,c\n1,2,3\n4,5,6\n", false)
}
#[test]
fn parses_into_a_rectangle() {
let g = Grid::from_text("a,b,c\n1,2\n", false);
assert_eq!(g.row_count(), 2);
assert_eq!(g.col_count(), 3);
assert_eq!(g.cell(1, 2), "", "short row is padded");
}
#[test]
fn empty_input_has_one_cell() {
let g = Grid::from_text("", false);
assert_eq!(g.row_count(), 1);
assert_eq!(g.col_count(), 1);
}
#[test]
fn navigation_moves_and_clamps() {
let mut g = grid();
assert_eq!((g.row(), g.col()), (0, 0));
g.handle_key(code(KeyCode::Up), 1);
assert_eq!(g.row(), 0, "up at top stays");
g.handle_key(code(KeyCode::Down), 1);
g.handle_key(key('l'), 1);
assert_eq!((g.row(), g.col()), (1, 1));
g.handle_key(code(KeyCode::End), 1);
assert_eq!(g.col(), 2);
g.handle_key(key('G'), 1);
assert_eq!(g.row(), 2);
g.handle_key(code(KeyCode::Down), 1);
assert_eq!(g.row(), 2, "down at bottom stays");
}
#[test]
fn edits_a_cell() {
let mut g = grid();
g.handle_key(code(KeyCode::Down), 1); g.handle_key(code(KeyCode::Enter), 1); assert!(g.is_editing());
assert_eq!(g.edit_buffer(), "1", "seeded with current value");
g.handle_key(code(KeyCode::Backspace), 1);
g.handle_key(key('9'), 1);
g.handle_key(code(KeyCode::Enter), 1); assert!(!g.is_editing());
assert_eq!(g.cell(1, 0), "9");
assert!(g.is_dirty());
assert_eq!(g.row(), 2, "Enter commits and moves down");
}
#[test]
fn escape_cancels_an_edit() {
let mut g = grid();
g.handle_key(code(KeyCode::Enter), 1);
g.handle_key(key('z'), 1);
g.handle_key(code(KeyCode::Esc), 1);
assert_eq!(g.cell(0, 0), "a", "cell unchanged");
assert!(!g.is_dirty());
}
#[test]
fn inserts_and_deletes_rows() {
let mut g = grid();
let before = g.row_count();
g.handle_key(alt(KeyCode::Down), 1); assert_eq!(g.row_count(), before + 1);
g.handle_key(alt(KeyCode::Delete), 1); assert_eq!(g.row_count(), before);
}
#[test]
fn inserts_and_deletes_columns() {
let mut g = grid();
let before = g.col_count();
g.handle_key(alt(KeyCode::Right), 1);
assert_eq!(g.col_count(), before + 1);
g.handle_key(alt(KeyCode::Backspace), 1);
assert_eq!(g.col_count(), before);
}
#[test]
fn keeps_at_least_one_row_and_column() {
let mut g = Grid::from_text("x", false);
g.handle_key(alt(KeyCode::Delete), 1);
g.handle_key(alt(KeyCode::Backspace), 1);
assert_eq!(g.row_count(), 1);
assert_eq!(g.col_count(), 1);
}
#[test]
fn sorts_by_column_keeping_header() {
let mut g = Grid::from_text("n\n3\n1\n2\n", false);
g.handle_key(key('s'), 1); assert_eq!(g.cell(0, 0), "n", "header stays first");
assert_eq!(g.cell(1, 0), "1");
assert_eq!(g.cell(3, 0), "3");
g.handle_key(key('S'), 1); assert_eq!(g.cell(1, 0), "3");
}
#[test]
fn sort_is_numeric_when_possible() {
let mut g = Grid::from_text("n\n10\n9\n", false);
g.handle_key(key('s'), 1);
assert_eq!(g.cell(1, 0), "9", "9 < 10 numerically, not lexically");
}
#[test]
fn finds_a_cell() {
let mut g = grid();
g.handle_key(key('/'), 1);
for c in "5".chars() {
g.handle_key(key(c), 1);
}
g.handle_key(code(KeyCode::Enter), 1);
assert_eq!((g.row(), g.col()), (2, 1), "jumped to the cell holding 5");
}
#[test]
fn undo_and_redo_round_trip() {
let mut g = grid();
g.handle_key(code(KeyCode::Enter), 1);
g.handle_key(key('Z'), 1);
g.handle_key(code(KeyCode::Enter), 1); assert_eq!(g.cell(0, 0), "aZ");
g.handle_key(key('u'), 1);
assert_eq!(g.cell(0, 0), "a", "undo restores");
g.handle_key(ctrl('r'), 1);
assert_eq!(g.cell(0, 0), "aZ", "redo reapplies");
}
#[test]
fn round_trips_to_text() {
let g = grid();
assert_eq!(g.to_text(), "a,b,c\n1,2,3\n4,5,6\n");
}
#[test]
fn save_and_close_outcomes() {
let mut g = grid();
assert_eq!(g.handle_key(ctrl('s'), 1), Outcome::Save);
assert_eq!(g.handle_key(key('q'), 1), Outcome::Close);
assert_eq!(g.handle_key(code(KeyCode::Esc), 1), Outcome::Close);
g.handle_key(code(KeyCode::Enter), 1);
assert_eq!(g.handle_key(code(KeyCode::Esc), 1), Outcome::Consumed);
}
#[test]
fn vertical_scroll_pins_the_header_and_follows_the_cursor() {
let mut g = Grid::from_text("h\n1\n2\n3\n4\n5\n6\n", false);
g.ensure_row_visible(3);
assert_eq!(g.row_scroll(), 1, "body never shows row 0 (pinned header)");
g.handle_key(key('G'), 3);
g.ensure_row_visible(3);
assert!(g.row_scroll() >= 1);
assert!(g.row() < g.row_scroll() + 3 && g.row() >= g.row_scroll());
}
#[test]
fn tsv_round_trips_with_tabs() {
let g = Grid::from_text("a\tb\n1\t2\n", true);
assert_eq!(g.col_count(), 2);
assert_eq!(g.to_text(), "a\tb\n1\t2\n");
}
}