From 845fa4271868afa326f969bc9b57db562fcd35ac Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Mon, 28 Apr 2025 10:41:41 +0800 Subject: [PATCH 1/2] feat: replace ratatui_textarea with tui_textarea --- Cargo.lock | 235 +++++++++++++++++++++++++++++++++++++++------ Cargo.toml | 4 +- src/app/edit.rs | 4 +- src/app/search.rs | 6 +- src/app/state.rs | 2 +- src/ui/handlers.rs | 53 +++++++--- src/ui/render.rs | 17 ++-- 7 files changed, 258 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f3b0f0..b77e92d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.19" @@ -196,7 +205,7 @@ version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -223,6 +232,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -253,13 +276,29 @@ dependencies = [ "bitflags", "crossterm_winapi", "libc", - "mio", + "mio 0.8.11", "parking_lot", "signal-hook", "signal-hook-mio", "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio 1.0.3", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -269,6 +308,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_arbitrary" version = "1.4.1" @@ -301,6 +375,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "excel-cli" version = "0.2.0" @@ -309,13 +393,13 @@ dependencies = [ "calamine", "chrono", "clap", - "crossterm", + "crossterm 0.27.0", "indexmap", "ratatui", - "ratatui-textarea", "rust_xlsxwriter", "serde", "serde_json", + "tui-textarea", ] [[package]] @@ -328,6 +412,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -345,12 +435,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -381,6 +465,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.9.0" @@ -398,6 +488,19 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "instability" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -406,9 +509,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -435,6 +538,12 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "lock_api" version = "0.4.12" @@ -487,6 +596,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -561,30 +682,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", - "crossterm", + "compact_str", + "crossterm 0.28.1", "indoc", + "instability", "itertools", "lru", "paste", "strum", "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "ratatui-textarea" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "802cc8229dab704f3dcbc97799186a5b4b7aea63ecc928ffc7d17753152527b8" -dependencies = [ - "crossterm", - "ratatui", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] @@ -605,6 +719,19 @@ dependencies = [ "zip 2.6.1", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -678,7 +805,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -703,6 +831,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -711,20 +845,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "rustversion", @@ -742,6 +876,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm 0.28.1", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -754,12 +899,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "utf8parse" version = "0.2.2" @@ -920,6 +1082,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 1f40277..aa5d68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ categories = ["command-line-utilities", "development-tools", "data-structures", exclude = ["/.github", "CHANGELOG.md", ".gitignore"] [dependencies] -ratatui = "0.24.0" +ratatui = "0.29.0" crossterm = "0.27.0" calamine = "0.22.1" anyhow = "1.0.79" @@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" indexmap = { version = "2.0", features = ["serde"] } -ratatui-textarea = "0.4.0" +tui-textarea = "0.7.0" [profile.release] opt-level = 3 diff --git a/src/app/edit.rs b/src/app/edit.rs index 623358a..0456468 100644 --- a/src/app/edit.rs +++ b/src/app/edit.rs @@ -10,7 +10,7 @@ impl AppState<'_> { self.input_buffer = content.clone(); // Set up TextArea for editing - self.text_area = ratatui_textarea::TextArea::default(); + self.text_area = tui_textarea::TextArea::default(); self.text_area.insert_str(&content); } @@ -47,7 +47,7 @@ impl AppState<'_> { self.workbook.set_cell_value(row, col, content)?; self.input_mode = InputMode::Normal; self.input_buffer = String::new(); - self.text_area = ratatui_textarea::TextArea::default(); + self.text_area = tui_textarea::TextArea::default(); } Ok(()) } diff --git a/src/app/search.rs b/src/app/search.rs index f4415e4..f587cdd 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -5,7 +5,7 @@ impl AppState<'_> { pub fn start_search_forward(&mut self) { self.input_mode = InputMode::SearchForward; self.input_buffer = String::new(); - self.text_area = ratatui_textarea::TextArea::default(); + self.text_area = tui_textarea::TextArea::default(); self.add_notification("Search forward mode".to_string()); self.highlight_enabled = true; } @@ -13,7 +13,7 @@ impl AppState<'_> { pub fn start_search_backward(&mut self) { self.input_mode = InputMode::SearchBackward; self.input_buffer = String::new(); - self.text_area = ratatui_textarea::TextArea::default(); + self.text_area = tui_textarea::TextArea::default(); self.add_notification("Search backward mode".to_string()); self.highlight_enabled = true; } @@ -54,7 +54,7 @@ impl AppState<'_> { self.input_mode = InputMode::Normal; self.input_buffer = String::new(); - self.text_area = ratatui_textarea::TextArea::default(); + self.text_area = tui_textarea::TextArea::default(); } pub fn find_all_matches(&self, query: &str) -> Vec<(usize, usize)> { diff --git a/src/app/state.rs b/src/app/state.rs index d1ad2c4..a3c362c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use ratatui_textarea::TextArea; +use tui_textarea::TextArea; use std::collections::HashMap; use std::path::PathBuf; diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index f529a42..a286e31 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -1,5 +1,5 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use ratatui_textarea::TextArea; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tui_textarea::{TextArea, Input, Key}; use crate::app::{AppState, InputMode}; @@ -213,13 +213,13 @@ fn handle_editing_mode(app_state: &mut AppState, key_code: KeyCode) { } KeyCode::Esc => app_state.cancel_input(), _ => { - let key_event = KeyEvent { - code: key_code, - modifiers: KeyModifiers::empty(), - kind: KeyEventKind::Press, - state: KeyEventState::NONE, + let input = Input { + key: key_code_to_tui_key(key_code), + ctrl: false, + alt: false, + shift: false, }; - app_state.text_area.input(key_event); + app_state.text_area.input(input); // Update input_buffer with the current TextArea content to sync with cell display app_state.input_buffer = app_state.text_area.lines().join("\n"); @@ -236,17 +236,42 @@ fn handle_search_mode(app_state: &mut AppState, key_code: KeyCode) { app_state.text_area = TextArea::default(); } _ => { - let key_event = KeyEvent { - code: key_code, - modifiers: KeyModifiers::empty(), - kind: KeyEventKind::Press, - state: KeyEventState::NONE, + let input = Input { + key: key_code_to_tui_key(key_code), + ctrl: false, + alt: false, + shift: false, }; - app_state.text_area.input(key_event); + app_state.text_area.input(input); } } } +// Convert crossterm::event::KeyCode to tui_textarea::Key +fn key_code_to_tui_key(key_code: KeyCode) -> Key { + match key_code { + KeyCode::Backspace => Key::Backspace, + KeyCode::Enter => Key::Enter, + KeyCode::Left => Key::Left, + KeyCode::Right => Key::Right, + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Tab => Key::Tab, + KeyCode::BackTab => Key::Null, // BackTab not supported in tui-textarea + KeyCode::Delete => Key::Delete, + KeyCode::Insert => Key::Null, // Insert not supported in tui-textarea + KeyCode::Esc => Key::Esc, + KeyCode::Char(c) => Key::Char(c), + KeyCode::F(n) => Key::F(n), + KeyCode::Null => Key::Null, + _ => Key::Null, + } +} + fn handle_help_mode(app_state: &mut AppState, key_code: KeyCode) { let line_count = app_state.help_text.lines().count(); diff --git a/src/ui/render.rs b/src/ui/render.rs index 87be59c..1e824a4 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -106,7 +106,7 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { Constraint::Length(app_state.info_panel_height as u16), // Info panel Constraint::Length(1), // Status bar ]) - .split(f.size()); + .split(f.area()); draw_title_with_tabs(f, app_state, chunks[0]); @@ -118,7 +118,7 @@ fn ui(f: &mut Frame, app_state: &mut AppState) { // If in help mode, draw the help popup over everything else if let InputMode::Help = app_state.input_mode { - draw_help_popup(f, app_state, f.size()); + draw_help_popup(f, app_state, f.area()); } } @@ -244,11 +244,10 @@ fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { rows.push(Row::new(cells)); } - let table = Table::new(std::iter::once(header_row).chain(rows)) + let table = Table::new(std::iter::once(header_row).chain(rows), &col_constraints) .block(Block::default().borders(Borders::ALL)) - .highlight_style(Style::default().add_modifier(Modifier::BOLD)) - .highlight_symbol(">> ") - .widths(&col_constraints); + .row_highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(">> "); f.render_widget(table, area); } @@ -359,7 +358,7 @@ fn draw_info_panel(f: &mut Frame, app_state: &AppState, area: Rect) { height: inner_area.height, }; - f.render_widget(app_state.text_area.widget(), padded_area); + f.render_widget(&app_state.text_area, padded_area); } _ => { // Get cell content @@ -487,7 +486,7 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { Paragraph::new("/").style(Style::default().bg(Color::Black).fg(Color::White)); f.render_widget(prefix_widget, chunks[0]); - f.render_widget(text_area.widget(), chunks[1]); + f.render_widget(&text_area, chunks[1]); } InputMode::SearchBackward => { @@ -502,7 +501,7 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { Paragraph::new("?").style(Style::default().bg(Color::Black).fg(Color::White)); f.render_widget(prefix_widget, chunks[0]); - f.render_widget(text_area.widget(), chunks[1]); + f.render_widget(&text_area, chunks[1]); } InputMode::Help => {} From 001a5ae7751fe8172d8ba5b7d28c02f93e70ac2b Mon Sep 17 00:00:00 2001 From: fuhan666 Date: Mon, 28 Apr 2025 11:27:09 +0800 Subject: [PATCH 2/2] feat: edit cell content using vim shortcuts --- CHANGELOG.md | 4 + README.md | 51 ++++- README_zh.md | 49 ++++- src/app/edit.rs | 27 ++- src/app/mod.rs | 2 + src/app/navigation.rs | 2 +- src/app/state.rs | 5 +- src/app/ui.rs | 29 ++- src/app/vim.rs | 361 ++++++++++++++++++++++++++++++++++ src/commands/executor.rs | 2 +- src/excel/workbook.rs | 2 +- src/json_export/converters.rs | 2 +- src/ui/handlers.rs | 41 ++-- src/ui/render.rs | 69 +++++-- 14 files changed, 586 insertions(+), 60 deletions(-) create mode 100644 src/app/vim.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b253c2..be1b499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Edit cell content using vim shortcuts + ## [0.2.0] - 2025-04-27 ### Added diff --git a/README.md b/README.md index 9b2f6e8..1164577 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The application has a simple and intuitive interface: - `Ctrl+→` (or `Command+→` on Mac): If current cell is empty, jump to the first non-empty cell to the right; if current cell is not empty, jump to the last non-empty cell to the right - `Ctrl+↑` (or `Command+↑` on Mac): If current cell is empty, jump to the first non-empty cell above; if current cell is not empty, jump to the last non-empty cell above - `Ctrl+↓` (or `Command+↓` on Mac): If current cell is empty, jump to the first non-empty cell below; if current cell is not empty, jump to the last non-empty cell below -- `i`: Edit current cell +- `Enter`: Edit current cell - `y`: Copy current cell content - `d`: Cut current cell content - `p`: Paste clipboard content to current cell @@ -112,12 +112,53 @@ The application has a simple and intuitive interface: - `N`: Jump to previous search result - `:`: Enter command mode (for Vim-like commands) -## Edit Mode +## Vim Edit Mode -In edit mode: +When editing cell content (press `Enter` to enter edit mode): -- `Enter`: Confirm edit -- `Esc`: Cancel edit +- **Mode Switching**: + + - `Esc`: Exit Vim mode and save changes + - `i`: Enter Insert mode + - `v`: Enter Visual mode + +- **Navigation (in Normal mode)**: + + - `h`, `j`, `k`, `l`: Move cursor left, down, up, right + - `w`: Move to next word + - `b`: Move to beginning of word + - `e`: Move to end of word + - `$`: Move to end of line + - `^`: Move to first non-blank character of line + - `gg`: Move to first line + - `G`: Move to last line + +- **Editing Operations**: + + - `x`: Delete character under cursor + - `D`: Delete to end of line + - `C`: Change to end of line + - `o`: Open new line below and enter Insert mode + - `O`: Open new line above and enter Insert mode + - `A`: Append at end of line + - `I`: Insert at beginning of line + +- **Visual Mode Operations**: + + - `y`: Yank (copy) selected text + - `d`: Delete selected text + - `c`: Change selected text (delete and enter Insert mode) + +- **Operator Commands**: + + - `y{motion}`: Yank text specified by motion + - `d{motion}`: Delete text specified by motion + - `c{motion}`: Change text specified by motion + +- **Clipboard Operations**: + - `p`: Paste yanked or deleted text + - `u`: Undo last change + - `Ctrl+r`: Redo last undone change ## Search Mode diff --git a/README_zh.md b/README_zh.md index 1cb9fa4..65dd014 100644 --- a/README_zh.md +++ b/README_zh.md @@ -100,7 +100,7 @@ excel-cli path/to/your/file.xlsx -j > data.json # (示例)将JSON输出保 - `Ctrl+→`(Mac 上为 `Command+→`):如果当前单元格为空,跳转到右侧第一个非空单元格;如果当前单元格非空,跳转到右侧最后一个非空单元格 - `Ctrl+↑`(Mac 上为 `Command+↑`):如果当前单元格为空,跳转到上方第一个非空单元格;如果当前单元格非空,跳转到上方最后一个非空单元格 - `Ctrl+↓`(Mac 上为 `Command+↓`):如果当前单元格为空,跳转到下方第一个非空单元格;如果当前单元格非空,跳转到下方最后一个非空单元格 -- `i`:编辑当前单元格 +- `Enter`:编辑当前单元格 - `y`:复制当前单元格内容 - `d`:剪切当前单元格内容 - `p`:将剪贴板内容粘贴到当前单元格 @@ -114,10 +114,51 @@ excel-cli path/to/your/file.xlsx -j > data.json # (示例)将JSON输出保 ## 编辑模式 -在编辑模式下: +编辑单元格内容时(按 `Enter` 进入编辑模式): -- `Enter`:确认编辑 -- `Esc`:取消编辑 +- **模式切换**: + + - `Esc`: 退出 Vim 模式并保存更改 + - `i`: 进入插入模式 + - `v`: 进入可视模式 + +- **导航(在普通模式下)**: + + - `h`, `j`, `k`, `l`: 将光标向左、下、上、右移动 + - `w`: 移动到下一个单词 + - `b`: 移动到单词开头 + - `e`: 移动到单词结尾 + - `$`: 移动到行尾 + - `^`: 移动到行内第一个非空字符 + - `gg`: 移动到第一行 + - `G`: 移动到最后一行 + +- **编辑操作**: + + - `x`: 删除光标下的字符 + - `D`: 删除到行尾 + - `C`: 修改到行尾(删除并进入插入模式) + - `o`: 在下方新开一行并进入插入模式 + - `O`: 在上方新开一行并进入插入模式 + - `A`: 在行尾追加 + - `I`: 在行首插入 + +- **可视模式操作**: + + - `y`: 复制选中文本 + - `d`: 删除选中文本 + - `c`: 修改选中文本(删除并进入插入模式) + +- **操作符命令**: + + - `y{motion}`: 复制由动作指定的文本 + - `d{motion}`: 删除由动作指定的文本 + - `c{motion}`: 修改由动作指定的文本 + +- **剪贴板操作**: + - `p`: 粘贴已复制或删除的文本 + - `u`: 撤销上一次更改 + - `Ctrl+r`: 重做上一次被撤销的更改 ## 搜索模式 diff --git a/src/app/edit.rs b/src/app/edit.rs index 0456468..239957e 100644 --- a/src/app/edit.rs +++ b/src/app/edit.rs @@ -1,7 +1,9 @@ use crate::actions::{ActionCommand, ActionType, CellAction}; use crate::app::AppState; use crate::app::InputMode; +use crate::app::{Transition, VimMode, VimState}; use anyhow::Result; +use tui_textarea::Input; impl AppState<'_> { pub fn start_editing(&mut self) { @@ -9,9 +11,30 @@ impl AppState<'_> { let content = self.get_cell_content(self.selected_cell.0, self.selected_cell.1); self.input_buffer = content.clone(); - // Set up TextArea for editing self.text_area = tui_textarea::TextArea::default(); self.text_area.insert_str(&content); + self.text_area.set_tab_length(4); + + self.vim_state = Some(VimState::new(VimMode::Normal)); + } + + pub fn handle_vim_input(&mut self, input: Input) -> Result<()> { + if let Some(vim_state) = &mut self.vim_state { + match vim_state.transition(input, &mut self.text_area) { + Transition::Mode(mode) => { + self.vim_state = Some(VimState::new(mode)); + } + Transition::Pending(pending) => { + self.vim_state = Some(vim_state.clone().with_pending(pending)); + } + Transition::Exit => { + // Confirm edit and exit Vim mode + self.confirm_edit()?; + } + Transition::Nop => {} + } + } + Ok(()) } pub fn confirm_edit(&mut self) -> Result<()> { @@ -21,7 +44,6 @@ impl AppState<'_> { let (row, col) = self.selected_cell; self.workbook.ensure_cell_exists(row, col); - self.ensure_column_widths(); let sheet_index = self.workbook.get_current_sheet_index(); @@ -48,6 +70,7 @@ impl AppState<'_> { self.input_mode = InputMode::Normal; self.input_buffer = String::new(); self.text_area = tui_textarea::TextArea::default(); + self.vim_state = None; } Ok(()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 81383c8..e18025c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,5 +5,7 @@ mod sheet; mod state; mod ui; mod undo_manager; +mod vim; pub use state::*; +pub use vim::*; diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 4a60803..bbfd344 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -1,6 +1,6 @@ use crate::app::AppState; -use crate::utils::Direction; use crate::utils::find_non_empty_cell; +use crate::utils::Direction; impl AppState<'_> { pub fn move_cursor(&mut self, delta_row: isize, delta_col: isize) { diff --git a/src/app/state.rs b/src/app/state.rs index a3c362c..e907039 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,9 +1,10 @@ use anyhow::Result; -use tui_textarea::TextArea; use std::collections::HashMap; use std::path::PathBuf; +use tui_textarea::TextArea; use crate::actions::UndoHistory; +use crate::app::VimState; use crate::excel::Workbook; pub enum InputMode { @@ -43,6 +44,7 @@ pub struct AppState<'a> { pub help_scroll: usize, pub help_visible_lines: usize, pub undo_history: UndoHistory, + pub vim_state: Option, } impl AppState<'_> { @@ -100,6 +102,7 @@ impl AppState<'_> { help_scroll: 0, help_visible_lines: 20, undo_history: UndoHistory::new(), + vim_state: None, }) } diff --git a/src/app/ui.rs b/src/app/ui.rs index ba915a2..99fc3ad 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -23,7 +23,7 @@ impl AppState<'_> { ] - Switch to next sheet\n\ :sheet [name/number] - Switch to sheet by name or index\n\n\ EDITING:\n\ - i - Edit current cell\n\ + Enter - Edit current cell\n\ :y - Copy current cell\n\ :d - Cut current cell\n\ :put, :pu - Paste to current cell\n\ @@ -57,7 +57,32 @@ impl AppState<'_> { :delsheet - Delete the current sheet\n\n\ UI ADJUSTMENTS:\n\ +/= - Increase info panel height\n\ - - - Decrease info panel height" + - - Decrease info panel height\n\n\ + EDITING MODE:\n\ + Esc - Exit Vim mode and save changes\n\ + i - Enter Insert mode\n\ + v - Enter Visual mode\n\ + y - Yank (copy) text in Visual mode or with operator\n\ + d - Delete text in Visual mode or with operator\n\ + c - Change text in Visual mode or with operator\n\ + p - Paste yanked or deleted text\n\ + u - Undo last change\n\ + Ctrl+r - Redo last undone change\n\ + h,j,k,l - Move cursor left, down, up, right\n\ + w - Move to next word\n\ + b - Move to beginning of word\n\ + e - Move to end of word\n\ + $ - Move to end of line\n\ + ^ - Move to first non-blank character of line\n\ + gg - Move to first line\n\ + G - Move to last line\n\ + x - Delete character under cursor\n\ + D - Delete to end of line\n\ + C - Change to end of line\n\ + o - Open new line below and enter Insert mode\n\ + O - Open new line above and enter Insert mode\n\ + A - Append at end of line\n\ + I - Insert at beginning of line" .to_string(); self.input_mode = InputMode::Help; diff --git a/src/app/vim.rs b/src/app/vim.rs new file mode 100644 index 0000000..4fc72ab --- /dev/null +++ b/src/app/vim.rs @@ -0,0 +1,361 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::Block; +use std::fmt; +use tui_textarea::{CursorMove, Input, Key, TextArea}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VimMode { + Normal, + Insert, + Visual, + Operator(char), +} + +impl VimMode { + pub fn block<'a>(&self) -> Block<'a> { + let help = match self { + Self::Normal => "Esc=exit, i=insert, v=visual, y/d/c=operator", + Self::Insert => "Esc=normal mode", + Self::Visual => "Esc=normal, y=yank, d=delete, c=change", + Self::Operator(_) => "Move cursor to apply operator", + }; + let title = format!(" {} MODE ({}) ", self, help); + Block::default().title(title) + } + + pub fn cursor_style(&self) -> Style { + let color = match self { + Self::Normal => Color::Reset, + Self::Insert => Color::LightBlue, + Self::Visual => Color::LightYellow, + Self::Operator(_) => Color::LightGreen, + }; + Style::default().fg(color).add_modifier(Modifier::REVERSED) + } +} + +impl fmt::Display for VimMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + Self::Normal => write!(f, "NORMAL"), + Self::Insert => write!(f, "INSERT"), + Self::Visual => write!(f, "VISUAL"), + Self::Operator(c) => write!(f, "OPERATOR({})", c), + } + } +} + +// How the Vim emulation state transitions +pub enum Transition { + Nop, + Mode(VimMode), + Pending(Input), + Exit, +} + +// State of Vim emulation +#[derive(Clone)] +pub struct VimState { + pub mode: VimMode, + pub pending: Input, // Pending input to handle a sequence with two keys like gg +} + +impl VimState { + pub fn new(mode: VimMode) -> Self { + Self { + mode, + pending: Input::default(), + } + } + + pub fn with_pending(self, pending: Input) -> Self { + Self { + mode: self.mode, + pending, + } + } + + pub fn transition(&self, input: Input, textarea: &mut TextArea<'_>) -> Transition { + if input.key == Key::Null { + return Transition::Nop; + } + + match self.mode { + VimMode::Normal | VimMode::Visual | VimMode::Operator(_) => { + match input { + // Navigation + Input { + key: Key::Char('h'), + .. + } => textarea.move_cursor(CursorMove::Back), + Input { + key: Key::Char('j'), + .. + } => textarea.move_cursor(CursorMove::Down), + Input { + key: Key::Char('k'), + .. + } => textarea.move_cursor(CursorMove::Up), + Input { + key: Key::Char('l'), + .. + } => textarea.move_cursor(CursorMove::Forward), + Input { + key: Key::Char('w'), + .. + } => textarea.move_cursor(CursorMove::WordForward), + Input { + key: Key::Char('e'), + ctrl: false, + .. + } => { + textarea.move_cursor(CursorMove::WordEnd); + if matches!(self.mode, VimMode::Operator(_)) { + textarea.move_cursor(CursorMove::Forward); // Include the text under the cursor + } + } + Input { + key: Key::Char('b'), + ctrl: false, + .. + } => textarea.move_cursor(CursorMove::WordBack), + Input { + key: Key::Char('^'), + .. + } => textarea.move_cursor(CursorMove::Head), + Input { + key: Key::Char('$'), + .. + } => textarea.move_cursor(CursorMove::End), + + // Editing operations + Input { + key: Key::Char('D'), + .. + } => { + textarea.delete_line_by_end(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('C'), + .. + } => { + textarea.delete_line_by_end(); + textarea.cancel_selection(); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('p'), + .. + } => { + textarea.paste(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('u'), + ctrl: false, + .. + } => { + textarea.undo(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('r'), + ctrl: true, + .. + } => { + textarea.redo(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('x'), + .. + } => { + textarea.delete_next_char(); + return Transition::Mode(VimMode::Normal); + } + + // Mode changes + Input { + key: Key::Char('i'), + .. + } => { + textarea.cancel_selection(); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('a'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::Forward); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('A'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::End); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('I'), + .. + } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::Head); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('o'), + .. + } => { + textarea.move_cursor(CursorMove::End); + textarea.insert_newline(); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char('O'), + .. + } => { + textarea.move_cursor(CursorMove::Head); + textarea.insert_newline(); + textarea.move_cursor(CursorMove::Up); + return Transition::Mode(VimMode::Insert); + } + + // Exit + Input { key: Key::Esc, .. } => { + if self.mode == VimMode::Visual { + textarea.cancel_selection(); + return Transition::Mode(VimMode::Normal); + } else { + return Transition::Exit; + } + } + + // Scrolling + Input { + key: Key::Char('e'), + ctrl: true, + .. + } => textarea.scroll((1, 0)), + Input { + key: Key::Char('y'), + ctrl: true, + .. + } => textarea.scroll((-1, 0)), + + // Visual mode + Input { + key: Key::Char('v'), + ctrl: false, + .. + } if self.mode == VimMode::Normal => { + textarea.start_selection(); + return Transition::Mode(VimMode::Visual); + } + Input { + key: Key::Char('V'), + ctrl: false, + .. + } if self.mode == VimMode::Normal => { + textarea.move_cursor(CursorMove::Head); + textarea.start_selection(); + textarea.move_cursor(CursorMove::End); + return Transition::Mode(VimMode::Visual); + } + + // Operators + Input { + key: Key::Char('g'), + ctrl: false, + .. + } if matches!( + self.pending, + Input { + key: Key::Char('g'), + ctrl: false, + .. + } + ) => + { + textarea.move_cursor(CursorMove::Top) + } + Input { + key: Key::Char('G'), + ctrl: false, + .. + } => textarea.move_cursor(CursorMove::Bottom), + Input { + key: Key::Char('y'), + ctrl: false, + .. + } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive + textarea.copy(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('d'), + ctrl: false, + .. + } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive + textarea.cut(); + return Transition::Mode(VimMode::Normal); + } + Input { + key: Key::Char('c'), + ctrl: false, + .. + } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); // Vim's text selection is inclusive + textarea.cut(); + return Transition::Mode(VimMode::Insert); + } + Input { + key: Key::Char(op @ ('y' | 'd' | 'c')), + ctrl: false, + .. + } if self.mode == VimMode::Normal => { + textarea.start_selection(); + return Transition::Mode(VimMode::Operator(op)); + } + + input => return Transition::Pending(input), + } + + // Handle the pending operator + match self.mode { + VimMode::Operator('y') => { + textarea.copy(); + Transition::Mode(VimMode::Normal) + } + VimMode::Operator('d') => { + textarea.cut(); + Transition::Mode(VimMode::Normal) + } + VimMode::Operator('c') => { + textarea.cut(); + Transition::Mode(VimMode::Insert) + } + _ => Transition::Nop, + } + } + VimMode::Insert => match input { + Input { key: Key::Esc, .. } + | Input { + key: Key::Char('c'), + ctrl: true, + .. + } => Transition::Mode(VimMode::Normal), + input => { + textarea.input(input); // Use default key mappings in insert mode + Transition::Mode(VimMode::Insert) + } + }, + } + } +} diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 6ce86a6..a983850 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -1,7 +1,7 @@ use std::path::Path; use crate::app::AppState; -use crate::json_export::{HeaderDirection, export_all_sheets_json, export_json}; +use crate::json_export::{export_all_sheets_json, export_json, HeaderDirection}; use crate::utils::col_name_to_index; impl AppState<'_> { diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 53f7ac4..2bdaa71 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use calamine::{DataType, Reader, open_workbook_auto}; +use calamine::{open_workbook_auto, DataType, Reader}; use chrono::Local; use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook}; use std::path::Path; diff --git a/src/json_export/converters.rs b/src/json_export/converters.rs index b76e672..612b3ec 100644 --- a/src/json_export/converters.rs +++ b/src/json_export/converters.rs @@ -1,5 +1,5 @@ use chrono::{Duration, NaiveDate, NaiveDateTime}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use crate::excel::{Cell, CellType, DataTypeInfo}; diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index a286e31..e5877ae 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -1,5 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tui_textarea::{TextArea, Input, Key}; +use tui_textarea::{Input, Key, TextArea}; use crate::app::{AppState, InputMode}; @@ -14,7 +14,7 @@ pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { handle_normal_mode(app_state, key.code); } } - InputMode::Editing => handle_editing_mode(app_state, key.code), + InputMode::Editing => handle_editing_mode(app_state, key), InputMode::Command => handle_command_mode(app_state, key.code), InputMode::SearchForward => handle_search_mode(app_state, key.code), InputMode::SearchBackward => handle_search_mode(app_state, key.code), @@ -58,6 +58,10 @@ fn handle_command_mode(app_state: &mut AppState, key_code: KeyCode) { fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { + KeyCode::Enter => { + app_state.g_pressed = false; + app_state.start_editing(); + } KeyCode::Char('h') => { app_state.g_pressed = false; app_state.move_cursor(0, -1); @@ -100,10 +104,6 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { app_state.add_notification(format!("Failed to switch to next sheet: {}", e)); } } - KeyCode::Char('i') => { - app_state.g_pressed = false; - app_state.start_editing(); - } KeyCode::Char('g') => { if app_state.g_pressed { app_state.jump_to_first_row(); @@ -204,26 +204,17 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { } } -fn handle_editing_mode(app_state: &mut AppState, key_code: KeyCode) { - match key_code { - KeyCode::Enter => { - if let Err(e) = app_state.confirm_edit() { - app_state.add_notification(format!("Error: {}", e)); - } - } - KeyCode::Esc => app_state.cancel_input(), - _ => { - let input = Input { - key: key_code_to_tui_key(key_code), - ctrl: false, - alt: false, - shift: false, - }; - app_state.text_area.input(input); +fn handle_editing_mode(app_state: &mut AppState, key: KeyEvent) { + // Convert KeyEvent to Input for tui-textarea + let input = Input { + key: key_code_to_tui_key(key.code), + ctrl: key.modifiers.contains(KeyModifiers::CONTROL), + alt: key.modifiers.contains(KeyModifiers::ALT), + shift: key.modifiers.contains(KeyModifiers::SHIFT), + }; - // Update input_buffer with the current TextArea content to sync with cell display - app_state.input_buffer = app_state.text_area.lines().join("\n"); - } + if let Err(e) = app_state.handle_vim_input(input) { + app_state.add_notification(format!("Vim input error: {}", e)); } } diff --git a/src/ui/render.rs b/src/ui/render.rs index 1e824a4..7151e0b 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,16 +1,16 @@ use anyhow::Result; use crossterm::{ - ExecutableCommand, event::{self, Event, KeyEventKind}, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, }; use ratatui::{ - Frame, Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}, + Frame, Terminal, }; use std::{io, time::Duration}; @@ -244,8 +244,17 @@ fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { rows.push(Row::new(cells)); } + // In Normal mode, add color to the border of the data display area to indicate current focus + let table_block = if matches!(app_state.input_mode, InputMode::Normal) { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightGreen)) + } else { + Block::default().borders(Borders::ALL) + }; + let table = Table::new(std::iter::once(header_row).chain(rows), &col_constraints) - .block(Block::default().borders(Borders::ALL)) + .block(table_block) .row_highlight_style(Style::default().add_modifier(Modifier::BOLD)) .highlight_symbol(">> "); @@ -326,7 +335,7 @@ fn parse_command(input: &str) -> Vec { vec![Span::raw(input)] } -fn draw_info_panel(f: &mut Frame, app_state: &AppState, area: Rect) { +fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -342,10 +351,40 @@ fn draw_info_panel(f: &mut Frame, app_state: &AppState, area: Rect) { // Handle the top panel based on the input mode match app_state.input_mode { InputMode::Editing => { - // In editing mode, show the text area for editing - // Create a block for the editing area with title - let title = format!(" Editing Cell {} ", cell_ref); - let edit_block = Block::default().borders(Borders::ALL).title(title); + let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { + match vim_state.mode { + crate::app::VimMode::Normal => ("NORMAL", Color::Green), + crate::app::VimMode::Insert => ("INSERT", Color::LightBlue), + crate::app::VimMode::Visual => ("VISUAL", Color::Yellow), + crate::app::VimMode::Operator(op) => { + let op_str = match op { + 'y' => "YANK", + 'd' => "DELETE", + 'c' => "CHANGE", + _ => "OPERATOR", + }; + (op_str, Color::LightRed) + } + } + } else { + ("VIM", Color::White) + }; + + let title = Line::from(vec![ + Span::raw(" Editing Cell "), + Span::raw(cell_ref.clone()), + Span::raw(" - "), + Span::styled( + vim_mode_str, + Style::default().fg(mode_color).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let edit_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightGreen)) + .title(title); f.render_widget(edit_block.clone(), chunks[0]); @@ -364,7 +403,6 @@ fn draw_info_panel(f: &mut Frame, app_state: &AppState, area: Rect) { // Get cell content let content = app_state.get_cell_content(row, col); - // Create block with title let title = format!(" Cell {} Content ", cell_ref); let cell_block = Block::default().borders(Borders::ALL).title(title); @@ -445,7 +483,7 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { match app_state.input_mode { InputMode::Normal => { let status = - "Input :help for operating instructions | hjkl=move [ ]=prev/next-sheet i=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command ".to_string(); + "Input :help for operating instructions | hjkl=move [ ]=prev/next-sheet Enter=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command ".to_string(); let status_style = Style::default().bg(Color::Black).fg(Color::White); let status_widget = Paragraph::new(status).style(status_style); @@ -453,12 +491,9 @@ fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { } InputMode::Editing => { - let status = format!( - "Editing cell {} (Enter=confirm, Esc=cancel)", - cell_reference(app_state.selected_cell) - ); - let status_style = Style::default().bg(Color::Black).fg(Color::White); - let status_widget = Paragraph::new(status).style(status_style); + let status_style = Style::default().bg(Color::Black); + let status_widget = + Paragraph::new("Press Esc to exit editing mode".to_string()).style(status_style); f.render_widget(status_widget, area); }