compiled autocomplete
This commit is contained in:
@@ -23,8 +23,8 @@ pub async fn execute_canvas_action<S: CanvasState>(
|
|||||||
return Ok(ActionResult::HandledByFeature(result));
|
return Ok(ActionResult::HandledByFeature(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle suggestion actions
|
// 2. Handle autocomplete actions
|
||||||
if let Some(result) = handle_suggestion_action(&action, state)? {
|
if let Some(result) = handle_autocomplete_action(&action, state)? {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,47 +56,62 @@ pub async fn execute_edit_action<S: CanvasState>(
|
|||||||
Ok(result.message().unwrap_or("").to_string())
|
Ok(result.message().unwrap_or("").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle suggestion-related actions
|
/// Handle autocomplete-related actions
|
||||||
fn handle_suggestion_action<S: CanvasState>(
|
fn handle_autocomplete_action<S: CanvasState>(
|
||||||
action: &CanvasAction,
|
action: &CanvasAction,
|
||||||
state: &mut S,
|
state: &mut S,
|
||||||
) -> Result<Option<ActionResult>> {
|
) -> Result<Option<ActionResult>> {
|
||||||
match action {
|
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 => {
|
CanvasAction::SuggestionDown => {
|
||||||
if let Some(suggestions) = state.get_suggestions() {
|
if state.is_autocomplete_ready() {
|
||||||
if !suggestions.is_empty() {
|
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
autocomplete_state.select_next();
|
||||||
let next = (current + 1) % suggestions.len();
|
|
||||||
state.set_selected_suggestion_index(Some(next));
|
|
||||||
return Ok(Some(ActionResult::success()));
|
return Ok(Some(ActionResult::success()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None) // Not handled - no active autocomplete
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::SuggestionUp => {
|
CanvasAction::SuggestionUp => {
|
||||||
if let Some(suggestions) = state.get_suggestions() {
|
if state.is_autocomplete_ready() {
|
||||||
if !suggestions.is_empty() {
|
if let Some(autocomplete_state) = state.autocomplete_state_mut() {
|
||||||
let current = state.get_selected_suggestion_index().unwrap_or(0);
|
autocomplete_state.select_previous();
|
||||||
let prev = if current == 0 { suggestions.len() - 1 } else { current - 1 };
|
|
||||||
state.set_selected_suggestion_index(Some(prev));
|
|
||||||
return Ok(Some(ActionResult::success()));
|
return Ok(Some(ActionResult::success()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None) // Not handled - no active autocomplete
|
||||||
}
|
}
|
||||||
|
|
||||||
CanvasAction::SelectSuggestion => {
|
CanvasAction::SelectSuggestion => {
|
||||||
// Let feature handle this via handle_feature_action since it's feature-specific
|
if state.is_autocomplete_ready() {
|
||||||
Ok(None)
|
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 => {
|
CanvasAction::ExitSuggestions => {
|
||||||
state.deactivate_suggestions();
|
if state.is_autocomplete_active() {
|
||||||
Ok(Some(ActionResult::success()))
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,15 +336,15 @@ async fn handle_generic_canvas_action<S: CanvasState>(
|
|||||||
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
Ok(ActionResult::error(format!("Unknown or unhandled custom action: {}", action_str)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggestion actions should have been handled above
|
// Autocomplete actions should have been handled above
|
||||||
CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
CanvasAction::TriggerAutocomplete | CanvasAction::SuggestionUp | CanvasAction::SuggestionDown |
|
||||||
CanvasAction::SelectSuggestion | CanvasAction::ExitSuggestions => {
|
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)]
|
#[derive(PartialEq)]
|
||||||
enum CharType {
|
enum CharType {
|
||||||
|
|||||||
@@ -34,10 +34,16 @@ pub enum CanvasAction {
|
|||||||
NextField,
|
NextField,
|
||||||
PrevField,
|
PrevField,
|
||||||
|
|
||||||
// Suggestions
|
// AUTOCOMPLETE ACTIONS (NEW)
|
||||||
|
/// Manually trigger autocomplete for current field
|
||||||
|
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 (escape hatch for feature-specific behavior)
|
||||||
@@ -69,6 +75,8 @@ impl CanvasAction {
|
|||||||
"move_word_end_prev" => Self::MoveWordEndPrev,
|
"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,
|
||||||
"suggestion_up" => Self::SuggestionUp,
|
"suggestion_up" => Self::SuggestionUp,
|
||||||
"suggestion_down" => Self::SuggestionDown,
|
"suggestion_down" => Self::SuggestionDown,
|
||||||
"select_suggestion" => Self::SelectSuggestion,
|
"select_suggestion" => Self::SelectSuggestion,
|
||||||
@@ -97,6 +105,8 @@ impl CanvasAction {
|
|||||||
Self::MoveWordEndPrev => "move_word_end_prev",
|
Self::MoveWordEndPrev => "move_word_end_prev",
|
||||||
Self::NextField => "next_field",
|
Self::NextField => "next_field",
|
||||||
Self::PrevField => "prev_field",
|
Self::PrevField => "prev_field",
|
||||||
|
// Autocomplete actions
|
||||||
|
Self::TriggerAutocomplete => "trigger_autocomplete",
|
||||||
Self::SuggestionUp => "suggestion_up",
|
Self::SuggestionUp => "suggestion_up",
|
||||||
Self::SuggestionDown => "suggestion_down",
|
Self::SuggestionDown => "suggestion_down",
|
||||||
Self::SelectSuggestion => "select_suggestion",
|
Self::SelectSuggestion => "select_suggestion",
|
||||||
@@ -146,7 +156,7 @@ impl CanvasAction {
|
|||||||
/// Check if this is a suggestion-related action
|
/// Check if this is a suggestion-related action
|
||||||
pub fn is_suggestion(&self) -> bool {
|
pub fn is_suggestion(&self) -> bool {
|
||||||
matches!(self,
|
matches!(self,
|
||||||
Self::SuggestionUp | Self::SuggestionDown |
|
Self::TriggerAutocomplete | Self::SuggestionUp | Self::SuggestionDown |
|
||||||
Self::SelectSuggestion | Self::ExitSuggestions
|
Self::SelectSuggestion | Self::ExitSuggestions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -200,6 +210,7 @@ mod tests {
|
|||||||
fn test_action_from_string() {
|
fn test_action_from_string() {
|
||||||
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
|
assert_eq!(CanvasAction::from_string("move_left"), CanvasAction::MoveLeft);
|
||||||
assert_eq!(CanvasAction::from_string("delete_char_backward"), CanvasAction::DeleteBackward);
|
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()));
|
assert_eq!(CanvasAction::from_string("unknown"), CanvasAction::Custom("unknown".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +231,7 @@ mod tests {
|
|||||||
assert!(!CanvasAction::InsertChar('a').is_movement());
|
assert!(!CanvasAction::InsertChar('a').is_movement());
|
||||||
|
|
||||||
assert!(CanvasAction::SuggestionUp.is_suggestion());
|
assert!(CanvasAction::SuggestionUp.is_suggestion());
|
||||||
|
assert!(CanvasAction::TriggerAutocomplete.is_suggestion());
|
||||||
assert!(!CanvasAction::MoveLeft.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},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Modifier, Style},
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, BorderType, Paragraph},
|
widgets::{Block, Borders, BorderType, List, ListItem, ListState, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use crate::state::CanvasState;
|
use crate::state::CanvasState;
|
||||||
@@ -14,6 +14,8 @@ use crate::modes::HighlightState;
|
|||||||
use super::theme::CanvasTheme;
|
use super::theme::CanvasTheme;
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
use std::cmp::{max, min};
|
use std::cmp::{max, min};
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
/// Render canvas using the CanvasState trait and CanvasTheme
|
/// Render canvas using the CanvasState trait and CanvasTheme
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
@@ -29,7 +31,7 @@ pub fn render_canvas<T: CanvasTheme>(
|
|||||||
let current_field_idx = form_state.current_field();
|
let current_field_idx = form_state.current_field();
|
||||||
let inputs: Vec<&String> = form_state.inputs();
|
let inputs: Vec<&String> = form_state.inputs();
|
||||||
|
|
||||||
render_canvas_impl(
|
let active_field_rect = render_canvas_impl(
|
||||||
f,
|
f,
|
||||||
area,
|
area,
|
||||||
&fields,
|
&fields,
|
||||||
@@ -42,10 +44,137 @@ pub fn render_canvas<T: CanvasTheme>(
|
|||||||
form_state.has_unsaved_changes(),
|
form_state.has_unsaved_changes(),
|
||||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||||
|i| form_state.has_display_override(i),
|
|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")]
|
#[cfg(feature = "gui")]
|
||||||
fn render_canvas_impl<T: CanvasTheme, F1, F2>(
|
fn render_canvas_impl<T: CanvasTheme, F1, F2>(
|
||||||
f: &mut Frame,
|
f: &mut Frame,
|
||||||
|
|||||||
@@ -1,47 +1,25 @@
|
|||||||
// canvas/src/lib.rs
|
// 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 actions;
|
||||||
pub mod modes;
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod suggestions;
|
|
||||||
pub mod dispatcher;
|
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")]
|
#[cfg(feature = "gui")]
|
||||||
pub mod gui;
|
pub mod gui;
|
||||||
|
|
||||||
// Re-export the main types for easy use
|
// Re-export commonly used types
|
||||||
pub use state::{CanvasState, ActionContext};
|
pub use actions::{CanvasAction, ActionResult};
|
||||||
pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action};
|
|
||||||
pub use modes::{AppMode, ModeManager, HighlightState};
|
|
||||||
pub use suggestions::SuggestionState;
|
|
||||||
pub use dispatcher::ActionDispatcher;
|
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")]
|
#[cfg(feature = "gui")]
|
||||||
pub use gui::{CanvasTheme, render_canvas};
|
pub use gui::{render_canvas, CanvasTheme};
|
||||||
|
|
||||||
// High-level convenience API
|
// Keep backwards compatibility exports
|
||||||
pub mod prelude {
|
pub use suggestions::SuggestionState;
|
||||||
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};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// canvas/src/state.rs
|
// canvas/src/state.rs
|
||||||
|
|
||||||
use crate::actions::CanvasAction;
|
use crate::actions::CanvasAction;
|
||||||
|
use crate::autocomplete::{AutocompleteState, SuggestionItem};
|
||||||
|
|
||||||
/// Context passed to feature-specific action handlers
|
/// Context passed to feature-specific action handlers
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -31,29 +32,133 @@ pub trait CanvasState {
|
|||||||
fn has_unsaved_changes(&self) -> bool;
|
fn has_unsaved_changes(&self) -> bool;
|
||||||
fn set_has_unsaved_changes(&mut self, changed: 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]> {
|
fn get_suggestions(&self) -> Option<&[String]> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legacy selected suggestion index (deprecated)
|
||||||
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
fn get_selected_suggestion_index(&self) -> Option<usize> {
|
||||||
None
|
self.autocomplete_state()
|
||||||
}
|
.and_then(|state| state.selected_index)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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> {
|
fn handle_feature_action(&mut self, _action: &CanvasAction, _context: &ActionContext) -> Option<String> {
|
||||||
None // Default: no feature-specific handling
|
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> {
|
fn handle_feature_action_legacy(&mut self, action: &str, context: &ActionContext) -> Option<String> {
|
||||||
// Convert string to typed action and delegate
|
// Convert string to typed action and delegate
|
||||||
let typed_action = match action {
|
let typed_action = match action {
|
||||||
@@ -71,12 +176,14 @@ pub trait CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Display Overrides (for links, computed values, etc.) ---
|
// --- Display Overrides (for links, computed values, etc.) ---
|
||||||
|
|
||||||
fn get_display_value_for_field(&self, index: usize) -> &str {
|
fn get_display_value_for_field(&self, index: usize) -> &str {
|
||||||
self.inputs()
|
self.inputs()
|
||||||
.get(index)
|
.get(index)
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_display_override(&self, _index: usize) -> bool {
|
fn has_display_override(&self, _index: usize) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user