Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f99aa79ec | ||
|
|
c594c35b37 | ||
|
|
828a63c30c | ||
|
|
36690e674a | ||
|
|
8788323c62 | ||
|
|
5b64996462 | ||
|
|
3f4380ff48 | ||
|
|
59a29aa54b | ||
|
|
5d084bf822 | ||
|
|
ebe4adaa5d | ||
|
|
c3441647e0 | ||
|
|
574803988d | ||
|
|
9ff3c59961 | ||
|
|
c5f22d7da1 | ||
|
|
3c62877757 | ||
|
|
cc19c61f37 | ||
|
|
ad82bd4302 | ||
|
|
d584a25fdb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@
|
||||
server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -475,10 +475,12 @@ name = "canvas"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"common",
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"toml",
|
||||
|
||||
@@ -14,13 +14,15 @@ common = { path = "../common" }
|
||||
ratatui = { workspace = true, optional = true }
|
||||
crossterm = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
unicode-width.workspace = true
|
||||
thiserror = { workspace = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
async-trait = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
@@ -28,3 +30,14 @@ tokio-test = "0.4.4"
|
||||
[features]
|
||||
default = []
|
||||
gui = ["ratatui"]
|
||||
autocomplete = ["tokio", "async-trait"]
|
||||
|
||||
[[example]]
|
||||
name = "autocomplete"
|
||||
required-features = ["autocomplete", "gui"]
|
||||
path = "examples/autocomplete.rs"
|
||||
|
||||
[[example]]
|
||||
name = "canvas_gui_demo"
|
||||
required-features = ["gui"]
|
||||
path = "examples/canvas_gui_demo.rs"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
trigger_autocomplete = ["Ctrl+p"]
|
||||
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
77
canvas/docs/new_function_to_config.txt
Normal file
77
canvas/docs/new_function_to_config.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
❯ git status
|
||||
On branch main
|
||||
Your branch is ahead of 'origin/main' by 1 commit.
|
||||
(use "git push" to publish your local commits)
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: src/canvas/actions/handlers/edit.rs
|
||||
modified: src/canvas/actions/types.rs
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")
|
||||
❯ git --no-pager diff
|
||||
diff --git a/canvas/src/canvas/actions/handlers/edit.rs b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
index a26fe6f..fa1becb 100644
|
||||
--- a/canvas/src/canvas/actions/handlers/edit.rs
|
||||
+++ b/canvas/src/canvas/actions/handlers/edit.rs
|
||||
@@ -29,6 +29,21 @@ pub async fn handle_edit_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
+ CanvasAction::SelectAll => {
|
||||
+ // Select all text in current field
|
||||
+ let current_input = state.get_current_input();
|
||||
+ let text_length = current_input.len();
|
||||
+
|
||||
+ // Set cursor to start and select all
|
||||
+ state.set_current_cursor_pos(0);
|
||||
+ // TODO: You'd need to add selection state to CanvasState trait
|
||||
+ // For now, just move cursor to end to "select" all
|
||||
+ state.set_current_cursor_pos(text_length);
|
||||
+ *ideal_cursor_column = text_length;
|
||||
+
|
||||
+ Ok(ActionResult::success_with_message(&format!("Selected all {} characters", text_length)))
|
||||
+ }
|
||||
+
|
||||
CanvasAction::DeleteBackward => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
@@ -323,6 +338,13 @@ impl ActionHandlerIntrospection for EditHandler {
|
||||
is_required: false,
|
||||
});
|
||||
|
||||
+ actions.push(ActionSpec {
|
||||
+ name: "select_all".to_string(),
|
||||
+ description: "Select all text in current field".to_string(),
|
||||
+ examples: vec!["Ctrl+a".to_string()],
|
||||
+ is_required: false, // Optional action
|
||||
+ });
|
||||
+
|
||||
HandlerCapabilities {
|
||||
mode_name: "edit".to_string(),
|
||||
actions,
|
||||
diff --git a/canvas/src/canvas/actions/types.rs b/canvas/src/canvas/actions/types.rs
|
||||
index 433a4d5..3794596 100644
|
||||
--- a/canvas/src/canvas/actions/types.rs
|
||||
+++ b/canvas/src/canvas/actions/types.rs
|
||||
@@ -31,6 +31,8 @@ pub enum CanvasAction {
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
+ SelectAll,
|
||||
+
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
@@ -62,6 +64,7 @@ impl CanvasAction {
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
+ "select_all" => Self::SelectAll,
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
╭─ ~/Doc/p/komp_ac/canvas on main ⇡1 !2
|
||||
╰─
|
||||
|
||||
417
canvas/examples/autocomplete.rs
Normal file
417
canvas/examples/autocomplete.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
// examples/autocomplete.rs
|
||||
// Run with: cargo run --example autocomplete --features "autocomplete,gui"
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas,
|
||||
modes::AppMode,
|
||||
state::{ActionContext, CanvasState},
|
||||
theme::CanvasTheme,
|
||||
},
|
||||
autocomplete::{
|
||||
AutocompleteCanvasState,
|
||||
AutocompleteState,
|
||||
SuggestionItem,
|
||||
execute_with_autocomplete,
|
||||
handle_autocomplete_feature_action,
|
||||
},
|
||||
CanvasAction,
|
||||
};
|
||||
|
||||
// Add the async_trait import
|
||||
use async_trait::async_trait;
|
||||
|
||||
// Simple theme implementation
|
||||
#[derive(Clone)]
|
||||
struct DemoTheme;
|
||||
|
||||
impl CanvasTheme for DemoTheme {
|
||||
fn bg(&self) -> Color { Color::Reset }
|
||||
fn fg(&self) -> Color { Color::White }
|
||||
fn accent(&self) -> Color { Color::Cyan }
|
||||
fn secondary(&self) -> Color { Color::Gray }
|
||||
fn highlight(&self) -> Color { Color::Yellow }
|
||||
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||
fn warning(&self) -> Color { Color::Red }
|
||||
fn border(&self) -> Color { Color::Gray }
|
||||
}
|
||||
|
||||
// Custom suggestion data type
|
||||
#[derive(Clone, Debug)]
|
||||
struct EmailSuggestion {
|
||||
email: String,
|
||||
provider: String,
|
||||
}
|
||||
|
||||
// Demo form state with autocomplete
|
||||
struct AutocompleteFormState {
|
||||
fields: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
mode: AppMode,
|
||||
has_changes: bool,
|
||||
debug_message: String,
|
||||
|
||||
// Autocomplete state
|
||||
autocomplete: AutocompleteState<EmailSuggestion>,
|
||||
}
|
||||
|
||||
impl AutocompleteFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
"John Doe".to_string(),
|
||||
"john@".to_string(), // Partial email to demonstrate autocomplete
|
||||
"+1 234 567 8900".to_string(),
|
||||
"San Francisco".to_string(),
|
||||
],
|
||||
field_names: vec![
|
||||
"Name".to_string(),
|
||||
"Email".to_string(),
|
||||
"Phone".to_string(),
|
||||
"City".to_string(),
|
||||
],
|
||||
current_field: 1, // Start on email field
|
||||
cursor_pos: 5, // Position after "john@"
|
||||
mode: AppMode::Edit,
|
||||
has_changes: false,
|
||||
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
|
||||
autocomplete: AutocompleteState::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for AutocompleteFormState {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
self.current_field = index.min(self.fields.len().saturating_sub(1));
|
||||
// Clear autocomplete when changing fields
|
||||
if self.is_autocomplete_active() {
|
||||
self.clear_autocomplete_suggestions();
|
||||
}
|
||||
}
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
let max_pos = if self.mode == AppMode::Edit {
|
||||
self.fields[self.current_field].len()
|
||||
} else {
|
||||
self.fields[self.current_field].len().saturating_sub(1)
|
||||
};
|
||||
self.cursor_pos = pos.min(max_pos);
|
||||
}
|
||||
fn current_mode(&self) -> AppMode { self.mode }
|
||||
fn get_current_input(&self) -> &str { &self.fields[self.current_field] }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.fields[self.current_field] }
|
||||
fn inputs(&self) -> Vec<&String> { self.fields.iter().collect() }
|
||||
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
// Handle autocomplete actions first
|
||||
if let Some(result) = handle_autocomplete_feature_action(action, self) {
|
||||
return Some(result);
|
||||
}
|
||||
|
||||
// Handle other custom actions
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => {
|
||||
match cmd.as_str() {
|
||||
"toggle_mode" => {
|
||||
self.mode = match self.mode {
|
||||
AppMode::Edit => AppMode::ReadOnly,
|
||||
AppMode::ReadOnly => AppMode::Edit,
|
||||
_ => AppMode::Edit,
|
||||
};
|
||||
Some(format!("Switched to {:?} mode", self.mode))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the #[async_trait] attribute to the implementation
|
||||
#[async_trait]
|
||||
impl AutocompleteCanvasState for AutocompleteFormState {
|
||||
type SuggestionData = EmailSuggestion;
|
||||
|
||||
fn supports_autocomplete(&self, field_index: usize) -> bool {
|
||||
// Only enable autocomplete for email field (index 1)
|
||||
field_index == 1
|
||||
}
|
||||
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&self.autocomplete)
|
||||
}
|
||||
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
Some(&mut self.autocomplete)
|
||||
}
|
||||
|
||||
fn should_trigger_autocomplete(&self) -> bool {
|
||||
let current_input = self.get_current_input();
|
||||
let current_field = self.current_field();
|
||||
|
||||
// Trigger for email field when we have "@" and at least 1 more character
|
||||
self.supports_autocomplete(current_field) &&
|
||||
current_input.contains('@') &&
|
||||
current_input.len() > current_input.find('@').unwrap_or(0) + 1 &&
|
||||
!self.is_autocomplete_active()
|
||||
}
|
||||
|
||||
/// This is where the magic happens - user implements their own async fetching
|
||||
async fn trigger_autocomplete_suggestions(&mut self) {
|
||||
// 1. Activate UI (shows loading spinner)
|
||||
self.activate_autocomplete();
|
||||
self.set_autocomplete_loading(true);
|
||||
|
||||
// 2. Get current input for querying
|
||||
let query = self.get_current_input().to_string();
|
||||
|
||||
// 3. Extract domain part from email
|
||||
let domain_part = if let Some(at_pos) = query.find('@') {
|
||||
query[at_pos + 1..].to_string()
|
||||
} else {
|
||||
self.set_autocomplete_loading(false);
|
||||
return; // No @ symbol, can't suggest
|
||||
};
|
||||
|
||||
// 4. SIMULATE ASYNC API CALL (in real code, this would be HTTP request)
|
||||
let email_prefix = query[..query.find('@').unwrap()].to_string();
|
||||
let suggestions = tokio::task::spawn_blocking(move || {
|
||||
// Simulate network delay
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
// Create mock suggestions based on domain input
|
||||
let popular_domains = vec![
|
||||
("gmail.com", "Gmail"),
|
||||
("yahoo.com", "Yahoo Mail"),
|
||||
("outlook.com", "Outlook"),
|
||||
("hotmail.com", "Hotmail"),
|
||||
("company.com", "Company Email"),
|
||||
("university.edu", "University"),
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (domain, provider) in popular_domains {
|
||||
if domain.starts_with(&domain_part) || domain_part.is_empty() {
|
||||
let full_email = format!("{}@{}", email_prefix, domain);
|
||||
results.push(SuggestionItem::new(
|
||||
EmailSuggestion {
|
||||
email: full_email.clone(),
|
||||
provider: provider.to_string(),
|
||||
},
|
||||
format!("{} ({})", full_email, provider), // display text
|
||||
full_email, // value to store
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}).await.unwrap_or_default();
|
||||
|
||||
// 5. Provide suggestions back to library
|
||||
self.set_autocomplete_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AutocompleteFormState) -> bool {
|
||||
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
|
||||
return false; // Quit
|
||||
}
|
||||
|
||||
let action = match key {
|
||||
// === AUTOCOMPLETE KEYS ===
|
||||
KeyCode::Tab => {
|
||||
if state.is_autocomplete_active() {
|
||||
Some(CanvasAction::SuggestionDown) // Navigate suggestions
|
||||
} else if state.supports_autocomplete(state.current_field()) {
|
||||
Some(CanvasAction::TriggerAutocomplete) // Manual trigger
|
||||
} else {
|
||||
Some(CanvasAction::NextField) // Normal tab
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::BackTab => {
|
||||
if state.is_autocomplete_active() {
|
||||
Some(CanvasAction::SuggestionUp)
|
||||
} else {
|
||||
Some(CanvasAction::PrevField)
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Enter => {
|
||||
if state.is_autocomplete_active() {
|
||||
Some(CanvasAction::SelectSuggestion) // Apply suggestion
|
||||
} else {
|
||||
Some(CanvasAction::NextField)
|
||||
}
|
||||
}
|
||||
|
||||
KeyCode::Esc => {
|
||||
if state.is_autocomplete_active() {
|
||||
Some(CanvasAction::ExitSuggestions) // Close autocomplete
|
||||
} else {
|
||||
Some(CanvasAction::Custom("toggle_mode".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// === STANDARD CANVAS KEYS ===
|
||||
KeyCode::Left => Some(CanvasAction::MoveLeft),
|
||||
KeyCode::Right => Some(CanvasAction::MoveRight),
|
||||
KeyCode::Up => Some(CanvasAction::MoveUp),
|
||||
KeyCode::Down => Some(CanvasAction::MoveDown),
|
||||
KeyCode::Home => Some(CanvasAction::MoveLineStart),
|
||||
KeyCode::End => Some(CanvasAction::MoveLineEnd),
|
||||
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
||||
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
||||
|
||||
// Character input
|
||||
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(CanvasAction::InsertChar(c))
|
||||
}
|
||||
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(action) = action {
|
||||
match execute_with_autocomplete(action.clone(), state).await {
|
||||
Ok(result) => {
|
||||
if let Some(msg) = result.message() {
|
||||
state.debug_message = msg.to_string();
|
||||
} else {
|
||||
state.debug_message = format!("Executed: {:?}", action);
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
state.debug_message = format!("Error: {}", e);
|
||||
true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.debug_message = format!("Unhandled key: {:?}", key);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AutocompleteFormState) -> io::Result<()> {
|
||||
let theme = DemoTheme;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, state: &AutocompleteFormState, theme: &DemoTheme) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas form
|
||||
let active_field_rect = render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
state,
|
||||
theme,
|
||||
state.mode == AppMode::Edit,
|
||||
&canvas::HighlightState::Off,
|
||||
);
|
||||
|
||||
// Render autocomplete dropdown on top if active
|
||||
if let Some(input_rect) = active_field_rect {
|
||||
canvas::render_autocomplete_dropdown(
|
||||
f,
|
||||
chunks[0],
|
||||
input_rect,
|
||||
theme,
|
||||
&state.autocomplete,
|
||||
);
|
||||
}
|
||||
|
||||
// Status info
|
||||
let autocomplete_status = if state.is_autocomplete_active() {
|
||||
if state.autocomplete.is_loading {
|
||||
"Loading suggestions..."
|
||||
} else if state.has_autocomplete_suggestions() {
|
||||
"Use Tab/Shift+Tab to navigate, Enter to select, Esc to cancel"
|
||||
} else {
|
||||
"No suggestions found"
|
||||
}
|
||||
} else {
|
||||
"Tab to trigger autocomplete"
|
||||
};
|
||||
|
||||
let status_lines = vec![
|
||||
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
|
||||
state.mode, state.current_field + 1, state.fields.len(), state.cursor_pos))),
|
||||
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
|
||||
Line::from(Span::raw(state.debug_message.clone())),
|
||||
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
|
||||
];
|
||||
|
||||
let status = Paragraph::new(status_lines)
|
||||
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
|
||||
|
||||
f.render_widget(status, chunks[1]);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let state = AutocompleteFormState::new();
|
||||
|
||||
let res = run_app(&mut terminal, state).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
388
canvas/examples/canvas_gui_demo.rs
Normal file
388
canvas/examples/canvas_gui_demo.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
// examples/canvas_gui_demo.rs
|
||||
|
||||
use std::io;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use canvas::{
|
||||
canvas::{
|
||||
gui::render_canvas,
|
||||
modes::{AppMode, HighlightState, ModeManager},
|
||||
state::{ActionContext, CanvasState},
|
||||
theme::CanvasTheme,
|
||||
},
|
||||
CanvasAction, execute,
|
||||
};
|
||||
|
||||
// Simple theme implementation
|
||||
#[derive(Clone)]
|
||||
struct DemoTheme;
|
||||
|
||||
impl CanvasTheme for DemoTheme {
|
||||
fn bg(&self) -> Color { Color::Reset }
|
||||
fn fg(&self) -> Color { Color::White }
|
||||
fn accent(&self) -> Color { Color::Cyan }
|
||||
fn secondary(&self) -> Color { Color::Gray }
|
||||
fn highlight(&self) -> Color { Color::Yellow }
|
||||
fn highlight_bg(&self) -> Color { Color::DarkGray }
|
||||
fn warning(&self) -> Color { Color::Red }
|
||||
fn border(&self) -> Color { Color::Gray }
|
||||
}
|
||||
|
||||
// Demo form state
|
||||
struct DemoFormState {
|
||||
fields: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
mode: AppMode,
|
||||
highlight_state: HighlightState,
|
||||
has_changes: bool,
|
||||
debug_message: String,
|
||||
}
|
||||
|
||||
impl DemoFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
fields: vec![
|
||||
"John Doe".to_string(),
|
||||
"john.doe@example.com".to_string(),
|
||||
"+1 234 567 8900".to_string(),
|
||||
"123 Main Street Apt 4B".to_string(),
|
||||
"San Francisco".to_string(),
|
||||
"This is a test comment with multiple words".to_string(),
|
||||
],
|
||||
field_names: vec![
|
||||
"Name".to_string(),
|
||||
"Email".to_string(),
|
||||
"Phone".to_string(),
|
||||
"Address".to_string(),
|
||||
"City".to_string(),
|
||||
"Comments".to_string(),
|
||||
],
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
mode: AppMode::ReadOnly,
|
||||
highlight_state: HighlightState::Off,
|
||||
has_changes: false,
|
||||
debug_message: "Ready - Use hjkl to move, w for next word, i to edit".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_edit_mode(&mut self) {
|
||||
if ModeManager::can_enter_edit_mode(self.mode) {
|
||||
self.mode = AppMode::Edit;
|
||||
self.debug_message = "Entered EDIT mode".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_readonly_mode(&mut self) {
|
||||
if ModeManager::can_enter_read_only_mode(self.mode) {
|
||||
self.mode = AppMode::ReadOnly;
|
||||
self.highlight_state = HighlightState::Off;
|
||||
self.debug_message = "Entered READ-ONLY mode".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_highlight_mode(&mut self) {
|
||||
if ModeManager::can_enter_highlight_mode(self.mode) {
|
||||
self.mode = AppMode::Highlight;
|
||||
self.highlight_state = HighlightState::Characterwise {
|
||||
anchor: (self.current_field, self.cursor_pos),
|
||||
};
|
||||
self.debug_message = "Entered VISUAL mode".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for DemoFormState {
|
||||
fn current_field(&self) -> usize {
|
||||
self.current_field
|
||||
}
|
||||
|
||||
fn current_cursor_pos(&self) -> usize {
|
||||
self.cursor_pos
|
||||
}
|
||||
|
||||
fn set_current_field(&mut self, index: usize) {
|
||||
self.current_field = index.min(self.fields.len().saturating_sub(1));
|
||||
self.cursor_pos = self.fields[self.current_field].len();
|
||||
}
|
||||
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) {
|
||||
let max_pos = self.fields[self.current_field].len();
|
||||
self.cursor_pos = pos.min(max_pos);
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
fn get_current_input(&self) -> &str {
|
||||
&self.fields[self.current_field]
|
||||
}
|
||||
|
||||
fn get_current_input_mut(&mut self) -> &mut String {
|
||||
&mut self.fields[self.current_field]
|
||||
}
|
||||
|
||||
fn inputs(&self) -> Vec<&String> {
|
||||
self.fields.iter().collect()
|
||||
}
|
||||
|
||||
fn fields(&self) -> Vec<&str> {
|
||||
self.field_names.iter().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool {
|
||||
self.has_changes
|
||||
}
|
||||
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||
self.has_changes = changed;
|
||||
}
|
||||
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(cmd) => {
|
||||
match cmd.as_str() {
|
||||
"enter_edit_mode" => {
|
||||
self.enter_edit_mode();
|
||||
Some("Entered edit mode".to_string())
|
||||
}
|
||||
"enter_readonly_mode" => {
|
||||
self.enter_readonly_mode();
|
||||
Some("Entered read-only mode".to_string())
|
||||
}
|
||||
"enter_highlight_mode" => {
|
||||
self.enter_highlight_mode();
|
||||
Some("Entered highlight mode".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple key mapping - users have full control!
|
||||
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut DemoFormState) -> bool {
|
||||
let is_edit_mode = state.mode == AppMode::Edit;
|
||||
|
||||
// Handle quit first
|
||||
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) ||
|
||||
key == KeyCode::F(10) {
|
||||
return false; // Signal to quit
|
||||
}
|
||||
|
||||
// Users directly map keys to actions - no configuration needed!
|
||||
let action = match (state.mode, key, modifiers) {
|
||||
// === READ-ONLY MODE KEYS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('h'), _) => Some(CanvasAction::MoveLeft),
|
||||
(AppMode::ReadOnly, KeyCode::Char('j'), _) => Some(CanvasAction::MoveDown),
|
||||
(AppMode::ReadOnly, KeyCode::Char('k'), _) => Some(CanvasAction::MoveUp),
|
||||
(AppMode::ReadOnly, KeyCode::Char('l'), _) => Some(CanvasAction::MoveRight),
|
||||
(AppMode::ReadOnly, KeyCode::Char('w'), _) => Some(CanvasAction::MoveWordNext),
|
||||
(AppMode::ReadOnly, KeyCode::Char('b'), _) => Some(CanvasAction::MoveWordPrev),
|
||||
(AppMode::ReadOnly, KeyCode::Char('e'), _) => Some(CanvasAction::MoveWordEnd),
|
||||
(AppMode::ReadOnly, KeyCode::Char('0'), _) => Some(CanvasAction::MoveLineStart),
|
||||
(AppMode::ReadOnly, KeyCode::Char('$'), _) => Some(CanvasAction::MoveLineEnd),
|
||||
(AppMode::ReadOnly, KeyCode::Tab, _) => Some(CanvasAction::NextField),
|
||||
(AppMode::ReadOnly, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
|
||||
|
||||
// === EDIT MODE KEYS ===
|
||||
(AppMode::Edit, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
|
||||
(AppMode::Edit, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
|
||||
(AppMode::Edit, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
|
||||
(AppMode::Edit, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
|
||||
(AppMode::Edit, KeyCode::Home, _) => Some(CanvasAction::MoveLineStart),
|
||||
(AppMode::Edit, KeyCode::End, _) => Some(CanvasAction::MoveLineEnd),
|
||||
(AppMode::Edit, KeyCode::Backspace, _) => Some(CanvasAction::DeleteBackward),
|
||||
(AppMode::Edit, KeyCode::Delete, _) => Some(CanvasAction::DeleteForward),
|
||||
(AppMode::Edit, KeyCode::Tab, _) => Some(CanvasAction::NextField),
|
||||
(AppMode::Edit, KeyCode::BackTab, _) => Some(CanvasAction::PrevField),
|
||||
|
||||
// Vim-style movement in edit mode (optional)
|
||||
(AppMode::Edit, KeyCode::Char('h'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveLeft),
|
||||
(AppMode::Edit, KeyCode::Char('l'), m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveRight),
|
||||
|
||||
// Word movement with Ctrl in edit mode
|
||||
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordPrev),
|
||||
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => Some(CanvasAction::MoveWordNext),
|
||||
|
||||
// === MODE TRANSITIONS ===
|
||||
(AppMode::ReadOnly, KeyCode::Char('i'), _) => Some(CanvasAction::Custom("enter_edit_mode".to_string())),
|
||||
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
|
||||
// 'a' moves to end of line then enters edit mode
|
||||
if let Ok(_) = execute(CanvasAction::MoveLineEnd, state).await {
|
||||
Some(CanvasAction::Custom("enter_edit_mode".to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
(AppMode::ReadOnly, KeyCode::Char('v'), _) => Some(CanvasAction::Custom("enter_highlight_mode".to_string())),
|
||||
(_, KeyCode::Esc, _) => Some(CanvasAction::Custom("enter_readonly_mode".to_string())),
|
||||
|
||||
// === CHARACTER INPUT IN EDIT MODE ===
|
||||
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
|
||||
Some(CanvasAction::InsertChar(c))
|
||||
},
|
||||
|
||||
// === ARROW KEYS IN READ-ONLY MODE ===
|
||||
(AppMode::ReadOnly, KeyCode::Left, _) => Some(CanvasAction::MoveLeft),
|
||||
(AppMode::ReadOnly, KeyCode::Right, _) => Some(CanvasAction::MoveRight),
|
||||
(AppMode::ReadOnly, KeyCode::Up, _) => Some(CanvasAction::MoveUp),
|
||||
(AppMode::ReadOnly, KeyCode::Down, _) => Some(CanvasAction::MoveDown),
|
||||
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Execute the action if we found one
|
||||
if let Some(action) = action {
|
||||
match execute(action.clone(), state).await {
|
||||
Ok(result) => {
|
||||
if result.is_success() {
|
||||
// Mark as changed for editing actions
|
||||
if is_edit_mode {
|
||||
match action {
|
||||
CanvasAction::InsertChar(_) | CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
|
||||
state.set_has_unsaved_changes(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(msg) = result.message() {
|
||||
state.debug_message = msg.to_string();
|
||||
} else {
|
||||
state.debug_message = format!("Executed: {}", action.description());
|
||||
}
|
||||
} else if let Some(msg) = result.message() {
|
||||
state.debug_message = format!("Error: {}", msg);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
state.debug_message = format!("Error executing action: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.debug_message = format!("Unhandled key: {:?} (mode: {:?})", key, state.mode);
|
||||
}
|
||||
|
||||
true // Continue running
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState) -> io::Result<()> {
|
||||
let theme = DemoTheme;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &state, &theme))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
|
||||
if !should_continue {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
// Render the canvas form
|
||||
render_canvas(
|
||||
f,
|
||||
chunks[0],
|
||||
state,
|
||||
theme,
|
||||
state.mode == AppMode::Edit,
|
||||
&state.highlight_state,
|
||||
);
|
||||
|
||||
// Render status bar
|
||||
let mode_text = match state.mode {
|
||||
AppMode::Edit => "EDIT",
|
||||
AppMode::ReadOnly => "NORMAL",
|
||||
AppMode::Highlight => "VISUAL",
|
||||
AppMode::General => "GENERAL",
|
||||
AppMode::Command => "COMMAND",
|
||||
};
|
||||
|
||||
let status_text = if state.has_changes {
|
||||
format!("-- {} -- [Modified]", mode_text)
|
||||
} else {
|
||||
format!("-- {} --", mode_text)
|
||||
};
|
||||
|
||||
let position_text = format!("Field: {}/{} | Cursor: {} | Actions: {}",
|
||||
state.current_field + 1,
|
||||
state.fields.len(),
|
||||
state.cursor_pos,
|
||||
CanvasAction::movement_actions().len() + CanvasAction::editing_actions().len());
|
||||
|
||||
let help_text = match state.mode {
|
||||
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | i/a: Edit | v: Visual | F10: Quit",
|
||||
AppMode::Edit => "Type to edit | Arrows/Ctrl+arrows: Move | Tab: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
|
||||
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
|
||||
_ => "Esc: Normal | F10: Quit",
|
||||
};
|
||||
|
||||
let status = Paragraph::new(vec![
|
||||
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
|
||||
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
|
||||
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
|
||||
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
|
||||
])
|
||||
.block(Block::default().borders(Borders::ALL).title("Status"));
|
||||
|
||||
f.render_widget(status, chunks[1]);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let state = DemoFormState::new();
|
||||
|
||||
let res = run_app(&mut terminal, state).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,133 +1,170 @@
|
||||
// canvas/src/autocomplete/actions.rs
|
||||
// src/autocomplete/actions.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::edit::handle_generic_canvas_action;
|
||||
use crate::config::CanvasConfig;
|
||||
use crate::canvas::actions::execute;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Version for states that implement rich autocomplete
|
||||
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
|
||||
/// Enhanced execute function for states that support autocomplete
|
||||
/// This is the main entry point for autocomplete-aware canvas execution
|
||||
///
|
||||
/// Use this instead of canvas::execute() if you want autocomplete behavior:
|
||||
/// ```rust
|
||||
/// execute_with_autocomplete(action, &mut state).await?;
|
||||
/// ```
|
||||
pub async fn execute_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
config: Option<&CanvasConfig>,
|
||||
) -> Result<ActionResult> {
|
||||
// 1. Try feature-specific handler first
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
ideal_cursor_column: *ideal_cursor_column,
|
||||
current_input: state.get_current_input().to_string(),
|
||||
current_field: state.current_field(),
|
||||
};
|
||||
match &action {
|
||||
// === AUTOCOMPLETE-SPECIFIC ACTIONS ===
|
||||
|
||||
if let Some(result) = handle_rich_autocomplete_action(action.clone(), state, &context) {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// 2. Handle generic actions and add auto-trigger logic
|
||||
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
|
||||
|
||||
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
|
||||
if let Some(cfg) = config {
|
||||
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
||||
if cfg.should_auto_trigger_autocomplete() {
|
||||
println!("AUTO-TRIGGER");
|
||||
match action {
|
||||
CanvasAction::InsertChar(_) => {
|
||||
println!("AUTO-T on Ins");
|
||||
let current_field = state.current_field();
|
||||
let current_input = state.get_current_input();
|
||||
|
||||
if state.supports_autocomplete(current_field)
|
||||
&& !state.is_autocomplete_active()
|
||||
&& current_input.len() >= 1
|
||||
{
|
||||
println!("ACT AUTOC");
|
||||
state.activate_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
println!("AUTO-T on nav");
|
||||
let current_field = state.current_field();
|
||||
|
||||
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
|
||||
state.activate_autocomplete();
|
||||
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
|
||||
state.deactivate_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
_ => {} // No auto-trigger for other actions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
||||
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
_context: &ActionContext,
|
||||
) -> Option<ActionResult> {
|
||||
match action {
|
||||
CanvasAction::TriggerAutocomplete => {
|
||||
let current_field = state.current_field();
|
||||
if state.supports_autocomplete(current_field) {
|
||||
state.activate_autocomplete();
|
||||
Some(ActionResult::success_with_message("Autocomplete activated"))
|
||||
if state.supports_autocomplete(state.current_field()) {
|
||||
state.trigger_autocomplete_suggestions().await;
|
||||
Ok(ActionResult::success_with_message("Triggered autocomplete"))
|
||||
} else {
|
||||
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
|
||||
Ok(ActionResult::success_with_message("Autocomplete not supported for this field"))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionUp => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_previous();
|
||||
}
|
||||
Some(ActionResult::success())
|
||||
if state.has_autocomplete_suggestions() {
|
||||
state.move_suggestion_selection(-1);
|
||||
Ok(ActionResult::success())
|
||||
} else {
|
||||
Some(ActionResult::success_with_message("No suggestions available"))
|
||||
Ok(ActionResult::success_with_message("No suggestions available"))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionDown => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
}
|
||||
Some(ActionResult::success())
|
||||
if state.has_autocomplete_suggestions() {
|
||||
state.move_suggestion_selection(1);
|
||||
Ok(ActionResult::success())
|
||||
} else {
|
||||
Some(ActionResult::success_with_message("No suggestions available"))
|
||||
Ok(ActionResult::success_with_message("No suggestions available"))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(msg) = state.apply_autocomplete_selection() {
|
||||
Some(ActionResult::success_with_message(&msg))
|
||||
if let Some(message) = state.apply_selected_suggestion() {
|
||||
Ok(ActionResult::success_with_message(&message))
|
||||
} else {
|
||||
Ok(ActionResult::success_with_message("No suggestion to select"))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
state.clear_autocomplete_suggestions();
|
||||
Ok(ActionResult::success_with_message("Closed autocomplete"))
|
||||
}
|
||||
|
||||
// === TEXT INSERTION WITH AUTO-TRIGGER ===
|
||||
|
||||
CanvasAction::InsertChar(_) => {
|
||||
// First, execute the character insertion normally
|
||||
let result = execute(action, state).await?;
|
||||
|
||||
// After successful insertion, check if we should auto-trigger autocomplete
|
||||
if result.is_success() && state.should_trigger_autocomplete() {
|
||||
state.trigger_autocomplete_suggestions().await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// === NAVIGATION/EDITING ACTIONS (clear autocomplete first) ===
|
||||
|
||||
CanvasAction::MoveLeft | CanvasAction::MoveRight |
|
||||
CanvasAction::MoveUp | CanvasAction::MoveDown |
|
||||
CanvasAction::NextField | CanvasAction::PrevField |
|
||||
CanvasAction::DeleteBackward | CanvasAction::DeleteForward => {
|
||||
// Clear autocomplete when navigating/editing
|
||||
if state.is_autocomplete_active() {
|
||||
state.clear_autocomplete_suggestions();
|
||||
}
|
||||
|
||||
// Execute the action normally
|
||||
execute(action, state).await
|
||||
}
|
||||
|
||||
// === ALL OTHER ACTIONS (normal execution) ===
|
||||
|
||||
_ => {
|
||||
// For all other actions, just execute normally
|
||||
execute(action, state).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to integrate autocomplete actions with CanvasState.handle_feature_action()
|
||||
///
|
||||
/// Use this in your CanvasState implementation like this:
|
||||
/// ```rust
|
||||
/// fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||
/// // Try autocomplete first
|
||||
/// if let Some(result) = handle_autocomplete_feature_action(action, self) {
|
||||
/// return Some(result);
|
||||
/// }
|
||||
///
|
||||
/// // Handle your other custom actions...
|
||||
/// None
|
||||
/// }
|
||||
/// ```
|
||||
pub fn handle_autocomplete_feature_action<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||
action: &CanvasAction,
|
||||
state: &S,
|
||||
) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::TriggerAutocomplete => {
|
||||
if state.supports_autocomplete(state.current_field()) {
|
||||
if state.is_autocomplete_active() {
|
||||
Some("Autocomplete already active".to_string())
|
||||
} else {
|
||||
Some(ActionResult::success_with_message("No suggestion selected"))
|
||||
None // Let execute_with_autocomplete handle it
|
||||
}
|
||||
} else {
|
||||
Some(ActionResult::success_with_message("No suggestions available"))
|
||||
Some("Autocomplete not available for this field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown => {
|
||||
if state.is_autocomplete_active() {
|
||||
None // Let execute_with_autocomplete handle navigation
|
||||
} else {
|
||||
Some("No autocomplete suggestions to navigate".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
if state.has_autocomplete_suggestions() {
|
||||
None // Let execute_with_autocomplete handle selection
|
||||
} else {
|
||||
Some("No suggestion to select".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
if state.is_autocomplete_active() {
|
||||
state.deactivate_autocomplete();
|
||||
Some(ActionResult::success_with_message("Exited autocomplete"))
|
||||
None // Let execute_with_autocomplete handle exit
|
||||
} else {
|
||||
Some(ActionResult::success())
|
||||
Some("No autocomplete to close".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
_ => None, // Not a rich autocomplete action
|
||||
_ => None // Not an autocomplete action
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy compatibility function - kept for backward compatibility
|
||||
/// This is the old function signature, now it just wraps the new system
|
||||
#[deprecated(note = "Use execute_with_autocomplete instead")]
|
||||
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState + Send>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
_ideal_cursor_column: &mut usize, // Ignored - new system manages this internally
|
||||
_config: Option<&()>, // Ignored - no more config system
|
||||
) -> Result<ActionResult> {
|
||||
execute_with_autocomplete(action, state).await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// canvas/src/autocomplete/gui.rs
|
||||
// src/autocomplete/gui.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
@@ -8,6 +8,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
// Use the correct import from our types module
|
||||
use crate::autocomplete::types::AutocompleteState;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
@@ -18,12 +19,12 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
autocomplete_state: &AutocompleteState<D>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
return;
|
||||
@@ -68,12 +69,12 @@ fn render_loading_indicator<T: CanvasTheme>(
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
fn render_suggestions_dropdown<T: CanvasTheme, D: Clone + Send + 'static>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
autocomplete_state: &AutocompleteState<D>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
// src/autocomplete/mod.rs
|
||||
|
||||
pub mod types;
|
||||
pub mod gui;
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
|
||||
// Re-export autocomplete types
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
// Re-export the main autocomplete API
|
||||
pub use types::{SuggestionItem, AutocompleteState};
|
||||
pub use state::AutocompleteCanvasState;
|
||||
pub use actions::execute_canvas_action_with_autocomplete;
|
||||
|
||||
// Re-export the new action functions
|
||||
pub use actions::{
|
||||
execute_with_autocomplete,
|
||||
handle_autocomplete_feature_action,
|
||||
};
|
||||
|
||||
// Re-export GUI functions if available
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::render_autocomplete_dropdown;
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
// canvas/src/state.rs
|
||||
// src/autocomplete/state.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
|
||||
/// Only implement this if you need the new autocomplete features.
|
||||
///
|
||||
/// # User Workflow:
|
||||
/// 1. User presses trigger key (Tab, Ctrl+K, etc.)
|
||||
/// 2. User's key mapping calls CanvasAction::TriggerAutocomplete
|
||||
/// 3. Library calls your trigger_autocomplete_suggestions() method
|
||||
/// 4. You implement async fetching logic in that method
|
||||
/// 5. You call set_autocomplete_suggestions() with results
|
||||
/// 6. Library manages UI state and navigation
|
||||
#[async_trait]
|
||||
pub trait AutocompleteCanvasState: CanvasState {
|
||||
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
|
||||
type SuggestionData: Clone + Send + 'static;
|
||||
|
||||
/// Check if a field supports autocomplete
|
||||
/// Check if a field supports autocomplete (user decides which fields)
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
false // Default: no autocomplete support
|
||||
}
|
||||
@@ -23,74 +33,157 @@ pub trait AutocompleteCanvasState: CanvasState {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// CLIENT API: Activate autocomplete for current field
|
||||
// === PUBLIC API METHODS (called by library) ===
|
||||
|
||||
/// Activate autocomplete for current field (shows loading spinner)
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field(); // Get field first
|
||||
let current_field = self.current_field();
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.activate(current_field); // Then use it
|
||||
state.activate(current_field);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Deactivate autocomplete
|
||||
/// Deactivate autocomplete (hides dropdown)
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set suggestions (called after async fetch completes)
|
||||
/// Set suggestions (called after your async fetch completes)
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<crate::autocomplete::SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set loading state
|
||||
/// Set loading state (show/hide spinner)
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if autocomplete is currently active
|
||||
// === QUERY METHODS ===
|
||||
|
||||
/// Check if autocomplete is currently active/visible
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction
|
||||
/// Check if autocomplete has suggestions ready for navigation
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_ready())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the selected value and display text (if any)
|
||||
let selection_info = if let Some(state) = self.autocomplete_state() {
|
||||
state.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
/// Check if there are available suggestions
|
||||
fn has_autocomplete_suggestions(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| !state.suggestions.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Apply the selection if we have one
|
||||
if let Some((value, display)) = selection_info {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
// === USER-IMPLEMENTABLE METHODS ===
|
||||
|
||||
// Deactivate autocomplete
|
||||
if let Some(state_mut) = self.autocomplete_state_mut() {
|
||||
state_mut.deactivate();
|
||||
/// Check if autocomplete should be triggered automatically (e.g., after typing 2+ chars)
|
||||
/// Override this to implement your own trigger logic
|
||||
fn should_trigger_autocomplete(&self) -> bool {
|
||||
let current_input = self.get_current_input();
|
||||
let current_field = self.current_field();
|
||||
|
||||
self.supports_autocomplete(current_field) &&
|
||||
current_input.len() >= 2 && // Default: trigger after 2 chars
|
||||
!self.is_autocomplete_active()
|
||||
}
|
||||
|
||||
/// **USER MUST IMPLEMENT**: Trigger autocomplete suggestions (async)
|
||||
/// This is where you implement your API calls, caching, etc.
|
||||
///
|
||||
/// # Example Implementation:
|
||||
/// ```rust
|
||||
/// #[async_trait]
|
||||
/// impl AutocompleteCanvasState for MyState {
|
||||
/// type SuggestionData = MyData;
|
||||
///
|
||||
/// async fn trigger_autocomplete_suggestions(&mut self) {
|
||||
/// self.activate_autocomplete(); // Show loading state
|
||||
///
|
||||
/// let query = self.get_current_input().to_string();
|
||||
/// let suggestions = my_api.search(&query).await.unwrap_or_default();
|
||||
///
|
||||
/// self.set_autocomplete_suggestions(suggestions);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
async fn trigger_autocomplete_suggestions(&mut self) {
|
||||
// Activate autocomplete UI
|
||||
self.activate_autocomplete();
|
||||
|
||||
// Default: just show loading state
|
||||
// User should override this to do actual async fetching
|
||||
self.set_autocomplete_loading(true);
|
||||
|
||||
// In a real implementation, you'd:
|
||||
// 1. Get current input: let query = self.get_current_input();
|
||||
// 2. Make API call: let results = api.search(query).await;
|
||||
// 3. Convert to suggestions: let suggestions = results.into_suggestions();
|
||||
// 4. Set suggestions: self.set_autocomplete_suggestions(suggestions);
|
||||
}
|
||||
|
||||
// === INTERNAL NAVIGATION METHODS (called by library actions) ===
|
||||
|
||||
/// Clear autocomplete suggestions and hide dropdown
|
||||
fn clear_autocomplete_suggestions(&mut self) {
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
|
||||
/// Move selection up/down in suggestions list
|
||||
fn move_suggestion_selection(&mut self, direction: i32) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
if direction > 0 {
|
||||
state.select_next();
|
||||
} else {
|
||||
state.select_previous();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!("Selected: {}", display))
|
||||
/// Get currently selected suggestion for display/application
|
||||
fn get_selected_suggestion(&self) -> Option<crate::autocomplete::SuggestionItem<Self::SuggestionData>> {
|
||||
self.autocomplete_state()?
|
||||
.get_selected()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Apply the selected suggestion to the current field
|
||||
fn apply_suggestion(&mut self, suggestion: &crate::autocomplete::SuggestionItem<Self::SuggestionData>) {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = suggestion.value_to_store.clone();
|
||||
self.set_has_unsaved_changes(true);
|
||||
|
||||
// Clear autocomplete
|
||||
self.clear_autocomplete_suggestions();
|
||||
}
|
||||
|
||||
/// Apply the currently selected suggestion (convenience method)
|
||||
fn apply_selected_suggestion(&mut self) -> Option<String> {
|
||||
if let Some(suggestion) = self.get_selected_suggestion() {
|
||||
let display_text = suggestion.display_text.clone();
|
||||
self.apply_suggestion(&suggestion);
|
||||
Some(format!("Applied: {}", display_text))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// === LEGACY COMPATIBILITY ===
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field (legacy method)
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
self.apply_selected_suggestion()
|
||||
}
|
||||
}
|
||||
|
||||
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal file
43
canvas/src/canvas/actions/handlers/dispatcher.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/canvas/actions/handlers/dispatcher.rs
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::actions::{CanvasAction, ActionResult};
|
||||
use crate::canvas::modes::AppMode;
|
||||
use anyhow::Result;
|
||||
|
||||
use super::{handle_edit_action, handle_readonly_action, handle_highlight_action};
|
||||
|
||||
/// Main action dispatcher - routes actions to mode-specific handlers
|
||||
pub async fn dispatch_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
// Check if the application wants to handle this action first
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
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(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
// Route to mode-specific handler
|
||||
match state.current_mode() {
|
||||
AppMode::Edit => {
|
||||
handle_edit_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
AppMode::ReadOnly => {
|
||||
handle_readonly_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
AppMode::Highlight => {
|
||||
handle_highlight_action(action, state, ideal_cursor_column).await
|
||||
}
|
||||
AppMode::General | AppMode::Command => {
|
||||
Ok(ActionResult::success_with_message("Mode does not handle canvas actions directly"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,34 @@
|
||||
// canvas/src/canvas/actions/edit.rs
|
||||
// src/canvas/actions/handlers/edit.rs
|
||||
//! Edit mode action handler
|
||||
//!
|
||||
//! Handles user input when in edit mode, supporting text entry, deletion,
|
||||
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
|
||||
|
||||
use crate::canvas::state::{CanvasState, ActionContext};
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::config::CanvasConfig;
|
||||
use crate::canvas::actions::movement::*;
|
||||
use crate::canvas::state::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a typed canvas action on any CanvasState implementation
|
||||
pub async fn execute_canvas_action<S: CanvasState>(
|
||||
/// Edit mode uses cursor-past-end behavior for text insertion
|
||||
const FOR_EDIT_MODE: bool = true;
|
||||
|
||||
/// Handle actions in edit mode with edit-specific cursor behavior
|
||||
///
|
||||
/// Edit mode allows text modification and uses cursor positioning that can
|
||||
/// go past the end of existing text to facilitate insertion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `action` - The action to perform
|
||||
/// * `state` - Mutable canvas state
|
||||
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
|
||||
pub async fn handle_edit_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
config: Option<&CanvasConfig>,
|
||||
) -> Result<ActionResult> {
|
||||
let context = ActionContext {
|
||||
key_code: None,
|
||||
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(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
handle_generic_canvas_action(action, state, ideal_cursor_column, config).await
|
||||
}
|
||||
|
||||
/// Handle core canvas actions with full type safety
|
||||
pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
config: Option<&CanvasConfig>,
|
||||
) -> Result<ActionResult> {
|
||||
match action {
|
||||
CanvasAction::InsertChar(c) => {
|
||||
// Insert character at cursor position and advance cursor
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
input.insert(cursor_pos, c);
|
||||
@@ -44,35 +38,8 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
let old_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
|
||||
// Perform field navigation
|
||||
let new_field = match action {
|
||||
CanvasAction::NextField => {
|
||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||
(old_field + 1) % total_fields
|
||||
} else {
|
||||
(old_field + 1).min(total_fields - 1)
|
||||
}
|
||||
}
|
||||
CanvasAction::PrevField => {
|
||||
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||
if old_field == 0 { total_fields - 1 } else { old_field - 1 }
|
||||
} else {
|
||||
old_field.saturating_sub(1)
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
state.set_current_field(new_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::DeleteBackward => {
|
||||
// Delete character before cursor (Backspace behavior)
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
let input = state.get_current_input_mut();
|
||||
@@ -85,6 +52,7 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
}
|
||||
|
||||
CanvasAction::DeleteForward => {
|
||||
// Delete character at cursor position (Delete key behavior)
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let input = state.get_current_input_mut();
|
||||
if cursor_pos < input.len() {
|
||||
@@ -94,75 +62,84 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Cursor movement actions
|
||||
CanvasAction::MoveLeft => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
if cursor_pos > 0 {
|
||||
state.set_current_cursor_pos(cursor_pos - 1);
|
||||
*ideal_cursor_column = cursor_pos - 1;
|
||||
}
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let cursor_pos = state.current_cursor_pos();
|
||||
let current_input = state.get_current_input();
|
||||
if cursor_pos < current_input.len() {
|
||||
state.set_current_cursor_pos(cursor_pos + 1);
|
||||
*ideal_cursor_column = cursor_pos + 1;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let end_pos = state.get_current_input().len();
|
||||
state.set_current_cursor_pos(end_pos);
|
||||
*ideal_cursor_column = end_pos;
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Field navigation (treating single-line fields as "lines")
|
||||
CanvasAction::MoveUp => {
|
||||
// For single-line fields, move to previous field
|
||||
let current_field = state.current_field();
|
||||
if current_field > 0 {
|
||||
state.set_current_field(current_field - 1);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
// For single-line fields, move to next field
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
if current_field < total_fields - 1 {
|
||||
state.set_current_field(current_field + 1);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Line-based movement
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Document-level movement (first/last field)
|
||||
CanvasAction::MoveFirstLine => {
|
||||
state.set_current_field(0);
|
||||
state.set_current_cursor_pos(0);
|
||||
*ideal_cursor_column = 0;
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, 0, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let last_field = state.fields().len() - 1;
|
||||
state.set_current_field(last_field);
|
||||
let end_pos = state.get_current_input().len();
|
||||
state.set_current_cursor_pos(end_pos);
|
||||
*ideal_cursor_column = end_pos;
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Word-based movement
|
||||
CanvasAction::MoveWordNext => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
@@ -193,61 +170,44 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
|
||||
CanvasAction::MoveWordEndPrev => {
|
||||
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(ActionResult::success())
|
||||
}
|
||||
|
||||
_ => Ok(ActionResult::success_with_message("Action not implemented")),
|
||||
}
|
||||
}
|
||||
// Field navigation with simple wrapping behavior
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
|
||||
// Helper functions for word navigation
|
||||
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos;
|
||||
|
||||
// Skip current word
|
||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && chars[pos].is_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
let new_field = match action {
|
||||
CanvasAction::NextField => {
|
||||
(current_field + 1) % total_fields // Simple wrap
|
||||
}
|
||||
CanvasAction::PrevField => {
|
||||
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos;
|
||||
|
||||
// Move to end of current word
|
||||
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
state.set_current_field(new_field);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
|
||||
if cursor_pos == 0 {
|
||||
return 0;
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom edit action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for edit mode"))
|
||||
}
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut pos = cursor_pos.saturating_sub(1);
|
||||
|
||||
// Skip whitespace
|
||||
while pos > 0 && chars[pos].is_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Skip to start of word
|
||||
while pos > 0 && chars[pos - 1].is_alphanumeric() {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
104
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
104
canvas/src/canvas/actions/handlers/highlight.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
// src/canvas/actions/handlers/highlight.rs
|
||||
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::movement::*;
|
||||
use crate::canvas::state::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
const FOR_EDIT_MODE: bool = false; // Highlight mode uses read-only cursor behavior
|
||||
|
||||
/// Handle actions in highlight/visual mode
|
||||
/// TODO: Implement selection logic and highlight-specific behaviors
|
||||
pub async fn handle_highlight_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
match action {
|
||||
// Movement actions work similar to read-only mode but with selection
|
||||
CanvasAction::MoveLeft => {
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
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 = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
if !current_input.is_empty() {
|
||||
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
||||
let final_pos = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
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;
|
||||
// TODO: Update selection range
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
// TODO: Update selection range
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Highlight mode doesn't handle editing actions
|
||||
CanvasAction::InsertChar(_) |
|
||||
CanvasAction::DeleteBackward |
|
||||
CanvasAction::DeleteForward => {
|
||||
Ok(ActionResult::success_with_message("Action not available in highlight mode"))
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom highlight action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for highlight mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
11
canvas/src/canvas/actions/handlers/mod.rs
Normal file
11
canvas/src/canvas/actions/handlers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/canvas/actions/handlers/mod.rs
|
||||
|
||||
pub mod edit;
|
||||
pub mod readonly;
|
||||
pub mod highlight;
|
||||
pub mod dispatcher;
|
||||
|
||||
pub use edit::handle_edit_action;
|
||||
pub use readonly::handle_readonly_action;
|
||||
pub use highlight::handle_highlight_action;
|
||||
pub use dispatcher::dispatch_action;
|
||||
183
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
183
canvas/src/canvas/actions/handlers/readonly.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
// src/canvas/actions/handlers/readonly.rs
|
||||
|
||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||
use crate::canvas::actions::movement::*;
|
||||
use crate::canvas::state::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
const FOR_EDIT_MODE: bool = false; // Read-only mode flag
|
||||
|
||||
/// Handle actions in read-only mode with read-only specific cursor behavior
|
||||
pub async fn handle_readonly_action<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<ActionResult> {
|
||||
match action {
|
||||
CanvasAction::MoveLeft => {
|
||||
let new_pos = move_left(state.current_cursor_pos());
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveRight => {
|
||||
let current_input = state.get_current_input();
|
||||
let current_pos = state.current_cursor_pos();
|
||||
let new_pos = move_right(current_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveUp => {
|
||||
let current_field = state.current_field();
|
||||
let new_field = current_field.saturating_sub(1);
|
||||
state.set_current_field(new_field);
|
||||
|
||||
// Apply ideal cursor column with read-only bounds
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveDown => {
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
let new_field = (current_field + 1).min(total_fields - 1);
|
||||
state.set_current_field(new_field);
|
||||
|
||||
// Apply ideal cursor column with read-only bounds
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveFirstLine => {
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
state.set_current_field(0);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLastLine => {
|
||||
let total_fields = state.fields().len();
|
||||
if total_fields == 0 {
|
||||
return Ok(ActionResult::success_with_message("No fields to navigate"));
|
||||
}
|
||||
|
||||
let last_field = total_fields - 1;
|
||||
state.set_current_field(last_field);
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = safe_cursor_position(current_input, *ideal_cursor_column, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineStart => {
|
||||
let new_pos = line_start_position();
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveLineEnd => {
|
||||
let current_input = state.get_current_input();
|
||||
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(new_pos);
|
||||
*ideal_cursor_column = new_pos;
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordNext => {
|
||||
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 = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEnd => {
|
||||
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 = clamp_cursor_position(new_pos, current_input, FOR_EDIT_MODE);
|
||||
state.set_current_cursor_pos(final_pos);
|
||||
*ideal_cursor_column = final_pos;
|
||||
}
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordPrev => {
|
||||
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(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::MoveWordEndPrev => {
|
||||
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(ActionResult::success())
|
||||
}
|
||||
|
||||
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||
let current_field = state.current_field();
|
||||
let total_fields = state.fields().len();
|
||||
|
||||
let new_field = match action {
|
||||
CanvasAction::NextField => {
|
||||
(current_field + 1) % total_fields // Simple wrap
|
||||
}
|
||||
CanvasAction::PrevField => {
|
||||
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Simple wrap
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
state.set_current_field(new_field);
|
||||
*ideal_cursor_column = state.current_cursor_pos();
|
||||
Ok(ActionResult::success())
|
||||
}
|
||||
|
||||
// Read-only mode doesn't handle editing actions
|
||||
CanvasAction::InsertChar(_) |
|
||||
CanvasAction::DeleteBackward |
|
||||
CanvasAction::DeleteForward => {
|
||||
Ok(ActionResult::success_with_message("Action not available in read-only mode"))
|
||||
}
|
||||
|
||||
CanvasAction::Custom(action_str) => {
|
||||
Ok(ActionResult::success_with_message(&format!("Custom readonly action: {}", action_str)))
|
||||
}
|
||||
|
||||
_ => {
|
||||
Ok(ActionResult::success_with_message("Action not implemented for read-only mode"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// canvas/src/canvas/actions/mod.rs
|
||||
pub mod types;
|
||||
pub mod edit;
|
||||
// src/canvas/actions/mod.rs
|
||||
|
||||
// Re-export the main types for convenience
|
||||
pub use types::{CanvasAction, ActionResult};
|
||||
pub use edit::execute_canvas_action;
|
||||
pub mod types;
|
||||
pub mod handlers;
|
||||
pub mod movement;
|
||||
|
||||
// Re-export the main API
|
||||
pub use types::{CanvasAction, ActionResult, execute};
|
||||
|
||||
49
canvas/src/canvas/actions/movement/char.rs
Normal file
49
canvas/src/canvas/actions/movement/char.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/canvas/actions/movement/char.rs
|
||||
|
||||
/// Calculate new position when moving left
|
||||
pub fn move_left(current_pos: usize) -> usize {
|
||||
current_pos.saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Calculate new position when moving right
|
||||
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
// Edit mode: can move past end of text
|
||||
(current_pos + 1).min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: stays within text bounds
|
||||
if current_pos < text.len().saturating_sub(1) {
|
||||
current_pos + 1
|
||||
} else {
|
||||
current_pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cursor position is valid for the given mode
|
||||
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
|
||||
if text.is_empty() {
|
||||
return pos == 0;
|
||||
}
|
||||
|
||||
if for_edit_mode {
|
||||
pos <= text.len()
|
||||
} else {
|
||||
pos < text.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp cursor position to valid bounds for the given mode
|
||||
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
pos.min(text.len())
|
||||
} else {
|
||||
pos.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
32
canvas/src/canvas/actions/movement/line.rs
Normal file
32
canvas/src/canvas/actions/movement/line.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// src/canvas/actions/movement/line.rs
|
||||
|
||||
/// Calculate cursor position for line start
|
||||
pub fn line_start_position() -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Calculate cursor position for line end
|
||||
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end of text
|
||||
text.len()
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays on last character
|
||||
text.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate safe cursor position when switching fields
|
||||
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else if for_edit_mode {
|
||||
// Edit mode: cursor can go past end
|
||||
ideal_column.min(text.len())
|
||||
} else {
|
||||
// Read-only/highlight mode: cursor stays within text
|
||||
ideal_column.min(text.len().saturating_sub(1))
|
||||
}
|
||||
}
|
||||
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
10
canvas/src/canvas/actions/movement/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/canvas/actions/movement/mod.rs
|
||||
|
||||
pub mod word;
|
||||
pub mod line;
|
||||
pub mod char;
|
||||
|
||||
// Re-export commonly used functions
|
||||
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
|
||||
pub use line::{line_start_position, line_end_position, safe_cursor_position};
|
||||
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};
|
||||
146
canvas/src/canvas/actions/movement/word.rs
Normal file
146
canvas/src/canvas/actions/movement/word.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
// src/canvas/actions/movement/word.rs
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the start of the next word from the current position
|
||||
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let current_pos = current_pos.min(chars.len());
|
||||
|
||||
if current_pos == chars.len() {
|
||||
return current_pos;
|
||||
}
|
||||
|
||||
let mut pos = current_pos;
|
||||
let initial_type = get_char_type(chars[pos]);
|
||||
|
||||
// Skip current word/token
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find the end of the current or next word
|
||||
pub 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);
|
||||
let current_type = get_char_type(chars[pos]);
|
||||
|
||||
// If we're not on whitespace, move to end of current word
|
||||
if current_type != CharType::Whitespace {
|
||||
while pos < len && get_char_type(chars[pos]) == current_type {
|
||||
pos += 1;
|
||||
}
|
||||
return pos.saturating_sub(1);
|
||||
}
|
||||
|
||||
// If we're on whitespace, find next word and go to its end
|
||||
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))
|
||||
}
|
||||
|
||||
/// Find the start of the previous word
|
||||
pub 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);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
// Move to start of word
|
||||
if get_char_type(chars[pos]) != CharType::Whitespace {
|
||||
let word_type = get_char_type(chars[pos]);
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
0
|
||||
} else {
|
||||
pos
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the end of the previous word
|
||||
pub fn find_prev_word_end(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);
|
||||
|
||||
// Skip whitespace backwards
|
||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
|
||||
return 0;
|
||||
}
|
||||
if pos == 0 && get_char_type(chars[0]) != 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;
|
||||
}
|
||||
|
||||
// Skip whitespace before this word
|
||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
||||
pos -= 1;
|
||||
}
|
||||
|
||||
if pos > 0 {
|
||||
pos - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,184 @@
|
||||
// src/canvas/actions/types.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use anyhow::Result;
|
||||
|
||||
/// All available canvas actions
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Basic cursor movement
|
||||
// Movement actions
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordPrev,
|
||||
MoveWordEnd,
|
||||
MoveWordEndPrev,
|
||||
|
||||
// Line movement
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
|
||||
// Field navigation
|
||||
|
||||
// Field movement
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
// Editing actions
|
||||
InsertChar(char),
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
// Autocomplete actions
|
||||
TriggerAutocomplete,
|
||||
SuggestionUp,
|
||||
SuggestionDown,
|
||||
SelectSuggestion,
|
||||
ExitSuggestions,
|
||||
|
||||
|
||||
// Custom actions
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl CanvasAction {
|
||||
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
|
||||
match key {
|
||||
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
||||
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
|
||||
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
|
||||
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
|
||||
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
|
||||
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
|
||||
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
|
||||
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
|
||||
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
|
||||
crossterm::event::KeyCode::Tab => Some(Self::NextField),
|
||||
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility method
|
||||
pub fn from_string(action: &str) -> Self {
|
||||
match action {
|
||||
"delete_char_backward" => Self::DeleteBackward,
|
||||
"delete_char_forward" => Self::DeleteForward,
|
||||
"move_left" => Self::MoveLeft,
|
||||
"move_right" => Self::MoveRight,
|
||||
"move_up" => Self::MoveUp,
|
||||
"move_down" => Self::MoveDown,
|
||||
"move_line_start" => Self::MoveLineStart,
|
||||
"move_line_end" => Self::MoveLineEnd,
|
||||
"move_first_line" => Self::MoveFirstLine,
|
||||
"move_last_line" => Self::MoveLastLine,
|
||||
"move_word_next" => Self::MoveWordNext,
|
||||
"move_word_end" => Self::MoveWordEnd,
|
||||
"move_word_prev" => Self::MoveWordPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
"select_suggestion" => Self::SelectSuggestion,
|
||||
"exit_suggestions" => Self::ExitSuggestions,
|
||||
_ => Self::Custom(action.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// Result type for canvas actions
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActionResult {
|
||||
Success(Option<String>),
|
||||
HandledByFeature(String),
|
||||
RequiresContext(String),
|
||||
Success,
|
||||
Message(String),
|
||||
HandledByApp(String),
|
||||
HandledByFeature(String), // Keep for compatibility
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
pub fn success() -> Self {
|
||||
Self::Success(None)
|
||||
Self::Success
|
||||
}
|
||||
|
||||
|
||||
pub fn success_with_message(msg: &str) -> Self {
|
||||
Self::Success(Some(msg.to_string()))
|
||||
Self::Message(msg.to_string())
|
||||
}
|
||||
|
||||
|
||||
pub fn handled_by_app(msg: &str) -> Self {
|
||||
Self::HandledByApp(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn error(msg: &str) -> Self {
|
||||
Self::Error(msg.into())
|
||||
Self::Error(msg.to_string())
|
||||
}
|
||||
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
|
||||
matches!(self, Self::Success | Self::Message(_) | Self::HandledByApp(_) | Self::HandledByFeature(_))
|
||||
}
|
||||
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Success(msg) => msg.as_deref(),
|
||||
Self::HandledByFeature(msg) => Some(msg),
|
||||
Self::RequiresContext(msg) => Some(msg),
|
||||
Self::Error(msg) => Some(msg),
|
||||
Self::Message(msg) | Self::HandledByApp(msg) | Self::HandledByFeature(msg) | Self::Error(msg) => Some(msg),
|
||||
Self::Success => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a canvas action on the given state
|
||||
pub async fn execute<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
) -> Result<ActionResult> {
|
||||
let mut ideal_cursor_column = 0;
|
||||
|
||||
super::handlers::dispatch_action(action, state, &mut ideal_cursor_column).await
|
||||
}
|
||||
|
||||
impl CanvasAction {
|
||||
/// Get a human-readable description of this action
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MoveLeft => "move left",
|
||||
Self::MoveRight => "move right",
|
||||
Self::MoveUp => "move up",
|
||||
Self::MoveDown => "move down",
|
||||
Self::MoveWordNext => "next word",
|
||||
Self::MoveWordPrev => "previous word",
|
||||
Self::MoveWordEnd => "word end",
|
||||
Self::MoveWordEndPrev => "previous word end",
|
||||
Self::MoveLineStart => "line start",
|
||||
Self::MoveLineEnd => "line end",
|
||||
Self::NextField => "next field",
|
||||
Self::PrevField => "previous field",
|
||||
Self::MoveFirstLine => "first field",
|
||||
Self::MoveLastLine => "last field",
|
||||
Self::InsertChar(c) => "insert character",
|
||||
Self::DeleteBackward => "delete backward",
|
||||
Self::DeleteForward => "delete forward",
|
||||
Self::TriggerAutocomplete => "trigger autocomplete",
|
||||
Self::SuggestionUp => "suggestion up",
|
||||
Self::SuggestionDown => "suggestion down",
|
||||
Self::SelectSuggestion => "select suggestion",
|
||||
Self::ExitSuggestions => "exit suggestions",
|
||||
Self::Custom(name) => "custom action",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all movement-related actions
|
||||
pub fn movement_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::MoveLeft,
|
||||
Self::MoveRight,
|
||||
Self::MoveUp,
|
||||
Self::MoveDown,
|
||||
Self::MoveWordNext,
|
||||
Self::MoveWordPrev,
|
||||
Self::MoveWordEnd,
|
||||
Self::MoveWordEndPrev,
|
||||
Self::MoveLineStart,
|
||||
Self::MoveLineEnd,
|
||||
Self::NextField,
|
||||
Self::PrevField,
|
||||
Self::MoveFirstLine,
|
||||
Self::MoveLastLine,
|
||||
]
|
||||
}
|
||||
|
||||
/// Get all editing-related actions
|
||||
pub fn editing_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::InsertChar(' '), // Example char
|
||||
Self::DeleteBackward,
|
||||
Self::DeleteForward,
|
||||
]
|
||||
}
|
||||
|
||||
/// Get all autocomplete-related actions
|
||||
pub fn autocomplete_actions() -> Vec<CanvasAction> {
|
||||
vec![
|
||||
Self::TriggerAutocomplete,
|
||||
Self::SuggestionUp,
|
||||
Self::SuggestionDown,
|
||||
Self::SelectSuggestion,
|
||||
Self::ExitSuggestions,
|
||||
]
|
||||
}
|
||||
|
||||
/// Check if this action modifies text content
|
||||
pub fn is_editing_action(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
Self::DeleteForward
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this action moves the cursor
|
||||
pub fn is_movement_action(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
|
||||
Self::MoveWordNext | Self::MoveWordPrev | Self::MoveWordEnd | Self::MoveWordEndPrev |
|
||||
Self::MoveLineStart | Self::MoveLineEnd | Self::NextField | Self::PrevField |
|
||||
Self::MoveFirstLine | Self::MoveLastLine
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// src/canvas/mod.rs
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
pub mod gui;
|
||||
pub mod theme;
|
||||
pub mod state;
|
||||
|
||||
// Re-export commonly used canvas types
|
||||
pub mod actions;
|
||||
pub mod gui;
|
||||
pub mod modes;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use actions::{CanvasAction, ActionResult};
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
|
||||
@@ -1,44 +1,107 @@
|
||||
// canvas/src/state.rs
|
||||
// src/canvas/state.rs
|
||||
//! Canvas state trait and related types
|
||||
//!
|
||||
//! This module defines the core trait that any form or input system must implement
|
||||
//! to work with the canvas library.
|
||||
|
||||
use crate::canvas::actions::CanvasAction;
|
||||
use crate::canvas::modes::AppMode;
|
||||
|
||||
/// Context passed to feature-specific action handlers
|
||||
/// Context information passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
pub struct ActionContext {
|
||||
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
|
||||
/// Original key code that triggered this action (for backwards compatibility)
|
||||
pub key_code: Option<crossterm::event::KeyCode>,
|
||||
/// Current ideal cursor column for vertical movement
|
||||
pub ideal_cursor_column: usize,
|
||||
/// Current input text
|
||||
pub current_input: String,
|
||||
/// Current field index
|
||||
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.
|
||||
/// Core trait that any form-like state must implement to work with canvas
|
||||
///
|
||||
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
|
||||
/// across any implementation - login forms, data entry forms, configuration screens, etc.
|
||||
///
|
||||
/// # Required Implementation
|
||||
///
|
||||
/// Your struct needs to track:
|
||||
/// - Current field index and cursor position
|
||||
/// - All input field values
|
||||
/// - Current interaction mode
|
||||
/// - Whether there are unsaved changes
|
||||
///
|
||||
/// # Example Implementation
|
||||
///
|
||||
/// ```rust
|
||||
/// struct MyForm {
|
||||
/// fields: Vec<String>,
|
||||
/// current_field: usize,
|
||||
/// cursor_pos: usize,
|
||||
/// mode: AppMode,
|
||||
/// dirty: bool,
|
||||
/// }
|
||||
///
|
||||
/// impl CanvasState for MyForm {
|
||||
/// fn current_field(&self) -> usize { self.current_field }
|
||||
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
/// // ... implement other required methods
|
||||
/// }
|
||||
/// ```
|
||||
pub trait CanvasState {
|
||||
// --- Core Navigation ---
|
||||
|
||||
/// Get current field index (0-based)
|
||||
fn current_field(&self) -> usize;
|
||||
|
||||
/// Get current cursor position within the current field
|
||||
fn current_cursor_pos(&self) -> usize;
|
||||
|
||||
/// Set current field index (should clamp to valid range)
|
||||
fn set_current_field(&mut self, index: usize);
|
||||
|
||||
/// Set cursor position within current field (should clamp to valid range)
|
||||
fn set_current_cursor_pos(&mut self, pos: usize);
|
||||
|
||||
// --- Mode Information ---
|
||||
|
||||
/// Get current interaction mode (edit, read-only, highlight, etc.)
|
||||
fn current_mode(&self) -> AppMode;
|
||||
|
||||
// --- Data Access ---
|
||||
|
||||
/// Get immutable reference to current field's text
|
||||
fn get_current_input(&self) -> &str;
|
||||
|
||||
/// Get mutable reference to current field's text
|
||||
fn get_current_input_mut(&mut self) -> &mut String;
|
||||
|
||||
/// Get all input values as immutable references
|
||||
fn inputs(&self) -> Vec<&String>;
|
||||
|
||||
/// Get all field names/labels
|
||||
fn fields(&self) -> Vec<&str>;
|
||||
|
||||
// --- State Management ---
|
||||
|
||||
/// Check if there are unsaved changes
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
|
||||
/// Mark whether there are unsaved changes
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Feature-specific action handling ---
|
||||
// --- Optional Overrides ---
|
||||
|
||||
/// Feature-specific action handling (NEW: Type-safe)
|
||||
/// Handle application-specific actions not covered by standard handlers
|
||||
/// Return Some(message) if the action was handled, None to use standard handling
|
||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
None // Default: no feature-specific handling
|
||||
None // Default: no custom handling
|
||||
}
|
||||
|
||||
// --- Display Overrides (for links, computed values, etc.) ---
|
||||
/// Get display value for a field (may differ from actual value)
|
||||
/// Used for things like password masking or computed display values
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
@@ -46,6 +109,8 @@ pub trait CanvasState {
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// Check if a field has a custom display value
|
||||
/// Return true if get_display_value_for_field returns something different than the actual value
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
// canvas/src/config.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasConfig {
|
||||
#[serde(default)]
|
||||
pub keybindings: CanvasKeybindings,
|
||||
#[serde(default)]
|
||||
pub behavior: CanvasBehavior,
|
||||
#[serde(default)]
|
||||
pub appearance: CanvasAppearance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanvasKeybindings {
|
||||
#[serde(default)]
|
||||
pub read_only: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub edit: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub suggestions: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub global: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasBehavior {
|
||||
#[serde(default = "default_wrap_around")]
|
||||
pub wrap_around_fields: bool,
|
||||
#[serde(default = "default_auto_save")]
|
||||
pub auto_save_on_field_change: bool,
|
||||
#[serde(default = "default_word_chars")]
|
||||
pub word_chars: String,
|
||||
#[serde(default = "default_suggestion_limit")]
|
||||
pub max_suggestions: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasAppearance {
|
||||
#[serde(default = "default_cursor_style")]
|
||||
pub cursor_style: String, // "block", "bar", "underline"
|
||||
#[serde(default = "default_show_field_numbers")]
|
||||
pub show_field_numbers: bool,
|
||||
#[serde(default = "default_highlight_current_field")]
|
||||
pub highlight_current_field: bool,
|
||||
}
|
||||
|
||||
// Default values
|
||||
fn default_wrap_around() -> bool { true }
|
||||
fn default_auto_save() -> bool { false }
|
||||
fn default_word_chars() -> String { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_".to_string() }
|
||||
fn default_suggestion_limit() -> usize { 10 }
|
||||
fn default_cursor_style() -> String { "block".to_string() }
|
||||
fn default_show_field_numbers() -> bool { false }
|
||||
fn default_highlight_current_field() -> bool { true }
|
||||
|
||||
impl Default for CanvasBehavior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wrap_around_fields: default_wrap_around(),
|
||||
auto_save_on_field_change: default_auto_save(),
|
||||
word_chars: default_word_chars(),
|
||||
max_suggestions: default_suggestion_limit(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasAppearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cursor_style: default_cursor_style(),
|
||||
show_field_numbers: default_show_field_numbers(),
|
||||
highlight_current_field: default_highlight_current_field(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasKeybindings {
|
||||
pub fn with_vim_defaults() -> Self {
|
||||
let mut keybindings = Self::default();
|
||||
|
||||
// Read-only mode (vim-style navigation)
|
||||
keybindings.read_only.insert("move_left".to_string(), vec!["h".to_string()]);
|
||||
keybindings.read_only.insert("move_right".to_string(), vec!["l".to_string()]);
|
||||
keybindings.read_only.insert("move_up".to_string(), vec!["k".to_string()]);
|
||||
keybindings.read_only.insert("move_down".to_string(), vec!["j".to_string()]);
|
||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["w".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end".to_string(), vec!["e".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["b".to_string()]);
|
||||
keybindings.read_only.insert("move_word_end_prev".to_string(), vec!["ge".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["0".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["$".to_string()]);
|
||||
keybindings.read_only.insert("move_first_line".to_string(), vec!["gg".to_string()]);
|
||||
keybindings.read_only.insert("move_last_line".to_string(), vec!["G".to_string()]);
|
||||
keybindings.read_only.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.read_only.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Edit mode
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Delete".to_string()]);
|
||||
keybindings.edit.insert("move_left".to_string(), vec!["Left".to_string()]);
|
||||
keybindings.edit.insert("move_right".to_string(), vec!["Right".to_string()]);
|
||||
keybindings.edit.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
keybindings.edit.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
keybindings.edit.insert("move_line_start".to_string(), vec!["Home".to_string()]);
|
||||
keybindings.edit.insert("move_line_end".to_string(), vec!["End".to_string()]);
|
||||
keybindings.edit.insert("move_word_next".to_string(), vec!["Ctrl+Right".to_string()]);
|
||||
keybindings.edit.insert("move_word_prev".to_string(), vec!["Ctrl+Left".to_string()]);
|
||||
keybindings.edit.insert("next_field".to_string(), vec!["Tab".to_string()]);
|
||||
keybindings.edit.insert("prev_field".to_string(), vec!["Shift+Tab".to_string()]);
|
||||
|
||||
// Suggestions
|
||||
keybindings.suggestions.insert("suggestion_up".to_string(), vec!["Up".to_string(), "Ctrl+p".to_string()]);
|
||||
keybindings.suggestions.insert("suggestion_down".to_string(), vec!["Down".to_string(), "Ctrl+n".to_string()]);
|
||||
keybindings.suggestions.insert("select_suggestion".to_string(), vec!["Enter".to_string(), "Tab".to_string()]);
|
||||
keybindings.suggestions.insert("exit_suggestions".to_string(), vec!["Esc".to_string()]);
|
||||
|
||||
// Global (works in both modes)
|
||||
keybindings.global.insert("move_up".to_string(), vec!["Up".to_string()]);
|
||||
keybindings.global.insert("move_down".to_string(), vec!["Down".to_string()]);
|
||||
|
||||
keybindings
|
||||
}
|
||||
|
||||
pub fn with_emacs_defaults() -> Self {
|
||||
let mut keybindings = Self::default();
|
||||
|
||||
// Emacs-style bindings
|
||||
keybindings.read_only.insert("move_left".to_string(), vec!["Ctrl+b".to_string()]);
|
||||
keybindings.read_only.insert("move_right".to_string(), vec!["Ctrl+f".to_string()]);
|
||||
keybindings.read_only.insert("move_up".to_string(), vec!["Ctrl+p".to_string()]);
|
||||
keybindings.read_only.insert("move_down".to_string(), vec!["Ctrl+n".to_string()]);
|
||||
keybindings.read_only.insert("move_word_next".to_string(), vec!["Alt+f".to_string()]);
|
||||
keybindings.read_only.insert("move_word_prev".to_string(), vec!["Alt+b".to_string()]);
|
||||
keybindings.read_only.insert("move_line_start".to_string(), vec!["Ctrl+a".to_string()]);
|
||||
keybindings.read_only.insert("move_line_end".to_string(), vec!["Ctrl+e".to_string()]);
|
||||
|
||||
keybindings.edit.insert("delete_char_backward".to_string(), vec!["Ctrl+h".to_string(), "Backspace".to_string()]);
|
||||
keybindings.edit.insert("delete_char_forward".to_string(), vec!["Ctrl+d".to_string(), "Delete".to_string()]);
|
||||
|
||||
keybindings
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasConfig {
|
||||
/// Load from canvas_config.toml or fallback to vim defaults
|
||||
pub fn load() -> Self {
|
||||
// Try to load canvas_config.toml from current directory
|
||||
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Fallback to vim defaults
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load from TOML string
|
||||
pub fn from_toml(toml_str: &str) -> Result<Self> {
|
||||
toml::from_str(toml_str)
|
||||
.with_context(|| "Failed to parse canvas config TOML")
|
||||
}
|
||||
|
||||
/// Load from file
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {:?}", path))?;
|
||||
Self::from_toml(&contents)
|
||||
}
|
||||
|
||||
/// NEW: Check if autocomplete should auto-trigger (simple logic)
|
||||
pub fn should_auto_trigger_autocomplete(&self) -> bool {
|
||||
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
|
||||
// If no trigger_autocomplete keybinding, use auto-trigger mode
|
||||
!self.has_trigger_autocomplete_keybinding()
|
||||
}
|
||||
|
||||
/// NEW: Check if user has configured manual trigger keybinding
|
||||
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
|
||||
self.keybindings.edit.contains_key("trigger_autocomplete") ||
|
||||
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
|
||||
self.keybindings.global.contains_key("trigger_autocomplete")
|
||||
}
|
||||
|
||||
/// Get action for key in read-only mode
|
||||
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Get action for key in edit mode
|
||||
pub fn get_edit_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.edit, key, modifiers)
|
||||
.or_else(|| self.get_action_in_mode(&self.keybindings.global, key, modifiers))
|
||||
}
|
||||
|
||||
/// Get action for key in suggestions mode
|
||||
pub fn get_suggestion_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||
self.get_action_in_mode(&self.keybindings.suggestions, key, modifiers)
|
||||
}
|
||||
|
||||
/// Get action for key (mode-aware)
|
||||
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, has_suggestions: bool) -> Option<&str> {
|
||||
// Suggestions take priority when active
|
||||
if has_suggestions {
|
||||
if let Some(action) = self.get_suggestion_action(key, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
|
||||
// Then check mode-specific
|
||||
if is_edit_mode {
|
||||
self.get_edit_action(key, modifiers)
|
||||
} else {
|
||||
self.get_read_only_action(key, modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_action_in_mode<'a>(&self, mode_bindings: &'a HashMap<String, Vec<String>>, key: KeyCode, modifiers: KeyModifiers) -> Option<&'a str> {
|
||||
for (action, bindings) in mode_bindings {
|
||||
for binding in bindings {
|
||||
if self.matches_keybinding(binding, key, modifiers) {
|
||||
return Some(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn matches_keybinding(&self, binding: &str, key: KeyCode, modifiers: KeyModifiers) -> bool {
|
||||
// Special handling for shift+character combinations
|
||||
if binding.to_lowercase().starts_with("shift+") {
|
||||
let parts: Vec<&str> = binding.split('+').collect();
|
||||
if parts.len() == 2 && parts[1].len() == 1 {
|
||||
let expected_lowercase = parts[1].chars().next().unwrap().to_lowercase().next().unwrap();
|
||||
let expected_uppercase = expected_lowercase.to_uppercase().next().unwrap();
|
||||
if let KeyCode::Char(actual_char) = key {
|
||||
if actual_char == expected_uppercase && modifiers.contains(KeyModifiers::SHIFT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Shift+Tab -> BackTab
|
||||
if binding.to_lowercase() == "shift+tab" && key == KeyCode::BackTab && modifiers.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle multi-character bindings (all standard keys without modifiers)
|
||||
if binding.len() > 1 && !binding.contains('+') {
|
||||
return match binding.to_lowercase().as_str() {
|
||||
// Navigation keys
|
||||
"left" => key == KeyCode::Left,
|
||||
"right" => key == KeyCode::Right,
|
||||
"up" => key == KeyCode::Up,
|
||||
"down" => key == KeyCode::Down,
|
||||
"home" => key == KeyCode::Home,
|
||||
"end" => key == KeyCode::End,
|
||||
"pageup" | "pgup" => key == KeyCode::PageUp,
|
||||
"pagedown" | "pgdn" => key == KeyCode::PageDown,
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => key == KeyCode::Insert,
|
||||
"delete" | "del" => key == KeyCode::Delete,
|
||||
"backspace" => key == KeyCode::Backspace,
|
||||
|
||||
// Tab keys
|
||||
"tab" => key == KeyCode::Tab,
|
||||
"backtab" => key == KeyCode::BackTab,
|
||||
|
||||
// Special keys
|
||||
"enter" | "return" => key == KeyCode::Enter,
|
||||
"escape" | "esc" => key == KeyCode::Esc,
|
||||
"space" => key == KeyCode::Char(' '),
|
||||
|
||||
// Function keys F1-F24
|
||||
"f1" => key == KeyCode::F(1),
|
||||
"f2" => key == KeyCode::F(2),
|
||||
"f3" => key == KeyCode::F(3),
|
||||
"f4" => key == KeyCode::F(4),
|
||||
"f5" => key == KeyCode::F(5),
|
||||
"f6" => key == KeyCode::F(6),
|
||||
"f7" => key == KeyCode::F(7),
|
||||
"f8" => key == KeyCode::F(8),
|
||||
"f9" => key == KeyCode::F(9),
|
||||
"f10" => key == KeyCode::F(10),
|
||||
"f11" => key == KeyCode::F(11),
|
||||
"f12" => key == KeyCode::F(12),
|
||||
"f13" => key == KeyCode::F(13),
|
||||
"f14" => key == KeyCode::F(14),
|
||||
"f15" => key == KeyCode::F(15),
|
||||
"f16" => key == KeyCode::F(16),
|
||||
"f17" => key == KeyCode::F(17),
|
||||
"f18" => key == KeyCode::F(18),
|
||||
"f19" => key == KeyCode::F(19),
|
||||
"f20" => key == KeyCode::F(20),
|
||||
"f21" => key == KeyCode::F(21),
|
||||
"f22" => key == KeyCode::F(22),
|
||||
"f23" => key == KeyCode::F(23),
|
||||
"f24" => key == KeyCode::F(24),
|
||||
|
||||
// Lock keys (may not work reliably in all terminals)
|
||||
"capslock" => key == KeyCode::CapsLock,
|
||||
"scrolllock" => key == KeyCode::ScrollLock,
|
||||
"numlock" => key == KeyCode::NumLock,
|
||||
|
||||
// System keys
|
||||
"printscreen" => key == KeyCode::PrintScreen,
|
||||
"pause" => key == KeyCode::Pause,
|
||||
"menu" => key == KeyCode::Menu,
|
||||
"keypadbegin" => key == KeyCode::KeypadBegin,
|
||||
|
||||
// Media keys (rarely supported but included for completeness)
|
||||
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
|
||||
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
|
||||
"mediaplaypause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::PlayPause),
|
||||
"mediareverse" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Reverse),
|
||||
"mediastop" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Stop),
|
||||
"mediafastforward" => key == KeyCode::Media(crossterm::event::MediaKeyCode::FastForward),
|
||||
"mediarewind" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Rewind),
|
||||
"mediatracknext" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackNext),
|
||||
"mediatrackprevious" => key == KeyCode::Media(crossterm::event::MediaKeyCode::TrackPrevious),
|
||||
"mediarecord" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Record),
|
||||
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
|
||||
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
|
||||
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
|
||||
|
||||
// Modifier keys (these work better as part of combinations)
|
||||
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
|
||||
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
|
||||
"leftalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftAlt),
|
||||
"leftsuper" | "leftwindows" | "leftcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftSuper),
|
||||
"lefthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftHyper),
|
||||
"leftmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftMeta),
|
||||
"rightshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightShift),
|
||||
"rightcontrol" | "rightctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightControl),
|
||||
"rightalt" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightAlt),
|
||||
"rightsuper" | "rightwindows" | "rightcmd" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightSuper),
|
||||
"righthyper" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightHyper),
|
||||
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
|
||||
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
|
||||
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
|
||||
|
||||
// Multi-key sequences need special handling
|
||||
"gg" => false, // This needs sequence handling
|
||||
_ => {
|
||||
// Handle single characters and punctuation
|
||||
if binding.len() == 1 {
|
||||
if let Some(c) = binding.chars().next() {
|
||||
key == KeyCode::Char(c)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Handle modifier combinations (like "Ctrl+F5", "Alt+Shift+A")
|
||||
let parts: Vec<&str> = binding.split('+').collect();
|
||||
let mut expected_modifiers = KeyModifiers::empty();
|
||||
let mut expected_key = None;
|
||||
|
||||
for part in parts {
|
||||
match part.to_lowercase().as_str() {
|
||||
// Modifiers
|
||||
"ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
|
||||
"shift" => expected_modifiers |= KeyModifiers::SHIFT,
|
||||
"alt" => expected_modifiers |= KeyModifiers::ALT,
|
||||
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
|
||||
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
|
||||
"meta" => expected_modifiers |= KeyModifiers::META,
|
||||
|
||||
// Navigation keys
|
||||
"left" => expected_key = Some(KeyCode::Left),
|
||||
"right" => expected_key = Some(KeyCode::Right),
|
||||
"up" => expected_key = Some(KeyCode::Up),
|
||||
"down" => expected_key = Some(KeyCode::Down),
|
||||
"home" => expected_key = Some(KeyCode::Home),
|
||||
"end" => expected_key = Some(KeyCode::End),
|
||||
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
|
||||
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
|
||||
|
||||
// Editing keys
|
||||
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
|
||||
"delete" | "del" => expected_key = Some(KeyCode::Delete),
|
||||
"backspace" => expected_key = Some(KeyCode::Backspace),
|
||||
|
||||
// Tab keys
|
||||
"tab" => expected_key = Some(KeyCode::Tab),
|
||||
"backtab" => expected_key = Some(KeyCode::BackTab),
|
||||
|
||||
// Special keys
|
||||
"enter" | "return" => expected_key = Some(KeyCode::Enter),
|
||||
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
|
||||
"space" => expected_key = Some(KeyCode::Char(' ')),
|
||||
|
||||
// Function keys
|
||||
"f1" => expected_key = Some(KeyCode::F(1)),
|
||||
"f2" => expected_key = Some(KeyCode::F(2)),
|
||||
"f3" => expected_key = Some(KeyCode::F(3)),
|
||||
"f4" => expected_key = Some(KeyCode::F(4)),
|
||||
"f5" => expected_key = Some(KeyCode::F(5)),
|
||||
"f6" => expected_key = Some(KeyCode::F(6)),
|
||||
"f7" => expected_key = Some(KeyCode::F(7)),
|
||||
"f8" => expected_key = Some(KeyCode::F(8)),
|
||||
"f9" => expected_key = Some(KeyCode::F(9)),
|
||||
"f10" => expected_key = Some(KeyCode::F(10)),
|
||||
"f11" => expected_key = Some(KeyCode::F(11)),
|
||||
"f12" => expected_key = Some(KeyCode::F(12)),
|
||||
"f13" => expected_key = Some(KeyCode::F(13)),
|
||||
"f14" => expected_key = Some(KeyCode::F(14)),
|
||||
"f15" => expected_key = Some(KeyCode::F(15)),
|
||||
"f16" => expected_key = Some(KeyCode::F(16)),
|
||||
"f17" => expected_key = Some(KeyCode::F(17)),
|
||||
"f18" => expected_key = Some(KeyCode::F(18)),
|
||||
"f19" => expected_key = Some(KeyCode::F(19)),
|
||||
"f20" => expected_key = Some(KeyCode::F(20)),
|
||||
"f21" => expected_key = Some(KeyCode::F(21)),
|
||||
"f22" => expected_key = Some(KeyCode::F(22)),
|
||||
"f23" => expected_key = Some(KeyCode::F(23)),
|
||||
"f24" => expected_key = Some(KeyCode::F(24)),
|
||||
|
||||
// Lock keys
|
||||
"capslock" => expected_key = Some(KeyCode::CapsLock),
|
||||
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
|
||||
"numlock" => expected_key = Some(KeyCode::NumLock),
|
||||
|
||||
// System keys
|
||||
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
|
||||
"pause" => expected_key = Some(KeyCode::Pause),
|
||||
"menu" => expected_key = Some(KeyCode::Menu),
|
||||
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
|
||||
|
||||
// Single character (letters, numbers, punctuation)
|
||||
part => {
|
||||
if part.len() == 1 {
|
||||
if let Some(c) = part.chars().next() {
|
||||
expected_key = Some(KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modifiers == expected_modifiers && Some(key) == expected_key
|
||||
}
|
||||
|
||||
/// Convenience method to create vim preset
|
||||
pub fn vim_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_vim_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience method to create emacs preset
|
||||
pub fn emacs_preset() -> Self {
|
||||
Self {
|
||||
keybindings: CanvasKeybindings::with_emacs_defaults(),
|
||||
behavior: CanvasBehavior::default(),
|
||||
appearance: CanvasAppearance::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug method to print loaded keybindings
|
||||
pub fn debug_keybindings(&self) {
|
||||
println!("📋 Canvas keybindings loaded:");
|
||||
println!(" Read-only: {} actions", self.keybindings.read_only.len());
|
||||
println!(" Edit: {} actions", self.keybindings.edit.len());
|
||||
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
|
||||
println!(" Global: {} actions", self.keybindings.global.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
pub use crate::canvas::actions::CanvasAction;
|
||||
pub use crate::dispatcher::ActionDispatcher;
|
||||
@@ -1,183 +0,0 @@
|
||||
// canvas/src/dispatcher.rs
|
||||
|
||||
use crate::canvas::state::CanvasState;
|
||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||
use crate::config::CanvasConfig;
|
||||
|
||||
/// High-level action dispatcher that coordinates between different action types
|
||||
pub struct ActionDispatcher;
|
||||
|
||||
impl ActionDispatcher {
|
||||
/// Dispatch any action to the appropriate handler
|
||||
pub async fn dispatch<S: CanvasState>(
|
||||
action: CanvasAction,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<ActionResult> {
|
||||
|
||||
// Load config once here instead of threading it everywhere
|
||||
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
|
||||
}
|
||||
|
||||
/// Quick action dispatch from KeyCode
|
||||
pub async fn dispatch_key<S: CanvasState>(
|
||||
key: crossterm::event::KeyCode,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<Option<ActionResult>> {
|
||||
if let Some(action) = CanvasAction::from_key(key) {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
Ok(Some(result))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch dispatch multiple actions
|
||||
pub async fn dispatch_batch<S: CanvasState>(
|
||||
actions: Vec<CanvasAction>,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> anyhow::Result<Vec<ActionResult>> {
|
||||
let mut results = Vec::new();
|
||||
for action in actions {
|
||||
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
|
||||
let is_success = result.is_success(); // Check success before moving
|
||||
results.push(result);
|
||||
|
||||
// Stop on first error
|
||||
if !is_success {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::CanvasAction;
|
||||
|
||||
// Simple test implementation
|
||||
struct TestFormState {
|
||||
current_field: usize,
|
||||
cursor_pos: usize,
|
||||
inputs: Vec<String>,
|
||||
field_names: Vec<String>,
|
||||
has_changes: bool,
|
||||
}
|
||||
|
||||
impl TestFormState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_field: 0,
|
||||
cursor_pos: 0,
|
||||
inputs: vec!["".to_string(), "".to_string()],
|
||||
field_names: vec!["username".to_string(), "password".to_string()],
|
||||
has_changes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for TestFormState {
|
||||
fn current_field(&self) -> usize { self.current_field }
|
||||
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
|
||||
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
|
||||
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
|
||||
|
||||
fn get_current_input(&self) -> &str { &self.inputs[self.current_field] }
|
||||
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
|
||||
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
|
||||
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
|
||||
|
||||
fn has_unsaved_changes(&self) -> bool { self.has_changes }
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
|
||||
|
||||
// Custom action handling for testing
|
||||
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
|
||||
match action {
|
||||
CanvasAction::Custom(s) if s == "test_custom" => {
|
||||
Some("Custom action handled".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typed_action_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
// Test character insertion
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::InsertChar('a'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_success());
|
||||
assert_eq!(state.get_current_input(), "a");
|
||||
assert_eq!(state.cursor_pos, 1);
|
||||
assert!(state.has_changes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_key_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch_key(
|
||||
crossterm::event::KeyCode::Char('b'),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().is_success());
|
||||
assert_eq!(state.get_current_input(), "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_custom_action() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let result = ActionDispatcher::dispatch(
|
||||
CanvasAction::Custom("test_custom".to_string()),
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
match result {
|
||||
ActionResult::HandledByFeature(msg) => {
|
||||
assert_eq!(msg, "Custom action handled");
|
||||
}
|
||||
_ => panic!("Expected HandledByFeature result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_dispatch() {
|
||||
let mut state = TestFormState::new();
|
||||
let mut ideal_cursor = 0;
|
||||
|
||||
let actions = vec![
|
||||
CanvasAction::InsertChar('h'),
|
||||
CanvasAction::InsertChar('i'),
|
||||
CanvasAction::MoveLeft,
|
||||
CanvasAction::InsertChar('e'),
|
||||
];
|
||||
|
||||
let results = ActionDispatcher::dispatch_batch(
|
||||
actions,
|
||||
&mut state,
|
||||
&mut ideal_cursor,
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert!(results.iter().all(|r| r.is_success()));
|
||||
assert_eq!(state.get_current_input(), "hei");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
// src/lib.rs
|
||||
|
||||
pub mod canvas;
|
||||
|
||||
// Only include autocomplete module if feature is enabled
|
||||
#[cfg(feature = "autocomplete")]
|
||||
pub mod autocomplete;
|
||||
pub mod config;
|
||||
pub mod dispatcher;
|
||||
|
||||
// Re-export the main API for easy access
|
||||
pub use canvas::actions::{CanvasAction, ActionResult, execute};
|
||||
pub use canvas::state::{CanvasState, ActionContext};
|
||||
pub use canvas::modes::{AppMode, ModeManager, HighlightState};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::gui::render_canvas;
|
||||
|
||||
// Re-export autocomplete API if feature is enabled
|
||||
#[cfg(feature = "autocomplete")]
|
||||
pub use autocomplete::{
|
||||
AutocompleteCanvasState,
|
||||
AutocompleteState,
|
||||
SuggestionItem,
|
||||
execute_with_autocomplete,
|
||||
handle_autocomplete_feature_action,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "gui", feature = "autocomplete"))]
|
||||
pub use autocomplete::render_autocomplete_dropdown;
|
||||
|
||||
55
canvas/view_docs.sh
Executable file
55
canvas/view_docs.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enhanced documentation viewer for your canvas library
|
||||
echo "=========================================="
|
||||
echo "CANVAS LIBRARY DOCUMENTATION"
|
||||
echo "=========================================="
|
||||
|
||||
# Function to display module docs with colors
|
||||
show_module() {
|
||||
local module=$1
|
||||
local title=$2
|
||||
|
||||
echo -e "\n\033[1;34m=== $title ===\033[0m"
|
||||
echo -e "\033[33mFiles in $module:\033[0m"
|
||||
find src/$module -name "*.rs" 2>/dev/null | sort
|
||||
echo
|
||||
|
||||
# Show doc comments for this module
|
||||
find src/$module -name "*.rs" 2>/dev/null | while read file; do
|
||||
if grep -q "///" "$file"; then
|
||||
echo -e "\033[32m--- $file ---\033[0m"
|
||||
grep -n "^\s*///" "$file" | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' | head -10
|
||||
echo
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Main modules
|
||||
show_module "canvas" "CANVAS SYSTEM"
|
||||
show_module "autocomplete" "AUTOCOMPLETE SYSTEM"
|
||||
show_module "config" "CONFIGURATION SYSTEM"
|
||||
|
||||
# Show lib.rs and other root files
|
||||
echo -e "\n\033[1;34m=== ROOT DOCUMENTATION ===\033[0m"
|
||||
if [ -f "src/lib.rs" ]; then
|
||||
echo -e "\033[32m--- src/lib.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/lib.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
if [ -f "src/dispatcher.rs" ]; then
|
||||
echo -e "\033[32m--- src/dispatcher.rs ---\033[0m"
|
||||
grep -n "^\s*///" src/dispatcher.rs | sed 's/^\([0-9]*:\)\s*\/\/\/ /\1 /' 2>/dev/null
|
||||
fi
|
||||
|
||||
echo -e "\n\033[1;36m=========================================="
|
||||
echo "To view specific module documentation:"
|
||||
echo " ./view_canvas_docs.sh canvas"
|
||||
echo " ./view_canvas_docs.sh autocomplete"
|
||||
echo " ./view_canvas_docs.sh config"
|
||||
echo "==========================================\033[0m"
|
||||
|
||||
# If specific module requested
|
||||
if [ $# -eq 1 ]; then
|
||||
show_module "$1" "$(echo $1 | tr '[:lower:]' '[:upper:]') MODULE DETAILS"
|
||||
fi
|
||||
1
client/.gitignore
vendored
Normal file
1
client/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
canvas_config.toml.txt
|
||||
@@ -1,58 +0,0 @@
|
||||
# canvas_config.toml - Complete Canvas Configuration
|
||||
|
||||
[behavior]
|
||||
wrap_around_fields = true
|
||||
auto_save_on_field_change = false
|
||||
word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
|
||||
max_suggestions = 6
|
||||
|
||||
[appearance]
|
||||
cursor_style = "block" # "block", "bar", "underline"
|
||||
show_field_numbers = false
|
||||
highlight_current_field = true
|
||||
|
||||
# Read-only mode keybindings (vim-style)
|
||||
[keybindings.read_only]
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["shift+g"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
# Edit mode keybindings
|
||||
[keybindings.edit]
|
||||
delete_char_backward = ["Backspace"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_left = ["Left"]
|
||||
move_right = ["Right"]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
move_line_start = ["Home"]
|
||||
move_line_end = ["End"]
|
||||
move_word_next = ["Ctrl+Right"]
|
||||
move_word_prev = ["Ctrl+Left"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
trigger_autocomplete = ["Ctrl+p"]
|
||||
|
||||
# Suggestion/autocomplete keybindings
|
||||
[keybindings.suggestions]
|
||||
suggestion_up = ["Up", "Ctrl+p"]
|
||||
suggestion_down = ["Down", "Ctrl+n"]
|
||||
select_suggestion = ["Enter", "Tab"]
|
||||
exit_suggestions = ["Esc"]
|
||||
trigger_autocomplete = ["Tab"]
|
||||
|
||||
# Global keybindings (work in both modes)
|
||||
[keybindings.global]
|
||||
move_up = ["Up"]
|
||||
move_down = ["Down"]
|
||||
@@ -39,25 +39,45 @@ enter_edit_mode_after = ["a"]
|
||||
previous_entry = ["left","q"]
|
||||
next_entry = ["right","1"]
|
||||
|
||||
move_left = ["h"]
|
||||
move_right = ["l"]
|
||||
move_up = ["k"]
|
||||
move_down = ["j"]
|
||||
move_word_next = ["w"]
|
||||
move_word_end = ["e"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_first_line = ["gg"]
|
||||
move_last_line = ["x"]
|
||||
enter_highlight_mode = ["v"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_up = ["k", "Up"]
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_line_end = ["$"]
|
||||
# move_word_next = ["w"]
|
||||
next_field = ["Tab"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
move_last_line = ["shift+g"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_line_start = ["0"]
|
||||
move_first_line = ["g+g"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
|
||||
[keybindings.highlight]
|
||||
exit_highlight_mode = ["esc"]
|
||||
enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_left = ["h", "Left"]
|
||||
move_right = ["l", "Right"]
|
||||
move_up = ["k", "Up"]
|
||||
move_down = ["j", "Down"]
|
||||
# Optional
|
||||
move_word_next = ["w"]
|
||||
move_line_start = ["0"]
|
||||
move_line_end = ["$"]
|
||||
move_word_prev = ["b"]
|
||||
move_word_end = ["e"]
|
||||
|
||||
|
||||
[keybindings.edit]
|
||||
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
|
||||
# exit_edit_mode = ["esc","ctrl+e"]
|
||||
@@ -65,15 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"]
|
||||
# select_suggestion = ["enter"]
|
||||
# next_field = ["enter"]
|
||||
enter_decider = ["enter"]
|
||||
prev_field = ["shift+enter"]
|
||||
exit = ["esc", "ctrl+e"]
|
||||
delete_char_forward = ["delete"]
|
||||
delete_char_backward = ["backspace"]
|
||||
move_left = ["left"]
|
||||
move_right = ["right"]
|
||||
suggestion_down = ["ctrl+n", "tab"]
|
||||
suggestion_up = ["ctrl+p", "shift+tab"]
|
||||
|
||||
### AUTOGENERATED CANVAS CONFIG
|
||||
# Required
|
||||
move_right = ["Right", "l"]
|
||||
delete_char_backward = ["Backspace"]
|
||||
next_field = ["Tab", "Enter"]
|
||||
move_up = ["Up", "k"]
|
||||
move_down = ["Down", "j"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
move_left = ["Left", "h"]
|
||||
# Optional
|
||||
move_last_line = ["Ctrl+End", "G"]
|
||||
delete_char_forward = ["Delete"]
|
||||
move_word_prev = ["Ctrl+Left", "b"]
|
||||
move_word_end = ["e"]
|
||||
move_word_end_prev = ["ge"]
|
||||
move_first_line = ["Ctrl+Home", "gg"]
|
||||
move_word_next = ["Ctrl+Right", "w"]
|
||||
move_line_start = ["Home", "0"]
|
||||
move_line_end = ["End", "$"]
|
||||
|
||||
[keybindings.command]
|
||||
exit_command_mode = ["ctrl+g", "esc"]
|
||||
command_execute = ["enter"]
|
||||
@@ -91,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
|
||||
[colors]
|
||||
theme = "dark"
|
||||
# Options: "light", "dark", "high_contrast"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use canvas::canvas::CanvasState;
|
||||
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher, canvas::ActionResult};
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::info;
|
||||
|
||||
@@ -79,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -141,16 +143,48 @@ async fn execute_canvas_action(
|
||||
}
|
||||
}
|
||||
|
||||
/// NEW: Unified canvas action handler for any CanvasState (LoginState, RegisterState, etc.)
|
||||
/// This replaces the old auth_e::execute_edit_action calls with the new canvas library
|
||||
/// FIXED: Unified canvas action handler with proper priority order for edit mode
|
||||
async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
key: KeyEvent,
|
||||
config: &Config,
|
||||
state: &mut S,
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try direct key mapping first (same pattern as FormState)
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
|
||||
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
|
||||
|
||||
// PRIORITY 1: Character insertion in edit mode comes FIRST
|
||||
if let KeyCode::Char(c) = key.code {
|
||||
// Only insert if no modifiers or just shift (for uppercase)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
|
||||
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
|
||||
// Fall through to try config mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check canvas config for special keys/combinations
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
@@ -165,33 +199,43 @@ async fn handle_canvas_state_edit<S: CanvasState>(
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall through to try config mapping
|
||||
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No canvas config mapping found"); // DEBUG
|
||||
}
|
||||
|
||||
// Try config-mapped action (same pattern as FormState)
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
|
||||
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
|
||||
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
|
||||
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
}
|
||||
Ok(ActionResult::HandledByFeature(msg)) => {
|
||||
return Ok(msg);
|
||||
}
|
||||
Ok(ActionResult::Error(msg)) => {
|
||||
return Ok(format!("Error: {}", msg));
|
||||
}
|
||||
Ok(ActionResult::RequiresContext(msg)) => {
|
||||
return Ok(format!("Context needed: {}", msg));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(format!("Action failed: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
|
||||
}
|
||||
} else {
|
||||
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
|
||||
}
|
||||
|
||||
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
|
||||
ideal_cursor_column: &mut usize,
|
||||
) -> Result<String> {
|
||||
// Try canvas action from key first
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(action_name);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
|
||||
Ok(ActionResult::Success(msg)) => {
|
||||
return Ok(msg.unwrap_or_default());
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::ui::handlers::rat_state::UiStateHandler;
|
||||
use anyhow::Result;
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use crossterm::cursor::SetCursorStyle;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -776,7 +777,6 @@ impl EventHandler {
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
false,
|
||||
).await {
|
||||
@@ -866,7 +866,6 @@ impl EventHandler {
|
||||
if app_state.ui.show_form {
|
||||
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
|
||||
key_event,
|
||||
config,
|
||||
form_state,
|
||||
true,
|
||||
).await {
|
||||
@@ -1102,77 +1101,49 @@ impl EventHandler {
|
||||
async fn handle_form_canvas_action(
|
||||
&mut self,
|
||||
key_event: KeyEvent,
|
||||
_config: &Config,
|
||||
form_state: &mut FormState,
|
||||
is_edit_mode: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let canvas_config = canvas::config::CanvasConfig::load();
|
||||
|
||||
// Handle suggestion actions first if suggestions are active
|
||||
if form_state.autocomplete_active {
|
||||
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
|
||||
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
|
||||
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback hardcoded suggestion handling
|
||||
match key_event.code {
|
||||
KeyCode::Up => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionUp,
|
||||
// PRIORITY 1: Handle character insertion in edit mode FIRST
|
||||
if is_edit_mode {
|
||||
if let KeyCode::Char(c) = key_event.code {
|
||||
// Only insert if it's not a special modifier combination
|
||||
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
|
||||
let canvas_action = CanvasAction::InsertChar(c);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Character insertion failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SuggestionDown,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::SelectSuggestion,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if let Ok(result) = ActionDispatcher::dispatch(
|
||||
CanvasAction::ExitSuggestions,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Handle config-mapped actions for non-character keys
|
||||
let action_str = canvas_config.get_action_for_key(
|
||||
key_event.code,
|
||||
key_event.modifiers,
|
||||
is_edit_mode,
|
||||
form_state.autocomplete_active
|
||||
form_state.autocomplete_active,
|
||||
);
|
||||
|
||||
if let Some(action_str) = action_str {
|
||||
// Skip mode transition actions - let the main event handler deal with them
|
||||
if Self::is_mode_transition_action(action_str) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canvas_action = CanvasAction::from_string(&action_str);
|
||||
// Execute the config-mapped action
|
||||
let canvas_action = CanvasAction::from_string(action_str);
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
@@ -1187,54 +1158,7 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to automatic key handling for edit mode
|
||||
if is_edit_mode {
|
||||
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Auto action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In read-only mode, only handle non-character keys
|
||||
let canvas_action = match key_event.code {
|
||||
KeyCode::Left => Some(CanvasAction::MoveLeft),
|
||||
KeyCode::Right => Some(CanvasAction::MoveRight),
|
||||
KeyCode::Up => Some(CanvasAction::MoveUp),
|
||||
KeyCode::Down => Some(CanvasAction::MoveDown),
|
||||
KeyCode::Home => Some(CanvasAction::MoveLineStart),
|
||||
KeyCode::End => Some(CanvasAction::MoveLineEnd),
|
||||
KeyCode::Tab => Some(CanvasAction::NextField),
|
||||
KeyCode::BackTab => Some(CanvasAction::PrevField),
|
||||
KeyCode::Delete => Some(CanvasAction::DeleteForward),
|
||||
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(canvas_action) = canvas_action {
|
||||
match ActionDispatcher::dispatch(
|
||||
canvas_action,
|
||||
form_state,
|
||||
&mut self.ideal_cursor_column,
|
||||
).await {
|
||||
Ok(result) => {
|
||||
return Ok(Some(result.message().unwrap_or("").to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(Some("Action failed".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No action found
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/state/pages/add_logic.rs
|
||||
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use crate::components::common::text_editor::{TextEditor, VimState};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -54,6 +54,7 @@ pub struct AddLogicState {
|
||||
// New fields for same-profile table names and column autocomplete
|
||||
pub same_profile_table_names: Vec<String>, // Tables from same profile only
|
||||
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl AddLogicState {
|
||||
@@ -91,6 +92,7 @@ impl AddLogicState {
|
||||
|
||||
same_profile_table_names: Vec::new(),
|
||||
script_editor_awaiting_column_autocomplete: None,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +271,9 @@ impl AddLogicState {
|
||||
|
||||
impl Default for AddLogicState {
|
||||
fn default() -> Self {
|
||||
Self::new(&EditorConfig::default())
|
||||
let mut state = Self::new(&EditorConfig::default());
|
||||
state.app_mode = AppMode::Edit;
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,4 +443,8 @@ impl CanvasState for AddLogicState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/add_table.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use ratatui::widgets::TableState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -63,6 +63,7 @@ pub struct AddTableState {
|
||||
pub column_name_cursor_pos: usize,
|
||||
pub column_type_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for AddTableState {
|
||||
@@ -85,6 +86,7 @@ impl Default for AddTableState {
|
||||
column_name_cursor_pos: 0,
|
||||
column_type_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,4 +299,8 @@ impl CanvasState for AddTableState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/state/pages/auth.rs
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction};
|
||||
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
|
||||
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct AuthState {
|
||||
}
|
||||
|
||||
/// Represents the state of the Login form UI
|
||||
#[derive(Default)]
|
||||
pub struct LoginState {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
@@ -31,10 +30,26 @@ pub struct LoginState {
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
pub login_request_pending: bool,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for LoginState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
login_request_pending: false,
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the state of the Registration form UI
|
||||
#[derive(Default, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct RegisterState {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
@@ -45,8 +60,26 @@ pub struct RegisterState {
|
||||
pub current_field: usize,
|
||||
pub current_cursor_pos: usize,
|
||||
pub has_unsaved_changes: bool,
|
||||
// NEW: Replace old autocomplete with external library's system
|
||||
pub autocomplete: AutocompleteState<String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl Default for RegisterState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
password: String::new(),
|
||||
password_confirmation: String::new(),
|
||||
role: String::new(),
|
||||
error_message: None,
|
||||
current_field: 0,
|
||||
current_cursor_pos: 0,
|
||||
has_unsaved_changes: false,
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
@@ -57,7 +90,10 @@ impl AuthState {
|
||||
|
||||
impl LoginState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +101,7 @@ impl RegisterState {
|
||||
pub fn new() -> Self {
|
||||
let mut state = Self {
|
||||
autocomplete: AutocompleteState::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Implement external library's CanvasState for RegisterState
|
||||
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
// Add autocomplete support for RegisterState
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/state/pages/form.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState};
|
||||
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
|
||||
use common::proto::komp_ac::search::search_response::Hit;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::Frame;
|
||||
@@ -41,6 +41,7 @@ pub struct FormState {
|
||||
pub selected_suggestion_index: Option<usize>,
|
||||
pub autocomplete_loading: bool,
|
||||
pub link_display_map: HashMap<usize, String>,
|
||||
pub app_mode: AppMode,
|
||||
}
|
||||
|
||||
impl FormState {
|
||||
@@ -74,6 +75,7 @@ impl FormState {
|
||||
selected_suggestion_index: None,
|
||||
autocomplete_loading: false,
|
||||
link_display_map: HashMap::new(),
|
||||
app_mode: AppMode::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +233,15 @@ impl FormState {
|
||||
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
|
||||
self.autocomplete_loading = false;
|
||||
}
|
||||
|
||||
// NEW: Add these methods to change modes
|
||||
pub fn set_edit_mode(&mut self) {
|
||||
self.app_mode = AppMode::Edit;
|
||||
}
|
||||
|
||||
pub fn set_readonly_mode(&mut self) {
|
||||
self.app_mode = AppMode::ReadOnly;
|
||||
}
|
||||
}
|
||||
|
||||
impl CanvasState for FormState {
|
||||
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
|
||||
fn has_display_override(&self, index: usize) -> bool {
|
||||
self.link_display_map.contains_key(&index)
|
||||
}
|
||||
|
||||
fn current_mode(&self) -> AppMode {
|
||||
self.app_mode
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user