This commit is contained in:
Priec
2025-08-17 17:52:40 +02:00
parent e36324af6f
commit b9a7f9a03f
7 changed files with 561 additions and 44 deletions

View File

@@ -29,7 +29,7 @@ regex = { workspace = true, optional = true }
tokio-test = "0.4.4" tokio-test = "0.4.4"
[features] [features]
default = [] default = ["textmode-vim"]
gui = ["ratatui", "crossterm"] gui = ["ratatui", "crossterm"]
suggestions = ["tokio"] suggestions = ["tokio"]
cursor-style = ["crossterm"] cursor-style = ["crossterm"]
@@ -37,6 +37,19 @@ validation = ["regex"]
computed = [] computed = []
textarea = ["gui"] 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 = "suggestions" name = "suggestions"
required-features = ["suggestions", "gui", "cursor-style"] required-features = ["suggestions", "gui", "cursor-style"]
@@ -77,6 +90,11 @@ name = "computed_fields"
required-features = ["gui", "computed"] required-features = ["gui", "computed"]
[[example]] [[example]]
name = "canvas_textarea_cursor_auto" name = "textarea_vim"
required-features = ["gui", "cursor-style", "textarea"] required-features = ["gui", "cursor-style", "textarea"]
path = "examples/canvas_textarea_cursor_auto.rs" path = "examples/textarea_vim.rs"
[[example]]
name = "textarea_normal"
required-features = ["gui", "cursor-style", "textarea"]
path = "examples/textarea_normal.rs"

View File

@@ -0,0 +1,390 @@
// 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)
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::Char('h'), _) | (KeyCode::Left, _) => editor.move_left(),
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => editor.move_right(),
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => editor.move_down(),
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => editor.move_up(),
// Word movement
(KeyCode::Char('w'), _) => editor.move_word_next(),
(KeyCode::Char('b'), _) => editor.move_word_prev(),
(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
(KeyCode::Char('W'), _) => editor.move_big_word_next(),
(KeyCode::Char('B'), _) => editor.move_big_word_prev(),
(KeyCode::Char('E'), _) => editor.move_big_word_end(),
// Line/document movement
(KeyCode::Char('0'), _) | (KeyCode::Home, _) => editor.move_line_start(),
(KeyCode::Char('$'), _) | (KeyCode::End, _) => editor.move_line_end(),
(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());
}
}
(KeyCode::Char('G'), _) => editor.move_last_line(),
// Delete
(KeyCode::Char('x'), _) => editor.delete_char_forward(),
(KeyCode::Char('X'), _) => editor.delete_char_backward(),
// Debug/info
(KeyCode::Char('?'), _) => {
editor.set_debug_message(format!(
"{}, Mode: NORMALMODE (always editing, 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

@@ -1,4 +1,4 @@
// examples/canvas_textarea_cursor_auto.rs // examples/textarea_vim.rs
//! Demonstrates automatic cursor management with the textarea widget //! Demonstrates automatic cursor management with the textarea widget
//! //!
//! This example REQUIRES the `cursor-style` and `textarea` features to compile. //! This example REQUIRES the `cursor-style` and `textarea` features to compile.

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::SteadyUnderScore;
AppMode::General => SetCursorStyle::SteadyBlock, // Block for general return execute!(io::stdout(), style);
AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command }
};
// Default (not normal): original mapping
execute!(io::stdout(), style) #[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

@@ -9,8 +9,27 @@ use crate::editor::FormEditor;
use crate::DataProvider; use crate::DataProvider;
impl<D: DataProvider> FormEditor<D> { impl<D: DataProvider> FormEditor<D> {
/// Change mode (for vim compatibility) /// Change mode
pub fn set_mode(&mut self, mode: AppMode) { 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) { match (self.ui_state.current_mode, mode) {
(AppMode::ReadOnly, AppMode::Highlight) => { (AppMode::ReadOnly, AppMode::Highlight) => {
self.enter_highlight_mode(); self.enter_highlight_mode();
@@ -23,7 +42,6 @@ impl<D: DataProvider> FormEditor<D> {
if new_mode != AppMode::Highlight { if new_mode != AppMode::Highlight {
self.ui_state.selection = SelectionState::None; self.ui_state.selection = SelectionState::None;
} }
#[cfg(feature = "cursor-style")] #[cfg(feature = "cursor-style")]
{ {
let _ = CursorManager::update_for_mode(new_mode); let _ = CursorManager::update_for_mode(new_mode);
@@ -32,7 +50,7 @@ impl<D: DataProvider> FormEditor<D> {
} }
} }
/// Exit edit mode to read-only mode (vim Escape) /// Exit edit mode to read-only mode
pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> { pub fn exit_edit_mode(&mut self) -> anyhow::Result<()> {
#[cfg(feature = "validation")] #[cfg(feature = "validation")]
{ {
@@ -41,7 +59,9 @@ impl<D: DataProvider> FormEditor<D> {
self.ui_state.current_field, self.ui_state.current_field,
current_text, current_text,
) { ) {
if let Some(reason) = self.ui_state.validation if let Some(reason) = self
.ui_state
.validation
.field_switch_block_reason( .field_switch_block_reason(
self.ui_state.current_field, self.ui_state.current_field,
current_text, current_text,
@@ -92,15 +112,29 @@ impl<D: DataProvider> FormEditor<D> {
} }
} }
self.set_mode(AppMode::ReadOnly); // NORMALMODE: stay in Edit (do not switch to ReadOnly)
#[cfg(feature = "suggestions")] #[cfg(feature = "textmode-normal")]
{ {
self.close_suggestions(); #[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(())
} }
Ok(())
} }
/// Enter edit mode from read-only mode (vim i/a/o) /// Enter edit mode
pub fn enter_edit_mode(&mut self) { pub fn enter_edit_mode(&mut self) {
#[cfg(feature = "computed")] #[cfg(feature = "computed")]
{ {
@@ -111,52 +145,104 @@ impl<D: DataProvider> FormEditor<D> {
} }
} }
} }
// 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); self.set_mode(AppMode::Edit);
} }
// -------------------- Highlight/Visual mode ------------------------- // -------------------- Highlight/Visual mode -------------------------
pub fn enter_highlight_mode(&mut self) { pub fn enter_highlight_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly { // NORMALMODE: ignore request (stay in Edit)
self.ui_state.current_mode = AppMode::Highlight; #[cfg(feature = "textmode-normal")]
self.ui_state.selection = SelectionState::Characterwise { {
anchor: (self.ui_state.current_field, self.ui_state.cursor_pos), return;
}; }
#[cfg(feature = "cursor-style")] // Default (not normal): original vim
{ #[cfg(not(feature = "textmode-normal"))]
let _ = CursorManager::update_for_mode(AppMode::Highlight); {
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) { pub fn enter_highlight_line_mode(&mut self) {
if self.ui_state.current_mode == AppMode::ReadOnly { // NORMALMODE: ignore
self.ui_state.current_mode = AppMode::Highlight; #[cfg(feature = "textmode-normal")]
self.ui_state.selection = {
SelectionState::Linewise { anchor_field: self.ui_state.current_field }; return;
}
#[cfg(feature = "cursor-style")] // Default (not normal): original vim
{ #[cfg(not(feature = "textmode-normal"))]
let _ = CursorManager::update_for_mode(AppMode::Highlight); {
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) { pub fn exit_highlight_mode(&mut self) {
if self.ui_state.current_mode == AppMode::Highlight { // NORMALMODE: ignore
self.ui_state.current_mode = AppMode::ReadOnly; #[cfg(feature = "textmode-normal")]
self.ui_state.selection = SelectionState::None; {
return;
}
#[cfg(feature = "cursor-style")] // Default (not normal): original vim
{ #[cfg(not(feature = "textmode-normal"))]
let _ = CursorManager::update_for_mode(AppMode::ReadOnly); {
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 { pub fn is_highlight_mode(&self) -> bool {
self.ui_state.current_mode == AppMode::Highlight #[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 { pub fn selection_state(&self) -> &SelectionState {
@@ -164,6 +250,8 @@ impl<D: DataProvider> FormEditor<D> {
} }
// Visual-mode movements reuse existing movement methods // 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) { pub fn move_left_with_selection(&mut self) {
let _ = self.move_left(); let _ = self.move_left();
} }

View File

@@ -16,6 +16,9 @@ pub mod validation;
#[cfg(feature = "computed")] #[cfg(feature = "computed")]
pub mod computed; pub mod computed;
#[path = "textmode/check.rs"]
mod textmode_check;
#[cfg(feature = "cursor-style")] #[cfg(feature = "cursor-style")]
pub use canvas::CursorManager; pub use canvas::CursorManager;

View File

@@ -0,0 +1,7 @@
// src/textmode/check.rs
#[cfg(all(feature = "textmode-vim", feature = "textmode-normal"))]
compile_error!("Enable exactly one of: textmode-vim or textmode-normal.");
#[cfg(not(any(feature = "textmode-vim", feature = "textmode-normal")))]
compile_error!("No textmode selected. Enable one of: textmode-vim or textmode-normal.");