From 4254efa083de0e36f610ff39beba86174517b2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 15:39:44 +0100 Subject: [PATCH 01/21] Remove tui-textarea, stub TextArea and TextAreaComponent --- Cargo.lock | 12 -- Cargo.toml | 2 +- src/components/textinput.rs | 238 ++++++++++++++++++++++++++++++++++-- 3 files changed, 232 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43472cf97c..fef623b983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,7 +1173,6 @@ dependencies = [ "struct-patch", "syntect", "tempfile", - "tui-textarea", "two-face", "unicode-segmentation", "unicode-truncate 2.0.1", @@ -3745,17 +3744,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tui-textarea" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "crossterm", - "ratatui", - "unicode-width 0.2.0", -] - [[package]] name = "two-face" version = "0.4.4" diff --git a/Cargo.toml b/Cargo.toml index 8f07dd34e4..e4900a2087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ parking_lot_core = "0.9" ratatui = { version = "0.29", default-features = false, features = [ 'crossterm', 'serde', + 'unstable-widget-ref', ] } rayon-core = "1.13" ron = "0.12" @@ -77,7 +78,6 @@ syntect = { version = "5.3", default-features = false, features = [ "plist-load", "html", ] } -tui-textarea = "0.7" two-face = { version = "0.4.4", default-features = false } unicode-segmentation = "1.12" unicode-truncate = "2.0" diff --git a/src/components/textinput.rs b/src/components/textinput.rs index e67d19eac9..dbaf8fab92 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -11,16 +11,18 @@ use crate::{ ui::{self, style::SharedTheme}, }; use anyhow::Result; -use crossterm::event::Event; -use ratatui::widgets::{Block, Borders}; +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; use ratatui::{ + buffer::Buffer, layout::{Alignment, Rect}, + style::Style, + widgets::{Block, Borders, Widget, WidgetRef}, widgets::{Clear, Paragraph}, Frame, }; -use std::cell::Cell; -use std::cell::OnceCell; -use tui_textarea::{CursorMove, Input, Key, Scrolling, TextArea}; +use std::cell::{Cell, OnceCell}; /// #[derive(PartialEq, Eq)] @@ -37,7 +39,229 @@ enum SelectionState { SelectionEndPending, } -type TextAreaComponent = TextArea<'static>; +enum CursorMove { + Top, + Bottom, + Up, + Down, + Back, + Forward, + Head, + End, + WordForward, + WordBack, + ParagraphForward, + ParagraphBack, +} + +enum Scrolling { + PageUp, + PageDown, +} + +#[derive(Default, PartialEq)] +enum Key { + #[default] + Null, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Backspace, + Delete, + Tab, + Char(char), +} + +#[derive(Default)] +struct Input { + key: Key, + ctrl: bool, + alt: bool, + shift: bool, +} + +impl From for Input { + /// Convert [`crossterm::event::Event`] into [`Input`]. + fn from(event: Event) -> Self { + match event { + Event::Key(key) => Self::from(key), + _ => Self::default(), + } + } +} + +impl From for Key { + /// Convert [`crossterm::event::KeyCode`] into [`Key`]. + fn from(code: KeyCode) -> Self { + match code { + KeyCode::Char(c) => Self::Char(c), + KeyCode::Backspace => Self::Backspace, + KeyCode::Left => Self::Left, + KeyCode::Right => Self::Right, + KeyCode::Up => Self::Up, + KeyCode::Down => Self::Down, + KeyCode::Tab => Self::Tab, + KeyCode::Delete => Self::Delete, + KeyCode::Home => Self::Home, + KeyCode::End => Self::End, + KeyCode::PageUp => Self::PageUp, + KeyCode::PageDown => Self::PageDown, + _ => Self::Null, + } + } +} + +impl From for Input { + /// Convert [`crossterm::event::KeyEvent`] into [`Input`]. + fn from(key: KeyEvent) -> Self { + if key.kind == KeyEventKind::Release { + // On Windows or when `crossterm::event::PushKeyboardEnhancementFlags` is set, + // key release event can be reported. Ignore it. (rhysd/tui-textarea#14) + return Self::default(); + } + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let key = Key::from(key.code); + + Self { + key, + ctrl, + alt, + shift, + } + } +} + +struct TextArea; + +impl TextArea { + fn new(_lines: Vec) -> Self { + todo!(); + } + + fn scroll(&mut self, _scrolling: Scrolling) { + todo!(); + } + + fn paste(&mut self) { + todo!(); + } + + fn redo(&mut self) { + todo!(); + } + + fn undo(&mut self) { + todo!(); + } + + #[cfg(test)] + fn cursor(&mut self) -> (usize, usize) { + todo!(); + } + + fn move_cursor(&mut self, _cursor_move: CursorMove) { + todo!(); + } + + fn delete_next_word(&mut self) { + todo!(); + } + + fn delete_word(&mut self) { + todo!(); + } + + fn delete_line_by_head(&mut self) { + todo!(); + } + + fn delete_line_by_end(&mut self) { + todo!(); + } + + fn delete_next_char(&mut self) { + todo!(); + } + + fn delete_char(&mut self) { + todo!(); + } + + fn insert_tab(&mut self) { + todo!(); + } + + fn insert_char(&mut self, _char: char) { + todo!(); + } + + fn set_block(&mut self, _block: Block<'_>) { + todo!(); + } + + fn set_style(&mut self, _style: Style) { + todo!(); + } + + fn set_placeholder_text(&mut self, _placeholder: String) { + todo!(); + } + + fn set_placeholder_style(&mut self, _style: Style) { + todo!(); + } + + fn set_cursor_line_style(&mut self, _style: Style) { + todo!(); + } + + fn set_mask_char(&mut self, _char: char) { + todo!(); + } +} + +type TextAreaComponent = TextArea; + +impl<'a> TextAreaComponent { + fn start_selection(&mut self) { + todo!(); + } + + fn cancel_selection(&mut self) { + todo!(); + } + + fn insert_newline(&mut self) { + todo!(); + } + + fn lines(&self) -> &'a [String] { + todo!(); + } +} + +impl Widget for TextAreaComponent { + fn render(self, _area: Rect, _buf: &mut Buffer) + where + Self: Sized, + { + todo!() + } +} + +impl WidgetRef for TextAreaComponent { + fn render_ref(&self, _area: Rect, _buf: &mut Buffer) { + todo!() + } +} /// pub struct TextInputComponent { @@ -260,7 +484,7 @@ impl TextInputComponent { } #[allow(clippy::too_many_lines, clippy::unnested_or_patterns)] - fn process_inputs(ta: &mut TextArea<'_>, input: &Input) -> bool { + fn process_inputs(ta: &mut TextArea, input: &Input) -> bool { match input { Input { key: Key::Char(c), From 7ca546defca51511c2a0e7f6bbd7b738ef27e711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 16:41:17 +0100 Subject: [PATCH 02/21] Implement a couple of methods, remove some This commit removes methods related to selection from the API. --- src/components/textinput.rs | 366 +++++++++++++----------------------- 1 file changed, 133 insertions(+), 233 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index dbaf8fab92..b389a4d5f1 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -32,13 +32,7 @@ pub enum InputType { Password, } -#[derive(PartialEq, Eq)] -enum SelectionState { - Selecting, - NotSelecting, - SelectionEndPending, -} - +#[derive(Clone, Copy)] enum CursorMove { Top, Bottom, @@ -46,12 +40,8 @@ enum CursorMove { Down, Back, Forward, - Head, + Home, End, - WordForward, - WordBack, - ParagraphForward, - ParagraphBack, } enum Scrolling { @@ -82,7 +72,6 @@ struct Input { key: Key, ctrl: bool, alt: bool, - shift: bool, } impl From for Input { @@ -127,23 +116,28 @@ impl From for Input { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); - let shift = key.modifiers.contains(KeyModifiers::SHIFT); let key = Key::from(key.code); - Self { - key, - ctrl, - alt, - shift, - } + Self { key, ctrl, alt } } } -struct TextArea; +struct TextArea<'a> { + lines: Vec, + block: Option>, + /// 0-based (row, column) + cursor: (usize, usize), + placeholder: String, +} -impl TextArea { - fn new(_lines: Vec) -> Self { - todo!(); +impl<'a> TextArea<'a> { + const fn new(lines: Vec) -> Self { + Self { + lines, + block: None, + cursor: (0, 0), + placeholder: String::new(), + } } fn scroll(&mut self, _scrolling: Scrolling) { @@ -164,11 +158,44 @@ impl TextArea { #[cfg(test)] fn cursor(&mut self) -> (usize, usize) { - todo!(); + self.cursor } - fn move_cursor(&mut self, _cursor_move: CursorMove) { - todo!(); + fn move_cursor(&mut self, cursor_move: CursorMove) { + let (current_row, current_column) = self.cursor; + + match cursor_move { + CursorMove::Top => self.cursor = (0, current_column), + CursorMove::Bottom => { + self.cursor = (self.lines.len(), current_column); + } + CursorMove::Up => { + self.cursor = + (current_row.saturating_sub(1), current_column); + } + CursorMove::Down => { + self.cursor = ( + (current_row + 1).min(self.lines.len()), + current_column, + ); + } + CursorMove::Back => { + self.cursor = + (current_row, current_column.saturating_sub(1)); + } + CursorMove::Forward => { + self.cursor = ( + current_row, + (current_column + 1) + .min(self.lines[current_row].len()), + ); + } + CursorMove::Home => self.cursor = (current_row, 0), + CursorMove::End => { + self.cursor = + (current_row, self.lines[current_row].len()); + } + } } fn delete_next_word(&mut self) { @@ -203,24 +230,24 @@ impl TextArea { todo!(); } - fn set_block(&mut self, _block: Block<'_>) { - todo!(); + fn set_block(&mut self, block: Block<'a>) { + self.block = Some(block); } fn set_style(&mut self, _style: Style) { - todo!(); + // Do nothing, implement or remove. } - fn set_placeholder_text(&mut self, _placeholder: String) { - todo!(); + fn set_placeholder_text(&mut self, placeholder: String) { + self.placeholder = placeholder; } fn set_placeholder_style(&mut self, _style: Style) { - todo!(); + // Do nothing, implement or remove. } fn set_cursor_line_style(&mut self, _style: Style) { - todo!(); + // Do nothing, implement or remove. } fn set_mask_char(&mut self, _char: char) { @@ -228,23 +255,24 @@ impl TextArea { } } -type TextAreaComponent = TextArea; +type TextAreaComponent = TextArea<'static>; +// TODO: +// `TextArea` and `TextAreaComponent` likely can be merged. impl<'a> TextAreaComponent { - fn start_selection(&mut self) { - todo!(); - } + fn insert_newline(&mut self) { + let (current_row, current_column) = self.cursor; - fn cancel_selection(&mut self) { - todo!(); - } + let (_, new_line) = + self.lines[current_row].split_at(current_column); - fn insert_newline(&mut self) { - todo!(); + self.lines.insert(current_row + 1, new_line.into()); + self.lines[current_row].truncate(current_column); + self.cursor = (current_row + 1, 0); } - fn lines(&self) -> &'a [String] { - todo!(); + fn lines(&'a self) -> &'a [String] { + &self.lines } } @@ -276,7 +304,6 @@ pub struct TextInputComponent { current_area: Cell, embed: bool, textarea: Option, - select_state: SelectionState, } impl TextInputComponent { @@ -299,7 +326,6 @@ impl TextInputComponent { current_area: Cell::new(Rect::default()), embed: false, textarea: None, - select_state: SelectionState::NotSelecting, } } @@ -448,41 +474,6 @@ impl TextInputComponent { } } - fn should_select(&mut self, input: &Input) { - if input.key == Key::Null { - return; - } - // Should we start selecting text, stop the current selection, or do nothing? - // the end is handled after the ending keystroke - - match (&self.select_state, input.shift) { - (SelectionState::Selecting, true) - | (SelectionState::NotSelecting, false) => { - // continue selecting or not selecting - } - (SelectionState::Selecting, false) => { - // end select - self.select_state = - SelectionState::SelectionEndPending; - } - (SelectionState::NotSelecting, true) => { - // start select - // this should always work since we are only called - // if we have a textarea to get input - if let Some(ta) = &mut self.textarea { - ta.start_selection(); - self.select_state = SelectionState::Selecting; - } - } - (SelectionState::SelectionEndPending, _) => { - // this really should not happen because the end pending state - // should have been picked up in the same pass as it was set - // so lets clear it - self.select_state = SelectionState::NotSelecting; - } - } - } - #[allow(clippy::too_many_lines, clippy::unnested_or_patterns)] fn process_inputs(ta: &mut TextArea, input: &Input) -> bool { match input { @@ -661,7 +652,7 @@ impl TextInputComponent { alt: true, .. } => { - ta.move_cursor(CursorMove::Head); + ta.move_cursor(CursorMove::Home); true } Input { @@ -710,79 +701,7 @@ impl TextInputComponent { ta.move_cursor(CursorMove::Bottom); true } - Input { - key: Key::Char('f'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Right, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::WordForward); - true - } - Input { - key: Key::Char('b'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Left, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::WordBack); - true - } - Input { - key: Key::Char(']'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('n'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Down, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::ParagraphForward); - true - } - Input { - key: Key::Char('['), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('p'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Up, - ctrl: true, - alt: false, - .. - } => { - ta.move_cursor(CursorMove::ParagraphBack); - true - } Input { key: Key::Char('u'), ctrl: true, @@ -899,7 +818,7 @@ impl Component for TextInputComponent { fn event(&mut self, ev: &Event) -> Result { let input = Input::from(ev.clone()); - self.should_select(&input); + if let Some(ta) = &mut self.textarea { let modified = if let Event::Key(e) = ev { if key_match(e, self.key_config.keys.exit_popup) { @@ -919,13 +838,6 @@ impl Component for TextInputComponent { false }; - if self.select_state - == SelectionState::SelectionEndPending - { - ta.cancel_selection(); - self.select_state = SelectionState::NotSelecting; - } - if modified { self.msg.take(); return Ok(EventState::Consumed); @@ -965,6 +877,7 @@ mod tests { comp.show_inner_textarea(); comp.set_text(String::from("a\nb")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { assert_eq!(ta.cursor(), (0, 0)); @@ -983,6 +896,7 @@ mod tests { comp.show_inner_textarea(); comp.set_text(String::from("a")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { let txt = ta.lines(); assert_eq!(txt[0].len(), 1); @@ -997,6 +911,7 @@ mod tests { comp.show_inner_textarea(); comp.set_text(String::from("a\nb\nc")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { let txt = ta.lines(); assert_eq!(txt[0], "a"); @@ -1006,109 +921,94 @@ mod tests { } #[test] - fn test_next_word_position() { + fn test_move_cursor_horizontally() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); comp.set_text(String::from("aa b;c")); assert!(comp.is_visible()); + if let Some(ta) = &mut comp.textarea { - // from word start - ta.move_cursor(CursorMove::Head); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 3)); - // from inside start + ta.move_cursor(CursorMove::Home); + assert_eq!(ta.cursor(), (0, 0)); + ta.move_cursor(CursorMove::Forward); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 5)); - // to string end + assert_eq!(ta.cursor(), (0, 1)); + ta.move_cursor(CursorMove::Forward); - ta.move_cursor(CursorMove::WordForward); + assert_eq!(ta.cursor(), (0, 2)); + + ta.move_cursor(CursorMove::End); assert_eq!(ta.cursor(), (0, 6)); - // from string end - ta.move_cursor(CursorMove::Forward); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), save_cursor); + ta.move_cursor(CursorMove::Back); + assert_eq!(ta.cursor(), (0, 5)); + + ta.move_cursor(CursorMove::Back); + assert_eq!(ta.cursor(), (0, 4)); } } #[test] - fn test_previous_word_position() { + fn test_move_cursor_vertically() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); - comp.set_text(String::from(" a bb;c")); + comp.set_text(String::from("aa b;c\ndef sa\ngitui")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { - // from string end - ta.move_cursor(CursorMove::End); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 6)); - // from inside word - ta.move_cursor(CursorMove::Back); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 3)); - // from word start - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 1)); - // to string start - ta.move_cursor(CursorMove::WordBack); + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (3, 0)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (3, 0)); + + ta.move_cursor(CursorMove::Top); assert_eq!(ta.cursor(), (0, 0)); - // from string start - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), save_cursor); + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (2, 0)); } } #[test] - fn test_next_word_multibyte() { + fn test_insert_newline() { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); - // should emojis be word boundaries or not? - // various editors (vs code, vim) do not agree with the - // behavhior of the original textinput here. - // - // tui-textarea agrees with them. - // So these tests are changed to match that behavior - // FYI: this line is "a à ❤ab🤯 a" - - // "01245 89A EFG" - let text = dbg!("a à \u{2764}ab\u{1F92F} a"); comp.show_inner_textarea(); - comp.set_text(String::from(text)); + comp.set_text(String::from("aa b;c asdf asdf")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { - ta.move_cursor(CursorMove::Head); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 2)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 4)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 9)); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), (0, 10)); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordForward); - assert_eq!(ta.cursor(), save_cursor); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 3)); - ta.move_cursor(CursorMove::End); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 9)); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 4)); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 2)); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), (0, 0)); - let save_cursor = ta.cursor(); - ta.move_cursor(CursorMove::WordBack); - assert_eq!(ta.cursor(), save_cursor); + ta.insert_newline(); + + assert_eq!(ta.lines(), &["aa ", "b;c asdf asdf"]); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (1, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["aa ", "b;c", " asdf asdf"]); + assert_eq!(ta.cursor(), (2, 0)); } } } From f20a11c87cbe7835b456244456e1918cbfdd845f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 16:44:19 +0100 Subject: [PATCH 03/21] Remove methods --- src/components/textinput.rs | 39 ------------------------------------- 1 file changed, 39 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index b389a4d5f1..2b03b01389 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -144,18 +144,6 @@ impl<'a> TextArea<'a> { todo!(); } - fn paste(&mut self) { - todo!(); - } - - fn redo(&mut self) { - todo!(); - } - - fn undo(&mut self) { - todo!(); - } - #[cfg(test)] fn cursor(&mut self) -> (usize, usize) { self.cursor @@ -702,33 +690,6 @@ impl TextInputComponent { true } - Input { - key: Key::Char('u'), - ctrl: true, - alt: false, - .. - } => { - ta.undo(); - true - } - Input { - key: Key::Char('r'), - ctrl: true, - alt: false, - .. - } => { - ta.redo(); - true - } - Input { - key: Key::Char('y'), - ctrl: true, - alt: false, - .. - } => { - ta.paste(); - true - } Input { key: Key::Char('v'), ctrl: true, From deeaa46fa89f48d1eedb422322a991d09886b2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 16:52:58 +0100 Subject: [PATCH 04/21] Add VerticalScroll --- src/components/textinput.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 2b03b01389..51eef0badd 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -1,4 +1,5 @@ use crate::app::Environment; +use crate::components::{ScrollType, VerticalScroll}; use crate::keys::key_match; use crate::ui::Size; use crate::{ @@ -44,11 +45,6 @@ enum CursorMove { End, } -enum Scrolling { - PageUp, - PageDown, -} - #[derive(Default, PartialEq)] enum Key { #[default] @@ -128,6 +124,7 @@ struct TextArea<'a> { /// 0-based (row, column) cursor: (usize, usize), placeholder: String, + scroll: VerticalScroll, } impl<'a> TextArea<'a> { @@ -137,11 +134,12 @@ impl<'a> TextArea<'a> { block: None, cursor: (0, 0), placeholder: String::new(), + scroll: VerticalScroll::new(), } } - fn scroll(&mut self, _scrolling: Scrolling) { - todo!(); + fn scroll(&self, scroll_type: ScrollType) -> bool { + self.scroll.move_top(scroll_type) } #[cfg(test)] @@ -699,7 +697,7 @@ impl TextInputComponent { | Input { key: Key::PageDown, .. } => { - ta.scroll(Scrolling::PageDown); + ta.scroll(ScrollType::PageDown); true } Input { @@ -711,7 +709,7 @@ impl TextInputComponent { | Input { key: Key::PageUp, .. } => { - ta.scroll(Scrolling::PageUp); + ta.scroll(ScrollType::PageUp); true } _ => false, From 7511da1f1a1e471c08e3caba2e52bfd9f5520f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 17:22:37 +0100 Subject: [PATCH 05/21] Implement DrawableComponent for TextAreaComponent --- src/components/textinput.rs | 53 ++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 51eef0badd..ae55406191 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -15,11 +15,12 @@ use anyhow::Result; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }; +use ratatui::text::{Line, Text}; +use ratatui::widgets::WidgetRef; use ratatui::{ - buffer::Buffer, layout::{Alignment, Rect}, style::Style, - widgets::{Block, Borders, Widget, WidgetRef}, + widgets::{Block, Borders}, widgets::{Clear, Paragraph}, Frame, }; @@ -124,16 +125,18 @@ struct TextArea<'a> { /// 0-based (row, column) cursor: (usize, usize), placeholder: String, + theme: SharedTheme, scroll: VerticalScroll, } impl<'a> TextArea<'a> { - const fn new(lines: Vec) -> Self { + const fn new(lines: Vec, theme: SharedTheme) -> Self { Self { lines, block: None, cursor: (0, 0), placeholder: String::new(), + theme, scroll: VerticalScroll::new(), } } @@ -212,8 +215,11 @@ impl<'a> TextArea<'a> { todo!(); } - fn insert_char(&mut self, _char: char) { - todo!(); + fn insert_char(&mut self, char: char) { + let (current_row, current_column) = self.cursor; + + self.lines[current_row].insert(current_column, char); + self.cursor = (current_row, current_column + 1); } fn set_block(&mut self, block: Block<'a>) { @@ -262,18 +268,28 @@ impl<'a> TextAreaComponent { } } -impl Widget for TextAreaComponent { - fn render(self, _area: Rect, _buf: &mut Buffer) - where - Self: Sized, - { - todo!() - } -} +impl DrawableComponent for TextAreaComponent { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + let lines: Vec<_> = self + .lines + .iter() + .skip(self.scroll.get_top()) + .map(|line| Line::from(line.clone())) + .collect(); + let paragraph = Paragraph::new(Text::from(lines)); + + if let Some(block) = &self.block { + block.render_ref(rect, f.buffer_mut()); -impl WidgetRef for TextAreaComponent { - fn render_ref(&self, _area: Rect, _buf: &mut Buffer) { - todo!() + let rect = block.inner(rect); + f.render_widget(paragraph, rect); + } else { + f.render_widget(paragraph, rect); + }; + + self.scroll.draw(f, rect, &self.theme); + + Ok(()) } } @@ -384,7 +400,8 @@ impl TextInputComponent { .collect(); self.textarea = Some({ - let mut text_area = TextArea::new(lines); + let mut text_area = + TextArea::new(lines, self.theme.clone()); if self.input_type == InputType::Password { text_area.set_mask_char('*'); } @@ -743,7 +760,7 @@ impl DrawableComponent for TextInputComponent { f.render_widget(Clear, area); - f.render_widget(ta, area); + ta.draw(f, area)?; if self.show_char_count { self.draw_char_count(f, area); From 8efa15fa0d0fa69c3cb1b23725d6ec0fbe179a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 17:49:22 +0100 Subject: [PATCH 06/21] Clamp column in cursor_move --- src/components/textinput.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index ae55406191..602a814683 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -156,16 +156,24 @@ impl<'a> TextArea<'a> { match cursor_move { CursorMove::Top => self.cursor = (0, current_column), CursorMove::Bottom => { - self.cursor = (self.lines.len(), current_column); + let last_row = self.lines.len() - 1; + + self.cursor = ( + last_row, + current_column.min(self.lines[last_row].len()), + ); } CursorMove::Up => { self.cursor = (current_row.saturating_sub(1), current_column); } CursorMove::Down => { + let new_row = + (current_row + 1).min(self.lines.len() - 1); + self.cursor = ( - (current_row + 1).min(self.lines.len()), - current_column, + new_row, + current_column.min(self.lines[new_row].len()), ); } CursorMove::Back => { @@ -851,7 +859,7 @@ mod tests { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); - comp.set_text(String::from("a\nb")); + comp.set_text(String::from("ab\nb")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { @@ -935,16 +943,16 @@ mod tests { if let Some(ta) = &mut comp.textarea { ta.move_cursor(CursorMove::Bottom); - assert_eq!(ta.cursor(), (3, 0)); - - ta.move_cursor(CursorMove::Up); assert_eq!(ta.cursor(), (2, 0)); ta.move_cursor(CursorMove::Up); assert_eq!(ta.cursor(), (1, 0)); + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (0, 0)); + ta.move_cursor(CursorMove::Bottom); - assert_eq!(ta.cursor(), (3, 0)); + assert_eq!(ta.cursor(), (2, 0)); ta.move_cursor(CursorMove::Top); assert_eq!(ta.cursor(), (0, 0)); @@ -954,6 +962,9 @@ mod tests { ta.move_cursor(CursorMove::Down); assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (2, 0)); } } From c0343c330535f89c21d744c8ac8bc6ee18f03342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 17:50:28 +0100 Subject: [PATCH 07/21] Implement delete_char --- src/components/textinput.rs | 67 ++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 602a814683..640d2364d5 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -216,7 +216,21 @@ impl<'a> TextArea<'a> { } fn delete_char(&mut self) { - todo!(); + let (current_row, current_column) = self.cursor; + + if current_column > 0 { + self.lines[current_row] + .remove(current_column.saturating_sub(1)); + self.cursor = (current_row, current_column - 1); + } else if current_row > 0 { + let current_line = self.lines[current_row].clone(); + self.lines[current_row - 1].push_str(¤t_line); + self.lines.remove(current_row); + self.cursor = + (current_row - 1, self.lines[current_row - 1].len()); + } else { + // We're at (0, 0), there's no characters to be deleted. Do nothing. + } } fn insert_tab(&mut self) { @@ -998,4 +1012,55 @@ mod tests { assert_eq!(ta.cursor(), (2, 0)); } } + + #[test] + fn test_delete_char() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aa b;c\ndef sa\ngitui")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Bottom); + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (2, 5)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "gitu"]); + assert_eq!(ta.cursor(), (2, 4)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "git"]); + assert_eq!(ta.cursor(), (2, 3)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "gi"]); + assert_eq!(ta.cursor(), (2, 2)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", "g"]); + assert_eq!(ta.cursor(), (2, 1)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa", ""]); + assert_eq!(ta.cursor(), (2, 0)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def sa"]); + assert_eq!(ta.cursor(), (1, 6)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def s"]); + assert_eq!(ta.cursor(), (1, 5)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aa b;c", "def "]); + assert_eq!(ta.cursor(), (1, 4)); + + ta.insert_char('g'); + assert_eq!(ta.lines(), &["aa b;c", "def g"]); + assert_eq!(ta.cursor(), (1, 5)); + } + } } From fc13859eea4250bd9d77cbe07f6deafe916b424a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 18:09:20 +0100 Subject: [PATCH 08/21] Show placeholder if present --- src/components/textinput.rs | 43 +++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 640d2364d5..758011b45c 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -130,7 +130,13 @@ struct TextArea<'a> { } impl<'a> TextArea<'a> { - const fn new(lines: Vec, theme: SharedTheme) -> Self { + fn new(lines: Vec, theme: SharedTheme) -> Self { + let lines = if lines.is_empty() { + vec![String::new()] + } else { + lines + }; + Self { lines, block: None, @@ -288,10 +294,16 @@ impl<'a> TextAreaComponent { fn lines(&'a self) -> &'a [String] { &self.lines } -} -impl DrawableComponent for TextAreaComponent { - fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + fn draw_placeholder(&self, f: &mut Frame, rect: Rect) { + let paragraph = Paragraph::new(Text::from(Line::from( + self.placeholder.clone(), + ))); + + f.render_widget(paragraph, rect); + } + + fn draw_lines(&self, f: &mut Frame, rect: Rect) { let lines: Vec<_> = self .lines .iter() @@ -300,14 +312,27 @@ impl DrawableComponent for TextAreaComponent { .collect(); let paragraph = Paragraph::new(Text::from(lines)); - if let Some(block) = &self.block { + f.render_widget(paragraph, rect); + } + + fn is_empty(&self) -> bool { + self.lines == [""] + } +} + +impl DrawableComponent for TextAreaComponent { + fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { + let rect = self.block.as_ref().map_or(rect, |block| { block.render_ref(rect, f.buffer_mut()); - let rect = block.inner(rect); - f.render_widget(paragraph, rect); + block.inner(rect) + }); + + if self.is_empty() && !self.placeholder.is_empty() { + self.draw_placeholder(f, rect); } else { - f.render_widget(paragraph, rect); - }; + self.draw_lines(f, rect); + } self.scroll.draw(f, rect, &self.theme); From b244a7b1230b2f94fcae8755706146e21ca6ca86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 18:30:33 +0100 Subject: [PATCH 09/21] Draw cursor --- src/components/textinput.rs | 38 +++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 758011b45c..98782fabbd 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -15,13 +15,12 @@ use anyhow::Result; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }; -use ratatui::text::{Line, Text}; -use ratatui::widgets::WidgetRef; +use ratatui::text::Span; use ratatui::{ layout::{Alignment, Rect}, - style::Style, - widgets::{Block, Borders}, - widgets::{Clear, Paragraph}, + style::{Modifier, Style}, + text::{Line, Text}, + widgets::{Block, Borders, Clear, Paragraph, WidgetRef}, Frame, }; use std::cell::{Cell, OnceCell}; @@ -124,6 +123,7 @@ struct TextArea<'a> { block: Option>, /// 0-based (row, column) cursor: (usize, usize), + cursor_style: Style, placeholder: String, theme: SharedTheme, scroll: VerticalScroll, @@ -141,6 +141,8 @@ impl<'a> TextArea<'a> { lines, block: None, cursor: (0, 0), + cursor_style: Style::default() + .add_modifier(Modifier::REVERSED), placeholder: String::new(), theme, scroll: VerticalScroll::new(), @@ -304,11 +306,35 @@ impl<'a> TextAreaComponent { } fn draw_lines(&self, f: &mut Frame, rect: Rect) { + let (current_row, current_column) = self.cursor; let lines: Vec<_> = self .lines .iter() + .enumerate() .skip(self.scroll.get_top()) - .map(|line| Line::from(line.clone())) + .map(|(row, line)| { + if row == current_row { + if current_column == line.len() { + Line::from(vec![ + Span::from(line.clone()), + Span::styled(" ", self.cursor_style), + ]) + } else { + let (before_cursor, cursor) = + line.split_at(current_column); + let (cursor, after_cursor) = + cursor.split_at(1); + + Line::from(vec![ + Span::from(before_cursor), + Span::styled(cursor, self.cursor_style), + Span::from(after_cursor), + ]) + } + } else { + Line::from(line.clone()) + } + }) .collect(); let paragraph = Paragraph::new(Text::from(lines)); From 367d7aa07972f040d2250c8b1e3f926c020fc6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 18:32:03 +0100 Subject: [PATCH 10/21] Clamp column in cursor_move --- src/components/textinput.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 98782fabbd..6cea778bdd 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -162,7 +162,10 @@ impl<'a> TextArea<'a> { let (current_row, current_column) = self.cursor; match cursor_move { - CursorMove::Top => self.cursor = (0, current_column), + CursorMove::Top => { + self.cursor = + (0, current_column.min(self.lines[0].len())); + } CursorMove::Bottom => { let last_row = self.lines.len() - 1; @@ -172,8 +175,12 @@ impl<'a> TextArea<'a> { ); } CursorMove::Up => { - self.cursor = - (current_row.saturating_sub(1), current_column); + let new_row = current_row.saturating_sub(1); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); } CursorMove::Down => { let new_row = @@ -1003,7 +1010,7 @@ mod tests { let env = Environment::test_env(); let mut comp = TextInputComponent::new(&env, "", "", false); comp.show_inner_textarea(); - comp.set_text(String::from("aa b;c\ndef sa\ngitui")); + comp.set_text(String::from("aa \nd\ngitui")); assert!(comp.is_visible()); if let Some(ta) = &mut comp.textarea { @@ -1030,6 +1037,19 @@ mod tests { ta.move_cursor(CursorMove::Down); assert_eq!(ta.cursor(), (2, 0)); + + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (2, 5)); + + ta.move_cursor(CursorMove::Up); + assert_eq!(ta.cursor(), (1, 1)); + + ta.move_cursor(CursorMove::Bottom); + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (2, 5)); + + ta.move_cursor(CursorMove::Top); + assert_eq!(ta.cursor(), (0, 3)); } } From c4d094d6278ec9bb051ae2f397b2dc27e3903878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 19:18:31 +0100 Subject: [PATCH 11/21] Implement delete_char, take Unicode into account --- src/components/textinput.rs | 180 +++++++++++++++++++++++++++++++----- 1 file changed, 157 insertions(+), 23 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 6cea778bdd..a1c4dad5ee 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -198,14 +198,19 @@ impl<'a> TextArea<'a> { CursorMove::Forward => { self.cursor = ( current_row, - (current_column + 1) - .min(self.lines[current_row].len()), + (current_column + 1).min( + self.lines[current_row] + .char_indices() + .count(), + ), ); } CursorMove::Home => self.cursor = (current_row, 0), CursorMove::End => { - self.cursor = - (current_row, self.lines[current_row].len()); + self.cursor = ( + current_row, + self.lines[current_row].char_indices().count(), + ); } } } @@ -227,22 +232,43 @@ impl<'a> TextArea<'a> { } fn delete_next_char(&mut self) { - todo!(); + let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; + + if current_column < current_line.len() { + if let Some((offset, _)) = + current_line.char_indices().nth(current_column) + { + current_line.remove(offset); + } + } else if current_row < self.lines.len().saturating_sub(1) { + let next_line = self.lines[current_row + 1].clone(); + self.lines[current_row].push_str(&next_line); + self.lines.remove(current_row + 1); + } else { + // We're at the end of the input. Do nothing. + } } fn delete_char(&mut self) { let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; if current_column > 0 { - self.lines[current_row] - .remove(current_column.saturating_sub(1)); - self.cursor = (current_row, current_column - 1); + if let Some((offset, _)) = + current_line.char_indices().nth(current_column - 1) + { + current_line.remove(offset); + self.cursor = (current_row, current_column - 1); + } } else if current_row > 0 { let current_line = self.lines[current_row].clone(); self.lines[current_row - 1].push_str(¤t_line); self.lines.remove(current_row); - self.cursor = - (current_row - 1, self.lines[current_row - 1].len()); + self.cursor = ( + current_row - 1, + self.lines[current_row - 1].char_indices().count(), + ); } else { // We're at (0, 0), there's no characters to be deleted. Do nothing. } @@ -254,8 +280,14 @@ impl<'a> TextArea<'a> { fn insert_char(&mut self, char: char) { let (current_row, current_column) = self.cursor; + let current_line = &mut self.lines[current_row]; + + let offset = current_line + .char_indices() + .nth(current_column) + .map_or_else(|| current_line.len(), |(i, _)| i); - self.lines[current_row].insert(current_column, char); + current_line.insert(offset, char); self.cursor = (current_row, current_column + 1); } @@ -321,26 +353,43 @@ impl<'a> TextAreaComponent { .skip(self.scroll.get_top()) .map(|(row, line)| { if row == current_row { - if current_column == line.len() { - Line::from(vec![ + if current_column == line.char_indices().count() { + return Line::from(vec![ Span::from(line.clone()), Span::styled(" ", self.cursor_style), - ]) - } else { + ]); + } + + if let Some((offset, _)) = + line.char_indices().nth(current_column) + { let (before_cursor, cursor) = - line.split_at(current_column); - let (cursor, after_cursor) = - cursor.split_at(1); + line.split_at(offset); + + if let Some((next_offset, _)) = + cursor.char_indices().nth(1) + { + let (cursor, after_cursor) = + cursor.split_at(next_offset); + + return Line::from(vec![ + Span::from(before_cursor), + Span::styled( + cursor, + self.cursor_style, + ), + Span::from(after_cursor), + ]); + } - Line::from(vec![ + return Line::from(vec![ Span::from(before_cursor), Span::styled(cursor, self.cursor_style), - Span::from(after_cursor), - ]) + ]); } - } else { - Line::from(line.clone()) } + + Line::from(line.clone()) }) .collect(); let paragraph = Paragraph::new(Text::from(lines)); @@ -1134,4 +1183,89 @@ mod tests { assert_eq!(ta.cursor(), (1, 5)); } } + + #[test] + fn test_delete_char_unicode() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("äÜö")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::End); + assert_eq!(ta.cursor(), (0, 3)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["äÜ"]); + assert_eq!(ta.cursor(), (0, 2)); + } + } + + #[test] + fn test_delete_next_char() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aa\ndef sa\ngitui")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["a", "def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["", "def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["def sa", "gitui"]); + assert_eq!(ta.cursor(), (0, 0)); + + ta.move_cursor(CursorMove::Down); + assert_eq!(ta.cursor(), (1, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["def sa", "itui"]); + assert_eq!(ta.cursor(), (1, 0)); + } + } + + #[test] + fn test_delete_next_char_empty() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text("".into()); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + assert_eq!(ta.cursor(), (0, 0)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &[""]); + assert_eq!(ta.cursor(), (0, 0)); + } + } + + #[test] + fn test_delete_next_char_unicode() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text("üäu".into()); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 1)); + + ta.delete_next_char(); + assert_eq!(ta.lines(), &["üu"]); + assert_eq!(ta.cursor(), (0, 1)); + } + } } From dc7044b46184425a24afde449afbffb0cf572c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 20:20:42 +0100 Subject: [PATCH 12/21] Correct for scrollbar margin --- src/components/textinput.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index a1c4dad5ee..1279b71f9a 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -404,16 +404,16 @@ impl<'a> TextAreaComponent { impl DrawableComponent for TextAreaComponent { fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()> { - let rect = self.block.as_ref().map_or(rect, |block| { + let inner_rect = self.block.as_ref().map_or(rect, |block| { block.render_ref(rect, f.buffer_mut()); block.inner(rect) }); if self.is_empty() && !self.placeholder.is_empty() { - self.draw_placeholder(f, rect); + self.draw_placeholder(f, inner_rect); } else { - self.draw_lines(f, rect); + self.draw_lines(f, inner_rect); } self.scroll.draw(f, rect, &self.theme); From d39ca57464c61aecf7b75628ac433e83e3607f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sat, 24 Jan 2026 20:21:18 +0100 Subject: [PATCH 13/21] Update scrollbar on draw --- src/components/textinput.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 1279b71f9a..17ca2eb284 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -346,11 +346,18 @@ impl<'a> TextAreaComponent { fn draw_lines(&self, f: &mut Frame, rect: Rect) { let (current_row, current_column) = self.cursor; + + let top = self.scroll.update( + current_row, + self.lines.len(), + rect.height.into(), + ); + let lines: Vec<_> = self .lines .iter() .enumerate() - .skip(self.scroll.get_top()) + .skip(top) .map(|(row, line)| { if row == current_row { if current_column == line.char_indices().count() { From 68b2a1de32e090b07a623e78d7552c00e8f4b97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 10:46:16 +0100 Subject: [PATCH 14/21] Handle Unicode in insert_newline --- src/components/textinput.rs | 44 +++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 17ca2eb284..b137cc2af5 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -323,12 +323,17 @@ type TextAreaComponent = TextArea<'static>; impl<'a> TextAreaComponent { fn insert_newline(&mut self) { let (current_row, current_column) = self.cursor; + let current_line = &self.lines[current_row]; - let (_, new_line) = - self.lines[current_row].split_at(current_column); + let offset = current_line + .char_indices() + .nth(current_column) + .map_or_else(|| current_line.len(), |(i, _)| i); - self.lines.insert(current_row + 1, new_line.into()); - self.lines[current_row].truncate(current_column); + let new_line = current_line[offset..].to_string(); + + self.lines.insert(current_row + 1, new_line); + self.lines[current_row].truncate(offset); self.cursor = (current_row + 1, 0); } @@ -1140,6 +1145,37 @@ mod tests { } } + #[test] + fn test_insert_newline_unicode() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("äaä b;ö üü")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (0, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["äaä", " b;ö üü"]); + assert_eq!(ta.cursor(), (1, 0)); + + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + ta.move_cursor(CursorMove::Forward); + assert_eq!(ta.cursor(), (1, 3)); + + ta.insert_newline(); + + assert_eq!(ta.lines(), &["äaä", " b;", "ö üü"]); + assert_eq!(ta.cursor(), (2, 0)); + } + } + #[test] fn test_delete_char() { let env = Environment::test_env(); From de3ac6bff98e4d746b4b0e0b929c217b06f145db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 10:52:56 +0100 Subject: [PATCH 15/21] Move cursor to end of previous line in delete_char --- src/components/textinput.rs | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index b137cc2af5..27f1bd6c83 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -242,9 +242,8 @@ impl<'a> TextArea<'a> { current_line.remove(offset); } } else if current_row < self.lines.len().saturating_sub(1) { - let next_line = self.lines[current_row + 1].clone(); + let next_line = self.lines.remove(current_row + 1); self.lines[current_row].push_str(&next_line); - self.lines.remove(current_row + 1); } else { // We're at the end of the input. Do nothing. } @@ -262,13 +261,14 @@ impl<'a> TextArea<'a> { self.cursor = (current_row, current_column - 1); } } else if current_row > 0 { - let current_line = self.lines[current_row].clone(); - self.lines[current_row - 1].push_str(¤t_line); - self.lines.remove(current_row); - self.cursor = ( - current_row - 1, - self.lines[current_row - 1].char_indices().count(), - ); + let current_line = self.lines.remove(current_row); + + let previous_line = &mut self.lines[current_row - 1]; + let previous_line_length = + previous_line.char_indices().count(); + + previous_line.push_str(¤t_line); + self.cursor = (current_row - 1, previous_line_length); } else { // We're at (0, 0), there's no characters to be deleted. Do nothing. } @@ -1245,6 +1245,24 @@ mod tests { } } + #[test] + fn test_delete_char_cursor_position() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from("aasd\nfdfsd\nölkj")); + assert!(comp.is_visible()); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::Bottom); + assert_eq!(ta.cursor(), (2, 0)); + + ta.delete_char(); + assert_eq!(ta.lines(), &["aasd", "fdfsdölkj"]); + assert_eq!(ta.cursor(), (1, 5)); + } + } + #[test] fn test_delete_next_char() { let env = Environment::test_env(); From 85cf89c31e4361710dfebc2e03085a2bfc655ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:24:04 +0100 Subject: [PATCH 16/21] Implement PageUp and PageDown in component This is to have a single source of truth for `self.scroll.top`. Currently, it depends entirely on `self.cursor.0` and keeping it that way obviates the need to change `self.cursor.0` after calling `self.scroll.move_top`. Instead, we don't call `self.scroll.move_top`, but simply change `self.cursor.0`. This change then propagates to `self.scroll.top` through `self.scroll.update` in `draw`. --- src/components/mod.rs | 2 +- src/components/textinput.rs | 62 ++++++++++++++++++++++--- src/components/utils/scroll_vertical.rs | 5 ++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/components/mod.rs b/src/components/mod.rs index 51322e18ae..c458472d5d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -169,7 +169,7 @@ pub fn command_pump( } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ScrollType { Up, Down, diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 27f1bd6c83..fdcb97534c 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -1,5 +1,5 @@ use crate::app::Environment; -use crate::components::{ScrollType, VerticalScroll}; +use crate::components::VerticalScroll; use crate::keys::key_match; use crate::ui::Size; use crate::{ @@ -43,6 +43,8 @@ enum CursorMove { Forward, Home, End, + PageUp, + PageDown, } #[derive(Default, PartialEq)] @@ -149,10 +151,6 @@ impl<'a> TextArea<'a> { } } - fn scroll(&self, scroll_type: ScrollType) -> bool { - self.scroll.move_top(scroll_type) - } - #[cfg(test)] fn cursor(&mut self) -> (usize, usize) { self.cursor @@ -212,6 +210,25 @@ impl<'a> TextArea<'a> { self.lines[current_row].char_indices().count(), ); } + CursorMove::PageUp => { + let new_row = current_row + .saturating_sub(self.scroll.get_visual_height()); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } + CursorMove::PageDown => { + let new_row = (current_row + + self.scroll.get_visual_height()) + .min(self.lines.len().saturating_sub(1)); + + self.cursor = ( + new_row, + current_column.min(self.lines[new_row].len()), + ); + } } } @@ -855,7 +872,7 @@ impl TextInputComponent { | Input { key: Key::PageDown, .. } => { - ta.scroll(ScrollType::PageDown); + ta.move_cursor(CursorMove::PageDown); true } Input { @@ -867,7 +884,7 @@ impl TextInputComponent { | Input { key: Key::PageUp, .. } => { - ta.scroll(ScrollType::PageUp); + ta.move_cursor(CursorMove::PageUp); true } _ => false, @@ -1114,6 +1131,37 @@ mod tests { } } + #[test] + fn test_move_cursor_vertically_page_up_down() { + let env = Environment::test_env(); + let mut comp = TextInputComponent::new(&env, "", "", false); + comp.show_inner_textarea(); + comp.set_text(String::from( + "aa \nd\ngitui\nasdf\ndf\ndfsdf\nsdfsdfsdfsdf", + )); + assert!(comp.is_visible()); + + let test_backend = + ratatui::backend::TestBackend::new(100, 100); + let mut terminal = ratatui::Terminal::new(test_backend) + .expect("Unable to set up terminal"); + let mut frame = terminal.get_frame(); + let rect = Rect::new(0, 0, 10, 5); + + // we call draw once before running the actual test as the component only learns its dimensions + // in a `draw` call. It needs to learn its dimensions because `PageUp` and `PageDown` rely on + // them for calculating how far to move the cursor. + comp.draw(&mut frame, rect).expect("draw not to fail"); + + if let Some(ta) = &mut comp.textarea { + ta.move_cursor(CursorMove::PageDown); + assert_eq!(ta.cursor(), (6, 0)); + + ta.move_cursor(CursorMove::PageUp); + assert_eq!(ta.cursor(), (0, 0)); + } + } + #[test] fn test_insert_newline() { let env = Environment::test_env(); diff --git a/src/components/utils/scroll_vertical.rs b/src/components/utils/scroll_vertical.rs index 51cb05f82f..d50ce41120 100644 --- a/src/components/utils/scroll_vertical.rs +++ b/src/components/utils/scroll_vertical.rs @@ -5,6 +5,7 @@ use crate::{ use ratatui::{layout::Rect, Frame}; use std::cell::Cell; +#[derive(Debug)] pub struct VerticalScroll { top: Cell, max_top: Cell, @@ -24,6 +25,10 @@ impl VerticalScroll { self.top.get() } + pub fn get_visual_height(&self) -> usize { + self.visual_height.get() + } + pub fn reset(&self) { self.top.set(0); } From 4c86b00be015beea8536c2c98bea126f87622c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:32:10 +0100 Subject: [PATCH 17/21] Implement placeholder style --- src/components/textinput.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index fdcb97534c..777d0481bb 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -15,11 +15,10 @@ use anyhow::Result; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }; -use ratatui::text::Span; use ratatui::{ layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Text}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, widgets::{Block, Borders, Clear, Paragraph, WidgetRef}, Frame, }; @@ -127,6 +126,7 @@ struct TextArea<'a> { cursor: (usize, usize), cursor_style: Style, placeholder: String, + placeholder_style: Style, theme: SharedTheme, scroll: VerticalScroll, } @@ -146,6 +146,7 @@ impl<'a> TextArea<'a> { cursor_style: Style::default() .add_modifier(Modifier::REVERSED), placeholder: String::new(), + placeholder_style: Style::default().fg(Color::DarkGray), theme, scroll: VerticalScroll::new(), } @@ -320,8 +321,8 @@ impl<'a> TextArea<'a> { self.placeholder = placeholder; } - fn set_placeholder_style(&mut self, _style: Style) { - // Do nothing, implement or remove. + fn set_placeholder_style(&mut self, placeholder_style: Style) { + self.placeholder_style = placeholder_style; } fn set_cursor_line_style(&mut self, _style: Style) { @@ -361,7 +362,8 @@ impl<'a> TextAreaComponent { fn draw_placeholder(&self, f: &mut Frame, rect: Rect) { let paragraph = Paragraph::new(Text::from(Line::from( self.placeholder.clone(), - ))); + ))) + .style(self.placeholder_style); f.render_widget(paragraph, rect); } From c2ba87d96281e9c1ca1461a09639d5003fe3b866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:33:45 +0100 Subject: [PATCH 18/21] Remove methods that won't be implemented --- src/components/textinput.rs | 83 ------------------------------------- 1 file changed, 83 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 777d0481bb..c1ad17cc5b 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -233,22 +233,6 @@ impl<'a> TextArea<'a> { } } - fn delete_next_word(&mut self) { - todo!(); - } - - fn delete_word(&mut self) { - todo!(); - } - - fn delete_line_by_head(&mut self) { - todo!(); - } - - fn delete_line_by_end(&mut self) { - todo!(); - } - fn delete_next_char(&mut self) { let (current_row, current_column) = self.cursor; let current_line = &mut self.lines[current_row]; @@ -292,10 +276,6 @@ impl<'a> TextArea<'a> { } } - fn insert_tab(&mut self) { - todo!(); - } - fn insert_char(&mut self, char: char) { let (current_row, current_column) = self.cursor; let current_line = &mut self.lines[current_row]; @@ -649,15 +629,6 @@ impl TextInputComponent { ta.insert_char(*c); true } - Input { - key: Key::Tab, - ctrl: false, - alt: false, - .. - } => { - ta.insert_tab(); - true - } Input { key: Key::Char('h'), ctrl: true, @@ -688,60 +659,6 @@ impl TextInputComponent { ta.delete_next_char(); true } - Input { - key: Key::Char('k'), - ctrl: true, - alt: false, - .. - } => { - ta.delete_line_by_end(); - true - } - Input { - key: Key::Char('j'), - ctrl: true, - alt: false, - .. - } => { - ta.delete_line_by_head(); - true - } - Input { - key: Key::Char('w'), - ctrl: true, - alt: false, - .. - } - | Input { - key: Key::Char('h'), - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Backspace, - ctrl: false, - alt: true, - .. - } => { - ta.delete_word(); - true - } - Input { - key: Key::Delete, - ctrl: false, - alt: true, - .. - } - | Input { - key: Key::Char('d'), - ctrl: false, - alt: true, - .. - } => { - ta.delete_next_word(); - true - } Input { key: Key::Char('n'), ctrl: true, From 80428e42e6e844fd50984109977858b2d8b3df40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:48:06 +0100 Subject: [PATCH 19/21] Implement mask char for use in password fields --- src/components/textinput.rs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index c1ad17cc5b..278edfeaa0 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -22,7 +22,9 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, WidgetRef}, Frame, }; +use std::borrow::Cow; use std::cell::{Cell, OnceCell}; +use std::iter::repeat_n; /// #[derive(PartialEq, Eq)] @@ -127,6 +129,7 @@ struct TextArea<'a> { cursor_style: Style, placeholder: String, placeholder_style: Style, + mask_char: Option, theme: SharedTheme, scroll: VerticalScroll, } @@ -147,6 +150,7 @@ impl<'a> TextArea<'a> { .add_modifier(Modifier::REVERSED), placeholder: String::new(), placeholder_style: Style::default().fg(Color::DarkGray), + mask_char: None, theme, scroll: VerticalScroll::new(), } @@ -309,8 +313,8 @@ impl<'a> TextArea<'a> { // Do nothing, implement or remove. } - fn set_mask_char(&mut self, _char: char) { - todo!(); + fn set_mask_char(&mut self, mask_char: char) { + self.mask_char = Some(mask_char); } } @@ -363,6 +367,14 @@ impl<'a> TextAreaComponent { .enumerate() .skip(top) .map(|(row, line)| { + let line: Cow<'_, str> = self.mask_char.map_or_else( + || line.into(), + |mask_char| { + repeat_n(mask_char, line.chars().count()) + .collect() + }, + ); + if row == current_row { if current_column == line.char_indices().count() { return Line::from(vec![ @@ -384,18 +396,21 @@ impl<'a> TextAreaComponent { cursor.split_at(next_offset); return Line::from(vec![ - Span::from(before_cursor), + Span::from(before_cursor.to_string()), Span::styled( - cursor, + cursor.to_string(), self.cursor_style, ), - Span::from(after_cursor), + Span::from(after_cursor.to_string()), ]); } return Line::from(vec![ - Span::from(before_cursor), - Span::styled(cursor, self.cursor_style), + Span::from(before_cursor.to_string()), + Span::styled( + cursor.to_string(), + self.cursor_style, + ), ]); } } From ebf7d5b2ee7738f79fae9bf9ae52236aa21015d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:51:45 +0100 Subject: [PATCH 20/21] Implement style --- src/components/textinput.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index 278edfeaa0..d8868cba05 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -124,6 +124,7 @@ impl From for Input { struct TextArea<'a> { lines: Vec, block: Option>, + style: Style, /// 0-based (row, column) cursor: (usize, usize), cursor_style: Style, @@ -145,6 +146,7 @@ impl<'a> TextArea<'a> { Self { lines, block: None, + style: Style::default(), cursor: (0, 0), cursor_style: Style::default() .add_modifier(Modifier::REVERSED), @@ -297,8 +299,8 @@ impl<'a> TextArea<'a> { self.block = Some(block); } - fn set_style(&mut self, _style: Style) { - // Do nothing, implement or remove. + fn set_style(&mut self, style: Style) { + self.style = style; } fn set_placeholder_text(&mut self, placeholder: String) { @@ -418,7 +420,8 @@ impl<'a> TextAreaComponent { Line::from(line.clone()) }) .collect(); - let paragraph = Paragraph::new(Text::from(lines)); + let paragraph = + Paragraph::new(Text::from(lines)).style(self.style); f.render_widget(paragraph, rect); } From 5c0687beeb3021b04ebfb2b79fd493a50f6204ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Sun, 25 Jan 2026 11:58:03 +0100 Subject: [PATCH 21/21] Remove set_cursor_line_style --- src/components/textinput.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/textinput.rs b/src/components/textinput.rs index d8868cba05..9ae785ca4f 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -311,10 +311,6 @@ impl<'a> TextArea<'a> { self.placeholder_style = placeholder_style; } - fn set_cursor_line_style(&mut self, _style: Style) { - // Do nothing, implement or remove. - } - fn set_mask_char(&mut self, mask_char: char) { self.mask_char = Some(mask_char); } @@ -564,8 +560,6 @@ impl TextInputComponent { text_area.set_mask_char('*'); } - text_area - .set_cursor_line_style(self.theme.text(true, false)); text_area.set_placeholder_text(self.default_msg.clone()); text_area.set_placeholder_style( self.theme