From 9e0fa9ddb1416d35895cc2d979d04fc7df28734a Mon Sep 17 00:00:00 2001 From: filipriec Date: Mon, 16 Jun 2025 11:54:54 +0200 Subject: [PATCH] autocomplete now autocompleting data not just id --- client/src/components/common/autocomplete.rs | 6 +- client/src/components/handlers/canvas.rs | 94 ++++++++--------- client/src/modes/canvas/edit.rs | 19 +++- client/src/state/pages/canvas_state.rs | 14 ++- client/src/state/pages/form.rs | 105 +++++++++++++------ 5 files changed, 149 insertions(+), 89 deletions(-) diff --git a/client/src/components/common/autocomplete.rs b/client/src/components/common/autocomplete.rs index fca7f60..30ff537 100644 --- a/client/src/components/common/autocomplete.rs +++ b/client/src/components/common/autocomplete.rs @@ -140,12 +140,12 @@ pub fn render_rich_autocomplete_dropdown( let display_part = values.first().cloned().unwrap_or_default(); // Get the first value if display_part.is_empty() { - format!("ID: {}", hit.id) + format!("{}", hit.id) } else { - format!("{} | ID: {}", display_part, hit.id) // ID at the end + format!("{} | {}", display_part, hit.id) // ID at the end } } else { - format!("ID: {} (parse error)", hit.id) + format!("{} (parse error)", hit.id) } }) .collect(); diff --git a/client/src/components/handlers/canvas.rs b/client/src/components/handlers/canvas.rs index 12b2fba..a218c79 100644 --- a/client/src/components/handlers/canvas.rs +++ b/client/src/components/handlers/canvas.rs @@ -1,16 +1,16 @@ // src/components/handlers/canvas.rs + use ratatui::{ - widgets::{Paragraph, Block, Borders}, - layout::{Layout, Constraint, Direction, Rect}, - style::{Style, Modifier}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, Frame, - prelude::Alignment, }; use crate::config::colors::themes::Theme; +use crate::state::app::highlight::HighlightState; use crate::state::pages::canvas_state::CanvasState; -use crate::state::app::highlight::HighlightState; // Ensure correct import path -use std::cmp::{min, max}; +use std::cmp::{max, min}; pub fn render_canvas( f: &mut Frame, @@ -21,9 +21,8 @@ pub fn render_canvas( inputs: &[&String], theme: &Theme, is_edit_mode: bool, - highlight_state: &HighlightState, // Using the enum state + highlight_state: &HighlightState, ) -> Option { - // ... (setup code remains the same) ... let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) @@ -58,46 +57,47 @@ pub fn render_canvas( let mut active_field_input_rect = None; - // Render 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, - }); + 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 inputs and cursor - for (i, input) in inputs.iter().enumerate() { + for (i, _input) in inputs.iter().enumerate() { let is_active = i == *current_field_idx; 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; - // --- Use match on the highlight_state enum --- match highlight_state { HighlightState::Off => { - // Not in highlight mode, render normally line = Line::from(Span::styled( 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 } => { - // --- Character-wise Highlight Logic --- let (anchor_field, anchor_char) = *anchor; let start_field = min(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 { (min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos)) } else if anchor_field < *current_field_idx { @@ -111,24 +111,20 @@ pub fn render_canvas( let normal_style_outside = Style::default().fg(theme.fg); if i >= start_field && i <= end_field { - // This line is within the character-wise highlight range - if start_field == end_field { // Case 1: Single Line Highlight - // Use start_char and end_char here + if start_field == end_field { 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 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(); line = Line::from(vec![ Span::styled(before, normal_style_in_highlight), 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 - // Use start_char here + } 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(); @@ -136,8 +132,7 @@ pub fn render_canvas( Span::styled(before, normal_style_in_highlight), Span::styled(highlighted, highlight_style), ]); - } else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index) - // Use end_char here + } 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(); @@ -145,19 +140,17 @@ pub fn render_canvas( Span::styled(highlighted, highlight_style), Span::styled(after, normal_style_in_highlight), ]); - } else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index) - line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line + } else { + line = Line::from(Span::styled(text, highlight_style)); } - } else { // Case 5: Line Outside Character-wise Highlight Range + } else { line = Line::from(Span::styled( text, - // Use normal styling (active or inactive) if is_active { normal_style_in_highlight } else { normal_style_outside } )); } } HighlightState::Linewise { anchor_line } => { - // --- Linewise Highlight Logic --- 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); @@ -165,25 +158,31 @@ pub fn render_canvas( let normal_style_outside = Style::default().fg(theme.fg); if i >= start_field && i <= end_field { - // Highlight the entire line line = Line::from(Span::styled(text, highlight_style)); } else { - // Line outside linewise highlight range line = Line::from(Span::styled( text, - // Use normal styling (active or inactive) if is_active { normal_style_in_highlight } else { normal_style_outside } )); } } - } // End match highlight_state + } 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]); - 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; f.set_cursor_position((cursor_x, cursor_y)); } @@ -191,4 +190,3 @@ pub fn render_canvas( active_field_input_rect } - diff --git a/client/src/modes/canvas/edit.rs b/client/src/modes/canvas/edit.rs index 498be68..c8374eb 100644 --- a/client/src/modes/canvas/edit.rs +++ b/client/src/modes/canvas/edit.rs @@ -132,13 +132,24 @@ pub async fn handle_edit_event( .get(selected_idx) .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 = form_state.get_current_input_mut(); *current_input = selection.id.to_string(); - let new_cursor_pos = current_input.len(); - form_state.set_current_cursor_pos(new_cursor_pos); - // FIX: Access ideal_cursor_column through event_handler - event_handler.ideal_cursor_column = new_cursor_pos; + + // 3. Set the persistent display override in the map + form_state.link_display_map.insert( + form_state.current_field, + display_name, + ); + + // 4. Finalize state form_state.deactivate_autocomplete(); form_state.set_has_unsaved_changes(true); return Ok(EditEventOutcome::Message( diff --git a/client/src/state/pages/canvas_state.rs b/client/src/state/pages/canvas_state.rs index 4fe02fb..fac6da2 100644 --- a/client/src/state/pages/canvas_state.rs +++ b/client/src/state/pages/canvas_state.rs @@ -3,6 +3,7 @@ use common::proto::multieko2::search::search_response::Hit; pub trait CanvasState { + // --- Existing methods (unchanged) --- fn current_field(&self) -> usize; fn current_cursor_pos(&self) -> usize; fn has_unsaved_changes(&self) -> bool; @@ -10,15 +11,22 @@ pub trait CanvasState { fn get_current_input(&self) -> &str; fn get_current_input_mut(&mut self) -> &mut String; fn fields(&self) -> Vec<&str>; - fn set_current_field(&mut self, index: usize); fn set_current_cursor_pos(&mut self, pos: usize); fn set_has_unsaved_changes(&mut self, changed: bool); - - // --- Autocomplete Support --- fn get_suggestions(&self) -> Option<&[String]>; fn get_selected_suggestion_index(&self) -> Option; fn get_rich_suggestions(&self) -> Option<&[Hit]> { 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 + } } diff --git a/client/src/state/pages/form.rs b/client/src/state/pages/form.rs index 112119d..7ec5935 100644 --- a/client/src/state/pages/form.rs +++ b/client/src/state/pages/form.rs @@ -3,18 +3,26 @@ use crate::config::colors::themes::Theme; use crate::state::app::highlight::HighlightState; 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::Frame; 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)] pub struct FieldDefinition { pub display_name: String, pub data_key: String, pub is_link: bool, - pub link_target_table: Option, + pub link_target_table: Option, } #[derive(Clone)] @@ -29,12 +37,11 @@ pub struct FormState { pub current_field: usize, pub has_unsaved_changes: bool, pub current_cursor_pos: usize, - - // --- MODIFIED AUTOCOMPLETE STATE --- pub autocomplete_active: bool, - pub autocomplete_suggestions: Vec, // Changed to use the Hit struct + pub autocomplete_suggestions: Vec, pub selected_suggestion_index: Option, - pub autocomplete_loading: bool, // To show a loading indicator + pub autocomplete_loading: bool, + pub link_display_map: HashMap, } impl FormState { @@ -55,11 +62,48 @@ impl FormState { current_field: 0, has_unsaved_changes: false, current_cursor_pos: 0, - // --- INITIALIZE NEW STATE --- autocomplete_active: false, autocomplete_suggestions: Vec::new(), 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::>( + &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( f, area, - self, // <--- This now correctly passes the concrete &FormState + self, &fields_str_slice, &self.current_field, &values_str_slice, @@ -102,7 +146,8 @@ impl FormState { } else { 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 { @@ -113,6 +158,7 @@ impl FormState { } pub fn get_current_input_mut(&mut self) -> &mut String { + self.link_display_map.remove(&self.current_field); self.values .get_mut(self.current_field) .expect("Invalid current_field index") @@ -159,11 +205,10 @@ impl FormState { self.has_unsaved_changes = false; self.current_field = 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) { self.autocomplete_active = false; self.autocomplete_suggestions.clear(); @@ -176,58 +221,42 @@ impl CanvasState for FormState { fn current_field(&self) -> usize { self.current_field } - fn current_cursor_pos(&self) -> usize { self.current_cursor_pos } - fn has_unsaved_changes(&self) -> bool { self.has_unsaved_changes } - fn inputs(&self) -> Vec<&String> { self.values.iter().collect() } - fn get_current_input(&self) -> &str { FormState::get_current_input(self) } - fn get_current_input_mut(&mut self) -> &mut String { FormState::get_current_input_mut(self) } - fn fields(&self) -> Vec<&str> { self.fields .iter() .map(|f| f.display_name.as_str()) .collect() } - fn set_current_field(&mut self, index: usize) { if index < self.fields.len() { self.current_field = index; } - // Deactivate autocomplete when changing fields self.deactivate_autocomplete(); } - fn set_current_cursor_pos(&mut self, pos: usize) { self.current_cursor_pos = pos; } - fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_unsaved_changes = changed; } - - // --- MODIFIED: Implement autocomplete trait methods --- - - /// Returns None because this state uses rich suggestions. fn get_suggestions(&self) -> Option<&[String]> { None } - - /// Returns rich suggestions. fn get_rich_suggestions(&self) -> Option<&[Hit]> { if self.autocomplete_active { Some(&self.autocomplete_suggestions) @@ -235,7 +264,6 @@ impl CanvasState for FormState { None } } - fn get_selected_suggestion_index(&self) -> Option { if self.autocomplete_active { self.selected_suggestion_index @@ -243,4 +271,19 @@ impl CanvasState for FormState { 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) + } }