From d60113453501b8469d00014acb744f82a6a0d9a2 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 7 Aug 2025 12:38:09 +0200 Subject: [PATCH] computed fields are working perfectly well now --- canvas/Cargo.toml | 5 + canvas/examples/computed_fields.rs | 620 +++++++++++++++++++++++++++++ canvas/src/canvas/state.rs | 15 + canvas/src/computed/mod.rs | 5 + canvas/src/computed/provider.rs | 31 ++ canvas/src/computed/state.rs | 88 ++++ canvas/src/data_provider.rs | 14 + canvas/src/editor.rs | 193 +++++++++ canvas/src/lib.rs | 8 + 9 files changed, 979 insertions(+) create mode 100644 canvas/examples/computed_fields.rs create mode 100644 canvas/src/computed/mod.rs create mode 100644 canvas/src/computed/provider.rs create mode 100644 canvas/src/computed/state.rs diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index d10fbfb..ea0e8a1 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -34,6 +34,7 @@ gui = ["ratatui", "crossterm"] suggestions = ["tokio"] cursor-style = ["crossterm"] validation = ["regex"] +computed = [] [[example]] name = "suggestions" @@ -64,3 +65,7 @@ required-features = ["gui", "validation"] [[example]] name = "validation_5" required-features = ["gui", "validation"] + +[[example]] +name = "computed_fields" +required-features = ["gui", "computed"] diff --git a/canvas/examples/computed_fields.rs b/canvas/examples/computed_fields.rs new file mode 100644 index 0000000..995d38b --- /dev/null +++ b/canvas/examples/computed_fields.rs @@ -0,0 +1,620 @@ +// examples/computed_fields.rs - COMPLETE WORKING VERSION +//! Demonstrates computed fields with the canvas library - Invoice Calculator Example +//! +//! This example REQUIRES the `computed` feature to compile. +//! +//! Run with: +//! cargo run --example computed_fields --features "gui,computed" + +#[cfg(not(feature = "computed"))] +compile_error!( + "This example requires the 'computed' feature. \ + Run with: cargo run --example computed_fields --features \"gui,computed\"" +); + +use std::io; +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style, Modifier}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, Terminal, +}; + +use canvas::{ + canvas::{gui::render_canvas_default, modes::AppMode}, + DataProvider, FormEditor, + computed::{ComputedProvider, ComputedContext}, +}; + +/// Invoice data with computed fields +struct InvoiceData { + fields: Vec<(String, String)>, + computed_indices: std::collections::HashSet, +} + +impl InvoiceData { + fn new() -> Self { + let mut computed_indices = std::collections::HashSet::new(); + + // Mark computed fields (read-only, calculated) + computed_indices.insert(4); // Subtotal + computed_indices.insert(5); // Tax Amount + computed_indices.insert(6); // Total + + Self { + fields: vec![ + ("๐Ÿ“ฆ Product Name".to_string(), "".to_string()), + ("๐Ÿ”ข Quantity".to_string(), "".to_string()), + ("๐Ÿ’ฐ Unit Price ($)".to_string(), "".to_string()), + ("๐Ÿ“Š Tax Rate (%)".to_string(), "".to_string()), + ("โž• Subtotal ($)".to_string(), "".to_string()), // COMPUTED + ("๐Ÿงพ Tax Amount ($)".to_string(), "".to_string()), // COMPUTED + ("๐Ÿ’ณ Total ($)".to_string(), "".to_string()), // COMPUTED + ("๐Ÿ“ Notes".to_string(), "".to_string()), + ], + computed_indices, + } + } +} + +impl DataProvider for InvoiceData { + fn field_count(&self) -> usize { + self.fields.len() + } + + fn field_name(&self, index: usize) -> &str { + &self.fields[index].0 + } + + fn field_value(&self, index: usize) -> &str { + &self.fields[index].1 + } + + fn set_field_value(&mut self, index: usize, value: String) { + // ๐Ÿ”ฅ FIXED: Allow computed fields to be updated for display purposes + // The editing protection happens at the editor level, not here + self.fields[index].1 = value; + } + + fn supports_suggestions(&self, _field_index: usize) -> bool { + false + } + + fn display_value(&self, _index: usize) -> Option<&str> { + None + } + + /// Mark computed fields + fn is_computed_field(&self, field_index: usize) -> bool { + self.computed_indices.contains(&field_index) + } + + /// Get computed field values + fn computed_field_value(&self, field_index: usize) -> Option { + if self.computed_indices.contains(&field_index) { + Some(self.fields[field_index].1.clone()) + } else { + None + } + } +} + +/// Invoice calculator - computes totals based on input fields +struct InvoiceCalculator; + +impl ComputedProvider for InvoiceCalculator { + fn compute_field(&mut self, context: ComputedContext) -> String { + // Helper to parse field values safely + let parse_field = |index: usize| -> f64 { + let value = context.field_values[index].trim(); + if value.is_empty() { + 0.0 + } else { + value.parse().unwrap_or(0.0) + } + }; + + match context.target_field { + 4 => { + // Subtotal = Quantity ร— Unit Price + let qty = parse_field(1); + let price = parse_field(2); + let subtotal = qty * price; + + if qty == 0.0 || price == 0.0 { + "".to_string() // Show empty if no meaningful calculation + } else { + format!("{:.2}", subtotal) + } + } + 5 => { + // Tax Amount = Subtotal ร— (Tax Rate / 100) + let qty = parse_field(1); + let price = parse_field(2); + let tax_rate = parse_field(3); + let subtotal = qty * price; + let tax_amount = subtotal * (tax_rate / 100.0); + + if subtotal == 0.0 || tax_rate == 0.0 { + "".to_string() + } else { + format!("{:.2}", tax_amount) + } + } + 6 => { + // Total = Subtotal + Tax Amount + let qty = parse_field(1); + let price = parse_field(2); + let tax_rate = parse_field(3); + let subtotal = qty * price; + + if subtotal == 0.0 { + "".to_string() + } else { + let tax_amount = subtotal * (tax_rate / 100.0); + let total = subtotal + tax_amount; + format!("{:.2}", total) + } + } + _ => "".to_string(), + } + } + + fn handles_field(&self, field_index: usize) -> bool { + matches!(field_index, 4 | 5 | 6) // Subtotal, Tax Amount, Total + } + + fn field_dependencies(&self, field_index: usize) -> Vec { + match field_index { + 4 => vec![1, 2], // Subtotal depends on Quantity, Unit Price + 5 => vec![1, 2, 3], // Tax Amount depends on Quantity, Unit Price, Tax Rate + 6 => vec![1, 2, 3], // Total depends on Quantity, Unit Price, Tax Rate + _ => vec![], + } + } +} + +/// Enhanced editor with computed fields +struct ComputedFieldsEditor { + editor: FormEditor, + calculator: InvoiceCalculator, + debug_message: String, + last_computed_values: Vec, +} + +impl ComputedFieldsEditor { + fn new(data_provider: D) -> Self { + let mut editor = FormEditor::new(data_provider); + editor.set_computed_provider(InvoiceCalculator); + + let calculator = InvoiceCalculator; + let last_computed_values = vec!["".to_string(); 8]; + + Self { + editor, + calculator, + debug_message: "๐Ÿ’ฐ Invoice Calculator - Start typing in fields to see calculations!".to_string(), + last_computed_values, + } + } + + fn is_computed_field(&self, field_index: usize) -> bool { + self.editor.ui_state().is_computed_field(field_index) + } + + fn update_computed_fields(&mut self) { + // Trigger recomputation of all computed fields + self.editor.recompute_all_fields(&mut self.calculator); + + // ๐Ÿ”ฅ CRITICAL FIX: Sync computed values to DataProvider so GUI shows them! + for i in [4, 5, 6] { // Computed field indices + let computed_value = self.editor.effective_field_value(i); + self.editor.data_provider_mut().set_field_value(i, computed_value.clone()); + } + + // Check if values changed to show feedback + let mut changed = false; + let mut has_calculations = false; + + for i in [4, 5, 6] { + let new_value = self.editor.effective_field_value(i); + if new_value != self.last_computed_values[i] { + changed = true; + self.last_computed_values[i] = new_value.clone(); + } + if !new_value.is_empty() { + has_calculations = true; + } + } + + if changed { + if has_calculations { + let subtotal = &self.last_computed_values[4]; + let tax = &self.last_computed_values[5]; + let total = &self.last_computed_values[6]; + + let mut parts = Vec::new(); + if !subtotal.is_empty() { + parts.push(format!("Subtotal=${}", subtotal)); + } + if !tax.is_empty() { + parts.push(format!("Tax=${}", tax)); + } + if !total.is_empty() { + parts.push(format!("Total=${}", total)); + } + + if !parts.is_empty() { + self.debug_message = format!("๐Ÿงฎ Calculated: {}", parts.join(", ")); + } else { + self.debug_message = "๐Ÿ’ฐ Enter Quantity and Unit Price to see calculations".to_string(); + } + } else { + self.debug_message = "๐Ÿ’ฐ Enter Quantity and Unit Price to see calculations".to_string(); + } + } + } + + fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { + let current_field = self.editor.current_field(); + let result = self.editor.insert_char(ch); + + if result.is_ok() && matches!(current_field, 1 | 2 | 3) { + self.editor.on_field_changed(&mut self.calculator, current_field); + self.update_computed_fields(); + } + + result + } + + fn delete_backward(&mut self) -> anyhow::Result<()> { + let current_field = self.editor.current_field(); + let result = self.editor.delete_backward(); + + if result.is_ok() && matches!(current_field, 1 | 2 | 3) { + self.editor.on_field_changed(&mut self.calculator, current_field); + self.update_computed_fields(); + } + + result + } + + fn delete_forward(&mut self) -> anyhow::Result<()> { + let current_field = self.editor.current_field(); + let result = self.editor.delete_forward(); + + if result.is_ok() && matches!(current_field, 1 | 2 | 3) { + self.editor.on_field_changed(&mut self.calculator, current_field); + self.update_computed_fields(); + } + + result + } + + fn next_field(&mut self) { + let old_field = self.editor.current_field(); + let _ = self.editor.next_field(); + let new_field = self.editor.current_field(); + + if old_field != new_field { + let field_name = self.editor.data_provider().field_name(new_field); + let field_type = if self.is_computed_field(new_field) { + "computed (read-only)" + } else { + "editable" + }; + self.debug_message = format!("โ†’ {} - {} field", field_name, field_type); + } + } + + fn prev_field(&mut self) { + let old_field = self.editor.current_field(); + let _ = self.editor.prev_field(); + let new_field = self.editor.current_field(); + + if old_field != new_field { + let field_name = self.editor.data_provider().field_name(new_field); + let field_type = if self.is_computed_field(new_field) { + "computed (read-only)" + } else { + "editable" + }; + self.debug_message = format!("โ† {} - {} field", field_name, field_type); + } + } + + fn enter_edit_mode(&mut self) { + let current = self.editor.current_field(); + + // Double protection: check both ways + if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) { + let field_name = self.editor.data_provider().field_name(current); + self.debug_message = format!( + "๐Ÿšซ {} is computed (read-only) - Press Tab to move to editable fields", + field_name + ); + return; + } + + self.editor.enter_edit_mode(); + let field_name = self.editor.data_provider().field_name(current); + self.debug_message = format!("โœ๏ธ Editing {} - Type to see calculations update", field_name); + } + + fn enter_append_mode(&mut self) { + let current = self.editor.current_field(); + + if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) { + let field_name = self.editor.data_provider().field_name(current); + self.debug_message = format!( + "๐Ÿšซ {} is computed (read-only) - Press Tab to move to editable fields", + field_name + ); + return; + } + + self.editor.enter_append_mode(); + let field_name = self.editor.data_provider().field_name(current); + self.debug_message = format!("โœ๏ธ Appending to {} - Type to see calculations", field_name); + } + + fn exit_edit_mode(&mut self) { + let current_field = self.editor.current_field(); + self.editor.exit_edit_mode(); + + if matches!(current_field, 1 | 2 | 3) { + self.editor.on_field_changed(&mut self.calculator, current_field); + self.update_computed_fields(); + } + + self.debug_message = "๐Ÿ”’ Normal mode - Press 'i' to edit fields".to_string(); + } + + // Delegate methods + fn current_field(&self) -> usize { self.editor.current_field() } + fn cursor_position(&self) -> usize { self.editor.cursor_position() } + fn mode(&self) -> AppMode { self.editor.mode() } + fn current_text(&self) -> &str { self.editor.current_text() } + fn data_provider(&self) -> &D { self.editor.data_provider() } + fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() } + fn move_left(&mut self) { self.editor.move_left(); } + fn move_right(&mut self) { self.editor.move_right(); } + fn move_up(&mut self) { let _ = self.editor.move_up(); } + fn move_down(&mut self) { let _ = self.editor.move_down(); } +} + +fn handle_key_press( + key: KeyCode, + modifiers: KeyModifiers, + editor: &mut ComputedFieldsEditor, +) -> anyhow::Result { + let mode = editor.mode(); + + if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) + || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) + || key == KeyCode::F(10) + { + return Ok(false); + } + + match (mode, key, modifiers) { + (AppMode::ReadOnly, KeyCode::Char('i'), _) => { + editor.enter_edit_mode(); + } + (AppMode::ReadOnly, KeyCode::Char('a'), _) => { + editor.enter_append_mode(); + } + (AppMode::ReadOnly, KeyCode::Char('A'), _) => { + editor.editor.move_line_end(); + editor.enter_edit_mode(); + } + (_, KeyCode::Esc, _) => { + if mode == AppMode::Edit { + editor.exit_edit_mode(); + } + } + + // Movement + (AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => { + editor.move_left(); + } + (AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => { + editor.move_right(); + } + (AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => { + editor.move_down(); + } + (AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => { + editor.move_up(); + } + + // Edit mode movement + (AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); } + (AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); } + (AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); } + (AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); } + + // Navigation + (_, KeyCode::Tab, _) => { + editor.next_field(); + } + (_, KeyCode::BackTab, _) => { + editor.prev_field(); + } + + // Editing + (AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + editor.insert_char(c)?; + } + (AppMode::Edit, KeyCode::Backspace, _) => { + editor.delete_backward()?; + } + (AppMode::Edit, KeyCode::Delete, _) => { + editor.delete_forward()?; + } + + // Debug info + (AppMode::ReadOnly, KeyCode::Char('?'), _) => { + let current = editor.current_field(); + let field_name = editor.data_provider().field_name(current); + let field_type = if editor.is_computed_field(current) { + "COMPUTED (read-only)" + } else { + "EDITABLE" + }; + editor.debug_message = format!( + "{} - {} - Position {} - Mode: {:?}", + field_name, field_type, editor.cursor_position(), mode + ); + } + + _ => {} + } + + Ok(true) +} + +fn run_app( + terminal: &mut Terminal, + mut editor: ComputedFieldsEditor, +) -> io::Result<()> { + editor.update_computed_fields(); // Initial computation + + loop { + terminal.draw(|f| ui(f, &editor))?; + + if let Event::Key(key) = event::read()? { + match handle_key_press(key.code, key.modifiers, &mut editor) { + Ok(should_continue) => { + if !should_continue { + break; + } + } + Err(e) => { + editor.debug_message = format!("Error: {}", e); + } + } + } + } + + Ok(()) +} + +fn ui(f: &mut Frame, editor: &ComputedFieldsEditor) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(10)]) + .split(f.area()); + + render_enhanced_canvas(f, chunks[0], editor); + render_computed_status(f, chunks[1], editor); +} + +fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor) { + render_canvas_default(f, area, &editor.editor); +} + +fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Length(7)]) + .split(area); + + let mode_text = match editor.mode() { + AppMode::Edit => "INSERT", + AppMode::ReadOnly => "NORMAL", + _ => "OTHER", + }; + + let current = editor.current_field(); + let field_status = if editor.is_computed_field(current) { + "๐Ÿ“Š COMPUTED FIELD (read-only)" + } else { + "โœ๏ธ EDITABLE FIELD" + }; + + let status_text = format!("-- {} -- {} | {}", mode_text, field_status, editor.debug_message); + + let status = Paragraph::new(Line::from(Span::raw(status_text))) + .block(Block::default().borders(Borders::ALL).title("๐Ÿ’ฐ Invoice Calculator")); + + f.render_widget(status, chunks[0]); + + let help_text = match editor.mode() { + AppMode::ReadOnly => { + "๐Ÿ’ฐ COMPUTED FIELDS DEMO: Real-time invoice calculations!\n\ + ๐Ÿ”ข EDITABLE: Product, Quantity, Unit Price, Tax Rate, Notes\n\ + ๐Ÿ“Š COMPUTED: Subtotal, Tax Amount, Total (calculated automatically)\n\ + \n\ + ๐Ÿš€ START: Press 'i' to edit Quantity, type '5', Tab to Unit Price, type '19.99'\n\ + Watch Subtotal and Total appear! Add Tax Rate to see tax calculations.\n\ + Navigation: Tab/Shift+Tab skips computed fields automatically" + } + AppMode::Edit => { + "โœ๏ธ EDIT MODE: Type numbers to see calculations appear!\n\ + \n\ + ๐Ÿ’ก EXAMPLE: Type '5' in Quantity, then Tab to Unit Price and type '19.99'\n\ + โ€ข Subtotal appears: $99.95\n\ + โ€ข Total appears: $99.95\n\ + โ€ข Add Tax Rate (like '10') to see tax: $9.99, Total: $109.94\n\ + \n\ + Esc=normal, Tab=next field (auto-skips computed fields)" + } + _ => "๐Ÿ’ฐ Invoice Calculator with Computed Fields" + }; + + let help_style = if editor.is_computed_field(editor.current_field()) { + Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC) + } else { + Style::default().fg(Color::Gray) + }; + + let help = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("๐Ÿš€ Try It Now!")) + .style(help_style) + .wrap(Wrap { trim: true }); + + f.render_widget(help, chunks[1]); +} + +fn main() -> Result<(), Box> { + println!("๐Ÿ’ฐ Canvas Computed Fields Demo - Invoice Calculator"); + println!("โœ… computed feature: ENABLED"); + println!("๐Ÿš€ QUICK TEST:"); + println!(" 1. Press 'i' to edit Quantity"); + println!(" 2. Type '5' and press Tab"); + println!(" 3. Type '19.99' in Unit Price"); + println!(" 4. Watch Subtotal ($99.95) and Total ($99.95) appear!"); + println!(); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let data = InvoiceData::new(); + let editor = ComputedFieldsEditor::new(data); + + let res = run_app(&mut terminal, editor); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + println!("๐Ÿ’ฐ Demo completed! Computed fields should have updated in real-time!"); + Ok(()) +} diff --git a/canvas/src/canvas/state.rs b/canvas/src/canvas/state.rs index 4fe1a4e..dd68c50 100644 --- a/canvas/src/canvas/state.rs +++ b/canvas/src/canvas/state.rs @@ -23,6 +23,10 @@ pub struct EditorState { // Validation state (only available with validation feature) #[cfg(feature = "validation")] pub(crate) validation: crate::validation::ValidationState, + + /// Computed fields state (only when computed feature is enabled) + #[cfg(feature = "computed")] + pub(crate) computed: Option, } #[derive(Debug, Clone)] @@ -56,6 +60,8 @@ impl EditorState { selection: SelectionState::None, #[cfg(feature = "validation")] validation: crate::validation::ValidationState::new(), + #[cfg(feature = "computed")] + computed: None, } } @@ -68,6 +74,15 @@ impl EditorState { self.current_field } + /// Check if field is computed + #[cfg(feature = "computed")] + pub fn is_computed_field(&self, field_index: usize) -> bool { + self.computed + .as_ref() + .map(|state| state.is_computed_field(field_index)) + .unwrap_or(false) + } + /// Get current cursor position (for user's business logic) pub fn cursor_position(&self) -> usize { self.cursor_pos diff --git a/canvas/src/computed/mod.rs b/canvas/src/computed/mod.rs new file mode 100644 index 0000000..4fcf7eb --- /dev/null +++ b/canvas/src/computed/mod.rs @@ -0,0 +1,5 @@ +pub mod provider; +pub mod state; + +pub use provider::{ComputedContext, ComputedProvider}; +pub use state::ComputedState; \ No newline at end of file diff --git a/canvas/src/computed/provider.rs b/canvas/src/computed/provider.rs new file mode 100644 index 0000000..8ac2a01 --- /dev/null +++ b/canvas/src/computed/provider.rs @@ -0,0 +1,31 @@ +// ================================================================================================ +// COMPUTED FIELDS - Provider and Context +// ================================================================================================ + +/// Context information provided to computed field calculations +#[derive(Debug, Clone)] +pub struct ComputedContext<'a> { + /// All field values in the form (index -> value) + pub field_values: &'a [&'a str], + /// The field index being computed + pub target_field: usize, + /// Current field that user is editing (if any) + pub current_field: Option, +} + +/// User implements this to provide computed field logic +pub trait ComputedProvider { + /// Compute value for a field based on other field values. + /// Called automatically when any field changes. + fn compute_field(&mut self, context: ComputedContext) -> String; + + /// Check if this provider handles the given field. + fn handles_field(&self, field_index: usize) -> bool; + + /// Get list of field dependencies for optimization. + /// If field A depends on fields [1, 3], only recompute A when fields 1 or 3 change. + /// Default: depend on all fields (always recompute) with a reasonable upper bound. + fn field_dependencies(&self, _field_index: usize) -> Vec { + (0..100).collect() + } +} \ No newline at end of file diff --git a/canvas/src/computed/state.rs b/canvas/src/computed/state.rs new file mode 100644 index 0000000..4ce10b4 --- /dev/null +++ b/canvas/src/computed/state.rs @@ -0,0 +1,88 @@ +/* file: canvas/src/computed/state.rs */ +/* +Add computed state module file implementing caching and dependencies +*/ + +// ================================================================================================ +// COMPUTED FIELDS - State: caching and dependencies +// ================================================================================================ + +use std::collections::{HashMap, HashSet}; + +/// Internal state for computed field management +#[derive(Debug, Clone)] +pub struct ComputedState { + /// Cached computed values (field_index -> computed_value) + computed_values: HashMap, + /// Field dependency graph (field_index -> depends_on_fields) + dependencies: HashMap>, + /// Track which fields are computed (display-only) + computed_fields: HashSet, +} + +impl ComputedState { + /// Create a new, empty computed state + pub fn new() -> Self { + Self { + computed_values: HashMap::new(), + dependencies: HashMap::new(), + computed_fields: HashSet::new(), + } + } + + /// Register a field as computed with its dependencies + /// + /// - `field_index`: the field that is computed (display-only) + /// - `dependencies`: indices of fields this computed field depends on + pub fn register_computed_field(&mut self, field_index: usize, mut dependencies: Vec) { + // Deduplicate dependencies to keep graph lean + dependencies.sort_unstable(); + dependencies.dedup(); + + self.computed_fields.insert(field_index); + self.dependencies.insert(field_index, dependencies); + } + + /// Check if a field is computed (read-only, skip editing/navigation) + pub fn is_computed_field(&self, field_index: usize) -> bool { + self.computed_fields.contains(&field_index) + } + + /// Get cached computed value for a field, if available + pub fn get_computed_value(&self, field_index: usize) -> Option<&String> { + self.computed_values.get(&field_index) + } + + /// Update cached computed value for a field + pub fn set_computed_value(&mut self, field_index: usize, value: String) { + self.computed_values.insert(field_index, value); + } + + /// Get fields that should be recomputed when `changed_field` changed + /// + /// This scans the dependency graph and returns all computed fields + /// that list `changed_field` as a dependency. + pub fn fields_to_recompute(&self, changed_field: usize) -> Vec { + self.dependencies + .iter() + .filter_map(|(field, deps)| { + if deps.contains(&changed_field) { + Some(*field) + } else { + None + } + }) + .collect() + } + + /// Iterator over all computed field indices + pub fn computed_fields(&self) -> impl Iterator + '_ { + self.computed_fields.iter().copied() + } +} + +impl Default for ComputedState { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/canvas/src/data_provider.rs b/canvas/src/data_provider.rs index dd654c3..4afac75 100644 --- a/canvas/src/data_provider.rs +++ b/canvas/src/data_provider.rs @@ -34,6 +34,20 @@ pub trait DataProvider { fn validation_config(&self, _field_index: usize) -> Option { None } + + /// Check if field is computed (display-only, skip in navigation) + /// Default: not computed + #[cfg(feature = "computed")] + fn is_computed_field(&self, _field_index: usize) -> bool { + false + } + + /// Get computed field value if this is a computed field. + /// Returns None for regular fields. Default: not computed. + #[cfg(feature = "computed")] + fn computed_field_value(&self, _field_index: usize) -> Option { + None + } } /// Optional: User implements this for suggestions data diff --git a/canvas/src/editor.rs b/canvas/src/editor.rs index a2d1d15..211096a 100644 --- a/canvas/src/editor.rs +++ b/canvas/src/editor.rs @@ -668,6 +668,45 @@ impl FormEditor { return Ok(()); } + // Skip computed fields during navigation when feature enabled + #[cfg(feature = "computed")] + { + if let Some(computed_state) = &self.ui_state.computed { + // Find previous non-computed field + let mut candidate = self.ui_state.current_field; + for _ in 0..field_count { + candidate = candidate.saturating_sub(1); + if !computed_state.is_computed_field(candidate) { + // Validate and move as usual + #[cfg(feature = "validation")] + { + let current_text = self.current_text(); + if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) { + if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) { + tracing::debug!("Field switch blocked: {}", reason); + return Err(anyhow::anyhow!("Cannot switch fields: {}", reason)); + } + } + } + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + } + self.ui_state.move_to_field(candidate, field_count); + self.clamp_cursor_to_current_field(); + return Ok(()); + } + if candidate == 0 { + break; + } + } + } + } + // Check if field switching is allowed (minimum character enforcement) #[cfg(feature = "validation")] { @@ -705,6 +744,45 @@ impl FormEditor { return Ok(()); } + // Skip computed fields during navigation when feature enabled + #[cfg(feature = "computed")] + { + if let Some(computed_state) = &self.ui_state.computed { + // Find next non-computed field + let mut candidate = self.ui_state.current_field; + for _ in 0..field_count { + candidate = (candidate + 1).min(field_count - 1); + if !computed_state.is_computed_field(candidate) { + // Validate and move as usual + #[cfg(feature = "validation")] + { + let current_text = self.current_text(); + if !self.ui_state.validation.allows_field_switch(self.ui_state.current_field, current_text) { + if let Some(reason) = self.ui_state.validation.field_switch_block_reason(self.ui_state.current_field, current_text) { + tracing::debug!("Field switch blocked: {}", reason); + return Err(anyhow::anyhow!("Cannot switch fields: {}", reason)); + } + } + } + #[cfg(feature = "validation")] + { + let current_text = self.current_text().to_string(); + let _validation_result = self.ui_state.validation.validate_field_content( + self.ui_state.current_field, + ¤t_text, + ); + } + self.ui_state.move_to_field(candidate, field_count); + self.clamp_cursor_to_current_field(); + return Ok(()); + } + if candidate == field_count - 1 { + break; + } + } + } + } + // Check if field switching is allowed (minimum character enforcement) #[cfg(feature = "validation")] { @@ -763,6 +841,112 @@ impl FormEditor { self.move_up() } + // ================================================================================================ + // COMPUTED FIELDS (behind 'computed' feature) + // ================================================================================================ + + /// Initialize computed fields from provider and computed provider + #[cfg(feature = "computed")] + pub fn set_computed_provider(&mut self, mut provider: C) + where + C: crate::computed::ComputedProvider, + { + // Initialize computed state + self.ui_state.computed = Some(crate::computed::ComputedState::new()); + + // Register computed fields and their dependencies + let field_count = self.data_provider.field_count(); + for field_index in 0..field_count { + if provider.handles_field(field_index) { + let deps = provider.field_dependencies(field_index); + if let Some(computed_state) = &mut self.ui_state.computed { + computed_state.register_computed_field(field_index, deps); + } + } + } + + // Initial computation of all computed fields + self.recompute_all_fields(&mut provider); + } + + /// Recompute specific computed fields + #[cfg(feature = "computed")] + pub fn recompute_fields(&mut self, provider: &mut C, field_indices: &[usize]) + where + C: crate::computed::ComputedProvider, + { + if let Some(computed_state) = &mut self.ui_state.computed { + // Collect all field values for context + let field_values: Vec = (0..self.data_provider.field_count()) + .map(|i| { + if computed_state.is_computed_field(i) { + // Use cached computed value + computed_state + .get_computed_value(i) + .cloned() + .unwrap_or_default() + } else { + // Use regular field value + self.data_provider.field_value(i).to_string() + } + }) + .collect(); + + let field_refs: Vec<&str> = field_values.iter().map(|s| s.as_str()).collect(); + + // Recompute specified fields + for &field_index in field_indices { + if provider.handles_field(field_index) { + let context = crate::computed::ComputedContext { + field_values: &field_refs, + target_field: field_index, + current_field: Some(self.ui_state.current_field), + }; + + let computed_value = provider.compute_field(context); + computed_state.set_computed_value(field_index, computed_value); + } + } + } + } + + /// Recompute all computed fields + #[cfg(feature = "computed")] + pub fn recompute_all_fields(&mut self, provider: &mut C) + where + C: crate::computed::ComputedProvider, + { + if let Some(computed_state) = &self.ui_state.computed { + let computed_fields: Vec = computed_state.computed_fields().collect(); + self.recompute_fields(provider, &computed_fields); + } + } + + /// Trigger recomputation when field changes (call this after set_field_value) + #[cfg(feature = "computed")] + pub fn on_field_changed(&mut self, provider: &mut C, changed_field: usize) + where + C: crate::computed::ComputedProvider, + { + if let Some(computed_state) = &self.ui_state.computed { + let fields_to_update = computed_state.fields_to_recompute(changed_field); + if !fields_to_update.is_empty() { + self.recompute_fields(provider, &fields_to_update); + } + } + } + + /// Enhanced getter that returns computed values for computed fields when available + #[cfg(feature = "computed")] + pub fn effective_field_value(&self, field_index: usize) -> String { + if let Some(computed_state) = &self.ui_state.computed { + if let Some(computed_value) = computed_state.get_computed_value(field_index) { + return computed_value.clone(); + } + } + self.data_provider.field_value(field_index).to_string() + } + /// Move to next field (alternative to move_down) pub fn next_field(&mut self) -> Result<()> { self.move_down() @@ -960,6 +1144,15 @@ impl FormEditor { /// Enter edit mode from read-only mode (vim i/a/o) pub fn enter_edit_mode(&mut self) { + #[cfg(feature = "computed")] + { + if let Some(computed_state) = &self.ui_state.computed { + if computed_state.is_computed_field(self.ui_state.current_field) { + // Can't edit computed fields - silently ignore + return; + } + } + } self.set_mode(AppMode::Edit); } diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index faf4b10..899d5c8 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -12,6 +12,10 @@ pub mod suggestions; #[cfg(feature = "validation")] pub mod validation; +// Only include computed module if feature is enabled +#[cfg(feature = "computed")] +pub mod computed; + #[cfg(feature = "cursor-style")] pub use canvas::CursorManager; @@ -41,6 +45,10 @@ pub use validation::{ CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper, }; +// Computed exports (only when computed feature is enabled) +#[cfg(feature = "computed")] +pub use computed::{ComputedProvider, ComputedContext, ComputedState}; + // Theming and GUI #[cfg(feature = "gui")] pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};