Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0338276f | ||
|
|
fe193f4f91 | ||
|
|
0011ba0c04 | ||
|
|
3c2eef9596 | ||
|
|
dac788351f |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -482,6 +482,8 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ toml = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
unicode-width.workspace = true
|
unicode-width.workspace = true
|
||||||
|
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = "0.3.19"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4.4"
|
tokio-test = "0.4.4"
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ highlight_current_field = true
|
|||||||
move_left = ["h"]
|
move_left = ["h"]
|
||||||
move_right = ["l"]
|
move_right = ["l"]
|
||||||
move_up = ["k"]
|
move_up = ["k"]
|
||||||
move_down = ["p"]
|
move_down = ["j"]
|
||||||
move_word_next = ["w"]
|
move_word_next = ["w"]
|
||||||
move_word_end = ["e"]
|
move_word_end = ["e"]
|
||||||
move_word_prev = ["b"]
|
move_word_prev = ["b"]
|
||||||
@@ -42,6 +42,8 @@ move_word_next = ["Ctrl+Right"]
|
|||||||
move_word_prev = ["Ctrl+Left"]
|
move_word_prev = ["Ctrl+Left"]
|
||||||
next_field = ["Tab"]
|
next_field = ["Tab"]
|
||||||
prev_field = ["Shift+Tab"]
|
prev_field = ["Shift+Tab"]
|
||||||
|
trigger_autocomplete = ["Ctrl+p"]
|
||||||
|
|
||||||
|
|
||||||
# Suggestion/autocomplete keybindings
|
# Suggestion/autocomplete keybindings
|
||||||
[keybindings.suggestions]
|
[keybindings.suggestions]
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// src/autocomplete/actions.rs
|
// canvas/src/autocomplete/actions.rs
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
use crate::canvas::state::{CanvasState, ActionContext};
|
||||||
use crate::autocomplete::state::AutocompleteCanvasState;
|
use crate::autocomplete::state::AutocompleteCanvasState;
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
use crate::canvas::actions::edit::handle_generic_canvas_action; // Import the core function
|
use crate::canvas::actions::edit::handle_generic_canvas_action;
|
||||||
|
use crate::config::CanvasConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Version for states that implement rich autocomplete
|
/// Version for states that implement rich autocomplete
|
||||||
@@ -11,6 +12,7 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
|
|||||||
action: CanvasAction,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
|
config: Option<&CanvasConfig>,
|
||||||
) -> Result<ActionResult> {
|
) -> Result<ActionResult> {
|
||||||
// 1. Try feature-specific handler first
|
// 1. Try feature-specific handler first
|
||||||
let context = ActionContext {
|
let context = ActionContext {
|
||||||
@@ -20,31 +22,77 @@ pub async fn execute_canvas_action_with_autocomplete<S: CanvasState + Autocomple
|
|||||||
current_field: state.current_field(),
|
current_field: state.current_field(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(result) = state.handle_feature_action(&action, &context) {
|
if let Some(result) = handle_rich_autocomplete_action(action.clone(), state, &context) {
|
||||||
return Ok(ActionResult::HandledByFeature(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Handle rich autocomplete actions
|
|
||||||
if let Some(result) = handle_rich_autocomplete_action(&action, state)? {
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Handle generic canvas actions
|
// 2. Handle generic actions and add auto-trigger logic
|
||||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
let result = handle_generic_canvas_action(action.clone(), state, ideal_cursor_column, config).await?;
|
||||||
|
|
||||||
|
// 3. AUTO-TRIGGER LOGIC: Check if we should activate/deactivate autocomplete
|
||||||
|
if let Some(cfg) = config {
|
||||||
|
println!("{:?}, {}", action, cfg.should_auto_trigger_autocomplete());
|
||||||
|
if cfg.should_auto_trigger_autocomplete() {
|
||||||
|
println!("AUTO-TRIGGER");
|
||||||
|
match action {
|
||||||
|
CanvasAction::InsertChar(_) => {
|
||||||
|
println!("AUTO-T on Ins");
|
||||||
|
let current_field = state.current_field();
|
||||||
|
let current_input = state.get_current_input();
|
||||||
|
|
||||||
|
if state.supports_autocomplete(current_field)
|
||||||
|
&& !state.is_autocomplete_active()
|
||||||
|
&& current_input.len() >= 1
|
||||||
|
{
|
||||||
|
println!("ACT AUTOC");
|
||||||
|
state.activate_autocomplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
|
println!("AUTO-T on nav");
|
||||||
|
let current_field = state.current_field();
|
||||||
|
|
||||||
|
if state.supports_autocomplete(current_field) && !state.is_autocomplete_active() {
|
||||||
|
state.activate_autocomplete();
|
||||||
|
} else if !state.supports_autocomplete(current_field) && state.is_autocomplete_active() {
|
||||||
|
state.deactivate_autocomplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {} // No auto-trigger for other actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
/// Handle rich autocomplete actions for AutocompleteCanvasState
|
||||||
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
||||||
action: &CanvasAction,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
) -> Result<Option<ActionResult>> {
|
_context: &ActionContext,
|
||||||
|
) -> Option<ActionResult> {
|
||||||
match action {
|
match action {
|
||||||
CanvasAction::TriggerAutocomplete => {
|
CanvasAction::TriggerAutocomplete => {
|
||||||
if state.supports_autocomplete(state.current_field()) {
|
let current_field = state.current_field();
|
||||||
|
if state.supports_autocomplete(current_field) {
|
||||||
state.activate_autocomplete();
|
state.activate_autocomplete();
|
||||||
Ok(Some(ActionResult::success_with_message("Autocomplete activated - fetching suggestions...")))
|
Some(ActionResult::success_with_message("Autocomplete activated"))
|
||||||
} else {
|
} else {
|
||||||
Ok(Some(ActionResult::error("Autocomplete not supported for this field")))
|
Some(ActionResult::success_with_message("Autocomplete not supported for this field"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::SuggestionUp => {
|
||||||
|
if state.is_autocomplete_ready() {
|
||||||
|
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||||
|
autocomplete_state.select_previous();
|
||||||
|
}
|
||||||
|
Some(ActionResult::success())
|
||||||
|
} else {
|
||||||
|
Some(ActionResult::success_with_message("No suggestions available"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,42 +100,34 @@ fn handle_rich_autocomplete_action<S: CanvasState + AutocompleteCanvasState>(
|
|||||||
if state.is_autocomplete_ready() {
|
if state.is_autocomplete_ready() {
|
||||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||||
autocomplete_state.select_next();
|
autocomplete_state.select_next();
|
||||||
return Ok(Some(ActionResult::success()));
|
|
||||||
}
|
}
|
||||||
|
Some(ActionResult::success())
|
||||||
|
} else {
|
||||||
|
Some(ActionResult::success_with_message("No suggestions available"))
|
||||||
}
|
}
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::SuggestionUp => {
|
|
||||||
if state.is_autocomplete_ready() {
|
|
||||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
|
||||||
autocomplete_state.select_previous();
|
|
||||||
return Ok(Some(ActionResult::success()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::SelectSuggestion => {
|
CanvasAction::SelectSuggestion => {
|
||||||
if state.is_autocomplete_ready() {
|
if state.is_autocomplete_ready() {
|
||||||
if let Some(message) = state.apply_autocomplete_selection() {
|
if let Some(msg) = state.apply_autocomplete_selection() {
|
||||||
return Ok(Some(ActionResult::success_with_message(message)));
|
Some(ActionResult::success_with_message(&msg))
|
||||||
} else {
|
} else {
|
||||||
return Ok(Some(ActionResult::error("No suggestion selected")));
|
Some(ActionResult::success_with_message("No suggestion selected"))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Some(ActionResult::success_with_message("No suggestions available"))
|
||||||
}
|
}
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::ExitSuggestions => {
|
CanvasAction::ExitSuggestions => {
|
||||||
if state.is_autocomplete_active() {
|
if state.is_autocomplete_active() {
|
||||||
state.deactivate_autocomplete();
|
state.deactivate_autocomplete();
|
||||||
Ok(Some(ActionResult::success_with_message("Autocomplete cancelled")))
|
Some(ActionResult::success_with_message("Exited autocomplete"))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Some(ActionResult::success())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => Ok(None),
|
_ => None, // Not a rich autocomplete action
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ fn render_loading_indicator<T: CanvasTheme>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let loading_block = Block::default()
|
let loading_block = Block::default()
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(theme.accent()))
|
|
||||||
.style(Style::default().bg(theme.bg()));
|
.style(Style::default().bg(theme.bg()));
|
||||||
|
|
||||||
let loading_paragraph = Paragraph::new(loading_text)
|
let loading_paragraph = Paragraph::new(loading_text)
|
||||||
@@ -92,8 +90,6 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
|
|||||||
|
|
||||||
// Background
|
// Background
|
||||||
let dropdown_block = Block::default()
|
let dropdown_block = Block::default()
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(theme.accent()))
|
|
||||||
.style(Style::default().bg(theme.bg()));
|
.style(Style::default().bg(theme.bg()));
|
||||||
|
|
||||||
// List items
|
// List items
|
||||||
@@ -111,7 +107,7 @@ fn render_suggestions_dropdown<T: CanvasTheme>(
|
|||||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate dropdown size based on suggestions
|
/// Calculate dropdown size based on suggestions - updated to match client dimensions
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||||
let max_width = display_texts
|
let max_width = display_texts
|
||||||
@@ -120,9 +116,9 @@ fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
|||||||
.max()
|
.max()
|
||||||
.unwrap_or(0) as u16;
|
.unwrap_or(0) as u16;
|
||||||
|
|
||||||
let horizontal_padding = 4; // borders + padding
|
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||||
let width = (max_width + horizontal_padding).max(12);
|
let width = (max_width + horizontal_padding).max(10); // Changed from 12 to 10 to match client
|
||||||
let height = (display_texts.len() as u16).min(8) + 2; // max 8 visible items + borders
|
let height = (display_texts.len() as u16).min(5); // Removed +2 since no borders
|
||||||
|
|
||||||
DropdownDimensions { width, height }
|
DropdownDimensions { width, height }
|
||||||
}
|
}
|
||||||
@@ -155,7 +151,7 @@ fn calculate_dropdown_position(
|
|||||||
dropdown_area
|
dropdown_area
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create styled list items
|
/// Create styled list items - updated to match client spacing
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||||
display_texts: &'a [&'a str],
|
display_texts: &'a [&'a str],
|
||||||
@@ -163,8 +159,8 @@ fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
|||||||
dropdown_width: u16,
|
dropdown_width: u16,
|
||||||
theme: &T,
|
theme: &T,
|
||||||
) -> Vec<ListItem<'a>> {
|
) -> Vec<ListItem<'a>> {
|
||||||
let horizontal_padding = 4;
|
let horizontal_padding = 2; // Changed from 4 to 2 to match client
|
||||||
let available_width = dropdown_width.saturating_sub(horizontal_padding);
|
let available_width = dropdown_width; // No border padding needed
|
||||||
|
|
||||||
display_texts
|
display_texts
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// canvas/src/actions/edit.rs
|
// canvas/src/canvas/actions/edit.rs
|
||||||
|
|
||||||
use crate::canvas::state::{CanvasState, ActionContext};
|
use crate::canvas::state::{CanvasState, ActionContext};
|
||||||
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
use crate::canvas::actions::types::{CanvasAction, ActionResult};
|
||||||
|
use crate::config::CanvasConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Execute a typed canvas action on any CanvasState implementation
|
/// Execute a typed canvas action on any CanvasState implementation
|
||||||
@@ -9,6 +10,7 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
|||||||
action: CanvasAction,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
|
config: Option<&CanvasConfig>,
|
||||||
) -> Result<ActionResult> {
|
) -> Result<ActionResult> {
|
||||||
let context = ActionContext {
|
let context = ActionContext {
|
||||||
key_code: None,
|
key_code: None,
|
||||||
@@ -21,7 +23,7 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
|||||||
return Ok(ActionResult::HandledByFeature(result));
|
return Ok(ActionResult::HandledByFeature(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
handle_generic_canvas_action(action, state, ideal_cursor_column, config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle core canvas actions with full type safety
|
/// Handle core canvas actions with full type safety
|
||||||
@@ -29,126 +31,84 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
action: CanvasAction,
|
action: CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
|
config: Option<&CanvasConfig>,
|
||||||
) -> Result<ActionResult> {
|
) -> Result<ActionResult> {
|
||||||
match action {
|
match action {
|
||||||
CanvasAction::InsertChar(c) => {
|
CanvasAction::InsertChar(c) => {
|
||||||
let cursor_pos = state.current_cursor_pos();
|
let cursor_pos = state.current_cursor_pos();
|
||||||
let field_value = state.get_current_input_mut();
|
let input = state.get_current_input_mut();
|
||||||
let mut chars: Vec<char> = field_value.chars().collect();
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
if cursor_pos <= chars.len() {
|
CanvasAction::NextField | CanvasAction::PrevField => {
|
||||||
chars.insert(cursor_pos, c);
|
let old_field = state.current_field();
|
||||||
*field_value = chars.into_iter().collect();
|
let total_fields = state.fields().len();
|
||||||
state.set_current_cursor_pos(cursor_pos + 1);
|
|
||||||
state.set_has_unsaved_changes(true);
|
// Perform field navigation
|
||||||
*ideal_cursor_column = state.current_cursor_pos();
|
let new_field = match action {
|
||||||
Ok(ActionResult::success())
|
CanvasAction::NextField => {
|
||||||
} else {
|
if config.map_or(true, |c| c.behavior.wrap_around_fields) {
|
||||||
Ok(ActionResult::error("Invalid cursor position for character insertion"))
|
(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 => {
|
CanvasAction::DeleteBackward => {
|
||||||
if state.current_cursor_pos() > 0 {
|
let cursor_pos = state.current_cursor_pos();
|
||||||
let cursor_pos = state.current_cursor_pos();
|
if cursor_pos > 0 {
|
||||||
let field_value = state.get_current_input_mut();
|
let input = state.get_current_input_mut();
|
||||||
let mut chars: Vec<char> = field_value.chars().collect();
|
input.remove(cursor_pos - 1);
|
||||||
|
state.set_current_cursor_pos(cursor_pos - 1);
|
||||||
if cursor_pos <= chars.len() {
|
state.set_has_unsaved_changes(true);
|
||||||
chars.remove(cursor_pos - 1);
|
*ideal_cursor_column = 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(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::DeleteForward => {
|
CanvasAction::DeleteForward => {
|
||||||
let cursor_pos = state.current_cursor_pos();
|
let cursor_pos = state.current_cursor_pos();
|
||||||
let field_value = state.get_current_input_mut();
|
let input = state.get_current_input_mut();
|
||||||
let mut chars: Vec<char> = field_value.chars().collect();
|
if cursor_pos < input.len() {
|
||||||
|
input.remove(cursor_pos);
|
||||||
if cursor_pos < chars.len() {
|
|
||||||
chars.remove(cursor_pos);
|
|
||||||
*field_value = chars.into_iter().collect();
|
|
||||||
state.set_has_unsaved_changes(true);
|
state.set_has_unsaved_changes(true);
|
||||||
*ideal_cursor_column = cursor_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::NextField => {
|
|
||||||
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(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::PrevField => {
|
|
||||||
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(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveLeft => {
|
CanvasAction::MoveLeft => {
|
||||||
let new_pos = state.current_cursor_pos().saturating_sub(1);
|
let cursor_pos = state.current_cursor_pos();
|
||||||
state.set_current_cursor_pos(new_pos);
|
if cursor_pos > 0 {
|
||||||
*ideal_cursor_column = new_pos;
|
state.set_current_cursor_pos(cursor_pos - 1);
|
||||||
|
*ideal_cursor_column = cursor_pos - 1;
|
||||||
|
}
|
||||||
Ok(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveRight => {
|
CanvasAction::MoveRight => {
|
||||||
|
let cursor_pos = state.current_cursor_pos();
|
||||||
let current_input = state.get_current_input();
|
let current_input = state.get_current_input();
|
||||||
let current_pos = state.current_cursor_pos();
|
if cursor_pos < current_input.len() {
|
||||||
if current_pos < current_input.len() {
|
state.set_current_cursor_pos(cursor_pos + 1);
|
||||||
let new_pos = current_pos + 1;
|
*ideal_cursor_column = cursor_pos + 1;
|
||||||
state.set_current_cursor_pos(new_pos);
|
|
||||||
*ideal_cursor_column = new_pos;
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveUp => {
|
|
||||||
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(ActionResult::success())
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::MoveDown => {
|
|
||||||
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(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
@@ -160,43 +120,55 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveLineEnd => {
|
CanvasAction::MoveLineEnd => {
|
||||||
let current_input = state.get_current_input();
|
let end_pos = state.get_current_input().len();
|
||||||
let new_pos = current_input.len();
|
state.set_current_cursor_pos(end_pos);
|
||||||
state.set_current_cursor_pos(new_pos);
|
*ideal_cursor_column = end_pos;
|
||||||
*ideal_cursor_column = new_pos;
|
Ok(ActionResult::success())
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::MoveUp => {
|
||||||
|
// For single-line fields, move to previous field
|
||||||
|
let current_field = state.current_field();
|
||||||
|
if current_field > 0 {
|
||||||
|
state.set_current_field(current_field - 1);
|
||||||
|
*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())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveFirstLine => {
|
CanvasAction::MoveFirstLine => {
|
||||||
let num_fields = state.fields().len();
|
state.set_current_field(0);
|
||||||
if num_fields > 0 {
|
state.set_current_cursor_pos(0);
|
||||||
state.set_current_field(0);
|
*ideal_cursor_column = 0;
|
||||||
let current_input = state.get_current_input();
|
Ok(ActionResult::success())
|
||||||
let max_pos = current_input.len();
|
|
||||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success_with_message("Moved to first field"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveLastLine => {
|
CanvasAction::MoveLastLine => {
|
||||||
let num_fields = state.fields().len();
|
let last_field = state.fields().len() - 1;
|
||||||
if num_fields > 0 {
|
state.set_current_field(last_field);
|
||||||
let new_field = num_fields - 1;
|
let end_pos = state.get_current_input().len();
|
||||||
state.set_current_field(new_field);
|
state.set_current_cursor_pos(end_pos);
|
||||||
let current_input = state.get_current_input();
|
*ideal_cursor_column = end_pos;
|
||||||
let max_pos = current_input.len();
|
Ok(ActionResult::success())
|
||||||
state.set_current_cursor_pos((*ideal_cursor_column).min(max_pos));
|
|
||||||
}
|
|
||||||
Ok(ActionResult::success_with_message("Moved to last field"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::MoveWordNext => {
|
CanvasAction::MoveWordNext => {
|
||||||
let current_input = state.get_current_input();
|
let current_input = state.get_current_input();
|
||||||
if !current_input.is_empty() {
|
if !current_input.is_empty() {
|
||||||
let new_pos = find_next_word_start(current_input, state.current_cursor_pos());
|
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(new_pos);
|
||||||
state.set_current_cursor_pos(final_pos);
|
*ideal_cursor_column = new_pos;
|
||||||
*ideal_cursor_column = final_pos;
|
|
||||||
}
|
}
|
||||||
Ok(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
@@ -204,19 +176,9 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
CanvasAction::MoveWordEnd => {
|
CanvasAction::MoveWordEnd => {
|
||||||
let current_input = state.get_current_input();
|
let current_input = state.get_current_input();
|
||||||
if !current_input.is_empty() {
|
if !current_input.is_empty() {
|
||||||
let current_pos = state.current_cursor_pos();
|
let new_pos = find_word_end(current_input, state.current_cursor_pos());
|
||||||
let new_pos = find_word_end(current_input, current_pos);
|
state.set_current_cursor_pos(new_pos);
|
||||||
|
*ideal_cursor_column = new_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(ActionResult::success())
|
Ok(ActionResult::success())
|
||||||
}
|
}
|
||||||
@@ -231,148 +193,61 @@ pub async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
Ok(ActionResult::success())
|
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_with_message("Moved to previous word end"))
|
|
||||||
}
|
|
||||||
|
|
||||||
CanvasAction::Custom(action_str) => {
|
CanvasAction::Custom(action_str) => {
|
||||||
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
Ok(ActionResult::success_with_message(&format!("Custom action: {}", action_str)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete actions are handled by the autocomplete module
|
_ => Ok(ActionResult::success_with_message("Action not implemented")),
|
||||||
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
|
||||||
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
|
|
||||||
Ok(ActionResult::error("Autocomplete actions should be handled by autocomplete module"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word movement helper functions
|
// Helper functions for word navigation
|
||||||
#[derive(PartialEq)]
|
fn find_next_word_start(text: &str, cursor_pos: usize) -> usize {
|
||||||
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 chars: Vec<char> = text.chars().collect();
|
||||||
let len = chars.len();
|
let mut pos = cursor_pos;
|
||||||
if len == 0 || current_pos >= len {
|
|
||||||
return len;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = current_pos;
|
// Skip current word
|
||||||
let initial_type = get_char_type(chars[pos]);
|
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||||
|
|
||||||
while pos < len && get_char_type(chars[pos]) == initial_type {
|
|
||||||
pos += 1;
|
pos += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
while pos < len && get_char_type(chars[pos]) == CharType::Whitespace {
|
// Skip whitespace
|
||||||
|
while pos < chars.len() && chars[pos].is_whitespace() {
|
||||||
pos += 1;
|
pos += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
pos
|
pos
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_word_end(text: &str, current_pos: usize) -> usize {
|
fn find_word_end(text: &str, cursor_pos: usize) -> usize {
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let len = chars.len();
|
let mut pos = cursor_pos;
|
||||||
if len == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = current_pos.min(len - 1);
|
// Move to end of current word
|
||||||
|
while pos < chars.len() && chars[pos].is_alphanumeric() {
|
||||||
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 += 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
|
pos
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
|
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 chars: Vec<char> = text.chars().collect();
|
||||||
let len = chars.len();
|
let mut pos = cursor_pos.saturating_sub(1);
|
||||||
if len == 0 || current_pos == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = current_pos.saturating_sub(1);
|
// Skip whitespace
|
||||||
|
while pos > 0 && chars[pos].is_whitespace() {
|
||||||
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
|
||||||
pos -= 1;
|
pos -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos == 0 && get_char_type(chars[pos]) == CharType::Whitespace {
|
// Skip to start of word
|
||||||
return 0;
|
while pos > 0 && chars[pos - 1].is_alphanumeric() {
|
||||||
}
|
|
||||||
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 -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
|
pos
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if pos > 0 {
|
|
||||||
pos - 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ pub mod edit;
|
|||||||
|
|
||||||
// Re-export the main types for convenience
|
// Re-export the main types for convenience
|
||||||
pub use types::{CanvasAction, ActionResult};
|
pub use types::{CanvasAction, ActionResult};
|
||||||
pub use edit::execute_canvas_action; // Remove execute_edit_action
|
pub use edit::execute_canvas_action;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// canvas/src/actions/types.rs
|
// src/canvas/actions/types.rs
|
||||||
|
|
||||||
use crossterm::event::KeyCode;
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
|
||||||
/// All possible canvas actions, type-safe and exhaustive
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum CanvasAction {
|
pub enum CanvasAction {
|
||||||
// Character input
|
// Character input
|
||||||
InsertChar(char),
|
InsertChar(char),
|
||||||
@@ -28,37 +25,43 @@ pub enum CanvasAction {
|
|||||||
MoveWordNext,
|
MoveWordNext,
|
||||||
MoveWordEnd,
|
MoveWordEnd,
|
||||||
MoveWordPrev,
|
MoveWordPrev,
|
||||||
MoveWordEndPrev,
|
|
||||||
|
|
||||||
// Field navigation
|
// Field navigation
|
||||||
NextField,
|
NextField,
|
||||||
PrevField,
|
PrevField,
|
||||||
|
|
||||||
// AUTOCOMPLETE ACTIONS (NEW)
|
// Autocomplete actions
|
||||||
/// Manually trigger autocomplete for current field
|
|
||||||
TriggerAutocomplete,
|
TriggerAutocomplete,
|
||||||
/// Move to next suggestion
|
|
||||||
SuggestionUp,
|
SuggestionUp,
|
||||||
/// Move to previous suggestion
|
|
||||||
SuggestionDown,
|
SuggestionDown,
|
||||||
/// Select the currently highlighted suggestion
|
|
||||||
SelectSuggestion,
|
SelectSuggestion,
|
||||||
/// Cancel/exit autocomplete mode
|
|
||||||
ExitSuggestions,
|
ExitSuggestions,
|
||||||
|
|
||||||
// Custom actions (escape hatch for feature-specific behavior)
|
// Custom actions
|
||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanvasAction {
|
impl CanvasAction {
|
||||||
/// Convert a string action to typed action (for backwards compatibility during migration)
|
pub fn from_key(key: crossterm::event::KeyCode) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
crossterm::event::KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
||||||
|
crossterm::event::KeyCode::Backspace => Some(Self::DeleteBackward),
|
||||||
|
crossterm::event::KeyCode::Delete => Some(Self::DeleteForward),
|
||||||
|
crossterm::event::KeyCode::Left => Some(Self::MoveLeft),
|
||||||
|
crossterm::event::KeyCode::Right => Some(Self::MoveRight),
|
||||||
|
crossterm::event::KeyCode::Up => Some(Self::MoveUp),
|
||||||
|
crossterm::event::KeyCode::Down => Some(Self::MoveDown),
|
||||||
|
crossterm::event::KeyCode::Home => Some(Self::MoveLineStart),
|
||||||
|
crossterm::event::KeyCode::End => Some(Self::MoveLineEnd),
|
||||||
|
crossterm::event::KeyCode::Tab => Some(Self::NextField),
|
||||||
|
crossterm::event::KeyCode::BackTab => Some(Self::PrevField),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility method
|
||||||
pub fn from_string(action: &str) -> Self {
|
pub fn from_string(action: &str) -> Self {
|
||||||
match action {
|
match action {
|
||||||
"insert_char" => {
|
|
||||||
// This is a bit tricky - we need the char from context
|
|
||||||
// For now, we'll use Custom until we refactor the call sites
|
|
||||||
Self::Custom(action.to_string())
|
|
||||||
}
|
|
||||||
"delete_char_backward" => Self::DeleteBackward,
|
"delete_char_backward" => Self::DeleteBackward,
|
||||||
"delete_char_forward" => Self::DeleteForward,
|
"delete_char_forward" => Self::DeleteForward,
|
||||||
"move_left" => Self::MoveLeft,
|
"move_left" => Self::MoveLeft,
|
||||||
@@ -72,10 +75,8 @@ impl CanvasAction {
|
|||||||
"move_word_next" => Self::MoveWordNext,
|
"move_word_next" => Self::MoveWordNext,
|
||||||
"move_word_end" => Self::MoveWordEnd,
|
"move_word_end" => Self::MoveWordEnd,
|
||||||
"move_word_prev" => Self::MoveWordPrev,
|
"move_word_prev" => Self::MoveWordPrev,
|
||||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
|
||||||
"next_field" => Self::NextField,
|
"next_field" => Self::NextField,
|
||||||
"prev_field" => Self::PrevField,
|
"prev_field" => Self::PrevField,
|
||||||
// Autocomplete actions
|
|
||||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||||
"suggestion_up" => Self::SuggestionUp,
|
"suggestion_up" => Self::SuggestionUp,
|
||||||
"suggestion_down" => Self::SuggestionDown,
|
"suggestion_down" => Self::SuggestionDown,
|
||||||
@@ -84,94 +85,13 @@ impl CanvasAction {
|
|||||||
_ => Self::Custom(action.to_string()),
|
_ => Self::Custom(action.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get string representation (for logging, debugging)
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::InsertChar(_) => "insert_char",
|
|
||||||
Self::DeleteBackward => "delete_char_backward",
|
|
||||||
Self::DeleteForward => "delete_char_forward",
|
|
||||||
Self::MoveLeft => "move_left",
|
|
||||||
Self::MoveRight => "move_right",
|
|
||||||
Self::MoveUp => "move_up",
|
|
||||||
Self::MoveDown => "move_down",
|
|
||||||
Self::MoveLineStart => "move_line_start",
|
|
||||||
Self::MoveLineEnd => "move_line_end",
|
|
||||||
Self::MoveFirstLine => "move_first_line",
|
|
||||||
Self::MoveLastLine => "move_last_line",
|
|
||||||
Self::MoveWordNext => "move_word_next",
|
|
||||||
Self::MoveWordEnd => "move_word_end",
|
|
||||||
Self::MoveWordPrev => "move_word_prev",
|
|
||||||
Self::MoveWordEndPrev => "move_word_end_prev",
|
|
||||||
Self::NextField => "next_field",
|
|
||||||
Self::PrevField => "prev_field",
|
|
||||||
// Autocomplete actions
|
|
||||||
Self::TriggerAutocomplete => "trigger_autocomplete",
|
|
||||||
Self::SuggestionUp => "suggestion_up",
|
|
||||||
Self::SuggestionDown => "suggestion_down",
|
|
||||||
Self::SelectSuggestion => "select_suggestion",
|
|
||||||
Self::ExitSuggestions => "exit_suggestions",
|
|
||||||
Self::Custom(s) => s,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create action from KeyCode for common cases
|
|
||||||
pub fn from_key(key: KeyCode) -> Option<Self> {
|
|
||||||
match key {
|
|
||||||
KeyCode::Char(c) => Some(Self::InsertChar(c)),
|
|
||||||
KeyCode::Backspace => Some(Self::DeleteBackward),
|
|
||||||
KeyCode::Delete => Some(Self::DeleteForward),
|
|
||||||
KeyCode::Left => Some(Self::MoveLeft),
|
|
||||||
KeyCode::Right => Some(Self::MoveRight),
|
|
||||||
KeyCode::Up => Some(Self::MoveUp),
|
|
||||||
KeyCode::Down => Some(Self::MoveDown),
|
|
||||||
KeyCode::Home => Some(Self::MoveLineStart),
|
|
||||||
KeyCode::End => Some(Self::MoveLineEnd),
|
|
||||||
KeyCode::Tab => Some(Self::NextField),
|
|
||||||
KeyCode::BackTab => Some(Self::PrevField),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this action modifies content
|
|
||||||
pub fn is_modifying(&self) -> bool {
|
|
||||||
matches!(self,
|
|
||||||
Self::InsertChar(_) |
|
|
||||||
Self::DeleteBackward |
|
|
||||||
Self::DeleteForward |
|
|
||||||
Self::SelectSuggestion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this action moves the cursor
|
|
||||||
pub fn is_movement(&self) -> bool {
|
|
||||||
matches!(self,
|
|
||||||
Self::MoveLeft | Self::MoveRight | Self::MoveUp | Self::MoveDown |
|
|
||||||
Self::MoveLineStart | Self::MoveLineEnd | Self::MoveFirstLine | Self::MoveLastLine |
|
|
||||||
Self::MoveWordNext | Self::MoveWordEnd | Self::MoveWordPrev | Self::MoveWordEndPrev |
|
|
||||||
Self::NextField | Self::PrevField
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a suggestion-related action
|
|
||||||
pub fn is_suggestion(&self) -> bool {
|
|
||||||
matches!(self,
|
|
||||||
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
|
|
||||||
Self::SelectSuggestion | Self::ExitSuggestions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of executing a canvas action
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ActionResult {
|
pub enum ActionResult {
|
||||||
/// Action completed successfully, optional message for user feedback
|
|
||||||
Success(Option<String>),
|
Success(Option<String>),
|
||||||
/// Action was handled by custom feature logic
|
|
||||||
HandledByFeature(String),
|
HandledByFeature(String),
|
||||||
/// Action requires additional context or cannot be performed
|
|
||||||
RequiresContext(String),
|
RequiresContext(String),
|
||||||
/// Action failed with error message
|
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,11 +100,11 @@ impl ActionResult {
|
|||||||
Self::Success(None)
|
Self::Success(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn success_with_message(msg: impl Into<String>) -> Self {
|
pub fn success_with_message(msg: &str) -> Self {
|
||||||
Self::Success(Some(msg.into()))
|
Self::Success(Some(msg.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(msg: impl Into<String>) -> Self {
|
pub fn error(msg: &str) -> Self {
|
||||||
Self::Error(msg.into())
|
Self::Error(msg.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,37 +121,3 @@ impl ActionResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_action_from_string() {
|
|
||||||
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
|
|
||||||
assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward);
|
|
||||||
assert_eq!(CanvasAction::from_string("trigger_autocomplete"), CanvasAction::TriggerAutocomplete);
|
|
||||||
assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_action_from_key() {
|
|
||||||
assert_eq!(CanvasAction::from_key(KeyCode::Char('a')), Some(CanvasAction::InsertChar('a')));
|
|
||||||
assert_eq!(CanvasAction::from_key(KeyCode::Left), Some(CanvasAction::MoveLeft));
|
|
||||||
assert_eq!(CanvasAction::from_key(KeyCode::Backspace), Some(CanvasAction::DeleteBackward));
|
|
||||||
assert_eq!(CanvasAction::from_key(KeyCode::F(1)), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_action_properties() {
|
|
||||||
assert!(CanvasAction::InsertChar('a').is_modifying());
|
|
||||||
assert!(!CanvasAction::MoveLeft.is_modifying());
|
|
||||||
|
|
||||||
assert!(CanvasAction::MoveLeft.is_movement());
|
|
||||||
assert!(!CanvasAction::InsertChar('a').is_movement());
|
|
||||||
|
|
||||||
assert!(CanvasAction::SuggestionUp.is_suggestion());
|
|
||||||
assert!(CanvasAction::TriggerAutocomplete.is_suggestion());
|
|
||||||
assert!(!CanvasAction::MoveLeft.is_suggestion());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -180,6 +180,20 @@ impl CanvasConfig {
|
|||||||
Self::from_toml(&contents)
|
Self::from_toml(&contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NEW: Check if autocomplete should auto-trigger (simple logic)
|
||||||
|
pub fn should_auto_trigger_autocomplete(&self) -> bool {
|
||||||
|
// If trigger_autocomplete keybinding exists anywhere, use manual mode only
|
||||||
|
// If no trigger_autocomplete keybinding, use auto-trigger mode
|
||||||
|
!self.has_trigger_autocomplete_keybinding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NEW: Check if user has configured manual trigger keybinding
|
||||||
|
pub fn has_trigger_autocomplete_keybinding(&self) -> bool {
|
||||||
|
self.keybindings.edit.contains_key("trigger_autocomplete") ||
|
||||||
|
self.keybindings.read_only.contains_key("trigger_autocomplete") ||
|
||||||
|
self.keybindings.global.contains_key("trigger_autocomplete")
|
||||||
|
}
|
||||||
|
|
||||||
/// Get action for key in read-only mode
|
/// Get action for key in read-only mode
|
||||||
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
pub fn get_read_only_action(&self, key: KeyCode, modifiers: KeyModifiers) -> Option<&str> {
|
||||||
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
self.get_action_in_mode(&self.keybindings.read_only, key, modifiers)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::canvas::state::CanvasState;
|
use crate::canvas::state::CanvasState;
|
||||||
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
use crate::canvas::actions::{CanvasAction, ActionResult, execute_canvas_action};
|
||||||
|
use crate::config::CanvasConfig;
|
||||||
|
|
||||||
/// High-level action dispatcher that coordinates between different action types
|
/// High-level action dispatcher that coordinates between different action types
|
||||||
pub struct ActionDispatcher;
|
pub struct ActionDispatcher;
|
||||||
@@ -13,7 +14,9 @@ impl ActionDispatcher {
|
|||||||
state: &mut S,
|
state: &mut S,
|
||||||
ideal_cursor_column: &mut usize,
|
ideal_cursor_column: &mut usize,
|
||||||
) -> anyhow::Result<ActionResult> {
|
) -> anyhow::Result<ActionResult> {
|
||||||
execute_canvas_action(action, state, ideal_cursor_column).await
|
|
||||||
|
// Load config once here instead of threading it everywhere
|
||||||
|
execute_canvas_action(action, state, ideal_cursor_column, Some(&CanvasConfig::load())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quick action dispatch from KeyCode
|
/// Quick action dispatch from KeyCode
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ move_word_next = ["Ctrl+Right"]
|
|||||||
move_word_prev = ["Ctrl+Left"]
|
move_word_prev = ["Ctrl+Left"]
|
||||||
next_field = ["Tab"]
|
next_field = ["Tab"]
|
||||||
prev_field = ["Shift+Tab"]
|
prev_field = ["Shift+Tab"]
|
||||||
|
trigger_autocomplete = ["Ctrl+p"]
|
||||||
|
|
||||||
# Suggestion/autocomplete keybindings
|
# Suggestion/autocomplete keybindings
|
||||||
[keybindings.suggestions]
|
[keybindings.suggestions]
|
||||||
|
|||||||
124
client/docs/canvas_add_functionality.md
Normal file
124
client/docs/canvas_add_functionality.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
## How Canvas Library Custom Functionality Works
|
||||||
|
|
||||||
|
### 1. **The Canvas Library Calls YOUR Custom Code First**
|
||||||
|
|
||||||
|
When you call `ActionDispatcher::dispatch()`, here's what happens:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Inside canvas library (canvas/src/actions/edit.rs):
|
||||||
|
pub async fn execute_canvas_action<S: CanvasState>(
|
||||||
|
action: CanvasAction,
|
||||||
|
state: &mut S,
|
||||||
|
ideal_cursor_column: &mut usize,
|
||||||
|
) -> Result<ActionResult> {
|
||||||
|
// 1. FIRST: Canvas library calls YOUR custom handler
|
||||||
|
if let Some(result) = state.handle_feature_action(&action, &context) {
|
||||||
|
return Ok(ActionResult::HandledByFeature(result)); // YOUR code handled it
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ONLY IF your code returns None: Canvas handles generic actions
|
||||||
|
handle_generic_canvas_action(action, state, ideal_cursor_column).await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Your Extension Point: `handle_feature_action`**
|
||||||
|
|
||||||
|
You add custom functionality by implementing `handle_feature_action` in your states:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In src/state/pages/auth.rs
|
||||||
|
impl CanvasState for LoginState {
|
||||||
|
// ... other methods ...
|
||||||
|
|
||||||
|
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
// Custom login-specific actions
|
||||||
|
CanvasAction::Custom(action_str) if action_str == "submit_login" => {
|
||||||
|
if self.username.is_empty() || self.password.is_empty() {
|
||||||
|
Some("Please fill in all required fields".to_string())
|
||||||
|
} else {
|
||||||
|
// Trigger login process
|
||||||
|
Some(format!("Logging in user: {}", self.username))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasAction::Custom(action_str) if action_str == "clear_form" => {
|
||||||
|
self.username.clear();
|
||||||
|
self.password.clear();
|
||||||
|
self.set_has_unsaved_changes(false);
|
||||||
|
Some("Login form cleared".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom behavior for standard actions
|
||||||
|
CanvasAction::NextField => {
|
||||||
|
// Custom validation when moving between fields
|
||||||
|
if self.current_field == 0 && self.username.is_empty() {
|
||||||
|
Some("Username cannot be empty".to_string())
|
||||||
|
} else {
|
||||||
|
None // Let canvas library handle the normal field movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let canvas library handle everything else
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Multiple Ways to Add Custom Functionality**
|
||||||
|
|
||||||
|
#### A) **Custom Actions via Config**
|
||||||
|
```toml
|
||||||
|
# In config.toml
|
||||||
|
[keybindings.edit]
|
||||||
|
submit_login = ["ctrl+enter"]
|
||||||
|
clear_form = ["ctrl+r"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B) **Override Standard Actions**
|
||||||
|
```rust
|
||||||
|
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::InsertChar('p') if self.current_field == 1 => {
|
||||||
|
// Custom behavior when typing 'p' in password field
|
||||||
|
Some("Password field - use secure input".to_string())
|
||||||
|
}
|
||||||
|
_ => None, // Let canvas handle normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C) **Context-Aware Logic**
|
||||||
|
```rust
|
||||||
|
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
|
||||||
|
match action {
|
||||||
|
CanvasAction::MoveDown => {
|
||||||
|
// Custom logic based on current state
|
||||||
|
if context.current_field == 1 && context.current_input.len() < 8 {
|
||||||
|
Some("Password should be at least 8 characters".to_string())
|
||||||
|
} else {
|
||||||
|
None // Normal field movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Canvas Library Philosophy
|
||||||
|
|
||||||
|
**Canvas Library = Generic behavior + Your extension points**
|
||||||
|
|
||||||
|
- ✅ **Canvas handles**: Character insertion, cursor movement, field navigation, etc.
|
||||||
|
- ✅ **You handle**: Validation, submission, clearing, app-specific logic
|
||||||
|
- ✅ **You decide**: Return `Some(message)` to override, `None` to use canvas default
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
You **don't communicate with the library elsewhere**. Instead:
|
||||||
|
|
||||||
|
1. **Canvas library calls your code first** via `handle_feature_action`
|
||||||
|
2. **Your code decides** whether to handle the action or let canvas handle it
|
||||||
|
3. **Canvas library handles** generic form behavior when you return `None`
|
||||||
|
|
||||||
@@ -3,8 +3,7 @@ use crate::config::colors::themes::Theme;
|
|||||||
use crate::state::app::highlight::HighlightState;
|
use crate::state::app::highlight::HighlightState;
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
use crate::state::pages::add_table::{AddTableFocus, AddTableState};
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{render_canvas, CanvasState, HighlightState as CanvasHighlightState}; // Use canvas library
|
||||||
// use crate::state::pages::add_table::{ColumnDefinition, LinkDefinition}; // Not directly used here
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
@@ -12,16 +11,24 @@ use ratatui::{
|
|||||||
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
widgets::{Block, BorderType, Borders, Cell, Paragraph, Row, Table},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::components::handlers::canvas::render_canvas;
|
|
||||||
use crate::components::common::dialog;
|
use crate::components::common::dialog;
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the Add New Table page layout, structuring the display of table information,
|
/// Renders the Add New Table page layout, structuring the display of table information,
|
||||||
/// input fields, and action buttons. Adapts layout based on terminal width.
|
/// input fields, and action buttons. Adapts layout based on terminal width.
|
||||||
pub fn render_add_table(
|
pub fn render_add_table(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
app_state: &AppState, // Currently unused, might be needed later
|
app_state: &AppState,
|
||||||
add_table_state: &mut AddTableState,
|
add_table_state: &mut AddTableState,
|
||||||
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
is_edit_mode: bool, // Determines if canvas inputs are in edit mode
|
||||||
highlight_state: &HighlightState, // For text highlighting in canvas
|
highlight_state: &HighlightState, // For text highlighting in canvas
|
||||||
@@ -349,17 +356,15 @@ pub fn render_add_table(
|
|||||||
&mut add_table_state.column_table_state,
|
&mut add_table_state.column_table_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Canvas Rendering (Column Definition Input) ---
|
// --- Canvas Rendering (Column Definition Input) - USING CANVAS LIBRARY ---
|
||||||
|
let canvas_highlight_state = convert_highlight_state(highlight_state);
|
||||||
let _active_field_rect = render_canvas(
|
let _active_field_rect = render_canvas(
|
||||||
f,
|
f,
|
||||||
canvas_area,
|
canvas_area,
|
||||||
add_table_state,
|
add_table_state, // AddTableState implements CanvasState
|
||||||
&add_table_state.fields(),
|
theme, // Theme implements CanvasTheme
|
||||||
&add_table_state.current_field(),
|
|
||||||
&add_table_state.inputs(),
|
|
||||||
theme,
|
|
||||||
is_edit_mode && focus_on_canvas_inputs,
|
is_edit_mode && focus_on_canvas_inputs,
|
||||||
highlight_state,
|
&canvas_highlight_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Button Style Helpers ---
|
// --- Button Style Helpers ---
|
||||||
@@ -557,7 +562,7 @@ pub fn render_add_table(
|
|||||||
|
|
||||||
// --- DIALOG ---
|
// --- DIALOG ---
|
||||||
// Render the dialog overlay if it's active
|
// Render the dialog overlay if it's active
|
||||||
if app_state.ui.dialog.dialog_show { // Use the passed-in app_state
|
if app_state.ui.dialog.dialog_show {
|
||||||
dialog::render_dialog(
|
dialog::render_dialog(
|
||||||
f,
|
f,
|
||||||
f.area(), // Render over the whole frame area
|
f.area(), // Render over the whole frame area
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/functions/modes/edit/add_table_e.rs
|
// src/functions/modes/edit/add_table_e.rs
|
||||||
use crate::state::pages::add_table::AddTableState;
|
use crate::state::pages::add_table::AddTableState;
|
||||||
use crate::state::pages::canvas_state::CanvasState; // Use trait
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/functions/modes/read_only/add_table_ro.rs
|
// src/functions/modes/read_only/add_table_ro.rs
|
||||||
use crate::config::binds::key_sequences::KeySequenceTracker;
|
use crate::config::binds::key_sequences::KeySequenceTracker;
|
||||||
use crate::state::pages::add_table::AddTableState;
|
use crate::state::pages::add_table::AddTableState;
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
|
||||||
use crate::state::app::state::AppState;
|
use crate::state::app::state::AppState;
|
||||||
|
use canvas::canvas::CanvasState;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
// Re-use word navigation helpers if they are public or move them to a common module
|
// Re-use word navigation helpers if they are public or move them to a common module
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/modes/canvas/edit.rs
|
// src/modes/canvas/edit.rs
|
||||||
use crate::config::binds::config::Config;
|
use crate::config::binds::config::Config;
|
||||||
use crate::functions::modes::edit::{
|
use crate::functions::modes::edit::{
|
||||||
add_logic_e, add_table_e, auth_e, form_e,
|
add_logic_e, add_table_e, form_e,
|
||||||
};
|
};
|
||||||
use crate::modes::handlers::event::EventHandler;
|
use crate::modes::handlers::event::EventHandler;
|
||||||
use crate::services::grpc_client::GrpcClient;
|
use crate::services::grpc_client::GrpcClient;
|
||||||
@@ -127,6 +127,60 @@ pub async fn handle_form_edit_with_canvas(
|
|||||||
Ok(String::new())
|
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
|
||||||
|
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) {
|
||||||
|
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(_) => {
|
||||||
|
// Fall through to try config mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn handle_edit_event(
|
pub async fn handle_edit_event(
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
@@ -283,21 +337,21 @@ pub async fn handle_edit_event(
|
|||||||
return Ok(EditEventOutcome::ExitEditMode);
|
return Ok(EditEventOutcome::ExitEditMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle all other edit actions
|
// Handle all other edit actions - NOW USING CANVAS LIBRARY
|
||||||
let msg = if app_state.ui.show_login {
|
let msg = if app_state.ui.show_login {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
login_state,
|
login_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||||
add_table_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
@@ -312,10 +366,10 @@ pub async fn handle_edit_event(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_register {
|
} else if app_state.ui.show_register {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
action_str,
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
register_state,
|
register_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
@@ -336,19 +390,19 @@ pub async fn handle_edit_event(
|
|||||||
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
|
||||||
if let KeyCode::Char(_) = key.code {
|
if let KeyCode::Char(_) = key.code {
|
||||||
let msg = if app_state.ui.show_login {
|
let msg = if app_state.ui.show_login {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
login_state,
|
login_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_add_table {
|
} else if app_state.ui.show_add_table {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of add_table_e::execute_edit_action
|
||||||
add_table_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
&mut admin_state.add_table_state,
|
&mut admin_state.add_table_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
@@ -363,10 +417,10 @@ pub async fn handle_edit_event(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else if app_state.ui.show_register {
|
} else if app_state.ui.show_register {
|
||||||
// FIX: Pass &mut event_handler.ideal_cursor_column
|
// NEW: Use unified canvas handler instead of auth_e::execute_edit_action
|
||||||
auth_e::execute_edit_action(
|
handle_canvas_state_edit(
|
||||||
"insert_char",
|
|
||||||
key,
|
key,
|
||||||
|
config,
|
||||||
register_state,
|
register_state,
|
||||||
&mut event_handler.ideal_cursor_column,
|
&mut event_handler.ideal_cursor_column,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// src/state/pages/add_table.rs
|
// src/state/pages/add_table.rs
|
||||||
use crate::state::pages::canvas_state::CanvasState;
|
use canvas::canvas::{CanvasState, ActionContext, CanvasAction}; // External library
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -67,7 +67,6 @@ pub struct AddTableState {
|
|||||||
|
|
||||||
impl Default for AddTableState {
|
impl Default for AddTableState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
// Initialize with some dummy data for demonstration
|
|
||||||
AddTableState {
|
AddTableState {
|
||||||
profile_name: "default".to_string(),
|
profile_name: "default".to_string(),
|
||||||
table_name: String::new(),
|
table_name: String::new(),
|
||||||
@@ -92,16 +91,91 @@ impl Default for AddTableState {
|
|||||||
|
|
||||||
impl AddTableState {
|
impl AddTableState {
|
||||||
pub const INPUT_FIELD_COUNT: usize = 3;
|
pub const INPUT_FIELD_COUNT: usize = 3;
|
||||||
|
|
||||||
|
/// Helper method to add a column from current inputs
|
||||||
|
pub fn add_column_from_inputs(&mut self) -> Option<String> {
|
||||||
|
if self.column_name_input.trim().is_empty() || self.column_type_input.trim().is_empty() {
|
||||||
|
return Some("Both column name and type are required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate column names
|
||||||
|
if self.columns.iter().any(|col| col.name == self.column_name_input.trim()) {
|
||||||
|
return Some("Column name already exists".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the column
|
||||||
|
self.columns.push(ColumnDefinition {
|
||||||
|
name: self.column_name_input.trim().to_string(),
|
||||||
|
data_type: self.column_type_input.trim().to_string(),
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear inputs and reset focus to column name for next entry
|
||||||
|
self.column_name_input.clear();
|
||||||
|
self.column_type_input.clear();
|
||||||
|
self.column_name_cursor_pos = 0;
|
||||||
|
self.column_type_cursor_pos = 0;
|
||||||
|
self.current_focus = AddTableFocus::InputColumnName;
|
||||||
|
self.last_canvas_field = 1;
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
|
||||||
|
Some(format!("Column '{}' added successfully", self.columns.last().unwrap().name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to delete selected items
|
||||||
|
pub fn delete_selected_items(&mut self) -> Option<String> {
|
||||||
|
let mut deleted_items = Vec::new();
|
||||||
|
|
||||||
|
// Remove selected columns
|
||||||
|
let initial_column_count = self.columns.len();
|
||||||
|
self.columns.retain(|col| {
|
||||||
|
if col.selected {
|
||||||
|
deleted_items.push(format!("column '{}'", col.name));
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove selected indexes
|
||||||
|
let initial_index_count = self.indexes.len();
|
||||||
|
self.indexes.retain(|idx| {
|
||||||
|
if idx.selected {
|
||||||
|
deleted_items.push(format!("index '{}'", idx.name));
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove selected links
|
||||||
|
let initial_link_count = self.links.len();
|
||||||
|
self.links.retain(|link| {
|
||||||
|
if link.selected {
|
||||||
|
deleted_items.push(format!("link to '{}'", link.linked_table_name));
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if deleted_items.is_empty() {
|
||||||
|
Some("No items selected for deletion".to_string())
|
||||||
|
} else {
|
||||||
|
self.has_unsaved_changes = true;
|
||||||
|
Some(format!("Deleted: {}", deleted_items.join(", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement CanvasState for the input fields
|
// Implement external library's CanvasState for AddTableState
|
||||||
impl CanvasState for AddTableState {
|
impl CanvasState for AddTableState {
|
||||||
fn current_field(&self) -> usize {
|
fn current_field(&self) -> usize {
|
||||||
match self.current_focus {
|
match self.current_focus {
|
||||||
AddTableFocus::InputTableName => 0,
|
AddTableFocus::InputTableName => 0,
|
||||||
AddTableFocus::InputColumnName => 1,
|
AddTableFocus::InputColumnName => 1,
|
||||||
AddTableFocus::InputColumnType => 2,
|
AddTableFocus::InputColumnType => 2,
|
||||||
// If focus is elsewhere, default to the first field for canvas rendering logic
|
// If focus is elsewhere, return the last canvas field used
|
||||||
_ => self.last_canvas_field,
|
_ => self.last_canvas_field,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,37 +189,6 @@ impl CanvasState for AddTableState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_unsaved_changes(&self) -> bool {
|
|
||||||
self.has_unsaved_changes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inputs(&self) -> Vec<&String> {
|
|
||||||
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input(&self) -> &str {
|
|
||||||
match self.current_focus {
|
|
||||||
AddTableFocus::InputTableName => &self.table_name_input,
|
|
||||||
AddTableFocus::InputColumnName => &self.column_name_input,
|
|
||||||
AddTableFocus::InputColumnType => &self.column_type_input,
|
|
||||||
_ => "", // Should not happen if called correctly
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_current_input_mut(&mut self) -> &mut String {
|
|
||||||
match self.current_focus {
|
|
||||||
AddTableFocus::InputTableName => &mut self.table_name_input,
|
|
||||||
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
|
||||||
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
|
||||||
_ => &mut self.table_name_input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields(&self) -> Vec<&str> {
|
|
||||||
// These must match the order used in render_add_table
|
|
||||||
vec!["Table name", "Name", "Type"]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_field(&mut self, index: usize) {
|
fn set_current_field(&mut self, index: usize) {
|
||||||
// Update both current focus and last canvas field
|
// Update both current focus and last canvas field
|
||||||
self.current_focus = match index {
|
self.current_focus = match index {
|
||||||
@@ -174,17 +217,84 @@ impl CanvasState for AddTableState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_current_input(&self) -> &str {
|
||||||
|
match self.current_focus {
|
||||||
|
AddTableFocus::InputTableName => &self.table_name_input,
|
||||||
|
AddTableFocus::InputColumnName => &self.column_name_input,
|
||||||
|
AddTableFocus::InputColumnType => &self.column_type_input,
|
||||||
|
_ => "", // Should not happen if called correctly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_input_mut(&mut self) -> &mut String {
|
||||||
|
match self.current_focus {
|
||||||
|
AddTableFocus::InputTableName => &mut self.table_name_input,
|
||||||
|
AddTableFocus::InputColumnName => &mut self.column_name_input,
|
||||||
|
AddTableFocus::InputColumnType => &mut self.column_type_input,
|
||||||
|
_ => &mut self.table_name_input, // Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inputs(&self) -> Vec<&String> {
|
||||||
|
vec![&self.table_name_input, &self.column_name_input, &self.column_type_input]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields(&self) -> Vec<&str> {
|
||||||
|
// These must match the order used in render_add_table
|
||||||
|
vec!["Table name", "Name", "Type"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_unsaved_changes(&self) -> bool {
|
||||||
|
self.has_unsaved_changes
|
||||||
|
}
|
||||||
|
|
||||||
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
fn set_has_unsaved_changes(&mut self, changed: bool) {
|
||||||
self.has_unsaved_changes = changed;
|
self.has_unsaved_changes = changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Autocomplete Support (Not needed for this form yet) ---
|
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
fn get_suggestions(&self) -> Option<&[String]> {
|
match action {
|
||||||
None
|
// Handle adding column when user presses Enter on the Add button or uses specific action
|
||||||
}
|
CanvasAction::Custom(action_str) if action_str == "add_column" => {
|
||||||
|
self.add_column_from_inputs()
|
||||||
|
}
|
||||||
|
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
// Handle table saving
|
||||||
None
|
CanvasAction::Custom(action_str) if action_str == "save_table" => {
|
||||||
|
if self.table_name_input.trim().is_empty() {
|
||||||
|
Some("Table name is required".to_string())
|
||||||
|
} else if self.columns.is_empty() {
|
||||||
|
Some("At least one column is required".to_string())
|
||||||
|
} else {
|
||||||
|
Some(format!("Saving table: {}", self.table_name_input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deleting selected items
|
||||||
|
CanvasAction::Custom(action_str) if action_str == "delete_selected" => {
|
||||||
|
self.delete_selected_items()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle canceling (clear form)
|
||||||
|
CanvasAction::Custom(action_str) if action_str == "cancel" => {
|
||||||
|
// Reset to defaults but keep profile_name
|
||||||
|
let profile = self.profile_name.clone();
|
||||||
|
*self = Self::default();
|
||||||
|
self.profile_name = profile;
|
||||||
|
Some("Form cleared".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation when moving between fields
|
||||||
|
CanvasAction::NextField => {
|
||||||
|
// When leaving table name field, update the table_name for display
|
||||||
|
if self.current_field() == 0 && !self.table_name_input.trim().is_empty() {
|
||||||
|
self.table_name = self.table_name_input.trim().to_string();
|
||||||
|
}
|
||||||
|
None // Let canvas library handle the normal field movement
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let canvas library handle everything else
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use sqlx::{PgPool, Transaction, Postgres};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
use common::proto::komp_ac::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
||||||
|
|
||||||
|
// TODO CRITICAL add decimal with optional precision"
|
||||||
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
||||||
("text", "TEXT"),
|
("text", "TEXT"),
|
||||||
("string", "TEXT"),
|
("string", "TEXT"),
|
||||||
|
|||||||
Reference in New Issue
Block a user