Compare commits

..

29 Commits

Author SHA1 Message Date
Priec
25b54afff4 improved textarea normal editor mode, not just vim 2025-08-17 18:35:51 +02:00
Priec
b9a7f9a03f textarea 2025-08-17 17:52:40 +02:00
Priec
e36324af6f working textarea with example, time to prepare it for the future implementations 2025-08-17 12:17:46 +02:00
Priec
60cb45dcca first textarea implementation 2025-08-17 11:01:38 +02:00
Priec
215be3cf09 renamed capital lettered functions and fixed examples 2025-08-16 23:10:50 +02:00
Priec
b2aa966588 suggestions behind features flag only 2025-08-15 00:06:54 +02:00
Priec
67512ac151 src/editor.rs doesnt exist anymore 2025-08-15 00:06:19 +02:00
Priec
3f5dedbd6e a bit of a cleanup, updated functionality of ge now working porperly well 2025-08-14 14:23:08 +02:00
Priec
ce07105eea more vim functionality added 2025-08-14 00:08:18 +02:00
Priec
587470c48b vim like behaviour is being built 2025-08-13 22:16:28 +02:00
Priec
3227d341ed cleared codebase 2025-08-13 01:22:50 +02:00
Priec
2b16a80ef8 removed silenced variables 2025-08-12 09:53:24 +02:00
Priec
8b742bbe09 comments for reimplementation of autotrigger 2025-08-11 23:08:57 +02:00
Priec
189d3d2fc5 suggestions2 only on tab trigger and not automatic 2025-08-11 23:05:56 +02:00
Priec
082093ea17 compiled examples 2025-08-11 22:50:28 +02:00
Priec
280f314100 fixing examples 2025-08-11 12:41:42 +02:00
Priec
163a6262c8 proper example is being set 2025-08-11 12:03:56 +02:00
Priec
e8a564aed3 sugggestions are agnostic 2025-08-11 00:01:53 +02:00
filipriec
53464dfcbf switch handled by the library from now on 2025-08-10 22:07:25 +02:00
filipriec
b364a6606d fixing more code refactorization 2025-08-10 16:10:45 +02:00
Priec
f09e476bb6 working, restored 2025-08-10 12:20:43 +02:00
Priec
e2c9cc4347 WIP: staged changes before destructive reset 2025-08-10 11:03:31 +02:00
Priec
06106dc31b improvements done by gpt5 2025-08-08 23:10:23 +02:00
Priec
8e3c85991c fixed example, now working everything properly well 2025-08-07 23:30:31 +02:00
Priec
d3e5418221 fixed example of suggestions2 2025-08-07 20:05:39 +02:00
Priec
0d0e54032c better suggestions2 example, not there yet 2025-08-07 18:51:45 +02:00
Priec
a8de16f66d suggestions is getting more and more strong than ever before 2025-08-07 16:00:46 +02:00
Priec
5b2e0e976f fixing examples 2025-08-07 13:51:59 +02:00
Priec
d601134535 computed fields are working perfectly well now 2025-08-07 12:38:09 +02:00
44 changed files with 7844 additions and 1971 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ server/tantivy_indexes
steel_decimal/tests/property_tests.proptest-regressions
.direnv/
canvas/*.toml
.aider*

View File

@@ -29,11 +29,26 @@ regex = { workspace = true, optional = true }
tokio-test = "0.4.4"
[features]
default = []
default = ["textmode-vim"]
gui = ["ratatui", "crossterm"]
suggestions = ["tokio"]
cursor-style = ["crossterm"]
validation = ["regex"]
computed = []
textarea = ["gui"]
# text modes (mutually exclusive; default to vim)
textmode-vim = []
textmode-normal = []
all-nontextmodes = [
"gui",
"suggestions",
"cursor-style",
"validation",
"computed",
"textarea"
]
[[example]]
name = "suggestions"
@@ -41,26 +56,45 @@ required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions.rs"
[[example]]
name = "canvas_gui_demo"
required-features = ["gui"]
path = "examples/canvas_gui_demo.rs"
name = "suggestions2"
required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/suggestions2.rs"
[[example]]
name = "canvas_cursor_auto"
required-features = ["gui", "cursor-style"]
path = "examples/canvas_cursor_auto.rs"
[[example]]
name = "validation_1"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation"]
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "computed_fields"
required-features = ["gui", "computed"]
[[example]]
name = "textarea_vim"
required-features = ["gui", "cursor-style", "textarea", "textmode-vim"]
path = "examples/textarea_vim.rs"
[[example]]
name = "textarea_normal"
required-features = ["gui", "cursor-style", "textarea", "textmode-normal"]
path = "examples/textarea_normal.rs"

View File

@@ -275,7 +275,8 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
}
fn current_text(&self) -> &str {
self.editor.current_text()
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
@@ -306,6 +307,42 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn open_line_below(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_below();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
}
result
}
fn open_line_above(&mut self) -> anyhow::Result<()> {
let result = self.editor.open_line_above();
if result.is_ok() {
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
}
result
}
fn move_big_word_next(&mut self) {
self.editor.move_big_word_next();
self.update_visual_selection();
}
fn move_big_word_prev(&mut self) {
self.editor.move_big_word_prev();
self.update_visual_selection();
}
fn move_big_word_end(&mut self) {
self.editor.move_big_word_end();
self.update_visual_selection();
}
fn move_big_word_end_prev(&mut self) {
self.editor.move_big_word_end_prev();
self.update_visual_selection();
}
}
// Demo form data with interesting text for cursor demonstration
@@ -389,10 +426,17 @@ fn handle_key_press(
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
editor.move_line_end();
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar |
editor.set_debug_message("✏️ INSERT (open line) - Cursor: Steady Bar |".to_string());
if let Err(e) = editor.open_line_below() {
editor.set_debug_message(format!("Error opening line below: {}", e));
}
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
if let Err(e) = editor.open_line_above() {
editor.set_debug_message(format!("Error opening line above: {}", e));
}
editor.clear_command_buffer();
}
@@ -507,10 +551,40 @@ fn handle_key_press(
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
// Check if this is 'ge' command
if editor.get_command_buffer() == "g" {
editor.move_word_end_prev();
editor.set_debug_message("ge: previous word end".to_string());
editor.clear_command_buffer();
} else {
editor.move_word_end();
editor.set_debug_message("e: word end".to_string());
editor.clear_command_buffer();
}
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('W'), _) => {
editor.move_big_word_next();
editor.set_debug_message("W: next WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('B'), _) => {
editor.move_big_word_prev();
editor.set_debug_message("B: previous WORD start".to_string());
editor.clear_command_buffer();
}
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('E'), _) => {
// Check if this is 'gE' command
if editor.get_command_buffer() == "g" {
editor.move_big_word_end_prev();
editor.set_debug_message("gE: previous WORD end".to_string());
editor.clear_command_buffer();
} else {
editor.move_big_word_end();
editor.set_debug_message("E: WORD end".to_string());
editor.clear_command_buffer();
}
}
// Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
@@ -721,9 +795,9 @@ fn render_status_and_help(
}
} else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last\n\
i/a/A=insert, v/b=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic"
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, gg/G=first/last\n\
i/a/A/o/O=insert, v/V=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic"
}
}
AppMode::Edit => {

View File

@@ -0,0 +1,623 @@
// 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<usize>,
}
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<String> {
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<usize> {
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<D: DataProvider> {
editor: FormEditor<D>,
calculator: InvoiceCalculator,
debug_message: String,
last_computed_values: Vec<String>,
}
impl<D: DataProvider> ComputedFieldsEditor<D> {
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 {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
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<InvoiceData>,
) -> anyhow::Result<bool> {
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<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ComputedFieldsEditor<InvoiceData>,
) -> 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<InvoiceData>) {
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<InvoiceData>) {
render_canvas_default(f, area, &editor.editor);
}
fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
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<dyn std::error::Error>> {
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(())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
// examples/textarea_normal.rs
//! Demonstrates automatic cursor management with the textarea widget
//!
//! This example REQUIRES the `cursor-style` and `textarea` features to compile,
//! and is adapted for `textmode-normal` (always editing, no vim modes).
//!
//! Run with:
//! cargo run --example canvas_textarea_cursor_auto_normal --features "gui,cursor-style,textarea,textmode-normal"
#[cfg(not(feature = "cursor-style"))]
compile_error!(
"This example requires the 'cursor-style' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto_normal --features \"gui,cursor-style,textarea,textmode-normal\""
);
#[cfg(not(feature = "textarea"))]
compile_error!(
"This example requires the 'textarea' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto_normal --features \"gui,cursor-style,textarea,textmode-normal\""
);
use std::io;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{modes::AppMode, CursorManager},
textarea::{TextArea, TextAreaState},
};
/// TextArea demo adapted for NORMALMODE (always editing)
struct AutoCursorTextArea {
textarea: TextAreaState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
}
impl AutoCursorTextArea {
fn new() -> Self {
let initial_text = "🎯 Automatic Cursor Management Demo (NORMALMODE)\n\
Welcome to the textarea cursor demo!\n\
\n\
This demo runs in NORMALMODE:\n\
• Always editing (no insert/normal toggle)\n\
• Cursor is always underscore _\n\
\n\
Navigation commands:\n\
• hjkl or arrow keys: move cursor\n\
• w/b/e/W/B/E: word movements\n\
• 0/$: line start/end\n\
• g/gG: first/last line\n\
\n\
Editing commands:\n\
• x/X: delete characters\n\
\n\
Press ? for help, Ctrl+Q to quit.";
let mut textarea = TextAreaState::from_text(initial_text);
textarea.set_placeholder("Start typing...");
Self {
textarea,
has_unsaved_changes: false,
debug_message: "🎯 NORMALMODE Demo - always editing".to_string(),
command_buffer: String::new(),
}
}
fn handle_textarea_input(&mut self, key: KeyEvent) {
self.textarea.input(key);
self.has_unsaved_changes = true;
}
fn move_left(&mut self) {
self.textarea.move_left();
self.debug_message = "← left".to_string();
}
fn move_right(&mut self) {
self.textarea.move_right();
self.debug_message = "→ right".to_string();
}
fn move_up(&mut self) {
self.textarea.move_up();
self.debug_message = "↑ up".to_string();
}
fn move_down(&mut self) {
self.textarea.move_down();
self.debug_message = "↓ down".to_string();
}
fn move_word_next(&mut self) {
self.textarea.move_word_next();
self.debug_message = "w: next word".to_string();
}
fn move_word_prev(&mut self) {
self.textarea.move_word_prev();
self.debug_message = "b: previous word".to_string();
}
fn move_word_end(&mut self) {
self.textarea.move_word_end();
self.debug_message = "e: word end".to_string();
}
fn move_word_end_prev(&mut self) {
self.textarea.move_word_end_prev();
self.debug_message = "ge: previous word end".to_string();
}
fn move_line_start(&mut self) {
self.textarea.move_line_start();
self.debug_message = "0: line start".to_string();
}
fn move_line_end(&mut self) {
self.textarea.move_line_end();
self.debug_message = "$: line end".to_string();
}
fn move_first_line(&mut self) {
self.textarea.move_first_line();
self.debug_message = "gg: first line".to_string();
}
fn move_last_line(&mut self) {
self.textarea.move_last_line();
self.debug_message = "G: last line".to_string();
}
fn delete_char_forward(&mut self) {
if let Ok(_) = self.textarea.delete_forward() {
self.has_unsaved_changes = true;
self.debug_message = "x: deleted character".to_string();
}
}
fn delete_char_backward(&mut self) {
if let Ok(_) = self.textarea.delete_backward() {
self.has_unsaved_changes = true;
self.debug_message = "X: deleted character backward".to_string();
}
}
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn get_cursor_info(&self) -> String {
format!(
"Line {}, Col {}",
self.textarea.current_field() + 1,
self.textarea.cursor_position() + 1
)
}
// === BIG WORD MOVEMENTS ===
fn move_big_word_next(&mut self) {
self.textarea.move_big_word_next();
self.debug_message = "W: next WORD".to_string();
}
fn move_big_word_prev(&mut self) {
self.textarea.move_big_word_prev();
self.debug_message = "B: previous WORD".to_string();
}
fn move_big_word_end(&mut self) {
self.textarea.move_big_word_end();
self.debug_message = "E: WORD end".to_string();
}
fn move_big_word_end_prev(&mut self) {
self.textarea.move_big_word_end_prev();
self.debug_message = "gE: previous WORD end".to_string();
}
}
/// Handle key press in NORMALMODE (always editing, casual editor style)
fn handle_key_press(
key_event: KeyEvent,
editor: &mut AutoCursorTextArea,
) -> anyhow::Result<bool> {
let KeyEvent {
code: key,
modifiers,
..
} = key_event;
// Quit
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 (key, modifiers) {
// Movement
(KeyCode::Left, _) => editor.move_left(),
(KeyCode::Right, _) => editor.move_right(),
(KeyCode::Up, _) => editor.move_up(),
(KeyCode::Down, _) => editor.move_down(),
// Word movement (Ctrl+Arrows)
(KeyCode::Left, m) if m.contains(KeyModifiers::CONTROL) => editor.move_word_prev(),
(KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL) => editor.move_word_next(),
(KeyCode::Right, m) if m.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT) => {
editor.move_word_end()
}
// Line/document movement
(KeyCode::Home, _) => editor.move_line_start(),
(KeyCode::End, _) => editor.move_line_end(),
(KeyCode::Home, m) if m.contains(KeyModifiers::CONTROL) => editor.move_first_line(),
(KeyCode::End, m) if m.contains(KeyModifiers::CONTROL) => editor.move_last_line(),
// Delete
(KeyCode::Delete, _) => editor.delete_char_forward(),
(KeyCode::Backspace, _) => editor.delete_char_backward(),
// Debug/info
(KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: NORMALMODE (casual editor, underscore cursor)",
editor.get_cursor_info()
));
editor.clear_command_buffer();
}
// Default: treat as text input
_ => editor.handle_textarea_input(key_event),
}
Ok(true)
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut editor: AutoCursorTextArea) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(f: &mut Frame, area: ratatui::layout::Rect, editor: &mut AutoCursorTextArea) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎯 Textarea with NORMALMODE (always editing)");
let textarea_widget = TextArea::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, editor: &AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
let status_text = if editor.has_pending_command() {
format!(
"-- NORMALMODE (underscore cursor) -- {} [{}]",
editor.debug_message(),
editor.get_command_buffer()
)
} else if editor.has_unsaved_changes() {
format!(
"-- NORMALMODE (underscore cursor) -- [Modified] {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
} else {
format!(
"-- NORMALMODE (underscore cursor) -- {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Cursor Status"));
f.render_widget(status, chunks[0]);
let help_text = "🎯 NORMALMODE (always editing)\n\
hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
x/X=delete, typing inserts text\n\
?=info, Ctrl+Q=quit";
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Help"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎯 Canvas Textarea Cursor Auto Demo (NORMALMODE)");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("✅ textmode-normal feature: ENABLED");
println!("🚀 Always editing, underscore cursor active");
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 editor = AutoCursorTextArea::new();
let res = run_app(&mut terminal, editor);
CursorManager::reset()?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

@@ -0,0 +1,652 @@
// examples/textarea_vim.rs
//! Demonstrates automatic cursor management with the textarea widget
//!
//! This example REQUIRES the `cursor-style` and `textarea` features to compile.
//!
//! Run with:
//! cargo run --example canvas_textarea_cursor_auto --features "gui,cursor-style,textarea"
// REQUIRE cursor-style and textarea features
#[cfg(not(feature = "cursor-style"))]
compile_error!(
"This example requires the 'cursor-style' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
);
#[cfg(not(feature = "textarea"))]
compile_error!(
"This example requires the 'textarea' feature. \
Run with: cargo run --example canvas_textarea_cursor_auto --features \"gui,cursor-style,textarea\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
modes::AppMode,
CursorManager, // This import only exists when cursor-style feature is enabled
},
textarea::{TextArea, TextAreaState},
};
/// Enhanced TextArea that demonstrates automatic cursor management
/// Now uses direct FormEditor method calls via Deref!
struct AutoCursorTextArea {
textarea: TextAreaState,
has_unsaved_changes: bool,
debug_message: String,
command_buffer: String,
}
impl AutoCursorTextArea {
fn new() -> Self {
let initial_text = "🎯 Automatic Cursor Management Demo\n\
Welcome to the textarea cursor demo!\n\
\n\
Try different modes:\n\
• Normal mode: Block cursor █\n\
• Insert mode: Bar cursor |\n\
\n\
Navigation commands:\n\
• hjkl or arrow keys: move cursor\n\
• i/a/A/o/O: enter insert mode\n\
• w/b/e/W/B/E: word movements\n\
• Esc: return to normal mode\n\
\n\
Watch how the terminal cursor changes automatically!\n\
This text can be edited when in insert mode.\n\
\n\
Press ? for help, F1/F2 for manual cursor control demo.";
let mut textarea = TextAreaState::from_text(initial_text);
textarea.set_placeholder("Start typing...");
Self {
textarea,
has_unsaved_changes: false,
debug_message: "🎯 Automatic Cursor Demo - cursor-style feature enabled!".to_string(),
command_buffer: String::new(),
}
}
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
fn enter_insert_mode(&mut self) -> std::io::Result<()> {
self.textarea.enter_edit_mode(); // 🎯 Direct FormEditor method call via Deref!
CursorManager::update_for_mode(AppMode::Edit)?; // 🎯 Automatic: cursor becomes bar |
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar |".to_string();
Ok(())
}
fn enter_append_mode(&mut self) -> std::io::Result<()> {
self.textarea.enter_append_mode(); // 🎯 Direct FormEditor method call!
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar |".to_string();
Ok(())
}
fn exit_to_normal_mode(&mut self) -> std::io::Result<()> {
self.textarea.exit_edit_mode(); // 🎯 Direct FormEditor method call!
CursorManager::update_for_mode(AppMode::ReadOnly)?; // 🎯 Automatic: cursor becomes steady block
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
Ok(())
}
// === MANUAL CURSOR OVERRIDE DEMONSTRATION ===
fn demo_manual_cursor_control(&mut self) -> std::io::Result<()> {
// Users can still manually control cursor if needed
CursorManager::update_for_mode(AppMode::Command)?;
self.debug_message = "🔧 Manual override: Command cursor _".to_string();
Ok(())
}
fn restore_automatic_cursor(&mut self) -> std::io::Result<()> {
// Restore automatic cursor based on current mode
CursorManager::update_for_mode(self.textarea.mode())?; // 🎯 Direct method call!
self.debug_message = "🎯 Restored automatic cursor management".to_string();
Ok(())
}
// === TEXTAREA OPERATIONS ===
fn handle_textarea_input(&mut self, key: KeyEvent) {
self.textarea.input(key);
self.has_unsaved_changes = true;
}
// === MOVEMENT OPERATIONS (using direct FormEditor methods!) ===
fn move_left(&mut self) {
self.textarea.move_left(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("← left");
}
fn move_right(&mut self) {
self.textarea.move_right(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("→ right");
}
fn move_up(&mut self) {
self.textarea.move_up(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("↑ up");
}
fn move_down(&mut self) {
self.textarea.move_down(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("↓ down");
}
fn move_word_next(&mut self) {
self.textarea.move_word_next(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("w: next word");
}
fn move_word_prev(&mut self) {
self.textarea.move_word_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("b: previous word");
}
fn move_word_end(&mut self) {
self.textarea.move_word_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("e: word end");
}
fn move_word_end_prev(&mut self) {
self.textarea.move_word_end_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("ge: previous word end");
}
fn move_line_start(&mut self) {
self.textarea.move_line_start(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("0: line start");
}
fn move_line_end(&mut self) {
self.textarea.move_line_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("$: line end");
}
fn move_first_line(&mut self) {
self.textarea.move_first_line(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("gg: first line");
}
fn move_last_line(&mut self) {
self.textarea.move_last_line(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("G: last line");
}
// === BIG WORD MOVEMENTS ===
fn move_big_word_next(&mut self) {
self.textarea.move_big_word_next(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("W: next WORD");
}
fn move_big_word_prev(&mut self) {
self.textarea.move_big_word_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("B: previous WORD");
}
fn move_big_word_end(&mut self) {
self.textarea.move_big_word_end(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("E: WORD end");
}
fn move_big_word_end_prev(&mut self) {
self.textarea.move_big_word_end_prev(); // 🎯 Direct FormEditor method call!
self.update_debug_for_movement("gE: previous WORD end");
}
fn update_debug_for_movement(&mut self, action: &str) {
self.debug_message = action.to_string();
}
// === DELETE OPERATIONS ===
fn delete_char_forward(&mut self) {
if let Ok(_) = self.textarea.delete_forward() { // 🎯 Direct FormEditor method call!
self.has_unsaved_changes = true;
self.debug_message = "x: deleted character".to_string();
}
}
fn delete_char_backward(&mut self) {
if let Ok(_) = self.textarea.delete_backward() { // 🎯 Direct FormEditor method call!
self.has_unsaved_changes = true;
self.debug_message = "X: deleted character backward".to_string();
}
}
// === VIM-STYLE EDITING ===
fn open_line_below(&mut self) -> anyhow::Result<()> {
let result = self.textarea.open_line_below(); // 🎯 Textarea-specific override!
if result.is_ok() {
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (open line below) - Cursor: Steady Bar |".to_string();
self.has_unsaved_changes = true;
}
result
}
fn open_line_above(&mut self) -> anyhow::Result<()> {
let result = self.textarea.open_line_above(); // 🎯 Textarea-specific override!
if result.is_ok() {
CursorManager::update_for_mode(AppMode::Edit)?;
self.debug_message = "✏️ INSERT (open line above) - Cursor: Steady Bar |".to_string();
self.has_unsaved_changes = true;
}
result
}
// === COMMAND BUFFER HANDLING ===
fn clear_command_buffer(&mut self) {
self.command_buffer.clear();
}
fn add_to_command_buffer(&mut self, ch: char) {
self.command_buffer.push(ch);
}
fn get_command_buffer(&self) -> &str {
&self.command_buffer
}
fn has_pending_command(&self) -> bool {
!self.command_buffer.is_empty()
}
// === GETTERS ===
fn mode(&self) -> AppMode {
self.textarea.mode() // 🎯 Direct FormEditor method call!
}
fn debug_message(&self) -> &str {
&self.debug_message
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn set_debug_message(&mut self, msg: String) {
self.debug_message = msg;
}
fn get_cursor_info(&self) -> String {
format!(
"Line {}, Col {}",
self.textarea.current_field() + 1, // 🎯 Direct FormEditor method call!
self.textarea.cursor_position() + 1 // 🎯 Direct FormEditor method call!
)
}
}
/// Handle key press with automatic cursor management
fn handle_key_press(
key_event: KeyEvent,
editor: &mut AutoCursorTextArea,
) -> anyhow::Result<bool> {
let KeyEvent { code: key, modifiers, .. } = key_event;
let mode = editor.mode();
// Quit handling
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) {
// === MODE TRANSITIONS WITH AUTOMATIC CURSOR MANAGEMENT ===
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_insert_mode()?;
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode()?;
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.move_line_end();
editor.enter_insert_mode()?;
editor.clear_command_buffer();
}
// Vim o/O commands
(AppMode::ReadOnly, KeyCode::Char('o'), _) => {
if let Err(e) = editor.open_line_below() {
editor.set_debug_message(format!("Error opening line below: {}", e));
}
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('O'), _) => {
if let Err(e) = editor.open_line_above() {
editor.set_debug_message(format!("Error opening line above: {}", e));
}
editor.clear_command_buffer();
}
// Escape: Exit any mode back to normal
(AppMode::Edit, KeyCode::Esc, _) => {
editor.exit_to_normal_mode()?;
}
// === INSERT MODE: Pass to textarea ===
(AppMode::Edit, _, _) => {
editor.handle_textarea_input(key_event);
}
// === CURSOR MANAGEMENT DEMONSTRATION ===
(AppMode::ReadOnly, KeyCode::F(1), _) => {
editor.demo_manual_cursor_control()?;
}
(AppMode::ReadOnly, KeyCode::F(2), _) => {
editor.restore_automatic_cursor()?;
}
// === MOVEMENT: VIM-STYLE NAVIGATION (Normal mode) ===
(AppMode::ReadOnly, KeyCode::Char('h'), _)
| (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _)
| (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _)
| (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _)
| (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
editor.clear_command_buffer();
}
// Word movement
(AppMode::ReadOnly, KeyCode::Char('w'), _) => {
editor.move_word_next();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('b'), _) => {
editor.move_word_prev();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('e'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_word_end_prev();
editor.clear_command_buffer();
} else {
editor.move_word_end();
editor.clear_command_buffer();
}
}
// Big word movement (vim W/B/E commands)
(AppMode::ReadOnly, KeyCode::Char('W'), _) => {
editor.move_big_word_next();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('B'), _) => {
editor.move_big_word_prev();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('E'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_big_word_end_prev();
editor.clear_command_buffer();
} else {
editor.move_big_word_end();
editor.clear_command_buffer();
}
}
// Line movement
(AppMode::ReadOnly, KeyCode::Char('0'), _)
| (AppMode::ReadOnly, KeyCode::Home, _) => {
editor.move_line_start();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('$'), _)
| (AppMode::ReadOnly, KeyCode::End, _) => {
editor.move_line_end();
editor.clear_command_buffer();
}
// Document movement with command buffer
(AppMode::ReadOnly, KeyCode::Char('g'), _) => {
if editor.get_command_buffer() == "g" {
editor.move_first_line();
editor.clear_command_buffer();
} else {
editor.clear_command_buffer();
editor.add_to_command_buffer('g');
editor.set_debug_message("g".to_string());
}
}
(AppMode::ReadOnly, KeyCode::Char('G'), _) => {
editor.move_last_line();
editor.clear_command_buffer();
}
// === DELETE OPERATIONS (Normal mode) ===
(AppMode::ReadOnly, KeyCode::Char('x'), _) => {
editor.delete_char_forward();
editor.clear_command_buffer();
}
(AppMode::ReadOnly, KeyCode::Char('X'), _) => {
editor.delete_char_backward();
editor.clear_command_buffer();
}
// === DEBUG/INFO COMMANDS ===
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: {:?} - Cursor managed automatically!",
editor.get_cursor_info(),
mode
));
editor.clear_command_buffer();
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
editor.set_debug_message("Invalid command sequence".to_string());
} else {
editor.set_debug_message(format!(
"Unhandled: {:?} + {:?} in {:?} mode",
key, modifiers, mode
));
}
}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: AutoCursorTextArea,
) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &mut AutoCursorTextArea,
) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎯 Textarea with Automatic Cursor Management");
let textarea_widget = TextArea::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
// Set cursor position for terminal cursor
// Always show cursor - CursorManager handles the style (block/bar/blinking)
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(
f: &mut Frame,
area: ratatui::layout::Rect,
editor: &AutoCursorTextArea,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
// Status bar with cursor information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
AppMode::Highlight => "VISUAL █ (blinking block)",
_ => "NORMAL █ (block cursor)",
};
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}]", mode_text, editor.debug_message(), editor.get_command_buffer())
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {} | {}", mode_text, editor.debug_message(), editor.get_cursor_info())
} else {
format!("-- {} -- {} | {}", mode_text, editor.debug_message(), editor.get_cursor_info())
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Automatic Cursor Status"));
f.render_widget(status, chunks[0]);
// Help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
if editor.has_pending_command() {
match editor.get_command_buffer() {
"g" => "Press 'g' again for first line, or any other key to cancel",
_ => "Pending command... (Esc to cancel)"
}
} else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | \n\
Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
i/a/A/o/O=insert, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic, Ctrl+Q=quit"
}
}
AppMode::Edit => {
"🎯 INSERT MODE - Cursor: | (bar)\n\
Type to edit text, arrows=move, Enter=new line\n\
Esc=normal mode"
}
AppMode::Highlight => {
"🎯 VISUAL MODE - Cursor: █ (blinking block)\n\
hjkl/arrows=extend selection\n\
Esc=normal mode"
}
_ => "🎯 Watch the cursor change automatically!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Automatic Cursor Management"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Print feature status
println!("🎯 Canvas Textarea Cursor Auto Demo");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("🚀 Automatic cursor management: ACTIVE");
println!("📖 Watch your terminal cursor change based on mode!");
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 mut editor = AutoCursorTextArea::new();
// Initialize with normal mode - library automatically sets block cursor
editor.exit_to_normal_mode()?;
let res = run_app(&mut terminal, editor);
// Reset cursor on exit
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

@@ -1,7 +1,7 @@
// examples/validation_1.rs
//! Demonstrates field validation with the canvas library
//!
//! This example REQUIRES the `validation` feature to compile.
//! This example REQUIRES the `validation` and `cursor-style` features to compile.
//!
//! Run with:
//! cargo run --example validation_1 --features "gui,validation"
@@ -10,10 +10,10 @@
//! cargo run --example validation_1 --features "gui"
// REQUIRE validation feature - example won't compile without it
#[cfg(not(feature = "validation"))]
#[cfg(not(all(feature = "validation", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' feature. \
Run with: cargo run --example validation_1 --features \"gui,validation\""
"This example requires the 'validation' and 'cursor-style' features. \
Run with: cargo run --example validation_1 --features \"gui,validation,cursor-style\""
);
use std::io;
@@ -34,11 +34,11 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, CharacterLimits, ValidationResult,
@@ -61,10 +61,8 @@ struct ValidationFormEditor<D: DataProvider> {
impl<D: DataProvider> ValidationFormEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
// Enable validation by default
editor.set_validation_enabled(true);
Self {
editor,
has_unsaved_changes: false,
@@ -97,7 +95,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
if self.validation_enabled {
self.debug_message = "✅ Validation ENABLED - Try exceeding limits!".to_string();
} else {
@@ -109,14 +106,12 @@ impl<D: DataProvider> ValidationFormEditor<D> {
if !self.validation_enabled {
return (true, None);
}
let can_switch = self.editor.can_switch_fields();
let reason = if !can_switch {
self.editor.field_switch_block_reason()
} else {
None
};
(can_switch, reason)
}
@@ -124,11 +119,9 @@ impl<D: DataProvider> ValidationFormEditor<D> {
if !self.validation_enabled {
return "❌ DISABLED".to_string();
}
if self.field_switch_blocked {
return "🚫 SWITCH BLOCKED".to_string();
}
let summary = self.editor.validation_summary();
if summary.has_errors() {
format!("{} ERRORS", summary.error_fields)
@@ -161,7 +154,6 @@ impl<D: DataProvider> ValidationFormEditor<D> {
for i in 0..field_count {
self.editor.validate_field(i);
}
let summary = self.editor.validation_summary();
self.debug_message = format!(
"🔍 Validated all fields: {} valid, {} warnings, {} errors",
@@ -249,38 +241,37 @@ impl<D: DataProvider> ValidationFormEditor<D> {
if !self.validation_enabled {
return;
}
if let Some(result) = self.editor.current_field_validation() {
match result {
ValidationResult::Valid => {
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
}
ValidationResult::Warning { message } => {
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
}
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => {
self.debug_message = format!("Field {}: ✅ Valid", self.editor.current_field() + 1);
}
ValidationResult::Warning { message } => {
self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message);
}
} else {
self.debug_message = format!("Field {}: 🔍 Not validated yet", self.editor.current_field() + 1);
}
}
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Type to test validation".to_string();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to test validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Validation active".to_string();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Validation active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Press 'v' to validate current field".to_string();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'v' to validate current field".to_string();
self.update_field_validation_status();
}
@@ -289,22 +280,24 @@ impl<D: DataProvider> ValidationFormEditor<D> {
if result.is_ok() {
self.has_unsaved_changes = true;
// Show real-time validation feedback
if let Some(validation_result) = self.editor.current_field_validation() {
match validation_result {
ValidationResult::Valid => {
// Don't spam with valid messages, just show character count if applicable
if let Some(limits) = self.get_current_field_limits() {
if let Some(status) = limits.status_text(self.editor.current_text()) {
self.debug_message = format!("✏️ {}", status);
}
let validation_result = self.editor.validate_current_field();
match validation_result {
ValidationResult::Valid => {
// Don't spam with valid messages, just show character count if applicable
if let Some(limits) = self.get_current_field_limits() {
let field_index = self.editor.current_field();
if let Some(status) = limits.status_text(
self.editor.data_provider().field_value(field_index)
) {
self.debug_message = format!("✏️ {}", status);
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("{}", message);
}
}
ValidationResult::Warning { message } => {
self.debug_message = format!("⚠️ {}", message);
}
ValidationResult::Error { message } => {
self.debug_message = format!("{}", message);
}
}
}
@@ -350,7 +343,8 @@ impl<D: DataProvider> ValidationFormEditor<D> {
}
fn current_text(&self) -> &str {
self.editor.current_text()
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D {
@@ -362,6 +356,7 @@ impl<D: DataProvider> ValidationFormEditor<D> {
}
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
@@ -531,7 +526,6 @@ fn handle_key_press(
editor.enter_edit_mode();
editor.clear_command_buffer();
}
// Escape: Exit edit mode
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
@@ -624,7 +618,6 @@ fn handle_key_press(
summary.validated_fields
));
}
_ => {
if editor.has_pending_command() {
editor.clear_command_buffer();
@@ -656,7 +649,6 @@ fn run_app<B: Backend>(
}
}
}
Ok(())
}
@@ -694,33 +686,33 @@ fn render_validation_status(
// Status bar with validation information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
let status_text = if editor.has_pending_command() {
format!("-- {} -- {} [{}] | Validation: {}",
format!("-- {} -- {} [{}] | Validation: {}",
mode_text, editor.debug_message(), editor.get_command_buffer(), validation_status)
} else if editor.has_unsaved_changes() {
format!("-- {} -- [Modified] {} | Validation: {}",
format!("-- {} -- [Modified] {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
} else {
format!("-- {} -- {} | Validation: {}",
format!("-- {} -- {} | Validation: {}",
mode_text, editor.debug_message(), validation_status)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🔍 Validation Status"));
f.render_widget(status, chunks[0]);
// Validation summary with field switching info
let summary = editor.editor.validation_summary();
let summary_text = if editor.validation_enabled {
let switch_info = if editor.field_switch_blocked {
format!("\n🚫 Field switching blocked: {}",
format!("\n🚫 Field switching blocked: {}",
editor.block_reason.as_deref().unwrap_or("Unknown reason"))
} else {
let (can_switch, reason) = editor.check_field_switch_allowed();
@@ -759,34 +751,32 @@ fn render_validation_status(
.block(Block::default().borders(Borders::ALL).title("📈 Validation Overview"))
.style(summary_style)
.wrap(Wrap { trim: true });
f.render_widget(validation_summary, chunks[1]);
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🔍 VALIDATION DEMO: Different fields have different limits!\n\
Fields with MINIMUM requirements will block field switching if too short!\n\
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🔍 VALIDATION: Different fields have different limits (some block field switching)!\n\
Movement: hjkl/arrows=move, Tab/Shift+Tab=fields\n\
Edit: i/a/A=insert modes, Esc=normal\n\
Validation: v=validate current, V=validate all, c=clear results, F1=toggle\n\
?=info, Ctrl+C/Ctrl+Q=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Type to test validation limits!\n\
Some fields have MINIMUM character requirements!\n\
"🎯 INSERT MODE - Cursor: | (bar)\n\
🔍 Type to test validation limits (some fields have MIN requirements)!\n\
Try typing 1-2 chars in Password/ID/Comment fields, then try to switch!\n\
arrows=move, Backspace/Del=delete, Esc=normal, Tab=next field\n\
Field switching may be BLOCKED if minimum requirements not met!"
}
_ => "🔍 Validation Demo Active!"
_ => "🎯 Watch the cursor change automatically while validating!"
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Validation Commands"))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]);
}
@@ -810,10 +800,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?;
let data = ValidationDemoData::new();
let editor = ValidationFormEditor::new(data);
let mut editor = ValidationFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),

View File

@@ -4,13 +4,13 @@
//! This example showcases the full potential of the pattern validation system
//! with creative real-world scenarios and edge cases.
//!
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui"
//! Run with: cargo run --example validation_advanced_patterns --features "validation,gui,cursor-style"
// REQUIRE validation and gui features
#[cfg(not(all(feature = "validation", feature = "gui")))]
// REQUIRE validation, gui and cursor-style features
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' and 'gui' features. \
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui\""
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui,cursor-style\""
);
use std::io;
@@ -38,6 +38,7 @@ use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
@@ -70,12 +71,12 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
// ... (keeping all the same methods as before for brevity)
// [All the previous methods: clear_command_buffer, add_to_command_buffer, etc.]
fn clear_command_buffer(&mut self) { self.command_buffer.clear(); }
fn add_to_command_buffer(&mut self, ch: char) { self.command_buffer.push(ch); }
fn get_command_buffer(&self) -> &str { &self.command_buffer }
fn has_pending_command(&self) -> bool { !self.command_buffer.is_empty() }
fn toggle_validation(&mut self) {
self.validation_enabled = !self.validation_enabled;
self.editor.set_validation_enabled(self.validation_enabled);
@@ -88,14 +89,14 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn move_left(&mut self) { self.editor.move_left(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_right(&mut self) { self.editor.move_right(); self.field_switch_blocked = false; self.block_reason = None; }
fn move_up(&mut self) {
match self.editor.move_up() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
Err(e) => { self.field_switch_blocked = true; self.block_reason = Some(e.to_string()); self.debug_message = format!("🚫 Field switch blocked: {}", e); }
}
}
fn move_down(&mut self) {
match self.editor.move_down() {
Ok(()) => { self.update_field_validation_status(); self.field_switch_blocked = false; self.block_reason = None; }
@@ -105,32 +106,34 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn move_line_start(&mut self) { self.editor.move_line_start(); }
fn move_line_end(&mut self) { self.editor.move_line_end(); }
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Testing advanced pattern validation".to_string();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Testing advanced pattern validation".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Advanced patterns active".to_string();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Advanced patterns active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE".to_string();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
self.update_field_validation_status();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch);
if result.is_ok() {
if let Some(validation_result) = self.editor.current_field_validation() {
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
let validation_result = self.editor.validate_current_field();
match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
}
Ok(result?)
@@ -152,7 +155,10 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
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 current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
@@ -176,12 +182,11 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn update_field_validation_status(&mut self) {
if !self.validation_enabled { return; }
if let Some(result) = self.editor.current_field_validation() {
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
let result = self.editor.validate_current_field();
match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); }
ValidationResult::Warning { message } => { self.debug_message = format!("Field {}: ⚠️ {}", self.editor.current_field() + 1, message); }
ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
}
@@ -380,7 +385,7 @@ impl DataProvider for AdvancedPatternData {
// Even positions (0,2,4...): vowels (a,e,i,o,u)
// Odd positions (1,3,5...): consonants
let vowels = ['a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'];
// For demo purposes, we'll just accept alphabetic characters
// In real usage, you'd implement the alternating logic based on position
c.is_alphabetic()
@@ -522,9 +527,9 @@ fn render_advanced_validation_status(
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let validation_status = editor.get_validation_status();
@@ -613,6 +618,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Canvas Advanced Pattern Validation Demo");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🎯 Advanced pattern filtering: ACTIVE");
println!("🧪 Edge cases and complex patterns: READY");
println!("💡 Each field showcases different validation capabilities!");
@@ -625,10 +631,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?;
let data = AdvancedPatternData::new();
let editor = AdvancedPatternFormEditor::new(data);
let mut editor = AdvancedPatternFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),

View File

@@ -18,13 +18,13 @@
//! Each mask's input position count EXACTLY matches its character limit to prevent
//! the critical bug where users can type more characters than they can see.
//!
//! Run with: cargo run --example validation_3 --features "gui,validation"
//! Run with: cargo run --example validation_3 --features "gui,validation,cursor-style"
// REQUIRE validation and gui features for mask functionality
#[cfg(not(all(feature = "validation", feature = "gui")))]
// REQUIRE validation, gui and cursor-style features for mask functionality
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' and 'gui' features. \
Run with: cargo run --example validation_3 --features \"gui,validation\""
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_3 --features \"gui,validation,cursor-style\""
);
use std::io;
@@ -50,6 +50,7 @@ use canvas::{
canvas::{
gui::render_canvas_default,
modes::AppMode,
CursorManager,
},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, DisplayMask,
@@ -107,7 +108,7 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
fn get_current_field_info(&self) -> (String, String, String) {
let field_index = self.editor.current_field();
let raw_data = self.editor.current_text();
let raw_data = self.editor.data_provider().field_value(field_index);
let display_data = if self.validation_enabled {
self.editor.current_display_text()
} else {
@@ -116,8 +117,8 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
let mask_info = if let Some(config) = self.editor.validation_state().get_field_config(field_index) {
if let Some(mask) = &config.display_mask {
format!("Pattern: '{}', Mode: {:?}",
mask.pattern(),
format!("Pattern: '{}', Mode: {:?}",
mask.pattern(),
mask.display_mode())
} else {
"No mask configured".to_string()
@@ -130,13 +131,13 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
}
// === ENHANCED MOVEMENT WITH MASK AWARENESS ===
fn move_left(&mut self) {
self.editor.move_left();
fn move_left(&mut self) {
self.editor.move_left();
self.update_cursor_info();
}
fn move_right(&mut self) {
self.editor.move_right();
fn move_right(&mut self) {
self.editor.move_right();
self.update_cursor_info();
}
@@ -154,13 +155,13 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
}
}
fn move_line_start(&mut self) {
self.editor.move_line_start();
fn move_line_start(&mut self) {
self.editor.move_line_start();
self.update_cursor_info();
}
fn move_line_end(&mut self) {
self.editor.move_line_end();
fn move_line_end(&mut self) {
self.editor.move_line_end();
self.update_cursor_info();
}
@@ -183,18 +184,21 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
// === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
self.debug_message = "✏️ INSERT MODE - Type to see mask formatting in real-time".to_string();
self.debug_message = "✏️ INSERT MODE - Cursor: Steady Bar | - Type to see mask formatting in real-time".to_string();
}
fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_mode();
self.debug_message = "✏️ INSERT (append) - Mask formatting active".to_string();
self.debug_message = "✏️ INSERT (append) - Cursor: Steady Bar | - Mask formatting active".to_string();
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
self.debug_message = "🔒 NORMAL MODE - Press 'r' to see raw data, 'm' for mask info".to_string();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █ - Press 'r' to see raw data, 'm' for mask info".to_string();
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
@@ -233,10 +237,16 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
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 current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn set_mode(&mut self, mode: AppMode) { self.editor.set_mode(mode); }
fn set_mode(&mut self, mode: AppMode) {
// Library automatically updates cursor for the mode
self.editor.set_mode(mode);
}
fn next_field(&mut self) {
match self.editor.next_field() {
@@ -258,7 +268,7 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
fn show_mask_details(&mut self) {
let (raw, display, mask_info) = self.get_current_field_info();
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
self.debug_message = format!("🔍 Field {}: {} | Raw: '{}' Display: '{}'",
self.current_field() + 1, mask_info, raw, display);
}
@@ -324,11 +334,11 @@ impl DataProvider for MaskDemoData {
.build())
}
1 => {
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
// 📞 Phone (Template) - FIXED: Perfect mask/limit coordination
let phone_template = DisplayMask::new("(###) ###-####", '#')
.with_template('_');
Some(ValidationConfigBuilder::new()
.with_display_mask(phone_template)
.with_display_mask(phone_template)
.with_max_length(10) // ✅ CRITICAL: Exactly matches 10 input positions
.build())
}
@@ -354,7 +364,7 @@ impl DataProvider for MaskDemoData {
let ssn_mask = DisplayMask::new("XXX-XX-XXXX", 'X');
Some(ValidationConfigBuilder::new()
.with_display_mask(ssn_mask)
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
.with_max_length(9) // ✅ CRITICAL: Exactly matches 9 input positions
.build())
}
6 => {
@@ -582,15 +592,15 @@ fn render_mask_status(
// Status bar with mask information
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let mask_status = editor.get_mask_status();
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
mode_text,
editor.debug_message(),
let status_text = format!("-- {} -- {} | Masks: {} | View: {}",
mode_text,
editor.debug_message(),
mask_status,
if editor.show_raw_data { "RAW" } else { "FORMATTED" });
@@ -602,7 +612,7 @@ fn render_mask_status(
// Data comparison showing raw vs display
let (raw_data, display_data, mask_info) = editor.get_current_field_info();
let field_name = editor.data_provider().field_name(editor.current_field());
let comparison_text = format!(
"📝 Current Field: {}\n\
🔧 Mask Config: {}\n\
@@ -634,17 +644,19 @@ fn render_mask_status(
// Enhanced help text
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🎭 MASK DEMO: See how visual formatting keeps business logic clean!\n\
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🎭 MASK DEMO: Visual formatting keeps business logic clean!\n\
\n\
📱 Try different fields to see various mask patterns:\n\
• Dynamic vs Template modes • Custom separators • Different input chars\n\
\n\
Commands: i/a=insert, m=mask details, r=toggle raw/display view\n\
Movement: hjkl/arrows=move, 0=$=line start/end, Tab=next field, F1=toggle masks\n\
Movement: hjkl/arrows=move, 0/$=line start/end, Tab=next field, F1=toggle masks\n\
?=detailed info, Ctrl+C=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Type to see real-time mask formatting!\n\
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Type to see real-time mask formatting!\n\
\n\
🔥 Key Features in Action:\n\
• Separators auto-appear as you type • Cursor skips over separators\n\
@@ -670,6 +682,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("🎭 Display masks: ACTIVE");
println!("✅ cursor-style feature: ENABLED");
println!("🔥 Key Benefits Demonstrated:");
println!(" • Clean separation: Visual formatting ≠ Business logic");
println!(" • User-friendly: Pretty displays with automatic cursor handling");
@@ -690,7 +703,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?;
let data = MaskDemoData::new();
let editor = MaskDemoFormEditor::new(data);
let mut editor = MaskDemoFormEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
@@ -702,6 +721,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
)?;
terminal.show_cursor()?;
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
if let Err(err) = res {
println!("{:?}", err);
}

View File

@@ -1,6 +1,6 @@
/* examples/validation_4.rs
Enhanced Feature 4 Demo: Multiple custom formatters with comprehensive edge cases
Demonstrates:
- Multiple formatter types: PSC, Phone, Credit Card, Date
- Edge case handling: incomplete input, invalid chars, overflow
@@ -13,10 +13,10 @@
#![allow(clippy::needless_return)]
#[cfg(not(all(feature = "validation", feature = "gui")))]
#[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!(
"This example requires the 'validation' and 'gui' features. \
Run with: cargo run --example validation_4 --features \"gui,validation\""
"This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_4 --features \"gui,validation,cursor-style\""
);
use std::io;
@@ -39,7 +39,7 @@ use ratatui::{
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
canvas::{gui::render_canvas_default, modes::AppMode, CursorManager},
DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder,
CustomFormatter, FormattingResult,
@@ -53,18 +53,18 @@ impl CustomFormatter for PSCFormatter {
if raw.is_empty() {
return FormattingResult::success("");
}
// Validate: only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("PSC must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
1..=3 => FormattingResult::success(raw),
4 => FormattingResult::warning(
format!("{} ", &raw[..3]),
format!("{} ", &raw[..3]),
"PSC incomplete (4/5 digits)"
),
5 => {
@@ -88,12 +88,12 @@ impl CustomFormatter for PhoneFormatter {
if raw.is_empty() {
return FormattingResult::success("");
}
// Only digits allowed
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Phone must contain only digits");
}
let len = raw.chars().count();
match len {
0 => FormattingResult::success(""),
@@ -120,11 +120,11 @@ impl CustomFormatter for CreditCardFormatter {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Card number must contain only digits");
}
let mut formatted = String::new();
for (i, ch) in raw.chars().enumerate() {
if i > 0 && i % 4 == 0 {
@@ -132,7 +132,7 @@ impl CustomFormatter for CreditCardFormatter {
}
formatted.push(ch);
}
let len = raw.chars().count();
match len {
0..=15 => FormattingResult::warning(formatted, format!("Card incomplete ({}/16 digits)", len)),
@@ -155,11 +155,11 @@ impl CustomFormatter for DateFormatter {
if raw.is_empty() {
return FormattingResult::success("");
}
if !raw.chars().all(|c| c.is_ascii_digit()) {
return FormattingResult::error("Date must contain only digits");
}
let len = raw.len();
match len {
0 => FormattingResult::success(""),
@@ -170,11 +170,11 @@ impl CustomFormatter for DateFormatter {
let month = &raw[..2];
let day = &raw[2..4];
let year = &raw[4..];
// Basic validation
let m: u32 = month.parse().unwrap_or(0);
let d: u32 = day.parse().unwrap_or(0);
if m == 0 || m > 12 {
FormattingResult::warning(
format!("{}/{}/{}", month, day, year),
@@ -217,15 +217,15 @@ impl DataProvider for MultiFormatterDemoData {
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) {
self.fields[index].1 = value;
}
@@ -288,7 +288,7 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
match self.editor.current_field() {
0 => "PSC",
1 => "Phone",
2 => "Credit Card",
2 => "Credit Card",
3 => "Date",
_ => "Plain Text",
}
@@ -320,16 +320,16 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
// Edge cases
vec!["00000", "0000000000", "0000000000000000", "99", "13012024", ""],
];
self.example_mode = (self.example_mode + 1) % examples.len();
let current_examples = &examples[self.example_mode];
for (i, example) in current_examples.iter().enumerate() {
if i < self.editor.data_provider().field_count() {
self.editor.data_provider_mut().set_field_value(i, example.to_string());
}
}
let mode_names = ["Valid Examples", "Incomplete Input", "Invalid Characters", "Edge Cases"];
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]);
}
@@ -364,9 +364,10 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
}
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) {
let raw = self.editor.current_text();
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
let status = if raw == display {
if self.has_formatter() {
if self.mode() == AppMode::Edit {
@@ -403,21 +404,23 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
// Delegate methods with enhanced feedback
fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode();
let field_type = self.current_field_type();
let rules = self.get_input_rules();
self.debug_message = format!("✏️ EDITING {} - {}", field_type, rules);
self.debug_message = format!("✏️ INSERT MODE - Cursor: Steady Bar | - {} - {}", field_type, rules);
}
fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode();
let (raw, display, _, warning) = self.get_current_field_analysis();
if let Some(warn) = warning {
self.debug_message = format!("🔒 NORMAL - {} | ⚠️ {}", self.current_field_type(), warn);
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} | ⚠️ {}", self.current_field_type(), warn);
} else if raw != display {
self.debug_message = format!("🔒 NORMAL - {} formatted successfully", self.current_field_type());
self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} formatted successfully", self.current_field_type());
} else {
self.debug_message = "🔒 NORMAL MODE".to_string();
self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
}
}
@@ -443,9 +446,10 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_cursor_position();
let raw = self.editor.current_text();
let field_index = self.editor.current_field();
let raw = self.editor.data_provider().field_value(field_index);
let display = self.editor.current_display_text();
if raw_pos != display_pos {
self.debug_message = format!(
"🗺️ Position mapping: Raw[{}]='{}' ↔ Display[{}]='{}'",
@@ -466,7 +470,7 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn data_provider_mut(&mut self) -> &mut D { self.editor.data_provider_mut() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
fn move_left(&mut self) { let _ = self.editor.move_left(); }
@@ -486,7 +490,7 @@ fn handle_key_press(
let mode = editor.mode();
// Quit
if matches!(key, KeyCode::F(10)) ||
if matches!(key, KeyCode::F(10)) ||
(key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL)) ||
(key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return Ok(false);
@@ -528,7 +532,7 @@ fn handle_key_press(
let (raw, display, status, warning) = editor.get_current_field_analysis();
let warning_text = warning.map(|w| format!(" ⚠️ {}", w)).unwrap_or_default();
editor.debug_message = format!(
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
"🔍 Field {}: {} | Raw: '{}' | Display: '{}'{}",
editor.current_field() + 1, status, raw, display, warning_text
);
},
@@ -588,9 +592,9 @@ fn render_enhanced_status(
// Status bar
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "NORMAL █ (block cursor)",
};
let formatter_count = (0..editor.data_provider().field_count())
@@ -616,7 +620,7 @@ fn render_enhanced_status(
let (raw, display, status, warning) = editor.get_current_field_analysis();
let field_name = editor.data_provider().field_name(editor.current_field());
let field_type = editor.current_field_type();
let mut analysis_lines = vec![
format!("📝 Current: {} ({})", field_name, field_type),
format!("🔧 Status: {}", status),
@@ -660,7 +664,8 @@ fn render_enhanced_status(
// Enhanced help
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
"🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
\n\
Try these formatters:
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456
@@ -671,7 +676,8 @@ fn render_enhanced_status(
Ctrl+C/F10=quit"
}
AppMode::Edit => {
"✏️ INSERT MODE - Real-time formatting as you type!\n\
"🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Real-time formatting as you type!\n\
\n\
Current field rules: {}\n\
• Raw input is authoritative (what gets stored)\n\
@@ -701,6 +707,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🧩 Enhanced features:");
println!(" • 5 different custom formatters with edge cases");
println!(" • Real-time format preview and validation");
@@ -716,7 +723,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?;
let data = MultiFormatterDemoData::new();
let editor = EnhancedDemoEditor::new(data);
let mut editor = EnhancedDemoEditor::new(data);
// Initialize with normal mode - library automatically sets block cursor
editor.editor.set_mode(AppMode::ReadOnly);
// Demonstrate that CursorManager is available and working
CursorManager::update_for_mode(AppMode::ReadOnly)?;
let res = run_app(&mut terminal, editor);
@@ -724,6 +737,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
// Library automatically resets cursor on FormEditor::drop()
// But we can also manually reset if needed
CursorManager::reset()?;
if let Err(err) = res {
println!("{:?}", err);
}

File diff suppressed because it is too large Load Diff

View File

@@ -114,7 +114,7 @@ async fn state_machine_example() {
}
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"submit" => {
@@ -147,7 +147,7 @@ async fn state_machine_example() {
println!(" Initial state: {:?}", form.state);
// Type some text to trigger state change
let _result = ActionDispatcher::dispatch(
let result = ActionDispatcher::dispatch(
CanvasAction::InsertChar('u'),
&mut form,
&mut ideal_cursor,
@@ -231,7 +231,7 @@ async fn event_driven_example() {
self.has_changes = changed;
}
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {
@@ -384,7 +384,7 @@ async fn validation_pipeline_example() {
fn has_unsaved_changes(&self) -> bool { self.has_changes }
fn set_has_unsaved_changes(&mut self, changed: bool) { self.has_changes = changed; }
fn handle_feature_action(&mut self, action: &CanvasAction, _context: &ActionContext) -> Option<String> {
fn handle_feature_action(&mut self, action: &CanvasAction, context: &ActionContext) -> Option<String> {
match action {
CanvasAction::Custom(cmd) => match cmd.as_str() {
"validate" => {

View File

@@ -5,6 +5,12 @@ pub mod line;
pub mod char;
// Re-export commonly used functions
pub use word::{find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end};
pub use word::{
find_next_word_start, find_word_end, find_prev_word_start, find_prev_word_end,
find_next_big_word_start, find_prev_big_word_start, find_big_word_end, find_prev_big_word_end,
// Add these new exports:
find_last_word_start_in_field, find_last_word_end_in_field,
find_last_big_word_start_in_field, find_last_big_word_end_in_field,
};
pub use line::{line_start_position, line_end_position, safe_cursor_position};
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};

View File

@@ -1,6 +1,7 @@
// src/canvas/actions/movement/word.rs
// Replace the entire file with this corrected version:
#[derive(PartialEq)]
#[derive(PartialEq, Copy, Clone)]
enum CharType {
Whitespace,
Alphanumeric,
@@ -55,7 +56,7 @@ pub fn find_word_end(text: &str, current_pos: usize) -> usize {
let mut pos = current_pos.min(len - 1);
let current_type = get_char_type(chars[pos]);
// If we're not on whitespace, move to end of current word
if current_type != CharType::Whitespace {
while pos < len && get_char_type(chars[pos]) == current_type {
@@ -107,40 +108,296 @@ pub fn find_prev_word_start(text: &str, current_pos: usize) -> usize {
}
}
/// Find the end of the previous word
/// Find the end of the previous word (CORRECTED VERSION for vim's ge command)
pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
// Find all word end positions using boundary detection
let mut word_ends = Vec::new();
let mut in_word = false;
let mut current_word_type: Option<CharType> = None;
for (i, &ch) in chars.iter().enumerate() {
let char_type = get_char_type(ch);
match char_type {
CharType::Whitespace => {
if in_word {
// End of a word
word_ends.push(i - 1);
in_word = false;
current_word_type = None;
}
}
_ => {
if !in_word || current_word_type != Some(char_type) {
// Start of a new word (or word type change)
if in_word {
// End the previous word first
word_ends.push(i - 1);
}
in_word = true;
current_word_type = Some(char_type);
}
}
}
}
// Add the final word end if text doesn't end with whitespace
if in_word && !chars.is_empty() {
word_ends.push(chars.len() - 1);
}
// Find the largest word end position that's before current_pos
for &end_pos in word_ends.iter().rev() {
if end_pos < current_pos {
return end_pos;
}
}
0
}
/// Find the start of the next big_word (whitespace-separated)
pub fn find_next_big_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos >= chars.len() {
return text.chars().count();
}
let mut pos = current_pos;
// If we're on non-whitespace, skip to end of current big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Skip whitespace to find start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
pos
}
/// Find the start of the previous big_word (whitespace-separated)
pub fn find_prev_big_word_start(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace {
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace {
return 0;
}
if pos == 0 && get_char_type(chars[0]) != CharType::Whitespace {
return 0;
}
let word_type = get_char_type(chars[pos]);
while pos > 0 && get_char_type(chars[pos - 1]) == word_type {
// Find start of current big_word by going back while non-whitespace
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Skip whitespace before this word
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace {
pos -= 1;
}
if pos > 0 {
pos - 1
} else {
0
}
pos
}
/// Find the end of the current/next big_word (whitespace-separated)
pub fn find_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = current_pos;
// If we're on whitespace, skip to start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
// If we reached end, return it
if pos >= chars.len() {
return chars.len();
}
// Find end of current big_word (last non-whitespace char)
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
/// Find the end of the previous big_word (whitespace-separated)
pub fn find_prev_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If we hit start of text and it's whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Skip back to start of current big_word, then forward to end
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Now find end of this big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
// ============================================================================
// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement)
// ============================================================================
/// Find the start of the last word in a field (for cross-field 'b' movement)
pub fn find_last_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this word by going backwards while chars are the same type
let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" };
while pos > 0 {
let prev_char = chars[pos - 1];
let prev_type = if prev_char.is_alphanumeric() {
"alnum"
} else if prev_char.is_whitespace() {
"space"
} else {
"punct"
};
// Stop if we hit whitespace or different word type
if prev_type == "space" || prev_type != char_type {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last word in a field (for cross-field 'ge' movement)
pub fn find_last_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
// Start from the end and find the last non-whitespace character
let mut pos = chars.len() - 1;
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if chars[pos].is_whitespace() {
return 0;
}
// We're now at the end of the last word
pos
}
/// Find the start of the last big_word in a field (for cross-field 'B' movement)
pub fn find_last_big_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this big_word by going backwards while chars are non-whitespace
while pos > 0 {
let prev_char = chars[pos - 1];
// Stop if we hit whitespace (big_word boundary)
if prev_char.is_whitespace() {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last big_word in a field (for cross-field 'gE' movement)
pub fn find_last_big_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// We're now at the end of the last big_word
pos
}

View File

@@ -15,15 +15,26 @@ impl CursorManager {
/// Update cursor style based on current mode
#[cfg(feature = "cursor-style")]
pub fn update_for_mode(mode: AppMode) -> io::Result<()> {
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
};
execute!(io::stdout(), style)
// NORMALMODE: force underscore for every mode
#[cfg(feature = "textmode-normal")]
{
let style = SetCursorStyle::SteadyBar;
return execute!(io::stdout(), style);
}
// Default (not normal): original mapping
#[cfg(not(feature = "textmode-normal"))]
{
let style = match mode {
AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert
AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal
AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command
};
return execute!(io::stdout(), style);
}
}
/// No-op when cursor-style feature is disabled

View File

@@ -15,6 +15,7 @@ use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider;
use crate::editor::FormEditor;
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
use std::cmp::{max, min};
@@ -67,6 +68,19 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
// Precompute completion for active field
#[cfg(feature = "suggestions")]
let active_completion = if ui_state.is_suggestions_active()
&& ui_state.suggestions.active_field == Some(current_field_idx)
{
ui_state.suggestions.completion_text.clone()
} else {
None
};
#[cfg(not(feature = "suggestions"))]
let active_completion: Option<String> = None;
render_canvas_fields(
f,
area,
@@ -78,37 +92,30 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
highlight_state,
editor.display_cursor_position(), // Use display cursor position for masks
false, // TODO: track unsaved changes in editor
|i| {
// Get display value for field i using editor logic (Feature 4 + masks)
#[cfg(feature = "validation")]
{
editor.display_text_for_field(i)
}
#[cfg(not(feature = "validation"))]
{
data_provider.field_value(i).to_string()
}
// Closures for getting display values and overrides
#[cfg(feature = "validation")]
|field_idx| editor.display_text_for_field(field_idx),
#[cfg(not(feature = "validation"))]
|field_idx| data_provider.field_value(field_idx).to_string(),
// Closure for checking display overrides
#[cfg(feature = "validation")]
|field_idx| {
editor.ui_state().validation_state().get_field_config(field_idx)
.map(|cfg| {
let has_formatter = cfg.custom_formatter.is_some();
let has_mask = cfg.display_mask.is_some();
has_formatter || has_mask
})
.unwrap_or(false)
},
|i| {
// Check if field has display override (custom formatter or mask)
#[cfg(feature = "validation")]
{
editor.ui_state().validation_state().get_field_config(i)
.map(|cfg| {
// Formatter takes precedence; if present, it's a display override
#[allow(unused_mut)]
let mut has_override = false;
#[cfg(feature = "validation")]
{
has_override = cfg.custom_formatter.is_some();
}
has_override || cfg.display_mask.is_some()
})
.unwrap_or(false)
}
#[cfg(not(feature = "validation"))]
{
false
#[cfg(not(feature = "validation"))]
|_field_idx| false,
// Closure for providing completion
|field_idx| {
if field_idx == current_field_idx {
active_completion.clone()
} else {
None
}
},
)
@@ -128,7 +135,7 @@ fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionSta
/// Core canvas field rendering
#[cfg(feature = "gui")]
fn render_canvas_fields<T: CanvasTheme, F1, F2>(
fn render_canvas_fields<T: CanvasTheme, F1, F2, F3>(
f: &mut Frame,
area: Rect,
fields: &[&str],
@@ -141,10 +148,12 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
has_unsaved_changes: bool,
get_display_value: F1,
has_display_override: F2,
get_completion: F3,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
F3: Fn(usize) -> Option<String>,
{
// Create layout
let columns = Layout::default()
@@ -198,6 +207,7 @@ where
current_cursor_pos,
get_display_value,
has_display_override,
get_completion,
)
}
@@ -229,7 +239,7 @@ fn render_field_labels<T: CanvasTheme>(
/// Render field values with highlighting
#[cfg(feature = "gui")]
fn render_field_values<T: CanvasTheme, F1, F2>(
fn render_field_values<T: CanvasTheme, F1, F2, F3>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[String],
@@ -239,35 +249,55 @@ fn render_field_values<T: CanvasTheme, F1, F2>(
current_cursor_pos: usize,
get_display_value: F1,
has_display_override: F2,
get_completion: F3,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
F3: Fn(usize) -> Option<String>,
{
let mut active_field_input_rect = None;
for (i, _input) in inputs.iter().enumerate() {
// FIX: Iterate over indices only since we never use the input values directly
for i in 0..inputs.len() {
let is_active = i == *current_field_idx;
let text = get_display_value(i);
let typed_text = get_display_value(i);
// Apply highlighting
let line = apply_highlighting(
&text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
);
let line = if is_active {
// Compose typed + gray completion for the active field
let normal_style = Style::default().fg(theme.fg());
let gray_style = Style::default().fg(theme.suggestion_gray());
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(typed_text.clone(), normal_style));
if let Some(completion) = get_completion(i) {
if !completion.is_empty() {
spans.push(Span::styled(completion, gray_style));
}
}
Line::from(spans)
} else {
// Non-active fields: keep existing highlighting logic
apply_highlighting(
&typed_text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
)
};
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
// Set cursor for active field
// Set cursor for active field at end of typed text (not after completion)
if is_active {
active_field_input_rect = Some(input_rows[i]);
set_cursor_position(f, input_rows[i], &text, current_cursor_pos, has_display_override(i));
set_cursor_position(f, input_rows[i], &typed_text, current_cursor_pos, has_display_override(i));
}
}
@@ -283,7 +313,7 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize,
highlight_state: &HighlightState,
theme: &T,
_is_active: bool,
is_active: bool,
) -> Line<'a> {
let text_len = text.chars().count();
@@ -295,10 +325,10 @@ fn apply_highlighting<'a, T: CanvasTheme>(
))
}
HighlightState::Characterwise { anchor } => {
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, _is_active)
apply_characterwise_highlighting(text, text_len, field_index, current_field_idx, current_cursor_pos, anchor, theme, is_active)
}
HighlightState::Linewise { anchor_line } => {
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, _is_active)
apply_linewise_highlighting(text, field_index, current_field_idx, anchor_line, theme, is_active)
}
}
}
@@ -447,16 +477,24 @@ fn set_cursor_position(
field_rect: Rect,
text: &str,
current_cursor_pos: usize,
has_display_override: bool,
_has_display_override: bool,
) {
// BUG FIX: Use the correct display cursor position, not end of text
let cursor_x = field_rect.x + current_cursor_pos as u16;
// Sum display widths of the first current_cursor_pos characters
let mut cols: u16 = 0;
for (i, ch) in text.chars().enumerate() {
if i >= current_cursor_pos {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let cursor_x = field_rect.x.saturating_add(cols);
let cursor_y = field_rect.y;
// SAFETY: Ensure cursor doesn't go beyond field bounds
// Clamp to field bounds
let max_cursor_x = field_rect.x + field_rect.width.saturating_sub(1);
let safe_cursor_x = cursor_x.min(max_cursor_x);
f.set_cursor_position((safe_cursor_x, cursor_y));
}

View File

@@ -36,13 +36,22 @@ impl ModeManager {
/// Transition to new mode with automatic cursor update (when cursor-style feature enabled)
pub fn transition_to_mode(current_mode: AppMode, new_mode: AppMode) -> std::io::Result<AppMode> {
if current_mode != new_mode {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
#[cfg(feature = "textmode-normal")]
{
// Always force Edit in normalmode
return Ok(AppMode::Edit);
}
#[cfg(not(feature = "textmode-normal"))]
{
if current_mode != new_mode {
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
Ok(new_mode)
}
Ok(new_mode)
}
/// Enter highlight mode with cursor styling

View File

@@ -14,23 +14,31 @@ pub struct EditorState {
// Mode state
pub(crate) current_mode: AppMode,
// Suggestions dropdown state
// Suggestions dropdown state (only available with suggestions feature)
#[cfg(feature = "suggestions")]
pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode)
pub(crate) selection: SelectionState,
// 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<crate::computed::ComputedState>,
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
pub struct SuggestionsUIState {
pub(crate) is_active: bool,
pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>,
pub(crate) active_query: Option<String>,
pub(crate) completion_text: Option<String>,
}
#[derive(Debug, Clone)]
@@ -46,16 +54,27 @@ impl EditorState {
current_field: 0,
cursor_pos: 0,
ideal_cursor_column: 0,
// NORMALMODE: always start in Edit
#[cfg(feature = "textmode-normal")]
current_mode: AppMode::Edit,
// Default (vim): start in ReadOnly
#[cfg(not(feature = "textmode-normal"))]
current_mode: AppMode::ReadOnly,
#[cfg(feature = "suggestions")]
suggestions: SuggestionsUIState {
is_active: false,
is_loading: false,
selected_index: None,
active_field: None,
active_query: None,
completion_text: None,
},
selection: SelectionState::None,
#[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(),
#[cfg(feature = "computed")]
computed: None,
}
}
@@ -68,6 +87,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
@@ -84,11 +112,13 @@ impl EditorState {
}
/// Check if suggestions dropdown is active (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.suggestions.is_active
}
/// Check if suggestions dropdown is loading (for user's business logic)
#[cfg(feature = "suggestions")]
pub fn is_suggestions_loading(&self) -> bool {
self.suggestions.is_loading
}
@@ -97,7 +127,7 @@ impl EditorState {
pub fn selection_state(&self) -> &SelectionState {
&self.selection
}
/// Get validation state (for user's business logic)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
@@ -117,7 +147,12 @@ impl EditorState {
}
}
pub(crate) fn set_cursor(&mut self, position: usize, max_position: usize, for_edit_mode: bool) {
pub(crate) fn set_cursor(
&mut self,
position: usize,
max_position: usize,
for_edit_mode: bool,
) {
if for_edit_mode {
// Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position);
@@ -128,18 +163,26 @@ impl EditorState {
self.ideal_cursor_column = self.cursor_pos;
}
pub(crate) fn activate_suggestions(&mut self, field_index: usize) {
/// Explicitly open suggestions — should only be called on Tab
#[cfg(feature = "suggestions")]
pub(crate) fn open_suggestions(&mut self, field_index: usize) {
self.suggestions.is_active = true;
self.suggestions.is_loading = true;
self.suggestions.active_field = Some(field_index);
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
pub(crate) fn deactivate_suggestions(&mut self) {
/// Explicitly close suggestions — should be called on Esc or field change
#[cfg(feature = "suggestions")]
pub(crate) fn close_suggestions(&mut self) {
self.suggestions.is_active = false;
self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
}
}

View File

@@ -14,6 +14,7 @@ pub trait CanvasTheme {
fn highlight(&self) -> Color;
fn highlight_bg(&self) -> Color;
fn warning(&self) -> Color;
fn suggestion_gray(&self) -> Color;
}
@@ -47,4 +48,7 @@ impl CanvasTheme for DefaultCanvasTheme {
fn warning(&self) -> Color {
Color::Red
}
fn suggestion_gray(&self) -> Color {
Color::DarkGray
}
}

View File

@@ -0,0 +1,5 @@
pub mod provider;
pub mod state;
pub use provider::{ComputedContext, ComputedProvider};
pub use state::ComputedState;

View File

@@ -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<usize>,
}
/// 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<usize> {
(0..100).collect()
}
}

View File

@@ -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<usize, String>,
/// Field dependency graph (field_index -> depends_on_fields)
dependencies: HashMap<usize, Vec<usize>>,
/// Track which fields are computed (display-only)
computed_fields: HashSet<usize>,
}
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<usize>) {
// 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<usize> {
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<Item = usize> + '_ {
self.computed_fields.iter().copied()
}
}
impl Default for ComputedState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,7 +1,9 @@
// src/data_provider.rs
//! Simplified user interface - only business data, no UI state
#[cfg(feature = "suggestions")]
use anyhow::Result;
#[cfg(feature = "suggestions")]
use async_trait::async_trait;
/// User implements this - only business data, no UI state
@@ -27,16 +29,31 @@ pub trait DataProvider {
fn display_value(&self, _index: usize) -> Option<&str> {
None // Default: use actual value
}
/// Get validation configuration for a field (optional)
/// Only available when the 'validation' feature is enabled
#[cfg(feature = "validation")]
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
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<String> {
None
}
}
/// Optional: User implements this for suggestions data
#[cfg(feature = "suggestions")]
#[async_trait]
pub trait SuggestionsProvider {
/// Fetch suggestions (user's business logic)
@@ -44,6 +61,7 @@ pub trait SuggestionsProvider {
-> Result<Vec<SuggestionItem>>;
}
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)]
pub struct SuggestionItem {
pub display_text: String,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
// src/editor/computed_helpers.rs
use crate::computed::{ComputedContext, ComputedProvider, ComputedState};
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "computed")]
pub fn set_computed_provider<C>(&mut self, mut provider: C)
where
C: ComputedProvider,
{
self.ui_state.computed = Some(ComputedState::new());
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);
}
}
}
self.recompute_all_fields(&mut provider);
}
#[cfg(feature = "computed")]
pub fn recompute_fields<C>(
&mut self,
provider: &mut C,
field_indices: &[usize],
) where
C: ComputedProvider,
{
if let Some(computed_state) = &mut self.ui_state.computed {
let field_values: Vec<String> = (0..self.data_provider.field_count())
.map(|i| {
if computed_state.is_computed_field(i) {
computed_state
.get_computed_value(i)
.cloned()
.unwrap_or_default()
} else {
self.data_provider.field_value(i).to_string()
}
})
.collect();
let field_refs: Vec<&str> =
field_values.iter().map(|s| s.as_str()).collect();
for &field_index in field_indices {
if provider.handles_field(field_index) {
let context = 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,
);
}
}
}
}
#[cfg(feature = "computed")]
pub fn recompute_all_fields<C>(&mut self, provider: &mut C)
where
C: ComputedProvider,
{
if let Some(computed_state) = &self.ui_state.computed {
let computed_fields: Vec<usize> =
computed_state.computed_fields().collect();
self.recompute_fields(provider, &computed_fields);
}
}
#[cfg(feature = "computed")]
pub fn on_field_changed<C>(
&mut self,
provider: &mut C,
changed_field: usize,
) where
C: 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);
}
}
}
#[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()
}
}

131
canvas/src/editor/core.rs Normal file
View File

@@ -0,0 +1,131 @@
// src/editor/core.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::EditorState;
use crate::DataProvider;
#[cfg(feature = "suggestions")]
use crate::SuggestionItem;
pub struct FormEditor<D: DataProvider> {
pub(crate) ui_state: EditorState,
pub(crate) data_provider: D,
#[cfg(feature = "suggestions")]
pub(crate) suggestions: Vec<SuggestionItem>,
#[cfg(feature = "validation")]
pub(crate) external_validation_callback: Option<
Box<
dyn FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync,
>,
>,
}
impl<D: DataProvider> FormEditor<D> {
// Make helpers visible to sibling modules in this crate
pub(crate) fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or_else(|| s.len())
}
#[allow(dead_code)]
pub(crate) fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
s[..byte_idx].chars().count()
}
pub fn new(data_provider: D) -> Self {
let editor = Self {
ui_state: EditorState::new(),
data_provider,
#[cfg(feature = "suggestions")]
suggestions: Vec::new(),
#[cfg(feature = "validation")]
external_validation_callback: None,
};
#[cfg(feature = "validation")]
{
let mut editor = editor;
editor.initialize_validation();
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(editor.ui_state.current_mode);
}
editor
}
#[cfg(not(feature = "validation"))]
{
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(editor.ui_state.current_mode);
}
editor
}
}
// Library-internal, used by multiple modules
pub(crate) fn current_text(&self) -> &str {
let field_index = self.ui_state.current_field;
if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
}
}
// Read-only getters
pub fn current_field(&self) -> usize {
self.ui_state.current_field()
}
pub fn cursor_position(&self) -> usize {
self.ui_state.cursor_position()
}
pub fn mode(&self) -> AppMode {
self.ui_state.mode()
}
#[cfg(feature = "suggestions")]
pub fn is_suggestions_active(&self) -> bool {
self.ui_state.is_suggestions_active()
}
pub fn ui_state(&self) -> &EditorState {
&self.ui_state
}
pub fn data_provider(&self) -> &D {
&self.data_provider
}
pub fn data_provider_mut(&mut self) -> &mut D {
&mut self.data_provider
}
#[cfg(feature = "suggestions")]
pub fn suggestions(&self) -> &[SuggestionItem] {
&self.suggestions
}
#[cfg(feature = "validation")]
pub fn validation_state(&self) -> &crate::validation::ValidationState {
self.ui_state.validation_state()
}
// Cursor cleanup
#[cfg(feature = "cursor-style")]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
CursorManager::reset()
}
#[cfg(not(feature = "cursor-style"))]
pub fn cleanup_cursor(&self) -> std::io::Result<()> {
Ok(())
}
}
impl<D: DataProvider> Drop for FormEditor<D> {
fn drop(&mut self) {
let _ = self.cleanup_cursor();
}
}

View File

@@ -0,0 +1,123 @@
// src/editor/display.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Get current field text for display.
/// Policies documented in original file.
#[cfg(feature = "validation")]
pub fn current_display_text(&self) -> String {
let field_index = self.ui_state.current_field;
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if matches!(self.ui_state.current_mode, AppMode::Edit) {
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Get effective display text for any field index (Feature 4 + masks).
#[cfg(feature = "validation")]
pub fn display_text_for_field(&self, field_index: usize) -> String {
let raw = if field_index < self.data_provider.field_count() {
self.data_provider.field_value(field_index)
} else {
""
};
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if cfg.custom_formatter.is_none() {
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
if cfg.custom_formatter.is_some() {
if field_index == self.ui_state.current_field
&& matches!(self.ui_state.current_mode, AppMode::Edit)
{
return raw.to_string();
}
if let Some((formatted, _mapper, _warning)) =
cfg.run_custom_formatter(raw)
{
return formatted;
}
}
if let Some(mask) = &cfg.display_mask {
return mask.apply_to_display(raw);
}
}
raw.to_string()
}
/// Map raw cursor to display position (formatter/mask aware).
pub fn display_cursor_position(&self) -> usize {
let current_text = self.current_text();
let char_count = current_text.chars().count();
let raw_pos = match self.ui_state.current_mode {
AppMode::Edit => self.ui_state.cursor_pos.min(char_count),
_ => {
if char_count == 0 {
0
} else {
self.ui_state
.cursor_pos
.min(char_count.saturating_sub(1))
}
}
};
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(field_index) {
if !matches!(self.ui_state.current_mode, AppMode::Edit) {
if let Some((formatted, mapper, _)) =
cfg.run_custom_formatter(current_text)
{
return mapper.raw_to_formatted(
current_text,
&formatted,
raw_pos,
);
}
}
if let Some(mask) = &cfg.display_mask {
return mask.raw_pos_to_display_pos(raw_pos);
}
}
}
raw_pos
}
}

View File

@@ -0,0 +1,348 @@
// src/editor/editing.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Open new line below (vim o)
pub fn open_line_below(&mut self) -> anyhow::Result<()> {
// paste the method body unchanged from editor.rs
// (exact code from your VIM COMMANDS: o and O section)
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let next_field = (self.ui_state.current_field + 1)
.min(field_count.saturating_sub(1));
self.transition_to_field(next_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Open new line above (vim O)
pub fn open_line_above(&mut self) -> anyhow::Result<()> {
let prev_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(prev_field)?;
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.enter_edit_mode();
Ok(())
}
/// Handle character insertion (mask/limit-aware)
pub fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
// paste entire insert_char body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
#[cfg(feature = "validation")]
let field_index = self.ui_state.current_field;
#[cfg(feature = "validation")]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(not(feature = "validation"))]
let field_index = self.ui_state.current_field;
#[cfg(not(feature = "validation"))]
let raw_cursor_pos = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let current_raw_text = self.data_provider.field_value(field_index);
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_cursor_pos =
mask.raw_pos_to_display_pos(raw_cursor_pos);
let pattern_char_len = mask.pattern().chars().count();
if display_cursor_pos >= pattern_char_len {
return Ok(());
}
if !mask.is_input_position(display_cursor_pos) {
return Ok(());
}
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if current_raw_text.chars().count() >= input_slots {
return Ok(());
}
}
}
}
#[cfg(feature = "validation")]
{
let vr = self.ui_state.validation.validate_char_insertion(
field_index,
current_raw_text,
raw_cursor_pos,
ch,
);
if !vr.is_acceptable() {
return Ok(());
}
}
let new_raw_text = {
let mut temp = current_raw_text.to_string();
let byte_pos = Self::char_to_byte_index(
current_raw_text,
raw_cursor_pos,
);
temp.insert(byte_pos, ch);
temp
};
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(limits) = &cfg.character_limits {
if let Some(result) = limits.validate_content(&new_raw_text)
{
if !result.is_acceptable() {
return Ok(());
}
}
}
if let Some(mask) = &cfg.display_mask {
let pattern_char_len = mask.pattern().chars().count();
let input_slots = (0..pattern_char_len)
.filter(|&pos| mask.is_input_position(pos))
.count();
if new_raw_text.chars().count() > input_slots {
return Ok(());
}
}
}
}
self.data_provider
.set_field_value(field_index, new_raw_text.clone());
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let new_raw_pos = raw_cursor_pos + 1;
let display_pos = mask.raw_pos_to_display_pos(new_raw_pos);
let next_input_display =
mask.next_input_position(display_pos);
let next_raw_pos =
mask.display_pos_to_raw_pos(next_input_display);
let max_raw = new_raw_text.chars().count();
self.ui_state.cursor_pos = next_raw_pos.min(max_raw);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
return Ok(());
}
}
}
self.ui_state.cursor_pos = raw_cursor_pos + 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
Ok(())
}
/// Delete backward (backspace)
pub fn delete_backward(&mut self) -> anyhow::Result<()> {
// paste entire delete_backward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
if self.ui_state.cursor_pos == 0 {
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
let new_cursor = self.ui_state.cursor_pos.saturating_sub(1);
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos - 1,
);
let end =
Self::char_to_byte_index(&current_text, self.ui_state.cursor_pos);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = new_cursor;
#[cfg(not(feature = "validation"))]
let target_cursor = new_cursor;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(new_cursor);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
target_cursor =
mask.display_pos_to_raw_pos(prev_input);
}
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
Ok(())
}
/// Delete forward (Delete key)
pub fn delete_forward(&mut self) -> anyhow::Result<()> {
// paste entire delete_forward body unchanged
if self.ui_state.current_mode != crate::canvas::modes::AppMode::Edit
{
return Ok(());
}
let field_index = self.ui_state.current_field;
let mut current_text =
self.data_provider.field_value(field_index).to_string();
if self.ui_state.cursor_pos < current_text.chars().count() {
let start = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos,
);
let end = Self::char_to_byte_index(
&current_text,
self.ui_state.cursor_pos + 1,
);
current_text.replace_range(start..end, "");
self.data_provider
.set_field_value(field_index, current_text.clone());
#[cfg(feature = "validation")]
let mut target_cursor = self.ui_state.cursor_pos;
#[cfg(not(feature = "validation"))]
let target_cursor = self.ui_state.cursor_pos;
#[cfg(feature = "validation")]
{
if let Some(cfg) = self.ui_state.validation.get_field_config(
field_index,
) {
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(
self.ui_state.cursor_pos,
);
let next_input =
mask.next_input_position(display_pos);
target_cursor = mask
.display_pos_to_raw_pos(next_input)
.min(current_text.chars().count());
}
}
}
self.ui_state.cursor_pos = target_cursor;
self.ui_state.ideal_cursor_column = target_cursor;
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&current_text,
);
}
}
Ok(())
}
/// Enter edit mode with cursor positioned for append (vim 'a')
pub fn enter_append_mode(&mut self) {
// paste body unchanged
let current_text = self.current_text();
let char_len = current_text.chars().count();
let append_pos = if current_text.is_empty() {
0
} else {
(self.ui_state.cursor_pos + 1).min(char_len)
};
self.ui_state.cursor_pos = append_pos;
self.ui_state.ideal_cursor_column = append_pos;
self.set_mode(crate::canvas::modes::AppMode::Edit);
}
/// Set current field value (validates under feature flag)
pub fn set_current_field_value(&mut self, value: String) {
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(field_index, value.clone());
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
/// Set specific field value by index (validates under feature flag)
pub fn set_field_value(&mut self, field_index: usize, value: String) {
if field_index < self.data_provider.field_count() {
self.data_provider
.set_field_value(field_index, value.clone());
if field_index == self.ui_state.current_field {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
}
#[cfg(feature = "validation")]
{
let _ = self
.ui_state
.validation
.validate_field_content(field_index, &value);
}
}
}
/// Clear the current field
pub fn clear_current_field(&mut self) {
self.set_current_field_value(String::new());
}
}

21
canvas/src/editor/mod.rs Normal file
View File

@@ -0,0 +1,21 @@
// src/editor/mod.rs
// Only module declarations and re-exports.
pub mod core;
pub mod display;
pub mod editing;
pub mod movement;
pub mod navigation;
pub mod mode;
#[cfg(feature = "suggestions")]
pub mod suggestions;
#[cfg(feature = "validation")]
pub mod validation_helpers;
#[cfg(feature = "computed")]
pub mod computed_helpers;
// Re-export the main type
pub use core::FormEditor;

310
canvas/src/editor/mode.rs Normal file
View File

@@ -0,0 +1,310 @@
// src/editor/mode.rs
#[cfg(feature = "cursor-style")]
use crate::canvas::CursorManager;
use crate::canvas::modes::AppMode;
use crate::canvas::state::SelectionState;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Change mode
pub fn set_mode(&mut self, mode: AppMode) {
// Avoid unused param warning in normalmode
#[cfg(feature = "textmode-normal")]
let _ = mode;
// NORMALMODE: force Edit, ignore requested mode
#[cfg(feature = "textmode-normal")]
{
self.ui_state.current_mode = AppMode::Edit;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Edit);
}
return;
}
// Default (not normal): original vim behavior
#[cfg(not(feature = "textmode-normal"))]
match (self.ui_state.current_mode, mode) {
(AppMode::ReadOnly, AppMode::Highlight) => {
self.enter_highlight_mode();
}
(AppMode::Highlight, AppMode::ReadOnly) => {
self.exit_highlight_mode();
}
(_, new_mode) => {
self.ui_state.current_mode = new_mode;
if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None;
}
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(new_mode);
}
}
}
}
/// Exit edit mode to read-only mode
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
#[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,
)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
return Err(anyhow::anyhow!(
"Cannot exit edit mode: {}",
reason
));
}
}
}
let current_text = self.current_text();
if !current_text.is_empty() {
let max_normal_pos =
current_text.chars().count().saturating_sub(1);
if self.ui_state.cursor_pos > max_normal_pos {
self.ui_state.cursor_pos = max_normal_pos;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if cfg.external_validation_enabled {
let text = self.current_text().to_string();
if !text.is_empty() {
self.set_external_validation(
field_index,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(field_index, &text);
self.set_external_validation(field_index, final_state);
}
}
}
}
}
// NORMALMODE: stay in Edit (do not switch to ReadOnly)
#[cfg(feature = "textmode-normal")]
{
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
return Ok(());
}
// Default (not normal): original vim behavior
#[cfg(not(feature = "textmode-normal"))]
{
self.set_mode(AppMode::ReadOnly);
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
}
/// Enter edit mode
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)
{
return;
}
}
}
// NORMALMODE: already in Edit, but enforce it
#[cfg(feature = "textmode-normal")]
{
self.ui_state.current_mode = AppMode::Edit;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Edit);
}
return;
}
// Default (not normal): vim behavior
#[cfg(not(feature = "textmode-normal"))]
self.set_mode(AppMode::Edit);
}
// -------------------- Highlight/Visual mode -------------------------
pub fn enter_highlight_mode(&mut self) {
// NORMALMODE: ignore request (stay in Edit)
#[cfg(feature = "textmode-normal")]
{
return;
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection = SelectionState::Characterwise {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos),
};
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
}
pub fn enter_highlight_line_mode(&mut self) {
// NORMALMODE: ignore
#[cfg(feature = "textmode-normal")]
{
return;
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::ReadOnly {
self.ui_state.current_mode = AppMode::Highlight;
self.ui_state.selection =
SelectionState::Linewise { anchor_field: self.ui_state.current_field };
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::Highlight);
}
}
}
}
pub fn exit_highlight_mode(&mut self) {
// NORMALMODE: ignore
#[cfg(feature = "textmode-normal")]
{
return;
}
// Default (not normal): original vim
#[cfg(not(feature = "textmode-normal"))]
{
if self.ui_state.current_mode == AppMode::Highlight {
self.ui_state.current_mode = AppMode::ReadOnly;
self.ui_state.selection = SelectionState::None;
#[cfg(feature = "cursor-style")]
{
let _ = CursorManager::update_for_mode(AppMode::ReadOnly);
}
}
}
}
pub fn is_highlight_mode(&self) -> bool {
#[cfg(feature = "textmode-normal")]
{
return false;
}
#[cfg(not(feature = "textmode-normal"))]
{
return self.ui_state.current_mode == AppMode::Highlight;
}
}
pub fn selection_state(&self) -> &SelectionState {
&self.ui_state.selection
}
// Visual-mode movements reuse existing movement methods
// These keep calling the movement methods; in normalmode selection is never enabled,
// so these just move without creating a selection.
pub fn move_left_with_selection(&mut self) {
let _ = self.move_left();
}
pub fn move_right_with_selection(&mut self) {
let _ = self.move_right();
}
pub fn move_up_with_selection(&mut self) {
let _ = self.move_up();
}
pub fn move_down_with_selection(&mut self) {
let _ = self.move_down();
}
pub fn move_word_next_with_selection(&mut self) {
self.move_word_next();
}
pub fn move_word_end_with_selection(&mut self) {
self.move_word_end();
}
pub fn move_word_prev_with_selection(&mut self) {
self.move_word_prev();
}
pub fn move_word_end_prev_with_selection(&mut self) {
self.move_word_end_prev();
}
pub fn move_big_word_next_with_selection(&mut self) {
self.move_big_word_next();
}
pub fn move_big_word_end_with_selection(&mut self) {
self.move_big_word_end();
}
pub fn move_big_word_prev_with_selection(&mut self) {
self.move_big_word_prev();
}
pub fn move_big_word_end_prev_with_selection(&mut self) {
self.move_big_word_end_prev();
}
pub fn move_line_start_with_selection(&mut self) {
self.move_line_start();
}
pub fn move_line_end_with_selection(&mut self) {
self.move_line_end();
}
}

View File

@@ -0,0 +1,690 @@
// src/editor/movement.rs
use crate::canvas::actions::movement::line::{
line_end_position, line_start_position,
};
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
use crate::canvas::actions::movement::word::{
find_last_big_word_start_in_field, find_last_word_start_in_field,
};
impl<D: DataProvider> FormEditor<D> {
/// Move cursor left within current field (mask-aware)
pub fn move_left(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
if let Some(prev_input) =
mask.prev_input_position(display_pos)
{
let raw_pos =
mask.display_pos_to_raw_pos(prev_input);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = raw_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
} else {
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
moved = true;
}
}
}
}
if !moved {
if self.ui_state.cursor_pos > 0 {
self.ui_state.cursor_pos -= 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move cursor right within current field (mask-aware)
pub fn move_right(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")]
let mut moved = false;
#[cfg(not(feature = "validation"))]
let moved = false;
#[cfg(feature = "validation")]
{
let field_index = self.ui_state.current_field;
if let Some(cfg) =
self.ui_state.validation.get_field_config(field_index)
{
if let Some(mask) = &cfg.display_mask {
let display_pos =
mask.raw_pos_to_display_pos(self.ui_state.cursor_pos);
let next_display_pos = mask.next_input_position(display_pos);
let next_pos =
mask.display_pos_to_raw_pos(next_display_pos);
let max_pos = self.current_text().chars().count();
self.ui_state.cursor_pos = next_pos.min(max_pos);
self.ui_state.ideal_cursor_column =
self.ui_state.cursor_pos;
moved = true;
}
}
}
if !moved {
let max_pos = self.current_text().chars().count();
if self.ui_state.cursor_pos < max_pos {
self.ui_state.cursor_pos += 1;
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
}
}
Ok(())
}
/// Move to start of current field (vim 0)
pub fn move_line_start(&mut self) {
let new_pos = line_start_position();
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Move to end of current field (vim $)
pub fn move_line_end(&mut self) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_pos = line_end_position(current_text, is_edit_mode);
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
}
/// Set cursor to exact position (for f/F/t/T etc.)
pub fn set_cursor_position(&mut self, position: usize) {
let current_text = self.current_text();
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let max_pos = if is_edit_mode {
char_len
} else {
char_len.saturating_sub(1)
};
let clamped_pos = position.min(max_pos);
self.ui_state.cursor_pos = clamped_pos;
self.ui_state.ideal_cursor_column = clamped_pos;
}
}
impl<D: DataProvider> FormEditor<D> {
/// Move to start of next word (vim w) - can cross field boundaries
pub fn move_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first word in new field
let first_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first word
find_next_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_word_pos.min(char_len)
} else {
first_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous word (vim b) - can cross field boundaries
pub fn move_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
return;
}
// Try to find previous word in current field
let new_pos = find_prev_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal word movement within current field - we found a previous word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_start = find_last_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_word_start;
self.ui_state.ideal_cursor_column = last_word_start;
}
}
}
}
}
/// Move to end of current/next word (vim e) - can cross field boundaries
pub fn move_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Recursively call move_word_end in the new field
self.move_word_end();
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find word end from there
let next_pos = find_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Position at start and find first word end
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
self.move_word_end();
}
} else {
// Normal word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous word (vim ge) - can cross field boundaries
pub fn move_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{find_prev_word_end, find_last_word_end_in_field};
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field (but don't recurse)
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find end of last word in the field
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field (but don't recurse)
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
return;
}
let new_pos = find_prev_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
// We didn't move within the current field, try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_word_end = find_last_word_end_in_field(new_text);
self.ui_state.cursor_pos = last_word_end;
self.ui_state.ideal_cursor_column = last_word_end;
}
}
}
} else {
// Normal word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of next big_word (vim W) - can cross field boundaries
pub fn move_big_word_next(&mut self) {
use crate::canvas::actions::movement::word::find_next_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field
if self.move_down().is_ok() {
// Successfully moved to next field, try to find first big_word
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_next_big_word_start(current_text, current_pos);
// Check if we've hit the end of the current field
if new_pos >= current_text.chars().count() {
// At end of field - jump to next field and start from beginning
if self.move_down().is_ok() {
// Successfully moved to next field
let new_text = self.current_text();
if new_text.is_empty() {
// New field is empty, cursor stays at 0
self.ui_state.cursor_pos = 0;
self.ui_state.ideal_cursor_column = 0;
} else {
// Find first big_word in new field
let first_big_word_pos = if new_text.chars().next().map_or(false, |c| !c.is_whitespace()) {
// Field starts with non-whitespace, go to position 0
0
} else {
// Field starts with whitespace, find first big_word
find_next_big_word_start(new_text, 0)
};
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_pos.min(char_len)
} else {
first_big_word_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
// If move_down() failed, we stay where we are (at end of last field)
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to start of previous big_word (vim B) - can cross field boundaries
pub fn move_big_word_prev(&mut self) {
use crate::canvas::actions::movement::word::find_prev_big_word_start;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to previous field and find last big_word
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
// Special case: if we're at position 0, jump to previous field
if current_pos == 0 {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
return;
}
// Try to find previous big_word in current field
let new_pos = find_prev_big_word_start(current_text, current_pos);
// Check if we actually moved
if new_pos < current_pos {
// Normal big_word movement within current field - we found a previous big_word
self.ui_state.cursor_pos = new_pos;
self.ui_state.ideal_cursor_column = new_pos;
} else {
// We didn't move (probably at start of first big_word), try previous field
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
// Check if we actually moved to a different field
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_start = find_last_big_word_start_in_field(new_text);
self.ui_state.cursor_pos = last_big_word_start;
self.ui_state.ideal_cursor_column = last_big_word_start;
}
}
}
}
}
/// Move to end of current/next big_word (vim E) - can cross field boundaries
pub fn move_big_word_end(&mut self) {
use crate::canvas::actions::movement::word::find_big_word_end;
let current_text = self.current_text();
if current_text.is_empty() {
// Empty field - try to move to next field (but don't recurse)
if self.move_down().is_ok() {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(char_len)
} else {
first_big_word_end.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let char_len = current_text.chars().count();
let new_pos = find_big_word_end(current_text, current_pos);
// Check if we didn't move or hit the end of the field
if new_pos == current_pos && current_pos + 1 < char_len {
// Try next character and find big_word end from there
let next_pos = find_big_word_end(current_text, current_pos + 1);
if next_pos < char_len {
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
next_pos.min(char_len)
} else {
next_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
return;
}
}
// If we're at or near the end of the field, try next field (but don't recurse)
if new_pos >= char_len.saturating_sub(1) {
if self.move_down().is_ok() {
// Find first big_word end in new field
let new_text = self.current_text();
if !new_text.is_empty() {
let first_big_word_end = find_big_word_end(new_text, 0);
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let new_char_len = new_text.chars().count();
let final_pos = if is_edit_mode {
first_big_word_end.min(new_char_len)
} else {
first_big_word_end.min(new_char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
} else {
// Normal big_word end movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
/// Move to end of previous big_word (vim gE) - can cross field boundaries
pub fn move_big_word_end_prev(&mut self) {
use crate::canvas::actions::movement::word::{
find_prev_big_word_end, find_big_word_end,
};
let current_text = self.current_text();
if current_text.is_empty() {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
// Find first big_word end in new field
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
}
return;
}
let current_pos = self.ui_state.cursor_pos;
let new_pos = find_prev_big_word_end(current_text, current_pos);
// Only try to cross fields if we didn't move at all (stayed at same position)
if new_pos == current_pos {
let current_field = self.ui_state.current_field;
if self.move_up().is_ok() {
if self.ui_state.current_field != current_field {
let new_text = self.current_text();
if !new_text.is_empty() {
let last_big_word_end = find_big_word_end(new_text, 0);
self.ui_state.cursor_pos = last_big_word_end;
self.ui_state.ideal_cursor_column = last_big_word_end;
}
}
}
} else {
// Normal big_word movement within current field
let is_edit_mode = self.ui_state.current_mode == AppMode::Edit;
let char_len = current_text.chars().count();
let final_pos = if is_edit_mode {
new_pos.min(char_len)
} else {
new_pos.min(char_len.saturating_sub(1))
};
self.ui_state.cursor_pos = final_pos;
self.ui_state.ideal_cursor_column = final_pos;
}
}
}

View File

@@ -0,0 +1,177 @@
// src/editor/navigation.rs
use crate::canvas::modes::AppMode;
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
/// Centralized field transition logic (unchanged).
pub fn transition_to_field(&mut self, new_field: usize) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let prev_field = self.ui_state.current_field;
#[cfg(feature = "computed")]
let mut target_field = new_field.min(field_count - 1);
#[cfg(not(feature = "computed"))]
let target_field = new_field.min(field_count - 1);
#[cfg(feature = "computed")]
{
if let Some(computed_state) = &self.ui_state.computed {
if computed_state.is_computed_field(target_field) {
if target_field >= prev_field {
for i in (target_field + 1)..field_count {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
}
} else {
let mut i = target_field;
loop {
if !computed_state.is_computed_field(i) {
target_field = i;
break;
}
if i == 0 {
break;
}
i -= 1;
}
}
}
}
}
if target_field == prev_field {
return Ok(());
}
#[cfg(feature = "validation")]
self.ui_state.validation.clear_last_switch_block();
#[cfg(feature = "validation")]
{
let current_text = self.current_text();
if !self
.ui_state
.validation
.allows_field_switch(prev_field, current_text)
{
if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason(prev_field, current_text)
{
self.ui_state
.validation
.set_last_switch_block(reason.clone());
tracing::debug!("Field switch blocked: {}", reason);
return Err(anyhow::anyhow!(
"Cannot switch fields: {}",
reason
));
}
}
}
#[cfg(feature = "validation")]
{
let text =
self.data_provider.field_value(prev_field).to_string();
let _ = self
.ui_state
.validation
.validate_field_content(prev_field, &text);
if let Some(cfg) =
self.ui_state.validation.get_field_config(prev_field)
{
if cfg.external_validation_enabled && !text.is_empty() {
self.set_external_validation(
prev_field,
crate::validation::ExternalValidationState::Validating,
);
if let Some(cb) =
self.external_validation_callback.as_mut()
{
let final_state = cb(prev_field, &text);
self.set_external_validation(prev_field, final_state);
}
}
}
}
#[cfg(feature = "computed")]
{
// Placeholder for recompute hook if needed later
}
self.ui_state.move_to_field(target_field, field_count);
let current_text = self.current_text();
let max_pos = current_text.chars().count();
self.ui_state.set_cursor(
self.ui_state.ideal_cursor_column,
max_pos,
self.ui_state.current_mode == AppMode::Edit,
);
// Automatically close suggestions on field switch
#[cfg(feature = "suggestions")]
{
self.close_suggestions();
}
Ok(())
}
/// Move to first line (vim gg)
pub fn move_first_line(&mut self) -> anyhow::Result<()> {
self.transition_to_field(0)
}
/// Move to last line (vim G)
pub fn move_last_line(&mut self) -> anyhow::Result<()> {
let last_field =
self.data_provider.field_count().saturating_sub(1);
self.transition_to_field(last_field)
}
/// Move to previous field (vim k / up)
pub fn move_up(&mut self) -> anyhow::Result<()> {
let new_field = self.ui_state.current_field.saturating_sub(1);
self.transition_to_field(new_field)
}
/// Move to next field (vim j / down)
pub fn move_down(&mut self) -> anyhow::Result<()> {
let new_field = (self.ui_state.current_field + 1)
.min(self.data_provider.field_count().saturating_sub(1));
self.transition_to_field(new_field)
}
/// Move to next field cyclic
pub fn move_to_next_field(&mut self) -> anyhow::Result<()> {
let field_count = self.data_provider.field_count();
if field_count == 0 {
return Ok(());
}
let new_field = (self.ui_state.current_field + 1) % field_count;
self.transition_to_field(new_field)
}
/// Aliases
pub fn prev_field(&mut self) -> anyhow::Result<()> {
self.move_up()
}
pub fn next_field(&mut self) -> anyhow::Result<()> {
self.move_down()
}
}

View File

@@ -0,0 +1,166 @@
// src/editor/suggestions.rs
use crate::editor::FormEditor;
use crate::{DataProvider, SuggestionItem};
impl<D: DataProvider> FormEditor<D> {
/// Compute inline completion for current selection and text
fn compute_current_completion(&self) -> Option<String> {
let typed = self.current_text();
let idx = self.ui_state.suggestions.selected_index?;
let sugg = self.suggestions.get(idx)?;
if let Some(rest) = sugg.value_to_store.strip_prefix(typed) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
None
}
/// Update UI state's completion text from current selection
pub fn update_inline_completion(&mut self) {
self.ui_state.suggestions.completion_text =
self.compute_current_completion();
}
/// Open the suggestions UI for `field_index`
pub fn open_suggestions(&mut self, field_index: usize) {
self.ui_state.open_suggestions(field_index);
}
/// Close suggestions UI and clear current suggestion results
pub fn close_suggestions(&mut self) {
self.ui_state.close_suggestions();
self.suggestions.clear();
}
/// Handle Escape key in ReadOnly mode (closes suggestions if active)
pub fn handle_escape_readonly(&mut self) {
if self.ui_state.suggestions.is_active {
self.close_suggestions();
}
}
// ----------------- Non-blocking suggestions API --------------------
#[cfg(feature = "suggestions")]
pub fn start_suggestions(&mut self, field_index: usize) -> Option<String> {
if !self.data_provider.supports_suggestions(field_index) {
return None;
}
let query = self.current_text().to_string();
self.ui_state.open_suggestions(field_index);
self.ui_state.suggestions.is_loading = true;
self.ui_state.suggestions.active_query = Some(query.clone());
self.suggestions.clear();
Some(query)
}
#[cfg(not(feature = "suggestions"))]
pub fn start_suggestions(&mut self, _field_index: usize) -> Option<String> {
None
}
#[cfg(feature = "suggestions")]
pub fn apply_suggestions_result(
&mut self,
field_index: usize,
query: &str,
results: Vec<SuggestionItem>,
) -> bool {
if self.ui_state.suggestions.active_field != Some(field_index) {
return false;
}
if self.ui_state.suggestions.active_query.as_deref() != Some(query) {
return false;
}
self.ui_state.suggestions.is_loading = false;
self.suggestions = results;
if !self.suggestions.is_empty() {
self.ui_state.suggestions.selected_index = Some(0);
self.update_inline_completion();
} else {
self.ui_state.suggestions.selected_index = None;
self.ui_state.suggestions.completion_text = None;
}
true
}
#[cfg(not(feature = "suggestions"))]
pub fn apply_suggestions_result(
&mut self,
_field_index: usize,
_query: &str,
_results: Vec<SuggestionItem>,
) -> bool {
false
}
#[cfg(feature = "suggestions")]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
if self.ui_state.suggestions.is_loading {
if let (Some(field), Some(query)) = (
self.ui_state.suggestions.active_field,
&self.ui_state.suggestions.active_query,
) {
return Some((field, query.clone()));
}
}
None
}
#[cfg(not(feature = "suggestions"))]
pub fn pending_suggestions_query(&self) -> Option<(usize, String)> {
None
}
pub fn cancel_suggestions(&mut self) {
self.close_suggestions();
}
pub fn suggestions_next(&mut self) {
if !self.ui_state.suggestions.is_active || self.suggestions.is_empty()
{
return;
}
let current = self.ui_state.suggestions.selected_index.unwrap_or(0);
let next = (current + 1) % self.suggestions.len();
self.ui_state.suggestions.selected_index = Some(next);
self.update_inline_completion();
}
pub fn apply_suggestion(&mut self) -> Option<String> {
if let Some(selected_index) = self.ui_state.suggestions.selected_index {
if let Some(suggestion) = self.suggestions.get(selected_index).cloned()
{
let field_index = self.ui_state.current_field;
self.data_provider.set_field_value(
field_index,
suggestion.value_to_store.clone(),
);
self.ui_state.cursor_pos = suggestion.value_to_store.len();
self.ui_state.ideal_cursor_column = self.ui_state.cursor_pos;
self.close_suggestions();
self.suggestions.clear();
#[cfg(feature = "validation")]
{
let _ = self.ui_state.validation.validate_field_content(
field_index,
&suggestion.value_to_store,
);
}
return Some(suggestion.display_text);
}
}
None
}
}

View File

@@ -0,0 +1,23 @@
// src/editor/suggestions_stub.rs
// Crate-private no-op methods so internal calls compile when feature is off.
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
pub(crate) fn open_suggestions(&mut self, _field_index: usize) {
// no-op
}
pub(crate) fn close_suggestions(&mut self) {
// no-op
}
pub(crate) fn handle_escape_readonly(&mut self) {
// no-op
}
pub(crate) fn cancel_suggestions(&mut self) {
// no-op
}
}

View File

@@ -0,0 +1,178 @@
// src/editor/validation_helpers.rs
use crate::editor::FormEditor;
use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> {
#[cfg(feature = "validation")]
pub fn set_validation_enabled(&mut self, enabled: bool) {
self.ui_state.validation.set_enabled(enabled);
}
#[cfg(feature = "validation")]
pub fn is_validation_enabled(&self) -> bool {
self.ui_state.validation.is_enabled()
}
#[cfg(feature = "validation")]
pub fn set_field_validation(
&mut self,
field_index: usize,
config: crate::validation::ValidationConfig,
) {
self.ui_state
.validation
.set_field_config(field_index, config);
}
#[cfg(feature = "validation")]
pub fn remove_field_validation(&mut self, field_index: usize) {
self.ui_state.validation.remove_field_config(field_index);
}
#[cfg(feature = "validation")]
pub fn validate_current_field(
&mut self,
) -> crate::validation::ValidationResult {
let field_index = self.ui_state.current_field;
let current_text = self.current_text().to_string();
self.ui_state
.validation
.validate_field_content(field_index, &current_text)
}
#[cfg(feature = "validation")]
pub fn validate_field(
&mut self,
field_index: usize,
) -> Option<crate::validation::ValidationResult> {
if field_index < self.data_provider.field_count() {
let text =
self.data_provider.field_value(field_index).to_string();
Some(
self.ui_state
.validation
.validate_field_content(field_index, &text),
)
} else {
None
}
}
#[cfg(feature = "validation")]
pub fn clear_validation_results(&mut self) {
self.ui_state.validation.clear_all_results();
}
#[cfg(feature = "validation")]
pub fn validation_summary(
&self,
) -> crate::validation::ValidationSummary {
self.ui_state.validation.summary()
}
#[cfg(feature = "validation")]
pub fn can_switch_fields(&self) -> bool {
let current_text = self.current_text();
self.ui_state.validation.allows_field_switch(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn field_switch_block_reason(&self) -> Option<String> {
let current_text = self.current_text();
self.ui_state.validation.field_switch_block_reason(
self.ui_state.current_field,
current_text,
)
}
#[cfg(feature = "validation")]
pub fn last_switch_block(&self) -> Option<&str> {
self.ui_state.validation.last_switch_block()
}
#[cfg(feature = "validation")]
pub fn current_limits_status_text(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some(limits) = &cfg.character_limits {
return limits.status_text(self.current_text());
}
}
None
}
#[cfg(feature = "validation")]
pub fn current_formatter_warning(&self) -> Option<String> {
let idx = self.ui_state.current_field;
if let Some(cfg) = self.ui_state.validation.get_field_config(idx) {
if let Some((_fmt, _mapper, warn)) =
cfg.run_custom_formatter(self.current_text())
{
return warn;
}
}
None
}
#[cfg(feature = "validation")]
pub fn external_validation_of(
&self,
field_index: usize,
) -> crate::validation::ExternalValidationState {
self.ui_state
.validation
.get_external_validation(field_index)
}
#[cfg(feature = "validation")]
pub fn clear_all_external_validation(&mut self) {
self.ui_state.validation.clear_all_external_validation();
}
#[cfg(feature = "validation")]
pub fn clear_external_validation(&mut self, field_index: usize) {
self.ui_state
.validation
.clear_external_validation(field_index);
}
#[cfg(feature = "validation")]
pub fn set_external_validation(
&mut self,
field_index: usize,
state: crate::validation::ExternalValidationState,
) {
self.ui_state
.validation
.set_external_validation(field_index, state);
}
#[cfg(feature = "validation")]
pub fn set_external_validation_callback<F>(&mut self, callback: F)
where
F: FnMut(usize, &str) -> crate::validation::ExternalValidationState
+ Send
+ Sync
+ 'static,
{
self.external_validation_callback = Some(Box::new(callback));
}
#[cfg(feature = "validation")]
pub(crate) fn initialize_validation(&mut self) {
let field_count = self.data_provider.field_count();
for field_index in 0..field_count {
if let Some(config) =
self.data_provider.validation_config(field_index)
{
self.ui_state
.validation
.set_field_config(field_index, config);
}
}
}
}

View File

@@ -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;
@@ -21,7 +25,9 @@ pub use canvas::CursorManager;
// Main API exports
pub use editor::FormEditor;
pub use data_provider::{DataProvider, SuggestionsProvider, SuggestionItem};
pub use data_provider::DataProvider;
#[cfg(feature = "suggestions")]
pub use data_provider::{SuggestionsProvider, SuggestionItem};
// UI state (read-only access for users)
pub use canvas::state::EditorState;
@@ -41,6 +47,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};
@@ -53,3 +63,11 @@ pub use canvas::gui::render_canvas_default;
#[cfg(all(feature = "gui", feature = "suggestions"))]
pub use suggestions::gui::render_suggestions_dropdown;
// First-class textarea module and exports
#[cfg(feature = "textarea")]
pub mod textarea;
#[cfg(feature = "textarea")]
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};

View File

@@ -0,0 +1,14 @@
// src/textarea/mod.rs
// Module routing and re-exports only. No logic here.
pub mod provider;
pub mod state;
#[cfg(feature = "gui")]
pub mod widget;
pub use provider::TextAreaProvider;
pub use state::{TextAreaEditor, TextAreaState};
#[cfg(feature = "gui")]
pub use widget::TextArea;

View File

@@ -0,0 +1,134 @@
// src/textarea/provider.rs
use crate::DataProvider;
#[derive(Debug, Clone)]
pub struct TextAreaProvider {
lines: Vec<String>,
name: String,
}
impl Default for TextAreaProvider {
fn default() -> Self {
Self {
lines: vec![String::new()],
name: "Text".to_string(),
}
}
}
impl TextAreaProvider {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let text = text.into();
let mut lines: Vec<String> =
text.split('\n').map(|s| s.to_string()).collect();
if lines.is_empty() {
lines.push(String::new());
}
Self {
lines,
name: "Text".to_string(),
}
}
pub fn to_text(&self) -> String {
self.lines.join("\n")
}
pub fn set_text<S: Into<String>>(&mut self, text: S) {
let text = text.into();
self.lines = text.split('\n').map(|s| s.to_string()).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
#[inline]
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or_else(|| s.len())
}
pub fn split_line_at(&mut self, line_idx: usize, at_char: usize) -> usize {
if line_idx >= self.lines.len() {
return self.lines.len().saturating_sub(1);
}
let line = &mut self.lines[line_idx];
let byte_idx = Self::char_to_byte_index(line, at_char);
let right = line[byte_idx..].to_string();
line.truncate(byte_idx);
let insert_at = line_idx + 1;
self.lines.insert(insert_at, right);
insert_at
}
pub fn join_with_next(&mut self, line_idx: usize) -> Option<usize> {
if line_idx + 1 >= self.lines.len() {
return None;
}
let left_len = self.lines[line_idx].chars().count();
let right = self.lines.remove(line_idx + 1);
self.lines[line_idx].push_str(&right);
Some(left_len)
}
pub fn join_with_prev(
&mut self,
line_idx: usize,
) -> Option<(usize, usize)> {
if line_idx == 0 || line_idx >= self.lines.len() {
return None;
}
let prev_idx = line_idx - 1;
let prev_len = self.lines[prev_idx].chars().count();
let curr = self.lines.remove(line_idx);
self.lines[prev_idx].push_str(&curr);
Some((prev_idx, prev_len))
}
pub fn insert_blank_line_after(&mut self, idx: usize) -> usize {
let clamped = idx.min(self.lines.len());
let insert_at = if clamped >= self.lines.len() {
self.lines.len()
} else {
clamped + 1
};
if insert_at == self.lines.len() {
self.lines.push(String::new());
} else {
self.lines.insert(insert_at, String::new());
}
insert_at
}
pub fn insert_blank_line_before(&mut self, idx: usize) -> usize {
let insert_at = idx.min(self.lines.len());
self.lines.insert(insert_at, String::new());
insert_at
}
}
impl DataProvider for TextAreaProvider {
fn field_count(&self) -> usize {
self.lines.len()
}
fn field_name(&self, _index: usize) -> &str {
&self.name
}
fn field_value(&self, index: usize) -> &str {
self.lines.get(index).map(|s| s.as_str()).unwrap_or("")
}
fn set_field_value(&mut self, index: usize, value: String) {
if index < self.lines.len() {
self.lines[index] = value;
}
}
}

View File

@@ -0,0 +1,264 @@
// src/textarea/state.rs
use std::ops::{Deref, DerefMut};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::editor::FormEditor;
use crate::textarea::provider::TextAreaProvider;
#[cfg(feature = "gui")]
use ratatui::{layout::Rect, widgets::Block};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
pub type TextAreaEditor = FormEditor<TextAreaProvider>;
pub struct TextAreaState {
pub(crate) editor: TextAreaEditor,
pub(crate) scroll_y: u16,
pub(crate) wrap: bool,
pub(crate) placeholder: Option<String>,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
editor: FormEditor::new(TextAreaProvider::default()),
scroll_y: 0,
wrap: false,
placeholder: None,
}
}
}
// Expose the entire FormEditor API directly on TextAreaState
impl Deref for TextAreaState {
type Target = TextAreaEditor;
fn deref(&self) -> &Self::Target {
&self.editor
}
}
impl DerefMut for TextAreaState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.editor
}
}
impl TextAreaState {
pub fn from_text<S: Into<String>>(text: S) -> Self {
let provider = TextAreaProvider::from_text(text);
Self {
editor: FormEditor::new(provider),
scroll_y: 0,
wrap: false,
placeholder: None,
}
}
pub fn text(&self) -> String {
self.editor.data_provider().to_text()
}
pub fn set_text<S: Into<String>>(&mut self, text: S) {
self.editor.data_provider_mut().set_text(text);
self.editor.ui_state.current_field = 0;
self.editor.ui_state.cursor_pos = 0;
self.editor.ui_state.ideal_cursor_column = 0;
}
pub fn set_wrap(&mut self, wrap: bool) {
self.wrap = wrap;
}
pub fn set_placeholder<S: Into<String>>(&mut self, s: S) {
self.placeholder = Some(s.into());
}
// Textarea-specific primitive: split at cursor
pub fn insert_newline(&mut self) {
let line_idx = self.current_field();
let col = self.cursor_position();
let new_idx = self
.editor
.data_provider_mut()
.split_line_at(line_idx, col);
let _ = self.transition_to_field(new_idx);
self.move_line_start();
self.enter_edit_mode();
}
// Textarea-specific primitive: backspace with line join at start-of-line
pub fn backspace(&mut self) {
let col = self.cursor_position();
if col > 0 {
let _ = self.delete_backward();
return;
}
let line_idx = self.current_field();
if line_idx == 0 {
return;
}
if let Some((prev_idx, new_col)) = self
.editor
.data_provider_mut()
.join_with_prev(line_idx)
{
let _ = self.transition_to_field(prev_idx);
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
// Textarea-specific primitive: delete or join with next line at EOL
pub fn delete_forward_or_join(&mut self) {
let line_idx = self.current_field();
let line_len = self.current_text().chars().count();
let col = self.cursor_position();
if col < line_len {
let _ = self.delete_forward();
return;
}
if let Some(new_col) = self
.editor
.data_provider_mut()
.join_with_next(line_idx)
{
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
// Override for multiline: insert new blank line below and enter insert mode.
pub fn open_line_below(&mut self) -> Result<()> {
let line_idx = self.current_field();
let new_idx = self
.editor
.data_provider_mut()
.insert_blank_line_after(line_idx);
self.transition_to_field(new_idx)?;
self.move_line_start();
self.enter_edit_mode();
Ok(())
}
// Override for multiline: insert new blank line above and enter insert mode.
pub fn open_line_above(&mut self) -> Result<()> {
let line_idx = self.current_field();
let new_idx = self
.editor
.data_provider_mut()
.insert_blank_line_before(line_idx);
self.transition_to_field(new_idx)?;
self.move_line_start();
self.enter_edit_mode();
Ok(())
}
// Drive from KeyEvent; you can still call all FormEditor methods directly
pub fn input(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
match (key.code, key.modifiers) {
(KeyCode::Enter, _) => self.insert_newline(),
(KeyCode::Backspace, _) => self.backspace(),
(KeyCode::Delete, _) => self.delete_forward_or_join(),
(KeyCode::Left, _) => {
let _ = self.move_left();
}
(KeyCode::Right, _) => {
let _ = self.move_right();
}
(KeyCode::Up, _) => {
let _ = self.move_up();
}
(KeyCode::Down, _) => {
let _ = self.move_down();
}
(KeyCode::Home, _)
| (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.move_line_start();
}
(KeyCode::End, _)
| (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.move_line_end();
}
// Optional: word motions
(KeyCode::Char('b'), KeyModifiers::ALT) => self.move_word_prev(),
(KeyCode::Char('f'), KeyModifiers::ALT) => self.move_word_next(),
(KeyCode::Char('e'), KeyModifiers::ALT) => self.move_word_end(),
// Printable characters
(KeyCode::Char(c), m) if m.is_empty() => {
self.enter_edit_mode();
let _ = self.insert_char(c);
}
// Simple Tab policy
(KeyCode::Tab, _) => {
self.enter_edit_mode();
for _ in 0..4 {
let _ = self.insert_char(' ');
}
}
_ => {}
}
}
// Cursor helpers for GUI
#[cfg(feature = "gui")]
pub fn cursor(&self, area: Rect, block: Option<&Block<'_>>) -> (u16, u16) {
let inner = if let Some(b) = block { b.inner(area) } else { area };
let line_idx = self.current_field() as u16;
let y = inner.y + line_idx.saturating_sub(self.scroll_y);
let current_line = self.current_text();
let col = self.display_cursor_position();
let mut x_off: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
if i >= col {
break;
}
x_off = x_off
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let x = inner.x.saturating_add(x_off);
(x, y)
}
#[cfg(feature = "gui")]
pub(crate) fn ensure_visible(
&mut self,
area: Rect,
block: Option<&Block<'_>>,
) {
let inner = if let Some(b) = block { b.inner(area) } else { area };
if inner.height == 0 {
return;
}
let line_idx = self.current_field() as u16;
if line_idx < self.scroll_y {
self.scroll_y = line_idx;
} else if line_idx >= self.scroll_y + inner.height {
self.scroll_y = line_idx.saturating_sub(inner.height - 1);
}
}
}

View File

@@ -0,0 +1,106 @@
// src/textarea/widget.rs
#[cfg(feature = "gui")]
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Paragraph, StatefulWidget, Widget, Wrap,
},
};
#[cfg(feature = "gui")]
use crate::data_provider::DataProvider; // bring trait into scope
#[cfg(feature = "gui")]
use crate::textarea::state::TextAreaState;
#[cfg(feature = "gui")]
#[derive(Debug, Clone)]
pub struct TextArea<'a> {
pub(crate) block: Option<Block<'a>>,
pub(crate) style: Style,
pub(crate) border_type: BorderType,
}
#[cfg(feature = "gui")]
impl<'a> Default for TextArea<'a> {
fn default() -> Self {
Self {
block: Some(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
),
style: Style::default(),
border_type: BorderType::Rounded,
}
}
}
#[cfg(feature = "gui")]
impl<'a> TextArea<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn border_type(mut self, ty: BorderType) -> Self {
self.border_type = ty;
if let Some(b) = &mut self.block {
*b = b.clone().border_type(ty);
}
self
}
}
#[cfg(feature = "gui")]
impl<'a> StatefulWidget for TextArea<'a> {
type State = TextAreaState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
state.ensure_visible(area, self.block.as_ref());
let inner = if let Some(b) = &self.block {
b.clone().render(area, buf);
b.inner(area)
} else {
area
};
let total = state.editor.data_provider().line_count();
let start = state.scroll_y as usize;
let end = start
.saturating_add(inner.height as usize)
.min(total);
let mut display_lines: Vec<Line> = Vec::with_capacity(end - start);
if start >= end {
if let Some(ph) = &state.placeholder {
display_lines.push(Line::from(Span::raw(ph.clone())));
}
} else {
for i in start..end {
let s = state.editor.data_provider().field_value(i);
display_lines.push(Line::from(Span::raw(s.to_string())));
}
}
let mut p = Paragraph::new(display_lines)
.alignment(Alignment::Left)
.style(self.style);
if state.wrap {
p = p.wrap(Wrap { trim: false });
}
p.render(inner, buf);
}
}

View File

@@ -122,42 +122,59 @@ impl CharacterLimits {
pub fn validate_insertion(
&self,
current_text: &str,
_position: usize,
position: usize,
character: char,
) -> Option<ValidationResult> {
let current_count = self.count(current_text);
let char_count = match self.count_mode {
CountMode::Characters => 1,
CountMode::DisplayWidth => {
let char_str = character.to_string();
char_str.width()
},
CountMode::Bytes => character.len_utf8(),
};
let new_count = current_count + char_count;
// FIX: Actually simulate the insertion at the specified position
// This makes the `position` parameter essential to the logic
// 1. Create the new string by inserting the character at the correct position
let mut new_text = String::with_capacity(current_text.len() + character.len_utf8());
let mut chars = current_text.chars();
// Append characters from the original string that come before the insertion point
// We clamp the position to be safe
let clamped_pos = position.min(current_text.chars().count());
for _ in 0..clamped_pos {
if let Some(ch) = chars.next() {
new_text.push(ch);
}
}
// Insert the new character
new_text.push(character);
// Append the rest of the original string
for ch in chars {
new_text.push(ch);
}
// 2. Now perform all validation on the *actual* resulting text
let new_count = self.count(&new_text);
let current_count = self.count(current_text);
// Check max length
if let Some(max) = self.max_length {
if new_count > max {
return Some(ValidationResult::error(format!(
"Character limit exceeded: {}/{}",
new_count,
"Character limit exceeded: {}/{}",
new_count,
max
)));
}
// Check warning threshold
if let Some(warning_threshold) = self.warning_threshold {
if new_count >= warning_threshold && current_count < warning_threshold {
return Some(ValidationResult::warning(format!(
"Approaching character limit: {}/{}",
new_count,
"Approaching character limit: {}/{}",
new_count,
max
)));
}
}
}
None // No validation issues
}

View File

@@ -21,6 +21,8 @@ pub struct ValidationState {
/// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>,
last_switch_block: Option<String>,
}
impl ValidationState {
@@ -32,6 +34,7 @@ impl ValidationState {
validated_fields: std::collections::HashSet::new(),
enabled: true,
external_results: HashMap::new(),
last_switch_block: None,
}
}
@@ -256,6 +259,22 @@ impl ValidationState {
error_fields: errors,
}
}
/// Set the last switch block reason (for UI convenience)
pub fn set_last_switch_block<S: Into<String>>(&mut self, reason: S) {
self.last_switch_block = Some(reason.into());
}
/// Clear the last switch block reason
pub fn clear_last_switch_block(&mut self) {
self.last_switch_block = None;
}
/// Get the last switch block reason (if any)
pub fn last_switch_block(&self) -> Option<&str> {
self.last_switch_block.as_deref()
}
}
/// Summary of validation state across all fields