From 15922ed953bd309046972a8c3f1fd4a9993330db Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 29 Jul 2025 09:35:17 +0200 Subject: [PATCH] canvas compiled for the first time --- Cargo.lock | 18 +- Cargo.toml | 4 + canvas/CANVAS_MIGRATION.md | 160 +++++++++++++ canvas/Cargo.toml | 4 + canvas/src/actions/edit.rs | 416 ++++++++++++++++++++++++++++++++++ canvas/src/actions/mod.rs | 3 + canvas/src/lib.rs | 30 +++ canvas/src/modes/highlight.rs | 15 ++ canvas/src/modes/manager.rs | 34 +++ canvas/src/modes/mod.rs | 7 + canvas/src/state.rs | 64 ++++++ canvas/src/suggestions.rs | 67 ++++++ client/Cargo.toml | 8 +- 13 files changed, 822 insertions(+), 8 deletions(-) create mode 100644 canvas/CANVAS_MIGRATION.md create mode 100644 canvas/src/actions/edit.rs create mode 100644 canvas/src/actions/mod.rs create mode 100644 canvas/src/lib.rs create mode 100644 canvas/src/modes/highlight.rs create mode 100644 canvas/src/modes/manager.rs create mode 100644 canvas/src/modes/mod.rs create mode 100644 canvas/src/state.rs create mode 100644 canvas/src/suggestions.rs diff --git a/Cargo.lock b/Cargo.lock index 9a4fcd4..acafab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,16 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "canvas" +version = "0.4.1" +dependencies = [ + "anyhow", + "common", + "crossterm", + "ratatui", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -541,7 +551,7 @@ dependencies = [ [[package]] name = "client" -version = "0.3.13" +version = "0.4.1" dependencies = [ "anyhow", "async-trait", @@ -591,7 +601,7 @@ dependencies = [ [[package]] name = "common" -version = "0.3.13" +version = "0.4.1" dependencies = [ "prost", "prost-types", @@ -2877,7 +2887,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "search" -version = "0.3.13" +version = "0.4.1" dependencies = [ "anyhow", "common", @@ -2976,7 +2986,7 @@ dependencies = [ [[package]] name = "server" -version = "0.3.13" +version = "0.4.1" dependencies = [ "anyhow", "bcrypt", diff --git a/Cargo.toml b/Cargo.toml index 3e8c641..69e461d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,4 +46,8 @@ rust_decimal_macros = "1.37.1" thiserror = "2.0.12" regex = "1.11.1" +# Canvas crate +ratatui = { version = "0.29.0", features = ["crossterm"] } +crossterm = "0.28.1" + common = { path = "./common" } diff --git a/canvas/CANVAS_MIGRATION.md b/canvas/CANVAS_MIGRATION.md new file mode 100644 index 0000000..9581797 --- /dev/null +++ b/canvas/CANVAS_MIGRATION.md @@ -0,0 +1,160 @@ +# Canvas Crate Migration Documentation + +## Files Moved from Client to Canvas + +### Core Canvas Files + +| **Canvas Location** | **Original Client Location** | **Purpose** | +|-------------------|---------------------------|-----------| +| `canvas/src/state.rs` | `client/src/state/pages/canvas_state.rs` | Core CanvasState trait | +| `canvas/src/actions/edit.rs` | `client/src/functions/modes/edit/form_e.rs` | Generic edit actions | +| `canvas/src/renderer.rs` | `client/src/components/handlers/canvas.rs` | Canvas rendering logic | +| `canvas/src/modes/highlight.rs` | `client/src/state/app/highlight.rs` | Highlight state types | +| `canvas/src/modes/manager.rs` | `client/src/modes/handlers/mode_manager.rs` | Mode management | + +## Import Replacements Needed in Client + +### 1. CanvasState Trait Usage +**Replace these imports:** +```rust +// OLD +use crate::state::pages::canvas_state::CanvasState; + +// NEW +use canvas::CanvasState; +``` + +**Files that need updating:** +- `src/modes/canvas/edit.rs` - Line 9 +- `src/modes/canvas/read_only.rs` - Line 5 +- `src/ui/handlers/render.rs` - Line 17 +- `src/state/pages/auth.rs` - All CanvasState impls +- `src/state/pages/form.rs` - CanvasState impl +- `src/state/pages/add_table.rs` - CanvasState impl +- `src/state/pages/add_logic.rs` - CanvasState impl + +### 2. Edit Actions Usage +**Replace these imports:** +```rust +// OLD +use crate::functions::modes::edit::form_e::{execute_edit_action, execute_common_action}; + +// NEW +use canvas::{execute_edit_action, execute_common_action}; +``` + +**Files that need updating:** +- `src/modes/canvas/edit.rs` - Lines 3-5 +- `src/functions/modes/edit/auth_e.rs` +- `src/functions/modes/edit/add_table_e.rs` +- `src/functions/modes/edit/add_logic_e.rs` + +### 3. Canvas Rendering Usage +**Replace these imports:** +```rust +// OLD +use crate::components::handlers::canvas::render_canvas; + +// NEW +use canvas::render_canvas; +``` + +**Files that need updating:** +- Any component that renders forms (login, register, add_table, add_logic, forms) + +### 4. Mode System Usage +**Replace these imports:** +```rust +// OLD +use crate::modes::handlers::mode_manager::{AppMode, ModeManager}; +use crate::state::app::highlight::HighlightState; + +// NEW +use canvas::{AppMode, ModeManager, HighlightState}; +``` + +**Files that need updating:** +- `src/modes/handlers/event.rs` - Line 14 +- `src/ui/handlers/ui.rs` - Mode derivation calls +- All mode handling files + +## Theme Integration Required + +The canvas crate expects a `CanvasTheme` trait. You need to implement this for your existing theme: + +```rust +// In client/src/config/colors/themes.rs +use canvas::CanvasTheme; +use ratatui::style::Color; + +impl CanvasTheme for Theme { + fn primary_fg(&self) -> Color { self.fg } + fn primary_bg(&self) -> Color { self.bg } + fn accent(&self) -> Color { self.accent } + fn warning(&self) -> Color { self.warning } + fn secondary(&self) -> Color { self.secondary } + fn highlight(&self) -> Color { self.highlight } + fn highlight_bg(&self) -> Color { self.highlight_bg } +} +``` + +## Systematic Replacement Strategy + +### Phase 1: Fix Compilation (Do This First) +1. Update `client/Cargo.toml` to depend on canvas +2. Add theme implementation +3. Replace imports in core files + +### Phase 2: Replace Feature-Specific Usage +1. Update auth components +2. Update form components +3. Update admin components +4. Update mode handlers + +### Phase 3: Remove Old Files (After Everything Works) +1. Delete `src/state/pages/canvas_state.rs` +2. Delete `src/functions/modes/edit/form_e.rs` +3. Delete `src/components/handlers/canvas.rs` +4. Delete `src/state/app/highlight.rs` +5. Delete `src/modes/handlers/mode_manager.rs` + +## Files Safe to Delete After Migration + +**These can be removed once imports are updated:** +- `client/src/state/pages/canvas_state.rs` +- `client/src/functions/modes/edit/form_e.rs` +- `client/src/components/handlers/canvas.rs` +- `client/src/state/app/highlight.rs` +- `client/src/modes/handlers/mode_manager.rs` + +## Quick Start Commands + +```bash +# 1. Add canvas dependency to client +cd client +echo 'canvas = { path = "../canvas" }' >> Cargo.toml + +# 2. Test compilation +cargo check + +# 3. Fix imports one file at a time +# Start with: src/config/colors/themes.rs (add CanvasTheme impl) +# Then: src/modes/canvas/edit.rs (replace form_e imports) +# Then: src/modes/canvas/read_only.rs (replace canvas_state import) + +# 4. After all imports fixed, delete old files +rm src/state/pages/canvas_state.rs +rm src/functions/modes/edit/form_e.rs +rm src/components/handlers/canvas.rs +rm src/state/app/highlight.rs +rm src/modes/handlers/mode_manager.rs +``` + +## Expected Compilation Errors + +You'll get errors like: +- `cannot find type 'CanvasState' in this scope` +- `cannot find function 'execute_edit_action' in this scope` +- `cannot find type 'AppMode' in this scope` + +Fix these by replacing the imports as documented above. diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index da63ae3..8657cd8 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -10,3 +10,7 @@ repository.workspace = true categories.workspace = true [dependencies] +common = { path = "../common" } +ratatui = { workspace = true } +crossterm = { workspace = true } +anyhow = { workspace = true } diff --git a/canvas/src/actions/edit.rs b/canvas/src/actions/edit.rs new file mode 100644 index 0000000..1b5ea92 --- /dev/null +++ b/canvas/src/actions/edit.rs @@ -0,0 +1,416 @@ +// canvas/src/actions/edit.rs + +use crate::state::CanvasState; +use crossterm::event::{KeyCode, KeyEvent}; +use anyhow::Result; + +/// Execute a generic edit action on any CanvasState implementation. +/// This is the core function that makes the mode system work across all features. +pub async fn execute_edit_action( + action: &str, + key: KeyEvent, + state: &mut S, + ideal_cursor_column: &mut usize, +) -> Result { + // 1. Try feature-specific handler first (for autocomplete, field-specific logic, etc.) + let context = crate::state::ActionContext { + key_code: Some(key.code), + ideal_cursor_column: *ideal_cursor_column, + current_input: state.get_current_input().to_string(), + current_field: state.current_field(), + }; + + if let Some(result) = state.handle_feature_action(action, &context) { + return Ok(result); + } + + // 2. Handle suggestion-related actions generically + if handle_suggestion_actions(action, state)? { + return Ok("".to_string()); // Suggestion action handled + } + + // 3. Fall back to generic canvas actions (handles 95% of all actions) + handle_generic_action(action, key, state, ideal_cursor_column).await +} + +/// Handle suggestion/autocomplete actions generically +fn handle_suggestion_actions(action: &str, state: &mut S) -> Result { + match action { + "suggestion_down" => { + if let Some(suggestions) = state.get_suggestions() { + if !suggestions.is_empty() { + let current = state.get_selected_suggestion_index().unwrap_or(0); + let next = (current + 1) % suggestions.len(); + state.set_selected_suggestion_index(Some(next)); + return Ok(true); + } + } + Ok(false) + } + "suggestion_up" => { + if let Some(suggestions) = state.get_suggestions() { + if !suggestions.is_empty() { + let current = state.get_selected_suggestion_index().unwrap_or(0); + let prev = if current == 0 { suggestions.len() - 1 } else { current - 1 }; + state.set_selected_suggestion_index(Some(prev)); + return Ok(true); + } + } + Ok(false) + } + "select_suggestion" => { + // Let feature handle this via handle_feature_action since it's feature-specific + Ok(false) + } + "exit_suggestions" => { + state.deactivate_suggestions(); + Ok(true) + } + _ => Ok(false) + } +} + +/// Handle generic canvas actions (movement, editing, etc.) +async fn handle_generic_action( + action: &str, + key: KeyEvent, + state: &mut S, + ideal_cursor_column: &mut usize, +) -> Result { + match action { + "insert_char" => { + if let KeyCode::Char(c) = key.code { + let cursor_pos = state.current_cursor_pos(); + let field_value = state.get_current_input_mut(); + let mut chars: Vec = field_value.chars().collect(); + if cursor_pos <= chars.len() { + chars.insert(cursor_pos, c); + *field_value = chars.into_iter().collect(); + state.set_current_cursor_pos(cursor_pos + 1); + state.set_has_unsaved_changes(true); + *ideal_cursor_column = state.current_cursor_pos(); + } + } else { + return Ok("Error: insert_char called without a char key.".to_string()); + } + Ok("".to_string()) + } + + "delete_char_backward" => { + if state.current_cursor_pos() > 0 { + let cursor_pos = state.current_cursor_pos(); + let field_value = state.get_current_input_mut(); + let mut chars: Vec = field_value.chars().collect(); + if cursor_pos <= chars.len() { + chars.remove(cursor_pos - 1); + *field_value = chars.into_iter().collect(); + let new_pos = cursor_pos - 1; + state.set_current_cursor_pos(new_pos); + state.set_has_unsaved_changes(true); + *ideal_cursor_column = new_pos; + } + } + Ok("".to_string()) + } + + "delete_char_forward" => { + let cursor_pos = state.current_cursor_pos(); + let field_value = state.get_current_input_mut(); + let mut chars: Vec = field_value.chars().collect(); + if cursor_pos < chars.len() { + chars.remove(cursor_pos); + *field_value = chars.into_iter().collect(); + state.set_has_unsaved_changes(true); + *ideal_cursor_column = cursor_pos; + } + Ok("".to_string()) + } + + "next_field" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + let current_field = state.current_field(); + let new_field = (current_field + 1) % num_fields; + state.set_current_field(new_field); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("".to_string()) + } + + "prev_field" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + let current_field = state.current_field(); + let new_field = if current_field == 0 { + num_fields - 1 + } else { + current_field - 1 + }; + state.set_current_field(new_field); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("".to_string()) + } + + "move_left" => { + let new_pos = state.current_cursor_pos().saturating_sub(1); + state.set_current_cursor_pos(new_pos); + *ideal_cursor_column = new_pos; + Ok("".to_string()) + } + + "move_right" => { + let current_input = state.get_current_input(); + let current_pos = state.current_cursor_pos(); + if current_pos < current_input.len() { + let new_pos = current_pos + 1; + state.set_current_cursor_pos(new_pos); + *ideal_cursor_column = new_pos; + } + Ok("".to_string()) + } + + "move_up" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + let current_field = state.current_field(); + let new_field = current_field.saturating_sub(1); + state.set_current_field(new_field); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("".to_string()) + } + + "move_down" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + let new_field = (state.current_field() + 1).min(num_fields - 1); + state.set_current_field(new_field); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("".to_string()) + } + + "move_line_start" => { + state.set_current_cursor_pos(0); + *ideal_cursor_column = 0; + Ok("".to_string()) + } + + "move_line_end" => { + let current_input = state.get_current_input(); + let new_pos = current_input.len(); + state.set_current_cursor_pos(new_pos); + *ideal_cursor_column = new_pos; + Ok("".to_string()) + } + + "move_first_line" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + state.set_current_field(0); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("Moved to first field".to_string()) + } + + "move_last_line" => { + let num_fields = state.fields().len(); + if num_fields > 0 { + let new_field = num_fields - 1; + state.set_current_field(new_field); + let current_input = state.get_current_input(); + let max_pos = current_input.len(); + state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos)); + } + Ok("Moved to last field".to_string()) + } + + "move_word_next" => { + let current_input = state.get_current_input(); + if !current_input.is_empty() { + let new_pos = find_next_word_start(current_input, state.current_cursor_pos()); + let final_pos = new_pos.min(current_input.len()); + state.set_current_cursor_pos(final_pos); + *ideal_cursor_column = final_pos; + } + Ok("".to_string()) + } + + "move_word_end" => { + let current_input = state.get_current_input(); + if !current_input.is_empty() { + let current_pos = state.current_cursor_pos(); + let new_pos = find_word_end(current_input, current_pos); + + let final_pos = if new_pos == current_pos { + find_word_end(current_input, new_pos + 1) + } else { + new_pos + }; + + let max_valid_index = current_input.len().saturating_sub(1); + let clamped_pos = final_pos.min(max_valid_index); + state.set_current_cursor_pos(clamped_pos); + *ideal_cursor_column = clamped_pos; + } + Ok("".to_string()) + } + + "move_word_prev" => { + let current_input = state.get_current_input(); + if !current_input.is_empty() { + let new_pos = find_prev_word_start(current_input, state.current_cursor_pos()); + state.set_current_cursor_pos(new_pos); + *ideal_cursor_column = new_pos; + } + Ok("".to_string()) + } + + "move_word_end_prev" => { + let current_input = state.get_current_input(); + if !current_input.is_empty() { + let new_pos = find_prev_word_end(current_input, state.current_cursor_pos()); + state.set_current_cursor_pos(new_pos); + *ideal_cursor_column = new_pos; + } + Ok("Moved to previous word end".to_string()) + } + + _ => Ok(format!("Unknown or unhandled edit action: {}", action)), + } +} + +// Word movement helper functions +#[derive(PartialEq)] +enum CharType { + Whitespace, + Alphanumeric, + Punctuation, +} + +fn get_char_type(c: char) -> CharType { + if c.is_whitespace() { + CharType::Whitespace + } else if c.is_alphanumeric() { + CharType::Alphanumeric + } else { + CharType::Punctuation + } +} + +fn find_next_word_start(text: &str, current_pos: usize) -> usize { + let chars: Vec = text.chars().collect(); + let len = chars.len(); + if len == 0 || current_pos >= len { + return len; + } + + let mut pos = current_pos; + let initial_type = get_char_type(chars[pos]); + + while pos < len && get_char_type(chars[pos]) == initial_type { + pos += 1; + } + + while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { + pos += 1; + } + + pos +} + +fn find_word_end(text: &str, current_pos: usize) -> usize { + let chars: Vec = text.chars().collect(); + let len = chars.len(); + if len == 0 { + return 0; + } + + let mut pos = current_pos.min(len - 1); + + if get_char_type(chars[pos]) == CharType::Whitespace { + pos = find_next_word_start(text, pos); + } + + if pos >= len { + return len.saturating_sub(1); + } + + let word_type = get_char_type(chars[pos]); + while pos < len && get_char_type(chars[pos]) == word_type { + pos += 1; + } + + pos.saturating_sub(1).min(len.saturating_sub(1)) +} + +fn find_prev_word_start(text: &str, current_pos: usize) -> usize { + let chars: Vec = text.chars().collect(); + if chars.is_empty() || current_pos == 0 { + return 0; + } + + let mut pos = current_pos.saturating_sub(1); + + while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { + pos -= 1; + } + + if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { + return 0; + } + + let word_type = get_char_type(chars[pos]); + while pos > 0 && get_char_type(chars[pos - 1]) == word_type { + pos -= 1; + } + + pos +} + +fn find_prev_word_end(text: &str, current_pos: usize) -> usize { + let chars: Vec = text.chars().collect(); + let len = chars.len(); + if len == 0 || current_pos == 0 { + return 0; + } + + let mut pos = current_pos.saturating_sub(1); + + while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { + pos -= 1; + } + + if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { + return 0; + } + if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace { + return 0; + } + + let word_type = get_char_type(chars[pos]); + while pos > 0 && get_char_type(chars[pos - 1]) == word_type { + pos -= 1; + } + + while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { + pos -= 1; + } + + if pos > 0 { + pos - 1 + } else { + 0 + } +} diff --git a/canvas/src/actions/mod.rs b/canvas/src/actions/mod.rs new file mode 100644 index 0000000..079ea3c --- /dev/null +++ b/canvas/src/actions/mod.rs @@ -0,0 +1,3 @@ +// canvas/src/actions/mod.rs + +pub mod edit; diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs new file mode 100644 index 0000000..bfeeed2 --- /dev/null +++ b/canvas/src/lib.rs @@ -0,0 +1,30 @@ +// canvas/src/lib.rs + +//! Canvas - A reusable text editing and form canvas system +//! +//! This crate provides a generic canvas abstraction for building text-based interfaces +//! with multiple input fields, cursor management, and mode-based editing. + +pub mod state; +pub mod actions; +pub mod modes; +pub mod suggestions; + +// Re-export the main types for easy use +pub use state::{CanvasState, ActionContext}; +pub use actions::edit::execute_edit_action; +pub use modes::{AppMode, ModeManager, HighlightState}; +pub use suggestions::SuggestionState; + +// High-level convenience API +pub mod prelude { + pub use crate::{ + CanvasState, + ActionContext, + execute_edit_action, + AppMode, + ModeManager, + HighlightState, + SuggestionState, + }; +} diff --git a/canvas/src/modes/highlight.rs b/canvas/src/modes/highlight.rs new file mode 100644 index 0000000..55c5a18 --- /dev/null +++ b/canvas/src/modes/highlight.rs @@ -0,0 +1,15 @@ +// src/state/app/highlight.rs +// canvas/src/modes/highlight.rs + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HighlightState { + Off, + Characterwise { anchor: (usize, usize) }, // (field_index, char_position) + Linewise { anchor_line: usize }, // field_index +} + +impl Default for HighlightState { + fn default() -> Self { + HighlightState::Off + } +} diff --git a/canvas/src/modes/manager.rs b/canvas/src/modes/manager.rs new file mode 100644 index 0000000..5c8a15e --- /dev/null +++ b/canvas/src/modes/manager.rs @@ -0,0 +1,34 @@ +// src/modes/handlers/mode_manager.rs +// canvas/src/modes/manager.rs + +use crate::modes::highlight::HighlightState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppMode { + General, // For intro and admin screens + ReadOnly, // Canvas read-only mode + Edit, // Canvas edit mode + Highlight, // Canvas highlight/visual mode + Command, // Command mode overlay +} + +pub struct ModeManager; + +impl ModeManager { + // Mode transition rules + pub fn can_enter_command_mode(current_mode: AppMode) -> bool { + !matches!(current_mode, AppMode::Edit) + } + + pub fn can_enter_edit_mode(current_mode: AppMode) -> bool { + matches!(current_mode, AppMode::ReadOnly) + } + + pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool { + matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight) + } + + pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool { + matches!(current_mode, AppMode::ReadOnly) + } +} diff --git a/canvas/src/modes/mod.rs b/canvas/src/modes/mod.rs new file mode 100644 index 0000000..9389af2 --- /dev/null +++ b/canvas/src/modes/mod.rs @@ -0,0 +1,7 @@ +// canvas/src/modes/mod.rs + +pub mod highlight; +pub mod manager; + +pub use highlight::HighlightState; +pub use manager::{AppMode, ModeManager}; diff --git a/canvas/src/state.rs b/canvas/src/state.rs new file mode 100644 index 0000000..f14cd53 --- /dev/null +++ b/canvas/src/state.rs @@ -0,0 +1,64 @@ +// canvas/src/state.rs + +/// Context passed to feature-specific action handlers +#[derive(Debug)] +pub struct ActionContext { + pub key_code: Option, + pub ideal_cursor_column: usize, + pub current_input: String, + pub current_field: usize, +} + +/// Core trait that any form-like state must implement to work with the canvas system. +/// This enables the same mode behaviors (edit, read-only, highlight) to work across +/// any implementation - login forms, data entry forms, configuration screens, etc. +pub trait CanvasState { + // --- Core Navigation --- + fn current_field(&self) -> usize; + fn current_cursor_pos(&self) -> usize; + fn set_current_field(&mut self, index: usize); + fn set_current_cursor_pos(&mut self, pos: usize); + + // --- Data Access --- + fn get_current_input(&self) -> &str; + fn get_current_input_mut(&mut self) -> &mut String; + fn inputs(&self) -> Vec<&String>; + fn fields(&self) -> Vec<&str>; + + // --- State Management --- + fn has_unsaved_changes(&self) -> bool; + fn set_has_unsaved_changes(&mut self, changed: bool); + + // --- Autocomplete/Suggestions (Optional) --- + fn get_suggestions(&self) -> Option<&[String]> { + None + } + fn get_selected_suggestion_index(&self) -> Option { + None + } + fn set_selected_suggestion_index(&mut self, _index: Option) { + // Default: no-op (override if you support suggestions) + } + fn activate_suggestions(&mut self, _suggestions: Vec) { + // Default: no-op (override if you support suggestions) + } + fn deactivate_suggestions(&mut self) { + // Default: no-op (override if you support suggestions) + } + + // --- Feature-specific action handling --- + fn handle_feature_action(&mut self, action: &str, context: &ActionContext) -> Option { + None // Default: no feature-specific handling + } + + // --- Display Overrides (for links, computed values, etc.) --- + fn get_display_value_for_field(&self, index: usize) -> &str { + self.inputs() + .get(index) + .map(|s| s.as_str()) + .unwrap_or("") + } + fn has_display_override(&self, _index: usize) -> bool { + false + } +} diff --git a/canvas/src/suggestions.rs b/canvas/src/suggestions.rs new file mode 100644 index 0000000..9da86f2 --- /dev/null +++ b/canvas/src/suggestions.rs @@ -0,0 +1,67 @@ +// canvas/src/suggestions.rs + +/// Generic suggestion system that can be implemented by any CanvasState +#[derive(Debug, Clone)] +pub struct SuggestionState { + pub suggestions: Vec, + pub selected_index: Option, + pub is_active: bool, + pub trigger_chars: Vec, // Characters that trigger suggestions +} + +impl Default for SuggestionState { + fn default() -> Self { + Self { + suggestions: Vec::new(), + selected_index: None, + is_active: false, + trigger_chars: vec![], // No auto-trigger by default + } + } +} + +impl SuggestionState { + pub fn new(trigger_chars: Vec) -> Self { + Self { + trigger_chars, + ..Default::default() + } + } + + pub fn activate_with_suggestions(&mut self, suggestions: Vec) { + self.suggestions = suggestions; + self.is_active = !self.suggestions.is_empty(); + self.selected_index = if self.is_active { Some(0) } else { None }; + } + + pub fn deactivate(&mut self) { + self.suggestions.clear(); + self.selected_index = None; + self.is_active = false; + } + + pub fn select_next(&mut self) { + if !self.suggestions.is_empty() { + let current = self.selected_index.unwrap_or(0); + self.selected_index = Some((current + 1) % self.suggestions.len()); + } + } + + pub fn select_previous(&mut self) { + if !self.suggestions.is_empty() { + let current = self.selected_index.unwrap_or(0); + self.selected_index = Some( + if current == 0 { self.suggestions.len() - 1 } else { current - 1 } + ); + } + } + + pub fn get_selected(&self) -> Option<&String> { + self.selected_index + .and_then(|idx| self.suggestions.get(idx)) + } + + pub fn should_trigger(&self, c: char) -> bool { + self.trigger_chars.contains(&c) + } +} diff --git a/client/Cargo.toml b/client/Cargo.toml index 6c66592..a39f1ec 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -5,17 +5,17 @@ edition.workspace = true license.workspace = true [dependencies] -anyhow = "1.0.98" +anyhow = { workspace = true } async-trait = "0.1.88" common = { path = "../common" } +ratatui = { workspace = true } +crossterm = { workspace = true } prost-types = { workspace = true } -crossterm = "0.28.1" dirs = "6.0.0" dotenvy = "0.15.7" lazy_static = "1.5.0" prost = "0.13.5" -ratatui = { version = "0.29.0", features = ["crossterm"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" time = "0.3.41" @@ -30,7 +30,7 @@ unicode-width = "0.2.0" [features] default = [] -ui-debug = [] + [dev-dependencies] rstest = "0.25.0"