compiled autocomplete
This commit is contained in:
@@ -23,8 +23,8 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
||||
return Ok(ActionResult::HandledByFeature(result));
|
||||
}
|
||||
|
||||
// 2. Handle suggestion actions
|
||||
if let Some(result) = handle_suggestion_action(&action, state)? {
|
||||
// 2. Handle autocomplete actions
|
||||
if let Some(result) = handle_autocomplete_action(&action, state)? {
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -51,52 +51,67 @@ pub async fn execute_edit_action<S: CanvasState>(
|
||||
};
|
||||
|
||||
let result = execute_canvas_action(typed_action, state, ideal_cursor_column).await?;
|
||||
|
||||
|
||||
// Convert ActionResult back to string for backwards compatibility
|
||||
Ok(result.message().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
/// Handle suggestion-related actions
|
||||
fn handle_suggestion_action<S: CanvasState>(
|
||||
/// Handle autocomplete-related actions
|
||||
fn handle_autocomplete_action<S: CanvasState>(
|
||||
action: &CanvasAction,
|
||||
state: &mut S,
|
||||
) -> Result<Option<ActionResult>> {
|
||||
match action {
|
||||
CanvasAction::TriggerAutocomplete => {
|
||||
if state.supports_autocomplete(state.current_field()) {
|
||||
state.activate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete activated - fetching suggestions...")))
|
||||
} else {
|
||||
Ok(Some(ActionResult::error("Autocomplete not supported for this field")))
|
||||
}
|
||||
}
|
||||
|
||||
CanvasAction::SuggestionDown => {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
if !suggestions.is_empty() {
|
||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
||||
let next = (current + 1) % suggestions.len();
|
||||
state.set_selected_suggestion_index(Some(next));
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||
autocomplete_state.select_next();
|
||||
return Ok(Some(ActionResult::success()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
Ok(None) // Not handled - no active autocomplete
|
||||
}
|
||||
|
||||
|
||||
CanvasAction::SuggestionUp => {
|
||||
if let Some(suggestions) = state.get_suggestions() {
|
||||
if !suggestions.is_empty() {
|
||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
||||
let prev = if current == 0 { suggestions.len() - 1 } else { current - 1 };
|
||||
state.set_selected_suggestion_index(Some(prev));
|
||||
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)
|
||||
Ok(None) // Not handled - no active autocomplete
|
||||
}
|
||||
|
||||
|
||||
CanvasAction::SelectSuggestion => {
|
||||
// Let feature handle this via handle_feature_action since it's feature-specific
|
||||
Ok(None)
|
||||
if state.is_autocomplete_ready() {
|
||||
if let Some(message) = state.apply_autocomplete_selection() {
|
||||
return Ok(Some(ActionResult::success_with_message(message)));
|
||||
} else {
|
||||
return Ok(Some(ActionResult::error("No suggestion selected")));
|
||||
}
|
||||
}
|
||||
Ok(None) // Not handled - no active autocomplete
|
||||
}
|
||||
|
||||
|
||||
CanvasAction::ExitSuggestions => {
|
||||
state.deactivate_suggestions();
|
||||
Ok(Some(ActionResult::success()))
|
||||
if state.is_autocomplete_active() {
|
||||
state.deactivate_autocomplete();
|
||||
Ok(Some(ActionResult::success_with_message("Autocomplete cancelled")))
|
||||
} else {
|
||||
Ok(None) // Not handled - autocomplete not active
|
||||
}
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
|
||||
_ => Ok(None), // Not an autocomplete action
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +126,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
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();
|
||||
@@ -129,7 +144,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
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();
|
||||
@@ -146,7 +161,7 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
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();
|
||||
@@ -321,15 +336,15 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
||||
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
||||
}
|
||||
|
||||
// Suggestion actions should have been handled above
|
||||
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||
// Autocomplete actions should have been handled above
|
||||
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
|
||||
Ok(ActionResult::error("Suggestion action not handled properly"))
|
||||
Ok(ActionResult::error("Autocomplete action not handled properly"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Word movement helper functions
|
||||
// Word movement helper functions (unchanged from previous implementation)
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum CharType {
|
||||
|
||||
@@ -7,39 +7,45 @@ use crossterm::event::KeyCode;
|
||||
pub enum CanvasAction {
|
||||
// Character input
|
||||
InsertChar(char),
|
||||
|
||||
|
||||
// Deletion
|
||||
DeleteBackward,
|
||||
DeleteForward,
|
||||
|
||||
|
||||
// Basic cursor movement
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
|
||||
|
||||
// Line movement
|
||||
MoveLineStart,
|
||||
MoveLineEnd,
|
||||
MoveFirstLine,
|
||||
MoveLastLine,
|
||||
|
||||
|
||||
// Word movement
|
||||
MoveWordNext,
|
||||
MoveWordEnd,
|
||||
MoveWordPrev,
|
||||
MoveWordEndPrev,
|
||||
|
||||
|
||||
// Field navigation
|
||||
NextField,
|
||||
PrevField,
|
||||
|
||||
// Suggestions
|
||||
|
||||
// AUTOCOMPLETE ACTIONS (NEW)
|
||||
/// Manually trigger autocomplete for current field
|
||||
TriggerAutocomplete,
|
||||
/// Move to next suggestion
|
||||
SuggestionUp,
|
||||
/// Move to previous suggestion
|
||||
SuggestionDown,
|
||||
/// Select the currently highlighted suggestion
|
||||
SelectSuggestion,
|
||||
/// Cancel/exit autocomplete mode
|
||||
ExitSuggestions,
|
||||
|
||||
|
||||
// Custom actions (escape hatch for feature-specific behavior)
|
||||
Custom(String),
|
||||
}
|
||||
@@ -69,6 +75,8 @@ impl CanvasAction {
|
||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
||||
"next_field" => Self::NextField,
|
||||
"prev_field" => Self::PrevField,
|
||||
// Autocomplete actions
|
||||
"trigger_autocomplete" => Self::TriggerAutocomplete,
|
||||
"suggestion_up" => Self::SuggestionUp,
|
||||
"suggestion_down" => Self::SuggestionDown,
|
||||
"select_suggestion" => Self::SelectSuggestion,
|
||||
@@ -76,7 +84,7 @@ impl CanvasAction {
|
||||
_ => Self::Custom(action.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Get string representation (for logging, debugging)
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
@@ -97,6 +105,8 @@ impl CanvasAction {
|
||||
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",
|
||||
@@ -104,7 +114,7 @@ impl CanvasAction {
|
||||
Self::Custom(s) => s,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Create action from KeyCode for common cases
|
||||
pub fn from_key(key: KeyCode) -> Option<Self> {
|
||||
match key {
|
||||
@@ -122,17 +132,17 @@ impl CanvasAction {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Check if this action modifies content
|
||||
pub fn is_modifying(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::InsertChar(_) |
|
||||
Self::DeleteBackward |
|
||||
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,
|
||||
@@ -142,11 +152,11 @@ impl CanvasAction {
|
||||
Self::NextField | Self::PrevField
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// Check if this is a suggestion-related action
|
||||
pub fn is_suggestion(&self) -> bool {
|
||||
matches!(self,
|
||||
Self::SuggestionUp | Self::SuggestionDown |
|
||||
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
|
||||
Self::SelectSuggestion | Self::ExitSuggestions
|
||||
)
|
||||
}
|
||||
@@ -169,19 +179,19 @@ impl ActionResult {
|
||||
pub fn success() -> Self {
|
||||
Self::Success(None)
|
||||
}
|
||||
|
||||
|
||||
pub fn success_with_message(msg: impl Into<String>) -> Self {
|
||||
Self::Success(Some(msg.into()))
|
||||
}
|
||||
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self::Error(msg.into())
|
||||
}
|
||||
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Success(_) | Self::HandledByFeature(_))
|
||||
}
|
||||
|
||||
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Success(msg) => msg.as_deref(),
|
||||
@@ -195,14 +205,15 @@ 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')));
|
||||
@@ -210,16 +221,17 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
126
canvas/src/autocomplete.rs
Normal file
126
canvas/src/autocomplete.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
// canvas/src/autocomplete.rs
|
||||
|
||||
/// Generic suggestion item that clients push to canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionItem<T> {
|
||||
/// The underlying data (client-specific, e.g., Hit, String, etc.)
|
||||
pub data: T,
|
||||
/// Text to display in the dropdown
|
||||
pub display_text: String,
|
||||
/// Value to store in the form field when selected
|
||||
pub value_to_store: String,
|
||||
}
|
||||
|
||||
impl<T> SuggestionItem<T> {
|
||||
pub fn new(data: T, display_text: String, value_to_store: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text,
|
||||
value_to_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience constructor for simple string suggestions
|
||||
pub fn simple(data: T, text: String) -> Self {
|
||||
Self {
|
||||
data,
|
||||
display_text: text.clone(),
|
||||
value_to_store: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Autocomplete state managed by canvas
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocompleteState<T> {
|
||||
/// Whether autocomplete is currently active/visible
|
||||
pub is_active: bool,
|
||||
/// Whether suggestions are being loaded (for spinner/loading indicator)
|
||||
pub is_loading: bool,
|
||||
/// Current suggestions to display
|
||||
pub suggestions: Vec<SuggestionItem<T>>,
|
||||
/// Currently selected suggestion index
|
||||
pub selected_index: Option<usize>,
|
||||
/// Field index that triggered autocomplete (for context)
|
||||
pub active_field: Option<usize>,
|
||||
}
|
||||
|
||||
impl<T> Default for AutocompleteState<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_active: false,
|
||||
is_loading: false,
|
||||
suggestions: Vec::new(),
|
||||
selected_index: None,
|
||||
active_field: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AutocompleteState<T> {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Activate autocomplete for a specific field
|
||||
pub fn activate(&mut self, field_index: usize) {
|
||||
self.is_active = true;
|
||||
self.active_field = Some(field_index);
|
||||
self.selected_index = None;
|
||||
self.suggestions.clear();
|
||||
self.is_loading = true;
|
||||
}
|
||||
|
||||
/// Deactivate autocomplete and clear state
|
||||
pub fn deactivate(&mut self) {
|
||||
self.is_active = false;
|
||||
self.is_loading = false;
|
||||
self.suggestions.clear();
|
||||
self.selected_index = None;
|
||||
self.active_field = None;
|
||||
}
|
||||
|
||||
/// Set suggestions and stop loading
|
||||
pub fn set_suggestions(&mut self, suggestions: Vec<SuggestionItem<T>>) {
|
||||
self.suggestions = suggestions;
|
||||
self.is_loading = false;
|
||||
self.selected_index = if self.suggestions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
/// Move selection down
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some((current + 1) % self.suggestions.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up
|
||||
pub fn select_previous(&mut self) {
|
||||
if !self.suggestions.is_empty() {
|
||||
let current = self.selected_index.unwrap_or(0);
|
||||
self.selected_index = Some(
|
||||
if current == 0 {
|
||||
self.suggestions.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get currently selected suggestion
|
||||
pub fn get_selected(&self) -> Option<&SuggestionItem<T>> {
|
||||
self.selected_index
|
||||
.and_then(|idx| self.suggestions.get(idx))
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction (active and has suggestions)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.is_active && !self.suggestions.is_empty() && !self.is_loading
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, BorderType, Paragraph},
|
||||
widgets::{Block, Borders, BorderType, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::CanvasState;
|
||||
@@ -14,6 +14,8 @@ use crate::modes::HighlightState;
|
||||
use super::theme::CanvasTheme;
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render canvas using the CanvasState trait and CanvasTheme
|
||||
#[cfg(feature = "gui")]
|
||||
@@ -28,8 +30,8 @@ pub fn render_canvas<T: CanvasTheme>(
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
|
||||
render_canvas_impl(
|
||||
|
||||
let active_field_rect = render_canvas_impl(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
@@ -42,10 +44,137 @@ pub fn render_canvas<T: CanvasTheme>(
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
);
|
||||
|
||||
// NEW: Render autocomplete dropdown if active
|
||||
if let Some(autocomplete_state) = form_state.autocomplete_state() {
|
||||
if autocomplete_state.is_active {
|
||||
if let Some(field_rect) = active_field_rect {
|
||||
render_autocomplete_dropdown(f, area, field_rect, theme, autocomplete_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active_field_rect
|
||||
}
|
||||
|
||||
/// Internal implementation of canvas rendering
|
||||
/// Render autocomplete dropdown
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_autocomplete_dropdown<T: CanvasTheme, S: CanvasState>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &crate::autocomplete::AutocompleteState<S::SuggestionData>,
|
||||
) {
|
||||
if autocomplete_state.is_loading {
|
||||
// Show loading indicator
|
||||
let loading_text = "Loading suggestions...";
|
||||
let loading_width = loading_text.width() as u16 + 2;
|
||||
let loading_height = 3;
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: loading_width,
|
||||
height: loading_height,
|
||||
};
|
||||
|
||||
// Adjust position to stay within frame
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(loading_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(loading_width);
|
||||
}
|
||||
|
||||
let loading_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent()))
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let loading_paragraph = Paragraph::new(loading_text)
|
||||
.block(loading_block)
|
||||
.style(Style::default().fg(theme.fg()))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
f.render_widget(loading_paragraph, dropdown_area);
|
||||
return;
|
||||
}
|
||||
|
||||
if autocomplete_state.suggestions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate dropdown dimensions
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
.map(|item| item.display_text.as_str())
|
||||
.collect();
|
||||
|
||||
let max_width = display_texts
|
||||
.iter()
|
||||
.map(|text| text.width())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
|
||||
let horizontal_padding = 4; // 2 for borders + 2 for internal padding
|
||||
let dropdown_width = (max_width + horizontal_padding).max(12);
|
||||
let dropdown_height = (autocomplete_state.suggestions.len() as u16).min(8) + 2; // +2 for borders
|
||||
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1,
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// Adjust position to stay within frame bounds
|
||||
if dropdown_area.bottom() > frame_area.height {
|
||||
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
|
||||
}
|
||||
if dropdown_area.right() > frame_area.width {
|
||||
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
|
||||
}
|
||||
dropdown_area.x = dropdown_area.x.max(0);
|
||||
dropdown_area.y = dropdown_area.y.max(0);
|
||||
|
||||
// Create dropdown background
|
||||
let dropdown_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent()))
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
// Create list items
|
||||
let items: Vec<ListItem> = display_texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| {
|
||||
let is_selected = autocomplete_state.selected_index == Some(i);
|
||||
let text_width = text.width() as u16;
|
||||
let available_width = dropdown_width.saturating_sub(horizontal_padding);
|
||||
let padding_needed = available_width.saturating_sub(text_width);
|
||||
let padded_text = format!("{}{}", text, " ".repeat(padding_needed as usize));
|
||||
|
||||
ListItem::new(padded_text).style(if is_selected {
|
||||
Style::default()
|
||||
.fg(theme.bg())
|
||||
.bg(theme.highlight())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg()).bg(theme.bg())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(dropdown_block);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(autocomplete_state.selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Internal implementation of canvas rendering (unchanged from previous version)
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_impl<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
@@ -77,7 +206,7 @@ where
|
||||
} else {
|
||||
Style::default().fg(theme.secondary())
|
||||
};
|
||||
|
||||
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
|
||||
@@ -1,47 +1,25 @@
|
||||
// canvas/src/lib.rs
|
||||
//! Canvas - A reusable text editing and form canvas system
|
||||
//!
|
||||
//! This crate provides a generic canvas abstraction for building text-based interfaces
|
||||
//! with multiple input fields, cursor management, and mode-based editing.
|
||||
|
||||
pub mod state;
|
||||
pub mod actions;
|
||||
pub mod modes;
|
||||
pub mod config;
|
||||
pub mod suggestions;
|
||||
pub mod dispatcher;
|
||||
pub mod state;
|
||||
pub mod suggestions; // Keep for backwards compatibility
|
||||
pub mod autocomplete; // NEW: Core autocomplete functionality
|
||||
pub mod modes;
|
||||
|
||||
// GUI module (optional, enabled with "gui" feature)
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod gui;
|
||||
|
||||
// Re-export the main types for easy use
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action};
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
pub use suggestions::SuggestionState;
|
||||
// Re-export commonly used types
|
||||
pub use actions::{CanvasAction, ActionResult};
|
||||
pub use dispatcher::ActionDispatcher;
|
||||
pub use state::{CanvasState, ActionContext};
|
||||
pub use autocomplete::{SuggestionItem, AutocompleteState}; // NEW
|
||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
||||
|
||||
// Re-export GUI types when available
|
||||
#[cfg(feature = "gui")]
|
||||
pub use gui::{CanvasTheme, render_canvas};
|
||||
pub use gui::{render_canvas, CanvasTheme};
|
||||
|
||||
// High-level convenience API
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
CanvasState,
|
||||
ActionContext,
|
||||
CanvasAction,
|
||||
ActionResult,
|
||||
execute_edit_action,
|
||||
execute_canvas_action,
|
||||
ActionDispatcher,
|
||||
AppMode,
|
||||
ModeManager,
|
||||
HighlightState,
|
||||
SuggestionState,
|
||||
};
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use crate::{CanvasTheme, render_canvas};
|
||||
}
|
||||
// Keep backwards compatibility exports
|
||||
pub use suggestions::SuggestionState;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// canvas/src/state.rs
|
||||
|
||||
use crate::actions::CanvasAction;
|
||||
use crate::autocomplete::{AutocompleteState, SuggestionItem};
|
||||
|
||||
/// Context passed to feature-specific action handlers
|
||||
#[derive(Debug)]
|
||||
@@ -31,29 +32,133 @@ pub trait CanvasState {
|
||||
fn has_unsaved_changes(&self) -> bool;
|
||||
fn set_has_unsaved_changes(&mut self, changed: bool);
|
||||
|
||||
// --- Autocomplete/Suggestions (Optional) ---
|
||||
// --- AUTOCOMPLETE SUPPORT (NEW) ---
|
||||
|
||||
/// Associated type for suggestion data (e.g., Hit, String, CustomType)
|
||||
type SuggestionData: Clone + Send + 'static;
|
||||
|
||||
/// Check if a field supports autocomplete
|
||||
fn supports_autocomplete(&self, _field_index: usize) -> bool {
|
||||
false // Default: no autocomplete support
|
||||
}
|
||||
|
||||
/// Get autocomplete state (read-only)
|
||||
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// Get autocomplete state (mutable)
|
||||
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> {
|
||||
None // Default: no autocomplete state
|
||||
}
|
||||
|
||||
/// CLIENT API: Activate autocomplete for current field
|
||||
fn activate_autocomplete(&mut self) {
|
||||
let current_field = self.current_field(); // Get field first
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.activate(current_field); // Then use it
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Deactivate autocomplete
|
||||
fn deactivate_autocomplete(&mut self) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set suggestions (called after async fetch completes)
|
||||
fn set_autocomplete_suggestions(&mut self, suggestions: Vec<SuggestionItem<Self::SuggestionData>>) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.set_suggestions(suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLIENT API: Set loading state
|
||||
fn set_autocomplete_loading(&mut self, loading: bool) {
|
||||
if let Some(state) = self.autocomplete_state_mut() {
|
||||
state.is_loading = loading;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if autocomplete is currently active
|
||||
fn is_autocomplete_active(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_active)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if autocomplete is ready for interaction
|
||||
fn is_autocomplete_ready(&self) -> bool {
|
||||
self.autocomplete_state()
|
||||
.map(|state| state.is_ready())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// INTERNAL: Apply selected autocomplete value to current field
|
||||
fn apply_autocomplete_selection(&mut self) -> Option<String> {
|
||||
// First, get the selected value and display text (if any)
|
||||
let selection_info = if let Some(state) = self.autocomplete_state() {
|
||||
state.get_selected().map(|selected| {
|
||||
(selected.value_to_store.clone(), selected.display_text.clone())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Apply the selection if we have one
|
||||
if let Some((value, display)) = selection_info {
|
||||
// Apply the value to current field
|
||||
*self.get_current_input_mut() = value;
|
||||
self.set_has_unsaved_changes(true);
|
||||
|
||||
// Deactivate autocomplete
|
||||
if let Some(state_mut) = self.autocomplete_state_mut() {
|
||||
state_mut.deactivate();
|
||||
}
|
||||
|
||||
Some(format!("Selected: {}", display))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// --- LEGACY AUTOCOMPLETE SUPPORT (for backwards compatibility) ---
|
||||
|
||||
/// Legacy suggestion support (deprecated - use autocomplete_state instead)
|
||||
fn get_suggestions(&self) -> Option<&[String]> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Legacy selected suggestion index (deprecated)
|
||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
fn set_selected_suggestion_index(&mut self, _index: Option<usize>) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
}
|
||||
fn activate_suggestions(&mut self, _suggestions: Vec<String>) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
}
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
// Default: no-op (override if you support suggestions)
|
||||
self.autocomplete_state()
|
||||
.and_then(|state| state.selected_index)
|
||||
}
|
||||
|
||||
// --- Feature-specific action handling (NEW: Type-safe) ---
|
||||
/// Legacy suggestion index setter (deprecated)
|
||||
fn set_selected_suggestion_index(&mut self, _index: Option<usize>) {
|
||||
// Deprecated - canvas manages selection internally
|
||||
}
|
||||
|
||||
/// Legacy activate suggestions (deprecated)
|
||||
fn activate_suggestions(&mut self, _suggestions: Vec<String>) {
|
||||
// Deprecated - use set_autocomplete_suggestions instead
|
||||
}
|
||||
|
||||
/// Legacy deactivate suggestions (deprecated)
|
||||
fn deactivate_suggestions(&mut self) {
|
||||
self.deactivate_autocomplete();
|
||||
}
|
||||
|
||||
// --- Feature-specific action handling ---
|
||||
|
||||
/// Feature-specific action handling (NEW: Type-safe)
|
||||
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||
None // Default: no feature-specific handling
|
||||
}
|
||||
|
||||
// --- Legacy string-based action handling (for backwards compatibility) ---
|
||||
/// Legacy string-based action handling (for backwards compatibility)
|
||||
fn handle_feature_action_legacy(&mut self, action: &str, context: &ActionContext) -> Option<String> {
|
||||
// Convert string to typed action and delegate
|
||||
let typed_action = match action {
|
||||
@@ -71,12 +176,14 @@ pub trait CanvasState {
|
||||
}
|
||||
|
||||
// --- Display Overrides (for links, computed values, etc.) ---
|
||||
|
||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||
self.inputs()
|
||||
.get(index)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn has_display_override(&self, _index: usize) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user