Compare commits

..

8 Commits

Author SHA1 Message Date
0c2588a299 example 2 line graph 2025-11-19 13:49:30 -07:00
0c527bb3cb add gnuplot plotting 2025-11-18 13:02:29 -07:00
6ca21b407d close #36 2025-11-18 12:33:34 -07:00
1323d15333 actually use the filetype 2025-11-18 10:11:01 -07:00
d69966bc01 fix csv (again) - added tests this time. Started on #27, #36 2025-11-18 10:09:29 -07:00
dae3b57f73 setup test for #33 2025-11-17 14:50:38 -07:00
ee1cac0522 fix #46 2025-11-17 14:39:30 -07:00
712d75b256 close #45 2025-11-17 13:42:17 -07:00
7 changed files with 269 additions and 34 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
/.vscode /.vscode
/*.csv /*.csv
/*.nscim /*.nscim
/plot.png

10
bash_complitions Normal file
View File

@@ -0,0 +1,10 @@
# This is only WIP, idk how to make it only take
# 1 argument yet
_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}"
# Only suggest *.csv and *.neoscim
COMPREPLY=($(compgen -f -X '!*.@(csv|nscim)' -- "$cur"))
}
complete -F _complete sc

View File

@@ -1,5 +1,9 @@
use std::{ use std::{
cmp::{max, min}, collections::HashMap, fs, io, path::PathBuf, time::SystemTime cmp::{max, min},
collections::HashMap,
fs, io,
path::PathBuf,
time::SystemTime,
}; };
use ratatui::{ use ratatui::{
@@ -38,7 +42,7 @@ impl Widget for &App {
let (x_max, y_max) = self.screen.how_many_cells_fit_in(&area, &self.vars); let (x_max, y_max) = self.screen.how_many_cells_fit_in(&area, &self.vars);
let is_selected = |x: usize, y: usize| -> bool { let is_selected = |x: usize, y: usize| -> bool {
if let Mode::Visual((mut x1, mut y1)) = self.mode { if let Mode::Visual((mut x1, mut y1)) | Mode::VisualCmd((mut x1, mut y1), _)= self.mode {
let (mut x2, mut y2) = self.grid.cursor(); let (mut x2, mut y2) = self.grid.cursor();
x1 += 1; x1 += 1;
y1 += 1; y1 += 1;
@@ -76,10 +80,6 @@ impl Widget for &App {
y_idx = y as usize - 1 + self.screen.scroll_y(); y_idx = y as usize - 1 + self.screen.scroll_y();
} }
if is_selected(x.into(), y.into()) {
style = style.fg(Color::LightMagenta).bg(Color::Blue);
}
const ORANGE1: Color = Color::Rgb(200, 160, 0); const ORANGE1: Color = Color::Rgb(200, 160, 0);
const ORANGE2: Color = Color::Rgb(180, 130, 0); const ORANGE2: Color = Color::Rgb(180, 130, 0);
@@ -125,7 +125,10 @@ impl Widget for &App {
Some(cell) => { Some(cell) => {
match cell { match cell {
CellType::Number(c) => display = c.to_string(), CellType::Number(c) => display = c.to_string(),
CellType::String(s) => display = s.to_owned(), CellType::String(s) => {
display = s.to_owned();
style = Style::new().fg(Color::LightMagenta)
}
CellType::Equation(e) => { CellType::Equation(e) => {
match self.grid.evaluate(e) { match self.grid.evaluate(e) {
Ok(val) => { Ok(val) => {
@@ -164,6 +167,10 @@ impl Widget for &App {
} }
None => should_render = false, None => should_render = false,
} }
if is_selected(x.into(), y.into()) {
style = style.bg(Color::Blue);
}
if (x_idx, y_idx) == self.grid.cursor() { if (x_idx, y_idx) == self.grid.cursor() {
should_render = true; should_render = true;
style = Style::new().fg(Color::Black).bg(Color::White); style = Style::new().fg(Color::Black).bg(Color::White);
@@ -312,6 +319,21 @@ impl App {
fn handle_events(&mut self) -> io::Result<()> { fn handle_events(&mut self) -> io::Result<()> {
match &mut self.mode { match &mut self.mode {
Mode::VisualCmd(pos, chord) => match event::read()? {
event::Event::Key(key) => match key.code {
event::KeyCode::Esc => self.mode = Mode::Visual(*pos),
event::KeyCode::Backspace => chord.backspace(),
event::KeyCode::Char(c) => chord.add_char(c),
event::KeyCode::Enter => {
// tmp is to get around reference issues.
let tmp = pos.clone();
Mode::process_cmd(self);
self.mode = Mode::Visual(tmp)
}
_ => {}
},
_ => {}
},
Mode::Chord(chord) => match event::read()? { Mode::Chord(chord) => match event::read()? {
event::Event::Key(key) => match key.code { event::Event::Key(key) => match key.code {
event::KeyCode::Esc => self.mode = Mode::Normal, event::KeyCode::Esc => self.mode = Mode::Normal,
@@ -381,20 +403,13 @@ impl App {
} }
Mode::Command(editor) => match event::read()? { Mode::Command(editor) => match event::read()? {
event::Event::Key(key) => match key.code { event::Event::Key(key) => match key.code {
event::KeyCode::Esc => { event::KeyCode::Esc => self.mode = Mode::Normal,
// just cancel the operation event::KeyCode::Backspace => editor.backspace(),
self.mode = Mode::Normal; event::KeyCode::Char(c) => editor.add_char(c),
}
event::KeyCode::Enter => { event::KeyCode::Enter => {
Mode::process_cmd(self); Mode::process_cmd(self);
self.mode = Mode::Normal; self.mode = Mode::Normal;
} }
event::KeyCode::Backspace => {
editor.backspace();
}
event::KeyCode::Char(c) => {
editor.add_char(c);
}
_ => {} _ => {}
}, },
_ => {} _ => {}

View File

@@ -11,7 +11,7 @@ use crate::app::{
logic::{ logic::{
cell::{CSV_DELIMITER, CellType}, cell::{CSV_DELIMITER, CellType},
ctx, ctx,
}, }, mode::Mode,
}; };
#[cfg(test)] #[cfg(test)]
@@ -91,7 +91,7 @@ impl Grid {
resolve_values = true; resolve_values = true;
} }
Some(CUSTOM_EXT) => { Some(CUSTOM_EXT) => {
resolve_values = true; resolve_values = false;
} }
_ => { _ => {
resolve_values = false; resolve_values = false;
@@ -110,21 +110,28 @@ impl Grid {
for x in 0..=mx { for x in 0..=mx {
let cell = &self.cells[x][y]; let cell = &self.cells[x][y];
// newline after the cell, because it's end of line.
// else, just put a comma after the cell.
let is_last = x==mx;
let delim = if is_last {
'\n'
} else {
CSV_DELIMITER
};
let data = if let Some(cell) = cell { let data = if let Some(cell) = cell {
if let Ok(val) = self.evaluate(&cell.to_string()) if let Ok(val) = self.evaluate(&cell.to_string())
&& resolve_values && resolve_values
{ {
val.to_string() format!("{}{}", val.to_string(), delim)
} else { } else {
cell.escaped_csv_string() format!("{}{}", cell.escaped_csv_string(), delim)
} }
} else { } else {
CSV_DELIMITER.to_string() delim.to_string()
}; };
write!(f, "{data}")?; write!(f, "{data}")?;
} }
write!(f, "\n")?;
} }
f.flush()?; f.flush()?;
@@ -459,6 +466,92 @@ impl Default for Grid {
} }
} }
#[test]
fn saving_csv() {
// setup grid
// This should be 1..10 in the A column, then
// 1^2..10^2 in the B column.
let mut app = App::new();
app.grid.set_cell("A0", 1.);
app.grid.set_cell("B0", "=A0^2".to_string());
app.grid.set_cell("A1", "=A0+A$0".to_string());
app.grid.set_cell("B1", "=A1^2".to_string());
app.grid.mv_cursor_to(0, 1);
app.mode = Mode::Visual(app.grid.cursor());
Mode::process_key(&mut app, 'l');
Mode::process_key(&mut app, 'y');
app.mode = Mode::Normal;
app.grid.mv_cursor_to(0, 2);
for _ in 0..10 {
Mode::process_key(&mut app, 'p');
}
// setup done
// insure that the cells are there
let cell = app.grid.get_cell_raw(0, 10).as_ref().expect("Should've been set");
let res = app.grid.evaluate(&cell.to_string()).expect("Should evaluate");
assert_eq!(res, 11.0);
assert_eq!(cell.escaped_csv_string(), "=A9+A$0");
let cell = app.grid.get_cell_raw(1, 10).as_ref().expect("Should've been set");
let res = app.grid.evaluate(&cell.to_string()).expect("Should evaluate");
assert_eq!(res, 121.0);
assert_eq!(cell.escaped_csv_string(), "=A10^2");
// set saving the file
let filename = "/tmp/file.csv";
app.grid.save_to(filename).expect("This will only work on linux systems");
let mut file = fs::OpenOptions::new().read(true).open(filename).expect("Just wrote the file");
let mut buf = String::new();
file.read_to_string(&mut buf).expect("Just opened the file");
let line = buf.lines().skip(10).next();
assert_eq!(line, Some("11,121"));
}
#[test]
fn saving_neoscim() {
// setup grid
// This should be 1..10 in the A column, then
// 1^2..10^2 in the B column.
let mut app = App::new();
app.grid.set_cell("A0", 1.);
app.grid.set_cell("B0", "=A0^2".to_string());
app.grid.set_cell("A1", "=A0+A$0".to_string());
app.grid.set_cell("B1", "=A1^2".to_string());
app.grid.mv_cursor_to(0, 1);
app.mode = Mode::Visual(app.grid.cursor());
Mode::process_key(&mut app, 'l');
Mode::process_key(&mut app, 'y');
app.mode = Mode::Normal;
app.grid.mv_cursor_to(0, 2);
for _ in 0..10 {
Mode::process_key(&mut app, 'p');
}
// setup done
// insure that the cells are there
let cell = app.grid.get_cell_raw(0, 10).as_ref().expect("Should've been set");
let res = app.grid.evaluate(&cell.to_string()).expect("Should evaluate");
assert_eq!(res, 11.0);
assert_eq!(cell.escaped_csv_string(), "=A9+A$0");
let cell = app.grid.get_cell_raw(1, 10).as_ref().expect("Should've been set");
let res = app.grid.evaluate(&cell.to_string()).expect("Should evaluate");
assert_eq!(res, 121.0);
assert_eq!(cell.escaped_csv_string(), "=A10^2");
// set saving the file
let filename= "/tmp/file.neoscim";
app.grid.save_to(filename).expect("This will only work on linux systems");
let mut file = fs::OpenOptions::new().read(true).open(filename).expect("Just wrote the file");
let mut buf = String::new();
file.read_to_string(&mut buf).expect("Just opened the file");
let line = buf.lines().skip(10).next();
assert_eq!(line, Some("=A9+A$0,=A10^2"));
}
// Do cells hold strings? // Do cells hold strings?
#[test] #[test]
fn cell_strings() { fn cell_strings() {
@@ -802,8 +895,16 @@ fn ranges() {
#[test] #[test]
fn recursive_ranges() { fn recursive_ranges() {
// recursive ranges causes weird behavior let mut grid = Grid::new();
// todo!();
grid.set_cell("A1", 1.);
grid.set_cell("B1", 2.);
grid.set_cell("B0", "=sum(A:A)".to_string());
grid.set_cell("A0", "=sum(B:B)".to_string());
let cell = grid.get_cell("B0").as_ref().expect("Just set it");
let res = grid.evaluate(&cell.to_string());
assert!(res.is_err());
} }
#[test] #[test]

View File

@@ -38,9 +38,9 @@ impl CellType {
// escape string of it has a comma // escape string of it has a comma
if display.contains(CSV_DELIMITER) { if display.contains(CSV_DELIMITER) {
format!("\"{display}\"{CSV_DELIMITER}") format!("\"{display}\"")
} else { } else {
format!("{display}{CSV_DELIMITER}") display
} }
} }

View File

@@ -1,5 +1,8 @@
use std::{ use std::{
cmp::{max, min}, fmt::Display, fs, path::PathBuf cmp::{max, min},
fmt::Display,
fs,
path::PathBuf, process::Command,
}; };
use ratatui::{ use ratatui::{
@@ -12,7 +15,7 @@ use crate::app::{
app::App, app::App,
error_msg::StatusMessage, error_msg::StatusMessage,
logic::{ logic::{
calc::{CSV_EXT, CUSTOM_EXT, LEN}, calc::{CSV_EXT, CUSTOM_EXT, Grid, LEN},
cell::CellType, cell::CellType,
}, },
}; };
@@ -23,6 +26,7 @@ pub enum Mode {
Normal, Normal,
Command(Chord), Command(Chord),
Visual((usize, usize)), Visual((usize, usize)),
VisualCmd((usize, usize), Chord),
} }
impl Display for Mode { impl Display for Mode {
@@ -33,6 +37,7 @@ impl Display for Mode {
Mode::Chord(_) => write!(f, "CHORD"), Mode::Chord(_) => write!(f, "CHORD"),
Mode::Command(_) => write!(f, "COMMAND"), Mode::Command(_) => write!(f, "COMMAND"),
Mode::Visual(_) => write!(f, "VISUAL"), Mode::Visual(_) => write!(f, "VISUAL"),
Mode::VisualCmd(_, _) => write!(f, "V-CMD"),
} }
} }
} }
@@ -43,6 +48,7 @@ impl Mode {
// Where you are typing // Where you are typing
Mode::Insert(_) => Style::new().fg(Color::White).bg(Color::Blue), Mode::Insert(_) => Style::new().fg(Color::White).bg(Color::Blue),
Mode::Command(_) => Style::new().fg(Color::Black).bg(Color::Magenta), Mode::Command(_) => Style::new().fg(Color::Black).bg(Color::Magenta),
Mode::VisualCmd(_, _) => Style::new().fg(Color::Black).bg(Color::Yellow),
Mode::Chord(_) => Style::new().fg(Color::Black).bg(Color::LightBlue), Mode::Chord(_) => Style::new().fg(Color::Black).bg(Color::LightBlue),
// Movement-based modes // Movement-based modes
Mode::Visual(_) => Style::new().fg(Color::Yellow), Mode::Visual(_) => Style::new().fg(Color::Yellow),
@@ -143,6 +149,82 @@ impl Mode {
_ => {} _ => {}
} }
} }
if let Mode::VisualCmd(pos, editor) = &mut app.mode {
let cmd = &editor.as_string()[1..];
let args = cmd.split_ascii_whitespace().collect::<Vec<&str>>();
if args.is_empty() {
return;
}
// These values are going to be used in probably all
// the commands related to ranges, we will just write
// logic here first, once.
let (x1, y1) = pos;
let (x1, y1) = (*x1, *y1);
let (x2, y2) = app.grid.cursor();
let (low_x, hi_x) = if x1 < x2 { (x1, x2) } else { (x2, x1) };
let (low_y, hi_y) = if y1 < y2 { (y1, y2) } else { (y2, y1) };
let mut save_range = |to: &str| {
let mut g = Grid::new();
for (i, x) in (low_x..=hi_x).enumerate() {
for (j, y) in (low_y..=hi_y).enumerate() {
g.set_cell_raw((i, j), app.grid.get_cell_raw(x, y).clone());
}
}
if let Err(_e) = g.save_to(to) {
app.msg = StatusMessage::error("Failed to save file");
}
};
let get_project_name = || {
if let Some(file) = &app.file {
if let Some(name) = file.file_name() {
if let Some(name) = name.to_str() {
return name;
}
}
}
return "unknown"
};
match args[0] {
"export" => {
if let Some(arg1) = args.get(1) {
save_range(&arg1);
} else {
app.msg = StatusMessage::error("export <path.csv>")
}
app.mode = Mode::Normal
}
"plot" => {
// Use gnuplot to plot the selected data.
// * Temp data will be stored in /tmp/
// * Output will either be plot.png or a name that you pass in
let output_filename = if let Some(arg1) = args.get(1) {
arg1
} else {
"plot.png"
};
save_range("/tmp/plot.csv");
let plot = include_str!("../../template.gnuplot");
let s = plot.replace("$FILE", "/tmp/plot.csv");
let s = s.replace("$TITLE", get_project_name());
let s = s.replace("$XLABEL", "hard-coded x");
let s = s.replace("$YLABEL", "hard-coded y");
let s = s.replace("$OUTPUT", "/tmp/output.png");
let _ = fs::write("/tmp/plot.p", s);
let _ = Command::new("gnuplot").arg("/tmp/plot.p").output();
let _ = fs::copy("/tmp/output.png", output_filename);
app.msg = StatusMessage::info("Wrote gnuplot data to /tmp");
app.mode = Mode::Normal
}
_ => {}
}
}
} }
pub fn process_key(app: &mut App, key: char) { pub fn process_key(app: &mut App, key: char) {
@@ -211,7 +293,13 @@ impl Mode {
app.grid.insert_row_above(app.grid.cursor()); app.grid.insert_row_above(app.grid.cursor());
} }
'v' => app.mode = Mode::Visual(app.grid.cursor()), 'v' => app.mode = Mode::Visual(app.grid.cursor()),
':' => app.mode = Mode::Command(Chord::new(':')), ':' => {
if let Self::Visual(pos) = app.mode {
app.mode = Mode::VisualCmd(pos, Chord::new(':'));
} else {
app.mode = Mode::Command(Chord::new(':'))
}
}
'p' => { 'p' => {
app.clipboard.paste(&mut app.grid, true); app.clipboard.paste(&mut app.grid, true);
app.grid.apply_momentum(app.clipboard.momentum()); app.grid.apply_momentum(app.clipboard.momentum());
@@ -323,15 +411,16 @@ impl Mode {
} }
} }
} }
// IDK why it works but it does. Keystrokes are process somewhere else? // Keys are process in the handle_event method in App for these
Mode::Insert(_chord) => {} Mode::Insert(_chord) => {}
Mode::Command(_chord) => {} Mode::Command(_chord) => {}
Mode::VisualCmd(_pos, _chord) => {}
} }
} }
pub fn chars_to_display(&self, cell: &Option<CellType>) -> u16 { pub fn chars_to_display(&self, cell: &Option<CellType>) -> u16 {
let len = match &self { let len = match &self {
Mode::Insert(edit) | Mode::Command(edit) | Mode::Chord(edit) => edit.len(), Mode::Insert(edit) | Mode::VisualCmd(_, edit) | Mode::Command(edit) | Mode::Chord(edit) => edit.len(),
Mode::Normal => { Mode::Normal => {
let len = cell.as_ref().map(|f| f.to_string().len()).unwrap_or_default(); let len = cell.as_ref().map(|f| f.to_string().len()).unwrap_or_default();
len len
@@ -360,6 +449,7 @@ impl Mode {
area, area,
), ),
Mode::Visual(_) => {} Mode::Visual(_) => {}
Mode::VisualCmd(_, editor) => f.render_widget(editor, area),
} }
} }
} }

18
template.gnuplot Normal file
View File

@@ -0,0 +1,18 @@
datafile = '$FILE'
set datafile separator ','
set title '$TITLE'
set key autotitle columnhead
set xlabel "$XLABEL"
set ylabel "$YLABEL"
set style line 1 linewidth 2 linecolor 1 pointtype 7 pointsize 1.5
set autoscale
set grid
set term png size 1280, 720
set output '$OUTPUT'
plot datafile using 1:2 with linespoints linestyle 1
# plot datafile using 1:2 with linespoints linestyle 1, datafile using 1:3, etc...
replot