Merge branch 'canvas' of gitlab.com:filipriec/komp_ac

This commit is contained in:
filipriec
2025-08-02 15:46:51 +02:00
37 changed files with 2497 additions and 2422 deletions

1
Cargo.lock generated
View File

@@ -475,6 +475,7 @@ name = "canvas"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
"common",
"crossterm",
"ratatui",

View File

@@ -12,9 +12,9 @@ categories.workspace = true
[dependencies]
common = { path = "../common" }
ratatui = { workspace = true, optional = true }
crossterm = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
crossterm = { workspace = true, optional = true }
anyhow.workspace = true
tokio = { workspace = true, optional = true }
toml = { workspace = true }
serde = { workspace = true }
unicode-width.workspace = true
@@ -22,6 +22,7 @@ thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
async-trait.workspace = true
[dev-dependencies]
tokio-test = "0.4.4"
@@ -29,3 +30,15 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
autocomplete = ["tokio"]
cursor-style = ["crossterm"]
[[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"

View 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 
╰─

View File

@@ -0,0 +1,392 @@
// 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,
theme::CanvasTheme,
},
autocomplete::gui::render_autocomplete_dropdown,
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
};
use async_trait::async_trait;
use anyhow::Result;
// 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,
}
// ===================================================================
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
// ===================================================================
struct ContactForm {
// Only business data - no UI state!
name: String,
email: String,
phone: String,
city: String,
}
impl ContactForm {
fn new() -> Self {
Self {
name: "John Doe".to_string(),
email: "john@".to_string(), // Partial email for demo
phone: "+1 234 567 8900".to_string(),
city: "San Francisco".to_string(),
}
}
}
// Simple trait implementation - only 4 methods!
impl DataProvider for ContactForm {
fn field_count(&self) -> usize { 4 }
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Name",
1 => "Email",
2 => "Phone",
3 => "City",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.name,
1 => &self.email,
2 => &self.phone,
3 => &self.city,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.name = value,
1 => self.email = value,
2 => self.phone = value,
3 => self.city = value,
_ => {}
}
}
fn supports_autocomplete(&self, field_index: usize) -> bool {
field_index == 1 // Only email field
}
}
// ===================================================================
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
// ===================================================================
struct EmailAutocomplete;
#[async_trait]
impl AutocompleteProvider for EmailAutocomplete {
type SuggestionData = EmailSuggestion;
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
{
// Extract domain part from email
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
} else {
return Ok(Vec::new()); // No @ symbol
};
// Simulate async API call
let suggestions = tokio::task::spawn_blocking(move || {
// Simulate network delay
std::thread::sleep(std::time::Duration::from_millis(200));
// Mock email suggestions
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 {
data: EmailSuggestion {
email: full_email.clone(),
provider: provider.to_string(),
},
display_text: format!("{} ({})", full_email, provider),
value_to_store: full_email,
});
}
}
results
}).await.unwrap_or_default();
Ok(suggestions)
}
}
// ===================================================================
// APPLICATION STATE - Much simpler!
// ===================================================================
struct AppState {
editor: FormEditor<ContactForm>,
autocomplete: EmailAutocomplete,
debug_message: String,
}
impl AppState {
fn new() -> Self {
let contact_form = ContactForm::new();
let mut editor = FormEditor::new(contact_form);
// Start on email field (index 1) at end of existing text
editor.set_mode(AppMode::Edit);
// TODO: Add method to set initial field/cursor position
Self {
editor,
autocomplete: EmailAutocomplete,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
}
}
}
// ===================================================================
// INPUT HANDLING - Much cleaner!
// ===================================================================
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return false; // Quit
}
// Handle input based on key
let result = match key {
// === AUTOCOMPLETE KEYS ===
KeyCode::Tab => {
if state.editor.is_autocomplete_active() {
state.editor.autocomplete_next();
Ok("Navigated to next suggestion".to_string())
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
state.editor.trigger_autocomplete(&mut state.autocomplete).await
.map(|_| "Triggered autocomplete".to_string())
} else {
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Enter => {
if state.editor.is_autocomplete_active() {
if let Some(applied) = state.editor.apply_autocomplete() {
Ok(format!("Applied: {}", applied))
} else {
Ok("No suggestion to apply".to_string())
}
} else {
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Esc => {
if state.editor.is_autocomplete_active() {
// Autocomplete will be cleared automatically by mode change
Ok("Cancelled autocomplete".to_string())
} else {
// Toggle between edit and readonly mode
let new_mode = match state.editor.mode() {
AppMode::Edit => AppMode::ReadOnly,
_ => AppMode::Edit,
};
state.editor.set_mode(new_mode);
Ok(format!("Switched to {:?} mode", new_mode))
}
}
// === MOVEMENT KEYS ===
KeyCode::Left => {
state.editor.move_left();
Ok("Moved left".to_string())
}
KeyCode::Right => {
state.editor.move_right();
Ok("Moved right".to_string())
}
KeyCode::Up => {
state.editor.move_to_next_field(); // TODO: Add move_up method
Ok("Moved up".to_string())
}
KeyCode::Down => {
state.editor.move_to_next_field(); // TODO: Add move_down method
Ok("Moved down".to_string())
}
// === TEXT INPUT ===
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
state.editor.insert_char(c)
.map(|_| format!("Inserted '{}'", c))
}
KeyCode::Backspace => {
// TODO: Add delete_backward method to FormEditor
Ok("Backspace (not implemented yet)".to_string())
}
_ => Ok(format!("Unhandled key: {:?}", key)),
};
// Update debug message
match result {
Ok(msg) => state.debug_message = msg,
Err(e) => state.debug_message = format!("Error: {}", e),
}
true
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> 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: &AppState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(5),
])
.split(f.area());
// Render the canvas form - much simpler!
let active_field_rect = render_canvas(
f,
chunks[0],
&state.editor,
theme,
);
// Render autocomplete dropdown if active
if let Some(input_rect) = active_field_rect {
render_autocomplete_dropdown(
f,
chunks[0],
input_rect,
theme,
&state.editor,
);
}
// Status info
let autocomplete_status = if state.editor.is_autocomplete_active() {
if state.editor.ui_state().is_autocomplete_loading() {
"Loading suggestions..."
} else if !state.editor.suggestions().is_empty() {
"Use 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.editor.mode(),
state.editor.current_field() + 1,
state.editor.data_provider().field_count(),
state.editor.cursor_position()))),
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 = AppState::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(())
}

View File

@@ -0,0 +1,724 @@
// examples/full_canvas_demo.rs
//! Demonstrates the FULL potential of the canvas library using the native API
use std::io;
use crossterm::{
cursor::SetCursorStyle,
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_default,
modes::{AppMode, ModeManager, HighlightState},
},
DataProvider, FormEditor,
};
/// Update cursor style based on current AppMode
fn update_cursor_for_mode(mode: AppMode) -> io::Result<()> {
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert mode
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal mode
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking block for visual mode
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general mode
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command mode
};
execute!(io::stdout(), style)
}
// Enhanced FormEditor that adds visual mode and status tracking
struct EnhancedFormEditor<D: DataProvider> {
editor: FormEditor<D>,
highlight_state: HighlightState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String, // For multi-key vim commands like "gg"
}
impl<D: DataProvider> EnhancedFormEditor<D> {
fn new(data_provider: D) -> Self {
Self {
editor: FormEditor::new(data_provider),
highlight_state: HighlightState::Off,
has_unsaved_changes: false,
debug_message: "Full Canvas Demo - All features enabled".to_string(),
command_buffer: String::new(),
}
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === VISUAL/HIGHLIGHT MODE SUPPORT ===
fn enter_visual_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
self.editor.set_mode(AppMode::Highlight);
self.highlight_state = HighlightState::Characterwise {
anchor: (
self.editor.current_field(),
self.editor.cursor_position(),
),
};
self.debug_message = "-- VISUAL --".to_string();
}
}
fn enter_visual_line_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.editor.mode()) {
self.editor.set_mode(AppMode::Highlight);
self.highlight_state =
HighlightState::Linewise { anchor_line: self.editor.current_field() };
self.debug_message = "-- VISUAL LINE --".to_string();
}
}
fn exit_visual_mode(&mut self) {
self.highlight_state = HighlightState::Off;
if self.editor.mode() == AppMode::Highlight {
self.editor.set_mode(AppMode::ReadOnly);
self.debug_message = "Visual mode exited".to_string();
}
}
fn update_visual_selection(&mut self) {
if self.editor.mode() == AppMode::Highlight {
match &self.highlight_state {
HighlightState::Characterwise { anchor: _ } => {
self.debug_message = format!(
"Visual selection: char {} in field {}",
self.editor.cursor_position(),
self.editor.current_field()
);
}
HighlightState::Linewise { anchor_line: _ } => {
self.debug_message = format!(
"Visual line selection: field {}",
self.editor.current_field()
);
}
_ => {}
}
}
}
// === ENHANCED MOVEMENT WITH VISUAL UPDATES ===
fn move_left(&mut self) {
self.editor.move_left();
self.update_visual_selection();
}
fn move_right(&mut self) {
self.editor.move_right();
self.update_visual_selection();
}
fn move_up(&mut self) {
self.editor.move_up();
self.update_visual_selection();
}
fn move_down(&mut self) {
self.editor.move_down();
self.update_visual_selection();
}
fn move_word_next(&mut self) {
self.editor.move_word_next();
self.update_visual_selection();
}
fn move_word_prev(&mut self) {
self.editor.move_word_prev();
self.update_visual_selection();
}
fn move_word_end(&mut self) {
self.editor.move_word_end();
self.update_visual_selection();
}
fn move_word_end_prev(&mut self) {
self.editor.move_word_end_prev();
self.update_visual_selection();
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_visual_selection();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_visual_selection();
}
fn move_first_line(&mut self) {
self.editor.move_first_line();
self.update_visual_selection();
}
fn move_last_line(&mut self) {
self.editor.move_last_line();
self.update_visual_selection();
}
fn prev_field(&mut self) {
self.editor.prev_field();
self.update_visual_selection();
}
fn next_field(&mut self) {
self.editor.next_field();
self.update_visual_selection();
}
// === DELETE OPERATIONS ===
fn delete_backward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_backward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "Deleted character backward".to_string();
}
Ok(result?)
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let result = self.editor.delete_forward();
if result.is_ok() {
self.has_unsaved_changes = true;
self.debug_message = "Deleted character forward".to_string();
}
Ok(result?)
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode();
self.debug_message = "-- INSERT --".to_string();
}
fn exit_edit_mode(&mut self) {
self.editor.exit_edit_mode();
self.exit_visual_mode();
self.debug_message = "".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
self.has_unsaved_changes = true;
}
Ok(result?)
}
// === DELEGATE TO ORIGINAL EDITOR ===
fn current_field(&self) -> usize {
self.editor.current_field()
}
fn cursor_position(&self) -> usize {
self.editor.cursor_position()
}
fn mode(&self) -> AppMode {
self.editor.mode()
}
fn current_text(&self) -> &str {
self.editor.current_text()
}
fn data_provider(&self) -> &D {
self.editor.data_provider()
}
fn ui_state(&self) -> &canvas::EditorState {
self.editor.ui_state()
}
fn set_mode(&mut self, mode: AppMode) {
self.editor.set_mode(mode);
if mode != AppMode::Highlight {
self.exit_visual_mode();
}
}
// === STATUS AND DEBUG ===
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn highlight_state(&self) -> &HighlightState {
&self.highlight_state
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
}
// Demo form data with interesting text for word movement
struct FullDemoData {
fields: Vec<(String, String)>,
}
impl FullDemoData {
fn new() -> Self {
Self {
fields: vec![
("Name".to_string(), "John-Paul McDonald".to_string()),
(
"Email".to_string(),
"user@example-domain.com".to_string(),
),
("Phone".to_string(), "+1 (555) 123-4567".to_string()),
("Address".to_string(), "123 Main St, Apt 4B".to_string()),
(
"Tags".to_string(),
"urgent,important,follow-up".to_string(),
),
(
"Notes".to_string(),
"This is a sample note with multiple words, punctuation! And symbols @#$"
.to_string(),
),
],
}
}
}
impl DataProvider for FullDemoData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
self.fields[index].1 = value;
}
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
}
/// Full vim-like key handling using the native FormEditor API
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut EnhancedFormEditor<FullDemoData>,
) -> anyhow::Result<bool> {
let old_mode = editor.mode(); // Store mode before processing
// Quit handling
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (old_mode, key, modifiers) {
// === MODE TRANSITIONS ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.move_right(); // Move after current character
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (append)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (end of line)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
editor.move_line_end();
editor.enter_edit_mode();
editor.set_debug_message("-- INSERT -- (open line)".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('v'), _) => {
editor.enter_visual_mode();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('V'), _) => {
editor.enter_visual_line_mode();
editor.clear_command_buffer();
}
(_, KeyCode::Esc, _) => {
editor.exit_edit_mode();
editor.clear_command_buffer();
}
// === MOVEMENT: VIM-STYLE NAVIGATION ===
// Basic movement (hjkl and arrows)
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('h'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Left, _) => {
editor.move_left();
editor.set_debug_message("← left".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('l'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Right, _) => {
editor.move_right();
editor.set_debug_message("→ right".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('j'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Down, _) => {
editor.move_down();
editor.set_debug_message("↓ next field".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('k'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Up, _) => {
editor.move_up();
editor.set_debug_message("↑ previous field".to_string());
editor.clear_command_buffer();
}
// Word movement - Full vim word navigation
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('w'), _) => {
editor.move_word_next();
editor.set_debug_message("w: next word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('b'), _) => {
editor.move_word_prev();
editor.set_debug_message("b: previous word start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
editor.move_word_end_prev();
editor.set_debug_message("W: previous word end".to_string());
editor.clear_command_buffer();
}
// Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Home, _) => {
editor.move_line_start();
editor.set_debug_message("0: line start".to_string());
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('$'), _)
| (AppMode::ReadOnly | AppMode::Highlight, KeyCode::End, _) => {
editor.move_line_end();
editor.set_debug_message("$: line end".to_string());
}
// Field/document movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('g'), _) => {
if editor.get_command_buffer() == "g" {
// Second 'g' - execute "gg" command
editor.move_first_line();
editor.set_debug_message("gg: first field".to_string());
editor.clear_command_buffer();
} else {
// First 'g' - start command buffer
editor.clear_command_buffer();
editor.add_to_command_buffer('g');
editor.set_debug_message("g".to_string());
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('G'), _) => {
editor.move_last_line();
editor.set_debug_message("G: last field".to_string());
editor.clear_command_buffer();
}
// === EDIT MODE MOVEMENT ===
(AppMode::Edit, KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_prev();
editor.set_debug_message("Ctrl+← word back".to_string());
}
(AppMode::Edit, KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => {
editor.move_word_next();
editor.set_debug_message("Ctrl+→ word forward".to_string());
}
(AppMode::Edit, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::Edit, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::Edit, KeyCode::Up, _) => {
editor.move_up();
}
(AppMode::Edit, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::Edit, KeyCode::Home, _) => {
editor.move_line_start();
}
(AppMode::Edit, KeyCode::End, _) => {
editor.move_line_end();
}
// === DELETE OPERATIONS ===
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Delete operations in normal mode (vim x)
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
editor.delete_forward()?;
editor.set_debug_message("x: deleted character".to_string());
}
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
editor.delete_backward()?;
editor.set_debug_message("X: deleted character backward".to_string());
}
// === TAB NAVIGATION ===
(_, KeyCode::Tab, _) => {
editor.next_field();
editor.set_debug_message("Tab: next field".to_string());
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
editor.set_debug_message("Shift+Tab: previous field".to_string());
}
// === CHARACTER INPUT ===
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"Field {}/{}, Pos {}, Mode: {:?}",
editor.current_field() + 1,
editor.data_provider().field_count(),
editor.cursor_position(),
editor.mode()
));
}
_ => {
// If we have a pending command and this key doesn't complete it, clear the buffer
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
} else {
editor.set_debug_message(format!(
"Unhandled: {:?} + {:?} in {:?} mode",
key, modifiers, old_mode
));
}
}
}
// Update cursor if mode changed
let new_mode = editor.mode();
if old_mode != new_mode {
update_cursor_for_mode(new_mode)?;
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: EnhancedFormEditor<FullDemoData>,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &EnhancedFormEditor<FullDemoData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_enhanced_canvas(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &EnhancedFormEditor<FullDemoData>,
) {
render_canvas_default(f, area, &editor.editor);
}
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &EnhancedFormEditor<FullDemoData>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
AppMode::Highlight => match editor.highlight_state() {
HighlightState::Characterwise { .. } => "VISUAL",
HighlightState::Linewise { .. } => "VISUAL LINE",
_ => "VISUAL",
},
_ => "NORMAL",
};
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {}", mode_text, editor.debug_message())
} else {
format!("-- {} -- {}", mode_text, editor.debug_message())
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("Status"));
f.render_widget(status, chunks[0]);
// Help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
if editor.has_pending_command() {
match editor.get_command_buffer() {
"g" => "Press 'g' again for first field, or any other key to cancel",
_ => "Pending command... (Esc to cancel)"
}
} else {
"Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last, i/a/A=insert, v/V=visual, x/X=delete, ?=info"
}
}
AppMode::Edit => {
"Insert: arrows=move, Ctrl+arrows=words, Backspace/Del=delete, Esc=normal, Tab/Shift+Tab=fields"
}
AppMode::Highlight => {
"Visual: hjkl/arrows=extend selection, w/b/e=word selection, Esc=normal"
}
_ => "Press ? for help"
};
let help = Paragraph::new(Line::from(Span::raw(help_text)))
.block(Block::default().borders(Borders::ALL).title("Commands"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
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 data = FullDemoData::new();
let mut editor = EnhancedFormEditor::new(data);
editor.set_mode(AppMode::ReadOnly); // Start in normal mode
// Set initial cursor style
update_cursor_for_mode(editor.mode())?;
let res = run_app(&mut terminal, editor);
// Reset cursor style on exit
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}

View File

@@ -1,21 +0,0 @@
// examples/generate_template.rs
use canvas::config::CanvasConfig;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 && args[1] == "clean" {
// Generate clean template with 80% active code
let template = CanvasConfig::generate_clean_template();
println!("{}", template);
} else {
// Generate verbose template with descriptions (default)
let template = CanvasConfig::generate_template();
println!("{}", template);
}
}
// Usage:
// cargo run --example generate_template > canvas_config.toml
// cargo run --example generate_template clean > canvas_config_clean.toml

View File

@@ -1,133 +0,0 @@
// src/autocomplete/actions.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
use crate::config::CanvasConfig;
use anyhow::Result;
/// Version for states that implement rich autocomplete
pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + AutocompleteCanvasState>(
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(),
};
if let Some(result) = handle_rich_autocomplete_action(action.clone(), state, &context) {
return Ok(result);
}
// 2. Handle generic actions using the new dispatcher directly
let result = ActionDispatcher::dispatch_with_config(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"))
} else {
Some(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())
} else {
Some(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())
} else {
Some(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))
} else {
Some(ActionResult::success_with_message("No suggestion selected"))
}
} else {
Some(ActionResult::success_with_message("No suggestions available"))
}
}
CanvasAction::ExitSuggestions => {
if state.is_autocomplete_active() {
state.deactivate_autocomplete();
Some(ActionResult::success_with_message("Exited autocomplete"))
} else {
Some(ActionResult::success())
}
}
_ => None, // Not a rich autocomplete action
}
}

View File

@@ -1,38 +1,41 @@
// canvas/src/autocomplete/gui.rs
// src/autocomplete/gui.rs
//! Autocomplete GUI updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
widgets::{Block, List, ListItem, ListState, Paragraph}, // Removed Borders
Frame,
};
use crate::autocomplete::types::AutocompleteState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::data_provider::{DataProvider, SuggestionItem};
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown - call this AFTER rendering canvas
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas
#[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
editor: &FormEditor<D>,
) {
if !autocomplete_state.is_active {
let ui_state = editor.ui_state();
if !ui_state.is_autocomplete_active() {
return;
}
if autocomplete_state.is_loading {
if ui_state.autocomplete.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme);
} else if !autocomplete_state.suggestions.is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
} else if !editor.suggestions().is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index);
}
}
@@ -73,9 +76,10 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
frame_area: Rect,
input_rect: Rect,
theme: &T,
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
suggestions: &[SuggestionItem], // Fixed: Removed <String> generic parameter
selected_index: Option<usize>,
) {
let display_texts: Vec<&str> = autocomplete_state.suggestions
let display_texts: Vec<&str> = suggestions
.iter()
.map(|item| item.display_text.as_str())
.collect();
@@ -95,19 +99,19 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
// List items
let items = create_suggestion_list_items(
&display_texts,
autocomplete_state.selected_index,
selected_index,
dropdown_dimensions.width,
theme,
);
let list = List::new(items).block(dropdown_block);
let mut list_state = ListState::default();
list_state.select(autocomplete_state.selected_index);
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Calculate dropdown size based on suggestions - updated to match client dimensions
/// Calculate dropdown size based on suggestions
#[cfg(feature = "gui")]
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
let max_width = display_texts
@@ -116,9 +120,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
.max()
.unwrap_or(0) as u16;
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
let horizontal_padding = 2;
let width = (max_width + horizontal_padding).max(10);
let height = (display_texts.len() as u16).min(5);
DropdownDimensions { width, height }
}
@@ -151,7 +155,7 @@ fn calculate_dropdown_position(
dropdown_area
}
/// Create styled list items - updated to match client spacing
/// Create styled list items
#[cfg(feature = "gui")]
fn create_suggestion_list_items<'a, T: CanvasTheme>(
display_texts: &'a [&'a str],
@@ -159,8 +163,7 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
dropdown_width: u16,
theme: &T,
) -> Vec<ListItem<'a>> {
let horizontal_padding = 2; // Changed from 4 to 2 to match client
let available_width = dropdown_width; // No border padding needed
let available_width = dropdown_width;
display_texts
.iter()

View File

@@ -1,10 +1,12 @@
// src/autocomplete/mod.rs
pub mod types;
pub mod gui;
pub mod state;
pub mod actions;
// Re-export autocomplete types
pub use types::{SuggestionItem, AutocompleteState};
pub use state::AutocompleteCanvasState;
pub use actions::execute_canvas_action_with_autocomplete;
pub mod state;
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main autocomplete types
pub use state::{AutocompleteProvider, SuggestionItem};
// Re-export GUI functions if available
#[cfg(feature = "gui")]
pub use gui::render_autocomplete_dropdown;

View File

@@ -1,96 +1,5 @@
// canvas/src/state.rs
// src/autocomplete/state.rs
//! Autocomplete provider types
use crate::canvas::state::CanvasState;
/// OPTIONAL extension trait for states that want rich autocomplete functionality.
/// Only implement this if you need the new autocomplete features.
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
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false // Default: no autocomplete support
}
/// Get autocomplete state (read-only)
fn autocomplete_state(&self) -> Option<&crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// Get autocomplete state (mutable)
fn autocomplete_state_mut(&mut self) -> Option<&mut crate::autocomplete::AutocompleteState<Self::SuggestionData>> {
None // Default: no autocomplete state
}
/// CLIENT API: Activate autocomplete for current field
fn activate_autocomplete(&mut self) {
let current_field = self.current_field(); // Get field first
if let Some(state) = self.autocomplete_state_mut() {
state.activate(current_field); // Then use it
}
}
/// CLIENT API: Deactivate autocomplete
fn deactivate_autocomplete(&mut self) {
if let Some(state) = self.autocomplete_state_mut() {
state.deactivate();
}
}
/// CLIENT API: Set suggestions (called after 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
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
fn is_autocomplete_active(&self) -> bool {
self.autocomplete_state()
.map(|state| state.is_active)
.unwrap_or(false)
}
/// Check if autocomplete is ready for interaction
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
};
// 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);
// Deactivate autocomplete
if let Some(state_mut) = self.autocomplete_state_mut() {
state_mut.deactivate();
}
Some(format!("Selected: {}", display))
} else {
None
}
}
}
// Re-export the main types from data_provider
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};

View File

@@ -1,126 +0,0 @@
// canvas/src/autocomplete.rs
/// Generic suggestion item that clients push to canvas
#[derive(Debug, Clone)]
pub struct SuggestionItem<T> {
/// The underlying data (client-specific, e.g., Hit, String, etc.)
pub data: T,
/// Text to display in the dropdown
pub display_text: String,
/// Value to store in the form field when selected
pub value_to_store: String,
}
impl<T> SuggestionItem<T> {
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
Self {
data,
display_text,
value_to_store,
}
}
/// Convenience constructor for simple string suggestions
pub fn simple(data: T, text: String) -> Self {
Self {
data,
display_text: text.clone(),
value_to_store: text,
}
}
}
/// Autocomplete state managed by canvas
#[derive(Debug, Clone)]
pub struct AutocompleteState<T> {
/// Whether autocomplete is currently active/visible
pub is_active: bool,
/// Whether suggestions are being loaded (for spinner/loading indicator)
pub is_loading: bool,
/// Current suggestions to display
pub suggestions: Vec<SuggestionItem<T>>,
/// Currently selected suggestion index
pub selected_index: Option<usize>,
/// Field index that triggered autocomplete (for context)
pub active_field: Option<usize>,
}
impl<T> Default for AutocompleteState<T> {
fn default() -> Self {
Self {
is_active: false,
is_loading: false,
suggestions: Vec::new(),
selected_index: None,
active_field: None,
}
}
}
impl<T> AutocompleteState<T> {
pub fn new() -> Self {
Self::default()
}
/// Activate autocomplete for a specific field
pub fn activate(&mut self, field_index: usize) {
self.is_active = true;
self.active_field = Some(field_index);
self.selected_index = None;
self.suggestions.clear();
self.is_loading = true;
}
/// Deactivate autocomplete and clear state
pub fn deactivate(&mut self) {
self.is_active = false;
self.is_loading = false;
self.suggestions.clear();
self.selected_index = None;
self.active_field = None;
}
/// Set suggestions and stop loading
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
self.suggestions = suggestions;
self.is_loading = false;
self.selected_index = if self.suggestions.is_empty() {
None
} else {
Some(0)
};
}
/// Move selection down
pub fn select_next(&mut self) {
if !self.suggestions.is_empty() {
let current = self.selected_index.unwrap_or(0);
self.selected_index = Some((current + 1) % self.suggestions.len());
}
}
/// Move selection up
pub fn select_previous(&mut self) {
if !self.suggestions.is_empty() {
let current = self.selected_index.unwrap_or(0);
self.selected_index = Some(
if current == 0 {
self.suggestions.len() - 1
} else {
current - 1
}
);
}
}
/// Get currently selected suggestion
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
self.selected_index
.and_then(|idx| self.suggestions.get(idx))
}
/// Check if autocomplete is ready for interaction (active and has suggestions)
pub fn is_ready(&self) -> bool {
self.is_active && !self.suggestions.is_empty() && !self.is_loading
}
}

View File

@@ -1,203 +0,0 @@
// src/canvas/actions/handlers/edit.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
const FOR_EDIT_MODE: bool = true; // Edit mode flag
/// Handle actions in edit mode with edit-specific cursor behavior
pub async fn handle_edit_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
input.insert(cursor_pos, c);
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos + 1;
Ok(ActionResult::success())
}
CanvasAction::DeleteBackward => {
let cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
let input = state.get_current_input_mut();
input.remove(cursor_pos - 1);
state.set_current_cursor_pos(cursor_pos - 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::DeleteForward => {
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
if cursor_pos < input.len() {
input.remove(cursor_pos);
state.set_has_unsaved_changes(true);
}
Ok(ActionResult::success())
}
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 => {
// 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);
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);
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::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::MoveFirstLine => {
state.set_current_field(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 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());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
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());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_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 => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(current_field + 1) % total_fields
} else {
(current_field + 1).min(total_fields - 1)
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
} else {
current_field.saturating_sub(1)
}
}
_ => unreachable!(),
};
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())
}
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"))
}
}
}

View File

@@ -1,106 +0,0 @@
// src/canvas/actions/handlers/highlight.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
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,
config: Option<&CanvasConfig>,
) -> 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"))
}
}
}

View File

@@ -1,10 +0,0 @@
// src/canvas/actions/handlers/mod.rs
pub mod edit;
pub mod readonly;
pub mod highlight;
// Re-export handler functions
pub use edit::handle_edit_action;
pub use readonly::handle_readonly_action;
pub use highlight::handle_highlight_action;

View File

@@ -1,193 +0,0 @@
// src/canvas/actions/handlers/readonly.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
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,
config: Option<&CanvasConfig>,
) -> 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 => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(current_field + 1) % total_fields
} else {
(current_field + 1).min(total_fields - 1)
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 }
} else {
current_field.saturating_sub(1)
}
}
_ => 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"))
}
}
}

View File

@@ -2,7 +2,6 @@
pub mod types;
pub mod movement;
pub mod handlers;
// Re-export the main types
// Re-export the main API
pub use types::{CanvasAction, ActionResult};

View File

@@ -1,35 +1,34 @@
// src/canvas/actions/types.rs
/// 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,
// Field movement
NextField,
PrevField,
MoveFirstLine,
MoveLastLine,
// Word movement
MoveWordNext,
MoveWordEnd,
MoveWordPrev,
MoveWordEndPrev,
// Field navigation
NextField,
PrevField,
// Editing actions
InsertChar(char),
DeleteBackward,
DeleteForward,
// Autocomplete actions
TriggerAutocomplete,
@@ -42,67 +41,131 @@ pub enum CanvasAction {
Custom(String),
}
impl CanvasAction {
/// Convert string action name to CanvasAction enum (config-driven)
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,
"move_word_end_prev" => Self::MoveWordEndPrev,
"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,
}
}
}
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
)
}
}

View File

@@ -0,0 +1,45 @@
// src/canvas/cursor.rs
//! Cursor style management for different canvas modes
#[cfg(feature = "cursor-style")]
use crossterm::{cursor::SetCursorStyle, execute};
#[cfg(feature = "cursor-style")]
use std::io;
use crate::canvas::modes::AppMode;
/// Manages cursor styles based on canvas modes
pub struct CursorManager;
impl CursorManager {
/// Update cursor style based on current mode
#[cfg(feature = "cursor-style")]
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
};
execute!(io::stdout(), style)
}
/// No-op when cursor-style feature is disabled
#[cfg(not(feature = "cursor-style"))]
pub fn update_for_mode(_mode: AppMode) -> io::Result<()> {
Ok(())
}
/// Reset cursor to default on cleanup
#[cfg(feature = "cursor-style")]
pub fn reset() -> io::Result<()> {
execute!(io::stdout(), SetCursorStyle::DefaultUserShape)
}
#[cfg(not(feature = "cursor-style"))]
pub fn reset() -> io::Result<()> {
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
// canvas/src/canvas/gui.rs
// src/canvas/gui.rs
//! Canvas GUI updated to work with FormEditor
#[cfg(feature = "gui")]
use ratatui::{
@@ -9,28 +10,42 @@ use ratatui::{
Frame,
};
use crate::canvas::state::CanvasState;
use crate::canvas::modes::HighlightState;
#[cfg(feature = "gui")]
use crate::canvas::theme::CanvasTheme;
use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete
/// Updated to work with FormEditor instead of CanvasState trait
#[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme>(
pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
editor: &FormEditor<D>,
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
let fields: Vec<&str> = form_state.fields();
let current_field_idx = form_state.current_field();
let inputs: Vec<&String> = form_state.inputs();
let ui_state = editor.ui_state();
let data_provider = editor.data_provider();
// Build field information
let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<String> = Vec::with_capacity(field_count);
for i in 0..field_count {
fields.push(data_provider.field_name(i));
inputs.push(data_provider.field_value(i).to_string());
}
let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
// For now, create a default highlight state (TODO: get from editor state)
let highlight_state = HighlightState::Off;
render_canvas_fields(
f,
@@ -40,11 +55,13 @@ pub fn render_canvas<T: CanvasTheme>(
&inputs,
theme,
is_edit_mode,
highlight_state,
form_state.current_cursor_pos(),
form_state.has_unsaved_changes(),
|i| form_state.get_display_value_for_field(i).to_string(),
|i| form_state.has_display_override(i),
&highlight_state,
ui_state.cursor_position(),
false, // TODO: track unsaved changes in editor
|i| {
data_provider.display_value(i).unwrap_or(data_provider.field_value(i)).to_string()
},
|i| data_provider.display_value(i).is_some(),
)
}
@@ -55,7 +72,7 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
inputs: &[String],
theme: &T,
is_edit_mode: bool,
highlight_state: &HighlightState,
@@ -112,7 +129,7 @@ where
// Render field values and return active field rect
render_field_values(
f,
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
input_rows.to_vec(),
inputs,
current_field_idx,
theme,
@@ -154,7 +171,7 @@ fn render_field_labels<T: CanvasTheme>(
fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[&String],
inputs: &[String],
current_field_idx: &usize,
theme: &T,
highlight_state: &HighlightState,
@@ -336,3 +353,14 @@ fn set_cursor_position(
let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
}
/// Set default theme if custom not specified
#[cfg(feature = "gui")]
pub fn render_canvas_default<D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
) -> Option<Rect> {
let theme = DefaultCanvasTheme::default();
render_canvas(f, area, editor, &theme)
}

View File

@@ -1,20 +1,19 @@
// src/canvas/mod.rs
pub mod actions;
pub mod gui;
pub mod modes;
pub mod state;
pub mod modes;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "gui")]
pub mod theme;
// Re-export commonly used canvas types
pub use actions::{CanvasAction, ActionResult};
#[cfg(feature = "cursor-style")]
pub mod cursor;
// Keep these exports for current functionality
pub use modes::{AppMode, ModeManager, HighlightState};
pub use state::{CanvasState, ActionContext};
// Re-export the main entry point
pub use crate::dispatcher::execute_canvas_action;
#[cfg(feature = "gui")]
pub use theme::CanvasTheme;
#[cfg(feature = "gui")]
pub use gui::render_canvas;
#[cfg(feature = "cursor-style")]
pub use cursor::CursorManager;

View File

@@ -1,6 +1,8 @@
// src/modes/handlers/mode_manager.rs
// canvas/src/modes/manager.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
@@ -30,4 +32,39 @@ impl ModeManager {
pub fn can_enter_highlight_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly)
}
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled)
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
if current_mode != new_mode {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
Ok(new_mode)
}
/// Enter highlight mode with cursor styling
pub fn enter_highlight_mode_with_cursor(current_mode: AppMode) -> std::io::Result<bool> {
if Self::can_enter_highlight_mode(current_mode) {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
Ok(true)
} else {
Ok(false)
}
}
/// Exit highlight mode with cursor styling
pub fn exit_highlight_mode_with_cursor() -> std::io::Result<AppMode> {
let new_mode = AppMode::ReadOnly;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
Ok(new_mode)
}
}

View File

@@ -1,56 +1,137 @@
// src/canvas/state.rs
//! Library-owned UI state - user never directly modifies this
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode;
/// Context passed to feature-specific action handlers
#[derive(Debug)]
pub struct ActionContext {
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
pub ideal_cursor_column: usize,
pub current_input: String,
pub current_field: usize,
/// Library-owned UI state - user never directly modifies this
#[derive(Debug, Clone)]
pub struct EditorState {
// Navigation state
pub(crate) current_field: usize,
pub(crate) cursor_pos: usize,
pub(crate) ideal_cursor_column: usize,
// Mode state
pub(crate) current_mode: AppMode,
// Autocomplete state
pub(crate) autocomplete: AutocompleteUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
}
/// Core trait that any form-like state must implement to work with the canvas system.
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
/// any implementation - login forms, data entry forms, configuration screens, etc.
pub trait CanvasState {
// --- Core Navigation ---
fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize;
fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize);
#[derive(Debug, Clone)]
pub struct AutocompleteUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
}
// --- Mode Information ---
fn current_mode(&self) -> AppMode;
#[derive(Debug, Clone)]
pub enum SelectionState {
None,
Characterwise { anchor: (usize, usize) },
Linewise { anchor_field: usize },
}
// --- Data Access ---
fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String;
fn inputs(&self) -> Vec<&String>;
fn fields(&self) -> Vec<&str>;
// --- State Management ---
fn has_unsaved_changes(&self) -> bool;
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Feature-specific action handling ---
/// Feature-specific action handling (Type-safe)
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
None // Default: no feature-specific handling
impl EditorState {
pub fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
ideal_cursor_column: 0,
current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
},
selection: SelectionState::None,
}
}
// --- Display Overrides (for links, computed values, etc.) ---
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
// ===================================================================
// READ-ONLY ACCESS: User can fetch UI state for compatibility
// ===================================================================
/// Get current field index (for user's business logic)
pub fn current_field(&self) -> usize {
self.current_field
}
fn has_display_override(&self, _index: usize) -> bool {
false
/// Get current cursor position (for user's business logic)
pub fn cursor_position(&self) -> usize {
self.cursor_pos
}
/// Get ideal cursor column (for vim-like behavior)
pub fn ideal_cursor_column(&self) -> usize { // ADD THIS
self.ideal_cursor_column
}
/// Get current mode (for user's business logic)
pub fn mode(&self) -> AppMode {
self.current_mode
}
/// Check if autocomplete is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool {
self.autocomplete.is_active
}
/// Check if autocomplete is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool {
self.autocomplete.is_loading
}
/// Get selection state (for user's business logic)
pub fn selection_state(&self) -> &SelectionState {
&self.selection
}
// ===================================================================
// INTERNAL MUTATIONS: Only library modifies these
// ===================================================================
pub(crate) fn move_to_field(&mut self, field_index: usize, field_count: usize) {
if field_index < field_count {
self.current_field = field_index;
// Reset cursor to safe position - will be clamped by movement logic
self.cursor_pos = 0;
}
}
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
if for_edit_mode {
// Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position);
} else {
// ReadOnly/Highlight: stay within text bounds
self.cursor_pos = position.min(max_position.saturating_sub(1));
}
self.ideal_cursor_column = self.cursor_pos;
}
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) {
self.autocomplete.is_active = true;
self.autocomplete.is_loading = true;
self.autocomplete.active_field = Some(field_index);
self.autocomplete.selected_index = None;
}
pub(crate) fn deactivate_autocomplete(&mut self) {
self.autocomplete.is_active = false;
self.autocomplete.is_loading = false;
self.autocomplete.active_field = None;
self.autocomplete.selected_index = None;
}
}
impl Default for EditorState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -15,3 +15,36 @@ pub trait CanvasTheme {
fn highlight_bg(&self) -> Color;
fn warning(&self) -> Color;
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Default)]
pub struct DefaultCanvasTheme;
#[cfg(feature = "gui")]
impl CanvasTheme for DefaultCanvasTheme {
fn bg(&self) -> Color {
Color::Black
}
fn fg(&self) -> Color {
Color::White
}
fn border(&self) -> Color {
Color::DarkGray
}
fn accent(&self) -> Color {
Color::Cyan
}
fn secondary(&self) -> Color {
Color::Gray
}
fn highlight(&self) -> Color {
Color::Yellow
}
fn highlight_bg(&self) -> Color {
Color::Blue
}
fn warning(&self) -> Color {
Color::Red
}
}

View File

@@ -1,363 +0,0 @@
// canvas/src/config.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use anyhow::{Context, Result};
use super::registry::{ActionRegistry, ActionSpec, ModeRegistry};
use super::validation::{ConfigValidator, ValidationError, ValidationResult, ValidationWarning};
#[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 {
/// NEW: Load and validate configuration
pub fn load() -> Self {
match Self::load_and_validate() {
Ok(config) => config,
Err(e) => {
eprintln!("⚠️ Canvas config validation failed: {}", e);
eprintln!(" Using vim defaults. Run CanvasConfig::generate_template() for help.");
Self::default()
}
}
}
/// NEW: Load configuration with validation
pub fn load_and_validate() -> Result<Self> {
// Try to load canvas_config.toml from current directory
let config = if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
config
} else {
// Fallback to vim defaults
Self::default()
};
// Validate the configuration
let validator = ConfigValidator::new();
let validation_result = validator.validate_keybindings(&config.keybindings);
if !validation_result.is_valid {
// Print validation errors
validator.print_validation_result(&validation_result);
// Create error with suggestions
let error_msg = format!(
"Configuration validation failed with {} errors",
validation_result.errors.len()
);
return Err(anyhow::anyhow!(error_msg));
}
// Print warnings if any
if !validation_result.warnings.is_empty() {
validator.print_validation_result(&validation_result);
}
Ok(config)
}
/// NEW: Generate a complete configuration template
pub fn generate_template() -> String {
let registry = ActionRegistry::new();
registry.generate_config_template()
}
/// NEW: Generate a clean, minimal configuration template
pub fn generate_clean_template() -> String {
let registry = ActionRegistry::new();
registry.generate_clean_template()
}
/// NEW: Validate current configuration
pub fn validate(&self) -> ValidationResult {
let validator = ConfigValidator::new();
validator.validate_keybindings(&self.keybindings)
}
/// NEW: Print validation results for current config
pub fn print_validation(&self) {
let validator = ConfigValidator::new();
let result = validator.validate_keybindings(&self.keybindings);
validator.print_validation_result(&result);
}
/// NEW: Generate config for missing required actions
pub fn generate_missing_config(&self) -> String {
let validator = ConfigValidator::new();
validator.generate_missing_config(&self.keybindings)
}
/// 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")
}
// ... rest of your existing methods stay the same ...
/// 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)
}
}
// ... keep all your existing private methods ...
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 {
// ... keep all your existing key matching logic ...
// (This is a very long method, so I'm just indicating to keep it as-is)
// Your existing implementation here...
true // placeholder - use your actual implementation
}
/// 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());
// NEW: Show validation status
let validation = self.validate();
if validation.is_valid {
println!(" ✅ Configuration is valid");
} else {
println!(" ❌ Configuration has {} errors", validation.errors.len());
}
if !validation.warnings.is_empty() {
println!(" ⚠️ Configuration has {} warnings", validation.warnings.len());
}
}
}
// Re-export for convenience
pub use crate::canvas::actions::CanvasAction;
pub use crate::dispatcher::ActionDispatcher;

View File

@@ -1,10 +0,0 @@
// src/config/mod.rs
mod registry;
mod config;
mod validation;
// Re-export everything from the main config module
pub use registry::*;
pub use validation::*;
pub use config::*;

View File

@@ -1,451 +0,0 @@
// src/config/registry.rs
use std::collections::HashMap;
use crate::canvas::modes::AppMode;
#[derive(Debug, Clone)]
pub struct ActionSpec {
pub name: String,
pub description: String,
pub examples: Vec<String>,
pub mode_specific: bool, // true if different behavior per mode
}
#[derive(Debug, Clone)]
pub struct ModeRegistry {
pub required: HashMap<String, ActionSpec>,
pub optional: HashMap<String, ActionSpec>,
pub auto_handled: Vec<String>, // Never appear in config
}
#[derive(Debug, Clone)]
pub struct ActionRegistry {
pub edit_mode: ModeRegistry,
pub readonly_mode: ModeRegistry,
pub suggestions: ModeRegistry,
pub global: ModeRegistry,
}
impl ActionRegistry {
pub fn new() -> Self {
Self {
edit_mode: Self::edit_mode_registry(),
readonly_mode: Self::readonly_mode_registry(),
suggestions: Self::suggestions_registry(),
global: Self::global_registry(),
}
}
fn edit_mode_registry() -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
// REQUIRED - These MUST be configured
required.insert("move_left".to_string(), ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["Left".to_string(), "h".to_string()],
mode_specific: false,
});
required.insert("move_right".to_string(), ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["Right".to_string(), "l".to_string()],
mode_specific: false,
});
required.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field or line".to_string(),
examples: vec!["Up".to_string(), "k".to_string()],
mode_specific: false,
});
required.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Move to next field or line".to_string(),
examples: vec!["Down".to_string(), "j".to_string()],
mode_specific: false,
});
required.insert("delete_char_backward".to_string(), ActionSpec {
name: "delete_char_backward".to_string(),
description: "Delete character before cursor".to_string(),
examples: vec!["Backspace".to_string()],
mode_specific: false,
});
required.insert("next_field".to_string(), ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string(), "Enter".to_string()],
mode_specific: false,
});
required.insert("prev_field".to_string(), ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
mode_specific: false,
});
// OPTIONAL - These can be configured or omitted
optional.insert("move_word_next".to_string(), ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["Ctrl+Right".to_string(), "w".to_string()],
mode_specific: false,
});
optional.insert("move_word_prev".to_string(), ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["Ctrl+Left".to_string(), "b".to_string()],
mode_specific: false,
});
optional.insert("move_word_end".to_string(), ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
mode_specific: false,
});
optional.insert("move_word_end_prev".to_string(), ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
mode_specific: false,
});
optional.insert("move_line_start".to_string(), ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["Home".to_string(), "0".to_string()],
mode_specific: false,
});
optional.insert("move_line_end".to_string(), ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["End".to_string(), "$".to_string()],
mode_specific: false,
});
optional.insert("move_first_line".to_string(), ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
mode_specific: false,
});
optional.insert("move_last_line".to_string(), ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
mode_specific: false,
});
optional.insert("delete_char_forward".to_string(), ActionSpec {
name: "delete_char_forward".to_string(),
description: "Delete character after cursor".to_string(),
examples: vec!["Delete".to_string()],
mode_specific: false,
});
ModeRegistry {
required,
optional,
auto_handled: vec![
"insert_char".to_string(), // Any printable character
],
}
}
fn readonly_mode_registry() -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
// REQUIRED - Navigation is essential in read-only mode
required.insert("move_left".to_string(), ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
mode_specific: true,
});
required.insert("move_right".to_string(), ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
mode_specific: true,
});
required.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
mode_specific: true,
});
required.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Move to next field".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
mode_specific: true,
});
// OPTIONAL - Advanced navigation
optional.insert("move_word_next".to_string(), ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["w".to_string()],
mode_specific: true,
});
optional.insert("move_word_prev".to_string(), ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["b".to_string()],
mode_specific: true,
});
optional.insert("move_word_end".to_string(), ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
mode_specific: true,
});
optional.insert("move_word_end_prev".to_string(), ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
mode_specific: true,
});
optional.insert("move_line_start".to_string(), ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["0".to_string()],
mode_specific: true,
});
optional.insert("move_line_end".to_string(), ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["$".to_string()],
mode_specific: true,
});
optional.insert("move_first_line".to_string(), ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["gg".to_string()],
mode_specific: true,
});
optional.insert("move_last_line".to_string(), ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["G".to_string()],
mode_specific: true,
});
optional.insert("next_field".to_string(), ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string()],
mode_specific: true,
});
optional.insert("prev_field".to_string(), ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
mode_specific: true,
});
ModeRegistry {
required,
optional,
auto_handled: vec![], // Read-only mode has no auto-handled actions
}
}
fn suggestions_registry() -> ModeRegistry {
let mut required = HashMap::new();
// REQUIRED - Essential for suggestion navigation
required.insert("suggestion_up".to_string(), ActionSpec {
name: "suggestion_up".to_string(),
description: "Move selection to previous suggestion".to_string(),
examples: vec!["Up".to_string(), "Ctrl+p".to_string()],
mode_specific: false,
});
required.insert("suggestion_down".to_string(), ActionSpec {
name: "suggestion_down".to_string(),
description: "Move selection to next suggestion".to_string(),
examples: vec!["Down".to_string(), "Ctrl+n".to_string()],
mode_specific: false,
});
required.insert("select_suggestion".to_string(), ActionSpec {
name: "select_suggestion".to_string(),
description: "Select the currently highlighted suggestion".to_string(),
examples: vec!["Enter".to_string(), "Tab".to_string()],
mode_specific: false,
});
required.insert("exit_suggestions".to_string(), ActionSpec {
name: "exit_suggestions".to_string(),
description: "Close suggestions without selecting".to_string(),
examples: vec!["Esc".to_string()],
mode_specific: false,
});
ModeRegistry {
required,
optional: HashMap::new(),
auto_handled: vec![],
}
}
fn global_registry() -> ModeRegistry {
let mut optional = HashMap::new();
// OPTIONAL - Global overrides
optional.insert("move_up".to_string(), ActionSpec {
name: "move_up".to_string(),
description: "Global override for up movement".to_string(),
examples: vec!["Up".to_string()],
mode_specific: false,
});
optional.insert("move_down".to_string(), ActionSpec {
name: "move_down".to_string(),
description: "Global override for down movement".to_string(),
examples: vec!["Down".to_string()],
mode_specific: false,
});
ModeRegistry {
required: HashMap::new(),
optional,
auto_handled: vec![],
}
}
pub fn get_mode_registry(&self, mode: &str) -> &ModeRegistry {
match mode {
"edit" => &self.edit_mode,
"read_only" => &self.readonly_mode,
"suggestions" => &self.suggestions,
"global" => &self.global,
_ => &self.global, // fallback
}
}
pub fn all_known_actions(&self) -> Vec<String> {
let mut actions = Vec::new();
for registry in [&self.edit_mode, &self.readonly_mode, &self.suggestions, &self.global] {
actions.extend(registry.required.keys().cloned());
actions.extend(registry.optional.keys().cloned());
}
actions.sort();
actions.dedup();
actions
}
pub fn generate_config_template(&self) -> String {
let mut template = String::new();
template.push_str("# Canvas Library Configuration Template\n");
template.push_str("# Generated automatically - customize as needed\n\n");
template.push_str("[keybindings.edit]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.edit_mode.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &self.edit_mode.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
template.push_str("[keybindings.read_only]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.readonly_mode.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &self.readonly_mode.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
template.push_str("[keybindings.suggestions]\n");
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &self.suggestions.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
template
}
pub fn generate_clean_template(&self) -> String {
let mut template = String::new();
// Edit Mode
template.push_str("[keybindings.edit]\n");
template.push_str("# Required\n");
for (name, spec) in &self.edit_mode.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push_str("# Optional\n");
for (name, spec) in &self.edit_mode.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Read-Only Mode
template.push_str("[keybindings.read_only]\n");
template.push_str("# Required\n");
for (name, spec) in &self.readonly_mode.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push_str("# Optional\n");
for (name, spec) in &self.readonly_mode.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Suggestions Mode
template.push_str("[keybindings.suggestions]\n");
template.push_str("# Required\n");
for (name, spec) in &self.suggestions.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
template.push('\n');
// Global (all optional)
if !self.global.optional.is_empty() {
template.push_str("[keybindings.global]\n");
template.push_str("# Optional\n");
for (name, spec) in &self.global.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
template
}
}

View File

@@ -1,279 +0,0 @@
// src/config/validation.rs
use std::collections::HashMap;
use thiserror::Error;
use crate::config::registry::{ActionRegistry, ModeRegistry};
use crate::config::CanvasKeybindings;
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Missing required action '{action}' in {mode} mode")]
MissingRequired {
action: String,
mode: String,
suggestion: String,
},
#[error("Unknown action '{action}' in {mode} mode")]
UnknownAction {
action: String,
mode: String,
similar: Vec<String>,
},
#[error("Multiple validation errors")]
Multiple(Vec<ValidationError>),
}
#[derive(Debug)]
pub struct ValidationWarning {
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub is_valid: bool,
}
impl ValidationResult {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
is_valid: true,
}
}
pub fn add_error(&mut self, error: ValidationError) {
self.errors.push(error);
self.is_valid = false;
}
pub fn add_warning(&mut self, warning: ValidationWarning) {
self.warnings.push(warning);
}
pub fn merge(&mut self, other: ValidationResult) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
if !other.is_valid {
self.is_valid = false;
}
}
}
pub struct ConfigValidator {
registry: ActionRegistry,
}
impl ConfigValidator {
pub fn new() -> Self {
Self {
registry: ActionRegistry::new(),
}
}
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
let mut result = ValidationResult::new();
// Validate each mode
result.merge(self.validate_mode_bindings(
"edit",
&keybindings.edit,
self.registry.get_mode_registry("edit")
));
result.merge(self.validate_mode_bindings(
"read_only",
&keybindings.read_only,
self.registry.get_mode_registry("read_only")
));
result.merge(self.validate_mode_bindings(
"suggestions",
&keybindings.suggestions,
self.registry.get_mode_registry("suggestions")
));
result.merge(self.validate_mode_bindings(
"global",
&keybindings.global,
self.registry.get_mode_registry("global")
));
result
}
fn validate_mode_bindings(
&self,
mode_name: &str,
bindings: &HashMap<String, Vec<String>>,
registry: &ModeRegistry
) -> ValidationResult {
let mut result = ValidationResult::new();
// Check for missing required actions
for (action_name, spec) in &registry.required {
if !bindings.contains_key(action_name) {
result.add_error(ValidationError::MissingRequired {
action: action_name.clone(),
mode: mode_name.to_string(),
suggestion: format!(
"Add to config: {} = {:?}",
action_name,
spec.examples
),
});
}
}
// Check for unknown actions
let all_known: std::collections::HashSet<_> = registry.required.keys()
.chain(registry.optional.keys())
.collect();
for action_name in bindings.keys() {
if !all_known.contains(action_name) {
let similar = self.find_similar_actions(action_name, &all_known);
result.add_error(ValidationError::UnknownAction {
action: action_name.clone(),
mode: mode_name.to_string(),
similar,
});
}
}
// Check for empty keybinding arrays
for (action_name, key_list) in bindings {
if key_list.is_empty() {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode has empty keybinding list",
action_name, mode_name
),
suggestion: Some(format!(
"Either add keybindings or remove the action from config"
)),
});
}
}
// Warn about auto-handled actions that shouldn't be in config
for auto_action in &registry.auto_handled {
if bindings.contains_key(auto_action) {
result.add_warning(ValidationWarning {
message: format!(
"Action '{}' in {} mode is auto-handled and shouldn't be in config",
auto_action, mode_name
),
suggestion: Some(format!(
"Remove '{}' from config - it's handled automatically",
auto_action
)),
});
}
}
result
}
fn find_similar_actions(&self, action: &str, known_actions: &std::collections::HashSet<&String>) -> Vec<String> {
let mut similar = Vec::new();
for known in known_actions {
if self.is_similar(action, known) {
similar.push(known.to_string());
}
}
similar.sort();
similar.truncate(3); // Limit to 3 suggestions
similar
}
fn is_similar(&self, a: &str, b: &str) -> bool {
// Simple similarity check - could be improved with proper edit distance
let a_lower = a.to_lowercase();
let b_lower = b.to_lowercase();
// Check if one contains the other
if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) {
return true;
}
// Check for common prefixes
let common_prefixes = ["move_", "delete_", "suggestion_"];
for prefix in &common_prefixes {
if a_lower.starts_with(prefix) && b_lower.starts_with(prefix) {
return true;
}
}
false
}
pub fn print_validation_result(&self, result: &ValidationResult) {
if result.is_valid && result.warnings.is_empty() {
println!("✅ Canvas configuration is valid!");
return;
}
if !result.errors.is_empty() {
println!("❌ Canvas configuration has errors:");
for error in &result.errors {
match error {
ValidationError::MissingRequired { action, mode, suggestion } => {
println!(" • Missing required action '{}' in {} mode", action, mode);
println!(" 💡 {}", suggestion);
}
ValidationError::UnknownAction { action, mode, similar } => {
println!(" • Unknown action '{}' in {} mode", action, mode);
if !similar.is_empty() {
println!(" 💡 Did you mean: {}", similar.join(", "));
}
}
ValidationError::Multiple(_) => {
println!(" • Multiple errors occurred");
}
}
println!();
}
}
if !result.warnings.is_empty() {
println!("⚠️ Canvas configuration has warnings:");
for warning in &result.warnings {
println!("{}", warning.message);
if let Some(suggestion) = &warning.suggestion {
println!(" 💡 {}", suggestion);
}
println!();
}
}
if !result.is_valid {
println!("🔧 To generate a config template, use:");
println!(" CanvasConfig::generate_template()");
}
}
pub fn generate_missing_config(&self, keybindings: &CanvasKeybindings) -> String {
let mut config = String::new();
let validation = self.validate_keybindings(keybindings);
for error in &validation.errors {
if let ValidationError::MissingRequired { action, mode, suggestion } = error {
if config.is_empty() {
config.push_str(&format!("# Missing required actions for canvas\n\n"));
config.push_str(&format!("[keybindings.{}]\n", mode));
}
config.push_str(&format!("{}\n", suggestion));
}
}
config
}
}

View File

@@ -0,0 +1,44 @@
// src/data_provider.rs
//! Simplified user interface - only business data, no UI state
use anyhow::Result;
use async_trait::async_trait;
/// User implements this - only business data, no UI state
pub trait DataProvider {
/// How many fields in the form
fn field_count(&self) -> usize;
/// Get field label/name
fn field_name(&self, index: usize) -> &str;
/// Get field value
fn field_value(&self, index: usize) -> &str;
/// Set field value (library calls this when text changes)
fn set_field_value(&mut self, index: usize, value: String);
/// Check if field supports autocomplete (optional)
fn supports_autocomplete(&self, _field_index: usize) -> bool {
false
}
/// Get display value (for password masking, etc.) - optional
fn display_value(&self, _index: usize) -> Option<&str> {
None // Default: use actual value
}
}
/// Optional: User implements this for autocomplete data
#[async_trait]
pub trait AutocompleteProvider {
/// Fetch autocomplete suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem>>;
}
#[derive(Debug, Clone)]
pub struct SuggestionItem {
pub display_text: String,
pub value_to_store: String,
}

View File

@@ -1,110 +0,0 @@
// src/dispatcher.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::{CanvasAction, ActionResult};
use crate::canvas::actions::handlers::{handle_edit_action, handle_readonly_action, handle_highlight_action};
use crate::canvas::modes::AppMode;
use crate::config::CanvasConfig;
use crossterm::event::{KeyCode, KeyModifiers};
/// Main entry point for executing canvas actions
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
ActionDispatcher::dispatch_with_config(action, state, ideal_cursor_column, config).await
}
/// High-level action dispatcher that routes actions to mode-specific handlers
pub struct ActionDispatcher;
impl ActionDispatcher {
/// Dispatch any action to the appropriate mode handler
pub async fn dispatch<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> anyhow::Result<ActionResult> {
let config = CanvasConfig::load();
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
}
/// Dispatch action with provided config
pub async fn dispatch_with_config<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> anyhow::Result<ActionResult> {
// Check for feature-specific handling 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, config).await
}
AppMode::ReadOnly => {
handle_readonly_action(action, state, ideal_cursor_column, config).await
}
AppMode::Highlight => {
handle_highlight_action(action, state, ideal_cursor_column, config).await
}
AppMode::General | AppMode::Command => {
// These modes might not handle canvas actions directly
Ok(ActionResult::success_with_message("Mode does not handle canvas actions"))
}
}
}
/// Quick action dispatch from KeyCode using config
pub async fn dispatch_key<S: CanvasState>(
key: KeyCode,
modifiers: KeyModifiers,
state: &mut S,
ideal_cursor_column: &mut usize,
is_edit_mode: bool,
has_suggestions: bool,
) -> anyhow::Result<Option<ActionResult>> {
let config = CanvasConfig::load();
if let Some(action_name) = config.get_action_for_key(key, modifiers, is_edit_mode, has_suggestions) {
let action = CanvasAction::from_string(action_name);
let result = Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).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();
results.push(result);
// Stop on first error
if !is_success {
break;
}
}
Ok(results)
}
}

543
canvas/src/editor.rs Normal file
View File

@@ -0,0 +1,543 @@
// src/editor.rs
//! Main API for the canvas library - FormEditor with library-owned state
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use anyhow::Result;
use crate::canvas::state::EditorState;
use crate::data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
use crate::canvas::modes::AppMode;
/// Main editor that manages UI state internally and delegates data to user
pub struct FormEditor<D: DataProvider> {
// Library owns all UI state
ui_state: EditorState,
// User owns business data
data_provider: D,
// Autocomplete suggestions (library manages UI, user provides data)
pub(crate) suggestions: Vec<SuggestionItem>,
}
impl<D: DataProvider> FormEditor<D> {
pub fn new(data_provider: D) -> Self {
Self {
ui_state: EditorState::new(),
data_provider,
suggestions: Vec::new(),
}
}
// ===================================================================
// READ-ONLY ACCESS: User can fetch UI state
// ===================================================================
/// Get current field index (for user's compatibility)
pub fn current_field(&self) -> usize {
self.ui_state.current_field()
}
/// Get current cursor position (for user's compatibility)
pub fn cursor_position(&self) -> usize {
self.ui_state.cursor_position()
}
/// Get current mode (for user's mode-dependent logic)
pub fn mode(&self) -> AppMode {
self.ui_state.mode()
}
/// Check if autocomplete is active (for user's logic)
pub fn is_autocomplete_active(&self) -> bool {
self.ui_state.is_autocomplete_active()
}
/// Get current field text (convenience method)
pub fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;
if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
}
}
/// Get reference to UI state for rendering
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
/// Get reference to data provider for rendering
pub fn data_provider(&self) -> &D {
&self.data_provider
}
/// Get autocomplete suggestions for rendering (read-only)
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
// ===================================================================
// SYNC OPERATIONS: No async needed for basic editing
// ===================================================================
/// Handle character insertion
pub fn insert_char(&mut self, ch: char) -> Result<()> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let cursor_pos = self.ui_state.cursor_pos;
// Get current text from user
let mut current_text = self.data_provider.field_value(field_index).to_string();
// Insert character
current_text.insert(cursor_pos, ch);
// Update user's data
self.data_provider.set_field_value(field_index, current_text);
// Update library's UI state
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Handle cursor movement
pub fn move_left(&mut self) {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
pub fn move_right(&mut self) {
let current_text = self.current_text();
let max_pos = if self.ui_state.current_mode == AppMode::Edit {
current_text.len() // Edit mode: can go past end
} else {
current_text.len().saturating_sub(1) // ReadOnly: stay in bounds
};
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
/// Handle field navigation
pub fn move_to_next_field(&mut self) {
let field_count = self.data_provider.field_count();
let next_field = (self.ui_state.current_field + 1) % field_count;
self.ui_state.move_to_field(next_field, field_count);
// Clamp cursor to new field
let current_text = self.current_text();
let max_pos = current_text.len();
self.ui_state.set_cursor(
self.ui_state.ideal_cursor_column,
max_pos,
self.ui_state.current_mode == AppMode::Edit
);
}
/// Change mode (for vim compatibility)
pub fn set_mode(&mut self, mode: AppMode) {
#[cfg(feature = "cursor-style")]
let old_mode = self.ui_state.current_mode;
self.ui_state.current_mode = mode;
// Clear autocomplete when changing modes
if mode != AppMode::Edit {
self.ui_state.deactivate_autocomplete();
}
// Update cursor style if mode changed and cursor-style feature is enabled
#[cfg(feature = "cursor-style")]
if old_mode != mode {
let _ = crate::canvas::CursorManager::update_for_mode(mode);
}
}
// ===================================================================
// ASYNC OPERATIONS: Only autocomplete needs async
// ===================================================================
/// Trigger autocomplete (async because it fetches data)
pub async fn trigger_autocomplete<A>(&mut self, provider: &mut A) -> Result<()>
where
A: AutocompleteProvider,
{
let field_index = self.ui_state.current_field;
if !self.data_provider.supports_autocomplete(field_index) {
return Ok(());
}
// Activate autocomplete UI
self.ui_state.activate_autocomplete(field_index);
// Fetch suggestions from user (no conversion needed!)
let query = self.current_text();
self.suggestions = provider.fetch_suggestions(field_index, query).await?;
// Update UI state
self.ui_state.autocomplete.is_loading = false;
if !self.suggestions.is_empty() {
self.ui_state.autocomplete.selected_index = Some(0);
}
Ok(())
}
/// Navigate autocomplete suggestions
pub fn autocomplete_next(&mut self) {
if !self.ui_state.autocomplete.is_active || self.suggestions.is_empty() {
return;
}
let current = self.ui_state.autocomplete.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len();
self.ui_state.autocomplete.selected_index = Some(next);
}
/// Apply selected autocomplete suggestion
pub fn apply_autocomplete(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.autocomplete.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned() {
let field_index = self.ui_state.current_field;
// Apply to user's data
self.data_provider.set_field_value(
field_index,
suggestion.value_to_store.clone()
);
// Update cursor position
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
// Close autocomplete
self.ui_state.deactivate_autocomplete();
self.suggestions.clear();
return Some(suggestion.display_text);
}
}
None
}
// ===================================================================
// ADD THESE MISSING MOVEMENT METHODS
// ===================================================================
/// Move to previous field (vim k / up arrow)
pub fn move_up(&mut self) {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
}
let current_field = self.ui_state.current_field;
let new_field = current_field.saturating_sub(1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to next field (vim j / down arrow)
pub fn move_down(&mut self) {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
}
let current_field = self.ui_state.current_field;
let new_field = (current_field + 1).min(field_count - 1);
self.ui_state.move_to_field(new_field, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to first field (vim gg)
pub fn move_first_line(&mut self) {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
}
self.ui_state.move_to_field(0, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to last field (vim G)
pub fn move_last_line(&mut self) {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return;
}
let last_field = field_count - 1;
self.ui_state.move_to_field(last_field, field_count);
self.clamp_cursor_to_current_field();
}
/// Move to previous field (alternative to move_up)
pub fn prev_field(&mut self) {
self.move_up();
}
/// Move to next field (alternative to move_down)
pub fn next_field(&mut self) {
self.move_down();
}
/// Move to start of current field (vim 0)
pub fn move_line_start(&mut self) {
use crate::canvas::actions::movement::line::line_start_position;
let new_pos = line_start_position();
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current field (vim $)
pub fn move_line_end(&mut self) {
use crate::canvas::actions::movement::line::line_end_position;
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to start of next word (vim w)
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_next_word_start(current_text, self.ui_state.cursor_pos);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let final_pos = if is_edit_mode {
new_pos.min(current_text.len())
} else {
new_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
/// Move to start of previous word (vim b)
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_start(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current/next word (vim e)
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_word_end(current_text, current_pos);
// If we didn't move, try next word
let final_pos = if new_pos == current_pos && current_pos + 1 < current_text.len() {
find_word_end(current_text, current_pos + 1)
} else {
new_pos
};
// Clamp for read-only mode
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let clamped_pos = if is_edit_mode {
final_pos.min(current_text.len())
} else {
final_pos.min(current_text.len().saturating_sub(1))
};
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
/// Move to end of previous word (vim ge)
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
return;
}
let new_pos = find_prev_word_end(current_text, self.ui_state.cursor_pos);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Delete character before cursor (vim x in insert mode / backspace)
pub fn delete_backward(&mut self) -> Result<()> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
if self.ui_state.cursor_pos == 0 {
return Ok(()); // Nothing to delete
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos <= current_text.len() {
current_text.remove(self.ui_state.cursor_pos - 1);
self.data_provider.set_field_value(field_index, current_text);
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
Ok(())
}
/// Delete character under cursor (vim x / delete key)
pub fn delete_forward(&mut self) -> Result<()> {
if self.ui_state.current_mode != AppMode::Edit {
return Ok(()); // Silently ignore in non-edit modes
}
let field_index = self.ui_state.current_field;
let mut current_text = self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.len() {
current_text.remove(self.ui_state.cursor_pos);
self.data_provider.set_field_value(field_index, current_text);
}
Ok(())
}
/// Exit edit mode to read-only mode (vim Escape)
pub fn exit_edit_mode(&mut self) {
self.set_mode(AppMode::ReadOnly);
// Deactivate autocomplete when exiting edit mode
self.ui_state.deactivate_autocomplete();
}
/// Enter edit mode from read-only mode (vim i/a/o)
pub fn enter_edit_mode(&mut self) {
self.set_mode(AppMode::Edit);
}
// ===================================================================
// HELPER METHODS
// ===================================================================
/// Clamp cursor position to valid bounds for current field and mode
fn clamp_cursor_to_current_field(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
use crate::canvas::actions::movement::line::safe_cursor_position;
let safe_pos = safe_cursor_position(
current_text,
self.ui_state.ideal_cursor_column,
is_edit_mode
);
self.ui_state.cursor_pos = safe_pos;
}
/// Set the value of the current field
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value);
// Reset cursor to start of field
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
/// Set the value of a specific field by index
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider.set_field_value(field_index, value);
// If we're modifying the current field, reset cursor
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
}
}
/// Clear the current field (set to empty string)
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
/// Get mutable access to data provider (for advanced operations)
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
}
/// Set cursor to exact position (for vim-style movements like f, F, t, T)
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
// Clamp to valid bounds for current mode
let max_pos = if is_edit_mode {
current_text.len() // Edit mode: can go past end
} else {
current_text.len().saturating_sub(1).max(0) // Read-only: stay within text
};
let clamped_pos = position.min(max_pos);
// Update cursor position directly
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
/// Cleanup cursor style (call this when shutting down)
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
#[cfg(feature = "cursor-style")]
{
crate::canvas::CursorManager::reset()
}
#[cfg(not(feature = "cursor-style"))]
{
Ok(())
}
}
}
// Add Drop implementation for automatic cleanup
impl<D: DataProvider> Drop for FormEditor<D> {
fn drop(&mut self) {
// Reset cursor to default when FormEditor is dropped
let _ = self.cleanup_cursor();
}
}

View File

@@ -1,11 +1,40 @@
// src/lib.rs
pub mod canvas;
pub mod autocomplete;
pub mod config;
pub mod dispatcher;
// Re-export the main API for easy access
pub use dispatcher::{execute_canvas_action, ActionDispatcher};
pub mod canvas;
pub mod editor;
pub mod data_provider;
// Only include autocomplete module if feature is enabled
#[cfg(feature = "autocomplete")]
pub mod autocomplete;
#[cfg(feature = "cursor-style")]
pub use canvas::CursorManager;
// ===================================================================
// NEW API: Library-owned state pattern
// ===================================================================
// Main API exports
pub use editor::FormEditor;
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;
pub use canvas::modes::AppMode;
// Actions and results (for users who want to handle actions manually)
pub use canvas::actions::{CanvasAction, ActionResult};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, HighlightState, ModeManager};
// Theming and GUI
#[cfg(feature = "gui")]
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas;
#[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_default;
#[cfg(all(feature = "gui", feature = "autocomplete"))]
pub use autocomplete::gui::render_autocomplete_dropdown;

55
canvas/view_docs.sh Executable file
View 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
View File

@@ -0,0 +1 @@
canvas_config.toml.txt

View File

@@ -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"]

View File

@@ -42,10 +42,42 @@ next_entry = ["right","1"]
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"]
@@ -53,13 +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"]
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"]
@@ -77,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors]
theme = "dark"
# Options: "light", "dark", "high_contrast"

View File

@@ -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;
@@ -143,23 +143,46 @@ 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
/// NEW: Unified canvas action handler for any CanvasState with character fallback
/// Complete canvas action handler with fallbacks for common keys
/// Debug version to see what's happening
/// 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> {
println!("DEBUG: Key pressed: {:?}", key); // DEBUG
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
// Try direct key mapping first (same pattern as FormState)
// 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
// 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 {
@@ -176,62 +199,43 @@ async fn handle_canvas_state_edit<S: CanvasState>(
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
}
}
} else {
println!("DEBUG: No canvas config mapping found"); // DEBUG
// 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) {
println!("DEBUG: Client config mapped to: {}", 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));
// 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: No client config mapping found"); // DEBUG
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
}
// Character insertion fallback
if let KeyCode::Char(c) = key.code {
println!("DEBUG: Using character fallback 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) => {
return Ok(format!("Character insertion failed: {}", e));
}
}
}
println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
Ok(String::new())
}

View File

@@ -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,18 +1101,39 @@ 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();
// Get action from config - handles all modes (edit/read-only/suggestions)
// 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 {
Ok(result) => {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
Err(_) => {
return Ok(Some("Character insertion failed".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 {
@@ -1138,25 +1158,6 @@ impl EventHandler {
}
}
// Handle character insertion for edit mode (not in config)
if is_edit_mode {
if let KeyCode::Char(c) = key_event.code {
let canvas_action = CanvasAction::InsertChar(c);
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("Character insertion failed".to_string()));
}
}
}
}
// No action found
Ok(None)
}