455 lines
16 KiB
Rust
455 lines
16 KiB
Rust
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::<Vec<&str>>();
|
|
// 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 <key>=<value>");
|
|
return;
|
|
}
|
|
let key = parts[0];
|
|
let value = parts[1];
|
|
|
|
app.vars.insert(key.to_owned(), value.to_owned());
|
|
}
|
|
app.msg = StatusMessage::error("set <key>=<value>")
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
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::<usize>() {
|
|
// 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<CellType>) -> 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<CellType>) {
|
|
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<char>,
|
|
}
|
|
|
|
impl From<String> 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::<String>()).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));
|
|
}
|