autocomplete now autocompleting data not just id

This commit is contained in:
filipriec
2025-06-16 11:54:54 +02:00
parent 8fcd28832d
commit 9e0fa9ddb1
5 changed files with 149 additions and 89 deletions

View File

@@ -140,12 +140,12 @@ pub fn render_rich_autocomplete_dropdown(
let display_part = values.first().cloned().unwrap_or_default(); // Get the first value let display_part = values.first().cloned().unwrap_or_default(); // Get the first value
if display_part.is_empty() { if display_part.is_empty() {
format!("ID: {}", hit.id) format!("{}", hit.id)
} else { } else {
format!("{} | ID: {}", display_part, hit.id) // ID at the end format!("{} | {}", display_part, hit.id) // ID at the end
} }
} else { } else {
format!("ID: {} (parse error)", hit.id) format!("{} (parse error)", hit.id)
} }
}) })
.collect(); .collect();

View File

@@ -1,16 +1,16 @@
// src/components/handlers/canvas.rs // src/components/handlers/canvas.rs
use ratatui::{ use ratatui::{
widgets::{Paragraph, Block, Borders}, layout::{Alignment, Constraint, Direction, Layout, Rect},
layout::{Layout, Constraint, Direction, Rect}, style::{Modifier, Style},
style::{Style, Modifier},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Frame,
prelude::Alignment,
}; };
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState; // Ensure correct import path use std::cmp::{max, min};
use std::cmp::{min, max};
pub fn render_canvas( pub fn render_canvas(
f: &mut Frame, f: &mut Frame,
@@ -21,9 +21,8 @@ pub fn render_canvas(
inputs: &[&String], inputs: &[&String],
theme: &Theme, theme: &Theme,
is_edit_mode: bool, is_edit_mode: bool,
highlight_state: &HighlightState, // Using the enum state highlight_state: &HighlightState,
) -> Option<Rect> { ) -> Option<Rect> {
// ... (setup code remains the same) ...
let columns = Layout::default() let columns = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
@@ -58,46 +57,47 @@ pub fn render_canvas(
let mut active_field_input_rect = None; let mut active_field_input_rect = None;
// Render labels
for (i, field) in fields.iter().enumerate() { for (i, field) in fields.iter().enumerate() {
let label = Paragraph::new(Line::from(Span::styled( let label = Paragraph::new(Line::from(Span::styled(
format!("{}:", field), format!("{}:", field),
Style::default().fg(theme.fg)), Style::default().fg(theme.fg),
)); )));
f.render_widget(label, Rect { f.render_widget(
label,
Rect {
x: columns[0].x, x: columns[0].x,
y: input_block.y + 1 + i as u16, y: input_block.y + 1 + i as u16,
width: columns[0].width, width: columns[0].width,
height: 1, height: 1,
}); },
);
} }
for (i, _input) in inputs.iter().enumerate() {
// Render inputs and cursor
for (i, input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx; let is_active = i == *current_field_idx;
let current_cursor_pos = form_state.current_cursor_pos(); let current_cursor_pos = form_state.current_cursor_pos();
let text = input.as_str();
let text_len = text.chars().count();
// Use the trait method to get display value
let text = form_state.get_display_value_for_field(i);
let text_len = text.chars().count();
let line: Line; let line: Line;
// --- Use match on the highlight_state enum ---
match highlight_state { match highlight_state {
HighlightState::Off => { HighlightState::Off => {
// Not in highlight mode, render normally
line = Line::from(Span::styled( line = Line::from(Span::styled(
text, text,
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) } if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
},
)); ));
} }
HighlightState::Characterwise { anchor } => { HighlightState::Characterwise { anchor } => {
// --- Character-wise Highlight Logic ---
let (anchor_field, anchor_char) = *anchor; let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx); let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx); let end_field = max(anchor_field, *current_field_idx);
// Use start_char and end_char consistently
let (start_char, end_char) = if 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)) (min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx { } else if anchor_field < *current_field_idx {
@@ -111,24 +111,20 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg); let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field { if i >= start_field && i <= end_field {
// This line is within the character-wise highlight range if start_field == end_field {
if start_field == end_field { // Case 1: Single Line Highlight
// Use start_char and end_char here
let clamped_start = start_char.min(text_len); let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic let clamped_end = end_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect(); 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 highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
// Define 'after' here
let after: String = text.chars().skip(clamped_end + 1).collect(); let after: String = text.chars().skip(clamped_end + 1).collect();
line = Line::from(vec![ line = Line::from(vec![
Span::styled(before, normal_style_in_highlight), Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style), Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight), // Use defined 'after' Span::styled(after, normal_style_in_highlight),
]); ]);
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line } else if i == start_field {
// Use start_char here
let safe_start = start_char.min(text_len); let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect(); let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect(); let highlighted: String = text.chars().skip(safe_start).collect();
@@ -136,8 +132,7 @@ pub fn render_canvas(
Span::styled(before, normal_style_in_highlight), Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style), Span::styled(highlighted, highlight_style),
]); ]);
} else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index) } else if i == end_field {
// Use end_char here
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 }; 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 highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect(); let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
@@ -145,19 +140,17 @@ pub fn render_canvas(
Span::styled(highlighted, highlight_style), Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight), Span::styled(after, normal_style_in_highlight),
]); ]);
} else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index) } else {
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line line = Line::from(Span::styled(text, highlight_style));
} }
} else { // Case 5: Line Outside Character-wise Highlight Range } else {
line = Line::from(Span::styled( line = Line::from(Span::styled(
text, text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside } if is_active { normal_style_in_highlight } else { normal_style_outside }
)); ));
} }
} }
HighlightState::Linewise { anchor_line } => { HighlightState::Linewise { anchor_line } => {
// --- Linewise Highlight Logic ---
let start_field = min(*anchor_line, *current_field_idx); let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*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 highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
@@ -165,25 +158,31 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg); let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field { if i >= start_field && i <= end_field {
// Highlight the entire line
line = Line::from(Span::styled(text, highlight_style)); line = Line::from(Span::styled(text, highlight_style));
} else { } else {
// Line outside linewise highlight range
line = Line::from(Span::styled( line = Line::from(Span::styled(
text, text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside } if is_active { normal_style_in_highlight } else { normal_style_outside }
)); ));
} }
} }
} // End match highlight_state }
let input_display = Paragraph::new(line).alignment(Alignment::Left); let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]); f.render_widget(input_display, input_rows[i]);
if is_active { if is_active {
active_field_input_rect = Some(input_rows[i]); active_field_input_rect = Some(input_rows[i]);
let cursor_x = input_rows[i].x + form_state.current_cursor_pos() as u16;
// --- CORRECTED CURSOR POSITIONING LOGIC ---
// Use the new generic trait method to check for an override.
let cursor_x = if form_state.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 + form_state.current_cursor_pos() as u16
};
let cursor_y = input_rows[i].y; let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y)); f.set_cursor_position((cursor_x, cursor_y));
} }
@@ -191,4 +190,3 @@ pub fn render_canvas(
active_field_input_rect active_field_input_rect
} }

View File

@@ -132,13 +132,24 @@ pub async fn handle_edit_event(
.get(selected_idx) .get(selected_idx)
.cloned() .cloned()
{ {
// --- THIS IS THE CORE LOGIC CHANGE ---
// 1. Get the friendly display name for the UI
let display_name =
form_state.get_display_name_for_hit(&selection);
// 2. Store the REAL ID in the form's values
let current_input = let current_input =
form_state.get_current_input_mut(); form_state.get_current_input_mut();
*current_input = selection.id.to_string(); *current_input = selection.id.to_string();
let new_cursor_pos = current_input.len();
form_state.set_current_cursor_pos(new_cursor_pos); // 3. Set the persistent display override in the map
// FIX: Access ideal_cursor_column through event_handler form_state.link_display_map.insert(
event_handler.ideal_cursor_column = new_cursor_pos; form_state.current_field,
display_name,
);
// 4. Finalize state
form_state.deactivate_autocomplete(); form_state.deactivate_autocomplete();
form_state.set_has_unsaved_changes(true); form_state.set_has_unsaved_changes(true);
return Ok(EditEventOutcome::Message( return Ok(EditEventOutcome::Message(

View File

@@ -3,6 +3,7 @@
use common::proto::multieko2::search::search_response::Hit; use common::proto::multieko2::search::search_response::Hit;
pub trait CanvasState { pub trait CanvasState {
// --- Existing methods (unchanged) ---
fn current_field(&self) -> usize; fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize; fn current_cursor_pos(&self) -> usize;
fn has_unsaved_changes(&self) -> bool; fn has_unsaved_changes(&self) -> bool;
@@ -10,15 +11,22 @@ pub trait CanvasState {
fn get_current_input(&self) -> &str; fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String; fn get_current_input_mut(&mut self) -> &mut String;
fn fields(&self) -> Vec<&str>; fn fields(&self) -> Vec<&str>;
fn set_current_field(&mut self, index: usize); fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize); fn set_current_cursor_pos(&mut self, pos: usize);
fn set_has_unsaved_changes(&mut self, changed: bool); fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Autocomplete Support ---
fn get_suggestions(&self) -> Option<&[String]>; fn get_suggestions(&self) -> Option<&[String]>;
fn get_selected_suggestion_index(&self) -> Option<usize>; fn get_selected_suggestion_index(&self) -> Option<usize>;
fn get_rich_suggestions(&self) -> Option<&[Hit]> { fn get_rich_suggestions(&self) -> Option<&[Hit]> {
None None
} }
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, _index: usize) -> bool {
false
}
} }

View File

@@ -3,12 +3,20 @@
use crate::config::colors::themes::Theme; use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState; use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState; use crate::state::pages::canvas_state::CanvasState;
use common::proto::multieko2::search::search_response::Hit; // Import Hit use common::proto::multieko2::search::search_response::Hit;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::Frame; use ratatui::Frame;
use std::collections::HashMap; use std::collections::HashMap;
// A struct to bridge the display name (label) to the data key from the server. fn json_value_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => String::new(),
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FieldDefinition { pub struct FieldDefinition {
pub display_name: String, pub display_name: String,
@@ -29,12 +37,11 @@ pub struct FormState {
pub current_field: usize, pub current_field: usize,
pub has_unsaved_changes: bool, pub has_unsaved_changes: bool,
pub current_cursor_pos: usize, pub current_cursor_pos: usize,
// --- MODIFIED AUTOCOMPLETE STATE ---
pub autocomplete_active: bool, pub autocomplete_active: bool,
pub autocomplete_suggestions: Vec<Hit>, // Changed to use the Hit struct pub autocomplete_suggestions: Vec<Hit>,
pub selected_suggestion_index: Option<usize>, pub selected_suggestion_index: Option<usize>,
pub autocomplete_loading: bool, // To show a loading indicator pub autocomplete_loading: bool,
pub link_display_map: HashMap<usize, String>,
} }
impl FormState { impl FormState {
@@ -55,11 +62,48 @@ impl FormState {
current_field: 0, current_field: 0,
has_unsaved_changes: false, has_unsaved_changes: false,
current_cursor_pos: 0, current_cursor_pos: 0,
// --- INITIALIZE NEW STATE ---
autocomplete_active: false, autocomplete_active: false,
autocomplete_suggestions: Vec::new(), autocomplete_suggestions: Vec::new(),
selected_suggestion_index: None, selected_suggestion_index: None,
autocomplete_loading: false, // Initialize loading state autocomplete_loading: false,
link_display_map: HashMap::new(),
}
}
pub fn get_display_name_for_hit(&self, hit: &Hit) -> String {
if let Ok(content_map) =
serde_json::from_str::<HashMap<String, serde_json::Value>>(
&hit.content_json,
)
{
const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"];
let mut keys: Vec<_> = content_map
.keys()
.filter(|k| !IGNORED_KEYS.contains(&k.as_str()))
.cloned()
.collect();
keys.sort();
let values: Vec<_> = keys
.iter()
.map(|key| {
content_map
.get(key)
.map(json_value_to_string)
.unwrap_or_default()
})
.filter(|s| !s.is_empty())
.take(1)
.collect();
let display_part = values.first().cloned().unwrap_or_default();
if display_part.is_empty() {
format!("ID: {}", hit.id)
} else {
format!("{} | ID: {}", display_part, hit.id)
}
} else {
format!("ID: {} (parse error)", hit.id)
} }
} }
@@ -78,7 +122,7 @@ impl FormState {
crate::components::form::form::render_form( crate::components::form::form::render_form(
f, f,
area, area,
self, // <--- This now correctly passes the concrete &FormState self,
&fields_str_slice, &fields_str_slice,
&self.current_field, &self.current_field,
&values_str_slice, &values_str_slice,
@@ -102,7 +146,8 @@ impl FormState {
} else { } else {
self.current_position = 1; self.current_position = 1;
} }
self.deactivate_autocomplete(); // Deactivate on reset self.deactivate_autocomplete();
self.link_display_map.clear();
} }
pub fn get_current_input(&self) -> &str { pub fn get_current_input(&self) -> &str {
@@ -113,6 +158,7 @@ impl FormState {
} }
pub fn get_current_input_mut(&mut self) -> &mut String { pub fn get_current_input_mut(&mut self) -> &mut String {
self.link_display_map.remove(&self.current_field);
self.values self.values
.get_mut(self.current_field) .get_mut(self.current_field)
.expect("Invalid current_field index") .expect("Invalid current_field index")
@@ -159,11 +205,10 @@ impl FormState {
self.has_unsaved_changes = false; self.has_unsaved_changes = false;
self.current_field = 0; self.current_field = 0;
self.current_cursor_pos = 0; self.current_cursor_pos = 0;
self.deactivate_autocomplete(); // Deactivate on update self.deactivate_autocomplete();
self.link_display_map.clear();
} }
// --- NEW HELPER METHOD ---
/// Deactivates autocomplete and clears its state.
pub fn deactivate_autocomplete(&mut self) { pub fn deactivate_autocomplete(&mut self) {
self.autocomplete_active = false; self.autocomplete_active = false;
self.autocomplete_suggestions.clear(); self.autocomplete_suggestions.clear();
@@ -176,58 +221,42 @@ impl CanvasState for FormState {
fn current_field(&self) -> usize { fn current_field(&self) -> usize {
self.current_field self.current_field
} }
fn current_cursor_pos(&self) -> usize { fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos self.current_cursor_pos
} }
fn has_unsaved_changes(&self) -> bool { fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes self.has_unsaved_changes
} }
fn inputs(&self) -> Vec<&String> { fn inputs(&self) -> Vec<&String> {
self.values.iter().collect() self.values.iter().collect()
} }
fn get_current_input(&self) -> &str { fn get_current_input(&self) -> &str {
FormState::get_current_input(self) FormState::get_current_input(self)
} }
fn get_current_input_mut(&mut self) -> &mut String { fn get_current_input_mut(&mut self) -> &mut String {
FormState::get_current_input_mut(self) FormState::get_current_input_mut(self)
} }
fn fields(&self) -> Vec<&str> { fn fields(&self) -> Vec<&str> {
self.fields self.fields
.iter() .iter()
.map(|f| f.display_name.as_str()) .map(|f| f.display_name.as_str())
.collect() .collect()
} }
fn set_current_field(&mut self, index: usize) { fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() { if index < self.fields.len() {
self.current_field = index; self.current_field = index;
} }
// Deactivate autocomplete when changing fields
self.deactivate_autocomplete(); self.deactivate_autocomplete();
} }
fn set_current_cursor_pos(&mut self, pos: usize) { fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos; self.current_cursor_pos = pos;
} }
fn set_has_unsaved_changes(&mut self, changed: bool) { fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed; self.has_unsaved_changes = changed;
} }
// --- MODIFIED: Implement autocomplete trait methods ---
/// Returns None because this state uses rich suggestions.
fn get_suggestions(&self) -> Option<&[String]> { fn get_suggestions(&self) -> Option<&[String]> {
None None
} }
/// Returns rich suggestions.
fn get_rich_suggestions(&self) -> Option<&[Hit]> { fn get_rich_suggestions(&self) -> Option<&[Hit]> {
if self.autocomplete_active { if self.autocomplete_active {
Some(&self.autocomplete_suggestions) Some(&self.autocomplete_suggestions)
@@ -235,7 +264,6 @@ impl CanvasState for FormState {
None None
} }
} }
fn get_selected_suggestion_index(&self) -> Option<usize> { fn get_selected_suggestion_index(&self) -> Option<usize> {
if self.autocomplete_active { if self.autocomplete_active {
self.selected_suggestion_index self.selected_suggestion_index
@@ -243,4 +271,19 @@ impl CanvasState for FormState {
None None
} }
} }
fn get_display_value_for_field(&self, index: usize) -> &str {
if let Some(display_text) = self.link_display_map.get(&index) {
return display_text.as_str();
}
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
// --- IMPLEMENT THE NEW TRAIT METHOD ---
fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
} }