diff --git a/Cargo.lock b/Cargo.lock index 1587dc5..e7dac15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "canvas" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "common", @@ -555,7 +555,7 @@ dependencies = [ [[package]] name = "client" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "async-trait", @@ -606,7 +606,7 @@ dependencies = [ [[package]] name = "common" -version = "0.4.1" +version = "0.4.2" dependencies = [ "prost", "prost-types", @@ -2892,7 +2892,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "search" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "common", @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "server" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "bcrypt", diff --git a/Cargo.toml b/Cargo.toml index ada3cf1..8b0458c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] # TODO: idk how to do the name, fix later # name = "komp_ac" -version = "0.4.1" +version = "0.4.2" edition = "2021" license = "GPL-3.0-or-later" authors = ["Filip Priečinský "] diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index e4a4998..390b07c 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -11,7 +11,7 @@ categories.workspace = true [dependencies] common = { path = "../common" } -ratatui = { workspace = true } +ratatui = { workspace = true, optional = true } crossterm = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } @@ -21,6 +21,10 @@ serde = { workspace = true } [dev-dependencies] tokio-test = "0.4.4" +[features] +default = [] +gui = ["ratatui"] + [[example]] name = "simple_login" path = "examples/simple_login.rs" diff --git a/canvas/src/gui/mod.rs b/canvas/src/gui/mod.rs new file mode 100644 index 0000000..c66d3a3 --- /dev/null +++ b/canvas/src/gui/mod.rs @@ -0,0 +1,7 @@ +// canvas/src/gui/mod.rs + +pub mod theme; +pub mod render; + +pub use theme::CanvasTheme; +pub use render::render_canvas; diff --git a/canvas/src/gui/render.rs b/canvas/src/gui/render.rs new file mode 100644 index 0000000..ae75bee --- /dev/null +++ b/canvas/src/gui/render.rs @@ -0,0 +1,244 @@ +// 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, 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}; + +/// Render canvas using the CanvasState trait and CanvasTheme +#[cfg(feature = "gui")] +pub fn render_canvas( + f: &mut Frame, + area: Rect, + form_state: &impl CanvasState, + theme: &T, + is_edit_mode: bool, + highlight_state: &HighlightState, +) -> Option { + let fields: Vec<&str> = form_state.fields(); + let current_field_idx = form_state.current_field(); + let inputs: Vec<&String> = form_state.inputs(); + + render_canvas_impl( + 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), + ) +} + +/// Internal implementation of canvas rendering +#[cfg(feature = "gui")] +fn render_canvas_impl( + 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 +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_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 +} diff --git a/canvas/src/gui/theme.rs b/canvas/src/gui/theme.rs new file mode 100644 index 0000000..6ea3932 --- /dev/null +++ b/canvas/src/gui/theme.rs @@ -0,0 +1,17 @@ +// canvas/src/gui/theme.rs + +#[cfg(feature = "gui")] +use ratatui::style::Color; + +/// Theme trait that must be implemented by applications using the canvas GUI +#[cfg(feature = "gui")] +pub trait CanvasTheme { + fn bg(&self) -> Color; + fn fg(&self) -> Color; + fn border(&self) -> Color; + fn accent(&self) -> Color; + fn secondary(&self) -> Color; + fn highlight(&self) -> Color; + fn highlight_bg(&self) -> Color; + fn warning(&self) -> Color; +} diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 5695f32..73da599 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -11,6 +11,10 @@ pub mod config; pub mod suggestions; pub mod dispatcher; +// GUI module (optional, enabled with "gui" feature) +#[cfg(feature = "gui")] +pub mod gui; + // Re-export the main types for easy use pub use state::{CanvasState, ActionContext}; pub use actions::{CanvasAction, ActionResult, execute_edit_action, execute_canvas_action}; @@ -18,6 +22,10 @@ pub use modes::{AppMode, ModeManager, HighlightState}; pub use suggestions::SuggestionState; pub use dispatcher::ActionDispatcher; +// Re-export GUI types when available +#[cfg(feature = "gui")] +pub use gui::{CanvasTheme, render_canvas}; + // High-level convenience API pub mod prelude { pub use crate::{ @@ -33,4 +41,7 @@ pub mod prelude { HighlightState, SuggestionState, }; + + #[cfg(feature = "gui")] + pub use crate::{CanvasTheme, render_canvas}; } diff --git a/client/Cargo.toml b/client/Cargo.toml index 366ec12..dad74f6 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -8,7 +8,7 @@ license.workspace = true anyhow = { workspace = true } async-trait = "0.1.88" common = { path = "../common" } -canvas = { path = "../canvas" } +canvas = { path = "../canvas", features = ["gui"] } ratatui = { workspace = true } crossterm = { workspace = true } diff --git a/client/config.toml b/client/config.toml index ec80c9f..3f1f213 100644 --- a/client/config.toml +++ b/client/config.toml @@ -29,6 +29,7 @@ move_up = ["Up"] move_down = ["Down"] toggle_sidebar = ["ctrl+t"] toggle_buffer_list = ["ctrl+b"] +revert = ["space+b+r"] # MODE SPECIFIC # READ ONLY MODE @@ -37,7 +38,6 @@ enter_edit_mode_before = ["i"] enter_edit_mode_after = ["a"] previous_entry = ["left","q"] next_entry = ["right","1"] -revert = ["space+b+r"] move_left = ["h"] move_right = ["l"] diff --git a/client/src/components/form/form.rs b/client/src/components/form/form.rs index 33e40f0..0f90a66 100644 --- a/client/src/components/form/form.rs +++ b/client/src/components/form/form.rs @@ -1,9 +1,7 @@ // src/components/form/form.rs use crate::components::common::autocomplete; -use crate::components::handlers::canvas::render_canvas; use crate::config::colors::themes::Theme; -use crate::state::app::highlight::HighlightState; -use canvas::CanvasState; +use canvas::{CanvasState, render_canvas, HighlightState}; // CHANGED: Import HighlightState from canvas use crate::state::pages::form::FormState; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, @@ -15,14 +13,14 @@ use ratatui::{ pub fn render_form( f: &mut Frame, area: Rect, - form_state: &FormState, // <--- CHANGE THIS to the concrete type + form_state: &FormState, fields: &[&str], current_field_idx: &usize, inputs: &[&String], table_name: &str, theme: &Theme, is_edit_mode: bool, - highlight_state: &HighlightState, + highlight_state: &HighlightState, // Now using canvas::HighlightState total_count: u64, current_position: u64, ) { @@ -63,27 +61,23 @@ pub fn render_form( .alignment(Alignment::Left); f.render_widget(count_para, main_layout[0]); - // Get the active field's rect from render_canvas - let active_field_rect = crate::components::handlers::canvas::render_canvas_library( + // Use the canvas library's render_canvas function + let active_field_rect = render_canvas( f, main_layout[1], form_state, - fields, - current_field_idx, - inputs, theme, is_edit_mode, highlight_state, ); - // --- NEW: RENDER AUTOCOMPLETE --- + // --- RENDER AUTOCOMPLETE --- if form_state.autocomplete_active { if let Some(active_rect) = active_field_rect { let selected_index = form_state.get_selected_suggestion_index(); if let Some(rich_suggestions) = form_state.get_rich_suggestions() { if !rich_suggestions.is_empty() { - // CHANGE THIS to call the renamed function autocomplete::render_hit_autocomplete_dropdown( f, active_rect, @@ -95,8 +89,6 @@ pub fn render_form( ); } } - // The fallback to simple suggestions is now correctly handled - // because the original render_autocomplete_dropdown exists again. else if let Some(simple_suggestions) = form_state.get_suggestions() { if !simple_suggestions.is_empty() { autocomplete::render_autocomplete_dropdown( @@ -112,4 +104,3 @@ pub fn render_form( } } } - diff --git a/client/src/config/colors/themes.rs b/client/src/config/colors/themes.rs index c73e541..d0b961c 100644 --- a/client/src/config/colors/themes.rs +++ b/client/src/config/colors/themes.rs @@ -1,5 +1,6 @@ -// src/client/themes/colors.rs +// src/config/colors/themes.rs use ratatui::style::Color; +use canvas::CanvasTheme; #[derive(Debug, Clone)] pub struct Theme { @@ -74,3 +75,37 @@ impl Default for Theme { Self::light() // Default to light theme } } + +impl CanvasTheme for Theme { + fn bg(&self) -> Color { + self.bg + } + + fn fg(&self) -> Color { + self.fg + } + + fn border(&self) -> Color { + self.border + } + + fn accent(&self) -> Color { + self.accent + } + + fn secondary(&self) -> Color { + self.secondary + } + + fn highlight(&self) -> Color { + self.highlight + } + + fn highlight_bg(&self) -> Color { + self.highlight_bg + } + + fn warning(&self) -> Color { + self.warning + } +} diff --git a/client/src/state/pages/form.rs b/client/src/state/pages/form.rs index 05d3f50..ea76c5b 100644 --- a/client/src/state/pages/form.rs +++ b/client/src/state/pages/form.rs @@ -1,8 +1,7 @@ // src/state/pages/form.rs use crate::config::colors::themes::Theme; -use crate::state::app::highlight::HighlightState; -use canvas::{CanvasState, CanvasAction, ActionContext}; // CHANGED: Use canvas crate +use canvas::{CanvasState, CanvasAction, ActionContext, HighlightState}; use common::proto::komp_ac::search::search_response::Hit; use ratatui::layout::Rect; use ratatui::Frame; @@ -113,7 +112,7 @@ impl FormState { area: Rect, theme: &Theme, is_edit_mode: bool, - highlight_state: &HighlightState, + highlight_state: &HighlightState, // Now using canvas::HighlightState ) { let fields_str_slice: Vec<&str> = self.fields().iter().map(|s| *s).collect(); @@ -146,7 +145,7 @@ impl FormState { } else { self.current_position = 1; } - self.deactivate_suggestions(); // CHANGED: Use canvas trait method + self.deactivate_suggestions(); self.link_display_map.clear(); } @@ -205,12 +204,10 @@ impl FormState { self.has_unsaved_changes = false; self.current_field = 0; self.current_cursor_pos = 0; - self.deactivate_suggestions(); // CHANGED: Use canvas trait method + self.deactivate_suggestions(); self.link_display_map.clear(); } - // REMOVED: deactivate_autocomplete() - now using trait method - // NEW: Keep the rich suggestions methods for compatibility pub fn get_rich_suggestions(&self) -> Option<&[Hit]> { if self.autocomplete_active { @@ -232,45 +229,45 @@ 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; } - self.deactivate_suggestions(); // CHANGED: Use canvas trait method + self.deactivate_suggestions(); } - + 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; } @@ -312,18 +309,18 @@ impl CanvasState for FormState { match action { CanvasAction::SelectSuggestion => { if let Some(selected_idx) = self.selected_suggestion_index { - if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() { // ADD .cloned() + if let Some(hit) = self.autocomplete_suggestions.get(selected_idx).cloned() { // Extract the value from the selected suggestion if let Ok(content_map) = serde_json::from_str::>(&hit.content_json) { let current_field_def = &self.fields[self.current_field]; if let Some(value) = content_map.get(¤t_field_def.data_key) { let new_value = json_value_to_string(value); - let display_name = self.get_display_name_for_hit(&hit); // Calculate first + let display_name = self.get_display_name_for_hit(&hit); *self.get_current_input_mut() = new_value.clone(); self.set_current_cursor_pos(new_value.len()); self.set_has_unsaved_changes(true); self.deactivate_suggestions(); - return Some(format!("Selected: {}", display_name)); // Use calculated value + return Some(format!("Selected: {}", display_name)); } } } diff --git a/client/src/ui/handlers/render.rs b/client/src/ui/handlers/render.rs index d28a7f2..ed0019d 100644 --- a/client/src/ui/handlers/render.rs +++ b/client/src/ui/handlers/render.rs @@ -19,7 +19,8 @@ use crate::config::colors::themes::Theme; use crate::modes::general::command_navigation::NavigationState; use crate::state::pages::canvas_state::CanvasState; use crate::state::app::buffer::BufferState; -use crate::state::app::highlight::HighlightState; +use crate::state::app::highlight::HighlightState as LocalHighlightState; // CHANGED: Alias local version +use canvas::HighlightState as CanvasHighlightState; // CHANGED: Import canvas version with alias use crate::state::app::state::AppState; use crate::state::pages::admin::AdminState; use crate::state::pages::auth::AuthState; @@ -32,6 +33,15 @@ use ratatui::{ Frame, }; +// Helper function to convert between HighlightState types +fn convert_highlight_state(local: &LocalHighlightState) -> CanvasHighlightState { + match local { + LocalHighlightState::Off => CanvasHighlightState::Off, + LocalHighlightState::Characterwise { anchor } => CanvasHighlightState::Characterwise { anchor: *anchor }, + LocalHighlightState::Linewise { anchor_line } => CanvasHighlightState::Linewise { anchor_line: *anchor_line }, + } +} + #[allow(clippy::too_many_arguments)] pub fn render_ui( f: &mut Frame, @@ -44,7 +54,7 @@ pub fn render_ui( buffer_state: &BufferState, theme: &Theme, is_event_handler_edit_mode: bool, - highlight_state: &HighlightState, + highlight_state: &LocalHighlightState, // Keep using local version event_handler_command_input: &str, event_handler_command_mode_active: bool, event_handler_command_message: &str, @@ -69,7 +79,6 @@ pub fn render_ui( const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15; - let mut bottom_area_constraints: Vec = vec![Constraint::Length(status_line_height)]; let command_palette_area_height = if navigation_state.active { 1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT @@ -129,7 +138,7 @@ pub fn render_ui( register_state, app_state, register_state.current_field() < 4, - highlight_state, + highlight_state, // Uses local version ); } else if app_state.ui.show_add_table { render_add_table( @@ -139,7 +148,7 @@ pub fn render_ui( app_state, &mut admin_state.add_table_state, is_event_handler_edit_mode, - highlight_state, + highlight_state, // Uses local version ); } else if app_state.ui.show_add_logic { render_add_logic( @@ -149,7 +158,7 @@ pub fn render_ui( app_state, &mut admin_state.add_logic_state, is_event_handler_edit_mode, - highlight_state, + highlight_state, // Uses local version ); } else if app_state.ui.show_login { render_login( @@ -159,7 +168,7 @@ pub fn render_ui( login_state, app_state, login_state.current_field() < 2, - highlight_state, + highlight_state, // Uses local version ); } else if app_state.ui.show_admin { crate::components::admin::admin_panel::render_admin_panel( @@ -201,12 +210,14 @@ pub fn render_ui( .split(form_actual_area)[1] }; + // CHANGED: Convert local HighlightState to canvas HighlightState for FormState + let canvas_highlight_state = convert_highlight_state(highlight_state); form_state.render( f, form_render_area, theme, is_event_handler_edit_mode, - highlight_state, + &canvas_highlight_state, // Use converted version ); }