From bfc918e0d25b630a4cbf973294876b6bb54a7a2a Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Wed, 20 Nov 2024 17:33:01 -0500 Subject: [PATCH] wip: command mode works now starts to address #3 --- Cargo.lock | 130 ++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/ui/mod.rs | 200 +++++++++++++++++++++++++++++++++++--------------- 3 files changed, 270 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3013130..f9705cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -676,6 +682,12 @@ version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc46dd3ec48fdd8e693a98d2b8bafae273a2d54c1de02a2a7e3d57d501f39677" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "hashbrown" version = "0.15.0" @@ -731,6 +743,16 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.5" @@ -1071,6 +1093,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1140,6 +1171,15 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "ratatui-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" +dependencies = [ + "ratatui", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -1200,18 +1240,63 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "roxmltree" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex 1.11.1", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.37" @@ -1243,6 +1328,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "serde" version = "1.0.214" @@ -1309,6 +1400,7 @@ dependencies = [ "ironcalc", "ratatui", "thiserror", + "tui-prompts", "tui-textarea", ] @@ -1468,6 +1560,35 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tui-prompts" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb6e0d8a972545cc209b933a1c06dab8932674b54ae19947834ec854fec2364f" +dependencies = [ + "itertools 0.13.0", + "ratatui", + "ratatui-macros", + "rstest", +] + [[package]] name = "tui-textarea" version = "0.7.0" @@ -1718,6 +1839,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index d0d485d..1cda053 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ futures = "0.3.31" ratatui = "0.29.0" thiserror = "1.0.65" tui-textarea = "0.7.0" +tui-prompts = "0.5.0" diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9e7bfca..c882acb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,6 +14,7 @@ use ratatui::{ widgets::{Block, Cell, Paragraph, Row, Table, TableState, Widget}, Frame, }; +use tui_prompts::{State, Status, TextPrompt, TextState}; use tui_textarea::{CursorMove, TextArea}; #[derive(Default, Debug, PartialEq)] @@ -21,13 +22,15 @@ pub enum Modality { #[default] Navigate, CellEdit, + Command, // TODO(zaphar): Command Mode? } #[derive(Default, Debug)] -pub struct AppState { +pub struct AppState<'ws> { pub modality: Modality, pub table_state: TableState, + pub command_state: TextState<'ws>, } // TODO(jwall): This should probably move to a different module. @@ -56,7 +59,7 @@ impl Default for Address { pub struct Workspace<'ws> { name: PathBuf, book: Book, - state: AppState, + state: AppState<'ws>, text_area: TextArea<'ws>, dirty: bool, show_help: bool, @@ -128,6 +131,7 @@ impl<'ws> Workspace<'ws> { let result = match self.state.modality { Modality::Navigate => self.handle_navigation_input(key)?, Modality::CellEdit => self.handle_edit_input(key)?, + Modality::Command => self.handle_command_input(key)?, }; return Ok(result); } @@ -151,18 +155,34 @@ impl<'ws> Workspace<'ws> { "* ESC: Exit edit mode".into(), "Otherwise edit as normal".into(), ]), + Modality::Command => Text::from(vec![ + "Command Mode:".into(), + "* ESC: Exit command mode".into(), + ]), }) .block(info_block) } + fn handle_command_input(&mut self, key: event::KeyEvent) -> Result> { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Esc | KeyCode::Enter => self.exit_command_mode()?, + _ => { + // NOOP + } + } + } + self.state.command_state.handle_key_event(key); + Ok(None) + } + fn handle_edit_input(&mut self, key: event::KeyEvent) -> Result> { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => { self.show_help = !self.show_help; } - KeyCode::Esc => self.exit_edit_mode()?, - KeyCode::Enter => self.exit_edit_mode()?, + KeyCode::Esc | KeyCode::Enter => self.exit_edit_mode()?, _ => { // NOOP } @@ -178,29 +198,21 @@ impl<'ws> Workspace<'ws> { Ok(None) } - fn exit_edit_mode(&mut self) -> Result<(), anyhow::Error> { - self.state.modality = Modality::Navigate; - self.text_area.set_cursor_line_style(Style::default()); - self.text_area.set_cursor_style(Style::default()); - let contents = self.text_area.lines().join("\n"); - if self.dirty { - self.book.edit_current_cell(contents)?; - self.book.evaluate(); + fn handle_command(&mut self, cmd: String) -> Result { + if cmd.is_empty() { + return Ok(true); } - Ok(()) + Ok(false) } fn handle_navigation_input(&mut self, key: event::KeyEvent) -> Result> { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('e') => { - self.state.modality = Modality::CellEdit; - self.text_area - .set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED)); - self.text_area - .set_cursor_style(Style::default().add_modifier(Modifier::SLOW_BLINK)); - self.text_area.move_cursor(CursorMove::Bottom); - self.text_area.move_cursor(CursorMove::End); + self.enter_edit_mode(); + } + KeyCode::Char(':') => { + self.enter_command_mode(); } KeyCode::Char('h') if key.modifiers == KeyModifiers::CONTROL => { self.show_help = !self.show_help; @@ -210,7 +222,13 @@ impl<'ws> Workspace<'ws> { } KeyCode::Char('r') if key.modifiers == KeyModifiers::CONTROL => { let (row_count, _) = self.book.get_size()?; - self.book.update_entry(&Address {row: row_count+1, col: 1 }, "")?; + self.book.update_entry( + &Address { + row: row_count + 1, + col: 1, + }, + "", + )?; let (row, _) = self.book.get_size()?; let mut loc = self.book.location.clone(); if loc.row < row as usize { @@ -221,7 +239,13 @@ impl<'ws> Workspace<'ws> { } KeyCode::Char('t') if key.modifiers == KeyModifiers::CONTROL => { let (_, col_count) = self.book.get_size()?; - self.book.update_entry(&Address {row: 1, col: col_count+1 }, "")?; + self.book.update_entry( + &Address { + row: 1, + col: col_count + 1, + }, + "", + )?; } KeyCode::Char('q') => { return Ok(Some(ExitCode::SUCCESS)); @@ -255,6 +279,49 @@ impl<'ws> Workspace<'ws> { return Ok(None); } + fn enter_navigation_mode(&mut self) { + self.state.modality = Modality::Navigate; + } + + fn enter_command_mode(&mut self) { + self.state.modality = Modality::Command; + self.state.command_state.truncate(); + *self.state.command_state.status_mut() = Status::Pending; + self.state.command_state.focus(); + } + + fn enter_edit_mode(&mut self) { + self.state.modality = Modality::CellEdit; + self.text_area + .set_cursor_line_style(Style::default().add_modifier(Modifier::UNDERLINED)); + self.text_area + .set_cursor_style(Style::default().add_modifier(Modifier::SLOW_BLINK)); + self.text_area.move_cursor(CursorMove::Bottom); + self.text_area.move_cursor(CursorMove::End); + } + + fn exit_command_mode(&mut self) -> Result<()> { + let cmd = self.state.command_state.value().to_owned(); + self.state.command_state.blur(); + *self.state.command_state.status_mut() = Status::Done; + self.handle_command(cmd)?; + self.enter_navigation_mode(); + Ok(()) + } + + fn exit_edit_mode(&mut self) -> Result<()> { + self.text_area.set_cursor_line_style(Style::default()); + self.text_area.set_cursor_style(Style::default()); + let contents = self.text_area.lines().join("\n"); + if self.dirty { + self.book.edit_current_cell(contents)?; + self.book.evaluate(); + self.dirty = false; + } + self.enter_navigation_mode(); + Ok(()) + } + fn handle_movement_change(&mut self) { let contents = self .book @@ -264,7 +331,8 @@ impl<'ws> Workspace<'ws> { } fn save_file(&self) -> Result<()> { - self.book.save_to_xlsx(&self.name.to_string_lossy().to_string())?; + self.book + .save_to_xlsx(&self.name.to_string_lossy().to_string())?; Ok(()) } } @@ -282,6 +350,7 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { where Self: Sized, { + use ratatui::widgets::StatefulWidget; let outer_block = Block::bordered() .title(Line::from( self.name @@ -292,6 +361,7 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { .title_bottom(match &self.state.modality { Modality::Navigate => "navigate", Modality::CellEdit => "edit", + Modality::Command => "command", }) .title_bottom( Line::from(format!( @@ -300,33 +370,45 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { )) .right_aligned(), ); - let [edit_rect, table_rect] = if self.show_help { + let [edit_rect, table_rect] = if self.show_help || self.state.modality == Modality::Command + { let [edit_rect, table_rect, info_rect] = Layout::vertical(&[ Constraint::Fill(4), Constraint::Fill(30), - Constraint::Fill(9), + if self.state.modality == Modality::Command { + Constraint::Max(1) + } else { + Constraint::Fill(9) + }, ]) .vertical_margin(2) .horizontal_margin(2) .flex(Flex::Legacy) .areas(area.clone()); - + // Help panel widget display - let info_para = self.render_help_text(); - info_para.render(info_rect, buf); + if self.state.modality == Modality::Command { + StatefulWidget::render( + TextPrompt::from("Command"), + info_rect, + buf, + &mut self.state.command_state, + ); + } else if self.show_help { + let info_para = self.render_help_text(); + info_para.render(info_rect, buf); + } [edit_rect, table_rect] } else { - let [edit_rect, table_rect] = Layout::vertical(&[ - Constraint::Fill(4), - Constraint::Fill(30), - ]) - .vertical_margin(2) - .horizontal_margin(2) - .flex(Flex::Legacy) - .areas(area.clone()); + let [edit_rect, table_rect] = + Layout::vertical(&[Constraint::Fill(4), Constraint::Fill(30)]) + .vertical_margin(2) + .horizontal_margin(2) + .flex(Flex::Legacy) + .areas(area.clone()); [edit_rect, table_rect] }; - + outer_block.render(area, buf); // Input widget display @@ -341,9 +423,7 @@ impl<'widget, 'ws: 'widget> Widget for &'widget mut Workspace<'ws> { // TODO(zaphar): Apparently scrolling by columns doesn't work? self.state.table_state.select_cell(Some((row, col))); self.state.table_state.select_column(Some(col)); - use ratatui::widgets::StatefulWidget; StatefulWidget::render(table, table_rect, buf, &mut self.state.table_state); - } } @@ -361,28 +441,26 @@ impl<'t, 'book: 't> TryFrom<&'book Book> for Table<'t> { .into_iter() .map(|ri| { let mut cells = vec![Cell::new(Text::from(ri.to_string()))]; - cells.extend((1..=col_count) - .into_iter() - .map(|ci| { - // TODO(zaphar): Is this safe? - let content = value.get_cell_addr_rendered(ri, ci).unwrap(); - 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() - })); + cells.extend((1..=col_count).into_iter().map(|ci| { + // TODO(zaphar): Is this safe? + let content = value.get_cell_addr_rendered(ri, ci).unwrap(); + 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();