Compare commits

..

38 Commits

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

1
.gitignore vendored
View File

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

View File

@@ -2,7 +2,7 @@
## Overview ## Overview
This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from autocomplete features, providing better type safety and maintainability. This guide covers the migration from the legacy canvas library structure to the new clean, modular architecture. The new design separates core canvas functionality from suggestions features, providing better type safety and maintainability.
## Key Changes ## Key Changes
@@ -10,7 +10,7 @@ This guide covers the migration from the legacy canvas library structure to the
``` ```
# Old Structure (LEGACY) # Old Structure (LEGACY)
src/ src/
├── state.rs # Mixed canvas + autocomplete ├── state.rs # Mixed canvas + suggestions
├── actions/edit.rs # Mixed concerns ├── actions/edit.rs # Mixed concerns
├── gui/render.rs # Everything together ├── gui/render.rs # Everything together
└── suggestions.rs # Legacy file └── suggestions.rs # Legacy file
@@ -21,9 +21,9 @@ src/
│ ├── state.rs # CanvasState trait only │ ├── state.rs # CanvasState trait only
│ ├── actions/edit.rs # Canvas actions only │ ├── actions/edit.rs # Canvas actions only
│ └── gui.rs # Canvas rendering │ └── gui.rs # Canvas rendering
├── autocomplete/ # Rich autocomplete features ├── suggestions/ # Suggestions dropdown features (not inline autocomplete)
│ ├── state.rs # AutocompleteCanvasState trait │ ├── state.rs # Suggestion provider types
│ ├── types.rs # SuggestionItem, AutocompleteState │ ├── gui.rs # Suggestions dropdown rendering
│ ├── actions.rs # Autocomplete actions │ ├── actions.rs # Autocomplete actions
│ └── gui.rs # Autocomplete dropdown rendering │ └── gui.rs # Autocomplete dropdown rendering
└── dispatcher.rs # Action routing └── dispatcher.rs # Action routing
@@ -31,7 +31,7 @@ src/
### 2. **Trait Separation** ### 2. **Trait Separation**
- **CanvasState**: Core form functionality (navigation, input, validation) - **CanvasState**: Core form functionality (navigation, input, validation)
- **AutocompleteCanvasState**: Optional rich autocomplete features - Suggestions module: Optional dropdown suggestions support
### 3. **Rich Suggestions** ### 3. **Rich Suggestions**
Replaced simple string suggestions with typed, rich suggestion objects. Replaced simple string suggestions with typed, rich suggestion objects.
@@ -93,34 +93,29 @@ impl CanvasState for YourFormState {
### Step 3: Implement Rich Autocomplete (Optional) ### Step 3: Implement Rich Autocomplete (Optional)
**If you want rich autocomplete features:** **If you want suggestions dropdown features:**
```rust ```rust
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem, AutocompleteState}; use canvas::{SuggestionItem};
impl AutocompleteCanvasState for YourFormState { impl YourFormState {
type SuggestionData = YourDataType; // e.g., Hit, CustomRecord, etc. fn supports_suggestions(&self, field_index: usize) -> bool {
// Define which fields support suggestions
fn supports_autocomplete(&self, field_index: usize) -> bool {
// Define which fields support autocomplete
matches!(field_index, 2 | 3 | 5) // Example: only certain fields matches!(field_index, 2 | 3 | 5) // Example: only certain fields
} }
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> { // Manage your own suggestion state or rely on FormEditor APIs
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> { // Manage your own suggestion state or rely on FormEditor APIs
Some(&mut self.autocomplete)
}
} }
``` ```
**Add autocomplete field to your state:** **Add suggestions storage to your state (optional, if you need to persist outside the editor):**
```rust ```rust
pub struct YourFormState { pub struct YourFormState {
// ... existing fields // ... existing fields
pub autocomplete: AutocompleteState<YourDataType>, // Optional: your own suggestions cache if needed
// pub suggestion_cache: Vec<SuggestionItem>,
} }
``` ```
@@ -149,9 +144,9 @@ form_state.set_autocomplete_suggestions(suggestions);
**Old rendering:** **Old rendering:**
```rust ```rust
// Manual autocomplete rendering // Manual suggestions rendering
if form_state.autocomplete_active { if editor.is_suggestions_active() {
render_autocomplete_dropdown(/* ... */); suggestions::gui::render_suggestions_dropdown(/* ... */);
} }
``` ```
@@ -162,13 +157,12 @@ use canvas::canvas::render_canvas;
let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state); let active_field_rect = render_canvas(f, area, form_state, theme, edit_mode, highlight_state);
// Optional: Rich autocomplete (if implementing AutocompleteCanvasState) // Suggestions dropdown (if active)
if form_state.is_autocomplete_active() { if editor.is_suggestions_active() {
if let Some(autocomplete_state) = form_state.autocomplete_state() { canvas::suggestions::render_suggestions_dropdown(
canvas::autocomplete::render_autocomplete_dropdown( f, f.area(), active_field_rect.unwrap(), theme, &editor
f, f.area(), active_field_rect.unwrap(), theme, autocomplete_state );
); }
}
} }
``` ```
@@ -181,16 +175,16 @@ form_state.deactivate_suggestions();
# NEW - Option A: Add your own method # NEW - Option A: Add your own method
impl YourFormState { impl YourFormState {
pub fn deactivate_autocomplete(&mut self) { pub fn deactivate_suggestions(&mut self) {
self.autocomplete_active = false; self.autocomplete_active = false;
self.autocomplete_suggestions.clear(); self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None; self.selected_suggestion_index = None;
} }
} }
form_state.deactivate_autocomplete(); editor.ui_state_mut().deactivate_suggestions();
# NEW - Option B: Use rich autocomplete trait # NEW - Option B: Suggestions via editor APIs
form_state.deactivate_autocomplete(); // If implementing AutocompleteCanvasState editor.ui_state_mut().deactivate_suggestions();
``` ```
## Benefits of New Architecture ## Benefits of New Architecture
@@ -217,8 +211,8 @@ let suggestions: Vec<SuggestionItem<UserRecord>> = vec![
- **Display Overrides**: Show friendly text while storing normalized data - **Display Overrides**: Show friendly text while storing normalized data
### 4. **Future-Proof** ### 4. **Future-Proof**
- Easy to add new autocomplete features - Easy to add new suggestion features
- Canvas features don't interfere with autocomplete - Canvas features don't interfere with suggestions
- Modular: Use only what you need - Modular: Use only what you need
## Advanced Features ## Advanced Features
@@ -262,7 +256,7 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
## Breaking Changes Summary ## Breaking Changes Summary
1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes 1. **Import paths changed**: Add `canvas::` or `dispatcher::` prefixes
2. **Legacy suggestion methods removed**: Replace with rich autocomplete or custom methods 2. **Legacy suggestion methods removed**: Replace with SuggestionItem-based dropdown or custom methods
3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions 3. **No more simple suggestions**: Use `SuggestionItem` for typed suggestions
4. **Trait split**: `AutocompleteCanvasState` is now separate and optional 4. **Trait split**: `AutocompleteCanvasState` is now separate and optional
@@ -283,11 +277,11 @@ SuggestionItem::new(user, "John Doe (Manager)".to_string(), "123".to_string());
- [ ] Updated all import paths - [ ] Updated all import paths
- [ ] Removed legacy methods from CanvasState implementation - [ ] Removed legacy methods from CanvasState implementation
- [ ] Added custom autocomplete methods if needed - [ ] Added custom suggestion methods if needed
- [ ] Updated suggestion usage to SuggestionItem - [ ] Updated usage to SuggestionItem
- [ ] Updated rendering calls - [ ] Updated rendering calls
- [ ] Tested form functionality - [ ] Tested form functionality
- [ ] Tested autocomplete functionality (if using) - [ ] Tested suggestions functionality (if using)
## Example: Complete Migration ## Example: Complete Migration
@@ -305,29 +299,25 @@ impl CanvasState for FormState {
**After:** **After:**
```rust ```rust
use canvas::canvas::{CanvasState, CanvasAction}; use canvas::canvas::{CanvasState, CanvasAction};
use canvas::autocomplete::{AutocompleteCanvasState, SuggestionItem}; use canvas::SuggestionItem;
impl CanvasState for FormState { impl CanvasState for FormState {
// Only core canvas methods, no suggestion methods // Only core canvas methods
fn current_field(&self) -> usize { /* ... */ } fn current_field(&self) -> usize { /* ... */ }
fn get_current_input(&self) -> &str { /* ... */ } fn get_current_input(&self) -> &str { /* ... */ }
// ... other core methods only // ... other core methods only
} }
impl AutocompleteCanvasState for FormState { // Use FormEditor + SuggestionsProvider for suggestions dropdown
type SuggestionData = Hit; type SuggestionData = Hit;
fn supports_autocomplete(&self, field_index: usize) -> bool { fn supports_suggestions(&self, field_index: usize) -> bool {
self.fields[field_index].is_link self.fields[field_index].is_link
} }
fn autocomplete_state(&self) -> Option<&AutocompleteState<Self::SuggestionData>> { // Maintain suggestion state through FormEditor and DataProvider
Some(&self.autocomplete)
}
fn autocomplete_state_mut(&mut self) -> Option<&mut AutocompleteState<Self::SuggestionData>> { // Maintain suggestion state through FormEditor and DataProvider
Some(&mut self.autocomplete)
}
} }
``` ```

View File

@@ -29,22 +29,72 @@ regex = { workspace = true, optional = true }
tokio-test = "0.4.4" tokio-test = "0.4.4"
[features] [features]
default = [] default = ["textmode-vim"]
gui = ["ratatui"] gui = ["ratatui", "crossterm"]
autocomplete = ["tokio"] suggestions = ["tokio"]
cursor-style = ["crossterm"] cursor-style = ["crossterm"]
validation = ["regex"] 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]] [[example]]
name = "autocomplete" name = "suggestions"
required-features = ["autocomplete", "gui"] required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/autocomplete.rs" path = "examples/suggestions.rs"
[[example]] [[example]]
name = "canvas_gui_demo" name = "suggestions2"
required-features = ["gui"] required-features = ["suggestions", "gui", "cursor-style"]
path = "examples/canvas_gui_demo.rs" path = "examples/suggestions2.rs"
[[example]]
name = "canvas_cursor_auto"
required-features = ["gui", "cursor-style"]
path = "examples/canvas_cursor_auto.rs"
[[example]] [[example]]
name = "validation_1" name = "validation_1"
required-features = ["gui", "validation"] required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_2"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_3"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_4"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "validation_5"
required-features = ["gui", "validation", "cursor-style"]
[[example]]
name = "computed_fields"
required-features = ["gui", "computed"]
[[example]]
name = "textarea_vim"
required-features = ["gui", "cursor-style", "textarea", "textmode-vim"]
path = "examples/textarea_vim.rs"
[[example]]
name = "textarea_normal"
required-features = ["gui", "cursor-style", "textarea", "textmode-normal"]
path = "examples/textarea_normal.rs"

View File

@@ -7,7 +7,7 @@ A reusable, type-safe canvas system for building form-based TUI applications wit
- **Type-Safe Actions**: No more string-based action names - everything is compile-time checked - **Type-Safe Actions**: No more string-based action names - everything is compile-time checked
- **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free - **Generic Design**: Implement `CanvasState` once, get navigation, editing, and suggestions for free
- **Vim-Like Experience**: Modal editing with familiar keybindings - **Vim-Like Experience**: Modal editing with familiar keybindings
- **Suggestion System**: Built-in autocomplete and suggestions support - **Suggestion System**: Built-in suggestions dropdown support
- **Framework Agnostic**: Works with any TUI framework or raw terminal handling - **Framework Agnostic**: Works with any TUI framework or raw terminal handling
- **Async Ready**: Full async/await support for modern Rust applications - **Async Ready**: Full async/await support for modern Rust applications
- **Batch Operations**: Execute multiple actions atomically - **Batch Operations**: Execute multiple actions atomically
@@ -144,7 +144,7 @@ pub enum CanvasAction {
## 🔧 Advanced Features ## 🔧 Advanced Features
### Suggestions and Autocomplete ### Suggestions Dropdown (not inline autocomplete)
```rust ```rust
impl CanvasState for MyForm { impl CanvasState for MyForm {
@@ -170,7 +170,7 @@ impl CanvasState for MyForm {
CanvasAction::SelectSuggestion => { CanvasAction::SelectSuggestion => {
if let Some(suggestion) = self.suggestions.get_selected() { if let Some(suggestion) = self.suggestions.get_selected() {
*self.get_current_input_mut() = suggestion.clone(); *self.get_current_input_mut() = suggestion.clone();
self.deactivate_autocomplete(); self.deactivate_suggestions();
Some("Applied suggestion".to_string()) Some("Applied suggestion".to_string())
} }
None None

16
canvas/aider.md Normal file
View File

@@ -0,0 +1,16 @@
# Aider Instructions
## General Rules
- Only modify files that I explicitly add with `/add`.
- If a prompt mentions multiple files, **ignore all files except the ones I have added**.
- Do not create, edit, or delete any files unless they are explicitly added.
- Keep all other files exactly as they are, even if the prompt suggests changes.
- Never move logic into or out of files that are not explicitly added.
- If a prompt suggests changes to multiple files, apply **only the subset of changes** that belong to the added file(s).
- If a change requires touching other files, ignore them, if they were not manually added.
## Coding Style
- Follow Rust 2021 edition idioms.
- No logic in `mod.rs` files (only exports/routing).
- Always update or create tests **only if the test file is explicitly added**.
- Do not think, only apply changes from the prompt

View File

@@ -1,392 +0,0 @@
// examples/autocomplete.rs
// Run with: cargo run --example autocomplete --features "autocomplete,gui"
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},
style::Color,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use canvas::{
canvas::{
gui::render_canvas,
modes::AppMode,
theme::CanvasTheme,
},
autocomplete::gui::render_autocomplete_dropdown,
FormEditor, DataProvider, AutocompleteProvider, SuggestionItem,
};
use async_trait::async_trait;
use anyhow::Result;
// Simple theme implementation
#[derive(Clone)]
struct DemoTheme;
impl CanvasTheme for DemoTheme {
fn bg(&self) -> Color { Color::Reset }
fn fg(&self) -> Color { Color::White }
fn accent(&self) -> Color { Color::Cyan }
fn secondary(&self) -> Color { Color::Gray }
fn highlight(&self) -> Color { Color::Yellow }
fn highlight_bg(&self) -> Color { Color::DarkGray }
fn warning(&self) -> Color { Color::Red }
fn border(&self) -> Color { Color::Gray }
}
// Custom suggestion data type
#[derive(Clone, Debug)]
struct EmailSuggestion {
email: String,
provider: String,
}
// ===================================================================
// SIMPLE DATA PROVIDER - Only business data, no UI concerns!
// ===================================================================
struct ContactForm {
// Only business data - no UI state!
name: String,
email: String,
phone: String,
city: String,
}
impl ContactForm {
fn new() -> Self {
Self {
name: "John Doe".to_string(),
email: "john@".to_string(), // Partial email for demo
phone: "+1 234 567 8900".to_string(),
city: "San Francisco".to_string(),
}
}
}
// Simple trait implementation - only 4 methods!
impl DataProvider for ContactForm {
fn field_count(&self) -> usize { 4 }
fn field_name(&self, index: usize) -> &str {
match index {
0 => "Name",
1 => "Email",
2 => "Phone",
3 => "City",
_ => "",
}
}
fn field_value(&self, index: usize) -> &str {
match index {
0 => &self.name,
1 => &self.email,
2 => &self.phone,
3 => &self.city,
_ => "",
}
}
fn set_field_value(&mut self, index: usize, value: String) {
match index {
0 => self.name = value,
1 => self.email = value,
2 => self.phone = value,
3 => self.city = value,
_ => {}
}
}
fn supports_autocomplete(&self, field_index: usize) -> bool {
field_index == 1 // Only email field
}
}
// ===================================================================
// SIMPLE AUTOCOMPLETE PROVIDER - Only data fetching!
// ===================================================================
struct EmailAutocomplete;
#[async_trait]
impl AutocompleteProvider for EmailAutocomplete {
type SuggestionData = EmailSuggestion;
async fn fetch_suggestions(&mut self, _field_index: usize, query: &str)
-> Result<Vec<SuggestionItem<Self::SuggestionData>>>
{
// Extract domain part from email
let (email_prefix, domain_part) = if let Some(at_pos) = query.find('@') {
(query[..at_pos].to_string(), query[at_pos + 1..].to_string())
} else {
return Ok(Vec::new()); // No @ symbol
};
// Simulate async API call
let suggestions = tokio::task::spawn_blocking(move || {
// Simulate network delay
std::thread::sleep(std::time::Duration::from_millis(200));
// Mock email suggestions
let popular_domains = vec![
("gmail.com", "Gmail"),
("yahoo.com", "Yahoo Mail"),
("outlook.com", "Outlook"),
("hotmail.com", "Hotmail"),
("company.com", "Company Email"),
("university.edu", "University"),
];
let mut results = Vec::new();
for (domain, provider) in popular_domains {
if domain.starts_with(&domain_part) || domain_part.is_empty() {
let full_email = format!("{}@{}", email_prefix, domain);
results.push(SuggestionItem {
data: EmailSuggestion {
email: full_email.clone(),
provider: provider.to_string(),
},
display_text: format!("{} ({})", full_email, provider),
value_to_store: full_email,
});
}
}
results
}).await.unwrap_or_default();
Ok(suggestions)
}
}
// ===================================================================
// APPLICATION STATE - Much simpler!
// ===================================================================
struct AppState {
editor: FormEditor<ContactForm>,
autocomplete: EmailAutocomplete,
debug_message: String,
}
impl AppState {
fn new() -> Self {
let contact_form = ContactForm::new();
let mut editor = FormEditor::new(contact_form);
// Start on email field (index 1) at end of existing text
editor.set_mode(AppMode::Edit);
// TODO: Add method to set initial field/cursor position
Self {
editor,
autocomplete: EmailAutocomplete,
debug_message: "Type in email field, Tab to trigger autocomplete, Enter to select, Esc to cancel".to_string(),
}
}
}
// ===================================================================
// INPUT HANDLING - Much cleaner!
// ===================================================================
async fn handle_key_press(key: KeyCode, modifiers: KeyModifiers, state: &mut AppState) -> bool {
if key == KeyCode::F(10) || (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL)) {
return false; // Quit
}
// Handle input based on key
let result = match key {
// === AUTOCOMPLETE KEYS ===
KeyCode::Tab => {
if state.editor.is_autocomplete_active() {
state.editor.autocomplete_next();
Ok("Navigated to next suggestion".to_string())
} else if state.editor.data_provider().supports_autocomplete(state.editor.current_field()) {
state.editor.trigger_autocomplete(&mut state.autocomplete).await
.map(|_| "Triggered autocomplete".to_string())
} else {
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Enter => {
if state.editor.is_autocomplete_active() {
if let Some(applied) = state.editor.apply_autocomplete() {
Ok(format!("Applied: {}", applied))
} else {
Ok("No suggestion to apply".to_string())
}
} else {
state.editor.move_to_next_field();
Ok("Moved to next field".to_string())
}
}
KeyCode::Esc => {
if state.editor.is_autocomplete_active() {
// Autocomplete will be cleared automatically by mode change
Ok("Cancelled autocomplete".to_string())
} else {
// Toggle between edit and readonly mode
let new_mode = match state.editor.mode() {
AppMode::Edit => AppMode::ReadOnly,
_ => AppMode::Edit,
};
state.editor.set_mode(new_mode);
Ok(format!("Switched to {:?} mode", new_mode))
}
}
// === MOVEMENT KEYS ===
KeyCode::Left => {
state.editor.move_left();
Ok("Moved left".to_string())
}
KeyCode::Right => {
state.editor.move_right();
Ok("Moved right".to_string())
}
KeyCode::Up => {
state.editor.move_to_next_field(); // TODO: Add move_up method
Ok("Moved up".to_string())
}
KeyCode::Down => {
state.editor.move_to_next_field(); // TODO: Add move_down method
Ok("Moved down".to_string())
}
// === TEXT INPUT ===
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
state.editor.insert_char(c)
.map(|_| format!("Inserted '{}'", c))
}
KeyCode::Backspace => {
// TODO: Add delete_backward method to FormEditor
Ok("Backspace (not implemented yet)".to_string())
}
_ => Ok(format!("Unhandled key: {:?}", key)),
};
// Update debug message
match result {
Ok(msg) => state.debug_message = msg,
Err(e) => state.debug_message = format!("Error: {}", e),
}
true
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut state: AppState) -> io::Result<()> {
let theme = DemoTheme;
loop {
terminal.draw(|f| ui(f, &state, &theme))?;
if let Event::Key(key) = event::read()? {
let should_continue = handle_key_press(key.code, key.modifiers, &mut state).await;
if !should_continue {
break;
}
}
}
Ok(())
}
fn ui(f: &mut Frame, state: &AppState, theme: &DemoTheme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(8),
Constraint::Length(5),
])
.split(f.area());
// Render the canvas form - much simpler!
let active_field_rect = render_canvas(
f,
chunks[0],
&state.editor,
theme,
);
// Render autocomplete dropdown if active
if let Some(input_rect) = active_field_rect {
render_autocomplete_dropdown(
f,
chunks[0],
input_rect,
theme,
&state.editor,
);
}
// Status info
let autocomplete_status = if state.editor.is_autocomplete_active() {
if state.editor.ui_state().is_autocomplete_loading() {
"Loading suggestions..."
} else if !state.editor.suggestions().is_empty() {
"Use Tab to navigate, Enter to select, Esc to cancel"
} else {
"No suggestions found"
}
} else {
"Tab to trigger autocomplete"
};
let status_lines = vec![
Line::from(Span::raw(format!("Mode: {:?} | Field: {}/{} | Cursor: {}",
state.editor.mode(),
state.editor.current_field() + 1,
state.editor.data_provider().field_count(),
state.editor.cursor_position()))),
Line::from(Span::raw(format!("Autocomplete: {}", autocomplete_status))),
Line::from(Span::raw(state.debug_message.clone())),
Line::from(Span::raw("F10: Quit | Tab: Trigger/Navigate autocomplete | Enter: Select | Esc: Cancel/Toggle mode")),
];
let status = Paragraph::new(status_lines)
.block(Block::default().borders(Borders::ALL).title("Status & Help"));
f.render_widget(status, chunks[1]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let state = AppState::new();
let res = run_app(&mut terminal, state).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}

View File

@@ -275,7 +275,8 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
} }
fn current_text(&self) -> &str { 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 { fn data_provider(&self) -> &D {
@@ -306,6 +307,42 @@ impl<D: DataProvider> AutoCursorFormEditor<D> {
fn has_unsaved_changes(&self) -> bool { fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes 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 // Demo form data with interesting text for cursor demonstration
@@ -346,7 +383,7 @@ impl DataProvider for CursorDemoData {
self.fields[index].1 = value; self.fields[index].1 = value;
} }
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }
@@ -389,10 +426,17 @@ fn handle_key_press(
editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string()); editor.set_debug_message("✏️ INSERT (end of line) - Cursor: Steady Bar |".to_string());
editor.clear_command_buffer(); editor.clear_command_buffer();
} }
(AppMode::ReadOnly, KeyCode::Char('o'), _) => { (AppMode::ReadOnly, KeyCode::Char('o'), _) => {
editor.move_line_end(); if let Err(e) = editor.open_line_below() {
editor.enter_edit_mode(); // 🎯 Automatic: cursor becomes bar | editor.set_debug_message(format!("Error opening line below: {}", e));
editor.set_debug_message("✏️ INSERT (open line) - Cursor: Steady Bar |".to_string()); }
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(); editor.clear_command_buffer();
} }
@@ -507,10 +551,40 @@ fn handle_key_press(
editor.clear_command_buffer(); editor.clear_command_buffer();
} }
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => { (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('e'), _) => {
editor.move_word_end(); // Check if this is 'ge' command
editor.set_debug_message("e: word end".to_string()); 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(); 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 // Line movement
(AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _) (AppMode::ReadOnly | AppMode::Highlight, KeyCode::Char('0'), _)
@@ -721,9 +795,9 @@ fn render_status_and_help(
} }
} else { } else {
"🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\ "🎯 CURSOR-STYLE DEMO: Normal █ | Insert | | Visual blinking█\n\
Normal: hjkl/arrows=move, w/b/e=words, 0/$=line, gg/G=first/last\n\ Normal: hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, gg/G=first/last\n\
i/a/A=insert, v/b=visual, x/X=delete, ?=info\n\ i/a/A/o/O=insert, v/V=visual, x/X=delete, ?=info\n\
F1=demo manual cursor, F2=restore automatic" F1=demo manual cursor, F2=restore automatic"
} }
} }
AppMode::Edit => { AppMode::Edit => {

View File

@@ -0,0 +1,623 @@
// examples/computed_fields.rs - COMPLETE WORKING VERSION
//! Demonstrates computed fields with the canvas library - Invoice Calculator Example
//!
//! This example REQUIRES the `computed` feature to compile.
//!
//! Run with:
//! cargo run --example computed_fields --features "gui,computed"
#[cfg(not(feature = "computed"))]
compile_error!(
"This example requires the 'computed' feature. \
Run with: cargo run --example computed_fields --features \"gui,computed\""
);
use std::io;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Modifier},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal,
};
use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode},
DataProvider, FormEditor,
computed::{ComputedProvider, ComputedContext},
};
/// Invoice data with computed fields
struct InvoiceData {
fields: Vec<(String, String)>,
computed_indices: std::collections::HashSet<usize>,
}
impl InvoiceData {
fn new() -> Self {
let mut computed_indices = std::collections::HashSet::new();
// Mark computed fields (read-only, calculated)
computed_indices.insert(4); // Subtotal
computed_indices.insert(5); // Tax Amount
computed_indices.insert(6); // Total
Self {
fields: vec![
("📦 Product Name".to_string(), "".to_string()),
("🔢 Quantity".to_string(), "".to_string()),
("💰 Unit Price ($)".to_string(), "".to_string()),
("📊 Tax Rate (%)".to_string(), "".to_string()),
(" Subtotal ($)".to_string(), "".to_string()), // COMPUTED
("🧾 Tax Amount ($)".to_string(), "".to_string()), // COMPUTED
("💳 Total ($)".to_string(), "".to_string()), // COMPUTED
("📝 Notes".to_string(), "".to_string()),
],
computed_indices,
}
}
}
impl DataProvider for InvoiceData {
fn field_count(&self) -> usize {
self.fields.len()
}
fn field_name(&self, index: usize) -> &str {
&self.fields[index].0
}
fn field_value(&self, index: usize) -> &str {
&self.fields[index].1
}
fn set_field_value(&mut self, index: usize, value: String) {
// 🔥 FIXED: Allow computed fields to be updated for display purposes
// The editing protection happens at the editor level, not here
self.fields[index].1 = value;
}
fn supports_suggestions(&self, _field_index: usize) -> bool {
false
}
fn display_value(&self, _index: usize) -> Option<&str> {
None
}
/// Mark computed fields
fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_indices.contains(&field_index)
}
/// Get computed field values
fn computed_field_value(&self, field_index: usize) -> Option<String> {
if self.computed_indices.contains(&field_index) {
Some(self.fields[field_index].1.clone())
} else {
None
}
}
}
/// Invoice calculator - computes totals based on input fields
struct InvoiceCalculator;
impl ComputedProvider for InvoiceCalculator {
fn compute_field(&mut self, context: ComputedContext) -> String {
// Helper to parse field values safely
let parse_field = |index: usize| -> f64 {
let value = context.field_values[index].trim();
if value.is_empty() {
0.0
} else {
value.parse().unwrap_or(0.0)
}
};
match context.target_field {
4 => {
// Subtotal = Quantity × Unit Price
let qty = parse_field(1);
let price = parse_field(2);
let subtotal = qty * price;
if qty == 0.0 || price == 0.0 {
"".to_string() // Show empty if no meaningful calculation
} else {
format!("{:.2}", subtotal)
}
}
5 => {
// Tax Amount = Subtotal × (Tax Rate / 100)
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
let tax_amount = subtotal * (tax_rate / 100.0);
if subtotal == 0.0 || tax_rate == 0.0 {
"".to_string()
} else {
format!("{:.2}", tax_amount)
}
}
6 => {
// Total = Subtotal + Tax Amount
let qty = parse_field(1);
let price = parse_field(2);
let tax_rate = parse_field(3);
let subtotal = qty * price;
if subtotal == 0.0 {
"".to_string()
} else {
let tax_amount = subtotal * (tax_rate / 100.0);
let total = subtotal + tax_amount;
format!("{:.2}", total)
}
}
_ => "".to_string(),
}
}
fn handles_field(&self, field_index: usize) -> bool {
matches!(field_index, 4 | 5 | 6) // Subtotal, Tax Amount, Total
}
fn field_dependencies(&self, field_index: usize) -> Vec<usize> {
match field_index {
4 => vec![1, 2], // Subtotal depends on Quantity, Unit Price
5 => vec![1, 2, 3], // Tax Amount depends on Quantity, Unit Price, Tax Rate
6 => vec![1, 2, 3], // Total depends on Quantity, Unit Price, Tax Rate
_ => vec![],
}
}
}
/// Enhanced editor with computed fields
struct ComputedFieldsEditor<D: DataProvider> {
editor: FormEditor<D>,
calculator: InvoiceCalculator,
debug_message: String,
last_computed_values: Vec<String>,
}
impl<D: DataProvider> ComputedFieldsEditor<D> {
fn new(data_provider: D) -> Self {
let mut editor = FormEditor::new(data_provider);
editor.set_computed_provider(InvoiceCalculator);
let calculator = InvoiceCalculator;
let last_computed_values = vec!["".to_string(); 8];
Self {
editor,
calculator,
debug_message: "💰 Invoice Calculator - Start typing in fields to see calculations!".to_string(),
last_computed_values,
}
}
fn is_computed_field(&self, field_index: usize) -> bool {
self.editor.ui_state().is_computed_field(field_index)
}
fn update_computed_fields(&mut self) {
// Trigger recomputation of all computed fields
self.editor.recompute_all_fields(&mut self.calculator);
// 🔥 CRITICAL FIX: Sync computed values to DataProvider so GUI shows them!
for i in [4, 5, 6] { // Computed field indices
let computed_value = self.editor.effective_field_value(i);
self.editor.data_provider_mut().set_field_value(i, computed_value.clone());
}
// Check if values changed to show feedback
let mut changed = false;
let mut has_calculations = false;
for i in [4, 5, 6] {
let new_value = self.editor.effective_field_value(i);
if new_value != self.last_computed_values[i] {
changed = true;
self.last_computed_values[i] = new_value.clone();
}
if !new_value.is_empty() {
has_calculations = true;
}
}
if changed {
if has_calculations {
let subtotal = &self.last_computed_values[4];
let tax = &self.last_computed_values[5];
let total = &self.last_computed_values[6];
let mut parts = Vec::new();
if !subtotal.is_empty() {
parts.push(format!("Subtotal=${}", subtotal));
}
if !tax.is_empty() {
parts.push(format!("Tax=${}", tax));
}
if !total.is_empty() {
parts.push(format!("Total=${}", total));
}
if !parts.is_empty() {
self.debug_message = format!("🧮 Calculated: {}", parts.join(", "));
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
} else {
self.debug_message = "💰 Enter Quantity and Unit Price to see calculations".to_string();
}
}
}
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.insert_char(ch);
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_backward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_backward();
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn delete_forward(&mut self) -> anyhow::Result<()> {
let current_field = self.editor.current_field();
let result = self.editor.delete_forward();
if result.is_ok() && matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
result
}
fn next_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.next_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{} - {} field", field_name, field_type);
}
}
fn prev_field(&mut self) {
let old_field = self.editor.current_field();
let _ = self.editor.prev_field();
let new_field = self.editor.current_field();
if old_field != new_field {
let field_name = self.editor.data_provider().field_name(new_field);
let field_type = if self.is_computed_field(new_field) {
"computed (read-only)"
} else {
"editable"
};
self.debug_message = format!("{} - {} field", field_name, field_type);
}
}
fn enter_edit_mode(&mut self) {
let current = self.editor.current_field();
// Double protection: check both ways
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
field_name
);
return;
}
self.editor.enter_edit_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Editing {} - Type to see calculations update", field_name);
}
fn enter_append_mode(&mut self) {
let current = self.editor.current_field();
if self.editor.data_provider().is_computed_field(current) || self.is_computed_field(current) {
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!(
"🚫 {} is computed (read-only) - Press Tab to move to editable fields",
field_name
);
return;
}
self.editor.enter_append_mode();
let field_name = self.editor.data_provider().field_name(current);
self.debug_message = format!("✏️ Appending to {} - Type to see calculations", field_name);
}
fn exit_edit_mode(&mut self) {
let current_field = self.editor.current_field();
self.editor.exit_edit_mode();
if matches!(current_field, 1 | 2 | 3) {
self.editor.on_field_changed(&mut self.calculator, current_field);
self.update_computed_fields();
}
self.debug_message = "🔒 Normal mode - Press 'i' to edit fields".to_string();
}
// Delegate methods
fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() }
fn current_text(&self) -> &str {
let field_index = self.editor.current_field();
self.editor.data_provider().field_value(field_index)
}
fn data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() }
fn move_left(&mut self) { self.editor.move_left(); }
fn move_right(&mut self) { self.editor.move_right(); }
fn move_up(&mut self) { let _ = self.editor.move_up(); }
fn move_down(&mut self) { let _ = self.editor.move_down(); }
}
fn handle_key_press(
key: KeyCode,
modifiers: KeyModifiers,
editor: &mut ComputedFieldsEditor<InvoiceData>,
) -> anyhow::Result<bool> {
let mode = editor.mode();
if (key == KeyCode::Char('q') && modifiers.contains(KeyModifiers::CONTROL))
|| (key == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL))
|| key == KeyCode::F(10)
{
return Ok(false);
}
match (mode, key, modifiers) {
(AppMode::ReadOnly, KeyCode::Char('i'), _) => {
editor.enter_edit_mode();
}
(AppMode::ReadOnly, KeyCode::Char('a'), _) => {
editor.enter_append_mode();
}
(AppMode::ReadOnly, KeyCode::Char('A'), _) => {
editor.editor.move_line_end();
editor.enter_edit_mode();
}
(_, KeyCode::Esc, _) => {
if mode == AppMode::Edit {
editor.exit_edit_mode();
}
}
// Movement
(AppMode::ReadOnly, KeyCode::Char('h'), _) | (AppMode::ReadOnly, KeyCode::Left, _) => {
editor.move_left();
}
(AppMode::ReadOnly, KeyCode::Char('l'), _) | (AppMode::ReadOnly, KeyCode::Right, _) => {
editor.move_right();
}
(AppMode::ReadOnly, KeyCode::Char('j'), _) | (AppMode::ReadOnly, KeyCode::Down, _) => {
editor.move_down();
}
(AppMode::ReadOnly, KeyCode::Char('k'), _) | (AppMode::ReadOnly, KeyCode::Up, _) => {
editor.move_up();
}
// Edit mode movement
(AppMode::Edit, KeyCode::Left, _) => { editor.move_left(); }
(AppMode::Edit, KeyCode::Right, _) => { editor.move_right(); }
(AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); }
(AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); }
// Navigation
(_, KeyCode::Tab, _) => {
editor.next_field();
}
(_, KeyCode::BackTab, _) => {
editor.prev_field();
}
// Editing
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
editor.insert_char(c)?;
}
(AppMode::Edit, KeyCode::Backspace, _) => {
editor.delete_backward()?;
}
(AppMode::Edit, KeyCode::Delete, _) => {
editor.delete_forward()?;
}
// Debug info
(AppMode::ReadOnly, KeyCode::Char('?'), _) => {
let current = editor.current_field();
let field_name = editor.data_provider().field_name(current);
let field_type = if editor.is_computed_field(current) {
"COMPUTED (read-only)"
} else {
"EDITABLE"
};
editor.debug_message = format!(
"{} - {} - Position {} - Mode: {:?}",
field_name, field_type, editor.cursor_position(), mode
);
}
_ => {}
}
Ok(true)
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut editor: ComputedFieldsEditor<InvoiceData>,
) -> io::Result<()> {
editor.update_computed_fields(); // Initial computation
loop {
terminal.draw(|f| ui(f, &editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key.code, key.modifiers, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.debug_message = format!("Error: {}", e);
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(10)])
.split(f.area());
render_enhanced_canvas(f, chunks[0], editor);
render_computed_status(f, chunks[1], editor);
}
fn render_enhanced_canvas(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
render_canvas_default(f, area, &editor.editor);
}
fn render_computed_status(f: &mut Frame, area: Rect, editor: &ComputedFieldsEditor<InvoiceData>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(7)])
.split(area);
let mode_text = match editor.mode() {
AppMode::Edit => "INSERT",
AppMode::ReadOnly => "NORMAL",
_ => "OTHER",
};
let current = editor.current_field();
let field_status = if editor.is_computed_field(current) {
"📊 COMPUTED FIELD (read-only)"
} else {
"✏️ EDITABLE FIELD"
};
let status_text = format!("-- {} -- {} | {}", mode_text, field_status, editor.debug_message);
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("💰 Invoice Calculator"));
f.render_widget(status, chunks[0]);
let help_text = match editor.mode() {
AppMode::ReadOnly => {
"💰 COMPUTED FIELDS DEMO: Real-time invoice calculations!\n\
🔢 EDITABLE: Product, Quantity, Unit Price, Tax Rate, Notes\n\
📊 COMPUTED: Subtotal, Tax Amount, Total (calculated automatically)\n\
\n\
🚀 START: Press 'i' to edit Quantity, type '5', Tab to Unit Price, type '19.99'\n\
Watch Subtotal and Total appear! Add Tax Rate to see tax calculations.\n\
Navigation: Tab/Shift+Tab skips computed fields automatically"
}
AppMode::Edit => {
"✏️ EDIT MODE: Type numbers to see calculations appear!\n\
\n\
💡 EXAMPLE: Type '5' in Quantity, then Tab to Unit Price and type '19.99'\n\
• Subtotal appears: $99.95\n\
• Total appears: $99.95\n\
• Add Tax Rate (like '10') to see tax: $9.99, Total: $109.94\n\
\n\
Esc=normal, Tab=next field (auto-skips computed fields)"
}
_ => "💰 Invoice Calculator with Computed Fields"
};
let help_style = if editor.is_computed_field(editor.current_field()) {
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC)
} else {
Style::default().fg(Color::Gray)
};
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Try It Now!"))
.style(help_style)
.wrap(Wrap { trim: true });
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("💰 Canvas Computed Fields Demo - Invoice Calculator");
println!("✅ computed feature: ENABLED");
println!("🚀 QUICK TEST:");
println!(" 1. Press 'i' to edit Quantity");
println!(" 2. Type '5' and press Tab");
println!(" 3. Type '19.99' in Unit Price");
println!(" 4. Watch Subtotal ($99.95) and Total ($99.95) appear!");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let data = InvoiceData::new();
let editor = ComputedFieldsEditor::new(data);
let res = run_app(&mut terminal, editor);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("💰 Demo completed! Computed fields should have updated in real-time!");
Ok(())
}

View File

@@ -345,7 +345,7 @@ impl DataProvider for FullDemoData {
self.fields[index].1 = value; self.fields[index].1 = value;
} }
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
// 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(),
(KeyCode::F(1), _) => {
// Switch to indicator mode
editor.textarea.use_overflow_indicator('$');
editor.set_debug_message("Overflow: indicator '$' (wrap OFF)".to_string());
}
(KeyCode::F(2), _) => {
// Switch to wrap mode
editor.textarea.use_wrap();
editor.set_debug_message("Overflow: wrap ON".to_string());
}
(KeyCode::F(3), _) => {
editor.textarea.set_wrap_indent_cols(3);
editor.set_debug_message("Wrap indent: 3 columns".to_string());
}
(KeyCode::F(4), _) => {
editor.textarea.set_wrap_indent_cols(0);
editor.set_debug_message("Wrap indent: 0 columns".to_string());
}
// Debug/info
(KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: NORMALMODE (casual editor, underscore cursor)",
editor.get_cursor_info()
));
editor.clear_command_buffer();
}
// Default: treat as text input
_ => editor.handle_textarea_input(key_event),
}
Ok(true)
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut editor: AutoCursorTextArea) -> io::Result<()> {
loop {
terminal.draw(|f| ui(f, &mut editor))?;
if let Event::Key(key) = event::read()? {
match handle_key_press(key, &mut editor) {
Ok(should_continue) => {
if !should_continue {
break;
}
}
Err(e) => {
editor.set_debug_message(format!("Error: {}", e));
}
}
}
}
Ok(())
}
fn ui(f: &mut Frame, editor: &mut AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(8)])
.split(f.area());
render_textarea(f, chunks[0], editor);
render_status_and_help(f, chunks[1], editor);
}
fn render_textarea(f: &mut Frame, area: ratatui::layout::Rect, editor: &mut AutoCursorTextArea) {
let block = Block::default()
.borders(Borders::ALL)
.title("🎯 Textarea with NORMALMODE (always editing)");
let textarea_widget = TextArea::default().block(block.clone());
f.render_stateful_widget(textarea_widget, area, &mut editor.textarea);
let (cx, cy) = editor.textarea.cursor(area, Some(&block));
f.set_cursor_position((cx, cy));
}
fn render_status_and_help(f: &mut Frame, area: ratatui::layout::Rect, editor: &AutoCursorTextArea) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Length(5)])
.split(area);
let status_text = if editor.has_pending_command() {
format!(
"-- NORMALMODE (underscore cursor) -- {} [{}]",
editor.debug_message(),
editor.get_command_buffer()
)
} else if editor.has_unsaved_changes() {
format!(
"-- NORMALMODE (underscore cursor) -- [Modified] {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
} else {
format!(
"-- NORMALMODE (underscore cursor) -- {} | {}",
editor.debug_message(),
editor.get_cursor_info()
)
};
let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🎯 Cursor Status"));
f.render_widget(status, chunks[0]);
let help_text = "🎯 NORMALMODE (always editing)\n\
hjkl/arrows=move, w/b/e=words, W/B/E=WORDS, 0/$=line, g/G=first/last\n\
x/X=delete, typing inserts text\n\
?=info, Ctrl+Q=quit";
let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 Help"))
.style(Style::default().fg(Color::Gray));
f.render_widget(help, chunks[1]);
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎯 Canvas Textarea Cursor Auto Demo (NORMALMODE)");
println!("✅ cursor-style feature: ENABLED");
println!("✅ textarea feature: ENABLED");
println!("✅ textmode-normal feature: ENABLED");
println!("🚀 Always editing, underscore cursor active");
println!();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let editor = AutoCursorTextArea::new();
let res = run_app(&mut terminal, editor);
CursorManager::reset()?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
println!("🎯 Cursor automatically reset to default!");
Ok(())
}

View File

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

View File

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

View File

@@ -4,13 +4,13 @@
//! This example showcases the full potential of the pattern validation system //! This example showcases the full potential of the pattern validation system
//! with creative real-world scenarios and edge cases. //! 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 // REQUIRE validation, gui and cursor-style features
#[cfg(not(all(feature = "validation", feature = "gui")))] #[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!( compile_error!(
"This example requires the 'validation' and 'gui' features. \ "This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_advanced_patterns --features \"validation,gui\"" Run with: cargo run --example validation_advanced_patterns --features \"validation,gui,cursor-style\""
); );
use std::io; use std::io;
@@ -38,6 +38,7 @@ use canvas::{
canvas::{ canvas::{
gui::render_canvas_default, gui::render_canvas_default,
modes::AppMode, modes::AppMode,
CursorManager,
}, },
DataProvider, FormEditor, DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter, ValidationConfig, ValidationConfigBuilder, PatternFilters, PositionFilter, PositionRange, CharacterFilter,
@@ -107,30 +108,32 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn move_line_end(&mut self) { self.editor.move_line_end(); } fn move_line_end(&mut self) { self.editor.move_line_end(); }
fn enter_edit_mode(&mut self) { fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_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) { fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_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) { fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_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(); self.update_field_validation_status();
} }
fn insert_char(&mut self, ch: char) -> anyhow::Result<()> { fn insert_char(&mut self, ch: char) -> anyhow::Result<()> {
let result = self.editor.insert_char(ch); let result = self.editor.insert_char(ch);
if result.is_ok() { if result.is_ok() {
if let Some(validation_result) = self.editor.current_field_validation() { let validation_result = self.editor.validate_current_field();
match validation_result { match validation_result {
ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); } ValidationResult::Valid => { self.debug_message = "✅ Character accepted".to_string(); }
ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); } ValidationResult::Warning { message } => { self.debug_message = format!("⚠️ Warning: {}", message); }
ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); } ValidationResult::Error { message } => { self.debug_message = format!("❌ Pattern violation: {}", message); }
}
} }
} }
Ok(result?) Ok(result?)
@@ -152,7 +155,10 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn current_field(&self) -> usize { self.editor.current_field() } fn current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() } fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() } 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 data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() } 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) { self.editor.set_mode(mode); }
@@ -176,12 +182,11 @@ impl<D: DataProvider> AdvancedPatternFormEditor<D> {
fn update_field_validation_status(&mut self) { fn update_field_validation_status(&mut self) {
if !self.validation_enabled { return; } if !self.validation_enabled { return; }
if let Some(result) = self.editor.current_field_validation() { let result = self.editor.validate_current_field();
match result { match result {
ValidationResult::Valid => { self.debug_message = format!("Field {}: ✅ Pattern valid", self.editor.current_field() + 1); } 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::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); } ValidationResult::Error { message } => { self.debug_message = format!("Field {}: ❌ {}", self.editor.current_field() + 1, message); }
}
} }
} }
@@ -522,9 +527,9 @@ fn render_advanced_validation_status(
// Status bar // Status bar
let mode_text = match editor.mode() { let mode_text = match editor.mode() {
AppMode::Edit => "INSERT", AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL", AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "OTHER", _ => "NORMAL █ (block cursor)",
}; };
let validation_status = editor.get_validation_status(); 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!("🚀 Canvas Advanced Pattern Validation Demo");
println!("✅ validation feature: ENABLED"); println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED"); println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🎯 Advanced pattern filtering: ACTIVE"); println!("🎯 Advanced pattern filtering: ACTIVE");
println!("🧪 Edge cases and complex patterns: READY"); println!("🧪 Edge cases and complex patterns: READY");
println!("💡 Each field showcases different validation capabilities!"); 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 mut terminal = Terminal::new(backend)?;
let data = AdvancedPatternData::new(); 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); 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()?; disable_raw_mode()?;
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),

View File

@@ -18,13 +18,13 @@
//! Each mask's input position count EXACTLY matches its character limit to prevent //! 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. //! 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 // REQUIRE validation, gui and cursor-style features for mask functionality
#[cfg(not(all(feature = "validation", feature = "gui")))] #[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!( compile_error!(
"This example requires the 'validation' and 'gui' features. \ "This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_3 --features \"gui,validation\"" Run with: cargo run --example validation_3 --features \"gui,validation,cursor-style\""
); );
use std::io; use std::io;
@@ -50,6 +50,7 @@ use canvas::{
canvas::{ canvas::{
gui::render_canvas_default, gui::render_canvas_default,
modes::AppMode, modes::AppMode,
CursorManager,
}, },
DataProvider, FormEditor, DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, DisplayMask, ValidationConfig, ValidationConfigBuilder, DisplayMask,
@@ -107,7 +108,7 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
fn get_current_field_info(&self) -> (String, String, String) { fn get_current_field_info(&self) -> (String, String, String) {
let field_index = self.editor.current_field(); 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 { let display_data = if self.validation_enabled {
self.editor.current_display_text() self.editor.current_display_text()
} else { } else {
@@ -183,18 +184,21 @@ impl<D: DataProvider> MaskDemoFormEditor<D> {
// === MODE TRANSITIONS === // === MODE TRANSITIONS ===
fn enter_edit_mode(&mut self) { fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_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) { fn enter_append_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_append_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) { fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_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<()> { 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 current_field(&self) -> usize { self.editor.current_field() }
fn cursor_position(&self) -> usize { self.editor.cursor_position() } fn cursor_position(&self) -> usize { self.editor.cursor_position() }
fn mode(&self) -> AppMode { self.editor.mode() } 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 data_provider(&self) -> &D { self.editor.data_provider() }
fn ui_state(&self) -> &canvas::EditorState { self.editor.ui_state() } 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) { fn next_field(&mut self) {
match self.editor.next_field() { match self.editor.next_field() {
@@ -582,9 +592,9 @@ fn render_mask_status(
// Status bar with mask information // Status bar with mask information
let mode_text = match editor.mode() { let mode_text = match editor.mode() {
AppMode::Edit => "INSERT", AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL", AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "OTHER", _ => "NORMAL █ (block cursor)",
}; };
let mask_status = editor.get_mask_status(); let mask_status = editor.get_mask_status();
@@ -634,17 +644,19 @@ fn render_mask_status(
// Enhanced help text // Enhanced help text
let help_text = match editor.mode() { let help_text = match editor.mode() {
AppMode::ReadOnly => { 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\ \n\
📱 Try different fields to see various mask patterns:\n\ 📱 Try different fields to see various mask patterns:\n\
• Dynamic vs Template modes • Custom separators • Different input chars\n\ • Dynamic vs Template modes • Custom separators • Different input chars\n\
\n\ \n\
Commands: i/a=insert, m=mask details, r=toggle raw/display view\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" ?=detailed info, Ctrl+C=quit"
} }
AppMode::Edit => { 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\ \n\
🔥 Key Features in Action:\n\ 🔥 Key Features in Action:\n\
• Separators auto-appear as you type • Cursor skips over separators\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!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED"); println!("✅ gui feature: ENABLED");
println!("🎭 Display masks: ACTIVE"); println!("🎭 Display masks: ACTIVE");
println!("✅ cursor-style feature: ENABLED");
println!("🔥 Key Benefits Demonstrated:"); println!("🔥 Key Benefits Demonstrated:");
println!(" • Clean separation: Visual formatting ≠ Business logic"); println!(" • Clean separation: Visual formatting ≠ Business logic");
println!(" • User-friendly: Pretty displays with automatic cursor handling"); 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 mut terminal = Terminal::new(backend)?;
let data = MaskDemoData::new(); 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); let res = run_app(&mut terminal, editor);
@@ -702,6 +721,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
)?; )?;
terminal.show_cursor()?; 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 { if let Err(err) = res {
println!("{:?}", err); println!("{:?}", err);
} }

View File

@@ -13,10 +13,10 @@
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
#[cfg(not(all(feature = "validation", feature = "gui")))] #[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!( compile_error!(
"This example requires the 'validation' and 'gui' features. \ "This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_4 --features \"gui,validation\"" Run with: cargo run --example validation_4 --features \"gui,validation,cursor-style\""
); );
use std::io; use std::io;
@@ -39,7 +39,7 @@ use ratatui::{
}; };
use canvas::{ use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode}, canvas::{gui::render_canvas_default, modes::AppMode, CursorManager},
DataProvider, FormEditor, DataProvider, FormEditor,
ValidationConfig, ValidationConfigBuilder, ValidationConfig, ValidationConfigBuilder,
CustomFormatter, FormattingResult, CustomFormatter, FormattingResult,
@@ -364,7 +364,8 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
} }
fn get_current_field_analysis(&self) -> (String, String, String, Option<String>) { 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 display = self.editor.current_display_text();
let status = if raw == display { let status = if raw == display {
@@ -403,21 +404,23 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
// Delegate methods with enhanced feedback // Delegate methods with enhanced feedback
fn enter_edit_mode(&mut self) { fn enter_edit_mode(&mut self) {
// Library will automatically update cursor to bar | in insert mode
self.editor.enter_edit_mode(); self.editor.enter_edit_mode();
let field_type = self.current_field_type(); let field_type = self.current_field_type();
let rules = self.get_input_rules(); 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) { fn exit_edit_mode(&mut self) {
// Library will automatically update cursor to block █ in normal mode
self.editor.exit_edit_mode(); self.editor.exit_edit_mode();
let (raw, display, _, warning) = self.get_current_field_analysis(); let (raw, display, _, warning) = self.get_current_field_analysis();
if let Some(warn) = warning { 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 { } 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 { } else {
self.debug_message = "🔒 NORMAL MODE".to_string(); self.debug_message = "🔒 NORMAL MODE - Cursor: Steady Block █".to_string();
} }
} }
@@ -443,7 +446,8 @@ impl<D: DataProvider> EnhancedDemoEditor<D> {
let raw_pos = self.editor.cursor_position(); let raw_pos = self.editor.cursor_position();
let display_pos = self.editor.display_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(); let display = self.editor.current_display_text();
if raw_pos != display_pos { if raw_pos != display_pos {
@@ -588,9 +592,9 @@ fn render_enhanced_status(
// Status bar // Status bar
let mode_text = match editor.mode() { let mode_text = match editor.mode() {
AppMode::Edit => "INSERT", AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL", AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "OTHER", _ => "NORMAL █ (block cursor)",
}; };
let formatter_count = (0..editor.data_provider().field_count()) let formatter_count = (0..editor.data_provider().field_count())
@@ -660,7 +664,8 @@ fn render_enhanced_status(
// Enhanced help // Enhanced help
let help_text = match editor.mode() { let help_text = match editor.mode() {
AppMode::ReadOnly => { AppMode::ReadOnly => {
"🧩 ENHANCED CUSTOM FORMATTER DEMO\n\ "🎯 CURSOR-STYLE: Normal █ | Insert |\n\
🧩 ENHANCED CUSTOM FORMATTER DEMO\n\
\n\ \n\
Try these formatters: Try these formatters:
• PSC: 01001 → 010 01 | Phone: 1234567890 → (123) 456-7890 | Card: 1234567890123456 → 1234 5678 9012 3456 • 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" Ctrl+C/F10=quit"
} }
AppMode::Edit => { AppMode::Edit => {
"✏️ INSERT MODE - Real-time formatting as you type!\n\ "🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Real-time formatting as you type!\n\
\n\ \n\
Current field rules: {}\n\ Current field rules: {}\n\
• Raw input is authoritative (what gets stored)\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!("🧩 Enhanced Canvas Custom Formatter Demo (Feature 4)");
println!("✅ validation feature: ENABLED"); println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED"); println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🧩 Enhanced features:"); println!("🧩 Enhanced features:");
println!(" • 5 different custom formatters with edge cases"); println!(" • 5 different custom formatters with edge cases");
println!(" • Real-time format preview and validation"); 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 mut terminal = Terminal::new(backend)?;
let data = MultiFormatterDemoData::new(); 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); 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)?; execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?; 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 { if let Err(err) = res {
println!("{:?}", err); println!("{:?}", err);
} }

View File

@@ -1,9 +1,10 @@
// examples/validation_5.rs // examples/validation_5.rs
//! Enhanced Feature 5: Comprehensive external validation (UI-only) demo with Feature 4 integration //! Enhanced Feature 5: Comprehensive external validation (UI-only) demo with automatic validation
//! //!
//! Demonstrates: //! Demonstrates:
//! - Multiple external validation types: PSC lookup, email domain check, username availability, //! - Multiple external validation types: PSC lookup, email domain check, username availability,
//! API key validation, credit card verification //! API key validation, credit card verification
//! - AUTOMATIC validation on field transitions (arrows, Tab, Esc)
//! - Async validation simulation with realistic delays //! - Async validation simulation with realistic delays
//! - Validation caching and debouncing //! - Validation caching and debouncing
//! - Progressive validation (local → remote) //! - Progressive validation (local → remote)
@@ -15,7 +16,8 @@
//! Controls: //! Controls:
//! - i/a: insert/append //! - i/a: insert/append
//! - Esc: exit edit mode (triggers validation on configured fields) //! - Esc: exit edit mode (triggers validation on configured fields)
//! - Tab/Shift+Tab: next/prev field (triggers validation) //! - Tab/Shift+Tab: next/prev field (triggers validation automatically)
//! - Arrow keys: move between fields (triggers validation automatically)
//! - v: manually trigger validation of current field //! - v: manually trigger validation of current field
//! - V: validate all fields //! - V: validate all fields
//! - c: clear external validation state for current field //! - c: clear external validation state for current field
@@ -26,18 +28,18 @@
//! - F1: toggle external validation globally //! - F1: toggle external validation globally
//! - F10/Ctrl+C: quit //! - F10/Ctrl+C: quit
//! //!
//! Run: cargo run --example validation_5 --features "gui,validation" //! Run: cargo run --example validation_5 --features "gui,validation,cursor-style"
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
#[cfg(not(all(feature = "validation", feature = "gui")))] #[cfg(not(all(feature = "validation", feature = "gui", feature = "cursor-style")))]
compile_error!( compile_error!(
"This example requires the 'validation' and 'gui' features. \ "This example requires the 'validation', 'gui' and 'cursor-style' features. \
Run with: cargo run --example validation_5 --features \"gui,validation\"" Run with: cargo run --example validation_5 --features \"gui,validation,cursor-style\""
); );
use std::io; use std::io;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Instant, Duration}; use std::time::{Instant, Duration};
@@ -59,7 +61,7 @@ use ratatui::{
}; };
use canvas::{ use canvas::{
canvas::{gui::render_canvas_default, modes::AppMode}, canvas::{gui::render_canvas_default, modes::AppMode, CursorManager},
DataProvider, FormEditor, DataProvider, FormEditor,
ValidationConfigBuilder, CustomFormatter, FormattingResult, ValidationConfigBuilder, CustomFormatter, FormattingResult,
validation::ExternalValidationState, validation::ExternalValidationState,
@@ -546,16 +548,15 @@ impl DataProvider for ValidationDemoData {
} }
} }
/// Enhanced editor with comprehensive external validation management /// Enhanced editor with automatic external validation management
struct ValidationDemoEditor<D: DataProvider> { struct ValidationDemoEditor<D: DataProvider> {
editor: FormEditor<D>, editor: FormEditor<D>,
services: ValidationServices, services: Arc<Mutex<ValidationServices>>,
validation_history: Vec<(usize, String, ValidationResult)>, validation_history: Vec<(usize, String, ValidationResult)>,
debug_message: String, debug_message: String,
show_history: bool, show_history: bool,
example_mode: usize, example_mode: usize,
validation_enabled: bool, validation_enabled: bool,
auto_validate: bool,
validation_stats: HashMap<usize, (u32, Duration)>, // field -> (count, total_time) validation_stats: HashMap<usize, (u32, Duration)>, // field -> (count, total_time)
} }
@@ -564,15 +565,70 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let mut editor = FormEditor::new(data_provider); let mut editor = FormEditor::new(data_provider);
editor.set_validation_enabled(true); editor.set_validation_enabled(true);
let services = Arc::new(Mutex::new(ValidationServices::new()));
let services_for_cb = Arc::clone(&services);
let services_for_history = Arc::clone(&services);
// Create a history tracker that we'll share between callback and editor
let validation_history: Arc<Mutex<Vec<(usize, String, ValidationResult)>>> = Arc::new(Mutex::new(Vec::new()));
let history_for_cb = Arc::clone(&validation_history);
// Library-level automatic external validation on field transitions
editor.set_external_validation_callback(move |field_idx, text| {
let mut svc = services_for_cb.lock().unwrap();
let validation_type = match field_idx {
0 => "PSC Lookup",
1 => "Email Domain Check",
2 => "Username Availability",
3 => "API Key Auth",
4 => "Credit Card Verify",
_ => "Unknown",
}.to_string();
let start_time = Instant::now();
let validation_result = match field_idx {
0 => svc.validate_psc(text),
1 => svc.validate_email(text),
2 => svc.validate_username(text),
3 => svc.validate_api_key(text),
4 => svc.validate_credit_card(text),
_ => ExternalValidationState::NotValidated,
};
// Record in shared history (if we can lock it)
if let Ok(mut history) = history_for_cb.try_lock() {
let duration = start_time.elapsed();
let result = ValidationResult {
state: validation_result.clone(),
started_at: start_time,
completed_at: Some(Instant::now()),
validation_type,
cached: false, // We could enhance this by checking if it was from cache
};
history.push((field_idx, text.to_string(), result));
// Limit history size
if history.len() > 50 {
history.remove(0);
}
}
validation_result
});
Self { Self {
editor, editor,
services: ValidationServices::new(), services,
validation_history: Vec::new(), validation_history: Vec::new(),
debug_message: "🧪 Enhanced External Validation Demo - Multiple validation types with rich scenarios!".to_string(), debug_message:
"🧪 Enhanced External Validation Demo - Automatic validation on field transitions!"
.to_string(),
show_history: false, show_history: false,
example_mode: 0, example_mode: 0,
validation_enabled: true, validation_enabled: true,
auto_validate: true,
validation_stats: HashMap::new(), validation_stats: HashMap::new(),
} }
} }
@@ -608,7 +664,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
self.current_field() < 5 self.current_field() < 5
} }
/// Trigger external validation for specific field /// Trigger external validation for specific field (manual validation)
fn validate_field(&mut self, field_index: usize) { fn validate_field(&mut self, field_index: usize) {
if !self.validation_enabled || field_index >= 5 { if !self.validation_enabled || field_index >= 5 {
return; return;
@@ -634,14 +690,17 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let mut result = ValidationResult::new(validation_type.clone()); let mut result = ValidationResult::new(validation_type.clone());
// Perform validation (in real app, this would be async) // Perform validation using the shared services
let validation_result = match field_index { let validation_result = {
0 => self.services.validate_psc(&raw_value), let mut svc = self.services.lock().unwrap();
1 => self.services.validate_email(&raw_value), match field_index {
2 => self.services.validate_username(&raw_value), 0 => svc.validate_psc(&raw_value),
3 => self.services.validate_api_key(&raw_value), 1 => svc.validate_email(&raw_value),
4 => self.services.validate_credit_card(&raw_value), 2 => svc.validate_username(&raw_value),
_ => ExternalValidationState::NotValidated, 3 => svc.validate_api_key(&raw_value),
4 => svc.validate_credit_card(&raw_value),
_ => ExternalValidationState::NotValidated,
}
}; };
result = result.complete(validation_result.clone()); result = result.complete(validation_result.clone());
@@ -665,7 +724,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
let duration_ms = result.duration().as_millis(); let duration_ms = result.duration().as_millis();
let cached_text = if result.cached { " (cached)" } else { "" }; let cached_text = if result.cached { " (cached)" } else { "" };
self.debug_message = format!( self.debug_message = format!(
"🔍 {} validation completed in {}ms{}", "🔍 {} validation completed in {}ms{} (manual)",
validation_type, duration_ms, cached_text validation_type, duration_ms, cached_text
); );
} }
@@ -675,7 +734,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
for i in 0..field_count { for i in 0..field_count {
self.validate_field(i); self.validate_field(i);
} }
self.debug_message = "🔍 All fields validated".to_string(); self.debug_message = "🔍 All fields validated manually".to_string();
} }
fn clear_validation_state(&mut self, field_index: Option<usize>) { fn clear_validation_state(&mut self, field_index: Option<usize>) {
@@ -690,7 +749,9 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
} }
self.validation_history.clear(); self.validation_history.clear();
self.validation_stats.clear(); self.validation_stats.clear();
self.services.clear_cache(); if let Ok(mut svc) = self.services.lock() {
svc.clear_cache();
}
self.debug_message = "🧹 Cleared all validation states and cache".to_string(); self.debug_message = "🧹 Cleared all validation states and cache".to_string();
} }
} }
@@ -720,7 +781,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
fn cycle_examples(&mut self) { fn cycle_examples(&mut self) {
let examples = [ let examples = [
// Valid examples // Valid examples
vec!["01001", "user@gmail.com", "alice_dev", "valid_api_key_123456789012345", "4000123456789012", "Valid data"], vec!["01001", "user@gmail.com", "alice_dev_new", "valid_api_key_123456789012345", "4000123456789012", "Valid data"],
// Invalid examples // Invalid examples
vec!["00000", "invalid-email", "admin", "short_key", "0000000000000000", "Invalid data"], vec!["00000", "invalid-email", "admin", "short_key", "0000000000000000", "Invalid data"],
// Warning examples // Warning examples
@@ -739,7 +800,7 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
} }
let mode_names = ["Valid Examples", "Invalid Examples", "Warning Cases", "Mixed Scenarios"]; let mode_names = ["Valid Examples", "Invalid Examples", "Warning Cases", "Mixed Scenarios"];
self.debug_message = format!("📋 Loaded: {}", mode_names[self.example_mode]); self.debug_message = format!("📋 Loaded: {} (navigate to trigger validation)", mode_names[self.example_mode]);
} }
fn get_validation_summary(&self) -> String { fn get_validation_summary(&self) -> String {
@@ -758,42 +819,44 @@ impl<D: DataProvider> ValidationDemoEditor<D> {
self.editor.ui_state().validation_state().get_external_validation(field_index) self.editor.ui_state().validation_state().get_external_validation(field_index)
} }
// Editor pass-through methods // Editor pass-through methods - simplified since library handles automatic validation
fn enter_edit_mode(&mut self) { fn enter_edit_mode(&mut self) {
self.editor.enter_edit_mode(); self.editor.enter_edit_mode();
let rules = self.field_validation_rules(); let rules = self.field_validation_rules();
self.debug_message = format!("✏️ EDITING {} - {}", self.field_type(), rules); self.debug_message = format!("✏️ INSERT MODE - Cursor: Steady Bar | - {} - {}", self.field_type(), rules);
} }
fn exit_edit_mode(&mut self) { fn exit_edit_mode(&mut self) {
let current_field = self.current_field();
self.editor.exit_edit_mode(); self.editor.exit_edit_mode();
// Library automatically validates on exit, no manual call needed
// Auto-validate on blur if enabled self.debug_message = format!("🔒 NORMAL - Cursor: Steady Block █ - {} (auto-validated)", self.field_type());
if self.auto_validate && self.has_external_validation() {
self.validate_field(current_field);
}
self.debug_message = format!("🔒 NORMAL - {}", self.field_type());
} }
fn next_field(&mut self) { fn next_field(&mut self) {
let current = self.current_field();
if let Ok(()) = self.editor.next_field() { if let Ok(()) = self.editor.next_field() {
if self.auto_validate && current < 5 { // Library triggers external validation automatically via transition_to_field()
self.validate_field(current); self.debug_message = "➡ Next field (auto-validation triggered by library)".to_string();
}
self.debug_message = "➡ Next field".to_string();
} }
} }
fn prev_field(&mut self) { fn prev_field(&mut self) {
let current = self.current_field();
if let Ok(()) = self.editor.prev_field() { if let Ok(()) = self.editor.prev_field() {
if self.auto_validate && current < 5 { // Library triggers external validation automatically via transition_to_field()
self.validate_field(current); self.debug_message = "⬅ Previous field (auto-validation triggered by library)".to_string();
} }
self.debug_message = "⬅ Previous field".to_string(); }
fn move_up(&mut self) {
if let Ok(()) = self.editor.move_up() {
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "⬆ Move up (auto-validation triggered by library)".to_string();
}
}
fn move_down(&mut self) {
if let Ok(()) = self.editor.move_down() {
// Library triggers external validation automatically via transition_to_field()
self.debug_message = "⬇ Move down (auto-validation triggered by library)".to_string();
} }
} }
@@ -839,37 +902,46 @@ fn run_app<B: Backend>(
}, },
(_, KeyCode::Esc, _) => editor.exit_edit_mode(), (_, KeyCode::Esc, _) => editor.exit_edit_mode(),
// Movement - cursor within field // Movement - these now trigger automatic validation via the library!
(_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => { let _ = editor.editor.move_left(); }, (_, KeyCode::Left, _) | (AppMode::ReadOnly, KeyCode::Char('h'), _) => {
(_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => { let _ = editor.editor.move_right(); }, let _ = editor.editor.move_left();
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => { let _ = editor.editor.move_up(); }, },
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => { let _ = editor.editor.move_down(); }, (_, KeyCode::Right, _) | (AppMode::ReadOnly, KeyCode::Char('l'), _) => {
// Field switching let _ = editor.editor.move_right();
},
(_, KeyCode::Up, _) | (AppMode::ReadOnly, KeyCode::Char('k'), _) => {
editor.move_up(); // Use wrapper to get debug message
},
(_, KeyCode::Down, _) | (AppMode::ReadOnly, KeyCode::Char('j'), _) => {
editor.move_down(); // Use wrapper to get debug message
},
// Field switching - these trigger automatic validation via the library!
(_, KeyCode::Tab, _) => editor.next_field(), (_, KeyCode::Tab, _) => editor.next_field(),
(_, KeyCode::BackTab, _) => editor.prev_field(), (_, KeyCode::BackTab, _) => editor.prev_field(),
// Validation commands // Manual validation commands (ONLY in ReadOnly mode)
(_, KeyCode::Char('v'), _) => { (AppMode::ReadOnly, KeyCode::Char('v'), _) => {
let field = editor.current_field(); let field = editor.current_field();
editor.validate_field(field); editor.validate_field(field);
}, },
(_, KeyCode::Char('V'), _) => editor.validate_all_fields(), (AppMode::ReadOnly, KeyCode::Char('V'), _) => editor.validate_all_fields(),
(_, KeyCode::Char('c'), _) => { (AppMode::ReadOnly, KeyCode::Char('c'), _) => {
let field = editor.current_field(); let field = editor.current_field();
editor.clear_validation_state(Some(field)); editor.clear_validation_state(Some(field));
}, },
(_, KeyCode::Char('C'), _) => editor.clear_validation_state(None), (AppMode::ReadOnly, KeyCode::Char('C'), _) => editor.clear_validation_state(None),
// UI toggles // UI toggles (ONLY in ReadOnly mode for alpha keys to avoid blocking text input)
(_, KeyCode::Char('r'), _) => editor.toggle_history_view(), (AppMode::ReadOnly, KeyCode::Char('r'), _) => editor.toggle_history_view(),
(_, KeyCode::Char('e'), _) => editor.cycle_examples(), (AppMode::ReadOnly, KeyCode::Char('e'), _) => editor.cycle_examples(),
(_, KeyCode::F(1), _) => editor.toggle_validation(), (_, KeyCode::F(1), _) => editor.toggle_validation(),
// Editing // Editing
(AppMode::Edit, KeyCode::Left, _) => { let _ = editor.editor.move_left(); }, (AppMode::Edit, KeyCode::Left, _) => { let _ = editor.editor.move_left(); },
(AppMode::Edit, KeyCode::Right, _) => { let _ = editor.editor.move_right(); }, (AppMode::Edit, KeyCode::Right, _) => { let _ = editor.editor.move_right(); },
(AppMode::Edit, KeyCode::Up, _) => { let _ = editor.editor.move_up(); }, (AppMode::Edit, KeyCode::Up, _) => { editor.move_up(); },
(AppMode::Edit, KeyCode::Down, _) => { let _ = editor.editor.move_down(); }, (AppMode::Edit, KeyCode::Down, _) => { editor.move_down(); },
(AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { (AppMode::Edit, KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
let _ = editor.insert_char(c); let _ = editor.insert_char(c);
}, },
@@ -915,26 +987,25 @@ fn render_validation_panel(
// Status bar // Status bar
let mode_text = match editor.mode() { let mode_text = match editor.mode() {
AppMode::Edit => "INSERT", AppMode::Edit => "INSERT | (bar cursor)",
AppMode::ReadOnly => "NORMAL", AppMode::ReadOnly => "NORMAL █ (block cursor)",
_ => "OTHER", _ => "NORMAL █ (block cursor)",
}; };
let summary = editor.get_validation_summary(); let summary = editor.get_validation_summary();
let status_text = format!( let status_text = format!(
"-- {} -- {} | {} | Auto: {} | View: {}", "-- {} -- {} | {} | View: {}",
mode_text, mode_text,
editor.debug_message, editor.debug_message,
summary, summary,
if editor.auto_validate { "ON" } else { "OFF" },
if editor.show_history { "HISTORY" } else { "STATUS" } if editor.show_history { "HISTORY" } else { "STATUS" }
); );
let status = Paragraph::new(Line::from(Span::raw(status_text))) let status = Paragraph::new(Line::from(Span::raw(status_text)))
.block(Block::default().borders(Borders::ALL).title("🧪 External Validation Demo")); .block(Block::default().borders(Borders::ALL).title("🧪 Automatic External Validation Demo"));
f.render_widget(status, chunks[0]); f.render_widget(status, chunks[0]);
// Validation states for all fields - FIXED: render each field on its own line // Validation states for all fields - render each field on its own line
let mut field_lines: Vec<Line> = Vec::new(); let mut field_lines: Vec<Line> = Vec::new();
for i in 0..editor.data_provider().field_count() { for i in 0..editor.data_provider().field_count() {
let field_name = editor.data_provider().field_name(i); let field_name = editor.data_provider().field_name(i);
@@ -969,9 +1040,8 @@ fn render_validation_panel(
field_lines.push(field_line); field_lines.push(field_line);
} }
// Use Vec<Line> to avoid a single long line overflowing
let validation_states = Paragraph::new(field_lines) let validation_states = Paragraph::new(field_lines)
.block(Block::default().borders(Borders::ALL).title("🔍 Validation States")); .block(Block::default().borders(Borders::ALL).title("🔍 Validation States (Library Auto-triggered)"));
f.render_widget(validation_states, chunks[1]); f.render_widget(validation_states, chunks[1]);
// History or Help panel // History or Help panel
@@ -1014,34 +1084,36 @@ fn render_validation_panel(
.collect(); .collect();
let history = List::new(recent_history) let history = List::new(recent_history)
.block(Block::default().borders(Borders::ALL).title("📜 Validation History (recent 5)")); .block(Block::default().borders(Borders::ALL).title("📜 Auto-Validation History (recent 5)"));
f.render_widget(history, chunks[2]); f.render_widget(history, chunks[2]);
} else { } else {
let help_text = match editor.mode() { let help_text = match editor.mode() {
AppMode::ReadOnly => { AppMode::ReadOnly => {
"🧪 EXTERNAL VALIDATION DEMO - Multiple validation types with async simulation\n\ "🎯 FULLY AUTOMATIC VALIDATION: Library handles all validation on field transitions!\n\
🧪 EXTERNAL VALIDATION DEMO - No manual triggers needed, just navigate!\n\
\n\ \n\
Commands: v=validate current, V=validate all, c=clear current, C=clear all\n\ 🚀 AUTOMATIC: Arrow keys, Tab, and Esc trigger validation automatically\n\
e=cycle examples, r=toggle history, h=field help, F1=toggle validation\n\ Manual: v=validate current, V=validate all, c=clear current, C=clear all\n\
Movement: Tab/Shift+Tab=switch fields, i/a=insert/append, Esc=exit edit\n\ Controls: e=cycle examples, r=toggle history, h=field help, F1=toggle validation\n\
\n\ \n\
Try different values to see validation in action!" Just load examples and navigate - validation happens automatically!"
} }
AppMode::Edit => { AppMode::Edit => {
"✏️ EDITING MODE - Type to see validation on field blur\n\ "🎯 INSERT MODE - Cursor: | (bar)\n\
✏️ Type to edit field content\n\
\n\ \n\
Current field validation will trigger when you:\n\ 🚀 AUTOMATIC: Library validates when you leave this field via:\n\
• Press Esc (exit edit mode)\n\ • Press Esc (exit edit mode)\n\
• Press Tab (move to next field)\n\ • Press Tab/Shift+Tab (move between fields)\n\
• Press 'v' manually\n\ • Press arrow keys (Up/Down move between fields)\n\
\n\ \n\
Esc=exit edit, arrows=navigate, Backspace/Del=delete" Esc=exit edit, arrows=navigate, Backspace/Del=delete"
} }
_ => "🧪 Enhanced External Validation Demo" _ => "🧪 Enhanced Fully Automatic External Validation Demo"
}; };
let help = Paragraph::new(help_text) let help = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("🚀 External Validation Features")) .block(Block::default().borders(Borders::ALL).title("🚀 Fully Automatic External Validation"))
.style(Style::default().fg(Color::Gray)) .style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true }); .wrap(Wrap { trim: true });
f.render_widget(help, chunks[2]); f.render_widget(help, chunks[2]);
@@ -1049,15 +1121,19 @@ fn render_validation_panel(
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🧪 Enhanced External Validation Demo (Feature 5)"); println!("🧪 Enhanced Fully Automatic External Validation Demo (Feature 5)");
println!("✅ validation feature: ENABLED"); println!("✅ validation feature: ENABLED");
println!("✅ gui feature: ENABLED"); println!("✅ gui feature: ENABLED");
println!("✅ cursor-style feature: ENABLED");
println!("🚀 NEW: Library handles all automatic validation!");
println!("🧪 Enhanced features:"); println!("🧪 Enhanced features:");
println!(" • 5 different external validation types with realistic scenarios"); println!(" • 5 different external validation types with realistic scenarios");
println!(" • LIBRARY-LEVEL automatic validation on all field transitions");
println!(" • Validation caching and performance metrics"); println!(" • Validation caching and performance metrics");
println!(" • Comprehensive validation history and error handling"); println!(" • Comprehensive validation history and error handling");
println!(" • Multiple example datasets for testing edge cases"); println!(" • Multiple example datasets for testing edge cases");
println!(" • Progressive validation patterns (local + remote simulation)"); println!(" • Progressive validation patterns (local + remote simulation)");
println!(" • NO manual validation calls needed - library handles everything!");
println!(); println!();
enable_raw_mode()?; enable_raw_mode()?;
@@ -1067,7 +1143,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let data = ValidationDemoData::new(); let data = ValidationDemoData::new();
let editor = ValidationDemoEditor::new(data); let mut editor = ValidationDemoEditor::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); let res = run_app(&mut terminal, editor);
@@ -1075,15 +1157,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?; 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 { if let Err(err) = res {
println!("{:?}", err); println!("{:?}", err);
} }
println!("🧪 Enhanced external validation demo completed!"); println!("🧪 Enhanced fully automatic external validation demo completed!");
println!("🏆 You experienced comprehensive external validation with:"); println!("🏆 You experienced library-level automatic external validation with:");
println!(" • Multiple validation services (PSC, Email, Username, API Key, Credit Card)"); println!(" • Multiple validation services (PSC, Email, Username, API Key, Credit Card)");
println!(" • AUTOMATIC validation handled entirely by the library");
println!(" • Realistic async validation simulation with caching"); println!(" • Realistic async validation simulation with caching");
println!(" • Comprehensive error handling and user feedback"); println!(" • Comprehensive error handling and user feedback");
println!(" • Performance metrics and validation history tracking"); println!(" • Performance metrics and validation history tracking");
println!(" • Zero manual validation calls needed!");
Ok(()) Ok(())
} }

View File

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

View File

@@ -1,12 +0,0 @@
// src/autocomplete/mod.rs
pub mod state;
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main autocomplete types
pub use state::{AutocompleteProvider, SuggestionItem};
// Re-export GUI functions if available
#[cfg(feature = "gui")]
pub use gui::render_autocomplete_dropdown;

View File

@@ -1,5 +0,0 @@
// src/autocomplete/state.rs
//! Autocomplete provider types
// Re-export the main types from data_provider
pub use crate::data_provider::{AutocompleteProvider, SuggestionItem};

View File

@@ -5,6 +5,12 @@ pub mod line;
pub mod char; pub mod char;
// Re-export commonly used functions // 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 line::{line_start_position, line_end_position, safe_cursor_position};
pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position}; pub use char::{move_left, move_right, is_valid_cursor_position, clamp_cursor_position};

View File

@@ -1,6 +1,7 @@
// src/canvas/actions/movement/word.rs // src/canvas/actions/movement/word.rs
// Replace the entire file with this corrected version:
#[derive(PartialEq)] #[derive(PartialEq, Copy, Clone)]
enum CharType { enum CharType {
Whitespace, Whitespace,
Alphanumeric, Alphanumeric,
@@ -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 { pub fn find_prev_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 { if chars.is_empty() || current_pos == 0 {
return 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); let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards // Skip whitespace backwards
while pos > 0 && get_char_type(chars[pos]) == CharType::Whitespace { while pos > 0 && chars[pos].is_whitespace() {
pos -= 1; pos -= 1;
} }
if pos == 0 && get_char_type(chars[0]) == CharType::Whitespace { // Find start of current big_word by going back while non-whitespace
return 0; while pos > 0 && !chars[pos - 1].is_whitespace() {
}
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 {
pos -= 1; pos -= 1;
} }
// Skip whitespace before this word pos
while pos > 0 && get_char_type(chars[pos - 1]) == CharType::Whitespace { }
pos -= 1;
} /// Find the end of the current/next big_word (whitespace-separated)
pub fn find_big_word_end(text: &str, current_pos: usize) -> usize {
if pos > 0 { let chars: Vec<char> = text.chars().collect();
pos - 1 if chars.is_empty() {
} else { return 0;
0 }
}
let mut pos = current_pos;
// If we're on whitespace, skip to start of next big_word
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
// If we reached end, return it
if pos >= chars.len() {
return chars.len();
}
// Find end of current big_word (last non-whitespace char)
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
/// Find the end of the previous big_word (whitespace-separated)
pub fn find_prev_big_word_end(text: &str, current_pos: usize) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() || current_pos == 0 {
return 0;
}
let mut pos = current_pos.saturating_sub(1);
// Skip whitespace backwards
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If we hit start of text and it's whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Skip back to start of current big_word, then forward to end
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Now find end of this big_word
while pos < chars.len() && !chars[pos].is_whitespace() {
pos += 1;
}
// Return position of last character in big_word
pos.saturating_sub(1)
}
// ============================================================================
// FIELD BOUNDARY HELPER FUNCTIONS (for cross-field movement)
// ============================================================================
/// Find the start of the last word in a field (for cross-field 'b' movement)
pub fn find_last_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this word by going backwards while chars are the same type
let char_type = if chars[pos].is_alphanumeric() { "alnum" } else { "punct" };
while pos > 0 {
let prev_char = chars[pos - 1];
let prev_type = if prev_char.is_alphanumeric() {
"alnum"
} else if prev_char.is_whitespace() {
"space"
} else {
"punct"
};
// Stop if we hit whitespace or different word type
if prev_type == "space" || prev_type != char_type {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last word in a field (for cross-field 'ge' movement)
pub fn find_last_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
// Start from the end and find the last non-whitespace character
let mut pos = chars.len() - 1;
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if chars[pos].is_whitespace() {
return 0;
}
// We're now at the end of the last word
pos
}
/// Find the start of the last big_word in a field (for cross-field 'B' movement)
pub fn find_last_big_word_start_in_field(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// Now we're on a non-whitespace character
// Find the start of this big_word by going backwards while chars are non-whitespace
while pos > 0 {
let prev_char = chars[pos - 1];
// Stop if we hit whitespace (big_word boundary)
if prev_char.is_whitespace() {
break;
}
pos -= 1;
}
pos
}
/// Find the end of the last big_word in a field (for cross-field 'gE' movement)
pub fn find_last_big_word_end_in_field(text: &str) -> usize {
let chars: Vec<char> = text.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = chars.len().saturating_sub(1);
// Skip trailing whitespace
while pos > 0 && chars[pos].is_whitespace() {
pos -= 1;
}
// If the whole field is whitespace, return 0
if pos == 0 && chars[0].is_whitespace() {
return 0;
}
// We're now at the end of the last big_word
pos
} }

View File

@@ -1,6 +1,7 @@
// src/canvas/actions/types.rs // src/canvas/actions/types.rs
/// All available canvas actions /// All available canvas actions
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum CanvasAction { pub enum CanvasAction {
// Movement actions // Movement actions
@@ -30,18 +31,19 @@ pub enum CanvasAction {
DeleteBackward, DeleteBackward,
DeleteForward, DeleteForward,
// Autocomplete actions // Suggestions actions
TriggerAutocomplete, TriggerSuggestions,
SuggestionUp, SuggestionUp,
SuggestionDown, SuggestionDown,
SelectSuggestion, SelectSuggestion,
ExitSuggestions, ExitSuggestions,
// Custom actions // Custom actions
Custom(String), Custom(String),
} }
/// Result type for canvas actions /// Result type for canvas actions
#[non_exhaustive]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ActionResult { pub enum ActionResult {
Success, Success,
@@ -101,7 +103,7 @@ impl CanvasAction {
Self::InsertChar(_c) => "insert character", Self::InsertChar(_c) => "insert character",
Self::DeleteBackward => "delete backward", Self::DeleteBackward => "delete backward",
Self::DeleteForward => "delete forward", Self::DeleteForward => "delete forward",
Self::TriggerAutocomplete => "trigger autocomplete", Self::TriggerSuggestions => "trigger suggestions",
Self::SuggestionUp => "suggestion up", Self::SuggestionUp => "suggestion up",
Self::SuggestionDown => "suggestion down", Self::SuggestionDown => "suggestion down",
Self::SelectSuggestion => "select suggestion", Self::SelectSuggestion => "select suggestion",
@@ -139,10 +141,10 @@ impl CanvasAction {
] ]
} }
/// Get all autocomplete-related actions /// Get all suggestions-related actions
pub fn autocomplete_actions() -> Vec<CanvasAction> { pub fn suggestions_actions() -> Vec<CanvasAction> {
vec![ vec![
Self::TriggerAutocomplete, Self::TriggerSuggestions,
Self::SuggestionUp, Self::SuggestionUp,
Self::SuggestionDown, Self::SuggestionDown,
Self::SelectSuggestion, Self::SelectSuggestion,

View File

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

View File

@@ -6,7 +6,7 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style}, style::{Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, BorderType, Paragraph}, widgets::{Block, Borders, BorderType, Paragraph, Wrap},
Frame, Frame,
}; };
@@ -15,12 +15,182 @@ use crate::canvas::theme::{CanvasTheme, DefaultCanvasTheme};
use crate::canvas::modes::HighlightState; use crate::canvas::modes::HighlightState;
use crate::data_provider::DataProvider; use crate::data_provider::DataProvider;
use crate::editor::FormEditor; use crate::editor::FormEditor;
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use std::cmp::{max, min}; use std::cmp::{max, min};
/// Render ONLY the canvas form fields - no autocomplete #[cfg(feature = "gui")]
/// Updated to work with FormEditor instead of CanvasState trait #[derive(Debug, Clone, Copy)]
pub enum OverflowMode {
Indicator(char), // default '$'
Wrap,
}
#[cfg(feature = "gui")]
#[derive(Debug, Clone, Copy)]
pub struct CanvasDisplayOptions {
pub overflow: OverflowMode,
}
#[cfg(feature = "gui")]
impl Default for CanvasDisplayOptions {
fn default() -> Self {
Self {
overflow: OverflowMode::Indicator('$'),
}
}
}
/// Utility: measure display width of a string
#[cfg(feature = "gui")]
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
/// Utility: clip a string to fit width, append indicator if overflow
#[cfg(feature = "gui")]
fn clip_with_indicator_line<'a>(s: &'a str, width: u16, indicator: char) -> Line<'a> {
if width == 0 {
return Line::from("");
}
if display_width(s) <= width {
return Line::from(Span::raw(s));
}
let budget = width.saturating_sub(1);
let mut out = String::new();
let mut used: u16 = 0;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used + w > budget {
break;
}
out.push(ch);
used = used.saturating_add(w);
}
Line::from(vec![Span::raw(out), Span::raw(indicator.to_string())])
}
#[cfg(feature = "gui")]
const RIGHT_PAD: u16 = 3;
#[cfg(feature = "gui")]
fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String {
if max_cols == 0 {
return String::new();
}
let mut cols: u16 = 0;
let mut out = String::new();
let mut taken: u16 = 0;
let mut started = false;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let next = cols.saturating_add(w);
if !started {
if next <= start_cols {
cols = next;
continue;
} else {
started = true;
}
}
if taken.saturating_add(w) > max_cols {
break;
}
out.push(ch);
taken = taken.saturating_add(w);
cols = next;
}
out
}
#[cfg(feature = "gui")]
fn compute_h_scroll_with_padding(cursor_cols: u16, width: u16) -> (u16, u16) {
let mut h = 0u16;
for _ in 0..2 {
let left_cols = if h > 0 { 1 } else { 0 };
let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols);
let needed = cursor_cols.saturating_sub(max_x_visible);
if needed <= h {
return (h, left_cols);
}
h = needed;
}
let left_cols = if h > 0 { 1 } else { 0 };
(h, left_cols)
}
#[cfg(feature = "gui")]
fn render_active_line_with_indicator<T: CanvasTheme>(
typed_text: &str,
completion: Option<&str>,
width: u16,
indicator: char,
cursor_chars: usize,
theme: &T,
) -> (Line<'static>, u16, u16) {
if width == 0 {
return (Line::from(""), 0, 0);
}
// Cursor display column
let mut cursor_cols: u16 = 0;
for (i, ch) in typed_text.chars().enumerate() {
if i >= cursor_chars {
break;
}
cursor_cols = cursor_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let (h_scroll, left_cols) = compute_h_scroll_with_padding(cursor_cols, width);
let total_cols = display_width(typed_text);
let content_budget = width.saturating_sub(left_cols);
let show_right = total_cols.saturating_sub(h_scroll) > content_budget;
let right_cols: u16 = if show_right { 1 } else { 0 };
let visible_cols = width.saturating_sub(left_cols + right_cols);
let visible_typed = slice_by_display_cols(typed_text, h_scroll, visible_cols);
let used_typed_cols = display_width(&visible_typed);
let mut remaining_cols = visible_cols.saturating_sub(used_typed_cols);
let mut visible_completion = String::new();
if let Some(comp) = completion {
if !comp.is_empty() && remaining_cols > 0 {
visible_completion = slice_by_display_cols(comp, 0, remaining_cols);
remaining_cols = remaining_cols.saturating_sub(display_width(&visible_completion));
}
}
let mut spans: Vec<Span> = Vec::with_capacity(3);
if left_cols == 1 {
spans.push(Span::raw(indicator.to_string()));
}
spans.push(Span::styled(
visible_typed,
Style::default().fg(theme.fg()),
));
if !visible_completion.is_empty() {
spans.push(Span::styled(
visible_completion,
Style::default().fg(theme.suggestion_gray()),
));
}
if show_right {
spans.push(Span::raw(indicator.to_string()));
}
(Line::from(spans), h_scroll, left_cols)
}
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_canvas<T: CanvasTheme, D: DataProvider>( pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
@@ -28,32 +198,63 @@ pub fn render_canvas<T: CanvasTheme, D: DataProvider>(
editor: &FormEditor<D>, editor: &FormEditor<D>,
theme: &T, theme: &T,
) -> Option<Rect> { ) -> Option<Rect> {
// Convert SelectionState to HighlightState let opts = CanvasDisplayOptions::default();
let highlight_state = convert_selection_to_highlight(editor.ui_state().selection_state()); render_canvas_with_options(f, area, editor, theme, opts)
render_canvas_with_highlight(f, area, editor, theme, &highlight_state)
} }
/// Render canvas with explicit highlight state (for advanced use)
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>( pub fn render_canvas_with_options<T: CanvasTheme, D: DataProvider>(
f: &mut Frame,
area: Rect,
editor: &FormEditor<D>,
theme: &T,
opts: CanvasDisplayOptions,
) -> Option<Rect> {
let highlight_state =
convert_selection_to_highlight(editor.ui_state().selection_state());
#[cfg(feature = "suggestions")]
let active_completion = if editor.ui_state().is_suggestions_active()
&& editor.ui_state().suggestions.active_field
== Some(editor.ui_state().current_field())
{
editor.ui_state().suggestions.completion_text.clone()
} else {
None
};
#[cfg(not(feature = "suggestions"))]
let active_completion: Option<String> = None;
render_canvas_with_highlight_and_options(
f,
area,
editor,
theme,
&highlight_state,
active_completion,
opts,
)
}
#[cfg(feature = "gui")]
fn render_canvas_with_highlight_and_options<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
editor: &FormEditor<D>, editor: &FormEditor<D>,
theme: &T, theme: &T,
highlight_state: &HighlightState, highlight_state: &HighlightState,
active_completion: Option<String>,
opts: CanvasDisplayOptions,
) -> Option<Rect> { ) -> Option<Rect> {
let ui_state = editor.ui_state(); let ui_state = editor.ui_state();
let data_provider = editor.data_provider(); let data_provider = editor.data_provider();
// Build field information
let field_count = data_provider.field_count(); let field_count = data_provider.field_count();
let mut fields: Vec<&str> = Vec::with_capacity(field_count); let mut fields: Vec<&str> = Vec::with_capacity(field_count);
let mut inputs: Vec<String> = Vec::with_capacity(field_count); let mut inputs: Vec<String> = Vec::with_capacity(field_count);
for i in 0..field_count { for i in 0..field_count {
fields.push(data_provider.field_name(i)); fields.push(data_provider.field_name(i));
// Use editor-provided effective display text per field (Feature 4/mask aware)
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
{ {
inputs.push(editor.display_text_for_field(i)); inputs.push(editor.display_text_for_field(i));
@@ -67,7 +268,7 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
let current_field_idx = ui_state.current_field(); let current_field_idx = ui_state.current_field();
let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit); let is_edit_mode = matches!(ui_state.mode(), crate::canvas::modes::AppMode::Edit);
render_canvas_fields( render_canvas_fields_with_options(
f, f,
area, area,
&fields, &fields,
@@ -76,59 +277,50 @@ pub fn render_canvas_with_highlight<T: CanvasTheme, D: DataProvider>(
theme, theme,
is_edit_mode, is_edit_mode,
highlight_state, highlight_state,
editor.display_cursor_position(), // Use display cursor position for masks editor.display_cursor_position(),
false, // TODO: track unsaved changes in editor false,
|i| { #[cfg(feature = "validation")]
// Get display value for field i using editor logic (Feature 4 + masks) |field_idx| editor.display_text_for_field(field_idx),
#[cfg(feature = "validation")] #[cfg(not(feature = "validation"))]
{ |field_idx| data_provider.field_value(field_idx).to_string(),
editor.display_text_for_field(i) #[cfg(feature = "validation")]
} |field_idx| {
#[cfg(not(feature = "validation"))] editor
{ .ui_state()
data_provider.field_value(i).to_string() .validation_state()
} .get_field_config(field_idx)
}, .map(|cfg| cfg.custom_formatter.is_some() || cfg.display_mask.is_some())
|i| { .unwrap_or(false)
// 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,
active_completion,
opts,
) )
} }
/// Convert SelectionState to HighlightState for rendering
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn convert_selection_to_highlight(selection: &crate::canvas::state::SelectionState) -> HighlightState { fn convert_selection_to_highlight(
selection: &crate::canvas::state::SelectionState,
) -> HighlightState {
use crate::canvas::state::SelectionState; use crate::canvas::state::SelectionState;
match selection { match selection {
SelectionState::None => HighlightState::Off, SelectionState::None => HighlightState::Off,
SelectionState::Characterwise { anchor } => HighlightState::Characterwise { anchor: *anchor }, SelectionState::Characterwise { anchor } => {
SelectionState::Linewise { anchor_field } => HighlightState::Linewise { anchor_line: *anchor_field }, HighlightState::Characterwise { anchor: *anchor }
}
SelectionState::Linewise { anchor_field } => {
HighlightState::Linewise {
anchor_line: *anchor_field,
}
}
} }
} }
/// Core canvas field rendering /// Core canvas field rendering with options
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn render_canvas_fields<T: CanvasTheme, F1, F2>( fn render_canvas_fields_with_options<T: CanvasTheme, F1, F2>(
f: &mut Frame, f: &mut Frame,
area: Rect, area: Rect,
fields: &[&str], fields: &[&str],
@@ -141,18 +333,18 @@ fn render_canvas_fields<T: CanvasTheme, F1, F2>(
has_unsaved_changes: bool, has_unsaved_changes: bool,
get_display_value: F1, get_display_value: F1,
has_display_override: F2, has_display_override: F2,
active_completion: Option<String>,
opts: CanvasDisplayOptions,
) -> Option<Rect> ) -> Option<Rect>
where where
F1: Fn(usize) -> String, F1: Fn(usize) -> String,
F2: Fn(usize) -> bool, F2: Fn(usize) -> bool,
{ {
// Create layout
let columns = Layout::default() let columns = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area); .split(area);
// Border style based on state
let border_style = if has_unsaved_changes { let border_style = if has_unsaved_changes {
Style::default().fg(theme.warning()) Style::default().fg(theme.warning())
} else if is_edit_mode { } else if is_edit_mode {
@@ -161,7 +353,6 @@ where
Style::default().fg(theme.secondary()) Style::default().fg(theme.secondary())
}; };
// Input container
let input_container = Block::default() let input_container = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
@@ -177,28 +368,111 @@ where
f.render_widget(&input_container, input_block); f.render_widget(&input_container, input_block);
// Input area layout
let input_area = input_container.inner(input_block); let input_area = input_container.inner(input_block);
let input_rows = Layout::default() let input_rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); fields.len()]) .constraints(vec![Constraint::Length(1); fields.len()])
.split(input_area); .split(input_area);
// Render field labels
render_field_labels(f, columns[0], input_block, fields, theme); render_field_labels(f, columns[0], input_block, fields, theme);
// Render field values and return active field rect let mut active_field_input_rect = None;
render_field_values(
f, for i in 0..inputs.len() {
input_rows.to_vec(), let is_active = i == *current_field_idx;
inputs, let typed_text = get_display_value(i);
current_field_idx, let inner_width = input_rows[i].width;
theme,
highlight_state, // ---- BEGIN MODIFIED SECTION ----
current_cursor_pos, let mut h_scroll_for_cursor: u16 = 0;
get_display_value, let mut left_offset_for_cursor: u16 = 0;
has_display_override,
) let line = match highlight_state {
// Selection highlighting active: always use highlighting, even for the active field
HighlightState::Characterwise { .. } | HighlightState::Linewise { .. } => {
apply_highlighting(
&typed_text,
i,
current_field_idx,
current_cursor_pos,
highlight_state,
theme,
is_active,
)
}
// No selection highlighting
HighlightState::Off => match opts.overflow {
// Indicator mode: special-case the active field to preserve h-scroll + indicators
OverflowMode::Indicator(ind) => {
if is_active {
let (l, hs, left_cols) = render_active_line_with_indicator(
&typed_text,
active_completion.as_deref(),
inner_width,
ind,
current_cursor_pos,
theme,
);
h_scroll_for_cursor = hs;
left_offset_for_cursor = left_cols;
l
} else if display_width(&typed_text) <= inner_width {
Line::from(Span::raw(typed_text.clone()))
} else {
clip_with_indicator_line(&typed_text, inner_width, ind)
}
}
// Wrap mode: keep active completion for active line
OverflowMode::Wrap => {
if is_active {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
typed_text.clone(),
Style::default().fg(theme.fg()),
));
if let Some(completion) = &active_completion {
if !completion.is_empty() {
spans.push(Span::styled(
completion.clone(),
Style::default().fg(theme.suggestion_gray()),
));
}
}
Line::from(spans)
} else {
Line::from(Span::raw(typed_text.clone()))
}
}
},
};
// ---- END MODIFIED SECTION ----
let mut p = Paragraph::new(line).alignment(Alignment::Left);
if matches!(opts.overflow, OverflowMode::Wrap) {
p = p.wrap(Wrap { trim: false });
}
f.render_widget(p, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
set_cursor_position_scrolled(
f,
input_rows[i],
&typed_text,
current_cursor_pos,
has_display_override(i),
h_scroll_for_cursor,
left_offset_for_cursor,
);
}
}
active_field_input_rect
} }
/// Render field labels /// Render field labels
@@ -227,53 +501,6 @@ fn render_field_labels<T: CanvasTheme>(
} }
} }
/// Render field values with highlighting
#[cfg(feature = "gui")]
fn render_field_values<T: CanvasTheme, F1, F2>(
f: &mut Frame,
input_rows: Vec<Rect>,
inputs: &[String],
current_field_idx: &usize,
theme: &T,
highlight_state: &HighlightState,
current_cursor_pos: usize,
get_display_value: F1,
has_display_override: F2,
) -> Option<Rect>
where
F1: Fn(usize) -> String,
F2: Fn(usize) -> bool,
{
let mut active_field_input_rect = None;
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let 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 input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
// Set cursor for active field
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));
}
}
active_field_input_rect
}
/// Apply highlighting based on highlight state /// Apply highlighting based on highlight state
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn apply_highlighting<'a, T: CanvasTheme>( fn apply_highlighting<'a, T: CanvasTheme>(
@@ -283,27 +510,40 @@ fn apply_highlighting<'a, T: CanvasTheme>(
current_cursor_pos: usize, current_cursor_pos: usize,
highlight_state: &HighlightState, highlight_state: &HighlightState,
theme: &T, theme: &T,
_is_active: bool, is_active: bool,
) -> Line<'a> { ) -> Line<'a> {
let text_len = text.chars().count(); let text_len = text.chars().count();
match highlight_state { match highlight_state {
HighlightState::Off => { HighlightState::Off => {
Line::from(Span::styled( Line::from(Span::styled(text, Style::default().fg(theme.fg())))
text,
Style::default().fg(theme.fg())
))
} }
HighlightState::Characterwise { anchor } => { 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 } => { 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,
)
} }
} }
} }
/// Apply characterwise highlighting - PROPER VIM-LIKE VERSION /// Apply characterwise highlighting (unchanged)
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn apply_characterwise_highlighting<'a, T: CanvasTheme>( fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
text: &'a str, text: &'a str,
@@ -319,21 +559,20 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
let start_field = min(anchor_field, *current_field_idx); let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx); let end_field = max(anchor_field, *current_field_idx);
// Vim-like styling:
// - Selected text: contrasting color + background (like vim visual selection)
// - All other text: normal color (no special colors for active fields, etc.)
let highlight_style = Style::default() let highlight_style = Style::default()
.fg(theme.highlight()) // ✅ Contrasting text color for selected text .fg(theme.highlight())
.bg(theme.highlight_bg()) // ✅ Background for selected text .bg(theme.highlight_bg())
.add_modifier(Modifier::BOLD); .add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else let normal_style = Style::default().fg(theme.fg());
if field_index >= start_field && field_index <= end_field { if field_index >= start_field && field_index <= end_field {
if start_field == end_field { if start_field == end_field {
// Single field selection
let (start_char, end_char) = if anchor_field == *current_field_idx { let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos)) (
min(anchor_char, current_cursor_pos),
max(anchor_char, current_cursor_pos),
)
} else if anchor_field < *current_field_idx { } else if anchor_field < *current_field_idx {
(anchor_char, current_cursor_pos) (anchor_char, current_cursor_pos)
} else { } else {
@@ -344,19 +583,19 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
let clamped_end = end_char.min(text_len); let clamped_end = end_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect(); let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars() let highlighted: String = text
.chars()
.skip(clamped_start) .skip(clamped_start)
.take(clamped_end.saturating_sub(clamped_start) + 1) .take(clamped_end.saturating_sub(clamped_start) + 1)
.collect(); .collect();
let after: String = text.chars().skip(clamped_end + 1).collect(); let after: String = text.chars().skip(clamped_end + 1).collect();
Line::from(vec![ Line::from(vec![
Span::styled(before, normal_style), // Normal text color Span::styled(before, normal_style),
Span::styled(highlighted, highlight_style), // Contrasting color + background Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style), // Normal text color Span::styled(after, normal_style),
]) ])
} else { } else {
// Multi-field selection
if field_index == anchor_field { if field_index == anchor_field {
if anchor_field < *current_field_idx { if anchor_field < *current_field_idx {
let clamped_start = anchor_char.min(text_len); let clamped_start = anchor_char.min(text_len);
@@ -398,17 +637,15 @@ fn apply_characterwise_highlighting<'a, T: CanvasTheme>(
]) ])
} }
} else { } else {
// Middle field: highlight entire field
Line::from(Span::styled(text, highlight_style)) Line::from(Span::styled(text, highlight_style))
} }
} }
} else { } else {
// Outside selection: always normal text color (no special active field color)
Line::from(Span::styled(text, normal_style)) Line::from(Span::styled(text, normal_style))
} }
} }
/// Apply linewise highlighting - PROPER VIM-LIKE VERSION /// Apply linewise highlighting (unchanged)
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn apply_linewise_highlighting<'a, T: CanvasTheme>( fn apply_linewise_highlighting<'a, T: CanvasTheme>(
text: &'a str, text: &'a str,
@@ -421,46 +658,52 @@ fn apply_linewise_highlighting<'a, T: CanvasTheme>(
let start_field = min(*anchor_line, *current_field_idx); let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx); let end_field = max(*anchor_line, *current_field_idx);
// Vim-like styling:
// - Selected lines: contrasting text color + background
// - All other lines: normal text color (no special active field color)
let highlight_style = Style::default() let highlight_style = Style::default()
.fg(theme.highlight()) // ✅ Contrasting text color for selected text .fg(theme.highlight())
.bg(theme.highlight_bg()) // ✅ Background for selected text .bg(theme.highlight_bg())
.add_modifier(Modifier::BOLD); .add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(theme.fg()); // ✅ Normal text color everywhere else let normal_style = Style::default().fg(theme.fg());
if field_index >= start_field && field_index <= end_field { if field_index >= start_field && field_index <= end_field {
// Selected line: contrasting text color + background
Line::from(Span::styled(text, highlight_style)) Line::from(Span::styled(text, highlight_style))
} else { } else {
// Normal line: normal text color (no special active field color)
Line::from(Span::styled(text, normal_style)) Line::from(Span::styled(text, normal_style))
} }
} }
/// Set cursor position /// Set cursor position (x clamp only; no Y offset with wrap in this version)
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn set_cursor_position( fn set_cursor_position_scrolled(
f: &mut Frame, f: &mut Frame,
field_rect: Rect, field_rect: Rect,
text: &str, text: &str,
current_cursor_pos: usize, current_cursor_pos: usize,
has_display_override: bool, _has_display_override: bool,
h_scroll: u16,
left_offset: u16,
) { ) {
// BUG FIX: Use the correct display cursor position, not end of text let mut cols: u16 = 0;
let cursor_x = field_rect.x + current_cursor_pos as u16; 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 mut visible_x = cols.saturating_sub(h_scroll).saturating_add(left_offset);
let limit = field_rect.width.saturating_sub(1 + RIGHT_PAD);
if visible_x > limit {
visible_x = limit;
}
let cursor_x = field_rect.x.saturating_add(visible_x);
let cursor_y = field_rect.y; let cursor_y = field_rect.y;
f.set_cursor_position((cursor_x, cursor_y));
// SAFETY: Ensure cursor doesn't go beyond 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));
} }
/// Set default theme if custom not specified /// Default theme
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_canvas_default<D: DataProvider>( pub fn render_canvas_default<D: DataProvider>(
f: &mut Frame, f: &mut Frame,

View File

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

View File

@@ -14,8 +14,9 @@ pub struct EditorState {
// Mode state // Mode state
pub(crate) current_mode: AppMode, pub(crate) current_mode: AppMode,
// Autocomplete state // Suggestions dropdown state (only available with suggestions feature)
pub(crate) autocomplete: AutocompleteUIState, #[cfg(feature = "suggestions")]
pub(crate) suggestions: SuggestionsUIState,
// Selection state (for vim visual mode) // Selection state (for vim visual mode)
pub(crate) selection: SelectionState, pub(crate) selection: SelectionState,
@@ -23,14 +24,21 @@ pub struct EditorState {
// Validation state (only available with validation feature) // Validation state (only available with validation feature)
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
pub(crate) validation: crate::validation::ValidationState, 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)] #[derive(Debug, Clone)]
pub struct AutocompleteUIState { pub struct SuggestionsUIState {
pub(crate) is_active: bool, pub(crate) is_active: bool,
pub(crate) is_loading: bool, pub(crate) is_loading: bool,
pub(crate) selected_index: Option<usize>, pub(crate) selected_index: Option<usize>,
pub(crate) active_field: Option<usize>, pub(crate) active_field: Option<usize>,
pub(crate) active_query: Option<String>,
pub(crate) completion_text: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -46,16 +54,27 @@ impl EditorState {
current_field: 0, current_field: 0,
cursor_pos: 0, cursor_pos: 0,
ideal_cursor_column: 0, ideal_cursor_column: 0,
// NORMALMODE: always start in Edit
#[cfg(feature = "textmode-normal")]
current_mode: AppMode::Edit, current_mode: AppMode::Edit,
autocomplete: AutocompleteUIState { // Default (vim): start in ReadOnly
#[cfg(not(feature = "textmode-normal"))]
current_mode: AppMode::ReadOnly,
#[cfg(feature = "suggestions")]
suggestions: SuggestionsUIState {
is_active: false, is_active: false,
is_loading: false, is_loading: false,
selected_index: None, selected_index: None,
active_field: None, active_field: None,
active_query: None,
completion_text: None,
}, },
selection: SelectionState::None, selection: SelectionState::None,
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
validation: crate::validation::ValidationState::new(), validation: crate::validation::ValidationState::new(),
#[cfg(feature = "computed")]
computed: None,
} }
} }
@@ -68,6 +87,15 @@ impl EditorState {
self.current_field 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) /// Get current cursor position (for user's business logic)
pub fn cursor_position(&self) -> usize { pub fn cursor_position(&self) -> usize {
self.cursor_pos self.cursor_pos
@@ -83,14 +111,16 @@ impl EditorState {
self.current_mode self.current_mode
} }
/// Check if autocomplete is active (for user's business logic) /// Check if suggestions dropdown is active (for user's business logic)
pub fn is_autocomplete_active(&self) -> bool { #[cfg(feature = "suggestions")]
self.autocomplete.is_active pub fn is_suggestions_active(&self) -> bool {
self.suggestions.is_active
} }
/// Check if autocomplete is loading (for user's business logic) /// Check if suggestions dropdown is loading (for user's business logic)
pub fn is_autocomplete_loading(&self) -> bool { #[cfg(feature = "suggestions")]
self.autocomplete.is_loading pub fn is_suggestions_loading(&self) -> bool {
self.suggestions.is_loading
} }
/// Get selection state (for user's business logic) /// Get selection state (for user's business logic)
@@ -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 { if for_edit_mode {
// Edit mode: can go past end for insertion // Edit mode: can go past end for insertion
self.cursor_pos = position.min(max_position); self.cursor_pos = position.min(max_position);
@@ -128,18 +163,26 @@ impl EditorState {
self.ideal_cursor_column = self.cursor_pos; self.ideal_cursor_column = self.cursor_pos;
} }
pub(crate) fn activate_autocomplete(&mut self, field_index: usize) { /// Explicitly open suggestions — should only be called on Tab
self.autocomplete.is_active = true; #[cfg(feature = "suggestions")]
self.autocomplete.is_loading = true; pub(crate) fn open_suggestions(&mut self, field_index: usize) {
self.autocomplete.active_field = Some(field_index); self.suggestions.is_active = true;
self.autocomplete.selected_index = None; 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_autocomplete(&mut self) { /// Explicitly close suggestions — should be called on Esc or field change
self.autocomplete.is_active = false; #[cfg(feature = "suggestions")]
self.autocomplete.is_loading = false; pub(crate) fn close_suggestions(&mut self) {
self.autocomplete.active_field = None; self.suggestions.is_active = false;
self.autocomplete.selected_index = None; self.suggestions.is_loading = false;
self.suggestions.active_field = None;
self.suggestions.active_query = None;
self.suggestions.selected_index = None;
self.suggestions.completion_text = None;
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
// ================================================================================================
// COMPUTED FIELDS - Provider and Context
// ================================================================================================
/// Context information provided to computed field calculations
#[derive(Debug, Clone)]
pub struct ComputedContext<'a> {
/// All field values in the form (index -> value)
pub field_values: &'a [&'a str],
/// The field index being computed
pub target_field: usize,
/// Current field that user is editing (if any)
pub current_field: Option<usize>,
}
/// User implements this to provide computed field logic
pub trait ComputedProvider {
/// Compute value for a field based on other field values.
/// Called automatically when any field changes.
fn compute_field(&mut self, context: ComputedContext) -> String;
/// Check if this provider handles the given field.
fn handles_field(&self, field_index: usize) -> bool;
/// Get list of field dependencies for optimization.
/// If field A depends on fields [1, 3], only recompute A when fields 1 or 3 change.
/// Default: depend on all fields (always recompute) with a reasonable upper bound.
fn field_dependencies(&self, _field_index: usize) -> Vec<usize> {
(0..100).collect()
}
}

View File

@@ -0,0 +1,88 @@
/* file: canvas/src/computed/state.rs */
/*
Add computed state module file implementing caching and dependencies
*/
// ================================================================================================
// COMPUTED FIELDS - State: caching and dependencies
// ================================================================================================
use std::collections::{HashMap, HashSet};
/// Internal state for computed field management
#[derive(Debug, Clone)]
pub struct ComputedState {
/// Cached computed values (field_index -> computed_value)
computed_values: HashMap<usize, String>,
/// Field dependency graph (field_index -> depends_on_fields)
dependencies: HashMap<usize, Vec<usize>>,
/// Track which fields are computed (display-only)
computed_fields: HashSet<usize>,
}
impl ComputedState {
/// Create a new, empty computed state
pub fn new() -> Self {
Self {
computed_values: HashMap::new(),
dependencies: HashMap::new(),
computed_fields: HashSet::new(),
}
}
/// Register a field as computed with its dependencies
///
/// - `field_index`: the field that is computed (display-only)
/// - `dependencies`: indices of fields this computed field depends on
pub fn register_computed_field(&mut self, field_index: usize, mut dependencies: Vec<usize>) {
// Deduplicate dependencies to keep graph lean
dependencies.sort_unstable();
dependencies.dedup();
self.computed_fields.insert(field_index);
self.dependencies.insert(field_index, dependencies);
}
/// Check if a field is computed (read-only, skip editing/navigation)
pub fn is_computed_field(&self, field_index: usize) -> bool {
self.computed_fields.contains(&field_index)
}
/// Get cached computed value for a field, if available
pub fn get_computed_value(&self, field_index: usize) -> Option<&String> {
self.computed_values.get(&field_index)
}
/// Update cached computed value for a field
pub fn set_computed_value(&mut self, field_index: usize, value: String) {
self.computed_values.insert(field_index, value);
}
/// Get fields that should be recomputed when `changed_field` changed
///
/// This scans the dependency graph and returns all computed fields
/// that list `changed_field` as a dependency.
pub fn fields_to_recompute(&self, changed_field: usize) -> Vec<usize> {
self.dependencies
.iter()
.filter_map(|(field, deps)| {
if deps.contains(&changed_field) {
Some(*field)
} else {
None
}
})
.collect()
}
/// Iterator over all computed field indices
pub fn computed_fields(&self) -> impl Iterator<Item = usize> + '_ {
self.computed_fields.iter().copied()
}
}
impl Default for ComputedState {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,7 +1,9 @@
// src/data_provider.rs // src/data_provider.rs
//! Simplified user interface - only business data, no UI state //! Simplified user interface - only business data, no UI state
#[cfg(feature = "suggestions")]
use anyhow::Result; use anyhow::Result;
#[cfg(feature = "suggestions")]
use async_trait::async_trait; use async_trait::async_trait;
/// User implements this - only business data, no UI state /// User implements this - only business data, no UI state
@@ -18,8 +20,8 @@ pub trait DataProvider {
/// Set field value (library calls this when text changes) /// Set field value (library calls this when text changes)
fn set_field_value(&mut self, index: usize, value: String); fn set_field_value(&mut self, index: usize, value: String);
/// Check if field supports autocomplete (optional) /// Check if field supports suggestions (optional)
fn supports_autocomplete(&self, _field_index: usize) -> bool { fn supports_suggestions(&self, _field_index: usize) -> bool {
false false
} }
@@ -34,16 +36,32 @@ pub trait DataProvider {
fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> { fn validation_config(&self, _field_index: usize) -> Option<crate::validation::ValidationConfig> {
None 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 autocomplete data /// Optional: User implements this for suggestions data
#[cfg(feature = "suggestions")]
#[async_trait] #[async_trait]
pub trait AutocompleteProvider { pub trait SuggestionsProvider {
/// Fetch autocomplete suggestions (user's business logic) /// Fetch suggestions (user's business logic)
async fn fetch_suggestions(&mut self, field_index: usize, query: &str) async fn fetch_suggestions(&mut self, field_index: usize, query: &str)
-> Result<Vec<SuggestionItem>>; -> Result<Vec<SuggestionItem>>;
} }
#[cfg(feature = "suggestions")]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SuggestionItem { pub struct SuggestionItem {
pub display_text: String, pub display_text: String,

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,22 @@ pub mod canvas;
pub mod editor; pub mod editor;
pub mod data_provider; pub mod data_provider;
// Only include autocomplete module if feature is enabled // Only include suggestions module if feature is enabled
#[cfg(feature = "autocomplete")] #[cfg(feature = "suggestions")]
pub mod autocomplete; pub mod suggestions;
// Only include validation module if feature is enabled // Only include validation module if feature is enabled
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
pub mod validation; pub mod validation;
// First-class textarea module and exports
#[cfg(feature = "textarea")]
pub mod textarea;
// Only include computed module if feature is enabled
#[cfg(feature = "computed")]
pub mod computed;
#[cfg(feature = "cursor-style")] #[cfg(feature = "cursor-style")]
pub use canvas::CursorManager; pub use canvas::CursorManager;
@@ -21,7 +29,9 @@ pub use canvas::CursorManager;
// Main API exports // Main API exports
pub use editor::FormEditor; pub use editor::FormEditor;
pub use data_provider::{DataProvider, AutocompleteProvider, SuggestionItem}; pub use data_provider::DataProvider;
#[cfg(feature = "suggestions")]
pub use data_provider::{SuggestionsProvider, SuggestionItem};
// UI state (read-only access for users) // UI state (read-only access for users)
pub use canvas::state::EditorState; pub use canvas::state::EditorState;
@@ -41,15 +51,26 @@ pub use validation::{
CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper, CustomFormatter, FormattingResult, PositionMapper, DefaultPositionMapper,
}; };
// Computed exports (only when computed feature is enabled)
#[cfg(feature = "computed")]
pub use computed::{ComputedProvider, ComputedContext, ComputedState};
// Theming and GUI // Theming and GUI
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme}; pub use canvas::theme::{CanvasTheme, DefaultCanvasTheme};
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::gui::render_canvas; pub use canvas::gui::{render_canvas, render_canvas_default};
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub use canvas::gui::render_canvas_default; pub use canvas::gui::render_canvas_with_options;
#[cfg(all(feature = "gui", feature = "autocomplete"))] #[cfg(feature = "gui")]
pub use autocomplete::gui::render_autocomplete_dropdown; pub use canvas::gui::{CanvasDisplayOptions, OverflowMode};
#[cfg(all(feature = "gui", feature = "suggestions"))]
pub use suggestions::gui::render_suggestions_dropdown;
#[cfg(feature = "textarea")]
pub use textarea::{TextArea, TextAreaProvider, TextAreaState, TextAreaEditor};

View File

@@ -1,5 +1,5 @@
// src/autocomplete/gui.rs // src/suggestions/gui.rs
//! Autocomplete GUI updated to work with FormEditor //! Suggestions dropdown GUI (not inline autocomplete) updated to work with FormEditor
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use ratatui::{ use ratatui::{
@@ -17,9 +17,9 @@ use crate::editor::FormEditor;
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
/// Render autocomplete dropdown for FormEditor - call this AFTER rendering canvas /// Render suggestions dropdown for FormEditor - call this AFTER rendering canvas
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>( pub fn render_suggestions_dropdown<T: CanvasTheme, D: DataProvider>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,
@@ -28,14 +28,14 @@ pub fn render_autocomplete_dropdown<T: CanvasTheme, D: DataProvider>(
) { ) {
let ui_state = editor.ui_state(); let ui_state = editor.ui_state();
if !ui_state.is_autocomplete_active() { if !ui_state.is_suggestions_active() {
return; return;
} }
if ui_state.autocomplete.is_loading { if ui_state.suggestions.is_loading {
render_loading_indicator(f, frame_area, input_rect, theme); render_loading_indicator(f, frame_area, input_rect, theme);
} else if !editor.suggestions().is_empty() { } else if !editor.suggestions().is_empty() {
render_suggestions_dropdown(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.autocomplete.selected_index); render_suggestions_dropdown_list(f, frame_area, input_rect, theme, editor.suggestions(), ui_state.suggestions.selected_index);
} }
} }
@@ -71,7 +71,7 @@ fn render_loading_indicator<T: CanvasTheme>(
/// Show actual suggestions list /// Show actual suggestions list
#[cfg(feature = "gui")] #[cfg(feature = "gui")]
fn render_suggestions_dropdown<T: CanvasTheme>( fn render_suggestions_dropdown_list<T: CanvasTheme>(
f: &mut Frame, f: &mut Frame,
frame_area: Rect, frame_area: Rect,
input_rect: Rect, input_rect: Rect,

View File

@@ -0,0 +1,12 @@
// src/suggestions/mod.rs
pub mod state;
#[cfg(feature = "gui")]
pub mod gui;
// Re-export the main suggestion types
pub use state::{SuggestionsProvider, SuggestionItem};
// Re-export GUI functions if available
#[cfg(feature = "gui")]
pub use gui::render_suggestions_dropdown;

View File

@@ -0,0 +1,5 @@
// src/suggestions/state.rs
//! Suggestions provider types (for dropdown suggestions, not real inline autocomplete)
// Re-export the main types from data_provider
pub use crate::data_provider::{SuggestionsProvider, SuggestionItem};

View File

@@ -0,0 +1,12 @@
// src/textarea/mod.rs
pub mod provider;
pub mod state;
#[cfg(feature = "gui")]
pub mod widget;
pub use provider::TextAreaProvider;
pub use state::{TextAreaEditor, TextAreaState, TextOverflowMode};
#[cfg(feature = "gui")]
pub use widget::TextArea;

View File

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

View File

@@ -0,0 +1,518 @@
// src/textarea/state.rs
use std::ops::{Deref, DerefMut};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::editor::FormEditor;
use crate::textarea::provider::TextAreaProvider;
use crate::data_provider::DataProvider;
#[cfg(feature = "gui")]
use ratatui::{layout::Rect, widgets::Block};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
#[cfg(feature = "gui")]
pub(crate) const RIGHT_PAD: u16 = 3;
#[cfg(feature = "gui")]
pub(crate) fn compute_h_scroll_with_padding(
cursor_cols: u16,
width: u16,
) -> (u16, u16) {
let mut h = 0u16;
for _ in 0..2 {
let left_cols = if h > 0 { 1 } else { 0 };
let max_x_visible = width.saturating_sub(1 + RIGHT_PAD + left_cols);
let needed = cursor_cols.saturating_sub(max_x_visible);
if needed <= h {
return (h, left_cols);
}
h = needed;
}
let left_cols = if h > 0 { 1 } else { 0 };
(h, left_cols)
}
#[cfg(feature = "gui")]
fn normalize_indent(width: u16, indent: u16) -> u16 {
indent.min(width.saturating_sub(1))
}
#[cfg(feature = "gui")]
pub(crate) fn count_wrapped_rows_indented(
s: &str,
width: u16,
indent: u16,
) -> u16 {
if width == 0 {
return 1;
}
let indent = normalize_indent(width, indent);
let cont_cap = width.saturating_sub(indent);
let mut rows: u16 = 1;
let mut used: u16 = 0;
let mut first = true;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
rows = rows.saturating_add(1);
first = false;
used = indent;
}
used = used.saturating_add(w);
}
rows
}
#[cfg(feature = "gui")]
fn wrapped_rows_to_cursor_indented(
s: &str,
width: u16,
indent: u16,
cursor_chars: usize,
) -> (u16, u16) {
if width == 0 {
return (0, 0);
}
let indent = normalize_indent(width, indent);
let cont_cap = width.saturating_sub(indent);
let mut row: u16 = 0;
let mut used: u16 = 0;
let mut first = true;
for (i, ch) in s.chars().enumerate() {
if i >= cursor_chars {
break;
}
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
if used > 0 && used.saturating_add(w) >= cap {
row = row.saturating_add(1);
first = false;
used = indent;
}
used = used.saturating_add(w);
}
(row, used.min(width.saturating_sub(1)))
}
pub type TextAreaEditor = FormEditor<TextAreaProvider>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextOverflowMode {
Indicator { ch: char },
Wrap,
}
pub struct TextAreaState {
pub(crate) editor: TextAreaEditor,
pub(crate) scroll_y: u16,
pub(crate) placeholder: Option<String>,
pub(crate) overflow_mode: TextOverflowMode,
pub(crate) h_scroll: u16,
#[cfg(feature = "gui")]
pub(crate) wrap_indent_cols: u16,
#[cfg(feature = "gui")]
pub(crate) edited_this_frame: bool,
}
impl Default for TextAreaState {
fn default() -> Self {
Self {
editor: FormEditor::new(TextAreaProvider::default()),
scroll_y: 0,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
edited_this_frame: false,
}
}
}
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,
placeholder: None,
overflow_mode: TextOverflowMode::Indicator { ch: '$' },
h_scroll: 0,
#[cfg(feature = "gui")]
wrap_indent_cols: 0,
#[cfg(feature = "gui")]
edited_this_frame: false,
}
}
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_placeholder<S: Into<String>>(&mut self, s: S) {
self.placeholder = Some(s.into());
}
pub fn use_overflow_indicator(&mut self, ch: char) {
self.overflow_mode = TextOverflowMode::Indicator { ch };
}
pub fn use_wrap(&mut self) {
self.overflow_mode = TextOverflowMode::Wrap;
}
pub fn set_wrap_indent_cols(&mut self, cols: u16) {
#[cfg(feature = "gui")]
{
self.wrap_indent_cols = cols;
}
}
pub fn insert_newline(&mut self) {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
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();
}
pub fn backspace(&mut self) {
let col = self.cursor_position();
if col > 0 {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
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)
{
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.transition_to_field(prev_idx);
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
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 {
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.delete_forward();
return;
}
if let Some(new_col) =
self.editor.data_provider_mut().join_with_next(line_idx)
{
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
self.set_cursor_position(new_col);
self.enter_edit_mode();
}
}
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();
}
(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(),
(KeyCode::Char(c), m) if m.is_empty() => {
self.enter_edit_mode();
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
let _ = self.insert_char(c);
}
(KeyCode::Tab, _) => {
self.enter_edit_mode();
#[cfg(feature = "gui")]
{
self.edited_this_frame = true;
}
for _ in 0..4 {
let _ = self.insert_char(' ');
}
}
_ => {}
}
}
#[cfg(feature = "gui")]
fn visual_rows_before_line_and_intra_indented(
&self,
width: u16,
line_idx: usize,
) -> u16 {
let provider = self.editor.data_provider();
let mut acc: u16 = 0;
let indent = self.wrap_indent_cols;
for i in 0..line_idx {
let s = provider.field_value(i);
acc = acc.saturating_add(count_wrapped_rows_indented(s, width, indent));
}
acc
}
#[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 usize;
match self.overflow_mode {
TextOverflowMode::Wrap => {
let width = inner.width;
let y_top = inner.y;
let indent = self.wrap_indent_cols;
if width == 0 {
let prefix = self.visual_rows_before_line_and_intra_indented(1, line_idx);
let y = y_top.saturating_add(prefix.saturating_sub(self.scroll_y));
return (inner.x, y);
}
let prefix_rows =
self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col_chars = self.display_cursor_position();
let (subrow, x_cols) = wrapped_rows_to_cursor_indented(
&current_line,
width,
indent,
col_chars,
);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let y = y_top.saturating_add(caret_vis_row.saturating_sub(self.scroll_y));
let x = inner.x.saturating_add(x_cols);
(x, y)
}
TextOverflowMode::Indicator { .. } => {
let y = inner.y + (line_idx as u16).saturating_sub(self.scroll_y);
let current_line = self.current_text();
let col = self.display_cursor_position();
let mut x_cols: u16 = 0;
let mut total_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if i < col {
x_cols = x_cols.saturating_add(w);
}
total_cols = total_cols.saturating_add(w);
}
let left_cols = if self.h_scroll > 0 { 1 } else { 0 };
let mut x_off_visible = x_cols
.saturating_sub(self.h_scroll)
.saturating_add(left_cols);
let limit = inner.width.saturating_sub(1 + RIGHT_PAD);
if x_off_visible > limit {
x_off_visible = limit;
}
let x = inner.x.saturating_add(x_off_visible);
(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;
}
match self.overflow_mode {
TextOverflowMode::Indicator { .. } => {
let line_idx_u16 = self.current_field() as u16;
if line_idx_u16 < self.scroll_y {
self.scroll_y = line_idx_u16;
} else if line_idx_u16 >= self.scroll_y + inner.height {
self.scroll_y = line_idx_u16.saturating_sub(inner.height - 1);
}
let width = inner.width;
if width == 0 {
return;
}
let current_line = self.current_text();
let mut total_cols: u16 = 0;
for ch in current_line.chars() {
total_cols = total_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
if total_cols <= width {
self.h_scroll = 0;
return;
}
let col = self.display_cursor_position();
let mut cursor_cols: u16 = 0;
for (i, ch) in current_line.chars().enumerate() {
if i >= col {
break;
}
cursor_cols = cursor_cols
.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, width);
if target_h > self.h_scroll {
self.h_scroll = target_h;
} else if cursor_cols < self.h_scroll {
self.h_scroll = cursor_cols;
}
}
TextOverflowMode::Wrap => {
let width = inner.width;
if width == 0 {
self.h_scroll = 0;
return;
}
let indent = self.wrap_indent_cols;
let line_idx = self.current_field() as usize;
let prefix_rows =
self.visual_rows_before_line_and_intra_indented(width, line_idx);
let current_line = self.current_text();
let col = self.display_cursor_position();
let (subrow, _x_cols) =
wrapped_rows_to_cursor_indented(&current_line, width, indent, col);
let caret_vis_row = prefix_rows.saturating_add(subrow);
let top = self.scroll_y;
let height = inner.height;
if caret_vis_row < top {
self.scroll_y = caret_vis_row;
} else {
let bottom = top.saturating_add(height.saturating_sub(1));
if caret_vis_row > bottom {
let shift = caret_vis_row.saturating_sub(bottom);
self.scroll_y = top.saturating_add(shift);
}
}
self.h_scroll = 0;
}
}
}
#[cfg(feature = "gui")]
pub(crate) fn take_edited_flag(&mut self) -> bool {
let v = self.edited_this_frame;
self.edited_this_frame = false;
v
}
}

View File

@@ -0,0 +1,352 @@
// 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,
},
};
#[cfg(feature = "gui")]
use crate::data_provider::DataProvider;
#[cfg(feature = "gui")]
use crate::textarea::state::{
compute_h_scroll_with_padding,
count_wrapped_rows_indented,
TextAreaState,
TextOverflowMode,
};
#[cfg(feature = "gui")]
use unicode_width::UnicodeWidthChar;
#[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")]
fn display_width(s: &str) -> u16 {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0) as u16)
.sum()
}
#[cfg(feature = "gui")]
fn display_cols_up_to(s: &str, char_count: usize) -> u16 {
let mut cols: u16 = 0;
for (i, ch) in s.chars().enumerate() {
if i >= char_count {
break;
}
cols = cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
cols
}
#[cfg(feature = "gui")]
fn slice_by_display_cols(s: &str, start_cols: u16, max_cols: u16) -> String {
if max_cols == 0 {
return String::new();
}
let mut current_cols: u16 = 0;
let mut output = String::new();
let mut taken: u16 = 0;
let mut started = false;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if !started {
if current_cols.saturating_add(w) <= start_cols {
current_cols = current_cols.saturating_add(w);
continue;
} else {
started = true;
}
}
if taken.saturating_add(w) > max_cols {
break;
}
output.push(ch);
taken = taken.saturating_add(w);
current_cols = current_cols.saturating_add(w);
}
output
}
#[cfg(feature = "gui")]
fn clip_window_with_indicator_padded(
text: &str,
view_width: u16,
indicator: char,
start_cols: u16,
) -> Line<'static> {
if view_width == 0 {
return Line::from("");
}
let total = display_width(text);
// Left indicator if we scrolled
let show_left = start_cols > 0;
let left_cols: u16 = if show_left { 1 } else { 0 };
// Capacity for text if we also need a right indicator
let cap_with_right = view_width.saturating_sub(left_cols + 1);
// Do we still have content beyond this window?
let remaining = total.saturating_sub(start_cols);
let show_right = remaining > cap_with_right;
// Final capacity for visible text
let max_visible = if show_right {
cap_with_right
} else {
view_width.saturating_sub(left_cols)
};
let visible = slice_by_display_cols(text, start_cols, max_visible);
let mut spans: Vec<Span> = Vec::new();
if show_left {
spans.push(Span::raw(indicator.to_string()));
}
// Visible text
spans.push(Span::raw(visible.clone()));
// Place $ flush-right
if show_right {
let used_cols = left_cols + display_width(&visible);
let right_pos = view_width.saturating_sub(1);
let filler = right_pos.saturating_sub(used_cols);
if filler > 0 {
spans.push(Span::raw(" ".repeat(filler as usize)));
}
spans.push(Span::raw(indicator.to_string()));
}
Line::from(spans)
}
#[cfg(feature = "gui")]
fn wrap_segments_with_indent(
s: &str,
width: u16,
indent: u16,
) -> Vec<String> {
let mut segments: Vec<String> = Vec::new();
if width == 0 {
segments.push(String::new());
return segments;
}
let indent = indent.min(width.saturating_sub(1));
let cont_cap = width.saturating_sub(indent);
let indent_str = " ".repeat(indent as usize);
let mut buf = String::new();
let mut used: u16 = 0;
let mut first = true;
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
let cap = if first { width } else { cont_cap };
// Early-wrap: wrap before filling the last cell (and avoid empty segment)
if used > 0 && used.saturating_add(w) >= cap {
segments.push(buf);
buf = String::new();
used = 0;
first = false;
if indent > 0 {
buf.push_str(&indent_str);
used = indent;
}
}
buf.push(ch);
used = used.saturating_add(w);
}
segments.push(buf);
segments
}
// Map visual row offset to (logical line, intra segment)
#[cfg(feature = "gui")]
fn resolve_start_line_and_intra_indented(
state: &TextAreaState,
inner: Rect,
) -> (usize, u16) {
let provider = state.editor.data_provider();
let total = provider.line_count();
if total == 0 {
return (0, 0);
}
let wrap = matches!(state.overflow_mode, TextOverflowMode::Wrap);
let width = inner.width;
let target_vis = state.scroll_y;
if !wrap {
let start = (target_vis as usize).min(total);
return (start, 0);
}
let indent = state.wrap_indent_cols;
let mut acc: u16 = 0;
for i in 0..total {
let s = provider.field_value(i);
let rows = count_wrapped_rows_indented(s, width, indent);
if acc.saturating_add(rows) > target_vis {
let intra = target_vis.saturating_sub(acc);
return (i, intra);
}
acc = acc.saturating_add(rows);
}
(total.saturating_sub(1), 0)
}
#[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 edited_now = state.take_edited_flag();
let wrap_mode = matches!(state.overflow_mode, TextOverflowMode::Wrap);
let provider = state.editor.data_provider();
let total = provider.line_count();
let (start, intra) = resolve_start_line_and_intra_indented(state, inner);
let mut display_lines: Vec<Line> = Vec::new();
if total == 0 || start >= total {
if let Some(ph) = &state.placeholder {
display_lines.push(Line::from(Span::raw(ph.clone())));
}
} else if wrap_mode {
// manual pre-wrap path (unchanged)
let mut rows_left = inner.height;
let indent = state.wrap_indent_cols;
let mut i = start;
while i < total && rows_left > 0 {
let s = provider.field_value(i);
let segments = wrap_segments_with_indent(s, inner.width, indent);
let skip = if i == start { intra as usize } else { 0 };
for seg in segments.into_iter().skip(skip) {
display_lines.push(Line::from(Span::raw(seg)));
rows_left = rows_left.saturating_sub(1);
if rows_left == 0 {
break;
}
}
i += 1;
}
} else {
// Indicator mode: full inner width; RIGHT_PAD only affects cursor clamp and h-scroll
let end = (start.saturating_add(inner.height as usize)).min(total);
for i in start..end {
let s = provider.field_value(i);
match state.overflow_mode {
TextOverflowMode::Wrap => unreachable!(),
TextOverflowMode::Indicator { ch } => {
let fits = display_width(&s) <= inner.width;
let start_cols = if i == state.current_field() {
let col_idx = state.display_cursor_position();
let cursor_cols = display_cols_up_to(&s, col_idx);
let (target_h, _left_cols) =
compute_h_scroll_with_padding(cursor_cols, inner.width);
if fits {
if edited_now { target_h } else { 0 }
} else {
target_h.max(state.h_scroll)
}
} else {
0
};
display_lines.push(clip_window_with_indicator_padded(
&s,
inner.width,
ch,
start_cols,
));
}
}
}
}
let p = Paragraph::new(display_lines)
.alignment(Alignment::Left)
.style(self.style);
// No Paragraph::wrap/scroll in wrap mode — we pre-wrap.
p.render(inner, buf);
}
}

View File

@@ -122,19 +122,36 @@ impl CharacterLimits {
pub fn validate_insertion( pub fn validate_insertion(
&self, &self,
current_text: &str, current_text: &str,
_position: usize, position: usize,
character: char, character: char,
) -> Option<ValidationResult> { ) -> Option<ValidationResult> {
// 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); 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;
// Check max length // Check max length
if let Some(max) = self.max_length { if let Some(max) = self.max_length {

View File

@@ -21,6 +21,8 @@ pub struct ValidationState {
/// External validation results per field (Feature 5) /// External validation results per field (Feature 5)
external_results: HashMap<usize, ExternalValidationState>, external_results: HashMap<usize, ExternalValidationState>,
last_switch_block: Option<String>,
} }
impl ValidationState { impl ValidationState {
@@ -32,6 +34,7 @@ impl ValidationState {
validated_fields: std::collections::HashSet::new(), validated_fields: std::collections::HashSet::new(),
enabled: true, enabled: true,
external_results: HashMap::new(), external_results: HashMap::new(),
last_switch_block: None,
} }
} }
@@ -256,6 +259,22 @@ impl ValidationState {
error_fields: errors, 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 /// Summary of validation state across all fields

View File

@@ -27,7 +27,7 @@ show_module() {
# Main modules # Main modules
show_module "canvas" "CANVAS SYSTEM" show_module "canvas" "CANVAS SYSTEM"
show_module "autocomplete" "AUTOCOMPLETE SYSTEM" show_module "suggestions" "SUGGESTIONS SYSTEM"
show_module "config" "CONFIGURATION SYSTEM" show_module "config" "CONFIGURATION SYSTEM"
# Show lib.rs and other root files # Show lib.rs and other root files
@@ -45,7 +45,7 @@ fi
echo -e "\n\033[1;36m==========================================" echo -e "\n\033[1;36m=========================================="
echo "To view specific module documentation:" echo "To view specific module documentation:"
echo " ./view_canvas_docs.sh canvas" echo " ./view_canvas_docs.sh canvas"
echo " ./view_canvas_docs.sh autocomplete" echo " ./view_canvas_docs.sh suggestions"
echo " ./view_canvas_docs.sh config" echo " ./view_canvas_docs.sh config"
echo "==========================================\033[0m" echo "==========================================\033[0m"