Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b54afff4 | ||
|
|
b9a7f9a03f | ||
|
|
e36324af6f | ||
|
|
60cb45dcca | ||
|
|
215be3cf09 | ||
|
|
b2aa966588 | ||
|
|
67512ac151 | ||
|
|
3f5dedbd6e | ||
|
|
ce07105eea | ||
|
|
587470c48b | ||
|
|
3227d341ed | ||
|
|
2b16a80ef8 | ||
|
|
8b742bbe09 | ||
|
|
189d3d2fc5 | ||
|
|
082093ea17 | ||
|
|
280f314100 | ||
|
|
163a6262c8 | ||
|
|
e8a564aed3 | ||
|
|
53464dfcbf | ||
|
|
b364a6606d | ||
|
|
f09e476bb6 | ||
|
|
e2c9cc4347 | ||
|
|
06106dc31b | ||
|
|
8e3c85991c | ||
|
|
d3e5418221 | ||
|
|
0d0e54032c | ||
|
|
a8de16f66d | ||
|
|
5b2e0e976f | ||
|
|
d601134535 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ server/tantivy_indexes
|
||||
steel_decimal/tests/property_tests.proptest-regressions
|
||||
.direnv/
|
||||
canvas/*.toml
|
||||
.aider*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
623
canvas/examples/computed_fields.rs
Normal file
623
canvas/examples/computed_fields.rs
Normal 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
1079
canvas/examples/suggestions2.rs
Normal file
1079
canvas/examples/suggestions2.rs
Normal file
File diff suppressed because it is too large
Load Diff
377
canvas/examples/textarea_normal.rs
Normal file
377
canvas/examples/textarea_normal.rs
Normal 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(())
|
||||
}
|
||||
652
canvas/examples/textarea_vim.rs
Normal file
652
canvas/examples/textarea_vim.rs
Normal 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(())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" => {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
5
canvas/src/computed/mod.rs
Normal file
5
canvas/src/computed/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod provider;
|
||||
pub mod state;
|
||||
|
||||
pub use provider::{ComputedContext, ComputedProvider};
|
||||
pub use state::ComputedState;
|
||||
31
canvas/src/computed/provider.rs
Normal file
31
canvas/src/computed/provider.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
88
canvas/src/computed/state.rs
Normal file
88
canvas/src/computed/state.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
1203
canvas/src/editor.rs
1203
canvas/src/editor.rs
File diff suppressed because it is too large
Load Diff
111
canvas/src/editor/computed_helpers.rs
Normal file
111
canvas/src/editor/computed_helpers.rs
Normal 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
131
canvas/src/editor/core.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
123
canvas/src/editor/display.rs
Normal file
123
canvas/src/editor/display.rs
Normal 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
|
||||
}
|
||||
}
|
||||
348
canvas/src/editor/editing.rs
Normal file
348
canvas/src/editor/editing.rs
Normal 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(
|
||||
¤t_text,
|
||||
self.ui_state.cursor_pos - 1,
|
||||
);
|
||||
let end =
|
||||
Self::char_to_byte_index(¤t_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,
|
||||
¤t_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(
|
||||
¤t_text,
|
||||
self.ui_state.cursor_pos,
|
||||
);
|
||||
let end = Self::char_to_byte_index(
|
||||
¤t_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,
|
||||
¤t_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
21
canvas/src/editor/mod.rs
Normal 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
310
canvas/src/editor/mode.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
690
canvas/src/editor/movement.rs
Normal file
690
canvas/src/editor/movement.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
canvas/src/editor/navigation.rs
Normal file
177
canvas/src/editor/navigation.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
166
canvas/src/editor/suggestions.rs
Normal file
166
canvas/src/editor/suggestions.rs
Normal 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
|
||||
}
|
||||
}
|
||||
23
canvas/src/editor/suggestions_stub.rs
Normal file
23
canvas/src/editor/suggestions_stub.rs
Normal 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
|
||||
}
|
||||
}
|
||||
178
canvas/src/editor/validation_helpers.rs
Normal file
178
canvas/src/editor/validation_helpers.rs
Normal 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, ¤t_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
14
canvas/src/textarea/mod.rs
Normal file
14
canvas/src/textarea/mod.rs
Normal 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;
|
||||
134
canvas/src/textarea/provider.rs
Normal file
134
canvas/src/textarea/provider.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
264
canvas/src/textarea/state.rs
Normal file
264
canvas/src/textarea/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
canvas/src/textarea/widget.rs
Normal file
106
canvas/src/textarea/widget.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user