diff --git a/Cargo.lock b/Cargo.lock index 4837c28..f7c1163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix", @@ -273,6 +274,95 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "gimli" version = "0.31.1" @@ -442,6 +532,18 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "proc-macro2" version = "1.0.89" @@ -555,6 +657,7 @@ dependencies = [ "clap", "crossterm", "csvx", + "futures", "ratatui", "thiserror", ] @@ -589,6 +692,15 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.13.2" diff --git a/Cargo.toml b/Cargo.toml index acb6ce4..f98afe6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,8 @@ edition = "2021" [dependencies] anyhow = { version = "1.0.91", features = ["backtrace"] } clap = { version = "4.5.20", features = ["derive"] } -crossterm = "0.28.1" +crossterm = { version = "0.28.1", features = ["event-stream"] } csvx = "0.1.17" +futures = "0.3.31" ratatui = "0.29.0" thiserror = "1.0.65" diff --git a/src/main.rs b/src/main.rs index 675c249..4f42ec2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; +use std::{path::PathBuf, process::ExitCode}; use clap::Parser; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui; +use ui::Workspace; mod sheet; mod ui; @@ -14,18 +14,17 @@ pub struct Args { workbook: PathBuf, } -fn run(terminal: &mut ratatui::DefaultTerminal, name: PathBuf) -> std::io::Result<()> { +fn run(terminal: &mut ratatui::DefaultTerminal, name: PathBuf) -> anyhow::Result { + let mut ws = Workspace::load(&name)?; loop { - terminal.draw(|frame| ui::draw(frame, &name))?; - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { - return Ok(()); - } + terminal.draw(|frame| ui::draw(frame, &mut ws))?; + if let Some(code) = ws.handle_event()? { + return Ok(code); } } } -fn main() -> std::io::Result<()> { +fn main() -> anyhow::Result { let args = Args::parse(); let mut terminal = ratatui::init(); diff --git a/src/sheet/mod.rs b/src/sheet/mod.rs index d9b37fe..68136fe 100644 --- a/src/sheet/mod.rs +++ b/src/sheet/mod.rs @@ -46,10 +46,10 @@ impl CellValue { } /// The Address in a [Tbl]. -#[derive(Default, Debug, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Default, Debug, PartialEq, PartialOrd, Ord, Eq, Clone)] pub struct Address { - row: usize, - col: usize, + pub row: usize, + pub col: usize, } impl Address { @@ -61,6 +61,7 @@ impl Address { /// A single table of addressable computable values. pub struct Tbl { pub csv: csvx::Table, + pub location: Address, } impl Tbl { @@ -82,9 +83,19 @@ impl Tbl { Ok(Self { csv: csvx::Table::new(input) .map_err(|e| anyhow!("Error parsing table from csv text: {}", e))?, + location: Address::default(), }) } + pub fn move_to(&mut self, addr: Address) -> Result<()> { + let (row, col) = self.dimensions(); + if addr.row >= row || addr.col >= col { + return Err(anyhow!("Invalid address to move to: {:?}", addr)); + } + self.location = addr; + Ok(()) + } + pub fn update_entry(&mut self, address: Address, value: CellValue) -> Result<()> { // TODO(zaphar): At some point we'll need to store the graph of computation let (row, col) = self.dimensions(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7bed427..47e2054 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,22 +1,39 @@ //! Ui rendering logic -use std::{fs::File, io::Read, path::PathBuf}; +use std::{fs::File, io::Read, path::PathBuf, process::ExitCode}; -use super::sheet::Tbl; +use super::sheet::{Address, Tbl}; use anyhow::{Context, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ self, - layout::{Constraint, Flex, Layout}, + layout::{Constraint, Flex}, style::{Color, Stylize}, - text::Text, - widgets::{Block, Cell, Row, Table, Tabs, Widget}, + text::{Line, Text}, + widgets::{Block, Cell, Row, Table, Widget}, Frame, }; +#[derive(Default, Debug, PartialEq)] +pub enum Modality { + #[default] + Navigate, + CellEdit, +} + +#[derive(Default, Debug)] +pub struct AppState { + pub modality: Modality, +} + +// Interaction Modalities +// * Navigate +// * Edit pub struct Workspace { name: String, tbl: Tbl, + state: AppState, } impl Workspace { @@ -24,6 +41,7 @@ impl Workspace { Self { tbl, name: name.into(), + state: AppState::default(), } } @@ -31,25 +49,142 @@ impl Workspace { let mut f = File::open(path)?; let mut buf = Vec::new(); let _ = f.read_to_end(&mut buf)?; - let input = String::from_utf8(buf) - .context(format!("Error reading file: {:?}", path))?; - let tbl = Tbl::from_str(input)?; - Ok(Workspace::new(tbl, path.file_name().map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|| "Unknown".to_string()))) + let input = String::from_utf8(buf).context(format!("Error reading file: {:?}", path))?; + let mut tbl = Tbl::from_str(input)?; + tbl.move_to(Address { row: 0, col: 0 })?; + Ok(Workspace::new( + tbl, + path.file_name() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "Unknown".to_string()), + )) } + + pub fn move_down(&mut self) -> Result<()> { + // TODO(jwall): Add a row automatically if necessary? + let mut loc = self.tbl.location.clone(); + let (row, _) = self.tbl.dimensions(); + if loc.row < row-1 { + loc.row += 1; + self.tbl.move_to(loc)?; + } + Ok(()) + } + + pub fn move_up(&mut self) -> Result<()> { + let mut loc = self.tbl.location.clone(); + if loc.row > 0 { + loc.row -= 1; + self.tbl.move_to(loc)?; + } + Ok(()) + } + + pub fn move_left(&mut self) -> Result<()> { + let mut loc = self.tbl.location.clone(); + if loc.col > 0 { + loc.col -= 1; + self.tbl.move_to(loc)?; + } + Ok(()) + } + + pub fn move_right(&mut self) -> Result<()> { + // TODO(jwall): Add a column automatically if necessary? + let mut loc = self.tbl.location.clone(); + let (_, col) = self.tbl.dimensions(); + if loc.col < col-1 { + loc.col += 1; + self.tbl.move_to(loc)?; + } + Ok(()) + } + + pub fn handle_event(&mut self) -> Result> { + if let Event::Key(key) = event::read()? { + return Ok(match self.state.modality { + Modality::Navigate => self.handle_navigation_event(key)?, + Modality::CellEdit => self.handle_edit_event(key)?, + }); + } + Ok(None) + } + + fn handle_edit_event(&mut self, key: event::KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Esc => { + self.state.modality = Modality::Navigate; + }, + KeyCode::Char('j') => { + self.move_down()?; + }, + KeyCode::Char('k') => { + self.move_up()?; + }, + KeyCode::Char('h') => { + self.move_left()?; + }, + KeyCode::Char('l') => { + self.move_right()?; + }, + _ => { + // noop + } + } + } + Ok(None) + } + + fn handle_navigation_event(&mut self, key: event::KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Esc => { + self.state.modality = Modality::Navigate; + }, + KeyCode::Char('q') => { + return Ok(Some(ExitCode::SUCCESS)); + }, + KeyCode::Char('j') => { + self.move_down()?; + }, + KeyCode::Char('k') => { + self.move_up()?; + }, + KeyCode::Char('h') => { + self.move_left()?; + }, + KeyCode::Char('l') => { + self.move_right()?; + }, + KeyCode::Char('e') => { + self.state.modality = Modality::CellEdit; + }, + _ => { + // noop + } + } + } + return Ok(None); + } + + // navigation methods left, right, up, down } -impl Widget for Workspace { +impl<'a> Widget for &'a Workspace { fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) where Self: Sized, { - use Constraint::{Min, Percentage}; - let rects = Layout::vertical([Min(1), Percentage(90)]).split(area); - let table = Table::from(&self.tbl); - let tabs = Tabs::new(vec![self.name.clone()]).select(0); - - tabs.render(rects[0], buf); - table.render(rects[1], buf); + let block = Block::bordered() + .title(Line::from(self.name.as_str())) + .title_bottom(match &self.state.modality { + Modality::Navigate => "navigate", + Modality::CellEdit => "edit", + }) + .title_bottom(Line::from(format!("{},{}", self.tbl.location.row, self.tbl.location.col)).right_aligned()); + let table = Table::from(&self.tbl).block(block); + table.render(area, buf); } } @@ -66,25 +201,30 @@ impl<'t> From<&Tbl> for Table<'t> { .get_calculated_table() .iter() .enumerate() - .map(|(i, r)| { - let cells = vec![Cell::new(format!("{}", i))] - .into_iter() - .chain(r.iter().map(|v| { - let content = format!("{}", v); - Cell::new(Text::raw(content)) - .bg(if i % 2 == 0 { - Color::Rgb(57, 61, 71) - } else { - Color::Rgb(165, 169, 160) - }) - .fg(if i % 2 == 0 { - Color::White - } else { - Color::Rgb(31,32,34) - }) - .underlined() - .bold() - })); + .map(|(ri, r)| { + let cells = + vec![Cell::new(format!("{}", ri))] + .into_iter() + .chain(r.iter().enumerate().map(|(ci, v)| { + let content = format!("{}", v); + let cell = Cell::new(Text::raw(content)); + match (value.location.row == ri, value.location.col == ci) { + (true, true) => + cell + .fg(Color::White).underlined(), + _ => + cell.bg(if ri % 2 == 0 { + Color::Rgb(57, 61, 71) + } else { + Color::Rgb(165, 169, 160) + }) + .fg(if ri % 2 == 0 { + Color::White + } else { + Color::Rgb(31, 32, 34) + }), + }.bold() + })); Row::new(cells) }) .collect(); @@ -103,8 +243,6 @@ impl<'t> From<&Tbl> for Table<'t> { } } -pub fn draw(frame: &mut Frame, name: &PathBuf) { - let ws = Workspace::load(name).unwrap(); - +pub fn draw(frame: &mut Frame, ws: &Workspace) { frame.render_widget(ws, frame.area()); }