diff --git a/Cargo.lock b/Cargo.lock index 21cc28d..df45210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3552,6 +3552,7 @@ checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ "crossterm", "ratatui", + "regex", "unicode-width 0.2.0", ] @@ -3848,7 +3849,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index 285ca7d..e6e86c2 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -22,6 +22,6 @@ toml = "0.8.20" tonic = "0.13.0" tracing = "0.1.41" tracing-subscriber = "0.3.19" -tui-textarea = { version = "0.7.0", features = ["crossterm"] } +tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] } unicode-segmentation = "1.12.0" unicode-width = "0.2.0" diff --git a/client/config.toml b/client/config.toml index f8a904b..667f638 100644 --- a/client/config.toml +++ b/client/config.toml @@ -83,6 +83,11 @@ force_quit = ["q!"] save_and_quit = ["wq"] revert = ["r"] +[editor] +keybinding_mode = "vim" # Options: "default", "vim", "emacs" +show_line_numbers = true +tab_width = 4 + [colors] theme = "dark" # Options: "light", "dark", "high_contrast" diff --git a/client/src/components/admin/add_logic.rs b/client/src/components/admin/add_logic.rs index e7d1230..d6e8463 100644 --- a/client/src/components/admin/add_logic.rs +++ b/client/src/components/admin/add_logic.rs @@ -6,13 +6,15 @@ use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState}; use crate::state::pages::canvas_state::CanvasState; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style, Stylize}, // Added Stylize for .dim() + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, BorderType, Borders, Paragraph}, // Removed unused Widget + widgets::{Block, BorderType, Borders, Paragraph}, Frame, }; use crate::components::handlers::canvas::render_canvas; use crate::components::common::dialog; +use crate::config::binds::config::EditorKeybindingMode; +use crate::components::common::text_editor::TextEditor; pub fn render_add_logic( f: &mut Frame, @@ -34,35 +36,44 @@ pub fn render_add_logic( f.render_widget(main_block, area); if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { - let mut editor = add_logic_state.script_content_editor.borrow_mut(); - let border_style = if is_edit_mode { - Style::default().fg(theme.highlight) - } else { - Style::default().fg(theme.highlight) + let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); + let border_style_color = if is_edit_mode { theme.highlight } else { theme.secondary }; + let border_style = Style::default().fg(border_style_color); + + editor_ref.set_cursor_line_style(Style::default().bg(theme.secondary)); + + let script_title_hint = match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => { + let vim_mode_status = TextEditor::get_vim_mode_status(&add_logic_state.vim_state); + if is_edit_mode { + format!("Script (VIM {}) - Esc for Normal. Tab navigates from Normal.", vim_mode_status) + } else { + format!("Script (VIM {}) - 'i'/'a'/'o' for Insert. Tab to navigate.", vim_mode_status) + } + } + EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { + if is_edit_mode { + "Script (Editing - Esc to exit edit. Tab navigates after exit.)".to_string() + } else { + "Script (Press Enter or Ctrl+E to edit. Tab to navigate.)".to_string() + } + } }; - editor.set_cursor_line_style( - Style::default().bg(theme.secondary), - ); - editor.set_line_number_style( - Style::default().fg(theme.secondary), - ); - - editor.set_block( + editor_ref.set_block( Block::default() - .title(Span::styled( - " Steel Script Content (Ctrl+E or Enter to edit, Esc to unfocus/exit edit) ", - Style::default().fg(theme.fg), - )) + .title(Span::styled(script_title_hint, Style::default().fg(theme.fg))) .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(border_style), ); - f.render_widget(&*editor, inner_area); + // Remove .widget() call - just pass the reference directly + f.render_widget(&*editor_ref, inner_area); return; } + // ... rest of the layout code ... let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -109,7 +120,6 @@ pub fn render_add_logic( | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription ); - render_canvas( f, canvas_area, @@ -123,20 +133,29 @@ pub fn render_add_logic( ); { - let mut editor = add_logic_state.script_content_editor.borrow_mut(); - editor.set_cursor_line_style(Style::default()); - editor.set_line_number_style( - Style::default().fg(theme.secondary).dim(), // Fixed: apply .dim() to Style, not Color - ); + let mut editor_ref = add_logic_state.script_content_editor.borrow_mut(); + editor_ref.set_cursor_line_style(Style::default()); - editor.set_block( + let border_style_color = if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { + theme.highlight + } else { + theme.secondary + }; + + let title_hint = match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => "Script Preview (VIM - Focus with Tab, then 'i'/'a'/'o' to edit)", + _ => "Script Preview (Focus with Tab, then Enter/Ctrl+E to edit)", + }; + + editor_ref.set_block( Block::default() - .title(" Steel Script Content ") + .title(title_hint) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.secondary)), + .border_style(Style::default().fg(border_style_color)), ); - f.render_widget(&*editor, script_content_area); + // Remove .widget() call here too + f.render_widget(&*editor_ref, script_content_area); } let get_button_style = |button_focus: AddLogicFocus, current_focus| { diff --git a/client/src/components/common.rs b/client/src/components/common.rs index ea30d5f..1c26c28 100644 --- a/client/src/components/common.rs +++ b/client/src/components/common.rs @@ -1,12 +1,14 @@ // src/components/common.rs pub mod command_line; pub mod status_line; +pub mod text_editor; pub mod background; pub mod dialog; pub mod autocomplete; pub use command_line::*; pub use status_line::*; +pub use text_editor::*; pub use background::*; pub use dialog::*; pub use autocomplete::*; diff --git a/client/src/components/common/text_editor.rs b/client/src/components/common/text_editor.rs new file mode 100644 index 0000000..c37c55e --- /dev/null +++ b/client/src/components/common/text_editor.rs @@ -0,0 +1,331 @@ +// src/components/common/text_editor.rs +use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; +use crossterm::event::{KeyEvent, KeyCode, KeyModifiers}; +use ratatui::style::{Color, Style, Modifier}; +use tui_textarea::{Input, Key, TextArea, CursorMove, Scrolling}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VimMode { + Normal, + Insert, + Visual, + Operator(char), +} + +impl VimMode { + 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), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Transition { + Nop, + Mode(VimMode), + Pending(Input), +} + +#[derive(Debug, Clone)] +pub struct VimState { + pub mode: VimMode, + pub pending: Input, +} + +impl Default for VimState { + fn default() -> Self { + Self { + mode: VimMode::Normal, + pending: Input::default(), + } + } +} + +impl VimState { + pub fn new(mode: VimMode) -> Self { + Self { + mode, + pending: Input::default(), + } + } + + fn with_pending(self, pending: Input) -> Self { + Self { + mode: self.mode, + pending, + } + } + + 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 { + 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); + } + } + 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), + Input { key: Key::Char('0'), .. } => textarea.move_cursor(CursorMove::Head), + 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); + } + 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('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); + } + Input { key: Key::Char('I'), .. } => { + textarea.cancel_selection(); + textarea.move_cursor(CursorMove::Head); + return Transition::Mode(VimMode::Insert); + } + 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); + } + Input { key: Key::Esc, .. } | Input { key: Key::Char('v'), ctrl: false, .. } if self.mode == VimMode::Visual => { + textarea.cancel_selection(); + return Transition::Mode(VimMode::Normal); + } + 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(c), ctrl: false, .. } if self.mode == VimMode::Operator(c) => { + textarea.move_cursor(CursorMove::Head); + textarea.start_selection(); + let cursor = textarea.cursor(); + textarea.move_cursor(CursorMove::Down); + if cursor == textarea.cursor() { + textarea.move_cursor(CursorMove::End); + } + } + 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 { key: Key::Char('y'), ctrl: false, .. } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); + textarea.copy(); + return Transition::Mode(VimMode::Normal); + } + Input { key: Key::Char('d'), ctrl: false, .. } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); + textarea.cut(); + return Transition::Mode(VimMode::Normal); + } + Input { key: Key::Char('c'), ctrl: false, .. } if self.mode == VimMode::Visual => { + textarea.move_cursor(CursorMove::Forward); + textarea.cut(); + return Transition::Mode(VimMode::Insert); + } + // Arrow keys work in normal mode + Input { key: Key::Up, .. } => textarea.move_cursor(CursorMove::Up), + Input { key: Key::Down, .. } => textarea.move_cursor(CursorMove::Down), + Input { key: Key::Left, .. } => textarea.move_cursor(CursorMove::Back), + Input { key: Key::Right, .. } => textarea.move_cursor(CursorMove::Forward), + 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); + Transition::Mode(VimMode::Insert) + } + }, + } + } +} + +pub struct TextEditor; + +impl TextEditor { + pub fn new_textarea(editor_config: &EditorConfig) -> TextArea<'static> { + let mut textarea = TextArea::default(); + + if editor_config.show_line_numbers { + textarea.set_line_number_style(Style::default().fg(Color::DarkGray)); + } + + textarea.set_tab_length(editor_config.tab_width); + + textarea + } + + pub fn handle_input( + textarea: &mut TextArea<'static>, + key_event: KeyEvent, + keybinding_mode: &EditorKeybindingMode, + vim_state: &mut VimState, + ) -> bool { + match keybinding_mode { + EditorKeybindingMode::Vim => { + Self::handle_vim_input(textarea, key_event, vim_state) + } + _ => { + let tui_input: Input = key_event.into(); + textarea.input(tui_input) + } + } + } + + fn handle_vim_input( + textarea: &mut TextArea<'static>, + key_event: KeyEvent, + vim_state: &mut VimState, + ) -> bool { + let input = Self::convert_key_event_to_input(key_event); + + *vim_state = match vim_state.transition(input, textarea) { + Transition::Mode(mode) if vim_state.mode != mode => { + // Update cursor style based on mode + textarea.set_cursor_style(mode.cursor_style()); + VimState::new(mode) + } + Transition::Nop | Transition::Mode(_) => vim_state.clone(), + Transition::Pending(input) => vim_state.clone().with_pending(input), + }; + + true // Always consider input as handled in vim mode + } + + fn convert_key_event_to_input(key_event: KeyEvent) -> Input { + let key = match key_event.code { + KeyCode::Char(c) => Key::Char(c), + KeyCode::Enter => Key::Enter, + KeyCode::Left => Key::Left, + KeyCode::Right => Key::Right, + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::Backspace => Key::Backspace, + KeyCode::Delete => Key::Delete, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Tab => Key::Tab, + KeyCode::Esc => Key::Esc, + _ => Key::Null, + }; + + Input { + key, + ctrl: key_event.modifiers.contains(KeyModifiers::CONTROL), + alt: key_event.modifiers.contains(KeyModifiers::ALT), + shift: key_event.modifiers.contains(KeyModifiers::SHIFT), + } + } + + pub fn get_vim_mode_status(vim_state: &VimState) -> String { + vim_state.mode.to_string() + } + + pub fn is_vim_insert_mode(vim_state: &VimState) -> bool { + matches!(vim_state.mode, VimMode::Insert) + } + + pub fn is_vim_normal_mode(vim_state: &VimState) -> bool { + matches!(vim_state.mode, VimMode::Normal) + } +} diff --git a/client/src/config/binds/config.rs b/client/src/config/binds/config.rs index cd59041..4ba1f28 100644 --- a/client/src/config/binds/config.rs +++ b/client/src/config/binds/config.rs @@ -1,11 +1,57 @@ // src/config/binds/config.rs -use serde::Deserialize; +use serde::{Deserialize, Serialize}; // Added Serialize for EditorKeybindingMode if needed elsewhere use std::collections::HashMap; use std::path::Path; use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyModifiers}; +// NEW: Editor Keybinding Mode Enum +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum EditorKeybindingMode { + #[serde(rename = "default")] + Default, + #[serde(rename = "vim")] + Vim, + #[serde(rename = "emacs")] + Emacs, +} + +impl Default for EditorKeybindingMode { + fn default() -> Self { + EditorKeybindingMode::Vim // Or EditorKeybindingMode::Default + } +} + +// NEW: Editor Configuration Struct +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditorConfig { + #[serde(default)] + pub keybinding_mode: EditorKeybindingMode, + #[serde(default = "default_show_line_numbers")] + pub show_line_numbers: bool, + #[serde(default = "default_tab_width")] + pub tab_width: u8, +} + +fn default_show_line_numbers() -> bool { + true +} + +fn default_tab_width() -> u8 { + 4 +} + +impl Default for EditorConfig { + fn default() -> Self { + EditorConfig { + keybinding_mode: EditorKeybindingMode::default(), + show_line_numbers: default_show_line_numbers(), + tab_width: default_tab_width(), + } + } +} + #[derive(Debug, Deserialize, Default)] pub struct ColorsConfig { #[serde(default = "default_theme")] @@ -22,9 +68,14 @@ pub struct Config { pub keybindings: ModeKeybindings, #[serde(default)] pub colors: ColorsConfig, + // NEW: Add editor configuration + #[serde(default)] + pub editor: EditorConfig, } -#[derive(Debug, Deserialize)] +// ... (rest of your Config struct and impl Config remains the same) +// Make sure ModeKeybindings is also deserializable if it's not already +#[derive(Debug, Deserialize, Default)] // Added Default here if not present pub struct ModeKeybindings { #[serde(default)] pub general: HashMap>, @@ -49,11 +100,11 @@ impl Config { let config_path = Path::new(manifest_dir).join("config.toml"); let config_str = std::fs::read_to_string(&config_path) .with_context(|| format!("Failed to read config file at {:?}", config_path))?; - let config: Config = toml::from_str(&config_str)?; + let config: Config = toml::from_str(&config_str) + .with_context(|| format!("Failed to parse config file: {}. Check for syntax errors or missing fields like an empty [editor] section if you added it.", config_str))?; // Enhanced error message Ok(config) } - pub fn get_general_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> { self.get_action_for_key_in_mode(&self.keybindings.general, key, modifiers) .or_else(|| self.get_action_for_key_in_mode(&self.keybindings.global, key, modifiers)) diff --git a/client/src/functions/modes/navigation/add_logic_nav.rs b/client/src/functions/modes/navigation/add_logic_nav.rs index 0c85204..e67e7d9 100644 --- a/client/src/functions/modes/navigation/add_logic_nav.rs +++ b/client/src/functions/modes/navigation/add_logic_nav.rs @@ -1,24 +1,23 @@ // src/functions/modes/navigation/add_logic_nav.rs -use crate::config::binds::config::Config; +use crate::config::binds::config::{Config, EditorKeybindingMode}; use crate::state::{ app::state::AppState, pages::add_logic::{AddLogicFocus, AddLogicState}, app::buffer::AppView, app::buffer::BufferState, }; -// Now that client/Cargo.toml uses crossterm 0.28.1, -// this KeyEvent will match what ratatui and tui-textarea expect. use crossterm::event::{KeyEvent, KeyCode, KeyModifiers}; use crate::services::GrpcClient; use tokio::sync::mpsc; use anyhow::Result; use common::proto::multieko2::table_script::PostTableScriptRequest; -use tui_textarea::Input as TextAreaInput; // Import with an alias +use crate::components::common::text_editor::TextEditor; +use tui_textarea::Input as TextAreaInput; pub type SaveLogicResultSender = mpsc::Sender>; pub fn handle_add_logic_navigation( - key_event: KeyEvent, // This is crossterm::event::KeyEvent v0.28.1 + key_event: KeyEvent, config: &Config, app_state: &mut AppState, add_logic_state: &mut AddLogicState, @@ -28,180 +27,236 @@ pub fn handle_add_logic_navigation( save_logic_sender: SaveLogicResultSender, command_message: &mut String, ) -> bool { - let action = config.get_general_action(key_event.code, key_event.modifiers).map(String::from); let mut handled = false; + let general_action = config.get_general_action(key_event.code, key_event.modifiers); if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { - // Add explicit type annotation for .into() - let textarea_input: TextAreaInput = key_event.into(); + let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); - if *is_edit_mode { - if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE { - *is_edit_mode = false; - *command_message = "Exited script edit mode. Press Ctrl+E/Enter to re-enter.".to_string(); - return true; - } + match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => { + if *is_edit_mode { // App considers textarea to be in "typing" (Insert) mode + let changed = TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state, + ); + if changed { add_logic_state.has_unsaved_changes = true; } - let changed = add_logic_state.script_content_editor.borrow_mut().input(textarea_input); - if changed { - add_logic_state.has_unsaved_changes = true; - } - handled = true; - } else { - match key_event.code { - KeyCode::Enter if key_event.modifiers == KeyModifiers::NONE => { - *is_edit_mode = true; - *command_message = "Entered script edit mode.".to_string(); - handled = true; - } - KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown | - KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => { - let changed = add_logic_state.script_content_editor.borrow_mut().input(textarea_input); - if changed { - add_logic_state.has_unsaved_changes = true; + // Check if we've transitioned to Normal mode + if key_event.code == KeyCode::Esc && TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) { + *is_edit_mode = false; + *command_message = "VIM: Normal Mode. Tab to navigate.".to_string(); } handled = true; + } else { // App considers textarea to be in "navigation" (Normal) mode + match key_event.code { + // Keys to enter Vim Insert mode + KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Char('o') | + KeyCode::Char('I') | KeyCode::Char('A') | KeyCode::Char('O') => { + *is_edit_mode = true; + TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state + ); + *command_message = "VIM: Insert Mode.".to_string(); + handled = true; + } + _ => { + if general_action.is_none() { + let changed = TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state, + ); + if changed { add_logic_state.has_unsaved_changes = true; } + handled = true; + } + } + } } - KeyCode::Esc if key_event.modifiers == KeyModifiers::NONE => { - add_logic_state.current_focus = AddLogicFocus::InputDescription; - app_state.ui.focus_outside_canvas = false; - *command_message = "Script content unfocused.".to_string(); - *is_edit_mode = matches!(add_logic_state.current_focus, - AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription); - handled = true; + } + EditorKeybindingMode::Emacs | EditorKeybindingMode::Default => { + if *is_edit_mode { + if key_event.code == KeyCode::Esc && key_event.modifiers == KeyModifiers::NONE { + *is_edit_mode = false; + *command_message = "Exited script edit. Tab to navigate.".to_string(); + handled = true; + } else if general_action.is_some() && (general_action.unwrap() == "next_field" || general_action.unwrap() == "prev_field") { + let changed = TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state + ); + if changed { add_logic_state.has_unsaved_changes = true; } + handled = true; + } else { + let changed = TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state + ); + if changed { add_logic_state.has_unsaved_changes = true; } + handled = true; + } } - _ => {} } } if handled { return true; } } - // ... (rest of the function remains the same) - match action.as_deref() { + // If not handled above (e.g., Tab/Shift+Tab, or Enter when script content not in edit mode), + // process general application-level actions. + let action_str = general_action.map(String::from); + match action_str.as_deref() { Some("exit_view") | Some("cancel_action") => { buffer_state.update_history(AppView::Admin); app_state.ui.show_add_logic = false; *command_message = "Exited Add Logic".to_string(); + *is_edit_mode = false; handled = true; } - Some("next_field") => { + Some("next_field") | Some("prev_field") => { + let is_next = action_str.as_deref() == Some("next_field"); let previous_focus = add_logic_state.current_focus; - add_logic_state.current_focus = match add_logic_state.current_focus { - AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn, - AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription, - AddLogicFocus::InputDescription => AddLogicFocus::InputScriptContent, - AddLogicFocus::InputScriptContent => AddLogicFocus::SaveButton, - AddLogicFocus::SaveButton => AddLogicFocus::CancelButton, - AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName, + + add_logic_state.current_focus = if is_next { + match add_logic_state.current_focus { + AddLogicFocus::InputLogicName => AddLogicFocus::InputTargetColumn, + AddLogicFocus::InputTargetColumn => AddLogicFocus::InputDescription, + AddLogicFocus::InputDescription => AddLogicFocus::InputScriptContent, + AddLogicFocus::InputScriptContent => AddLogicFocus::SaveButton, + AddLogicFocus::SaveButton => AddLogicFocus::CancelButton, + AddLogicFocus::CancelButton => AddLogicFocus::InputLogicName, + } + } else { + match add_logic_state.current_focus { + AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton, + AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName, + AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn, + AddLogicFocus::InputScriptContent => AddLogicFocus::InputDescription, + AddLogicFocus::SaveButton => AddLogicFocus::InputScriptContent, + AddLogicFocus::CancelButton => AddLogicFocus::SaveButton, + } }; - if previous_focus == AddLogicFocus::InputScriptContent && - add_logic_state.current_focus != AddLogicFocus::InputScriptContent { + + if add_logic_state.current_focus == AddLogicFocus::InputScriptContent { *is_edit_mode = false; + let mode_hint = match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => "'i'/'a'/'o' to insert", + _ => "Enter/Ctrl+E to edit", + }; + *command_message = format!("Focus: Script Content. Press {} or Tab.", mode_hint); + } else if matches!(add_logic_state.current_focus, AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription) { + *is_edit_mode = true; + *command_message = format!("Focus: {:?}. Edit mode ON.", add_logic_state.current_focus); + } else { + *is_edit_mode = false; + *command_message = format!("Focus: {:?}", add_logic_state.current_focus); } - *is_edit_mode = matches!(add_logic_state.current_focus, - AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription); app_state.ui.focus_outside_canvas = !matches!( add_logic_state.current_focus, AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription | AddLogicFocus::InputScriptContent ); - *command_message = format!("Focus: {:?}", add_logic_state.current_focus); - handled = true; - } - Some("prev_field") => { - let previous_focus = add_logic_state.current_focus; - add_logic_state.current_focus = match add_logic_state.current_focus { - AddLogicFocus::InputLogicName => AddLogicFocus::CancelButton, - AddLogicFocus::InputTargetColumn => AddLogicFocus::InputLogicName, - AddLogicFocus::InputDescription => AddLogicFocus::InputTargetColumn, - AddLogicFocus::InputScriptContent => AddLogicFocus::InputDescription, - AddLogicFocus::SaveButton => AddLogicFocus::InputScriptContent, - AddLogicFocus::CancelButton => AddLogicFocus::SaveButton, - }; - if previous_focus == AddLogicFocus::InputScriptContent && - add_logic_state.current_focus != AddLogicFocus::InputScriptContent { - *is_edit_mode = false; - } - *is_edit_mode = matches!(add_logic_state.current_focus, - AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription); - - app_state.ui.focus_outside_canvas = !matches!( - add_logic_state.current_focus, - AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription | AddLogicFocus::InputScriptContent - ); - *command_message = format!("Focus: {:?}", add_logic_state.current_focus); handled = true; } Some("select") => { match add_logic_state.current_focus { - AddLogicFocus::SaveButton => { - if let Some(table_def_id) = add_logic_state.selected_table_id { - let script_lines = add_logic_state.script_content_editor.borrow().lines().to_vec(); - let script_content = script_lines.join("\n"); - - if add_logic_state.target_column_input.trim().is_empty() { - *command_message = "Cannot save: Target Column cannot be empty.".to_string(); - } else if script_content.trim().is_empty() { - *command_message = "Cannot save: Script Content cannot be empty.".to_string(); - } else { - *command_message = "Saving logic script...".to_string(); - app_state.show_loading_dialog("Saving Script", "Please wait..."); - - let request = PostTableScriptRequest { - table_definition_id: table_def_id, - target_column: add_logic_state.target_column_input.trim().to_string(), - script: script_content.trim().to_string(), - description: add_logic_state.description_input.trim().to_string(), - }; - let mut client_clone = grpc_client.clone(); - let sender_clone = save_logic_sender.clone(); - tokio::spawn(async move { - let result = client_clone.post_table_script(request).await - .map(|res| format!("Script saved with ID: {}", res.id)) - .map_err(|e| anyhow::anyhow!("gRPC call failed: {}", e)); - if sender_clone.send(result).await.is_err() { - // Log error or handle if receiver dropped - } - }); + AddLogicFocus::InputScriptContent => { + *is_edit_mode = true; + let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); + match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => { + TextEditor::handle_input( + &mut editor_borrow, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state, + ); + *command_message = "VIM: Insert Mode.".to_string(); + } + _ => { + *command_message = "Entered script edit mode.".to_string(); } - } else { - *command_message = "Cannot save: Table Definition ID is missing.".to_string(); } handled = true; } - AddLogicFocus::CancelButton => { - buffer_state.update_history(AppView::Admin); - app_state.ui.show_add_logic = false; - *command_message = "Cancelled Add Logic".to_string(); - handled = true; - } + AddLogicFocus::SaveButton => { handled = true; } + AddLogicFocus::CancelButton => { *is_edit_mode = false; handled = true; } AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => { *is_edit_mode = !*is_edit_mode; *command_message = format!("Field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" }); handled = true; } - AddLogicFocus::InputScriptContent => { - if !*is_edit_mode { - *is_edit_mode = true; - *command_message = "Entered script edit mode.".to_string(); - } - handled = true; - } } } Some("toggle_edit_mode") => { match add_logic_state.current_focus { - AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription | AddLogicFocus::InputScriptContent => { + AddLogicFocus::InputScriptContent => { + let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); + match add_logic_state.editor_keybinding_mode { + EditorKeybindingMode::Vim => { + if *is_edit_mode { + TextEditor::handle_input( + &mut editor_borrow, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state, + ); + if TextEditor::is_vim_normal_mode(&add_logic_state.vim_state) { + *is_edit_mode = false; + *command_message = "VIM: Normal Mode. Tab to navigate.".to_string(); + } else { + *command_message = "VIM: Still in Insert Mode (toggle error?).".to_string(); + } + } else { + TextEditor::handle_input( + &mut editor_borrow, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state, + ); + *is_edit_mode = true; + *command_message = "VIM: Insert Mode.".to_string(); + } + } + _ => { + *is_edit_mode = !*is_edit_mode; + *command_message = format!("Script edit mode: {}", if *is_edit_mode { "ON" } else { "OFF. Tab to navigate." }); + } + } + handled = true; + } + AddLogicFocus::InputLogicName | AddLogicFocus::InputTargetColumn | AddLogicFocus::InputDescription => { *is_edit_mode = !*is_edit_mode; - *command_message = format!("Edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" }); - } - _ => { - *command_message = "Cannot toggle edit mode here.".to_string(); + *command_message = format!("Canvas field edit mode: {}", if *is_edit_mode { "ON" } else { "OFF" }); + handled = true; } + _ => { *command_message = "Cannot toggle edit mode here.".to_string(); handled = true; } + } + } + _ => { + if add_logic_state.current_focus == AddLogicFocus::InputScriptContent && + !*is_edit_mode && + add_logic_state.editor_keybinding_mode == EditorKeybindingMode::Vim { + let mut editor_borrow = add_logic_state.script_content_editor.borrow_mut(); + let changed = TextEditor::handle_input( + &mut editor_borrow, + key_event, + &add_logic_state.editor_keybinding_mode, + &mut add_logic_state.vim_state + ); + if changed { add_logic_state.has_unsaved_changes = true; } + handled = true; } - handled = true; } - _ => {} } handled } diff --git a/client/src/state/pages/add_logic.rs b/client/src/state/pages/add_logic.rs index 690b130..9577fc7 100644 --- a/client/src/state/pages/add_logic.rs +++ b/client/src/state/pages/add_logic.rs @@ -1,10 +1,10 @@ // src/state/pages/add_logic.rs +use crate::config::binds::config::{EditorConfig, EditorKeybindingMode}; use crate::state::pages::canvas_state::CanvasState; +use crate::components::common::text_editor::{TextEditor, VimState}; // Add VimState import use std::cell::RefCell; use std::rc::Rc; use tui_textarea::TextArea; -// Removed unused Style, Color imports if not used in Default for TextArea styling -// use ratatui::style::{Color, Style}; // Keep if you add custom styling in default() #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AddLogicFocus { @@ -31,13 +31,13 @@ pub struct AddLogicState { pub target_column_cursor_pos: usize, pub description_cursor_pos: usize, pub has_unsaved_changes: bool, + pub editor_keybinding_mode: EditorKeybindingMode, + pub vim_state: VimState, // Add this field } -impl Default for AddLogicState { - fn default() -> Self { - let editor = TextArea::default(); // No 'mut' needed if not modified further here - // Example: editor.set_placeholder_text("Enter script..."); - // Example: editor.set_line_number_style(Style::default().fg(Color::DarkGray)); +impl AddLogicState { + pub fn new(editor_config: &EditorConfig) -> Self { + let editor = TextEditor::new_textarea(editor_config); AddLogicState { profile_name: "default".to_string(), selected_table_id: None, @@ -51,15 +51,21 @@ impl Default for AddLogicState { target_column_cursor_pos: 0, description_cursor_pos: 0, has_unsaved_changes: false, + editor_keybinding_mode: editor_config.keybinding_mode.clone(), + vim_state: VimState::default(), // Add this field initialization } } -} -// ... rest of the CanvasState impl remains the same -impl AddLogicState { pub const INPUT_FIELD_COUNT: usize = 3; } +impl Default for AddLogicState { + fn default() -> Self { + Self::new(&EditorConfig::default()) + } +} + +// ... rest of the CanvasState implementation remains the same impl CanvasState for AddLogicState { fn current_field(&self) -> usize { match self.current_focus {