Compare commits

...

20 Commits

Author SHA1 Message Date
Priec
8788323c62 fixed canvas library 2025-07-31 20:44:23 +02:00
Priec
5b64996462 example with debug stuff 2025-07-31 19:05:57 +02:00
Priec
3f4380ff48 documented code now 2025-07-31 17:29:03 +02:00
Priec
59a29aa54b not working example to canvas crate, improving and fixing now 2025-07-31 15:07:28 +02:00
Priec
5d084bf822 fixed working canvas in client, need more fixes now 2025-07-31 14:44:47 +02:00
Priec
ebe4adaa5d bug is present, i cant type or move in canvas from client 2025-07-31 13:39:38 +02:00
Priec
c3441647e0 docs and config adjustement 2025-07-31 13:18:27 +02:00
Priec
574803988d introspection to generated config now works 2025-07-31 12:31:21 +02:00
Priec
9ff3c59961 Remove canvas .toml files from git tracking and ensure they remain ignored 2025-07-31 11:37:56 +02:00
Priec
c5f22d7da1 canvas library config is now required 2025-07-31 11:16:21 +02:00
Priec
3c62877757 removing compatibility code fully, we are now fresh without compat layer. We compiled successfuly 2025-07-30 22:54:02 +02:00
Priec
cc19c61f37 new canvas library changed client for compatibility 2025-07-30 22:42:32 +02:00
Priec
ad82bd4302 canvas robust solution to movement 2025-07-30 22:02:52 +02:00
Priec
d584a25fdb removed hardcoded values from the canvas library 2025-07-30 21:16:16 +02:00
Priec
baa4295059 removed _e files completely 2025-07-30 20:25:58 +02:00
Priec
6cbfac9d6e read only deleted completely 2025-07-30 19:39:27 +02:00
Priec
13d28f19ea removing _ro files completely 2025-07-30 19:30:55 +02:00
Priec
8fa86965b8 removing canvasstate permanently 2025-07-30 19:20:23 +02:00
Priec
72c38f613f canvasstate is now officially nonexistent as dep 2025-07-30 19:14:35 +02:00
Priec
e4982f871f add_logic is now using canvas library 2025-07-30 18:02:59 +02:00
62 changed files with 3396 additions and 4159 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
server/tantivy_indexes
steel_decimal/tests/property_tests.proptest-regressions
.direnv/
canvas/*.toml

1
Cargo.lock generated
View File

@@ -479,6 +479,7 @@ dependencies = [
"crossterm",
"ratatui",
"serde",
"thiserror",
"tokio",
"tokio-test",
"toml",

View File

@@ -18,6 +18,7 @@ tokio = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
unicode-width.workspace = true
thiserror = { workspace = true }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
@@ -28,3 +29,7 @@ tokio-test = "0.4.4"
[features]
default = []
gui = ["ratatui"]
[[example]]
name = "ratatui_demo"
path = "examples/ratatui_demo.rs"

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"]
# Global keybindings (work in both modes)
[keybindings.global]
move_up = ["Up"]
move_down = ["Down"]

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,367 @@
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas,
modes::{AppMode, HighlightState, ModeManager},
state::{ActionContext, CanvasState},
theme::CanvasTheme,
},
config::CanvasConfig,
dispatcher::ActionDispatcher,
CanvasAction,
};
// Simple theme implementation
#[derive(Clone)]
struct DemoTheme;
impl CanvasTheme for DemoTheme {
fn bg(&self) -> Color { Color::Reset }
fn fg(&self) -> Color { Color::White }
fn accent(&self) -> Color { Color::Cyan }
fn secondary(&self) -> Color { Color::Gray }
fn highlight(&self) -> Color { Color::Yellow }
fn highlight_bg(&self) -> Color { Color::DarkGray }
fn warning(&self) -> Color { Color::Red }
fn border(&self) -> Color { Color::Gray }
}
// Demo form state
struct DemoFormState {
fields: Vec<String>,
field_names: Vec<String>,
current_field: usize,
cursor_pos: usize,
mode: AppMode,
highlight_state: HighlightState,
has_changes: bool,
ideal_cursor_column: usize,
last_action: Option<String>,
debug_message: String,
}
impl DemoFormState {
fn new() -> Self {
Self {
fields: vec![
"John Doe".to_string(),
"john.doe@example.com".to_string(),
"+1 234 567 8900".to_string(),
"123 Main Street Apt 4B".to_string(),
"San Francisco".to_string(),
"This is a test comment with multiple words".to_string(),
],
field_names: vec![
"Name".to_string(),
"Email".to_string(),
"Phone".to_string(),
"Address".to_string(),
"City".to_string(),
"Comments".to_string(),
],
current_field: 0,
cursor_pos: 0,
mode: AppMode::ReadOnly,
highlight_state: HighlightState::Off,
has_changes: false,
ideal_cursor_column: 0,
last_action: None,
debug_message: "Ready".to_string(),
}
}
fn enter_edit_mode(&mut self) {
if ModeManager::can_enter_edit_mode(self.mode) {
self.mode = AppMode::Edit;
self.debug_message = "Entered EDIT mode".to_string();
}
}
fn enter_readonly_mode(&mut self) {
if ModeManager::can_enter_read_only_mode(self.mode) {
self.mode = AppMode::ReadOnly;
self.highlight_state = HighlightState::Off;
self.debug_message = "Entered READ-ONLY mode".to_string();
}
}
fn enter_highlight_mode(&mut self) {
if ModeManager::can_enter_highlight_mode(self.mode) {
self.mode = AppMode::Highlight;
self.highlight_state = HighlightState::Characterwise {
anchor: (self.current_field, self.cursor_pos),
};
self.debug_message = "Entered VISUAL mode".to_string();
}
}
}
impl CanvasState for DemoFormState {
fn current_field(&self) -> usize {
self.current_field
}
fn current_cursor_pos(&self) -> usize {
self.cursor_pos
}
fn set_current_field(&mut self, index: usize) {
self.current_field = index.min(self.fields.len().saturating_sub(1));
self.cursor_pos = self.fields[self.current_field].len();
}
fn set_current_cursor_pos(&mut self, pos: usize) {
let max_pos = self.fields[self.current_field].len();
self.cursor_pos = pos.min(max_pos);
}
fn current_mode(&self) -> AppMode {
self.mode
}
fn get_current_input(&self) -> &str {
&self.fields[self.current_field]
}
fn get_current_input_mut(&mut self) -> &mut String {
&mut self.fields[self.current_field]
}
fn inputs(&self) -> Vec<&String> {
self.fields.iter().collect()
}
fn fields(&self) -> Vec<&str> {
self.field_names.iter().map(|s| s.as_str()).collect()
}
fn has_unsaved_changes(&self) -> bool {
self.has_changes
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => {
match cmd.as_str() {
"enter_edit_mode" => {
self.enter_edit_mode();
Some("Entered edit mode".to_string())
}
"enter_readonly_mode" => {
self.enter_readonly_mode();
Some("Entered read-only mode".to_string())
}
"enter_highlight_mode" => {
self.enter_highlight_mode();
Some("Entered highlight mode".to_string())
}
_ => None,
}
}
_ => None,
}
}
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: DemoFormState, config: CanvasConfig) -> io::Result<()> {
let theme = DemoTheme;
loop {
terminal.draw(|f| ui(f, &state, &theme))?;
if let Event::Key(key) = event::read()? {
// Handle quit
if (key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
(key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) ||
key.code == KeyCode::F(10) {
break;
}
let is_edit_mode = state.mode == AppMode::Edit;
let mut handled = false;
// First priority: Try to dispatch through config system
let mut ideal_cursor = state.ideal_cursor_column;
if let Ok(Some(result)) = ActionDispatcher::dispatch_key(
key.code,
key.modifiers,
&mut state,
&mut ideal_cursor,
is_edit_mode,
false,
).await {
state.ideal_cursor_column = ideal_cursor;
state.debug_message = format!("Config handled: {:?}", key.code);
// Mark as changed for text modification keys in edit mode
if is_edit_mode {
match key.code {
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => {
state.set_has_unsaved_changes(true);
}
_ => {}
}
}
handled = true;
}
// Second priority: Handle character input in edit mode
if !handled && is_edit_mode {
if let KeyCode::Char(c) = key.code {
if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) {
let action = CanvasAction::InsertChar(c);
let mut ideal_cursor = state.ideal_cursor_column;
if let Ok(_) = ActionDispatcher::dispatch_with_config(
action,
&mut state,
&mut ideal_cursor,
Some(&config),
).await {
state.ideal_cursor_column = ideal_cursor;
state.set_has_unsaved_changes(true);
state.debug_message = format!("Inserted char: '{}'", c);
handled = true;
}
}
}
}
// Third priority: Fallback mode transitions
if !handled {
match (state.mode, key.code) {
(AppMode::ReadOnly, KeyCode::Char('i') | KeyCode::Char('a') | KeyCode::Insert) => {
state.enter_edit_mode();
if key.code == KeyCode::Char('a') {
state.cursor_pos = state.fields[state.current_field].len();
}
state.debug_message = format!("Entered edit mode via {:?}", key.code);
handled = true;
}
(AppMode::ReadOnly, KeyCode::Char('v')) => {
state.enter_highlight_mode();
state.debug_message = "Entered visual mode".to_string();
handled = true;
}
(_, KeyCode::Esc) => {
state.enter_readonly_mode();
state.debug_message = "Entered read-only mode".to_string();
handled = true;
}
_ => {}
}
}
if !handled {
state.debug_message = format!("Unhandled key: {:?}", key.code);
}
}
}
Ok(())
}
fn ui(f: &mut Frame, state: &DemoFormState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(4),
])
.split(f.area());
// Render the canvas form
render_canvas(
f,
chunks[0],
state,
theme,
state.mode == AppMode::Edit,
&state.highlight_state,
);
// Render status bar
let mode_text = match state.mode {
AppMode::Edit => "EDIT",
AppMode::ReadOnly => "NORMAL",
AppMode::Highlight => "VISUAL",
AppMode::General => "GENERAL",
AppMode::Command => "COMMAND",
};
let status_text = if state.has_changes {
format!("-- {} -- [Modified]", mode_text)
} else {
format!("-- {} --", mode_text)
};
let position_text = format!("Field: {}/{} | Cursor: {} | Column: {}",
state.current_field + 1,
state.fields.len(),
state.cursor_pos,
state.ideal_cursor_column);
let help_text = match state.mode {
AppMode::ReadOnly => "hjkl/arrows: Move | Tab/Shift+Tab: Fields | w/b/e: Words | 0/$: Line | gg/G: File | i/a: Edit | v: Visual | F10: Quit",
AppMode::Edit => "Type to edit | hjkl/arrows: Move | Tab/Enter: Next field | Backspace/Delete: Delete | Home/End: Line | Esc: Normal | F10: Quit",
AppMode::Highlight => "hjkl/arrows: Select | w/b/e: Words | 0/$: Line | Esc: Normal | F10: Quit",
_ => "Esc: Normal | F10: Quit",
};
let status = Paragraph::new(vec![
Line::from(Span::styled(status_text, Style::default().fg(theme.accent()))),
Line::from(Span::styled(position_text, Style::default().fg(theme.fg()))),
Line::from(Span::styled(state.debug_message.clone(), Style::default().fg(theme.warning()))),
Line::from(Span::styled(help_text, Style::default().fg(theme.secondary()))),
])
.block(Block::default().borders(Borders::ALL).title("Status"));
f.render_widget(status, chunks[1]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = CanvasConfig::load();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let state = DemoFormState::new();
let res = run_app(&mut terminal, state, config).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,21 @@
// 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,9 +1,9 @@
// canvas/src/autocomplete/actions.rs
// src/autocomplete/actions.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::autocomplete::state::AutocompleteCanvasState;
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::canvas::actions::edit::handle_generic_canvas_action;
use crate::dispatcher::ActionDispatcher; // NEW: Use dispatcher directly
use crate::config::CanvasConfig;
use anyhow::Result;
@@ -26,40 +26,35 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
return Ok(result);
}
// 2. Handle generic actions and add auto-trigger logic
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
// 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
&& 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
}
}

View File

@@ -1,253 +0,0 @@
// canvas/src/canvas/actions/edit.rs
use crate::canvas::state::{CanvasState, ActionContext};
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::CanvasConfig;
use anyhow::Result;
/// Execute a typed canvas action on any CanvasState implementation
pub async fn execute_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
let context = ActionContext {
key_code: None,
ideal_cursor_column: *ideal_cursor_column,
current_input: state.get_current_input().to_string(),
current_field: state.current_field(),
};
if let Some(result) = state.handle_feature_action(&action, &context) {
return Ok(ActionResult::HandledByFeature(result));
}
handle_generic_canvas_action(action, state, ideal_cursor_column, config).await
}
/// Handle core canvas actions with full type safety
pub async fn handle_generic_canvas_action<S: CanvasState>(
action: CanvasAction,
state: &mut S,
ideal_cursor_column: &mut usize,
config: Option<&CanvasConfig>,
) -> Result<ActionResult> {
match action {
CanvasAction::InsertChar(c) => {
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::NextField | CanvasAction::PrevField => {
let old_field = state.current_field();
let total_fields = state.fields().len();
// Perform field navigation
let new_field = match action {
CanvasAction::NextField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
(old_field + 1) % total_fields
} else {
(old_field + 1).min(total_fields - 1)
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if old_field == 0 { total_fields - 1 } else { old_field - 1 }
} else {
old_field.saturating_sub(1)
}
}
_ => unreachable!(),
};
state.set_current_field(new_field);
*ideal_cursor_column = state.current_cursor_pos();
Ok(ActionResult::success())
}
CanvasAction::DeleteBackward => {
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 cursor_pos = state.current_cursor_pos();
if cursor_pos > 0 {
state.set_current_cursor_pos(cursor_pos - 1);
*ideal_cursor_column = cursor_pos - 1;
}
Ok(ActionResult::success())
}
CanvasAction::MoveRight => {
let cursor_pos = state.current_cursor_pos();
let current_input = state.get_current_input();
if cursor_pos < current_input.len() {
state.set_current_cursor_pos(cursor_pos + 1);
*ideal_cursor_column = cursor_pos + 1;
}
Ok(ActionResult::success())
}
CanvasAction::MoveLineStart => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let end_pos = state.get_current_input().len();
state.set_current_cursor_pos(end_pos);
*ideal_cursor_column = end_pos;
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);
*ideal_cursor_column = state.current_cursor_pos();
}
Ok(ActionResult::success())
}
CanvasAction::MoveDown => {
// For single-line fields, move to next field
let current_field = state.current_field();
let total_fields = state.fields().len();
if current_field < total_fields - 1 {
state.set_current_field(current_field + 1);
*ideal_cursor_column = state.current_cursor_pos();
}
Ok(ActionResult::success())
}
CanvasAction::MoveFirstLine => {
state.set_current_field(0);
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok(ActionResult::success())
}
CanvasAction::MoveLastLine => {
let last_field = state.fields().len() - 1;
state.set_current_field(last_field);
let end_pos = state.get_current_input().len();
state.set_current_cursor_pos(end_pos);
*ideal_cursor_column = end_pos;
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::Custom(action_str) => {
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
}
_ => Ok(ActionResult::success_with_message("Action not implemented")),
}
}
// Helper functions for word navigation
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos;
// Skip current word
while pos < chars.len() && chars[pos].is_alphanumeric() {
pos += 1;
}
// Skip whitespace
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
pos
}
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos;
// Move to end of current word
while pos < chars.len() && chars[pos].is_alphanumeric() {
pos += 1;
}
pos
}
fn find_prev_word_start(text: &str, cursor_pos: usize) -> usize {
if cursor_pos == 0 {
return 0;
}
let chars: Vec<char> = text.chars().collect();
let mut pos = cursor_pos.saturating_sub(1);
// Skip whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// Skip to start of word
while pos > 0 && chars[pos - 1].is_alphanumeric() {
pos -= 1;
}
pos
}

View File

@@ -0,0 +1,376 @@
// src/canvas/actions/handlers/edit.rs
//! Edit mode action handler
//!
//! Handles user input when in edit mode, supporting text entry, deletion,
//! and cursor movement with edit-specific behavior (cursor can go past end of text).
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
use crate::canvas::actions::movement::*;
use crate::canvas::state::CanvasState;
use crate::config::CanvasConfig;
use anyhow::Result;
/// Edit mode uses cursor-past-end behavior for text insertion
const FOR_EDIT_MODE: bool = true;
/// Empty struct that implements edit mode capabilities
pub struct EditHandler;
/// Handle actions in edit mode with edit-specific cursor behavior
///
/// Edit mode allows text modification and uses cursor positioning that can
/// go past the end of existing text to facilitate insertion.
///
/// # Arguments
/// * `action` - The action to perform
/// * `state` - Mutable canvas state
/// * `ideal_cursor_column` - Desired column for vertical movement (maintained across line changes)
/// * `config` - Optional configuration for behavior customization
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) => {
// Insert character at cursor position and advance cursor
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
input.insert(cursor_pos, c);
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 => {
// Delete character before cursor (Backspace behavior)
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 => {
// Delete character at cursor position (Delete key behavior)
let cursor_pos = state.current_cursor_pos();
let input = state.get_current_input_mut();
if cursor_pos < input.len() {
input.remove(cursor_pos);
state.set_has_unsaved_changes(true);
}
Ok(ActionResult::success())
}
// Cursor movement actions
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())
}
// Field navigation (treating single-line fields as "lines")
CanvasAction::MoveUp => {
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 => {
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())
}
// Line-based movement
CanvasAction::MoveLineStart => {
let new_pos = line_start_position();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
CanvasAction::MoveLineEnd => {
let current_input = state.get_current_input();
let new_pos = line_end_position(current_input, FOR_EDIT_MODE);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok(ActionResult::success())
}
// Document-level movement (first/last field)
CanvasAction::MoveFirstLine => {
state.set_current_field(0);
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())
}
// Word-based movement
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())
}
// Field navigation with wrapping behavior
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 // Wrap to first field
} else {
(current_field + 1).min(total_fields - 1) // Stop at last field
}
}
CanvasAction::PrevField => {
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
if current_field == 0 { total_fields - 1 } else { current_field - 1 } // Wrap to last field
} else {
current_field.saturating_sub(1) // Stop at first field
}
}
_ => 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"))
}
}
}
impl ActionHandlerIntrospection for EditHandler {
/// Report all actions this handler supports with examples and requirements
/// Used for automatic config generation and validation
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// REQUIRED ACTIONS - These must be configured for edit mode to work properly
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["Left".to_string(), "h".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["Right".to_string(), "l".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field or line".to_string(),
examples: vec!["Up".to_string(), "k".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move to next field or line".to_string(),
examples: vec!["Down".to_string(), "j".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "delete_char_backward".to_string(),
description: "Delete character before cursor (Backspace)".to_string(),
examples: vec!["Backspace".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string(), "Enter".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - These enhance functionality but aren't required
actions.push(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()],
is_required: false,
});
actions.push(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()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["Home".to_string(), "0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["End".to_string(), "$".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["Ctrl+Home".to_string(), "gg".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["Ctrl+End".to_string(), "G".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "delete_char_forward".to_string(),
description: "Delete character after cursor (Delete key)".to_string(),
examples: vec!["Delete".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "edit".to_string(),
actions,
auto_handled: vec![
"insert_char".to_string(), // Any printable character is inserted automatically
],
}
}
fn validate_capabilities() -> Result<(), String> {
// TODO: Could add runtime validation that the handler actually
// implements all the actions it claims to support
// For now, just validate that we have the essential actions
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 7 { // We expect at least 7 required actions
return Err(format!(
"Edit handler claims only {} required actions, expected at least 7",
required_count
));
}
Ok(())
}
}

View File

@@ -0,0 +1,205 @@
// src/canvas/actions/handlers/highlight.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
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
pub struct HighlightHandler;
/// 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"))
}
}
}
impl ActionHandlerIntrospection for HighlightHandler {
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// For now, highlight mode uses similar movement to readonly
// but this will be discovered from actual implementation
// REQUIRED ACTIONS - Basic movement in highlight mode
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor left and extend selection".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor right and extend selection".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move up and extend selection".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move down and extend selection".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - Advanced highlight movement
actions.push(ActionSpec {
name: "move_word_next".to_string(),
description: "Move to next word and extend selection".to_string(),
examples: vec!["w".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move to word end and extend selection".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_prev".to_string(),
description: "Move to previous word and extend selection".to_string(),
examples: vec!["b".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move to line start and extend selection".to_string(),
examples: vec!["0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move to line end and extend selection".to_string(),
examples: vec!["$".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "highlight".to_string(),
actions,
auto_handled: vec![], // Highlight mode has no auto-handled actions
}
}
fn validate_capabilities() -> Result<(), String> {
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 4 { // We expect at least 4 required actions (basic movement)
return Err(format!(
"Highlight handler claims only {} required actions, expected at least 4",
required_count
));
}
Ok(())
}
}

View File

@@ -0,0 +1,10 @@
// 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

@@ -0,0 +1,322 @@
// src/canvas/actions/handlers/readonly.rs
use crate::canvas::actions::types::{CanvasAction, ActionResult};
use crate::config::introspection::{ActionHandlerIntrospection, HandlerCapabilities, ActionSpec};
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"))
}
}
}
pub struct ReadOnlyHandler;
impl ActionHandlerIntrospection for ReadOnlyHandler {
fn introspect() -> HandlerCapabilities {
let mut actions = Vec::new();
// REQUIRED ACTIONS - Navigation is essential in read-only mode
actions.push(ActionSpec {
name: "move_left".to_string(),
description: "Move cursor one position to the left".to_string(),
examples: vec!["h".to_string(), "Left".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_right".to_string(),
description: "Move cursor one position to the right".to_string(),
examples: vec!["l".to_string(), "Right".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_up".to_string(),
description: "Move to previous field".to_string(),
examples: vec!["k".to_string(), "Up".to_string()],
is_required: true,
});
actions.push(ActionSpec {
name: "move_down".to_string(),
description: "Move to next field".to_string(),
examples: vec!["j".to_string(), "Down".to_string()],
is_required: true,
});
// OPTIONAL ACTIONS - Advanced navigation features
actions.push(ActionSpec {
name: "move_word_next".to_string(),
description: "Move cursor to start of next word".to_string(),
examples: vec!["w".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_prev".to_string(),
description: "Move cursor to start of previous word".to_string(),
examples: vec!["b".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end".to_string(),
description: "Move cursor to end of current/next word".to_string(),
examples: vec!["e".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_word_end_prev".to_string(),
description: "Move cursor to end of previous word".to_string(),
examples: vec!["ge".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_start".to_string(),
description: "Move cursor to beginning of line".to_string(),
examples: vec!["0".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_line_end".to_string(),
description: "Move cursor to end of line".to_string(),
examples: vec!["$".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_first_line".to_string(),
description: "Move to first field".to_string(),
examples: vec!["gg".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "move_last_line".to_string(),
description: "Move to last field".to_string(),
examples: vec!["G".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "next_field".to_string(),
description: "Move to next input field".to_string(),
examples: vec!["Tab".to_string()],
is_required: false,
});
actions.push(ActionSpec {
name: "prev_field".to_string(),
description: "Move to previous input field".to_string(),
examples: vec!["Shift+Tab".to_string()],
is_required: false,
});
HandlerCapabilities {
mode_name: "read_only".to_string(),
actions,
auto_handled: vec![], // Read-only mode has no auto-handled actions
}
}
fn validate_capabilities() -> Result<(), String> {
let caps = Self::introspect();
let required_count = caps.actions.iter().filter(|a| a.is_required).count();
if required_count < 4 { // We expect at least 4 required actions (basic movement)
return Err(format!(
"ReadOnly handler claims only {} required actions, expected at least 4",
required_count
));
}
Ok(())
}
}

View File

@@ -1,7 +1,8 @@
// canvas/src/canvas/actions/mod.rs
pub mod types;
pub mod edit;
// src/canvas/actions/mod.rs
// Re-export the main types for convenience
pub mod types;
pub mod movement;
pub mod handlers;
// Re-export the main types
pub use types::{CanvasAction, ActionResult};
pub use edit::execute_canvas_action;

View File

@@ -0,0 +1,49 @@
// src/canvas/actions/movement/char.rs
/// Calculate new position when moving left
pub fn move_left(current_pos: usize) -> usize {
current_pos.saturating_sub(1)
}
/// Calculate new position when moving right
pub fn move_right(current_pos: usize, text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
return current_pos;
}
if for_edit_mode {
// Edit mode: can move past end of text
(current_pos + 1).min(text.len())
} else {
// Read-only/highlight mode: stays within text bounds
if current_pos < text.len().saturating_sub(1) {
current_pos + 1
} else {
current_pos
}
}
}
/// Check if cursor position is valid for the given mode
pub fn is_valid_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> bool {
if text.is_empty() {
return pos == 0;
}
if for_edit_mode {
pos <= text.len()
} else {
pos < text.len()
}
}
/// Clamp cursor position to valid bounds for the given mode
pub fn clamp_cursor_position(pos: usize, text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
pos.min(text.len())
} else {
pos.min(text.len().saturating_sub(1))
}
}

View File

@@ -0,0 +1,32 @@
// src/canvas/actions/movement/line.rs
/// Calculate cursor position for line start
pub fn line_start_position() -> usize {
0
}
/// Calculate cursor position for line end
pub fn line_end_position(text: &str, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
// Edit mode: cursor can go past end of text
text.len()
} else {
// Read-only/highlight mode: cursor stays on last character
text.len().saturating_sub(1)
}
}
/// Calculate safe cursor position when switching fields
pub fn safe_cursor_position(text: &str, ideal_column: usize, for_edit_mode: bool) -> usize {
if text.is_empty() {
0
} else if for_edit_mode {
// Edit mode: cursor can go past end
ideal_column.min(text.len())
} else {
// Read-only/highlight mode: cursor stays within text
ideal_column.min(text.len().saturating_sub(1))
}
}

View File

@@ -0,0 +1,10 @@
// src/canvas/actions/movement/mod.rs
pub mod word;
pub mod line;
pub mod char;
// Re-export commonly used functions
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
pub use line::{line_start_position, line_end_position, safe_cursor_position};
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};

View File

@@ -0,0 +1,146 @@
// src/canvas/actions/movement/word.rs
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
/// Find the start of the next word from the current position
pub fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
// Skip current word/token
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
// Skip whitespace
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
/// Find the end of the current or next word
pub fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
// If we're not on whitespace, move to end of current word
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
// If we're on whitespace, find next word and go to its end
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
/// Find the start of the previous word
pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
// Move to start of word
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
/// Find the end of the previous word
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
// Skip whitespace before this word
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -25,6 +25,7 @@ pub enum CanvasAction {
MoveWordNext,
MoveWordEnd,
MoveWordPrev,
MoveWordEndPrev,
// Field navigation
NextField,
@@ -42,24 +43,7 @@ pub enum CanvasAction {
}
impl CanvasAction {
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
match key {
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
crossterm::event::KeyCode::Tab => Some(Self::NextField),
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
_ => None,
}
}
// Backward compatibility method
/// Convert string action name to CanvasAction enum (config-driven)
pub fn from_string(action: &str) -> Self {
match action {
"delete_char_backward" => Self::DeleteBackward,
@@ -75,6 +59,7 @@ impl CanvasAction {
"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,

View File

@@ -1,15 +1,18 @@
// src/canvas/mod.rs
pub mod actions;
pub mod modes;
pub mod gui;
pub mod theme;
pub mod modes;
pub mod state;
pub mod theme;
// Re-export commonly used canvas types
pub use actions::{CanvasAction, ActionResult};
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;

View File

@@ -1,44 +1,107 @@
// canvas/src/state.rs
// src/canvas/state.rs
//! Canvas state trait and related types
//!
//! This module defines the core trait that any form or input system must implement
//! to work with the canvas library.
use crate::canvas::actions::CanvasAction;
use crate::canvas::modes::AppMode;
/// Context passed to feature-specific action handlers
/// Context information passed to feature-specific action handlers
#[derive(Debug)]
pub struct ActionContext {
pub key_code: Option<crossterm::event::KeyCode>, // Kept for backwards compatibility
/// Original key code that triggered this action (for backwards compatibility)
pub key_code: Option<crossterm::event::KeyCode>,
/// Current ideal cursor column for vertical movement
pub ideal_cursor_column: usize,
/// Current input text
pub current_input: String,
/// Current field index
pub current_field: usize,
}
/// Core trait that any form-like state must implement to work with the canvas system.
/// This enables the same mode behaviors (edit, read-only, highlight) to work across
/// any implementation - login forms, data entry forms, configuration screens, etc.
/// Core trait that any form-like state must implement to work with canvas
///
/// This trait enables the same mode behaviors (edit, read-only, highlight) to work
/// across any implementation - login forms, data entry forms, configuration screens, etc.
///
/// # Required Implementation
///
/// Your struct needs to track:
/// - Current field index and cursor position
/// - All input field values
/// - Current interaction mode
/// - Whether there are unsaved changes
///
/// # Example Implementation
///
/// ```rust
/// struct MyForm {
/// fields: Vec<String>,
/// current_field: usize,
/// cursor_pos: usize,
/// mode: AppMode,
/// dirty: bool,
/// }
///
/// impl CanvasState for MyForm {
/// fn current_field(&self) -> usize { self.current_field }
/// fn current_cursor_pos(&self) -> usize { self.cursor_pos }
/// // ... implement other required methods
/// }
/// ```
pub trait CanvasState {
// --- Core Navigation ---
/// Get current field index (0-based)
fn current_field(&self) -> usize;
/// Get current cursor position within the current field
fn current_cursor_pos(&self) -> usize;
/// Set current field index (should clamp to valid range)
fn set_current_field(&mut self, index: usize);
/// Set cursor position within current field (should clamp to valid range)
fn set_current_cursor_pos(&mut self, pos: usize);
// --- Mode Information ---
/// Get current interaction mode (edit, read-only, highlight, etc.)
fn current_mode(&self) -> AppMode;
// --- Data Access ---
/// Get immutable reference to current field's text
fn get_current_input(&self) -> &str;
/// Get mutable reference to current field's text
fn get_current_input_mut(&mut self) -> &mut String;
/// Get all input values as immutable references
fn inputs(&self) -> Vec<&String>;
/// Get all field names/labels
fn fields(&self) -> Vec<&str>;
// --- State Management ---
/// Check if there are unsaved changes
fn has_unsaved_changes(&self) -> bool;
/// Mark whether there are unsaved changes
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Feature-specific action handling ---
// --- Optional Overrides ---
/// Feature-specific action handling (NEW: Type-safe)
/// Handle application-specific actions not covered by standard handlers
/// Return Some(message) if the action was handled, None to use standard handling
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
None // Default: no feature-specific handling
None // Default: no custom handling
}
// --- Display Overrides (for links, computed values, etc.) ---
/// Get display value for a field (may differ from actual value)
/// Used for things like password masking or computed display values
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
@@ -46,6 +109,8 @@ pub trait CanvasState {
.unwrap_or("")
}
/// Check if a field has a custom display value
/// Return true if get_display_value_for_field returns something different than the actual value
fn has_display_override(&self, _index: usize) -> bool {
false
}

View File

@@ -1,83 +1,73 @@
// canvas/src/config.rs
// src/config/config.rs
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crossterm::event::{KeyCode, KeyModifiers};
use anyhow::{Context, Result};
// Import from sibling modules
use super::registry::ActionRegistry;
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,
pub struct CanvasKeybindings {
pub edit: HashMap<String, Vec<String>>,
pub read_only: HashMap<String, Vec<String>>,
pub global: HashMap<String, Vec<String>>,
}
#[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>>,
impl Default for CanvasKeybindings {
fn default() -> Self {
Self {
edit: HashMap::new(),
read_only: HashMap::new(),
global: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasBehavior {
#[serde(default = "default_wrap_around")]
pub confirm_on_save: bool,
pub auto_indent: bool,
pub wrap_search: bool,
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(),
confirm_on_save: true,
auto_indent: true,
wrap_search: true,
wrap_around_fields: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasAppearance {
pub line_numbers: bool,
pub syntax_highlighting: bool,
pub current_line_highlight: bool,
}
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(),
line_numbers: true,
syntax_highlighting: true,
current_line_highlight: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasConfig {
pub keybindings: CanvasKeybindings,
pub behavior: CanvasBehavior,
pub appearance: CanvasAppearance,
}
impl Default for CanvasConfig {
fn default() -> Self {
Self {
@@ -89,105 +79,329 @@ impl Default for CanvasConfig {
}
impl CanvasKeybindings {
/// Generate complete vim defaults from introspection system
/// This ensures defaults are always in sync with actual handler capabilities
pub fn with_vim_defaults() -> Self {
let registry = ActionRegistry::from_handlers();
Self::generate_from_registry(&registry)
}
/// Generate keybindings from action registry (used by both defaults and config generation)
/// This is the single source of truth for what keybindings should exist
fn generate_from_registry(registry: &ActionRegistry) -> Self {
let mut keybindings = Self::default();
// Read-only mode (vim-style navigation)
// Generate keybindings for each mode discovered by introspection
for (mode_name, mode_registry) in &registry.modes {
let mode_bindings = match mode_name.as_str() {
"edit" => &mut keybindings.edit,
"read_only" => &mut keybindings.read_only,
"highlight" => &mut keybindings.global, // Highlight actions go in global
_ => {
// Handle any future modes discovered by introspection
eprintln!("Warning: Unknown mode '{}' discovered by introspection", mode_name);
continue;
}
};
// Add ALL required actions
for (action_name, action_spec) in &mode_registry.required {
if !action_spec.examples.is_empty() {
mode_bindings.insert(
action_name.clone(),
action_spec.examples.clone()
);
}
}
// Add ALL optional actions
for (action_name, action_spec) in &mode_registry.optional {
if !action_spec.examples.is_empty() {
mode_bindings.insert(
action_name.clone(),
action_spec.examples.clone()
);
}
}
}
keybindings
}
/// Generate a minimal fallback configuration if introspection fails
/// This should rarely be used, but provides safety net
fn minimal_fallback() -> Self {
let mut keybindings = Self::default();
// Absolute minimum required for basic functionality
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();
/// Validate that generated keybindings match the current introspection state
/// This helps catch when handlers change but defaults become stale
pub fn validate_against_introspection(&self) -> Result<(), Vec<String>> {
let registry = ActionRegistry::from_handlers();
let expected = Self::generate_from_registry(&registry);
let mut errors = Vec::new();
// 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()]);
// Check each mode
for (mode_name, expected_bindings) in [
("edit", &expected.edit),
("read_only", &expected.read_only),
("global", &expected.global),
] {
let actual_bindings = match mode_name {
"edit" => &self.edit,
"read_only" => &self.read_only,
"global" => &self.global,
_ => continue,
};
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()]);
// Check for missing actions
for action_name in expected_bindings.keys() {
if !actual_bindings.contains_key(action_name) {
errors.push(format!(
"Missing action '{}' in {} mode (expected by introspection)",
action_name, mode_name
));
}
}
keybindings
// Check for unexpected actions
for action_name in actual_bindings.keys() {
if !expected_bindings.contains_key(action_name) {
errors.push(format!(
"Unexpected action '{}' in {} mode (not found in introspection)",
action_name, mode_name
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
impl CanvasConfig {
/// Load from canvas_config.toml or fallback to vim defaults
/// Enhanced load method with introspection validation
pub fn load() -> Self {
// Try to load canvas_config.toml from current directory
if let Ok(config) = Self::from_file(std::path::Path::new("canvas_config.toml")) {
return config;
match Self::load_and_validate() {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to load config file: {}", e);
eprintln!("Using auto-generated defaults from introspection");
Self::default()
}
}
}
/// Load and validate configuration with enhanced introspection checks
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 {
// Use auto-generated defaults if file doesn't exist
eprintln!("Config file not found, using auto-generated defaults");
Self::default()
};
// Validate the configuration against current introspection state
let registry = ActionRegistry::from_handlers();
// Validate handlers are working correctly
if let Err(handler_errors) = registry.validate_against_implementation() {
eprintln!("Handler validation warnings:");
for error in handler_errors {
eprintln!(" - {}", error);
}
}
// Validate the configuration against the dynamic registry
let validator = ConfigValidator::new(registry);
let validation_result = validator.validate_keybindings(&config.keybindings);
if !validation_result.is_valid {
eprintln!("Configuration validation failed:");
validator.print_validation_result(&validation_result);
} else if !validation_result.warnings.is_empty() {
eprintln!("Configuration validation warnings:");
validator.print_validation_result(&validation_result);
}
// Optional: Validate that our defaults match introspection
if let Err(sync_errors) = config.keybindings.validate_against_introspection() {
eprintln!("Default keybindings out of sync with introspection:");
for error in sync_errors {
eprintln!(" - {}", error);
}
}
Ok(config)
}
/// Generate a complete configuration template that matches current defaults
/// This ensures the generated config file has the same content as defaults
pub fn generate_complete_template() -> String {
let registry = ActionRegistry::from_handlers();
let defaults = CanvasKeybindings::generate_from_registry(&registry);
let mut template = String::new();
template.push_str("# Canvas Library Configuration\n");
template.push_str("# Auto-generated from handler introspection\n");
template.push_str("# This config contains ALL available actions\n\n");
// Generate sections for each mode
for (mode_name, bindings) in [
("read_only", &defaults.read_only),
("edit", &defaults.edit),
("global", &defaults.global),
] {
if bindings.is_empty() {
continue;
}
template.push_str(&format!("[keybindings.{}]\n", mode_name));
// Get mode registry for categorization
if let Some(mode_registry) = registry.get_mode_registry(mode_name) {
// Required actions first
let mut found_required = false;
for (action_name, keybindings) in bindings {
if mode_registry.required.contains_key(action_name) {
if !found_required {
template.push_str("# Required\n");
found_required = true;
}
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
// Optional actions second
let mut found_optional = false;
for (action_name, keybindings) in bindings {
if mode_registry.optional.contains_key(action_name) {
if !found_optional {
template.push_str("# Optional\n");
found_optional = true;
}
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
} else {
// Fallback: just list all actions
for (action_name, keybindings) in bindings {
template.push_str(&format!("{} = {:?}\n", action_name, keybindings));
}
}
template.push('\n');
}
template
}
/// Generate config that only contains actions different from defaults
/// Useful for minimal user configs
pub fn generate_minimal_template() -> String {
let defaults = CanvasKeybindings::with_vim_defaults();
let mut template = String::new();
template.push_str("# Minimal Canvas Configuration\n");
template.push_str("# Only uncomment and modify the keybindings you want to change\n");
template.push_str("# All other actions will use their default vim-style keybindings\n\n");
for (mode_name, bindings) in [
("read_only", &defaults.read_only),
("edit", &defaults.edit),
("global", &defaults.global),
] {
if bindings.is_empty() {
continue;
}
template.push_str(&format!("# [keybindings.{}]\n", mode_name));
for (action_name, keybindings) in bindings {
template.push_str(&format!("# {} = {:?}\n", action_name, keybindings));
}
template.push('\n');
}
template
}
/// Generate template from actual handler capabilities (legacy method for compatibility)
pub fn generate_template() -> String {
Self::generate_complete_template()
}
/// Generate clean template from actual handler capabilities (legacy method for compatibility)
pub fn generate_clean_template() -> String {
let registry = ActionRegistry::from_handlers();
// Fallback to vim defaults
Self::default()
// Validate handlers first
if let Err(errors) = registry.validate_against_implementation() {
for error in errors {
eprintln!(" - {}", error);
}
}
registry.generate_clean_template()
}
/// Validate current configuration against actual implementation
pub fn validate(&self) -> ValidationResult {
let registry = ActionRegistry::from_handlers();
let validator = ConfigValidator::new(registry);
validator.validate_keybindings(&self.keybindings)
}
/// Print validation results for current config
pub fn print_validation(&self) {
let registry = ActionRegistry::from_handlers();
let validator = ConfigValidator::new(registry);
let result = validator.validate_keybindings(&self.keybindings);
validator.print_validation_result(&result);
}
/// 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")
.context("Failed to parse TOML configuration")
}
/// 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))?;
.context("Failed to read config file")?;
Self::from_toml(&contents)
}
/// NEW: Check if autocomplete should auto-trigger (simple logic)
/// 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
/// 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") ||
@@ -206,21 +420,9 @@ impl CanvasConfig {
.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
pub fn get_action_for_key(&self, key: KeyCode, modifiers: KeyModifiers, is_edit_mode: bool, _has_suggestions: bool) -> Option<&str> {
// Check mode-specific
if is_edit_mode {
self.get_edit_action(key, modifiers)
} else {
@@ -271,21 +473,21 @@ impl CanvasConfig {
"end" => key == KeyCode::End,
"pageup" | "pgup" => key == KeyCode::PageUp,
"pagedown" | "pgdn" => key == KeyCode::PageDown,
// Editing keys
"insert" | "ins" => key == KeyCode::Insert,
"delete" | "del" => key == KeyCode::Delete,
"backspace" => key == KeyCode::Backspace,
// Tab keys
"tab" => key == KeyCode::Tab,
"backtab" => key == KeyCode::BackTab,
// Special keys
"enter" | "return" => key == KeyCode::Enter,
"escape" | "esc" => key == KeyCode::Esc,
"space" => key == KeyCode::Char(' '),
// Function keys F1-F24
"f1" => key == KeyCode::F(1),
"f2" => key == KeyCode::F(2),
@@ -311,18 +513,18 @@ impl CanvasConfig {
"f22" => key == KeyCode::F(22),
"f23" => key == KeyCode::F(23),
"f24" => key == KeyCode::F(24),
// Lock keys (may not work reliably in all terminals)
"capslock" => key == KeyCode::CapsLock,
"scrolllock" => key == KeyCode::ScrollLock,
"numlock" => key == KeyCode::NumLock,
// System keys
"printscreen" => key == KeyCode::PrintScreen,
"pause" => key == KeyCode::Pause,
"menu" => key == KeyCode::Menu,
"keypadbegin" => key == KeyCode::KeypadBegin,
// Media keys (rarely supported but included for completeness)
"mediaplay" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Play),
"mediapause" => key == KeyCode::Media(crossterm::event::MediaKeyCode::Pause),
@@ -337,7 +539,7 @@ impl CanvasConfig {
"medialowervolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::LowerVolume),
"mediaraisevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::RaiseVolume),
"mediamutevolume" => key == KeyCode::Media(crossterm::event::MediaKeyCode::MuteVolume),
// Modifier keys (these work better as part of combinations)
"leftshift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftShift),
"leftcontrol" | "leftctrl" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl),
@@ -353,7 +555,7 @@ impl CanvasConfig {
"rightmeta" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::RightMeta),
"isolevel3shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel3Shift),
"isolevel5shift" => key == KeyCode::Modifier(crossterm::event::ModifierKeyCode::IsoLevel5Shift),
// Multi-key sequences need special handling
"gg" => false, // This needs sequence handling
_ => {
@@ -385,7 +587,7 @@ impl CanvasConfig {
"super" | "windows" | "cmd" => expected_modifiers |= KeyModifiers::SUPER,
"hyper" => expected_modifiers |= KeyModifiers::HYPER,
"meta" => expected_modifiers |= KeyModifiers::META,
// Navigation keys
"left" => expected_key = Some(KeyCode::Left),
"right" => expected_key = Some(KeyCode::Right),
@@ -395,21 +597,21 @@ impl CanvasConfig {
"end" => expected_key = Some(KeyCode::End),
"pageup" | "pgup" => expected_key = Some(KeyCode::PageUp),
"pagedown" | "pgdn" => expected_key = Some(KeyCode::PageDown),
// Editing keys
"insert" | "ins" => expected_key = Some(KeyCode::Insert),
"delete" | "del" => expected_key = Some(KeyCode::Delete),
"backspace" => expected_key = Some(KeyCode::Backspace),
// Tab keys
"tab" => expected_key = Some(KeyCode::Tab),
"backtab" => expected_key = Some(KeyCode::BackTab),
// Special keys
"enter" | "return" => expected_key = Some(KeyCode::Enter),
"escape" | "esc" => expected_key = Some(KeyCode::Esc),
"space" => expected_key = Some(KeyCode::Char(' ')),
// Function keys
"f1" => expected_key = Some(KeyCode::F(1)),
"f2" => expected_key = Some(KeyCode::F(2)),
@@ -435,18 +637,18 @@ impl CanvasConfig {
"f22" => expected_key = Some(KeyCode::F(22)),
"f23" => expected_key = Some(KeyCode::F(23)),
"f24" => expected_key = Some(KeyCode::F(24)),
// Lock keys
"capslock" => expected_key = Some(KeyCode::CapsLock),
"scrolllock" => expected_key = Some(KeyCode::ScrollLock),
"numlock" => expected_key = Some(KeyCode::NumLock),
// System keys
"printscreen" => expected_key = Some(KeyCode::PrintScreen),
"pause" => expected_key = Some(KeyCode::Pause),
"menu" => expected_key = Some(KeyCode::Menu),
"keypadbegin" => expected_key = Some(KeyCode::KeypadBegin),
// Single character (letters, numbers, punctuation)
part => {
if part.len() == 1 {
@@ -460,35 +662,4 @@ impl CanvasConfig {
modifiers == expected_modifiers && Some(key) == expected_key
}
/// Convenience method to create vim preset
pub fn vim_preset() -> Self {
Self {
keybindings: CanvasKeybindings::with_vim_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
/// Convenience method to create emacs preset
pub fn emacs_preset() -> Self {
Self {
keybindings: CanvasKeybindings::with_emacs_defaults(),
behavior: CanvasBehavior::default(),
appearance: CanvasAppearance::default(),
}
}
/// Debug method to print loaded keybindings
pub fn debug_keybindings(&self) {
println!("📋 Canvas keybindings loaded:");
println!(" Read-only: {} actions", self.keybindings.read_only.len());
println!(" Edit: {} actions", self.keybindings.edit.len());
println!(" Suggestions: {} actions", self.keybindings.suggestions.len());
println!(" Global: {} actions", self.keybindings.global.len());
}
}
// Re-export for convenience
pub use crate::canvas::actions::CanvasAction;
pub use crate::dispatcher::ActionDispatcher;

View File

@@ -0,0 +1,93 @@
// src/config/introspection.rs
//! Handler capability introspection system
//!
//! This module provides traits and utilities for handlers to report their capabilities,
//! enabling automatic configuration generation and validation.
use std::collections::HashMap;
/// Specification for a single action that a handler can perform
#[derive(Debug, Clone)]
pub struct ActionSpec {
/// Action name (e.g., "move_left", "delete_char_backward")
pub name: String,
/// Human-readable description of what this action does
pub description: String,
/// Example keybindings for this action (e.g., ["Left", "h"])
pub examples: Vec<String>,
/// Whether this action is required for the handler to function properly
pub is_required: bool,
}
/// Complete capability description for a single handler
#[derive(Debug, Clone)]
pub struct HandlerCapabilities {
/// Mode name this handler operates in (e.g., "edit", "read_only")
pub mode_name: String,
/// All actions this handler can perform
pub actions: Vec<ActionSpec>,
/// Actions handled automatically without configuration (e.g., "insert_char")
pub auto_handled: Vec<String>,
}
/// Trait that handlers implement to report their capabilities
///
/// This enables the configuration system to automatically discover what actions
/// are available and validate user configurations against actual implementations.
pub trait ActionHandlerIntrospection {
/// Return complete capability information for this handler
fn introspect() -> HandlerCapabilities;
/// Validate that this handler actually supports its claimed actions
/// Override this to add custom validation logic
fn validate_capabilities() -> Result<(), String> {
Ok(()) // Default: assume handler is valid
}
}
/// Discovers capabilities from all registered handlers
pub struct HandlerDiscovery;
impl HandlerDiscovery {
/// Discover capabilities from all known handlers
/// Add new handlers to this function as they are created
pub fn discover_all() -> HashMap<String, HandlerCapabilities> {
let mut capabilities = HashMap::new();
// Register all known handlers here
let edit_caps = crate::canvas::actions::handlers::edit::EditHandler::introspect();
capabilities.insert("edit".to_string(), edit_caps);
let readonly_caps = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::introspect();
capabilities.insert("read_only".to_string(), readonly_caps);
let highlight_caps = crate::canvas::actions::handlers::highlight::HighlightHandler::introspect();
capabilities.insert("highlight".to_string(), highlight_caps);
capabilities
}
/// Validate all handlers support their claimed capabilities
pub fn validate_all_handlers() -> Result<(), Vec<String>> {
let mut errors = Vec::new();
// Validate each handler
if let Err(e) = crate::canvas::actions::handlers::edit::EditHandler::validate_capabilities() {
errors.push(format!("Edit handler: {}", e));
}
if let Err(e) = crate::canvas::actions::handlers::readonly::ReadOnlyHandler::validate_capabilities() {
errors.push(format!("ReadOnly handler: {}", e));
}
if let Err(e) = crate::canvas::actions::handlers::highlight::HighlightHandler::validate_capabilities() {
errors.push(format!("Highlight handler: {}", e));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}

12
canvas/src/config/mod.rs Normal file
View File

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

View File

@@ -0,0 +1,135 @@
// src/config/registry.rs
use std::collections::HashMap;
use crate::config::introspection::{HandlerDiscovery, ActionSpec, HandlerCapabilities};
#[derive(Debug, Clone)]
pub struct ModeRegistry {
pub required: HashMap<String, ActionSpec>,
pub optional: HashMap<String, ActionSpec>,
pub auto_handled: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ActionRegistry {
pub modes: HashMap<String, ModeRegistry>,
}
impl ActionRegistry {
/// NEW: Create registry by discovering actual handler capabilities
pub fn from_handlers() -> Self {
let handler_capabilities = HandlerDiscovery::discover_all();
let mut modes = HashMap::new();
for (mode_name, capabilities) in handler_capabilities {
let mode_registry = Self::build_mode_registry(capabilities);
modes.insert(mode_name, mode_registry);
}
Self { modes }
}
/// Build a mode registry from handler capabilities
fn build_mode_registry(capabilities: HandlerCapabilities) -> ModeRegistry {
let mut required = HashMap::new();
let mut optional = HashMap::new();
for action_spec in capabilities.actions {
if action_spec.is_required {
required.insert(action_spec.name.clone(), action_spec);
} else {
optional.insert(action_spec.name.clone(), action_spec);
}
}
ModeRegistry {
required,
optional,
auto_handled: capabilities.auto_handled,
}
}
/// Validate that the registry matches the actual implementation
pub fn validate_against_implementation(&self) -> Result<(), Vec<String>> {
HandlerDiscovery::validate_all_handlers()
}
pub fn get_mode_registry(&self, mode: &str) -> Option<&ModeRegistry> {
self.modes.get(mode)
}
pub fn all_known_actions(&self) -> Vec<String> {
let mut actions = Vec::new();
for registry in self.modes.values() {
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 from actual handler capabilities\n\n");
for (mode_name, registry) in &self.modes {
template.push_str(&format!("[keybindings.{}]\n", mode_name));
if !registry.required.is_empty() {
template.push_str("# REQUIRED ACTIONS - These must be configured\n");
for (name, spec) in &registry.required {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("{} = {:?}\n\n", name, spec.examples));
}
}
if !registry.optional.is_empty() {
template.push_str("# OPTIONAL ACTIONS - Configure these if you want them enabled\n");
for (name, spec) in &registry.optional {
template.push_str(&format!("# {}\n", spec.description));
template.push_str(&format!("# {} = {:?}\n\n", name, spec.examples));
}
}
if !registry.auto_handled.is_empty() {
template.push_str("# AUTO-HANDLED - These are handled automatically, don't configure:\n");
for auto_action in &registry.auto_handled {
template.push_str(&format!("# {} (automatic)\n", auto_action));
}
template.push('\n');
}
}
template
}
pub fn generate_clean_template(&self) -> String {
let mut template = String::new();
for (mode_name, registry) in &self.modes {
template.push_str(&format!("[keybindings.{}]\n", mode_name));
if !registry.required.is_empty() {
template.push_str("# Required\n");
for (name, spec) in &registry.required {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
if !registry.optional.is_empty() {
template.push_str("# Optional\n");
for (name, spec) in &registry.optional {
template.push_str(&format!("{} = {:?}\n", name, spec.examples));
}
}
template.push('\n');
}
template
}
}

View File

@@ -0,0 +1,278 @@
// 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 {
// FIXED: Accept registry parameter to match config.rs calls
pub fn new(registry: ActionRegistry) -> Self {
Self {
registry,
}
}
pub fn validate_keybindings(&self, keybindings: &CanvasKeybindings) -> ValidationResult {
let mut result = ValidationResult::new();
// Validate each mode that exists in the registry
if let Some(edit_registry) = self.registry.get_mode_registry("edit") {
result.merge(self.validate_mode_bindings(
"edit",
&keybindings.edit,
edit_registry
));
}
if let Some(readonly_registry) = self.registry.get_mode_registry("read_only") {
result.merge(self.validate_mode_bindings(
"read_only",
&keybindings.read_only,
readonly_registry
));
}
// Skip suggestions mode if not discovered by introspection
// (autocomplete is separate concern as requested)
// Skip global mode if not discovered by introspection
// (can be added later if needed)
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

@@ -1,32 +1,87 @@
// canvas/src/dispatcher.rs
// src/dispatcher.rs
use crate::canvas::state::CanvasState;
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
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};
/// High-level action dispatcher that coordinates between different action types
/// 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 handler
/// 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> {
// Load config once here instead of threading it everywhere
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
let config = CanvasConfig::load();
Self::dispatch_with_config(action, state, ideal_cursor_column, Some(&config)).await
}
/// Quick action dispatch from KeyCode
pub async fn dispatch_key<S: CanvasState>(
key: crossterm::event::KeyCode,
/// 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>> {
if let Some(action) = CanvasAction::from_key(key) {
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
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)
@@ -42,7 +97,7 @@ impl ActionDispatcher {
let mut results = Vec::new();
for action in actions {
let result = Self::dispatch(action, state, ideal_cursor_column).await?;
let is_success = result.is_success(); // Check success before moving
let is_success = result.is_success();
results.push(result);
// Stop on first error
@@ -53,131 +108,3 @@ impl ActionDispatcher {
Ok(results)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::CanvasAction;
// Simple test implementation
struct TestFormState {
current_field: usize,
cursor_pos: usize,
inputs: Vec<String>,
field_names: Vec<String>,
has_changes: bool,
}
impl TestFormState {
fn new() -> Self {
Self {
current_field: 0,
cursor_pos: 0,
inputs: vec!["".to_string(), "".to_string()],
field_names: vec!["username".to_string(), "password".to_string()],
has_changes: false,
}
}
}
impl CanvasState for TestFormState {
fn current_field(&self) -> usize { self.current_field }
fn current_cursor_pos(&self) -> usize { self.cursor_pos }
fn set_current_field(&mut self, index: usize) { self.current_field = index; }
fn set_current_cursor_pos(&mut self, pos: usize) { self.cursor_pos = pos; }
fn get_current_input(&self) -> &str { &self.inputs[self.current_field] }
fn get_current_input_mut(&mut self) -> &mut String { &mut self.inputs[self.current_field] }
fn inputs(&self) -> Vec<&String> { self.inputs.iter().collect() }
fn fields(&self) -> Vec<&str> { self.field_names.iter().map(|s| s.as_str()).collect() }
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
// Custom action handling for testing
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &crate::state::ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(s) if s == "test_custom" => {
Some("Custom action handled".to_string())
}
_ => None,
}
}
}
#[tokio::test]
async fn test_typed_action_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
// Test character insertion
let result = ActionDispatcher::dispatch(
CanvasAction::InsertChar('a'),
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert!(result.is_success());
assert_eq!(state.get_current_input(), "a");
assert_eq!(state.cursor_pos, 1);
assert!(state.has_changes);
}
#[tokio::test]
async fn test_key_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let result = ActionDispatcher::dispatch_key(
crossterm::event::KeyCode::Char('b'),
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert!(result.is_some());
assert!(result.unwrap().is_success());
assert_eq!(state.get_current_input(), "b");
}
#[tokio::test]
async fn test_custom_action() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let result = ActionDispatcher::dispatch(
CanvasAction::Custom("test_custom".to_string()),
&mut state,
&mut ideal_cursor,
).await.unwrap();
match result {
ActionResult::HandledByFeature(msg) => {
assert_eq!(msg, "Custom action handled");
}
_ => panic!("Expected HandledByFeature result"),
}
}
#[tokio::test]
async fn test_batch_dispatch() {
let mut state = TestFormState::new();
let mut ideal_cursor = 0;
let actions = vec![
CanvasAction::InsertChar('h'),
CanvasAction::InsertChar('i'),
CanvasAction::MoveLeft,
CanvasAction::InsertChar('e'),
];
let results = ActionDispatcher::dispatch_batch(
actions,
&mut state,
&mut ideal_cursor,
).await.unwrap();
assert_eq!(results.len(), 4);
assert!(results.iter().all(|r| r.is_success()));
assert_eq!(state.get_current_input(), "hei");
}
}

View File

@@ -3,3 +3,9 @@ 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 use canvas::actions::{CanvasAction, ActionResult};
pub use canvas::state::{CanvasState, ActionContext};
pub use canvas::modes::{AppMode, HighlightState, ModeManager};

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

@@ -39,25 +39,45 @@ enter_edit_mode_after = ["a"]
previous_entry = ["left","q"]
next_entry = ["right","1"]
move_left = ["h"]
move_right = ["l"]
move_up = ["k"]
move_down = ["j"]
move_word_next = ["w"]
move_word_end = ["e"]
move_word_prev = ["b"]
move_word_end_prev = ["ge"]
move_line_start = ["0"]
move_line_end = ["$"]
move_first_line = ["gg"]
move_last_line = ["x"]
enter_highlight_mode = ["v"]
enter_highlight_mode_linewise = ["ctrl+v"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_up = ["k", "Up"]
move_left = ["h", "Left"]
move_right = ["l", "Right"]
move_down = ["j", "Down"]
# Optional
move_line_end = ["$"]
move_word_next = ["w"]
next_field = ["Tab"]
move_word_prev = ["b"]
move_word_end = ["e"]
move_last_line = ["shift+g"]
move_word_end_prev = ["ge"]
move_line_start = ["0"]
move_first_line = ["g+g"]
prev_field = ["Shift+Tab"]
[keybindings.highlight]
exit_highlight_mode = ["esc"]
enter_highlight_mode_linewise = ["ctrl+v"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_left = ["h", "Left"]
move_right = ["l", "Right"]
move_up = ["k", "Up"]
move_down = ["j", "Down"]
# Optional
move_word_next = ["w"]
move_line_start = ["0"]
move_line_end = ["$"]
move_word_prev = ["b"]
move_word_end = ["e"]
[keybindings.edit]
# BIG CHANGES NOW EXIT HANDLES EITHER IF THOSE
# exit_edit_mode = ["esc","ctrl+e"]
@@ -65,15 +85,30 @@ enter_highlight_mode_linewise = ["ctrl+v"]
# select_suggestion = ["enter"]
# next_field = ["enter"]
enter_decider = ["enter"]
prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"]
delete_char_backward = ["backspace"]
move_left = ["left"]
move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"]
### AUTOGENERATED CANVAS CONFIG
# Required
move_right = ["Right", "l"]
delete_char_backward = ["Backspace"]
next_field = ["Tab", "Enter"]
move_up = ["Up", "k"]
move_down = ["Down", "j"]
prev_field = ["Shift+Tab"]
move_left = ["Left", "h"]
# Optional
move_last_line = ["Ctrl+End", "G"]
delete_char_forward = ["Delete"]
move_word_prev = ["Ctrl+Left", "b"]
move_word_end = ["e"]
move_word_end_prev = ["ge"]
move_first_line = ["Ctrl+Home", "gg"]
move_word_next = ["Ctrl+Right", "w"]
move_line_start = ["Home", "0"]
move_line_end = ["End", "$"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]
command_execute = ["enter"]
@@ -91,3 +126,9 @@ keybinding_mode = "vim" # Options: "default", "vim", "emacs"
[colors]
theme = "dark"
# Options: "light", "dark", "high_contrast"

View File

@@ -3,7 +3,7 @@ use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_logic::{AddLogicFocus, AddLogicState};
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
@@ -11,10 +11,18 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph},
Frame,
};
use crate::components::handlers::canvas::render_canvas;
use crate::components::common::{dialog, autocomplete}; // Added autocomplete
use crate::config::binds::config::EditorKeybindingMode;
// Helper function to convert between HighlightState types
fn convert_highlight_state(local: &HighlightState) -> CanvasHighlightState {
match local {
HighlightState::Off => CanvasHighlightState::Off,
HighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor },
HighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line },
}
}
pub fn render_add_logic(
f: &mut Frame,
area: Rect,
@@ -77,18 +85,18 @@ pub fn render_add_logic(
let editor_borrow = add_logic_state.script_content_editor.borrow();
editor_borrow.cursor() // Returns (row, col) as (usize, usize)
};
let (cursor_line, cursor_col) = current_cursor;
// Account for TextArea's block borders (1 for each side)
let block_offset_x = 1;
let block_offset_y = 1;
// Position autocomplete at current cursor position
// Add 1 to column to position dropdown right after the cursor
let autocomplete_x = cursor_col + 1;
let autocomplete_y = cursor_line;
let input_rect = Rect {
x: (inner_area.x + block_offset_x + autocomplete_x as u16).min(inner_area.right().saturating_sub(20)),
y: (inner_area.y + block_offset_y + autocomplete_y as u16).min(inner_area.bottom().saturating_sub(5)),
@@ -152,40 +160,37 @@ pub fn render_add_logic(
);
f.render_widget(profile_text, top_info_area);
// Canvas
// Canvas - USING CANVAS LIBRARY
let focus_on_canvas_inputs = matches!(
add_logic_state.current_focus,
AddLogicFocus::InputLogicName
| AddLogicFocus::InputTargetColumn
| AddLogicFocus::InputDescription
);
// Call render_canvas and get the active_field_rect
let canvas_highlight_state = convert_highlight_state(highlight_state);
let active_field_rect = render_canvas(
f,
canvas_area,
add_logic_state, // Pass the whole state as it impl CanvasState
&add_logic_state.fields(),
&add_logic_state.current_field(),
&add_logic_state.inputs(),
theme,
is_edit_mode && focus_on_canvas_inputs, // is_edit_mode for canvas fields
highlight_state,
add_logic_state, // AddLogicState implements CanvasState
theme, // Theme implements CanvasTheme
is_edit_mode && focus_on_canvas_inputs,
&canvas_highlight_state,
);
// --- Render Autocomplete for Target Column ---
// `is_edit_mode` here refers to the general edit mode of the EventHandler
if is_edit_mode && add_logic_state.current_field() == 1 { // Target Column field
if let Some(suggestions) = add_logic_state.get_suggestions() { // Uses CanvasState impl
let selected = add_logic_state.get_selected_suggestion_index();
if !suggestions.is_empty() { // Only render if there are suggestions to show
if add_logic_state.in_target_column_suggestion_mode && add_logic_state.show_target_column_suggestions {
if !add_logic_state.target_column_suggestions.is_empty() {
if let Some(input_rect) = active_field_rect {
autocomplete::render_autocomplete_dropdown(
f,
input_rect,
f.area(), // Full frame area for clamping
theme,
suggestions,
selected,
&add_logic_state.target_column_suggestions,
add_logic_state.selected_target_column_suggestion_index,
);
}
}

View File

@@ -3,7 +3,7 @@ use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},

View File

@@ -1,8 +1,6 @@
// src/components/handlers.rs
pub mod canvas;
pub mod sidebar;
pub mod buffer_list;
pub use canvas::*;
pub use sidebar::*;
pub use buffer_list::*;

View File

@@ -1,255 +0,0 @@
// src/components/handlers/canvas.rs
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use canvas::canvas::CanvasState as LibraryCanvasState;
use std::cmp::{max, min};
/// Render canvas for legacy CanvasState (AddTableState, LoginState, RegisterState, AddLogicState)
pub fn render_canvas(
f: &mut Frame,
area: Rect,
form_state: &impl LegacyCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
render_canvas_impl(
f,
area,
fields,
current_field_idx,
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),
)
}
/// Render canvas for library CanvasState (FormState)
pub fn render_canvas_library(
f: &mut Frame,
area: Rect,
form_state: &impl LibraryCanvasState,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
) -> Option<Rect> {
render_canvas_impl(
f,
area,
fields,
current_field_idx,
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),
)
}
/// Internal implementation shared by both render functions
fn render_canvas_impl<F1, F2>(
f: &mut Frame,
area: Rect,
fields: &[&str],
current_field_idx: &usize,
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
current_cursor_pos: usize,
has_unsaved_changes: bool,
get_display_value: F1,
has_display_override: F2,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
{
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
let border_style = if has_unsaved_changes {
Style::default().fg(theme.warning)
} else if is_edit_mode {
Style::default().fg(theme.accent)
} else {
Style::default().fg(theme.secondary)
};
let input_container = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(theme.bg));
let input_block = Rect {
x: columns[1].x,
y: columns[1].y,
width: columns[1].width,
height: fields.len() as u16 + 2,
};
f.render_widget(&input_container, input_block);
let input_area = input_container.inner(input_block);
let input_rows = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); fields.len()])
.split(input_area);
let mut active_field_input_rect = None;
for (i, field) in fields.iter().enumerate() {
let label = Paragraph::new(Line::from(Span::styled(
format!("{}:", field),
Style::default().fg(theme.fg),
)));
f.render_widget(
label,
Rect {
x: columns[0].x,
y: input_block.y + 1 + i as u16,
width: columns[0].width,
height: 1,
},
);
}
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
// Use the provided closure to get display value
let text = get_display_value(i);
let text_len = text.chars().count();
let line: Line;
match highlight_state {
HighlightState::Off => {
line = Line::from(Span::styled(
&text,
if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
},
));
}
HighlightState::Characterwise { anchor } => {
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx {
(anchor_char, current_cursor_pos)
} else {
(current_cursor_pos, anchor_char)
};
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight);
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
if start_field == end_field {
let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
let after: String = text.chars().skip(clamped_end + 1).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else if i == start_field {
let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
]);
} else if i == end_field {
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
line = Line::from(vec![
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else {
line = Line::from(Span::styled(&text, highlight_style));
}
} else {
line = Line::from(Span::styled(
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
HighlightState::Linewise { anchor_line } => {
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
let normal_style_in_highlight = Style::default().fg(theme.highlight);
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
line = Line::from(Span::styled(&text, highlight_style));
} else {
line = Line::from(Span::styled(
&text,
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
}
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
// Use the provided closure to check for display override
let cursor_x = if has_display_override(i) {
// If an override exists, place the cursor at the end.
input_rows[i].x + text.chars().count() as u16
} else {
// Otherwise, use the real cursor position.
input_rows[i].x + current_cursor_pos as u16
};
let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y));
}
}
active_field_input_rect
}

View File

@@ -1,9 +1,5 @@
// src/functions/modes.rs
pub mod read_only;
pub mod edit;
pub mod navigation;
pub use read_only::*;
pub use edit::*;
pub use navigation::*;

View File

@@ -1,6 +0,0 @@
// src/functions/modes/edit.rs
pub mod form_e;
pub mod auth_e;
pub mod add_table_e;
pub mod add_logic_e;

View File

@@ -1,135 +0,0 @@
// src/functions/modes/edit/add_logic_e.rs
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Keep key for insert_char
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let mut message = String::new();
match action {
"next_field" => {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
"prev_field" => {
let current_field = state.current_field();
let prev_field = if current_field == 0 {
AddLogicState::INPUT_FIELD_COUNT - 1
} else {
current_field - 1
};
state.set_current_field(prev_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[prev_field]);
}
"delete_char_forward" => {
let current_pos = state.current_cursor_pos();
let current_input_mut = state.get_current_input_mut();
if current_pos < current_input_mut.len() {
current_input_mut.remove(current_pos);
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"delete_char_backward" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.get_current_input_mut().remove(new_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 { state.update_target_column_suggestions(); }
}
}
"move_left" => {
let current_pos = state.current_cursor_pos();
if current_pos > 0 {
let new_pos = current_pos - 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"move_right" => {
let current_pos = state.current_cursor_pos();
let input_len = state.get_current_input().len();
if current_pos < input_len {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
}
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let current_pos = state.current_cursor_pos();
state.get_current_input_mut().insert(current_pos, c);
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
state.set_has_unsaved_changes(true);
if state.current_field() == 1 {
state.update_target_column_suggestions();
}
}
}
"suggestion_down" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let next_selection = (current_selection + 1) % state.target_column_suggestions.len();
state.selected_target_column_suggestion_index = Some(next_selection);
}
}
"suggestion_up" => {
if state.in_target_column_suggestion_mode && !state.target_column_suggestions.is_empty() {
let current_selection = state.selected_target_column_suggestion_index.unwrap_or(0);
let prev_selection = if current_selection == 0 {
state.target_column_suggestions.len() - 1
} else {
current_selection - 1
};
state.selected_target_column_suggestion_index = Some(prev_selection);
}
}
"select_suggestion" => {
if state.in_target_column_suggestion_mode {
let mut selected_suggestion_text: Option<String> = None;
if let Some(selected_idx) = state.selected_target_column_suggestion_index {
if let Some(suggestion) = state.target_column_suggestions.get(selected_idx) {
selected_suggestion_text = Some(suggestion.clone());
}
}
if let Some(suggestion_text) = selected_suggestion_text {
state.target_column_input = suggestion_text.clone();
state.target_column_cursor_pos = state.target_column_input.len();
*ideal_cursor_column = state.target_column_cursor_pos;
state.set_has_unsaved_changes(true);
message = format!("Selected column: '{}'", suggestion_text);
}
state.in_target_column_suggestion_mode = false;
state.show_target_column_suggestions = false;
state.selected_target_column_suggestion_index = None;
state.update_target_column_suggestions();
} else {
let current_field = state.current_field();
let next_field = (current_field + 1) % AddLogicState::INPUT_FIELD_COUNT;
state.set_current_field(next_field);
*ideal_cursor_column = state.current_cursor_pos();
message = format!("Focus on field {}", state.fields()[next_field]);
}
}
_ => {}
}
Ok(message)
}

View File

@@ -1,341 +0,0 @@
// src/functions/modes/edit/add_table_e.rs
use crate::state::pages::add_table::AddTableState;
use crossterm::event::{KeyCode, KeyEvent};
use canvas::canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}
/// Executes edit actions for the AddTable view canvas.
pub async fn execute_edit_action(
action: &str,
key: KeyEvent, // Needed for insert_char
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
// Add other params like grpc_client if needed for future actions (e.g., validation)
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key.".to_string());
}
Ok("".to_string()) // No message needed for char insertion
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
// Prevent cycling forward
if current_field < last_field_index {
state.set_current_field(current_field + 1);
}
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
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 max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let current_field = state.current_field();
// Prevent moving up from the first field
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("ahoj".to_string())
}
"move_down" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_last_line" => {
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
// Actions handled by main event loop (mode changes, save, revert)
"exit_edit_mode" | "save" | "revert" => {
Ok("Action handled by main loop".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}

View File

@@ -1,466 +0,0 @@
// src/functions/modes/edit/auth_e.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::RegisterState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crossterm::event::{KeyCode, KeyEvent};
use canvas::autocomplete::AutocompleteCanvasState;
use canvas::canvas::CanvasState;
use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<String> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
return Ok("No changes to save or revert.".to_string());
}
if let Some(form_state) =
(state as &mut dyn Any).downcast_mut::<FormState>()
{
match action {
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
)
.await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now
Ok(message)
}
"revert" => {
revert(
form_state,
grpc_client,
)
.await
}
_ => unreachable!(),
}
} else {
Ok(format!(
"Action '{}' not implemented for this state type.",
action
))
}
}
_ => Ok(format!("Common action '{}' not handled here.", action)),
}
}
pub async fn execute_edit_action<S: CanvasState + Any + Send>(
action: &str,
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key."
.to_string());
}
Ok("working?".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_down" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to first field".to_string())
}
"move_last_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to last field".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
// --- Autocomplete Actions ---
"suggestion_down" | "suggestion_up" | "select_suggestion" | "exit_suggestion_mode" => {
// Attempt to downcast to RegisterState to handle suggestion logic here
if let Some(register_state) = (state as &mut dyn Any).downcast_mut::<RegisterState>() {
// Only handle if it's the role field (index 4) and autocomplete is active
if register_state.current_field() == 4 && register_state.is_autocomplete_active() {
match action {
"suggestion_down" => {
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
autocomplete_state.select_next();
Ok("Suggestion changed down".to_string())
} else {
Ok("No autocomplete state".to_string())
}
}
"suggestion_up" => {
if let Some(autocomplete_state) = register_state.autocomplete_state_mut() {
autocomplete_state.select_previous();
Ok("Suggestion changed up".to_string())
} else {
Ok("No autocomplete state".to_string())
}
}
"select_suggestion" => {
if let Some(message) = register_state.apply_autocomplete_selection() {
Ok(message)
} else {
Ok("No suggestion selected".to_string())
}
}
"exit_suggestion_mode" => {
register_state.deactivate_autocomplete();
Ok("Suggestions hidden".to_string())
}
_ => Ok("Suggestion action ignored: State mismatch.".to_string())
}
} else {
Ok("Suggestion action ignored: Not on role field or autocomplete not active.".to_string())
}
} else {
Ok(format!("Action '{}' not applicable for this state type.", action))
}
}
// --- End Autocomplete Actions ---
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,435 +0,0 @@
// src/functions/modes/edit/form_e.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crate::tui::functions::common::form::SaveOutcome;
use crate::modes::handlers::event::EventOutcome;
use crossterm::event::{KeyCode, KeyEvent};
use canvas::canvas::CanvasState;
use std::any::Any;
use anyhow::Result;
pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
) -> Result<EventOutcome> {
match action {
"save" | "revert" => {
if !state.has_unsaved_changes() {
return Ok(EventOutcome::Ok("No changes to save or revert.".to_string()));
}
if let Some(form_state) =
(state as &mut dyn Any).downcast_mut::<FormState>()
{
match action {
"save" => {
let save_result = save(
app_state,
form_state,
grpc_client,
).await;
match save_result {
Ok(save_outcome) => {
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
SaveOutcome::UpdatedExisting => "Entry updated.".to_string(),
SaveOutcome::CreatedNew(_) => "New entry created.".to_string(),
};
Ok(EventOutcome::DataSaved(save_outcome, message))
}
Err(e) => Err(e),
}
}
"revert" => {
let revert_result = revert(
form_state,
grpc_client,
).await;
match revert_result {
Ok(message) => Ok(EventOutcome::Ok(message)),
Err(e) => Err(e),
}
}
_ => unreachable!(),
}
} else {
Ok(EventOutcome::Ok(format!(
"Action '{}' not implemented for this state type.",
action
)))
}
}
_ => Ok(EventOutcome::Ok(format!("Common action '{}' not handled here.", action))),
}
}
pub async fn execute_edit_action<S: CanvasState>(
action: &str,
key: KeyEvent,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
match action {
"insert_char" => {
if let KeyCode::Char(c) = key.code {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.insert(cursor_pos, c);
*field_value = chars.into_iter().collect();
state.set_current_cursor_pos(cursor_pos + 1);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = state.current_cursor_pos();
}
} else {
return Ok("Error: insert_char called without a char key."
.to_string());
}
Ok("".to_string())
}
"delete_char_backward" => {
if state.current_cursor_pos() > 0 {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos <= chars.len() {
chars.remove(cursor_pos - 1);
*field_value = chars.into_iter().collect();
let new_pos = cursor_pos - 1;
state.set_current_cursor_pos(new_pos);
state.set_has_unsaved_changes(true);
*ideal_cursor_column = new_pos;
}
}
Ok("".to_string())
}
"delete_char_forward" => {
let cursor_pos = state.current_cursor_pos();
let field_value = state.get_current_input_mut();
let mut chars: Vec<char> = field_value.chars().collect();
if cursor_pos < chars.len() {
chars.remove(cursor_pos);
*field_value = chars.into_iter().collect();
state.set_has_unsaved_changes(true);
*ideal_cursor_column = cursor_pos;
}
Ok("".to_string())
}
"next_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = (current_field + 1) % num_fields;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"prev_field" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = if current_field == 0 {
num_fields - 1
} else {
current_field - 1
};
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_left" => {
let new_pos = state.current_cursor_pos().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_up" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_down" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = (state.current_field() + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len();
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_first_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to first field".to_string())
}
"move_last_line" => {
let num_fields = state.fields().len();
if num_fields > 0 {
let new_field = num_fields - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_pos = current_input.len();
state.set_current_cursor_pos(
(*ideal_cursor_column).min(max_pos),
);
}
Ok("Moved to last field".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len());
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos {
find_word_end(current_input, new_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
_ => Ok(format!("Unknown or unhandled edit action: {}", action)),
}
}
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len {
return len;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[pos]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,6 +0,0 @@
// src/functions/modes/read_only.rs
pub mod auth_ro;
pub mod form_ro;
pub mod add_table_ro;
pub mod add_logic_ro;

View File

@@ -1,235 +0,0 @@
// src/functions/modes/read_only/add_logic_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_logic::AddLogicState; // Changed
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::state::AppState;
use anyhow::Result;
// Word navigation helpers (get_char_type, find_next_word_start, etc.)
// can be kept as they are generic.
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() { CharType::Whitespace }
else if c.is_alphanumeric() { CharType::Alphanumeric }
else { CharType::Punctuation }
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len { return len.saturating_sub(1); }
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
pos
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddLogic view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState,
state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
if current_field > 0 {
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields == 0 { return Ok("No fields.".to_string()); }
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
} else {
// Move focus outside canvas when moving down from the last field
// FIX: Go to ScriptContentPreview instead of SaveButton
app_state.ui.focus_outside_canvas = true;
state.last_canvas_field = 2;
state.current_focus = crate::state::pages::add_logic::AddLogicFocus::ScriptContentPreview; // FIXED!
*command_message = "Focus moved to script preview".to_string();
}
Ok(command_message.clone())
}
// ... (rest of the actions remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddLogicState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddLogicState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() { 0 } else { current_input.len().saturating_sub(1) };
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len().saturating_sub(1) {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos == current_pos && current_pos < current_input.len().saturating_sub(1) {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(current_input, state.current_cursor_pos());
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
"enter_edit_mode_before" | "enter_edit_mode_after" | "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
Ok("Mode change handled by main loop".to_string())
}
_ => {
key_sequence_tracker.reset();
command_message.clear();
Ok(format!("Unknown read-only action: {}", action))
},
}
}

View File

@@ -1,267 +0,0 @@
// src/functions/modes/read_only/add_table_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState;
use canvas::canvas::CanvasState;
use anyhow::Result;
// Re-use word navigation helpers if they are public or move them to a common module
// For now, duplicating them here for simplicity. Consider refactoring later.
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() { CharType::Whitespace }
else if c.is_alphanumeric() { CharType::Alphanumeric }
else { CharType::Punctuation }
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 || current_pos >= len { return len; }
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == initial_type { pos += 1; }
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace { pos += 1; }
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 { return 0; }
let mut pos = current_pos.min(len - 1);
if get_char_type(chars[pos]) == CharType::Whitespace {
pos = find_next_word_start(text, pos);
}
if pos >= len { return len.saturating_sub(1); }
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type { pos += 1; }
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { return 0; }
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { pos -= 1; }
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace { return 0; }
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type { pos -= 1; }
pos
}
// Note: find_prev_word_end might need adjustments based on desired behavior.
// This version finds the end of the word *before* the previous word start.
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let prev_start = find_prev_word_start(text, current_pos);
if prev_start == 0 { return 0; }
// Find the end of the word that starts at prev_start - 1
find_word_end(text, prev_start.saturating_sub(1))
}
/// Executes read-only actions for the AddTable view canvas.
pub async fn execute_action(
action: &str,
app_state: &mut AppState, // Needed for focus_outside_canvas
state: &mut AddTableState,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String, // Keep for potential messages
) -> Result<String> {
// Use the CanvasState trait methods implemented for AddTableState
match action {
"move_up" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field(); // Gets the index (0, 1, or 2)
if current_field > 0 {
// This handles moving from field 2 -> 1, or 1 -> 0
let new_field = current_field - 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column as cursor moved
*command_message = "".to_string(); // Clear message for successful internal navigation
} else {
// current_field is 0 (InputTableName), and user pressed Up.
// Forbid moving up. Do not change focus or cursor.
*command_message = "At top of form.".to_string();
}
Ok(command_message.clone())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields == 0 {
*command_message = "No fields.".to_string();
return Ok(command_message.clone());
}
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field < last_field_index {
let new_field = current_field + 1;
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len(); // Allow cursor at end
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
*command_message = "".to_string();
} else {
// Move focus outside canvas when moving down from the last field
app_state.ui.focus_outside_canvas = true;
// Set focus to the first element outside canvas (AddColumnButton)
state.current_focus =
crate::state::pages::add_table::AddTableFocus::AddColumnButton;
*command_message = "Focus moved below canvas".to_string();
}
Ok(command_message.clone())
}
// ... (other actions like "move_first_line", "move_left", etc. remain the same) ...
"move_first_line" => {
key_sequence_tracker.reset();
if AddTableState::INPUT_FIELD_COUNT > 0 {
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = AddTableState::INPUT_FIELD_COUNT;
if num_fields > 0 {
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = current_input.len();
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos; // Update ideal column
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
// Allow moving cursor one position past the end
if current_pos < current_input.len() {
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_next" => {
let current_input = state.get_current_input();
let new_pos = find_next_word_start(
current_input,
state.current_cursor_pos(),
);
let final_pos = new_pos.min(current_input.len()); // Allow cursor at end
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
// If find_word_end returns current_pos, try starting search from next char
let final_pos =
if new_pos == current_pos && current_pos < current_input.len() {
find_word_end(current_input, current_pos + 1)
} else {
new_pos
};
let max_valid_index = current_input.len(); // Allow cursor at end
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_prev" => {
let current_input = state.get_current_input();
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;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
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;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
*command_message = "".to_string();
Ok(command_message.clone())
}
"move_line_end" => {
let current_input = state.get_current_input();
let new_pos = current_input.len(); // Allow cursor at end
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
*command_message = "".to_string();
Ok(command_message.clone())
}
// Actions handled by main event loop (mode changes)
"enter_edit_mode_before" | "enter_edit_mode_after"
| "enter_command_mode" | "exit_highlight_mode" => {
key_sequence_tracker.reset();
// These actions are primarily mode changes handled by the main event loop.
// The message here might be overridden by the main loop's message for mode change.
*command_message = "Mode change initiated".to_string();
Ok(command_message.clone())
}
_ => {
key_sequence_tracker.reset();
*command_message =
format!("Unknown read-only action: {}", action);
Ok(command_message.clone())
}
}
}

View File

@@ -1,343 +0,0 @@
// src/functions/modes/read_only/auth_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::state::app::state::AppState;
use canvas::canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
pub async fn execute_action<S: CanvasState>(
action: &str,
app_state: &mut AppState,
state: &mut S,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
"move_up" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("move up from functions/modes/read_only/auth_ro.rs".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let last_field_index = num_fields - 1;
if current_field == last_field_index {
// Already on the last field, move focus outside
app_state.ui.focus_outside_canvas = true;
app_state.focused_button_index= 0;
key_sequence_tracker.reset();
Ok("Focus moved below canvas".to_string())
} else {
// Move to the next field within the canvas
let new_field = (current_field + 1).min(last_field_index);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string()) // Clear previous debug message
}
}
"move_first_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"exit_edit_mode" => {
key_sequence_tracker.reset();
command_message.clear();
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty()
&& current_pos < current_input.len().saturating_sub(1)
{
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos != current_pos {
new_pos
} else {
find_word_end(current_input, new_pos + 1)
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
_ => {
key_sequence_tracker.reset();
Ok(format!("Unknown read-only action: {}", action))
},
}
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,329 +0,0 @@
// src/functions/modes/read_only/form_ro.rs
use crate::config::binds::key_sequences::KeySequenceTracker;
use canvas::canvas::CanvasState;
use anyhow::Result;
#[derive(PartialEq)]
enum CharType {
Whitespace,
Alphanumeric,
Punctuation,
}
pub async fn execute_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
key_sequence_tracker: &mut KeySequenceTracker,
command_message: &mut String,
) -> Result<String> {
match action {
"previous_entry" | "next_entry" => {
key_sequence_tracker.reset();
Ok(format!(
"Action '{}' should be handled by context-specific logic",
action
))
}
"move_up" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = current_field.saturating_sub(1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string())
}
"move_down" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate.".to_string());
}
let current_field = state.current_field();
let new_field = (current_field + 1).min(num_fields - 1);
state.set_current_field(new_field);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
Ok("".to_string())
}
"move_first_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
state.set_current_field(0);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_last_line" => {
key_sequence_tracker.reset();
let num_fields = state.fields().len();
if num_fields == 0 {
return Ok("No fields to navigate to.".to_string());
}
let last_field_index = num_fields - 1;
state.set_current_field(last_field_index);
let current_input = state.get_current_input();
let max_cursor_pos = if current_input.is_empty() {
0
} else {
current_input.len().saturating_sub(1)
};
let new_pos = (*ideal_cursor_column).min(max_cursor_pos);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"exit_edit_mode" => {
key_sequence_tracker.reset();
command_message.clear();
Ok("".to_string())
}
"move_left" => {
let current_pos = state.current_cursor_pos();
let new_pos = current_pos.saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
Ok("".to_string())
}
"move_right" => {
let current_input = state.get_current_input();
let current_pos = state.current_cursor_pos();
if !current_input.is_empty()
&& current_pos < current_input.len().saturating_sub(1)
{
let new_pos = current_pos + 1;
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_next" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos =
find_next_word_start(current_input, state.current_cursor_pos());
let final_pos = new_pos.min(current_input.len().saturating_sub(1));
state.set_current_cursor_pos(final_pos);
*ideal_cursor_column = final_pos;
}
Ok("".to_string())
}
"move_word_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let current_pos = state.current_cursor_pos();
let new_pos = find_word_end(current_input, current_pos);
let final_pos = if new_pos != current_pos {
new_pos
} else {
find_word_end(current_input, new_pos + 1)
};
let max_valid_index = current_input.len().saturating_sub(1);
let clamped_pos = final_pos.min(max_valid_index);
state.set_current_cursor_pos(clamped_pos);
*ideal_cursor_column = clamped_pos;
}
Ok("".to_string())
}
"move_word_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_start(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("".to_string())
}
"move_word_end_prev" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = find_prev_word_end(
current_input,
state.current_cursor_pos(),
);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
}
Ok("Moved to previous word end".to_string())
}
"move_line_start" => {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
Ok("".to_string())
}
"move_line_end" => {
let current_input = state.get_current_input();
if !current_input.is_empty() {
let new_pos = current_input.len().saturating_sub(1);
state.set_current_cursor_pos(new_pos);
*ideal_cursor_column = new_pos;
} else {
state.set_current_cursor_pos(0);
*ideal_cursor_column = 0;
}
Ok("".to_string())
}
_ => {
key_sequence_tracker.reset();
Ok(format!("Unknown read-only action: {}", action))
},
}
}
fn get_char_type(c: char) -> CharType {
if c.is_whitespace() {
CharType::Whitespace
} else if c.is_alphanumeric() {
CharType::Alphanumeric
} else {
CharType::Punctuation
}
}
fn find_next_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let current_pos = current_pos.min(chars.len());
if current_pos == chars.len() {
return current_pos;
}
let mut pos = current_pos;
let initial_type = get_char_type(chars[pos]);
while pos < chars.len() && get_char_type(chars[pos]) == initial_type {
pos += 1;
}
while pos < chars.len() && get_char_type(chars[pos]) == CharType::Whitespace {
pos += 1;
}
pos
}
fn find_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return 0;
}
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
pos += 1;
}
return pos.saturating_sub(1);
}
pos = find_next_word_start(text, pos);
if pos >= len {
return len.saturating_sub(1);
}
let word_type = get_char_type(chars[pos]);
while pos < len && get_char_type(chars[pos]) == word_type {
pos += 1;
}
pos.saturating_sub(1).min(len.saturating_sub(1))
}
fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if get_char_type(chars[pos]) != CharType::Whitespace {
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
0
} else {
pos
}
}
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
pos -= 1;
}
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
}

View File

@@ -1,8 +1,5 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::functions::modes::edit::{
add_logic_e, add_table_e, form_e,
};
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
@@ -15,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;
@@ -82,7 +79,9 @@ pub async fn handle_form_edit_with_canvas(
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
@@ -127,16 +126,65 @@ pub async fn handle_form_edit_with_canvas(
Ok(String::new())
}
/// 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
/// Helper function to execute a specific action using canvas library
async fn execute_canvas_action(
action: &str,
key: KeyEvent,
form_state: &mut FormState,
ideal_cursor_column: &mut usize,
) -> Result<String> {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => Ok(msg.unwrap_or_default()),
Ok(ActionResult::HandledByFeature(msg)) => Ok(msg),
Ok(ActionResult::Error(msg)) => Ok(format!("Error: {}", msg)),
Ok(ActionResult::RequiresContext(msg)) => Ok(format!("Context needed: {}", msg)),
Err(e) => Ok(format!("Action failed: {}", e)),
}
}
/// FIXED: Unified canvas action handler with proper priority order for edit mode
async fn handle_canvas_state_edit<S: CanvasState>(
key: KeyEvent,
config: &Config,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try direct key mapping first (same pattern as FormState)
if let Some(canvas_action) = CanvasAction::from_key(key.code) {
// println!("DEBUG: Key pressed: {:?}", key); // DEBUG
// PRIORITY 1: Character insertion in edit mode comes FIRST
if let KeyCode::Char(c) = key.code {
// Only insert if no modifiers or just shift (for uppercase)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
// println!("DEBUG: Using character insertion priority for: {}", c); // DEBUG
let canvas_action = CanvasAction::InsertChar(c);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
// println!("DEBUG: Character insertion failed: {:?}, trying config", e);
// Fall through to try config mappings
}
}
}
}
// PRIORITY 2: Check canvas config for special keys/combinations
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_edit_action(key.code, key.modifiers) {
// println!("DEBUG: Canvas config mapped to: {}", action_name); // DEBUG
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
@@ -151,33 +199,43 @@ async fn handle_canvas_state_edit<S: CanvasState>(
return Ok(format!("Context needed: {}", msg));
}
Err(_) => {
// Fall through to try config mapping
// println!("DEBUG: Canvas action failed, trying client config"); // DEBUG
}
}
} else {
// println!("DEBUG: No canvas config mapping found"); // DEBUG
}
// Try config-mapped action (same pattern as FormState)
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
// PRIORITY 3: Check client config ONLY for non-character keys or modified keys
if !matches!(key.code, KeyCode::Char(_)) || !key.modifiers.is_empty() {
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers) {
// println!("DEBUG: Client config mapped to: {} (for non-char key)", action_str); // DEBUG
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
}
Ok(ActionResult::HandledByFeature(msg)) => {
return Ok(msg);
}
Ok(ActionResult::Error(msg)) => {
return Ok(format!("Error: {}", msg));
}
Ok(ActionResult::RequiresContext(msg)) => {
return Ok(format!("Context needed: {}", msg));
}
Err(e) => {
return Ok(format!("Action failed: {}", e));
}
}
} else {
// println!("DEBUG: No client config mapping found for non-char key"); // DEBUG
}
} else {
// println!("DEBUG: Skipping client config for character key in edit mode"); // DEBUG
}
// println!("DEBUG: No action taken for key: {:?}", key); // DEBUG
Ok(String::new())
}
@@ -291,8 +349,8 @@ pub async fn handle_edit_event(
} else {
"insert_char"
};
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action,
key,
form_state,
@@ -321,8 +379,8 @@ pub async fn handle_edit_event(
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIX: Pass &mut event_handler.ideal_cursor_column
let msg = form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
let msg = execute_canvas_action(
"next_field",
key,
form_state,
@@ -357,10 +415,10 @@ pub async fn handle_edit_event(
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
action_str,
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
@@ -375,8 +433,8 @@ pub async fn handle_edit_event(
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
action_str,
key,
form_state,
@@ -408,10 +466,10 @@ pub async fn handle_edit_event(
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
"insert_char",
// NEW: Use unified canvas handler instead of add_logic_e::execute_edit_action
handle_canvas_state_edit(
key,
config,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
@@ -426,8 +484,8 @@ pub async fn handle_edit_event(
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
// FIXED: Use canvas library instead of form_e::execute_edit_action
execute_canvas_action(
"insert_char",
key,
form_state,

View File

@@ -5,16 +5,85 @@ use crate::config::binds::key_sequences::KeySequenceTracker;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::add_table::AddTableState;
use crate::state::app::state::AppState;
use crate::functions::modes::read_only::{add_logic_ro, auth_ro, form_ro, add_table_ro};
use canvas::{canvas::{CanvasAction, CanvasState, ActionResult}, dispatcher::ActionDispatcher};
use crossterm::event::KeyEvent;
use anyhow::Result;
/// Helper function to dispatch canvas action for any CanvasState
async fn dispatch_canvas_action<S: CanvasState>(
action: &str,
state: &mut S,
ideal_cursor_column: &mut usize,
) -> String {
let canvas_action = CanvasAction::from_string(action);
match ActionDispatcher::dispatch(canvas_action, state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => msg.unwrap_or_default(),
Ok(ActionResult::HandledByFeature(msg)) => msg,
Ok(ActionResult::Error(msg)) => format!("Error: {}", msg),
Ok(ActionResult::RequiresContext(msg)) => format!("Context needed: {}", msg),
Err(e) => format!("Action failed: {}", e),
}
}
/// Helper function to dispatch canvas action to the appropriate state based on UI
async fn dispatch_to_active_state(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
ideal_cursor_column: &mut usize,
) -> String {
if app_state.ui.show_add_table {
dispatch_canvas_action(action, add_table_state, ideal_cursor_column).await
} else if app_state.ui.show_add_logic {
dispatch_canvas_action(action, add_logic_state, ideal_cursor_column).await
} else if app_state.ui.show_register {
dispatch_canvas_action(action, register_state, ideal_cursor_column).await
} else if app_state.ui.show_login {
dispatch_canvas_action(action, login_state, ideal_cursor_column).await
} else {
dispatch_canvas_action(action, form_state, ideal_cursor_column).await
}
}
/// Helper function to handle context-specific actions that need special treatment
async fn handle_context_action(
action: &str,
app_state: &AppState,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<Option<String>> {
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
Ok(Some(crate::tui::functions::form::handle_action(
action,
form_state,
grpc_client,
ideal_cursor_column,
).await?))
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
Ok(Some(crate::tui::functions::login::handle_action(action).await?))
} else {
Ok(None) // Not a context action, use regular canvas dispatch
}
}
pub async fn handle_form_readonly_with_canvas(
key_event: KeyEvent,
config: &Config,
@@ -22,7 +91,9 @@ pub async fn handle_form_readonly_with_canvas(
ideal_cursor_column: &mut usize,
) -> Result<String> {
// Try canvas action from key first
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
let canvas_config = canvas::config::CanvasConfig::load();
if let Some(action_name) = canvas_config.get_read_only_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(action_name);
match ActionDispatcher::dispatch(canvas_action, form_state, ideal_cursor_column).await {
Ok(ActionResult::Success(msg)) => {
return Ok(msg.unwrap_or_default());
@@ -89,8 +160,7 @@ pub async fn handle_read_only_event(
}
if config.is_enter_edit_mode_after(key.code, key.modifiers) {
// Determine target state to adjust cursor
// Determine target state to adjust cursor - all states now use CanvasState trait
if app_state.ui.show_login {
let current_input = login_state.get_current_input();
let current_pos = login_state.current_cursor_pos();
@@ -120,8 +190,7 @@ pub async fn handle_read_only_event(
*ideal_cursor_column = add_table_state.current_cursor_pos();
}
} else {
// Handle FormState (uses library CanvasState)
use canvas::canvas::CanvasState as LibraryCanvasState; // Import at the top of the function
// Handle FormState
let current_input = form_state.get_current_input();
let current_pos = form_state.current_cursor_pos();
if !current_input.is_empty() && current_pos < current_input.len() {
@@ -135,76 +204,31 @@ pub async fn handle_read_only_event(
return Ok((false, command_message.clone()));
}
const CONTEXT_ACTIONS_FORM: &[&str] = &[
"previous_entry",
"next_entry",
];
const CONTEXT_ACTIONS_LOGIN: &[&str] = &[
"previous_entry",
"next_entry",
];
if key.modifiers.is_empty() {
key_sequence_tracker.add_key(key.code);
let sequence = key_sequence_tracker.get_sequence();
if let Some(action) = config.matches_key_sequence_generalized(&sequence).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register{
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
@@ -216,62 +240,26 @@ pub async fn handle_read_only_event(
if sequence.len() == 1 && !config.is_key_sequence_prefix(&sequence) {
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
key_sequence_tracker.reset();
return Ok((false, result));
@@ -282,62 +270,26 @@ pub async fn handle_read_only_event(
key_sequence_tracker.reset();
if let Some(action) = config.get_read_only_action_for_key(key.code, key.modifiers).as_deref() {
let result = if app_state.ui.show_form && CONTEXT_ACTIONS_FORM.contains(&action) {
crate::tui::functions::form::handle_action(
// Try context-specific actions first, otherwise use canvas dispatch
let result = if let Some(context_result) = handle_context_action(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
).await? {
context_result
} else {
dispatch_to_active_state(
action,
app_state,
form_state,
grpc_client,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
action,
app_state,
login_state,
register_state,
add_table_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_add_logic {
add_logic_ro::execute_action(
action,
app_state,
add_logic_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_register {
auth_ro::execute_action(
action,
app_state,
register_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
login_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
} else {
form_ro::execute_action(
action,
form_state,
ideal_cursor_column,
key_sequence_tracker,
command_message,
)
.await?
).await
};
return Ok((false, result));
}

View File

@@ -1,4 +1,3 @@
// src/modes/handlers.rs
pub mod event;
pub mod event_helper;
pub mod mode_manager;

View File

@@ -15,23 +15,20 @@ use crate::modes::{
general::{dialog, navigation},
handlers::mode_manager::{AppMode, ModeManager},
};
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use crate::services::auth::AuthClient;
use crate::services::grpc_client::GrpcClient;
use canvas::{canvas::CanvasAction, dispatcher::ActionDispatcher};
use canvas::canvas::CanvasState as LibraryCanvasState;
use super::event_helper::*;
use canvas::canvas::CanvasState; // Only need this import now
use crate::state::{
app::{
buffer::{AppView, BufferState},
highlight::HighlightState,
search::SearchState, // Correctly imported
search::SearchState,
state::AppState,
},
pages::{
admin::AdminState,
auth::{AuthState, LoginState, RegisterState},
canvas_state::CanvasState,
form::FormState,
intro::IntroState,
},
@@ -48,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;
@@ -89,7 +87,6 @@ pub struct EventHandler {
pub navigation_state: NavigationState,
pub search_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub search_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
// --- ADDED FOR LIVE AUTOCOMPLETE ---
pub autocomplete_result_sender: mpsc::UnboundedSender<Vec<Hit>>,
pub autocomplete_result_receiver: mpsc::UnboundedReceiver<Vec<Hit>>,
}
@@ -103,7 +100,7 @@ impl EventHandler {
grpc_client: GrpcClient,
) -> Result<Self> {
let (search_tx, search_rx) = unbounded_channel();
let (autocomplete_tx, autocomplete_rx) = unbounded_channel(); // ADDED
let (autocomplete_tx, autocomplete_rx) = unbounded_channel();
Ok(EventHandler {
command_mode: false,
command_input: String::new(),
@@ -122,7 +119,6 @@ impl EventHandler {
navigation_state: NavigationState::new(),
search_result_sender: search_tx,
search_result_receiver: search_rx,
// --- ADDED ---
autocomplete_result_sender: autocomplete_tx,
autocomplete_result_receiver: autocomplete_rx,
})
@@ -136,6 +132,95 @@ impl EventHandler {
self.navigation_state.activate_find_file(options);
}
// Helper functions - replace the removed event_helper functions
fn get_current_field_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_field()
} else if app_state.ui.show_register {
register_state.current_field()
} else {
form_state.current_field()
}
}
fn get_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_cursor_pos()
} else if app_state.ui.show_register {
register_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
}
}
fn get_has_unsaved_changes_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> bool {
if app_state.ui.show_login {
login_state.has_unsaved_changes()
} else if app_state.ui.show_register {
register_state.has_unsaved_changes()
} else {
form_state.has_unsaved_changes()
}
}
fn get_current_input_for_state<'a>(
app_state: &AppState,
login_state: &'a LoginState,
register_state: &'a RegisterState,
form_state: &'a FormState,
) -> &'a str {
if app_state.ui.show_login {
login_state.get_current_input()
} else if app_state.ui.show_register {
register_state.get_current_input()
} else {
form_state.get_current_input()
}
}
fn set_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
form_state: &mut FormState,
pos: usize,
) {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(pos);
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(pos);
} else {
form_state.set_current_cursor_pos(pos);
}
}
fn get_cursor_pos_for_mixed_state(
app_state: &AppState,
login_state: &LoginState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login || app_state.ui.show_register {
login_state.current_cursor_pos()
} else {
form_state.current_cursor_pos()
}
}
// This function handles state changes.
async fn handle_search_palette_event(
&mut self,
@@ -199,7 +284,6 @@ impl EventHandler {
_ => {}
}
// --- START CORRECTED LOGIC ---
if trigger_search {
search_state.is_loading = true;
search_state.results.clear();
@@ -214,7 +298,6 @@ impl EventHandler {
"--- 1. Spawning search task for query: '{}' ---",
query
);
// We now move the grpc_client into the task, just like with login.
tokio::spawn(async move {
info!("--- 2. Background task started. ---");
match grpc_client.search_table(table_name, query).await {
@@ -226,7 +309,6 @@ impl EventHandler {
let _ = sender.send(response.hits);
}
Err(e) => {
// THE FIX: Use the debug formatter `{:?}` to print the full error chain.
error!("--- 3b. gRPC call failed: {:?} ---", e);
let _ = sender.send(vec![]);
}
@@ -235,8 +317,6 @@ impl EventHandler {
}
}
// The borrow on `app_state.search_state` ends here.
// Now we can safely modify the Option itself.
if should_close {
app_state.search_state = None;
app_state.ui.show_search_palette = false;
@@ -264,7 +344,6 @@ impl EventHandler {
) -> Result<EventOutcome> {
if app_state.ui.show_search_palette {
if let Event::Key(key_event) = event {
// The call no longer passes grpc_client
return self
.handle_search_palette_event(
key_event,
@@ -581,7 +660,7 @@ impl EventHandler {
if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode_linewise")
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = get_current_field_for_state(
let current_field_index = Self::get_current_field_for_state(
app_state,
login_state,
register_state,
@@ -596,13 +675,13 @@ impl EventHandler {
else if config.get_read_only_action_for_key(key_code, modifiers) == Some("enter_highlight_mode")
&& ModeManager::can_enter_highlight_mode(current_mode)
{
let current_field_index = get_current_field_for_state(
let current_field_index = Self::get_current_field_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_current_cursor_pos_for_state(
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -627,13 +706,13 @@ impl EventHandler {
else if config.get_read_only_action_for_key(key_code, modifiers).as_deref() == Some("enter_edit_mode_after")
&& ModeManager::can_enter_edit_mode(current_mode)
{
let current_input = get_current_input_for_state(
let current_input = Self::get_current_input_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_cursor_pos_for_mixed_state(
let current_cursor_pos = Self::get_cursor_pos_for_mixed_state(
app_state,
login_state,
form_state
@@ -642,14 +721,14 @@ impl EventHandler {
// Move cursor forward if possible
if !current_input.is_empty() && current_cursor_pos < current_input.len() {
let new_cursor_pos = current_cursor_pos + 1;
set_current_cursor_pos_for_state(
Self::set_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
form_state,
new_cursor_pos
);
self.ideal_cursor_column = get_current_cursor_pos_for_state(
self.ideal_cursor_column = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -694,13 +773,12 @@ impl EventHandler {
}
}
// Try canvas action for form first (NEW: Canvas library integration)
// Try canvas action for form first
if app_state.ui.show_form {
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
key_event,
config,
form_state,
false, // not edit mode
false,
).await {
return Ok(EventOutcome::Ok(canvas_message));
}
@@ -753,7 +831,7 @@ impl EventHandler {
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
&mut self.key_sequence_tracker,
&mut self.grpc_client, // <-- FIX 2
&mut self.grpc_client,
&mut self.command_message,
&mut self.edit_mode_cooldown,
&mut self.ideal_cursor_column,
@@ -784,13 +862,12 @@ impl EventHandler {
}
}
// Try canvas action for form first (NEW: Canvas library integration)
// Try canvas action for form first
if app_state.ui.show_form {
if let Ok(Some(canvas_message)) = self.handle_form_canvas_action(
key_event,
config,
form_state,
true, // edit mode
true,
).await {
if !canvas_message.is_empty() {
self.command_message = canvas_message.clone();
@@ -823,7 +900,7 @@ impl EventHandler {
self.edit_mode_cooldown = true;
// Check for unsaved changes across all states
let has_changes = get_has_unsaved_changes_for_state(
let has_changes = Self::get_has_unsaved_changes_for_state(
app_state,
login_state,
register_state,
@@ -840,13 +917,13 @@ impl EventHandler {
terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?;
// Get current input and cursor position
let current_input = get_current_input_for_state(
let current_input = Self::get_current_input_for_state(
app_state,
login_state,
register_state,
form_state
);
let current_cursor_pos = get_current_cursor_pos_for_state(
let current_cursor_pos = Self::get_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -856,7 +933,7 @@ impl EventHandler {
// Adjust cursor if it's beyond the input length
if !current_input.is_empty() && current_cursor_pos >= current_input.len() {
let new_pos = current_input.len() - 1;
set_current_cursor_pos_for_state(
Self::set_current_cursor_pos_for_state(
app_state,
login_state,
register_state,
@@ -906,7 +983,7 @@ impl EventHandler {
form_state,
&mut self.command_input,
&mut self.command_message,
&mut self.grpc_client, // <-- FIX 5
&mut self.grpc_client,
command_handler,
terminal,
&mut current_position,
@@ -1024,80 +1101,49 @@ impl EventHandler {
async fn handle_form_canvas_action(
&mut self,
key_event: KeyEvent,
_config: &Config, // Not used anymore - canvas has its own config
form_state: &mut FormState,
is_edit_mode: bool,
) -> Result<Option<String>> {
// Load canvas config (canvas_config.toml or vim defaults)
let canvas_config = canvas::config::CanvasConfig::load();
// Handle suggestion actions first if suggestions are active
if form_state.autocomplete_active {
if let Some(action_str) = canvas_config.get_suggestion_action(key_event.code, key_event.modifiers) {
let canvas_action = CanvasAction::from_string(&action_str);
match ActionDispatcher::dispatch(canvas_action, form_state, &mut self.ideal_cursor_column).await {
Ok(result) => return Ok(Some(result.message().unwrap_or("").to_string())),
Err(_) => return Ok(Some("Suggestion action failed".to_string())),
}
}
// Fallback hardcoded suggestion handling
match key_event.code {
KeyCode::Up => {
if let Ok(result) = ActionDispatcher::dispatch(
CanvasAction::SuggestionUp,
// PRIORITY 1: Handle character insertion in edit mode FIRST
if is_edit_mode {
if let KeyCode::Char(c) = key_event.code {
// Only insert if it's not a special modifier combination
if key_event.modifiers.is_empty() || key_event.modifiers == KeyModifiers::SHIFT {
let canvas_action = CanvasAction::InsertChar(c);
match ActionDispatcher::dispatch(
canvas_action,
form_state,
&mut self.ideal_cursor_column,
).await {
return Ok(Some(result.message().unwrap_or("").to_string()));
Ok(result) => {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
Err(_) => {
return Ok(Some("Character insertion failed".to_string()));
}
}
}
KeyCode::Down => {
if let Ok(result) = ActionDispatcher::dispatch(
CanvasAction::SuggestionDown,
form_state,
&mut self.ideal_cursor_column,
).await {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
}
KeyCode::Enter => {
if let Ok(result) = ActionDispatcher::dispatch(
CanvasAction::SelectSuggestion,
form_state,
&mut self.ideal_cursor_column,
).await {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
}
KeyCode::Esc => {
if let Ok(result) = ActionDispatcher::dispatch(
CanvasAction::ExitSuggestions,
form_state,
&mut self.ideal_cursor_column,
).await {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
}
_ => {}
}
}
// FIXED: Use canvas config instead of client config
// 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 {
// Filter out mode transition actions - let legacy handlers deal with these
// Skip mode transition actions - let the main event handler deal with them
if Self::is_mode_transition_action(action_str) {
return Ok(None); // Let legacy handler handle mode transitions
return Ok(None);
}
let canvas_action = CanvasAction::from_string(&action_str);
// Execute the config-mapped action
let canvas_action = CanvasAction::from_string(action_str);
match ActionDispatcher::dispatch(
canvas_action,
form_state,
@@ -1112,59 +1158,10 @@ impl EventHandler {
}
}
// Fallback to automatic key handling for edit mode
if is_edit_mode {
if let Some(canvas_action) = CanvasAction::from_key(key_event.code) {
match ActionDispatcher::dispatch(
canvas_action,
form_state,
&mut self.ideal_cursor_column,
).await {
Ok(result) => {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
Err(_) => {
return Ok(Some("Auto action failed".to_string()));
}
}
}
} else {
// In read-only mode, only handle non-character keys
let canvas_action = match key_event.code {
// Only handle special keys that don't conflict with vim bindings
KeyCode::Left => Some(CanvasAction::MoveLeft),
KeyCode::Right => Some(CanvasAction::MoveRight),
KeyCode::Up => Some(CanvasAction::MoveUp),
KeyCode::Down => Some(CanvasAction::MoveDown),
KeyCode::Home => Some(CanvasAction::MoveLineStart),
KeyCode::End => Some(CanvasAction::MoveLineEnd),
KeyCode::Tab => Some(CanvasAction::NextField),
KeyCode::BackTab => Some(CanvasAction::PrevField),
KeyCode::Delete => Some(CanvasAction::DeleteForward),
KeyCode::Backspace => Some(CanvasAction::DeleteBackward),
_ => None,
};
if let Some(canvas_action) = canvas_action {
match ActionDispatcher::dispatch(
canvas_action,
form_state,
&mut self.ideal_cursor_column,
).await {
Ok(result) => {
return Ok(Some(result.message().unwrap_or("").to_string()));
}
Err(_) => {
return Ok(Some("Action failed".to_string()));
}
}
}
}
// No action found
Ok(None)
}
// ADDED: Helper function to identify mode transition actions
fn is_mode_transition_action(action: &str) -> bool {
matches!(action,
"exit" |
@@ -1181,11 +1178,11 @@ impl EventHandler {
"force_quit" |
"save_and_quit" |
"revert" |
"enter_decider" | // This is also handled specially by legacy system
"trigger_autocomplete" | // This is handled specially by legacy system
"suggestion_up" | // These are handled above in suggestion logic
"enter_decider" |
"trigger_autocomplete" |
"suggestion_up" |
"suggestion_down" |
"previous_entry" | // Navigation between records
"previous_entry" |
"next_entry" |
"toggle_sidebar" |
"toggle_buffer_list" |

View File

@@ -1,105 +0,0 @@
// src/modes/handlers/event_helper.rs
//! Helper functions to handle the differences between legacy and library CanvasState traits
use crate::state::app::state::AppState;
use crate::state::pages::{
form::FormState,
auth::{LoginState, RegisterState},
};
use crate::state::pages::canvas_state::CanvasState as LegacyCanvasState;
use canvas::canvas::CanvasState as LibraryCanvasState;
/// Get the current field index from the appropriate state based on which UI is active
pub fn get_current_field_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_field() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_field() // Uses LegacyCanvasState
} else {
form_state.current_field() // Uses LibraryCanvasState
}
}
/// Get the current cursor position from the appropriate state based on which UI is active
pub fn get_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}
/// Check if the appropriate state has unsaved changes based on which UI is active
pub fn get_has_unsaved_changes_for_state(
app_state: &AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &FormState,
) -> bool {
if app_state.ui.show_login {
login_state.has_unsaved_changes() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.has_unsaved_changes() // Uses LegacyCanvasState
} else {
form_state.has_unsaved_changes() // Uses LibraryCanvasState
}
}
/// Get the current input from the appropriate state based on which UI is active
pub fn get_current_input_for_state<'a>(
app_state: &AppState,
login_state: &'a LoginState,
register_state: &'a RegisterState,
form_state: &'a FormState,
) -> &'a str {
if app_state.ui.show_login {
login_state.get_current_input() // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.get_current_input() // Uses LegacyCanvasState
} else {
form_state.get_current_input() // Uses LibraryCanvasState
}
}
/// Set the cursor position for the appropriate state based on which UI is active
pub fn set_current_cursor_pos_for_state(
app_state: &AppState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
form_state: &mut FormState,
pos: usize,
) {
if app_state.ui.show_login {
login_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else if app_state.ui.show_register {
register_state.set_current_cursor_pos(pos); // Uses LegacyCanvasState
} else {
form_state.set_current_cursor_pos(pos); // Uses LibraryCanvasState
}
}
/// Get cursor position for mixed login/register vs form logic
pub fn get_cursor_pos_for_mixed_state(
app_state: &AppState,
login_state: &LoginState,
form_state: &FormState,
) -> usize {
if app_state.ui.show_login || app_state.ui.show_register {
login_state.current_cursor_pos() // Uses LegacyCanvasState
} else {
form_state.current_cursor_pos() // Uses LibraryCanvasState
}
}

View File

@@ -6,4 +6,3 @@ pub mod admin;
pub mod intro;
pub mod add_table;
pub mod add_logic;
pub mod canvas_state;

View File

@@ -1,6 +1,6 @@
// src/state/pages/add_logic.rs
use crate::config::binds::config::{EditorConfig, EditorKeybindingMode};
use crate::state::pages::canvas_state::CanvasState;
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
use crate::components::common::text_editor::{TextEditor, VimState};
use std::cell::RefCell;
use std::rc::Rc;
@@ -50,10 +50,11 @@ pub struct AddLogicState {
pub script_editor_trigger_position: Option<(usize, usize)>, // (line, column)
pub all_table_names: Vec<String>,
pub script_editor_filter_text: String,
// New fields for same-profile table names and column autocomplete
pub same_profile_table_names: Vec<String>, // Tables from same profile only
pub script_editor_awaiting_column_autocomplete: Option<String>, // Table name waiting for column fetch
pub app_mode: AppMode,
}
impl AddLogicState {
@@ -88,9 +89,10 @@ impl AddLogicState {
script_editor_trigger_position: None,
all_table_names: Vec::new(),
script_editor_filter_text: String::new(),
same_profile_table_names: Vec::new(),
script_editor_awaiting_column_autocomplete: None,
app_mode: AppMode::Edit,
}
}
@@ -181,7 +183,7 @@ impl AddLogicState {
}
self.same_profile_table_names.contains(&suggestion.to_string())
}
/// Sets table columns for autocomplete suggestions
pub fn set_table_columns(&mut self, columns: Vec<String>) {
self.table_columns_for_suggestions = columns.clone();
@@ -225,67 +227,68 @@ impl AddLogicState {
self.script_editor_trigger_position = None;
self.script_editor_filter_text.clear();
}
/// Helper method to validate and save logic
pub fn save_logic(&mut self) -> Option<String> {
if self.logic_name_input.trim().is_empty() {
return Some("Logic name is required".to_string());
}
if self.target_column_input.trim().is_empty() {
return Some("Target column is required".to_string());
}
let script_content = {
let editor_borrow = self.script_content_editor.borrow();
editor_borrow.lines().join("\n")
};
if script_content.trim().is_empty() {
return Some("Script content is required".to_string());
}
// Here you would typically save to database/storage
// For now, just clear the form and mark as saved
self.has_unsaved_changes = false;
Some(format!("Logic '{}' saved successfully", self.logic_name_input.trim()))
}
/// Helper method to clear the form
pub fn clear_form(&mut self) -> Option<String> {
let profile = self.profile_name.clone();
let table_id = self.selected_table_id;
let table_name = self.selected_table_name.clone();
let editor_config = EditorConfig::default(); // You might want to preserve the actual config
*self = Self::new(&editor_config);
self.profile_name = profile;
self.selected_table_id = table_id;
self.selected_table_name = table_name;
Some("Form cleared".to_string())
}
}
impl Default for AddLogicState {
fn default() -> Self {
Self::new(&EditorConfig::default())
let mut state = Self::new(&EditorConfig::default());
state.app_mode = AppMode::Edit;
state
}
}
// Implement external library's CanvasState for AddLogicState
impl CanvasState for AddLogicState {
fn current_field(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => 0,
AddLogicFocus::InputTargetColumn => 1,
AddLogicFocus::InputDescription => 2,
// If focus is elsewhere, return the last canvas field used
_ => self.last_canvas_field,
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "",
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input,
}
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn set_current_field(&mut self, index: usize) {
let new_focus = match index {
0 => AddLogicFocus::InputLogicName,
@@ -303,6 +306,15 @@ impl CanvasState for AddLogicState {
}
}
fn current_cursor_pos(&self) -> usize {
match self.current_focus {
AddLogicFocus::InputLogicName => self.logic_name_cursor_pos,
AddLogicFocus::InputTargetColumn => self.target_column_cursor_pos,
AddLogicFocus::InputDescription => self.description_cursor_pos,
_ => 0,
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
match self.current_focus {
AddLogicFocus::InputLogicName => {
@@ -318,29 +330,121 @@ impl CanvasState for AddLogicState {
}
}
fn get_current_input(&self) -> &str {
match self.current_focus {
AddLogicFocus::InputLogicName => &self.logic_name_input,
AddLogicFocus::InputTargetColumn => &self.target_column_input,
AddLogicFocus::InputDescription => &self.description_input,
_ => "", // Should not happen if called correctly
}
}
fn get_current_input_mut(&mut self) -> &mut String {
match self.current_focus {
AddLogicFocus::InputLogicName => &mut self.logic_name_input,
AddLogicFocus::InputTargetColumn => &mut self.target_column_input,
AddLogicFocus::InputDescription => &mut self.description_input,
_ => &mut self.logic_name_input, // Fallback
}
}
fn inputs(&self) -> Vec<&String> {
vec![
&self.logic_name_input,
&self.target_column_input,
&self.description_input,
]
}
fn fields(&self) -> Vec<&str> {
vec!["Logic Name", "Target Column", "Description"]
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
fn get_suggestions(&self) -> Option<&[String]> {
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
Some(&self.target_column_suggestions)
} else {
None
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
match action {
// Handle saving logic script
CanvasAction::Custom(action_str) if action_str == "save_logic" => {
self.save_logic()
}
// Handle clearing the form
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
self.clear_form()
}
// Handle target column autocomplete activation
CanvasAction::Custom(action_str) if action_str == "activate_autocomplete" => {
if self.current_field() == 1 { // Target Column field
self.in_target_column_suggestion_mode = true;
self.update_target_column_suggestions();
Some("Autocomplete activated".to_string())
} else {
None
}
}
// Handle target column suggestion selection
CanvasAction::Custom(action_str) if action_str == "select_suggestion" => {
if self.current_field() == 1 && self.in_target_column_suggestion_mode {
if let Some(selected_idx) = self.selected_target_column_suggestion_index {
if let Some(suggestion) = self.target_column_suggestions.get(selected_idx) {
self.target_column_input = suggestion.clone();
self.target_column_cursor_pos = suggestion.len();
self.in_target_column_suggestion_mode = false;
self.show_target_column_suggestions = false;
self.has_unsaved_changes = true;
return Some(format!("Selected: {}", suggestion));
}
}
}
None
}
// Custom validation when moving between fields
CanvasAction::NextField => {
match self.current_field() {
0 => { // Logic Name field
if self.logic_name_input.trim().is_empty() {
Some("Logic name cannot be empty".to_string())
} else {
None // Let canvas library handle the normal field movement
}
}
1 => { // Target Column field
// Update suggestions when entering target column field
self.update_target_column_suggestions();
None
}
_ => None,
}
}
// Handle character insertion with validation
CanvasAction::InsertChar(c) => {
if self.current_field() == 1 { // Target Column field
// Update suggestions after character insertion
// Note: Canvas library will handle the actual insertion
// This is just for triggering suggestion updates
None // Let canvas handle insertion, then we'll update suggestions
} else {
None // Let canvas handle normally
}
}
// Let canvas library handle everything else
_ => None,
}
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
if self.current_field() == 1
&& self.in_target_column_suggestion_mode
&& self.show_target_column_suggestions
{
self.selected_target_column_suggestion_index
} else {
None
}
fn current_mode(&self) -> AppMode {
self.app_mode
}
}

View File

@@ -1,5 +1,5 @@
// src/state/pages/add_table.rs
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
use ratatui::widgets::TableState;
use serde::{Deserialize, Serialize};
@@ -63,6 +63,7 @@ pub struct AddTableState {
pub column_name_cursor_pos: usize,
pub column_type_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub app_mode: AppMode,
}
impl Default for AddTableState {
@@ -85,6 +86,7 @@ impl Default for AddTableState {
column_name_cursor_pos: 0,
column_type_cursor_pos: 0,
has_unsaved_changes: false,
app_mode: AppMode::Edit,
}
}
}
@@ -297,4 +299,8 @@ impl CanvasState for AddTableState {
_ => None,
}
}
fn current_mode(&self) -> AppMode {
self.app_mode
}
}

View File

@@ -1,5 +1,5 @@
// src/state/pages/auth.rs
use canvas::canvas::{CanvasState, ActionContext, CanvasAction};
use canvas::canvas::{CanvasState, ActionContext, CanvasAction, AppMode};
use canvas::autocomplete::{AutocompleteCanvasState, AutocompleteState, SuggestionItem};
use lazy_static::lazy_static;
@@ -22,7 +22,6 @@ pub struct AuthState {
}
/// Represents the state of the Login form UI
#[derive(Default)]
pub struct LoginState {
pub username: String,
pub password: String,
@@ -31,10 +30,26 @@ pub struct LoginState {
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
pub login_request_pending: bool,
pub app_mode: AppMode,
}
impl Default for LoginState {
fn default() -> Self {
Self {
username: String::new(),
password: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
login_request_pending: false,
app_mode: AppMode::Edit,
}
}
}
/// Represents the state of the Registration form UI
#[derive(Default, Clone)]
#[derive(Clone)]
pub struct RegisterState {
pub username: String,
pub email: String,
@@ -45,8 +60,26 @@ pub struct RegisterState {
pub current_field: usize,
pub current_cursor_pos: usize,
pub has_unsaved_changes: bool,
// NEW: Replace old autocomplete with external library's system
pub autocomplete: AutocompleteState<String>,
pub app_mode: AppMode,
}
impl Default for RegisterState {
fn default() -> Self {
Self {
username: String::new(),
email: String::new(),
password: String::new(),
password_confirmation: String::new(),
role: String::new(),
error_message: None,
current_field: 0,
current_cursor_pos: 0,
has_unsaved_changes: false,
autocomplete: AutocompleteState::new(),
app_mode: AppMode::Edit,
}
}
}
impl AuthState {
@@ -57,7 +90,10 @@ impl AuthState {
impl LoginState {
pub fn new() -> Self {
Self::default()
Self {
app_mode: AppMode::Edit,
..Default::default()
}
}
}
@@ -65,6 +101,7 @@ impl RegisterState {
pub fn new() -> Self {
let mut state = Self {
autocomplete: AutocompleteState::new(),
app_mode: AppMode::Edit,
..Default::default()
};
@@ -146,6 +183,10 @@ impl CanvasState for LoginState {
_ => None,
}
}
fn current_mode(&self) -> AppMode {
self.app_mode
}
}
// Implement external library's CanvasState for RegisterState
@@ -237,6 +278,10 @@ impl CanvasState for RegisterState {
_ => None,
}
}
fn current_mode(&self) -> AppMode {
self.app_mode
}
}
// Add autocomplete support for RegisterState

View File

@@ -1,32 +0,0 @@
// src/state/pages/canvas_state.rs
use common::proto::komp_ac::search::search_response::Hit;
pub trait CanvasState {
// --- Existing methods (unchanged) ---
fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize;
fn has_unsaved_changes(&self) -> bool;
fn inputs(&self) -> Vec<&String>;
fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String;
fn fields(&self) -> Vec<&str>;
fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize);
fn set_has_unsaved_changes(&mut self, changed: bool);
fn get_suggestions(&self) -> Option<&[String]>;
fn get_selected_suggestion_index(&self) -> Option<usize>;
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
None
}
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, _index: usize) -> bool {
false
}
}

View File

@@ -1,7 +1,7 @@
// src/state/pages/form.rs
use crate::config::colors::themes::Theme;
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState};
use canvas::canvas::{CanvasState, CanvasAction, ActionContext, HighlightState, AppMode};
use common::proto::komp_ac::search::search_response::Hit;
use ratatui::layout::Rect;
use ratatui::Frame;
@@ -41,6 +41,7 @@ pub struct FormState {
pub selected_suggestion_index: Option<usize>,
pub autocomplete_loading: bool,
pub link_display_map: HashMap<usize, String>,
pub app_mode: AppMode,
}
impl FormState {
@@ -74,6 +75,7 @@ impl FormState {
selected_suggestion_index: None,
autocomplete_loading: false,
link_display_map: HashMap::new(),
app_mode: AppMode::Edit,
}
}
@@ -231,6 +233,15 @@ impl FormState {
self.selected_suggestion_index = if self.autocomplete_active { Some(0) } else { None };
self.autocomplete_loading = false;
}
// NEW: Add these methods to change modes
pub fn set_edit_mode(&mut self) {
self.app_mode = AppMode::Edit;
}
pub fn set_readonly_mode(&mut self) {
self.app_mode = AppMode::ReadOnly;
}
}
impl CanvasState for FormState {
@@ -320,4 +331,8 @@ impl CanvasState for FormState {
fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
fn current_mode(&self) -> AppMode {
self.app_mode
}
}

View File

@@ -16,11 +16,10 @@ use crate::components::{
};
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState;
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
use canvas::canvas::CanvasState;
use crate::state::app::buffer::BufferState;
use crate::state::app::highlight::HighlightState as LocalHighlightState; // CHANGED: Alias local version
use canvas::canvas::HighlightState as CanvasHighlightState; // CHANGED: Import canvas version with alias
use crate::state::app::highlight::HighlightState as LocalHighlightState;
use canvas::canvas::HighlightState as CanvasHighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::auth::AuthState;

View File

@@ -8,9 +8,8 @@ use crate::config::storage::storage::load_auth_data;
use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::state::pages::canvas_state::CanvasState as LocalCanvasState; // Keep local one with alias
use canvas::canvas::CanvasState; // Import external library's CanvasState trait
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
use canvas::canvas::CanvasState; // Only external library import
use crate::state::pages::form::{FormState, FieldDefinition};
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;

View File

@@ -2,8 +2,7 @@
use rstest::{fixture, rstest};
use std::collections::HashMap;
use client::state::pages::form::{FormState, FieldDefinition};
use canvas::state::CanvasState
use client::state::pages::canvas_state::CanvasState;
use canvas::canvas::CanvasState;
#[fixture]
fn test_form_state() -> FormState {

View File

@@ -2,7 +2,7 @@
pub use rstest::{fixture, rstest};
pub use client::services::grpc_client::GrpcClient;
pub use client::state::pages::form::FormState;
pub use client::state::pages::canvas_state::CanvasState;
pub use canvas::canvas::CanvasState;
pub use prost_types::Value;
pub use prost_types::value::Kind;
pub use std::collections::HashMap;