autocomplete is now robust, but unified, time to split it up
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -482,6 +482,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"toml",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -50,5 +50,6 @@ regex = "1.11.1"
|
||||
ratatui = { version = "0.29.0", features = ["crossterm"] }
|
||||
crossterm = "0.28.1"
|
||||
toml = "0.8.20"
|
||||
unicode-width = "0.2.0"
|
||||
|
||||
common = { path = "./common" }
|
||||
|
||||
@@ -17,6 +17,7 @@ anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
unicode-width.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4.4"
|
||||
|
||||
193
canvas/src/gui/autocomplete.rs
Normal file
193
canvas/src/gui/autocomplete.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
// canvas/src/gui/autocomplete.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::autocomplete::AutocompleteState;
|
||||
use super::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Render autocomplete dropdown - call this AFTER rendering canvas
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_autocomplete_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
if !autocomplete_state.is_active {
|
||||
return;
|
||||
}
|
||||
|
||||
if autocomplete_state.is_loading {
|
||||
render_loading_indicator(f, frame_area, input_rect, theme);
|
||||
} else if !autocomplete_state.suggestions.is_empty() {
|
||||
render_suggestions_dropdown(f, frame_area, input_rect, theme, autocomplete_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show loading spinner/text
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_loading_indicator<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
) {
|
||||
let loading_text = "Loading suggestions...";
|
||||
let loading_width = loading_text.width() as u16 + 4; // +4 for borders and padding
|
||||
let loading_height = 3;
|
||||
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
loading_width,
|
||||
loading_height,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// Show actual suggestions list
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_suggestions_dropdown<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
frame_area: Rect,
|
||||
input_rect: Rect,
|
||||
theme: &T,
|
||||
autocomplete_state: &AutocompleteState<impl Clone + Send + 'static>,
|
||||
) {
|
||||
let display_texts: Vec<&str> = autocomplete_state.suggestions
|
||||
.iter()
|
||||
.map(|item| item.display_text.as_str())
|
||||
.collect();
|
||||
|
||||
let dropdown_dimensions = calculate_dropdown_dimensions(&display_texts);
|
||||
let dropdown_area = calculate_dropdown_position(
|
||||
input_rect,
|
||||
frame_area,
|
||||
dropdown_dimensions.width,
|
||||
dropdown_dimensions.height,
|
||||
);
|
||||
|
||||
// Background
|
||||
let dropdown_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.accent()))
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
// List items
|
||||
let items = create_suggestion_list_items(
|
||||
&display_texts,
|
||||
autocomplete_state.selected_index,
|
||||
dropdown_dimensions.width,
|
||||
theme,
|
||||
);
|
||||
|
||||
let list = List::new(items).block(dropdown_block);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(autocomplete_state.selected_index);
|
||||
|
||||
f.render_stateful_widget(list, dropdown_area, &mut list_state);
|
||||
}
|
||||
|
||||
/// Calculate dropdown size based on suggestions
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_dimensions(display_texts: &[&str]) -> DropdownDimensions {
|
||||
let max_width = display_texts
|
||||
.iter()
|
||||
.map(|text| text.width())
|
||||
.max()
|
||||
.unwrap_or(0) as u16;
|
||||
|
||||
let horizontal_padding = 4; // borders + padding
|
||||
let width = (max_width + horizontal_padding).max(12);
|
||||
let height = (display_texts.len() as u16).min(8) + 2; // max 8 visible items + borders
|
||||
|
||||
DropdownDimensions { width, height }
|
||||
}
|
||||
|
||||
/// Position dropdown to stay in bounds
|
||||
#[cfg(feature = "gui")]
|
||||
fn calculate_dropdown_position(
|
||||
input_rect: Rect,
|
||||
frame_area: Rect,
|
||||
dropdown_width: u16,
|
||||
dropdown_height: u16,
|
||||
) -> Rect {
|
||||
let mut dropdown_area = Rect {
|
||||
x: input_rect.x,
|
||||
y: input_rect.y + 1, // below input field
|
||||
width: dropdown_width,
|
||||
height: dropdown_height,
|
||||
};
|
||||
|
||||
// Keep in 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);
|
||||
|
||||
dropdown_area
|
||||
}
|
||||
|
||||
/// Create styled list items
|
||||
#[cfg(feature = "gui")]
|
||||
fn create_suggestion_list_items<'a, T: CanvasTheme>(
|
||||
display_texts: &'a [&'a str],
|
||||
selected_index: Option<usize>,
|
||||
dropdown_width: u16,
|
||||
theme: &T,
|
||||
) -> Vec<ListItem<'a>> {
|
||||
let horizontal_padding = 4;
|
||||
let available_width = dropdown_width.saturating_sub(horizontal_padding);
|
||||
|
||||
display_texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| {
|
||||
let is_selected = selected_index == Some(i);
|
||||
let text_width = text.width() as u16;
|
||||
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()
|
||||
}
|
||||
|
||||
/// Helper struct for dropdown dimensions
|
||||
#[cfg(feature = "gui")]
|
||||
struct DropdownDimensions {
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
336
canvas/src/gui/canvas.rs
Normal file
336
canvas/src/gui/canvas.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
// canvas/src/gui/canvas.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, BorderType, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::state::CanvasState;
|
||||
use crate::modes::HighlightState;
|
||||
use super::theme::CanvasTheme;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use std::cmp::{max, min};
|
||||
|
||||
/// Render ONLY the canvas form fields - no autocomplete
|
||||
#[cfg(feature = "gui")]
|
||||
pub fn render_canvas<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
|
||||
render_canvas_fields(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
¤t_field_idx,
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
)
|
||||
}
|
||||
|
||||
/// Core canvas field rendering
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
// Create layout
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
// Border style based on state
|
||||
let border_style = if has_unsaved_changes {
|
||||
Style::default().fg(theme.warning())
|
||||
} else if is_edit_mode {
|
||||
Style::default().fg(theme.accent())
|
||||
} else {
|
||||
Style::default().fg(theme.secondary())
|
||||
};
|
||||
|
||||
// Input container
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
// Input area layout
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
// Render field labels
|
||||
render_field_labels(f, columns[0], input_block, fields, theme);
|
||||
|
||||
// Render field values and return active field rect
|
||||
render_field_values(
|
||||
f,
|
||||
input_rows.to_vec(), // Fix: Convert Rc<[Rect]> to Vec<Rect>
|
||||
inputs,
|
||||
current_field_idx,
|
||||
theme,
|
||||
highlight_state,
|
||||
current_cursor_pos,
|
||||
get_display_value,
|
||||
has_display_override,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render field labels
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_labels<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
label_area: Rect,
|
||||
input_block: Rect,
|
||||
fields: &[&str],
|
||||
theme: &T,
|
||||
) {
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg()),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: label_area.x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: label_area.width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render field values with highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn render_field_values<T: CanvasTheme, F1, F2>(
|
||||
f: &mut Frame,
|
||||
input_rows: Vec<Rect>,
|
||||
inputs: &[&String],
|
||||
current_field_idx: &usize,
|
||||
theme: &T,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
let text = get_display_value(i);
|
||||
|
||||
// Apply highlighting
|
||||
let line = apply_highlighting(
|
||||
&text,
|
||||
i,
|
||||
current_field_idx,
|
||||
current_cursor_pos,
|
||||
highlight_state,
|
||||
theme,
|
||||
is_active,
|
||||
);
|
||||
|
||||
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
// Set cursor for active field
|
||||
if is_active {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
|
||||
}
|
||||
}
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
|
||||
/// Apply highlighting based on highlight state
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
highlight_state: &HighlightState,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let text_len = text.chars().count();
|
||||
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight())
|
||||
} else {
|
||||
Style::default().fg(theme.fg())
|
||||
},
|
||||
))
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply characterwise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
text_len: usize,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
current_cursor_pos: usize,
|
||||
anchor: &(usize, usize),
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
if start_field == end_field {
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
(anchor_char, current_cursor_pos)
|
||||
} else {
|
||||
(current_cursor_pos, anchor_char)
|
||||
};
|
||||
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars()
|
||||
.skip(clamped_start)
|
||||
.take(clamped_end.saturating_sub(clamped_start) + 1)
|
||||
.collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
])
|
||||
} else {
|
||||
// Multi-field selection
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply linewise highlighting
|
||||
#[cfg(feature = "gui")]
|
||||
fn apply_linewise_highlighting<'a, T: CanvasTheme>(
|
||||
text: &'a str,
|
||||
field_index: usize,
|
||||
current_field_idx: &usize,
|
||||
anchor_line: &usize,
|
||||
theme: &T,
|
||||
is_active: bool,
|
||||
) -> Line<'a> {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if field_index >= start_field && field_index <= end_field {
|
||||
Line::from(Span::styled(text, highlight_style))
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cursor position
|
||||
#[cfg(feature = "gui")]
|
||||
fn set_cursor_position(
|
||||
f: &mut Frame,
|
||||
field_rect: Rect,
|
||||
text: &str,
|
||||
current_cursor_pos: usize,
|
||||
has_display_override: bool,
|
||||
) {
|
||||
let cursor_x = if has_display_override {
|
||||
field_rect.x + text.chars().count() as u16
|
||||
} else {
|
||||
field_rect.x + current_cursor_pos as u16
|
||||
};
|
||||
let cursor_y = field_rect.y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
// canvas/src/gui/mod.rs
|
||||
|
||||
pub mod theme;
|
||||
pub mod render;
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod canvas;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod autocomplete;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub mod theme;
|
||||
|
||||
// Export the separate rendering functions
|
||||
#[cfg(feature = "gui")]
|
||||
pub use canvas::render_canvas;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use autocomplete::render_autocomplete_dropdown;
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
pub use theme::CanvasTheme;
|
||||
pub use render::render_canvas;
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
// canvas/src/gui/render.rs
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, BorderType, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use crate::state::CanvasState;
|
||||
use crate::modes::HighlightState;
|
||||
#[cfg(feature = "gui")]
|
||||
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")]
|
||||
pub fn render_canvas<T: CanvasTheme>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
form_state: &impl CanvasState,
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
) -> Option<Rect> {
|
||||
let fields: Vec<&str> = form_state.fields();
|
||||
let current_field_idx = form_state.current_field();
|
||||
let inputs: Vec<&String> = form_state.inputs();
|
||||
|
||||
let active_field_rect = render_canvas_impl(
|
||||
f,
|
||||
area,
|
||||
&fields,
|
||||
¤t_field_idx,
|
||||
&inputs,
|
||||
theme,
|
||||
is_edit_mode,
|
||||
highlight_state,
|
||||
form_state.current_cursor_pos(),
|
||||
form_state.has_unsaved_changes(),
|
||||
|i| form_state.get_display_value_for_field(i).to_string(),
|
||||
|i| form_state.has_display_override(i),
|
||||
);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// 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,
|
||||
area: Rect,
|
||||
fields: &[&str],
|
||||
current_field_idx: &usize,
|
||||
inputs: &[&String],
|
||||
theme: &T,
|
||||
is_edit_mode: bool,
|
||||
highlight_state: &HighlightState,
|
||||
current_cursor_pos: usize,
|
||||
has_unsaved_changes: bool,
|
||||
get_display_value: F1,
|
||||
has_display_override: F2,
|
||||
) -> Option<Rect>
|
||||
where
|
||||
F1: Fn(usize) -> String,
|
||||
F2: Fn(usize) -> bool,
|
||||
{
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||
.split(area);
|
||||
|
||||
let border_style = if has_unsaved_changes {
|
||||
Style::default().fg(theme.warning())
|
||||
} else if is_edit_mode {
|
||||
Style::default().fg(theme.accent())
|
||||
} else {
|
||||
Style::default().fg(theme.secondary())
|
||||
};
|
||||
|
||||
let input_container = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(theme.bg()));
|
||||
|
||||
let input_block = Rect {
|
||||
x: columns[1].x,
|
||||
y: columns[1].y,
|
||||
width: columns[1].width,
|
||||
height: fields.len() as u16 + 2,
|
||||
};
|
||||
|
||||
f.render_widget(&input_container, input_block);
|
||||
|
||||
let input_area = input_container.inner(input_block);
|
||||
let input_rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(vec![Constraint::Length(1); fields.len()])
|
||||
.split(input_area);
|
||||
|
||||
let mut active_field_input_rect = None;
|
||||
|
||||
// Render field labels
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
let label = Paragraph::new(Line::from(Span::styled(
|
||||
format!("{}:", field),
|
||||
Style::default().fg(theme.fg()),
|
||||
)));
|
||||
f.render_widget(
|
||||
label,
|
||||
Rect {
|
||||
x: columns[0].x,
|
||||
y: input_block.y + 1 + i as u16,
|
||||
width: columns[0].width,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Render field values
|
||||
for (i, _input) in inputs.iter().enumerate() {
|
||||
let is_active = i == *current_field_idx;
|
||||
|
||||
// Use the provided closure to get display value
|
||||
let text = get_display_value(i);
|
||||
let text_len = text.chars().count();
|
||||
let line: Line;
|
||||
|
||||
match highlight_state {
|
||||
HighlightState::Off => {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active {
|
||||
Style::default().fg(theme.highlight())
|
||||
} else {
|
||||
Style::default().fg(theme.fg())
|
||||
},
|
||||
));
|
||||
}
|
||||
HighlightState::Characterwise { anchor } => {
|
||||
let (anchor_field, anchor_char) = *anchor;
|
||||
let start_field = min(anchor_field, *current_field_idx);
|
||||
let end_field = max(anchor_field, *current_field_idx);
|
||||
|
||||
let (start_char, end_char) = if anchor_field == *current_field_idx {
|
||||
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
|
||||
} else if anchor_field < *current_field_idx {
|
||||
(anchor_char, current_cursor_pos)
|
||||
} else {
|
||||
(current_cursor_pos, anchor_char)
|
||||
};
|
||||
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
if start_field == end_field {
|
||||
let clamped_start = start_char.min(text_len);
|
||||
let clamped_end = end_char.min(text_len);
|
||||
|
||||
let before: String = text.chars().take(clamped_start).collect();
|
||||
let highlighted: String = text.chars()
|
||||
.skip(clamped_start)
|
||||
.take(clamped_end.saturating_sub(clamped_start) + 1)
|
||||
.collect();
|
||||
let after: String = text.chars().skip(clamped_end + 1).collect();
|
||||
|
||||
line = Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else if i == start_field {
|
||||
let safe_start = start_char.min(text_len);
|
||||
let before: String = text.chars().take(safe_start).collect();
|
||||
let highlighted: String = text.chars().skip(safe_start).collect();
|
||||
line = Line::from(vec![
|
||||
Span::styled(before, normal_style_in_highlight),
|
||||
Span::styled(highlighted, highlight_style),
|
||||
]);
|
||||
} else if i == end_field {
|
||||
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
|
||||
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
|
||||
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
|
||||
line = Line::from(vec![
|
||||
Span::styled(highlighted, highlight_style),
|
||||
Span::styled(after, normal_style_in_highlight),
|
||||
]);
|
||||
} else {
|
||||
line = Line::from(Span::styled(&text, highlight_style));
|
||||
}
|
||||
} else {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
HighlightState::Linewise { anchor_line } => {
|
||||
let start_field = min(*anchor_line, *current_field_idx);
|
||||
let end_field = max(*anchor_line, *current_field_idx);
|
||||
let highlight_style = Style::default()
|
||||
.fg(theme.highlight())
|
||||
.bg(theme.highlight_bg())
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal_style_in_highlight = Style::default().fg(theme.highlight());
|
||||
let normal_style_outside = Style::default().fg(theme.fg());
|
||||
|
||||
if i >= start_field && i <= end_field {
|
||||
line = Line::from(Span::styled(&text, highlight_style));
|
||||
} else {
|
||||
line = Line::from(Span::styled(
|
||||
&text,
|
||||
if is_active { normal_style_in_highlight } else { normal_style_outside }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let input_display = Paragraph::new(line).alignment(Alignment::Left);
|
||||
f.render_widget(input_display, input_rows[i]);
|
||||
|
||||
if is_active {
|
||||
active_field_input_rect = Some(input_rows[i]);
|
||||
|
||||
// Use the provided closure to check for display override
|
||||
let cursor_x = if has_display_override(i) {
|
||||
// If an override exists, place the cursor at the end.
|
||||
input_rows[i].x + text.chars().count() as u16
|
||||
} else {
|
||||
// Otherwise, use the real cursor position.
|
||||
input_rows[i].x + current_cursor_pos as u16
|
||||
};
|
||||
let cursor_y = input_rows[i].y;
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
active_field_input_rect
|
||||
}
|
||||
@@ -27,7 +27,7 @@ tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2.0"
|
||||
unicode-width.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
Reference in New Issue
Block a user