From b9a7f9a03f81cb4812b1efc063b22006167585ce Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 17 Aug 2025 17:52:40 +0200 Subject: [PATCH] textarea --- canvas/Cargo.toml | 24 +- canvas/examples/textarea_normal.rs | 390 ++++++++++++++++++ ...extarea_cursor_auto.rs => textarea_vim.rs} | 2 +- canvas/src/canvas/cursor.rs | 29 +- canvas/src/editor/mode.rs | 150 +++++-- canvas/src/lib.rs | 3 + canvas/src/textmode/check.rs | 7 + 7 files changed, 561 insertions(+), 44 deletions(-) create mode 100644 canvas/examples/textarea_normal.rs rename canvas/examples/{canvas_textarea_cursor_auto.rs => textarea_vim.rs} (99%) create mode 100644 canvas/src/textmode/check.rs diff --git a/canvas/Cargo.toml b/canvas/Cargo.toml index 777e66d..31199b1 100644 --- a/canvas/Cargo.toml +++ b/canvas/Cargo.toml @@ -29,7 +29,7 @@ regex = { workspace = true, optional = true } tokio-test = "0.4.4" [features] -default = [] +default = ["textmode-vim"] gui = ["ratatui", "crossterm"] suggestions = ["tokio"] cursor-style = ["crossterm"] @@ -37,6 +37,19 @@ validation = ["regex"] computed = [] textarea = ["gui"] +# text modes (mutually exclusive; default to vim) +textmode-vim = [] +textmode-normal = [] + +all-nontextmodes = [ + "gui", + "suggestions", + "cursor-style", + "validation", + "computed", + "textarea" +] + [[example]] name = "suggestions" required-features = ["suggestions", "gui", "cursor-style"] @@ -77,6 +90,11 @@ name = "computed_fields" required-features = ["gui", "computed"] [[example]] -name = "canvas_textarea_cursor_auto" +name = "textarea_vim" 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" diff --git a/canvas/examples/textarea_normal.rs b/canvas/examples/textarea_normal.rs new file mode 100644 index 0000000..7584a63 --- /dev/null +++ b/canvas/examples/textarea_normal.rs @@ -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 { + 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(terminal: &mut Terminal, 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> { + 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(()) +} diff --git a/canvas/examples/canvas_textarea_cursor_auto.rs b/canvas/examples/textarea_vim.rs similarity index 99% rename from canvas/examples/canvas_textarea_cursor_auto.rs rename to canvas/examples/textarea_vim.rs index 2fed485..16ecbdf 100644 --- a/canvas/examples/canvas_textarea_cursor_auto.rs +++ b/canvas/examples/textarea_vim.rs @@ -1,4 +1,4 @@ -// examples/canvas_textarea_cursor_auto.rs +// examples/textarea_vim.rs //! Demonstrates automatic cursor management with the textarea widget //! //! This example REQUIRES the `cursor-style` and `textarea` features to compile. diff --git a/canvas/src/canvas/cursor.rs b/canvas/src/canvas/cursor.rs index 720ca6a..4fd7e56 100644 --- a/canvas/src/canvas/cursor.rs +++ b/canvas/src/canvas/cursor.rs @@ -15,15 +15,26 @@ impl CursorManager { /// Update cursor style based on current mode #[cfg(feature = "cursor-style")] pub fn update_for_mode(mode: AppMode) -> io::Result<()> { - let style = match mode { - AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert - AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal - AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual - AppMode::General => SetCursorStyle::SteadyBlock, // Block for general - AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command - }; - - execute!(io::stdout(), style) + // NORMALMODE: force underscore for every mode + #[cfg(feature = "textmode-normal")] + { + let style = SetCursorStyle::SteadyUnderScore; + return execute!(io::stdout(), style); + } + + // Default (not normal): original mapping + #[cfg(not(feature = "textmode-normal"))] + { + let style = match mode { + AppMode::Edit => SetCursorStyle::SteadyBar, // Thin line for insert + AppMode::ReadOnly => SetCursorStyle::SteadyBlock, // Block for normal + AppMode::Highlight => SetCursorStyle::BlinkingBlock, // Blinking for visual + AppMode::General => SetCursorStyle::SteadyBlock, // Block for general + AppMode::Command => SetCursorStyle::SteadyUnderScore, // Underscore for command + }; + + return execute!(io::stdout(), style); + } } /// No-op when cursor-style feature is disabled diff --git a/canvas/src/editor/mode.rs b/canvas/src/editor/mode.rs index 94cbe17..a7bae11 100644 --- a/canvas/src/editor/mode.rs +++ b/canvas/src/editor/mode.rs @@ -9,8 +9,27 @@ use crate::editor::FormEditor; use crate::DataProvider; impl FormEditor { - /// Change mode (for vim compatibility) + /// 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(); @@ -23,7 +42,6 @@ impl FormEditor { if new_mode != AppMode::Highlight { self.ui_state.selection = SelectionState::None; } - #[cfg(feature = "cursor-style")] { let _ = CursorManager::update_for_mode(new_mode); @@ -32,7 +50,7 @@ impl FormEditor { } } - /// 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<()> { #[cfg(feature = "validation")] { @@ -41,7 +59,9 @@ impl FormEditor { self.ui_state.current_field, current_text, ) { - if let Some(reason) = self.ui_state.validation + if let Some(reason) = self + .ui_state + .validation .field_switch_block_reason( self.ui_state.current_field, current_text, @@ -92,15 +112,29 @@ impl FormEditor { } } - self.set_mode(AppMode::ReadOnly); - #[cfg(feature = "suggestions")] + // NORMALMODE: stay in Edit (do not switch to ReadOnly) + #[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) { #[cfg(feature = "computed")] { @@ -111,52 +145,104 @@ impl FormEditor { } } } + + // 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) { - 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), - }; + // NORMALMODE: ignore request (stay in Edit) + #[cfg(feature = "textmode-normal")] + { + return; + } - #[cfg(feature = "cursor-style")] - { - let _ = CursorManager::update_for_mode(AppMode::Highlight); + // 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) { - 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 }; + // NORMALMODE: ignore + #[cfg(feature = "textmode-normal")] + { + return; + } - #[cfg(feature = "cursor-style")] - { - let _ = CursorManager::update_for_mode(AppMode::Highlight); + // 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) { - if self.ui_state.current_mode == AppMode::Highlight { - self.ui_state.current_mode = AppMode::ReadOnly; - self.ui_state.selection = SelectionState::None; + // NORMALMODE: ignore + #[cfg(feature = "textmode-normal")] + { + return; + } - #[cfg(feature = "cursor-style")] - { - let _ = CursorManager::update_for_mode(AppMode::ReadOnly); + // 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 { - 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 { @@ -164,6 +250,8 @@ impl FormEditor { } // 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(); } diff --git a/canvas/src/lib.rs b/canvas/src/lib.rs index 99347c4..affa31c 100644 --- a/canvas/src/lib.rs +++ b/canvas/src/lib.rs @@ -16,6 +16,9 @@ pub mod validation; #[cfg(feature = "computed")] pub mod computed; +#[path = "textmode/check.rs"] +mod textmode_check; + #[cfg(feature = "cursor-style")] pub use canvas::CursorManager; diff --git a/canvas/src/textmode/check.rs b/canvas/src/textmode/check.rs new file mode 100644 index 0000000..6956e51 --- /dev/null +++ b/canvas/src/textmode/check.rs @@ -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.");