use std::{ cmp::{max, min}, fmt::Display, path::PathBuf, }; use ratatui::{ prelude, style::{Color, Style}, widgets::{Paragraph, Widget}, }; use crate::app::{ app::App, error_msg::StatusMessage, logic::{ calc::{CSV_EXT, CUSTOM_EXT, LEN}, cell::CellType, }, }; pub enum Mode { Insert(Chord), Chord(Chord), Normal, Command(Chord), Visual((usize, usize)), } impl Display for Mode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Mode::Normal => write!(f, "NORMAL"), Mode::Insert(_) => write!(f, "INSERT"), Mode::Chord(_) => write!(f, "CHORD"), Mode::Command(_) => write!(f, "COMMAND"), Mode::Visual(_) => write!(f, "VISUAL"), } } } impl Mode { pub fn get_style(&self) -> Style { match self { // Where you are typing Mode::Insert(_) => Style::new().fg(Color::White).bg(Color::Blue), Mode::Command(_) => Style::new().fg(Color::Black).bg(Color::Magenta), Mode::Chord(_) => Style::new().fg(Color::Black).bg(Color::LightBlue), // Movement-based modes Mode::Visual(_) => Style::new().fg(Color::Yellow), Mode::Normal => Style::new().fg(Color::Green), } } pub fn process_cmd(app: &mut App) { if let Mode::Command(editor) = &mut app.mode { // [':', 'q'] let cmd = &editor.as_string()[1..]; let args = cmd.split_ascii_whitespace().collect::>(); // we are guaranteed at least 1 arg if args.is_empty() { return; } match args[0] { "w" => { // first try the passed argument as file if let Some(arg) = args.get(1) { let mut path: PathBuf = arg.into(); match path.extension() { Some(s) => { match s.to_str() { // leave the file alone, it already has // a valid extension Some(CSV_EXT) | Some(CUSTOM_EXT) => {} _ => { path.add_extension(CUSTOM_EXT); } } } None => { path.add_extension(CUSTOM_EXT); } }; if let Err(e) = app.grid.save_to(&path) { app.msg = StatusMessage::error(format!("{e}")); } else { // file saving was a success, adopt the provided file // if we don't already have one (this is how vim works) app.msg = StatusMessage::info(format!( "Saved file {}", path.file_name().map(|f| f.to_str().unwrap_or("n/a")).unwrap_or("n/a") )); if let None = app.file { app.file = Some(path) } } // then try the file that we opened the program with } else if let Some(file) = &app.file { if let Err(e) = app.grid.save_to(file) { app.msg = StatusMessage::error(format!("{e}")); } else { app.msg = StatusMessage::info(format!( "Saved file {}", file.file_name().map(|f| f.to_str().unwrap_or("n/a")).unwrap_or("n/a") )); } // you need to provide a file from *somewhere* } else { app.msg = StatusMessage::error("No file selected"); } } // quit "q" => { if app.grid.needs_to_be_saved() { app.exit = false; app.msg = StatusMessage::error("File not saved"); } else { app.exit = true } } // force quit "q!" => { app.exit = true; } "set" => { if let Some(arg) = args.get(1) { let parts: Vec<&str> = arg.split('=').collect(); if parts.len() != 2 { app.msg = StatusMessage::error("set ="); return; } let key = parts[0]; let value = parts[1]; app.vars.insert(key.to_owned(), value.to_owned()); } app.msg = StatusMessage::error("set =") } _ => {} } } } pub fn process_key(app: &mut App, key: char) { match &mut app.mode { Mode::Normal | Mode::Visual(_) => { match key { // < 'h' => { let (x, y) = app.grid.cursor(); app.grid.mv_cursor_to(x.saturating_sub(1), y); return; } // v 'j' => { let (x, y) = app.grid.cursor(); app.grid.mv_cursor_to(x, min(y.saturating_add(1), LEN - 1)); return; } // ^ 'k' => { let (x, y) = app.grid.cursor(); app.grid.mv_cursor_to(x, y.saturating_sub(1)); return; } // > 'l' => { let (x, y) = app.grid.cursor(); app.grid.mv_cursor_to(min(x.saturating_add(1), LEN - 1), y); return; } '0' => { let (_, y) = app.grid.cursor(); app.grid.mv_cursor_to(0, y); return; } // edit cell 'i' | 'a' => { let (x, y) = app.grid.cursor(); let val = app.grid.get_cell_raw(x, y).as_ref().map(|f| f.to_string()).unwrap_or(String::new()); app.mode = Mode::Insert(Chord::from(val)); } // replace cell 'r' => { app.mode = Mode::Insert(Chord::from(String::new())); } // insert column before 'I' => { app.grid.insert_column_before(app.grid.cursor()); } // insert column after 'A' => { let c = app.grid.cursor(); app.grid.insert_column_after(c); app.grid.mv_cursor_to(c.0 + 1, c.1); } // insert row below 'o' => { let c = app.grid.cursor(); app.grid.insert_row_below(c); app.grid.mv_cursor_to(c.0, c.1 + 1); } // insert row above 'O' => { app.grid.insert_row_above(app.grid.cursor()); } 'v' => app.mode = Mode::Visual(app.grid.cursor()), ':' => app.mode = Mode::Command(Chord::new(':')), 'p' => { app.clipboard.paste(&mut app.grid, true); app.grid.apply_momentum(app.clipboard.momentum()); return; } // loose chars will put you into chord mode c => { if let Mode::Normal = app.mode { app.mode = Mode::Chord(Chord::new(c)) } } } if let Mode::Visual((x1, y1)) = app.mode { // TODO visual copy, paste, etc let (x2, y2) = app.grid.cursor(); match key { 'd' | 'x' => { app.clipboard.clipboard_cut((x1, y1), (x2, y2), &mut app.grid); app.mode = Mode::Normal } 'y' => { app.clipboard.clipboard_copy((x1, y1), (x2, y2), &app.grid); app.msg = StatusMessage::info(format!("Yanked {} cells", app.clipboard.qty())); app.mode = Mode::Normal } _ => {} } } } Mode::Chord(chord) => { chord.add_char(key); // the chord starts with a :, send it over to be a command if chord.buf[0] == ':' { app.mode = Mode::Command(Chord::new(':')); return; } // Try and parse out a preceding number match chord.as_string()[0..chord.as_string().len() - 1].parse::() { // For chords that can take a numeric input Ok(num) => match key { 'G' => { let (x, _) = app.grid.cursor(); app.grid.mv_cursor_to(x, num); app.mode = Mode::Normal; } _ => { if key.is_alphabetic() { app.mode = Mode::Normal; for _ in 0..num { Mode::process_key(app, key); } } } }, Err(_) => { let c = chord.as_string(); // match everything up to, and then the new key match (&c[..c.len() - 1], key) { // delete cell under cursor ("d", ' ') | ("d", 'w') => { let loc = app.grid.cursor(); app.clipboard.clipboard_cut(loc, loc, &mut app.grid); app.mode = Mode::Normal; } // go to top of row ("g", 'g') => { let (x, _) = app.grid.cursor(); app.grid.mv_cursor_to(x, 0); app.mode = Mode::Normal; } // center screen to cursor ("z", 'z') => { app.screen.center_x(app.grid.cursor(), &app.vars); app.screen.center_y(app.grid.cursor(), &app.vars); app.mode = Mode::Normal; } // mark cell ("m", i) => { app.marks.insert(i, app.grid.cursor()); app.mode = Mode::Normal; } // goto marked cell ("'", i) => { if let Some((cx, cy)) = app.marks.get(&i) { app.grid.mv_cursor_to(*cx, *cy); } app.mode = Mode::Normal; } // copy 1 cell ("y", 'y') => { let point = app.grid.cursor(); app.clipboard.clipboard_copy(point, point, &app.grid); app.mode = Mode::Normal; app.msg = StatusMessage::info("Yanked 1 cell"); } ("g", 'p') => { app.clipboard.paste(&mut app.grid, false); app.grid.apply_momentum(app.clipboard.momentum()); app.mode = Mode::Normal; let plural = if app.clipboard.qty() > 1 { "cells" } else { "cell" }; app.msg = StatusMessage::info(format!("Pasted {plural}, no formatting")); return; } _ => {} } } } } // IDK why it works but it does. Keystrokes are process somewhere else? Mode::Insert(_chord) => {} Mode::Command(_chord) => {} } } pub fn chars_to_display(&self, cell: &Option) -> u16 { let len = match &self { Mode::Insert(edit) | Mode::Command(edit) | Mode::Chord(edit) => edit.len(), Mode::Normal => { let len = cell.as_ref().map(|f| f.to_string().len()).unwrap_or_default(); len } Mode::Visual(_) => 0, }; // min 20 chars, expand if needed let len = max(len as u16 + 1, 20); len } pub fn render(&self, f: &mut ratatui::Frame, area: prelude::Rect, cell: &Option) { match &self { Mode::Insert(editor) => { f.render_widget(editor, area); } Mode::Command(editor) => { f.render_widget(editor, area); } Mode::Chord(chord) => f.render_widget(chord, area), Mode::Normal => f.render_widget( Paragraph::new({ let cell = cell.as_ref().map(|f| f.to_string()).unwrap_or_default(); cell }), area, ), Mode::Visual(_) => {} } } } pub struct Chord { buf: Vec, } impl From for Chord { fn from(value: String) -> Self { let b = value.as_bytes().iter().map(|f| *f as char).collect(); Chord { buf: b, } } } impl Chord { pub fn new(inital: char) -> Self { let mut buf = Vec::new(); buf.push(inital); Self { buf, } } pub fn backspace(&mut self) { self.buf.pop(); } pub fn add_char(&mut self, c: char) { self.buf.push(c) } pub fn as_string(&self) -> String { self.buf.iter().collect() } pub fn len(&self) -> usize { self.buf.len() } } impl Widget for &Chord { fn render(self, area: prelude::Rect, buf: &mut prelude::Buffer) { Paragraph::new(self.buf.iter().collect::()).render(area, buf); } } #[test] fn movement_keybinds() { let mut app = App::new(); assert_eq!(app.grid.cursor(), (0, 0)); Mode::process_key(&mut app, 'j'); assert_eq!(app.grid.cursor(), (0, 1)); Mode::process_key(&mut app, 'l'); assert_eq!(app.grid.cursor(), (1, 1)); Mode::process_key(&mut app, 'k'); assert_eq!(app.grid.cursor(), (1, 0)); Mode::process_key(&mut app, 'h'); assert_eq!(app.grid.cursor(), (0, 0)); } #[test] fn keybinds() { let mut app = App::new(); // start at B1 app.grid.mv_cursor_to(1, 1); assert_eq!(app.grid.cursor(), (1, 1)); // gg app.mode = Mode::Chord(Chord::new('g')); Mode::process_key(&mut app, 'g'); assert_eq!(app.grid.cursor(), (1, 0)); // 0 app.mode = Mode::Normal; Mode::process_key(&mut app, '0'); assert_eq!(app.grid.cursor(), (0, 0)); // 10l // this should mean all the directions work app.grid.mv_cursor_to(0, 0); app.mode = Mode::Chord(Chord::new('1')); Mode::process_key(&mut app, '0'); Mode::process_key(&mut app, 'l'); assert_eq!(app.grid.cursor(), (10, 0)); }