canvas compiled for the first time
This commit is contained in:
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -470,6 +470,16 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "canvas"
|
||||||
|
version = "0.4.1"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"crossterm",
|
||||||
|
"ratatui",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cassowary"
|
name = "cassowary"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -541,7 +551,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.3.13"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -591,7 +601,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.3.13"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
@@ -2877,7 +2887,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "search"
|
name = "search"
|
||||||
version = "0.3.13"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"common",
|
"common",
|
||||||
@@ -2976,7 +2986,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "0.3.13"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
|||||||
@@ -46,4 +46,8 @@ rust_decimal_macros = "1.37.1"
|
|||||||
thiserror = "2.0.12"
|
thiserror = "2.0.12"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
|
|
||||||
|
# Canvas crate
|
||||||
|
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||||
|
crossterm = "0.28.1"
|
||||||
|
|
||||||
common = { path = "./common" }
|
common = { path = "./common" }
|
||||||
|
|||||||
160
canvas/CANVAS_MIGRATION.md
Normal file
160
canvas/CANVAS_MIGRATION.md
Normal file
@@ -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.
|
||||||
@@ -10,3 +10,7 @@ repository.workspace = true
|
|||||||
categories.workspace = true
|
categories.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
|||||||
416
canvas/src/actions/edit.rs
Normal file
416
canvas/src/actions/edit.rs
Normal file
@@ -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<S: CanvasState>(
|
||||||
|
action: &str,
|
||||||
|
key: KeyEvent,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<String> {
|
||||||
|
// 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<S: CanvasState>(action: &str, state: &mut S) -> Result<bool> {
|
||||||
|
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<S: CanvasState>(
|
||||||
|
action: &str,
|
||||||
|
key: KeyEvent,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<String> {
|
||||||
|
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<char> = 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<char> = 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<char> = 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<char> = 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<char> = 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<char> = 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<char> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
canvas/src/actions/mod.rs
Normal file
3
canvas/src/actions/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// canvas/src/actions/mod.rs
|
||||||
|
|
||||||
|
pub mod edit;
|
||||||
30
canvas/src/lib.rs
Normal file
30
canvas/src/lib.rs
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
15
canvas/src/modes/highlight.rs
Normal file
15
canvas/src/modes/highlight.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
canvas/src/modes/manager.rs
Normal file
34
canvas/src/modes/manager.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
canvas/src/modes/mod.rs
Normal file
7
canvas/src/modes/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// canvas/src/modes/mod.rs
|
||||||
|
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod manager;
|
||||||
|
|
||||||
|
pub use highlight::HighlightState;
|
||||||
|
pub use manager::{AppMode, ModeManager};
|
||||||
64
canvas/src/state.rs
Normal file
64
canvas/src/state.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// canvas/src/state.rs
|
||||||
|
|
||||||
|
/// Context passed to feature-specific action handlers
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ActionContext {
|
||||||
|
pub key_code: Option<crossterm::event::KeyCode>,
|
||||||
|
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<usize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn set_selected_suggestion_index(&mut self, _index: Option<usize>) {
|
||||||
|
// Default: no-op (override if you support suggestions)
|
||||||
|
}
|
||||||
|
fn activate_suggestions(&mut self, _suggestions: Vec<String>) {
|
||||||
|
// 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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
67
canvas/src/suggestions.rs
Normal file
67
canvas/src/suggestions.rs
Normal file
@@ -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<String>,
|
||||||
|
pub selected_index: Option<usize>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub trigger_chars: Vec<char>, // 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<char>) -> Self {
|
||||||
|
Self {
|
||||||
|
trigger_chars,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn activate_with_suggestions(&mut self, suggestions: Vec<String>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,17 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.98"
|
anyhow = { workspace = true }
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
crossterm = { workspace = true }
|
||||||
prost-types = { workspace = true }
|
prost-types = { workspace = true }
|
||||||
crossterm = "0.28.1"
|
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
prost = "0.13.5"
|
prost = "0.13.5"
|
||||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
time = "0.3.41"
|
time = "0.3.41"
|
||||||
@@ -30,7 +30,7 @@ unicode-width = "0.2.0"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
ui-debug = []
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.25.0"
|
rstest = "0.25.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user