From d62b703a211f6e06fc81b3f96671f431e1e59252 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Sun, 3 Aug 2025 11:31:35 -0700 Subject: [PATCH] custom textarea (#1794) This replaces tui-textarea with a custom textarea component. Key differences: 1. wrapped lines 2. better unicode handling 3. uses the native terminal cursor This should perhaps be spun out into its own separate crate at some point, but for now it's convenient to have it in-tree. --- codex-rs/Cargo.lock | 39 +- codex-rs/tui/Cargo.toml | 4 +- codex-rs/tui/src/app.rs | 13 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 637 ++++---- .../src/bottom_pane/chat_composer_history.rs | 91 +- codex-rs/tui/src/bottom_pane/mod.rs | 17 +- codex-rs/tui/src/bottom_pane/textarea.rs | 1294 +++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 4 + 8 files changed, 1690 insertions(+), 409 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/textarea.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d71553cf..e1a0e162 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -859,6 +859,7 @@ dependencies = [ "mcp-types", "path-clean", "pretty_assertions", + "rand 0.8.5", "ratatui", "ratatui-image", "regex-lite", @@ -868,13 +869,13 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", + "textwrap 0.16.2", "tokio", "tracing", "tracing-appender", "tracing-subscriber", "tui-input", "tui-markdown", - "tui-textarea", "unicode-segmentation", "unicode-width 0.1.14", "uuid", @@ -4173,6 +4174,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.10" @@ -4235,7 +4242,7 @@ dependencies = [ "starlark_syntax", "static_assertions", "strsim 0.10.0", - "textwrap", + "textwrap 0.11.0", "thiserror 1.0.69", ] @@ -4524,6 +4531,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4988,17 +5006,6 @@ dependencies = [ "tracing", ] -[[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 = "typenum" version = "1.18.0" @@ -5017,6 +5024,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 09b537c6..823fd142 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -48,6 +48,7 @@ serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" strum = "0.27.2" strum_macros = "0.27.2" +textwrap = "0.16.2" tokio = { version = "1", features = [ "io-std", "macros", @@ -60,7 +61,6 @@ tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tui-input = "0.14.0" tui-markdown = "0.3.3" -tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" unicode-width = "0.1" uuid = "1" @@ -70,3 +70,5 @@ uuid = "1" [dev-dependencies] insta = "1.43.1" pretty_assertions = "1" +rand = "0.8" +chrono = { version = "0.4", features = ["serde"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index efaec7f1..1142bd87 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -438,14 +438,15 @@ impl App<'_> { ); self.pending_history_lines.clear(); } - match &mut self.app_state { + terminal.draw(|frame| match &mut self.app_state { AppState::Chat { widget } => { - terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?; + if let Some((x, y)) = widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + frame.render_widget_ref(&**widget, frame.area()) } - AppState::GitWarning { screen } => { - terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?; - } - } + AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()), + })?; Ok(()) } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7e187bec..c9ad7197 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,6 +1,11 @@ use codex_core::protocol::TokenUsage; +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Margin; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Style; @@ -8,13 +13,11 @@ use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; -use ratatui::widgets::Widget; +use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; -use tui_textarea::Input; -use tui_textarea::Key; -use tui_textarea::TextArea; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; @@ -22,7 +25,10 @@ use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; use codex_file_search::FileMatch; +use std::cell::RefCell; const BASE_PLACEHOLDER_TEXT: &str = "..."; /// If the pasted content exceeds this number of characters, replace it with a @@ -35,8 +41,14 @@ pub enum InputResult { None, } -pub(crate) struct ChatComposer<'a> { - textarea: TextArea<'a>, +struct TokenUsageInfo { + token_usage: TokenUsage, + model_context_window: Option, +} + +pub(crate) struct ChatComposer { + textarea: TextArea, + textarea_state: RefCell, active_popup: ActivePopup, app_event_tx: AppEventSender, history: ChatComposerHistory, @@ -45,6 +57,8 @@ pub(crate) struct ChatComposer<'a> { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + token_usage_info: Option, + has_focus: bool, } /// Popup state – at most one can be visible at any time. @@ -54,20 +68,17 @@ enum ActivePopup { File(FileSearchPopup), } -impl ChatComposer<'_> { +impl ChatComposer { pub fn new( has_input_focus: bool, app_event_tx: AppEventSender, enhanced_keys_supported: bool, ) -> Self { - let mut textarea = TextArea::default(); - textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT); - textarea.set_cursor_line_style(ratatui::style::Style::default()); - let use_shift_enter_hint = enhanced_keys_supported; - let mut this = Self { - textarea, + Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), active_popup: ActivePopup::None, app_event_tx, history: ChatComposerHistory::new(), @@ -76,13 +87,13 @@ impl ChatComposer<'_> { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), - }; - this.update_border(has_input_focus); - this + token_usage_info: None, + has_focus: has_input_focus, + } } - pub fn desired_height(&self) -> u16 { - self.textarea.lines().len().max(1) as u16 + pub fn desired_height(&self, width: u16) -> u16 { + self.textarea.desired_height(width - 1) + match &self.active_popup { ActivePopup::None => 1u16, ActivePopup::Command(c) => c.calculate_required_height(), @@ -90,6 +101,21 @@ impl ChatComposer<'_> { } } + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let popup_height = match &self.active_popup { + ActivePopup::Command(popup) => popup.calculate_required_height(), + ActivePopup::File(popup) => popup.calculate_required_height(), + ActivePopup::None => 1, + }; + let [textarea_rect, _] = + Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area); + let mut textarea_rect = textarea_rect; + textarea_rect.width = textarea_rect.width.saturating_sub(1); + textarea_rect.x += 1; + let state = self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, &state) + } + /// Returns true if the composer currently contains no user input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -103,28 +129,10 @@ impl ChatComposer<'_> { token_usage: TokenUsage, model_context_window: Option, ) { - let placeholder = match (token_usage.total_tokens, model_context_window) { - (total_tokens, Some(context_window)) => { - let percent_remaining: u8 = if context_window > 0 { - // Calculate the percentage of context left. - let percent = 100.0 - (total_tokens as f32 / context_window as f32 * 100.0); - percent.clamp(0.0, 100.0) as u8 - } else { - // If we don't have a context window, we cannot compute the - // percentage. - 100 - }; - // When https://github.com/openai/codex/issues/1257 is resolved, - // check if `percent_remaining < 25`, and if so, recommend - // /compact. - format!("{BASE_PLACEHOLDER_TEXT} — {percent_remaining}% context left") - } - (total_tokens, None) => { - format!("{BASE_PLACEHOLDER_TEXT} — {total_tokens} tokens used") - } - }; - - self.textarea.set_placeholder_text(placeholder); + self.token_usage_info = Some(TokenUsageInfo { + token_usage, + model_context_window, + }); } /// Record the history metadata advertised by `SessionConfiguredEvent` so @@ -142,8 +150,12 @@ impl ChatComposer<'_> { offset: usize, entry: Option, ) -> bool { - self.history - .on_entry_response(log_id, offset, entry, &mut self.textarea) + let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { + return false; + }; + self.textarea.set_text(&text); + self.textarea.set_cursor(0); + true } pub fn handle_paste(&mut self, pasted: String) -> bool { @@ -179,7 +191,7 @@ impl ChatComposer<'_> { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { self.ctrl_c_quit_hint = show; - self.update_border(has_focus); + self.set_has_focus(has_focus); } /// Handle a key event coming from the main UI. @@ -207,49 +219,47 @@ impl ChatComposer<'_> { unreachable!(); }; - match key_event.into() { - Input { key: Key::Up, .. } => { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } => { popup.move_up(); (InputResult::None, true) } - Input { key: Key::Down, .. } => { + KeyEvent { + code: KeyCode::Down, + .. + } => { popup.move_down(); (InputResult::None, true) } - Input { key: Key::Tab, .. } => { + KeyEvent { + code: KeyCode::Tab, .. + } => { if let Some(cmd) = popup.selected_command() { - let first_line = self - .textarea - .lines() - .first() - .map(|s| s.as_str()) - .unwrap_or(""); + let first_line = self.textarea.text().lines().next().unwrap_or(""); let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.select_all(); - self.textarea.cut(); - let _ = self.textarea.insert_str(format!("/{} ", cmd.command())); + self.textarea.set_text(&format!("/{} ", cmd.command())); } } (InputResult::None, true) } - Input { - key: Key::Enter, - shift: false, - alt: false, - ctrl: false, + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. } => { if let Some(cmd) = popup.selected_command() { // Send command to the app layer. self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)); // Clear textarea so no residual text remains. - self.textarea.select_all(); - self.textarea.cut(); + self.textarea.set_text(""); // Hide popup since the command has been dispatched. self.active_popup = ActivePopup::None; @@ -268,16 +278,23 @@ impl ChatComposer<'_> { unreachable!(); }; - match key_event.into() { - Input { key: Key::Up, .. } => { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } => { popup.move_up(); (InputResult::None, true) } - Input { key: Key::Down, .. } => { + KeyEvent { + code: KeyCode::Down, + .. + } => { popup.move_down(); (InputResult::None, true) } - Input { key: Key::Esc, .. } => { + KeyEvent { + code: KeyCode::Esc, .. + } => { // Hide popup without modifying text, remember token to avoid immediate reopen. if let Some(tok) = Self::current_at_token(&self.textarea) { self.dismissed_file_popup_token = Some(tok.to_string()); @@ -285,12 +302,13 @@ impl ChatComposer<'_> { self.active_popup = ActivePopup::None; (InputResult::None, true) } - Input { key: Key::Tab, .. } - | Input { - key: Key::Enter, - ctrl: false, - alt: false, - shift: false, + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. } => { if let Some(sel) = popup.selected_match() { let sel_path = sel.to_string(); @@ -315,46 +333,89 @@ impl ChatComposer<'_> { /// - A token is delimited by ASCII whitespace (space, tab, newline). /// - If the token under the cursor starts with `@` and contains at least /// one additional character, that token (without `@`) is returned. - fn current_at_token(textarea: &tui_textarea::TextArea) -> Option { - let (row, col) = textarea.cursor(); + fn current_at_token(textarea: &TextArea) -> Option { + let cursor_offset = textarea.cursor(); + let text = textarea.text(); - // Guard against out-of-bounds rows. - let line = textarea.lines().get(row)?.as_str(); + // Adjust the provided byte offset to the nearest valid char boundary at or before it. + let mut safe_cursor = cursor_offset.min(text.len()); + // If we're not on a char boundary, move back to the start of the current char. + if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { + // Find the last valid boundary <= cursor_offset. + safe_cursor = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= cursor_offset) + .last() + .unwrap_or(0); + } - // Calculate byte offset for cursor position - let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::(); + // Split the line around the (now safe) cursor position. + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; - // Split the line at the cursor position so we can search for word - // boundaries on both sides. - let before_cursor = &line[..cursor_byte_offset]; - let after_cursor = &line[cursor_byte_offset..]; + // Detect whether we're on whitespace at the cursor boundary. + let at_whitespace = if safe_cursor < text.len() { + text[safe_cursor..] + .chars() + .next() + .map(|c| c.is_whitespace()) + .unwrap_or(false) + } else { + false + }; - // Find start index (first character **after** the previous multi-byte whitespace). - let start_idx = before_cursor + // Left candidate: token containing the cursor position. + let start_left = before_cursor .char_indices() .rfind(|(_, c)| c.is_whitespace()) .map(|(idx, c)| idx + c.len_utf8()) .unwrap_or(0); - - // Find end index (first multi-byte whitespace **after** the cursor position). - let end_rel_idx = after_cursor + let end_left_rel = after_cursor .char_indices() .find(|(_, c)| c.is_whitespace()) .map(|(idx, _)| idx) .unwrap_or(after_cursor.len()); - let end_idx = cursor_byte_offset + end_rel_idx; - - if start_idx >= end_idx { - return None; - } - - let token = &line[start_idx..end_idx]; - - if token.starts_with('@') && token.len() > 1 { - Some(token[1..].to_string()) + let end_left = safe_cursor + end_left_rel; + let token_left = if start_left < end_left { + Some(&text[start_left..end_left]) } else { None + }; + + // Right candidate: token immediately after any whitespace from the cursor. + let ws_len_right: usize = after_cursor + .chars() + .take_while(|c| c.is_whitespace()) + .map(|c| c.len_utf8()) + .sum(); + let start_right = safe_cursor + ws_len_right; + let end_right_rel = text[start_right..] + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(text.len() - start_right); + let end_right = start_right + end_right_rel; + let token_right = if start_right < end_right { + Some(&text[start_right..end_right]) + } else { + None + }; + + let left_at = token_left + .filter(|t| t.starts_with('@') && t.len() > 1) + .map(|t| t[1..].to_string()); + let right_at = token_right + .filter(|t| t.starts_with('@') && t.len() > 1) + .map(|t| t[1..].to_string()); + + if at_whitespace { + return right_at.or(left_at); } + if after_cursor.starts_with('@') { + return right_at.or(left_at); + } + left_at.or(right_at) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -363,94 +424,73 @@ impl ChatComposer<'_> { /// where the cursor is within the token and regardless of how many /// `@tokens` exist in the line. fn insert_selected_path(&mut self, path: &str) { - let (row, col) = self.textarea.cursor(); + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); - // Materialize the textarea lines so we can mutate them easily. - let mut lines: Vec = self.textarea.lines().to_vec(); + let before_cursor = &text[..cursor_offset]; + let after_cursor = &text[cursor_offset..]; - if let Some(line) = lines.get_mut(row) { - // Calculate byte offset for cursor position - let cursor_byte_offset = line.chars().take(col).map(|c| c.len_utf8()).sum::(); + // Determine token boundaries. + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); - let before_cursor = &line[..cursor_byte_offset]; - let after_cursor = &line[cursor_byte_offset..]; + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = cursor_offset + end_rel_idx; - // Determine token boundaries. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(path); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = cursor_byte_offset + end_rel_idx; - - // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. - let mut new_line = - String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1); - new_line.push_str(&line[..start_idx]); - new_line.push_str(path); - new_line.push(' '); - new_line.push_str(&line[end_idx..]); - - *line = new_line; - - // Re-populate the textarea. - let new_text = lines.join("\n"); - self.textarea.select_all(); - self.textarea.cut(); - let _ = self.textarea.insert_str(new_text); - - // Note: tui-textarea currently exposes only relative cursor - // movements. Leaving the cursor position unchanged is acceptable - // as subsequent typing will move the cursor naturally. - } + self.textarea.set_text(&new_text); } /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let input: Input = key_event.into(); - match input { + match key_event { // ------------------------------------------------------------- // History navigation (Up / Down) – only when the composer is not // empty or when the cursor is at the correct position, to avoid // interfering with normal cursor movement. // ------------------------------------------------------------- - Input { key: Key::Up, .. } => { - if self.history.should_handle_navigation(&self.textarea) { - let consumed = self - .history - .navigate_up(&mut self.textarea, &self.app_event_tx); - if consumed { - return (InputResult::None, true); - } - } - self.handle_input_basic(input) - } - Input { key: Key::Down, .. } => { - if self.history.should_handle_navigation(&self.textarea) { - let consumed = self - .history - .navigate_down(&mut self.textarea, &self.app_event_tx); - if consumed { - return (InputResult::None, true); - } - } - self.handle_input_basic(input) - } - Input { - key: Key::Enter, - shift: false, - alt: false, - ctrl: false, + KeyEvent { + code: KeyCode::Up | KeyCode::Down, + .. } => { - let mut text = self.textarea.lines().join("\n"); - self.textarea.select_all(); - self.textarea.cut(); + if self + .history + .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) + { + let replace_text = match key_event.code { + KeyCode::Up => self.history.navigate_up(&self.app_event_tx), + KeyCode::Down => self.history.navigate_down(&self.app_event_tx), + _ => unreachable!(), + }; + if let Some(text) = replace_text { + self.textarea.set_text(&text); + self.textarea.set_cursor(0); + return (InputResult::None, true); + } + } + self.handle_input_basic(key_event) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let mut text = self.textarea.text().to_string(); + self.textarea.set_text(""); // Replace all pending pastes in the text for (placeholder, actual) in &self.pending_pastes { @@ -467,41 +507,15 @@ impl ChatComposer<'_> { (InputResult::Submitted(text), true) } } - Input { - key: Key::Enter, .. - } - | Input { - key: Key::Char('j'), - ctrl: true, - alt: false, - shift: false, - } => { - self.textarea.insert_newline(); - (InputResult::None, true) - } - Input { - key: Key::Char('d'), - ctrl: true, - alt: false, - shift: false, - } => { - self.textarea.input(Input { - key: Key::Delete, - ctrl: false, - alt: false, - shift: false, - }); - (InputResult::None, true) - } input => self.handle_input_basic(input), } } /// Handle generic Input events that modify the textarea content. - fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) { + fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { // Special handling for backspace on placeholders - if let Input { - key: Key::Backspace, + if let KeyEvent { + code: KeyCode::Backspace, .. } = input { @@ -510,20 +524,9 @@ impl ChatComposer<'_> { } } - if let Input { - key: Key::Char('u'), - ctrl: true, - alt: false, - .. - } = input - { - self.textarea.delete_line_by_head(); - return (InputResult::None, true); - } - // Normal input handling self.textarea.input(input); - let text_after = self.textarea.lines().join("\n"); + let text_after = self.textarea.text(); // Check if any placeholders were removed and remove their corresponding pending pastes self.pending_pastes @@ -535,21 +538,16 @@ impl ChatComposer<'_> { /// Attempts to remove a placeholder if the cursor is at the end of one. /// Returns true if a placeholder was removed. fn try_remove_placeholder_at_cursor(&mut self) -> bool { - let (row, col) = self.textarea.cursor(); - let line = self - .textarea - .lines() - .get(row) - .map(|s| s.as_str()) - .unwrap_or(""); + let p = self.textarea.cursor(); + let text = self.textarea.text(); // Find any placeholder that ends at the cursor position let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| { - if col < ph.len() { + if p < ph.len() { return None; } - let potential_ph_start = col - ph.len(); - if line[potential_ph_start..col] == *ph { + let potential_ph_start = p - ph.len(); + if text[potential_ph_start..p] == *ph { Some(ph.clone()) } else { None @@ -557,17 +555,7 @@ impl ChatComposer<'_> { }); if let Some(placeholder) = placeholder_to_remove { - // Remove the entire placeholder from the text - let placeholder_len = placeholder.len(); - for _ in 0..placeholder_len { - self.textarea.input(Input { - key: Key::Backspace, - ctrl: false, - alt: false, - shift: false, - }); - } - // Remove from pending pastes + self.textarea.replace_range(p - placeholder.len()..p, ""); self.pending_pastes.retain(|(ph, _)| ph != &placeholder); true } else { @@ -579,16 +567,7 @@ impl ChatComposer<'_> { /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. fn sync_command_popup(&mut self) { - // Inspect only the first line to decide whether to show the popup. In - // the common case (no leading slash) we avoid copying the entire - // textarea contents. - let first_line = self - .textarea - .lines() - .first() - .map(|s| s.as_str()) - .unwrap_or(""); - + let first_line = self.textarea.text().lines().next().unwrap_or(""); let input_starts_with_slash = first_line.starts_with('/'); match &mut self.active_popup { ActivePopup::Command(popup) => { @@ -644,74 +623,29 @@ impl ChatComposer<'_> { self.dismissed_file_popup_token = None; } - fn update_border(&mut self, has_focus: bool) { - let border_style = if has_focus { - Style::default().fg(Color::Cyan) - } else { - Style::default().dim() - }; - - self.textarea.set_block( - ratatui::widgets::Block::default() - .borders(Borders::LEFT) - .border_type(BorderType::QuadrantOutside) - .border_style(border_style), - ); + fn set_has_focus(&mut self, has_focus: bool) { + self.has_focus = has_focus; } } -impl WidgetRef for &ChatComposer<'_> { +impl WidgetRef for &ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let popup_height = match &self.active_popup { + ActivePopup::Command(popup) => popup.calculate_required_height(), + ActivePopup::File(popup) => popup.calculate_required_height(), + ActivePopup::None => 1, + }; + let [textarea_rect, popup_rect] = + Layout::vertical([Constraint::Min(0), Constraint::Max(popup_height)]).areas(area); match &self.active_popup { ActivePopup::Command(popup) => { - let popup_height = popup.calculate_required_height(); - - // Split the provided rect so that the popup is rendered at the - // **bottom** and the textarea occupies the remaining space above. - let popup_height = popup_height.min(area.height); - let textarea_rect = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height.saturating_sub(popup_height), - }; - let popup_rect = Rect { - x: area.x, - y: area.y + textarea_rect.height, - width: area.width, - height: popup_height, - }; - - popup.render(popup_rect, buf); - self.textarea.render(textarea_rect, buf); + popup.render_ref(popup_rect, buf); } ActivePopup::File(popup) => { - let popup_height = popup.calculate_required_height(); - - let popup_height = popup_height.min(area.height); - let textarea_rect = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height.saturating_sub(popup_height), - }; - let popup_rect = Rect { - x: area.x, - y: area.y + textarea_rect.height, - width: area.width, - height: popup_height, - }; - - popup.render(popup_rect, buf); - self.textarea.render(textarea_rect, buf); + popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let mut textarea_rect = area; - textarea_rect.height = textarea_rect.height.saturating_sub(1); - self.textarea.render(textarea_rect, buf); - let mut bottom_line_rect = area; - bottom_line_rect.y += textarea_rect.height; - bottom_line_rect.height = 1; + let bottom_line_rect = popup_rect; let key_hint_style = Style::default().fg(Color::Cyan); let hint = if self.ctrl_c_quit_hint { vec![ @@ -740,6 +674,56 @@ impl WidgetRef for &ChatComposer<'_> { .render_ref(bottom_line_rect, buf); } } + Block::default() + .border_style(Style::default().dim()) + .borders(Borders::LEFT) + .border_type(BorderType::QuadrantOutside) + .border_style(Style::default().fg(if self.has_focus { + Color::Cyan + } else { + Color::Gray + })) + .render_ref( + Rect::new(textarea_rect.x, textarea_rect.y, 1, textarea_rect.height), + buf, + ); + let mut textarea_rect = textarea_rect; + textarea_rect.width = textarea_rect.width.saturating_sub(1); + textarea_rect.x += 1; + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + if self.textarea.text().is_empty() { + let placeholder = if let Some(token_usage_info) = &self.token_usage_info { + let token_usage = &token_usage_info.token_usage; + let model_context_window = token_usage_info.model_context_window; + match (token_usage.total_tokens, model_context_window) { + (total_tokens, Some(context_window)) => { + let percent_remaining: u8 = if context_window > 0 { + // Calculate the percentage of context left. + let percent = + 100.0 - (total_tokens as f32 / context_window as f32 * 100.0); + percent.clamp(0.0, 100.0) as u8 + } else { + // If we don't have a context window, we cannot compute the + // percentage. + 100 + }; + // When https://github.com/openai/codex/issues/1257 is resolved, + // check if `percent_remaining < 25`, and if so, recommend + // /compact. + format!("{BASE_PLACEHOLDER_TEXT} — {percent_remaining}% context left") + } + (total_tokens, None) => { + format!("{BASE_PLACEHOLDER_TEXT} — {total_tokens} tokens used") + } + } + } else { + BASE_PLACEHOLDER_TEXT.to_string() + }; + Line::from(placeholder) + .style(Style::default().dim()) + .render_ref(textarea_rect.inner(Margin::new(1, 0)), buf); + } } } @@ -749,7 +733,7 @@ mod tests { use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use tui_textarea::TextArea; + use crate::bottom_pane::textarea::TextArea; #[test] fn test_current_at_token_basic_cases() { @@ -792,9 +776,9 @@ mod tests { ]; for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::default(); + let mut textarea = TextArea::new(); textarea.insert_str(input); - textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); + textarea.set_cursor(cursor_pos); let result = ChatComposer::current_at_token(&textarea); assert_eq!( @@ -826,9 +810,9 @@ mod tests { ]; for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::default(); + let mut textarea = TextArea::new(); textarea.insert_str(input); - textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); + textarea.set_cursor(cursor_pos); let result = ChatComposer::current_at_token(&textarea); assert_eq!( @@ -863,13 +847,13 @@ mod tests { // Full-width space boundaries ( "test @İstanbul", - 6, + 8, Some("İstanbul".to_string()), "@ token after full-width space", ), ( "@ЙЦУ @诶", - 6, + 10, Some("诶".to_string()), "Full-width space between Unicode tokens", ), @@ -883,9 +867,9 @@ mod tests { ]; for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::default(); + let mut textarea = TextArea::new(); textarea.insert_str(input); - textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_pos)); + textarea.set_cursor(cursor_pos); let result = ChatComposer::current_at_token(&textarea); assert_eq!( @@ -907,7 +891,7 @@ mod tests { let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); - assert_eq!(composer.textarea.lines(), ["hello"]); + assert_eq!(composer.textarea.text(), "hello"); assert!(composer.pending_pastes.is_empty()); let (result, _) = @@ -932,7 +916,7 @@ mod tests { let needs_redraw = composer.handle_paste(large.clone()); assert!(needs_redraw); let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.lines(), [placeholder.as_str()]); + assert_eq!(composer.textarea.text(), placeholder); assert_eq!(composer.pending_pastes.len(), 1); assert_eq!(composer.pending_pastes[0].0, placeholder); assert_eq!(composer.pending_pastes[0].1, large); @@ -1008,7 +992,7 @@ mod tests { composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); // Move cursor to end and press backspace - composer.textarea.move_cursor(tui_textarea::CursorMove::End); + composer.textarea.set_cursor(composer.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); } @@ -1123,7 +1107,7 @@ mod tests { current_pos += content.len(); } ( - composer.textarea.lines().join("\n"), + composer.textarea.text().to_string(), composer.pending_pastes.len(), current_pos, ) @@ -1134,25 +1118,18 @@ mod tests { let mut deletion_states = vec![]; // First deletion - composer - .textarea - .move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16)); + composer.textarea.set_cursor(states[0].2); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.lines().join("\n"), + composer.textarea.text().to_string(), composer.pending_pastes.len(), )); // Second deletion - composer - .textarea - .move_cursor(tui_textarea::CursorMove::Jump( - 0, - composer.textarea.lines().join("\n").len() as u16, - )); + composer.textarea.set_cursor(composer.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.lines().join("\n"), + composer.textarea.text().to_string(), composer.pending_pastes.len(), )); @@ -1191,17 +1168,13 @@ mod tests { composer.handle_paste(paste.clone()); composer .textarea - .move_cursor(tui_textarea::CursorMove::Jump( - 0, - (placeholder.len() - pos_from_end) as u16, - )); + .set_cursor((placeholder.len() - pos_from_end) as usize); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( - composer.textarea.lines().join("\n").contains(&placeholder), + composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.select_all(); - composer.textarea.cut(); + composer.textarea.set_text(""); result }) .collect(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 5715c994..a744a409 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -1,8 +1,5 @@ use std::collections::HashMap; -use tui_textarea::CursorMove; -use tui_textarea::TextArea; - use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_core::protocol::Op; @@ -67,59 +64,52 @@ impl ChatComposerHistory { /// Should Up/Down key presses be interpreted as history navigation given /// the current content and cursor position of `textarea`? - pub fn should_handle_navigation(&self, textarea: &TextArea) -> bool { + pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { if self.history_entry_count == 0 && self.local_history.is_empty() { return false; } - if textarea.is_empty() { + if text.is_empty() { return true; } // Textarea is not empty – only navigate when cursor is at start and // text matches last recalled history entry so regular editing is not // hijacked. - let (row, col) = textarea.cursor(); - if row != 0 || col != 0 { + if cursor != 0 { return false; } - let lines = textarea.lines(); - matches!(&self.last_history_text, Some(prev) if prev == &lines.join("\n")) + matches!(&self.last_history_text, Some(prev) if prev == text) } /// Handle . Returns true when the key was consumed and the caller /// should request a redraw. - pub fn navigate_up(&mut self, textarea: &mut TextArea, app_event_tx: &AppEventSender) -> bool { + pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { - return false; + return None; } let next_idx = match self.history_cursor { None => (total_entries as isize) - 1, - Some(0) => return true, // already at oldest + Some(0) => return None, // already at oldest Some(idx) => idx - 1, }; self.history_cursor = Some(next_idx); - self.populate_history_at_index(next_idx as usize, textarea, app_event_tx); - true + self.populate_history_at_index(next_idx as usize, app_event_tx) } /// Handle . - pub fn navigate_down( - &mut self, - textarea: &mut TextArea, - app_event_tx: &AppEventSender, - ) -> bool { + pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { - return false; + return None; } let next_idx_opt = match self.history_cursor { - None => return false, // not browsing + None => return None, // not browsing Some(idx) if (idx as usize) + 1 >= total_entries => None, Some(idx) => Some(idx + 1), }; @@ -127,16 +117,15 @@ impl ChatComposerHistory { match next_idx_opt { Some(idx) => { self.history_cursor = Some(idx); - self.populate_history_at_index(idx as usize, textarea, app_event_tx); + self.populate_history_at_index(idx as usize, app_event_tx) } None => { // Past newest – clear and exit browsing mode. self.history_cursor = None; self.last_history_text = None; - self.replace_textarea_content(textarea, ""); + Some(String::new()) } } - true } /// Integrate a GetHistoryEntryResponse event. @@ -145,19 +134,18 @@ impl ChatComposerHistory { log_id: u64, offset: usize, entry: Option, - textarea: &mut TextArea, - ) -> bool { + ) -> Option { if self.history_log_id != Some(log_id) { - return false; + return None; } - let Some(text) = entry else { return false }; + let text = entry?; self.fetched_history.insert(offset, text.clone()); if self.history_cursor == Some(offset as isize) { - self.replace_textarea_content(textarea, &text); - return true; + self.last_history_text = Some(text.clone()); + return Some(text); } - false + None } // --------------------------------------------------------------------- @@ -167,21 +155,20 @@ impl ChatComposerHistory { fn populate_history_at_index( &mut self, global_idx: usize, - textarea: &mut TextArea, app_event_tx: &AppEventSender, - ) { + ) -> Option { if global_idx >= self.history_entry_count { // Local entry. if let Some(text) = self .local_history .get(global_idx - self.history_entry_count) { - let t = text.clone(); - self.replace_textarea_content(textarea, &t); + self.last_history_text = Some(text.clone()); + return Some(text.clone()); } } else if let Some(text) = self.fetched_history.get(&global_idx) { - let t = text.clone(); - self.replace_textarea_content(textarea, &t); + self.last_history_text = Some(text.clone()); + return Some(text.clone()); } else if let Some(log_id) = self.history_log_id { let op = Op::GetHistoryEntryRequest { offset: global_idx, @@ -189,14 +176,7 @@ impl ChatComposerHistory { }; app_event_tx.send(AppEvent::CodexOp(op)); } - } - - fn replace_textarea_content(&mut self, textarea: &mut TextArea, text: &str) { - textarea.select_all(); - textarea.cut(); - let _ = textarea.insert_str(text); - textarea.move_cursor(CursorMove::Jump(0, 0)); - self.last_history_text = Some(text.to_string()); + None } } @@ -217,11 +197,9 @@ mod tests { // Pretend there are 3 persistent entries. history.set_metadata(1, 3); - let mut textarea = TextArea::default(); - // First Up should request offset 2 (latest) and await async data. - assert!(history.should_handle_navigation(&textarea)); - assert!(history.navigate_up(&mut textarea, &tx)); + assert!(history.should_handle_navigation("", 0)); + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); @@ -235,14 +213,15 @@ mod tests { }, history_request1 ); - assert_eq!(textarea.lines().join("\n"), ""); // still empty // Inject the async response. - assert!(history.on_entry_response(1, 2, Some("latest".into()), &mut textarea)); - assert_eq!(textarea.lines().join("\n"), "latest"); + assert_eq!( + Some("latest".into()), + history.on_entry_response(1, 2, Some("latest".into())) + ); // Next Up should move to offset 1. - assert!(history.navigate_up(&mut textarea, &tx)); + assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet // Verify second CodexOp event for offset 1. let event2 = rx.try_recv().expect("expected second event"); @@ -257,7 +236,9 @@ mod tests { history_request_2 ); - history.on_entry_response(1, 1, Some("older".into()), &mut textarea); - assert_eq!(textarea.lines().join("\n"), "older"); + assert_eq!( + Some("older".into()), + history.on_entry_response(1, 1, Some("older".into())) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 281f0859..cab78bbe 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -19,6 +19,7 @@ mod chat_composer_history; mod command_popup; mod file_search_popup; mod status_indicator_view; +mod textarea; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { @@ -36,7 +37,7 @@ use status_indicator_view::StatusIndicatorView; pub(crate) struct BottomPane<'a> { /// Composer is retained even when a BottomPaneView is displayed so the /// input state is retained when the view is closed. - composer: ChatComposer<'a>, + composer: ChatComposer, /// If present, this is displayed instead of the `composer`. active_view: Option + 'a>>, @@ -74,7 +75,19 @@ impl BottomPane<'_> { self.active_view .as_ref() .map(|v| v.desired_height(width)) - .unwrap_or(self.composer.desired_height()) + .unwrap_or(self.composer.desired_height(width)) + } + + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + // Hide the cursor whenever an overlay view is active (e.g. the + // status indicator shown while a task is running, or approval modal). + // In these states the textarea is not interactable, so we should not + // show its caret. + if self.active_view.is_some() { + None + } else { + self.composer.cursor_pos(area) + } } /// Forward a key event to the active view or the composer. diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs new file mode 100644 index 00000000..e150135b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -0,0 +1,1294 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; +use std::cell::Ref; +use std::cell::RefCell; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug)] +pub(crate) struct TextArea { + text: String, + cursor_pos: usize, + wrap_cache: RefCell>, + preferred_col: Option, +} + +#[derive(Debug, Clone)] +struct WrapCache { + width: u16, + lines: Vec>, +} + +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct TextAreaState { + /// Index into wrapped lines of the first visible line. + scroll: u16, +} + +impl TextArea { + pub fn new() -> Self { + Self { + text: String::new(), + cursor_pos: 0, + wrap_cache: RefCell::new(None), + preferred_col: None, + } + } + + pub fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + self.wrap_cache.replace(None); + self.preferred_col = None; + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn insert_str(&mut self, text: &str) { + self.insert_str_at(self.cursor_pos, text); + } + + pub fn insert_str_at(&mut self, pos: usize, text: &str) { + self.text.insert_str(pos, text); + self.wrap_cache.replace(None); + if pos <= self.cursor_pos { + self.cursor_pos += text.len(); + } + self.preferred_col = None; + } + + pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { + assert!(range.start <= range.end); + let start = range.start.clamp(0, self.text.len()); + let end = range.end.clamp(0, self.text.len()); + let removed_len = end - start; + let inserted_len = text.len(); + if removed_len == 0 && inserted_len == 0 { + return; + } + let diff = inserted_len as isize - removed_len as isize; + + self.text.replace_range(range, text); + self.wrap_cache.replace(None); + self.preferred_col = None; + + // Update the cursor position to account for the edit. + self.cursor_pos = if self.cursor_pos < start { + // Cursor was before the edited range – no shift. + self.cursor_pos + } else if self.cursor_pos <= end { + // Cursor was inside the replaced range – move to end of the new text. + start + inserted_len + } else { + // Cursor was after the replaced range – shift by the length diff. + ((self.cursor_pos as isize) + diff) as usize + } + .min(self.text.len()); + } + + pub fn cursor(&self) -> usize { + self.cursor_pos + } + + pub fn set_cursor(&mut self, pos: usize) { + self.cursor_pos = pos.clamp(0, self.text.len()); + self.preferred_col = None; + } + + pub fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len() as u16 + } + + #[allow(dead_code)] + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_with_state(area, &TextAreaState::default()) + } + + /// Compute the on-screen cursor position taking scrolling into account. + pub fn cursor_pos_with_state(&self, area: Rect, state: &TextAreaState) -> Option<(u16, u16)> { + let lines = self.wrapped_lines(area.width); + let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); + let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; + let ls = &lines[i]; + let col = self.text[ls.start..self.cursor_pos].width() as u16; + let screen_row = i + .saturating_sub(effective_scroll as usize) + .try_into() + .unwrap_or(0); + Some((area.x + col, area.y + screen_row)) + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + fn current_display_col(&self) -> usize { + let bol = self.beginning_of_current_line(); + self.text[bol..self.cursor_pos].width() + } + + fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { + // partition_point returns the index of the first element for which + // the predicate is false, i.e. the count of elements with start <= pos. + let idx = lines.partition_point(|r| r.start <= pos); + if idx == 0 { None } else { Some(idx - 1) } + } + + fn move_to_display_col_on_line( + &mut self, + line_start: usize, + line_end: usize, + target_col: usize, + ) { + let mut width_so_far = 0usize; + for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { + width_so_far += g.width(); + if width_so_far > target_col { + self.cursor_pos = line_start + i; + return; + } + } + self.cursor_pos = line_end; + } + + fn beginning_of_line(&self, pos: usize) -> usize { + self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) + } + fn beginning_of_current_line(&self) -> usize { + self.beginning_of_line(self.cursor_pos) + } + + fn end_of_line(&self, pos: usize) -> usize { + self.text[pos..] + .find('\n') + .map(|i| i + pos) + .unwrap_or(self.text.len()) + } + fn end_of_current_line(&self) -> usize { + self.end_of_line(self.cursor_pos) + } + + pub(crate) fn beginning_of_previous_word(&self) -> usize { + if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace()) + { + self.text[..first_non_ws] + .rfind(|c: char| c.is_whitespace()) + .map(|i| i + 1) + .unwrap_or(0) + } else { + 0 + } + } + + pub(crate) fn end_of_next_word(&self) -> usize { + let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) + else { + return self.text.len(); + }; + let word_start = self.cursor_pos + first_non_ws; + match self.text[word_start..].find(|c: char| c.is_whitespace()) { + Some(rel_idx) => word_start + rel_idx, + None => self.text.len(), + } + } + + pub fn input(&mut self, event: KeyEvent) { + match event { + KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT, + .. + } => self.insert_str(&c.to_string()), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Enter, + .. + } => self.insert_str("\n"), + KeyEvent { + code: KeyCode::Backspace, + .. + } => self.delete_backward(1), + KeyEvent { + code: KeyCode::Delete, + .. + } => self.delete_forward(1), + + KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.delete_backward_word(); + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_beginning_of_line(); + } + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.kill_to_end_of_line(); + } + + // Cursor movement + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_left(); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_cursor_right(); + } + KeyEvent { + code: KeyCode::Up, .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Down, + .. + } => { + self.move_cursor_down(); + } + KeyEvent { + code: KeyCode::Home, + .. + } => { + self.move_cursor_to_beginning_of_line(false); + } + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_beginning_of_line(true); + } + + KeyEvent { + code: KeyCode::End, .. + } => { + self.move_cursor_to_end_of_line(false); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_to_end_of_line(true); + } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.beginning_of_previous_word()); + } + KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.set_cursor(self.end_of_next_word()); + } + o => { + tracing::debug!("Unhandled key event in TextArea: {:?}", o); + } + } + } + + // ####### Input Functions ####### + pub fn delete_backward(&mut self, n: usize) { + if n == 0 || self.cursor_pos == 0 { + return; + } + let mut gc = + unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false); + let mut target = self.cursor_pos; + for _ in 0..n { + match gc.prev_boundary(&self.text, 0) { + Ok(Some(b)) => target = b, + Ok(None) => { + target = 0; + break; + } + Err(_) => { + target = target.saturating_sub(1); + } + } + } + self.replace_range(target..self.cursor_pos, ""); + } + + pub fn delete_forward(&mut self, n: usize) { + if n == 0 || self.cursor_pos >= self.text.len() { + return; + } + let mut gc = + unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false); + let mut target = self.cursor_pos; + for _ in 0..n { + match gc.next_boundary(&self.text, 0) { + Ok(Some(b)) => target = b, + Ok(None) => { + target = self.text.len(); + break; + } + Err(_) => { + target = target.saturating_add(1); + } + } + } + self.replace_range(self.cursor_pos..target, ""); + } + + pub fn delete_backward_word(&mut self) { + self.replace_range(self.beginning_of_previous_word()..self.cursor_pos, ""); + } + + pub fn kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + if self.cursor_pos == eol { + if eol < self.text.len() { + self.replace_range(self.cursor_pos..eol + 1, ""); + } + } else { + self.replace_range(self.cursor_pos..eol, ""); + } + } + + pub fn kill_to_beginning_of_line(&mut self) { + let bol = self.beginning_of_current_line(); + if self.cursor_pos == bol { + if bol > 0 { + self.replace_range(bol - 1..bol, ""); + } + } else { + self.replace_range(bol..self.cursor_pos, ""); + } + } + + /// Move the cursor left by a single grapheme cluster. + pub fn move_cursor_left(&mut self) { + let mut gc = + unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false); + match gc.prev_boundary(&self.text, 0) { + Ok(Some(boundary)) => self.cursor_pos = boundary, + Ok(None) => self.cursor_pos = 0, // Already at start. + Err(_) => self.cursor_pos = self.cursor_pos.saturating_sub(1), + } + self.preferred_col = None; + } + + /// Move the cursor right by a single grapheme cluster. + pub fn move_cursor_right(&mut self) { + let mut gc = + unicode_segmentation::GraphemeCursor::new(self.cursor_pos, self.text.len(), false); + match gc.next_boundary(&self.text, 0) { + Ok(Some(boundary)) => self.cursor_pos = boundary, + Ok(None) => self.cursor_pos = self.text.len(), // Already at end. + Err(_) => self.cursor_pos = self.cursor_pos.saturating_add(1), + } + self.preferred_col = None; + } + + pub fn move_cursor_up(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, maybe_line)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx > 0 { + let prev = &lines[idx - 1]; + let line_start = prev.start; + let line_end = prev.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + // We had wrapping info. Apply movement accordingly. + match maybe_line { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already at first visual line -> move to start + self.cursor_pos = 0; + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0); + let prev_line_end = prev_nl; + self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); + } else { + self.cursor_pos = 0; + self.preferred_col = None; + } + } + + pub fn move_cursor_down(&mut self) { + // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. + if let Some((target_col, move_to_last)) = { + let cache_ref = self.wrap_cache.borrow(); + if let Some(cache) = cache_ref.as_ref() { + let lines = &cache.lines; + if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { + let cur_range = &lines[idx]; + let target_col = self + .preferred_col + .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); + if idx + 1 < lines.len() { + let next = &lines[idx + 1]; + let line_start = next.start; + let line_end = next.end.saturating_sub(1); + Some((target_col, Some((line_start, line_end)))) + } else { + Some((target_col, None)) + } + } else { + None + } + } else { + None + } + } { + match move_to_last { + Some((line_start, line_end)) => { + if self.preferred_col.is_none() { + self.preferred_col = Some(target_col); + } + self.move_to_display_col_on_line(line_start, line_end, target_col); + return; + } + None => { + // Already on last visual line -> move to end + self.cursor_pos = self.text.len(); + self.preferred_col = None; + return; + } + } + } + + // Fallback to logical line navigation if we don't have wrapping info yet. + let target_col = match self.preferred_col { + Some(c) => c, + None => { + let c = self.current_display_col(); + self.preferred_col = Some(c); + c + } + }; + if let Some(next_nl) = self.text[self.cursor_pos..] + .find('\n') + .map(|i| i + self.cursor_pos) + { + let next_line_start = next_nl + 1; + let next_line_end = self.text[next_line_start..] + .find('\n') + .map(|i| i + next_line_start) + .unwrap_or(self.text.len()); + self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); + } else { + self.cursor_pos = self.text.len(); + self.preferred_col = None; + } + } + + pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { + let bol = self.beginning_of_current_line(); + if move_up_at_bol && self.cursor_pos == bol { + self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); + } else { + self.set_cursor(bol); + } + self.preferred_col = None; + } + + pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { + let eol = self.end_of_current_line(); + if move_down_at_eol && self.cursor_pos == eol { + let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); + self.set_cursor(self.end_of_line(next_pos)); + } else { + self.set_cursor(eol); + } + } + + #[allow(clippy::unwrap_used)] + fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { + // Ensure cache is ready (potentially mutably borrow, then drop) + { + let mut cache = self.wrap_cache.borrow_mut(); + let needs_recalc = match cache.as_ref() { + Some(c) => c.width != width, + None => true, + }; + if needs_recalc { + let mut lines: Vec> = Vec::new(); + for line in textwrap::wrap( + &self.text, + Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + .iter() + { + match line { + std::borrow::Cow::Borrowed(slice) => { + let start = + unsafe { slice.as_ptr().offset_from(self.text.as_ptr()) as usize }; + let end = start + slice.len(); + let trailing_spaces = + self.text[end..].chars().take_while(|c| *c == ' ').count(); + lines.push(start..end + trailing_spaces + 1); + } + std::borrow::Cow::Owned(_) => unreachable!(), + } + } + *cache = Some(WrapCache { width, lines }); + } + } + + let cache = self.wrap_cache.borrow(); + Ref::map(cache, |c| &c.as_ref().unwrap().lines) + } + + /// Calculate the scroll offset that should be used to satisfy the + /// invariants given the current area size and wrapped lines. + /// + /// - Cursor is always on screen. + /// - No scrolling if content fits in the area. + fn effective_scroll( + &self, + area_height: u16, + lines: &[Range], + current_scroll: u16, + ) -> u16 { + let total_lines = lines.len() as u16; + if area_height >= total_lines { + return 0; + } + + // Where is the cursor within wrapped lines? Prefer assigning boundary positions + // (where pos equals the start of a wrapped line) to that later line. + let cursor_line_idx = + Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; + + let max_scroll = total_lines.saturating_sub(area_height); + let mut scroll = current_scroll.min(max_scroll); + + // Ensure cursor is visible within [scroll, scroll + area_height) + if cursor_line_idx < scroll { + scroll = cursor_line_idx; + } else if cursor_line_idx >= scroll + area_height { + scroll = cursor_line_idx + 1 - area_height; + } + scroll + } +} + +impl WidgetRef for &TextArea { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + for (i, ls) in lines.iter().enumerate() { + let s = &self.text[ls.start..ls.end - 1]; + buf.set_string(area.x, area.y + i as u16, s, Style::default()); + } + } +} + +impl StatefulWidgetRef for &TextArea { + type State = TextAreaState; + + fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + for (row, ls) in (start..end).enumerate() { + let r = &lines[ls]; + let s = &self.text[r.start..r.end - 1]; + buf.set_string(area.x, area.y + row as u16, s, Style::default()); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + // crossterm types are intentionally not imported here to avoid unused warnings + use rand::prelude::*; + + fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { + let r: u8 = rng.gen_range(0..100); + match r { + 0..=4 => "\n".to_string(), + 5..=12 => " ".to_string(), + 13..=35 => (rng.gen_range(b'a'..=b'z') as char).to_string(), + 36..=45 => (rng.gen_range(b'A'..=b'Z') as char).to_string(), + 46..=52 => (rng.gen_range(b'0'..=b'9') as char).to_string(), + 53..=65 => { + // Some emoji (wide graphemes) + let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"]; + choices[rng.gen_range(0..choices.len())].to_string() + } + 66..=75 => { + // CJK wide characters + let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"]; + choices[rng.gen_range(0..choices.len())].to_string() + } + 76..=85 => { + // Combining mark sequences + let base = ["e", "a", "o", "n", "u"][rng.gen_range(0..5)]; + let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"]; + format!("{}{}", base, marks[rng.gen_range(0..marks.len())]) + } + 86..=92 => { + // Some non-latin single codepoints (Greek, Cyrillic, Hebrew) + let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"]; + choices[rng.gen_range(0..choices.len())].to_string() + } + _ => { + // ZWJ sequences (single graphemes but multi-codepoint) + let choices = [ + "👩\u{200D}💻", // woman technologist + "👨\u{200D}💻", // man technologist + "🏳️\u{200D}🌈", // rainbow flag + ]; + choices[rng.gen_range(0..choices.len())].to_string() + } + } + } + + fn ta_with(text: &str) -> TextArea { + let mut t = TextArea::new(); + t.insert_str(text); + t + } + + #[test] + fn insert_and_replace_update_cursor_and_text() { + // insert helpers + let mut t = ta_with("hello"); + t.set_cursor(5); + t.insert_str("!"); + assert_eq!(t.text(), "hello!"); + assert_eq!(t.cursor(), 6); + + t.insert_str_at(0, "X"); + assert_eq!(t.text(), "Xhello!"); + assert_eq!(t.cursor(), 7); + + // Insert after the cursor should not move it + t.set_cursor(1); + let end = t.text().len(); + t.insert_str_at(end, "Y"); + assert_eq!(t.text(), "Xhello!Y"); + assert_eq!(t.cursor(), 1); + + // replace_range cases + // 1) cursor before range + let mut t = ta_with("abcd"); + t.set_cursor(1); + t.replace_range(2..3, "Z"); + assert_eq!(t.text(), "abZd"); + assert_eq!(t.cursor(), 1); + + // 2) cursor inside range + let mut t = ta_with("abcd"); + t.set_cursor(2); + t.replace_range(1..3, "Q"); + assert_eq!(t.text(), "aQd"); + assert_eq!(t.cursor(), 2); + + // 3) cursor after range with shifted by diff + let mut t = ta_with("abcd"); + t.set_cursor(4); + t.replace_range(0..1, "AA"); + assert_eq!(t.text(), "AAbcd"); + assert_eq!(t.cursor(), 5); + } + + #[test] + fn delete_backward_and_forward_edges() { + let mut t = ta_with("abc"); + t.set_cursor(1); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // deleting backward at start is a no-op + t.set_cursor(0); + t.delete_backward(1); + assert_eq!(t.text(), "bc"); + assert_eq!(t.cursor(), 0); + + // forward delete removes next grapheme + t.set_cursor(1); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + assert_eq!(t.cursor(), 1); + + // forward delete at end is a no-op + t.set_cursor(t.text().len()); + t.delete_forward(1); + assert_eq!(t.text(), "b"); + } + + #[test] + fn delete_backward_word_and_kill_line_variants() { + // delete backward word at end removes the whole previous word + let mut t = ta_with("hello world "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 8); + + // From inside a word, delete from word start to cursor + let mut t = ta_with("foo bar"); + t.set_cursor(6); // inside "bar" (after 'a') + t.delete_backward_word(); + assert_eq!(t.text(), "foo r"); + assert_eq!(t.cursor(), 4); + + // From end, delete the last word only + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + + // kill_to_end_of_line when not at EOL + let mut t = ta_with("abc\ndef"); + t.set_cursor(1); // on first line, middle + t.kill_to_end_of_line(); + assert_eq!(t.text(), "a\ndef"); + assert_eq!(t.cursor(), 1); + + // kill_to_end_of_line when at EOL deletes newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(3); // EOL of first line + t.kill_to_end_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + + // kill_to_beginning_of_line from middle of line + let mut t = ta_with("abc\ndef"); + t.set_cursor(5); // on second line, after 'e' + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abc\nef"); + + // kill_to_beginning_of_line at beginning of non-first line removes the previous newline + let mut t = ta_with("abc\ndef"); + t.set_cursor(4); // beginning of second line + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), "abcdef"); + assert_eq!(t.cursor(), 3); + } + + #[test] + fn cursor_left_and_right_handle_graphemes() { + let mut t = ta_with("a👍b"); + t.set_cursor(t.text().len()); + + t.move_cursor_left(); // before 'b' + let after_first_left = t.cursor(); + t.move_cursor_left(); // before '👍' + let after_second_left = t.cursor(); + t.move_cursor_left(); // before 'a' + let after_third_left = t.cursor(); + + assert!(after_first_left < t.text().len()); + assert!(after_second_left < after_first_left); + assert!(after_third_left < after_second_left); + + // Move right back to end safely + t.move_cursor_right(); + t.move_cursor_right(); + t.move_cursor_right(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn cursor_vertical_movement_across_lines_and_bounds() { + let mut t = ta_with("short\nloooooooooong\nmid"); + // Place cursor on second line, column 5 + let second_line_start = 6; // after first '\n' + t.set_cursor(second_line_start + 5); + + // Move up: target column preserved, clamped by line length + t.move_cursor_up(); + assert_eq!(t.cursor(), 5); // first line has len 5 + + // Move up again goes to start of text + t.move_cursor_up(); + assert_eq!(t.cursor(), 0); + + // Move down: from start to target col tracked + t.move_cursor_down(); + // On first move down, we should land on second line, at col 0 (target col remembered as 0) + let pos_after_down = t.cursor(); + assert!(pos_after_down >= second_line_start); + + // Move down again to third line; clamp to its length + t.move_cursor_down(); + let third_line_start = t.text().find("mid").unwrap(); + let third_line_end = third_line_start + 3; + assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end); + + // Moving down at last line jumps to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn home_end_and_emacs_style_home_end() { + let mut t = ta_with("one\ntwo\nthree"); + // Position at middle of second line + let second_line_start = t.text().find("two").unwrap(); + t.set_cursor(second_line_start + 1); + + t.move_cursor_to_beginning_of_line(false); + assert_eq!(t.cursor(), second_line_start); + + // Ctrl-A behavior: if at BOL, go to beginning of previous line + t.move_cursor_to_beginning_of_line(true); + assert_eq!(t.cursor(), 0); // beginning of first line + + // Move to EOL of first line + t.move_cursor_to_end_of_line(false); + assert_eq!(t.cursor(), 3); + + // Ctrl-E: if at EOL, go to end of next line + t.move_cursor_to_end_of_line(true); + // end of second line ("two") is right before its '\n' + let end_second_nl = t.text().find("\nthree").unwrap(); + assert_eq!(t.cursor(), end_second_nl); + } + + #[test] + fn end_of_line_or_down_at_end_of_text() { + let mut t = ta_with("one\ntwo"); + // Place cursor at absolute end of the text + t.set_cursor(t.text().len()); + // Should remain at end without panicking + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); + + // Also verify behavior when at EOL of a non-final line: + let eol_first_line = 3; // index of '\n' in "one\ntwo" + t.set_cursor(eol_first_line); + t.move_cursor_to_end_of_line(true); + assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line + } + + #[test] + fn word_navigation_helpers() { + let t = ta_with(" alpha beta gamma"); + let mut t = t; // make mutable for set_cursor + // Put cursor after "alpha" + let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); + t.set_cursor(after_alpha); + assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces + + // Put cursor at start of beta + let beta_start = t.text().find("beta").unwrap(); + t.set_cursor(beta_start); + assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); + + // If at end, end_of_next_word returns len + t.set_cursor(t.text().len()); + assert_eq!(t.end_of_next_word(), t.text().len()); + } + + #[test] + fn wrapping_and_cursor_positions() { + let mut t = ta_with("hello world here"); + let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words + // desired height counts wrapped lines + assert!(t.desired_height(area.width) >= 3); + + // Place cursor in "world" + let world_start = t.text().find("world").unwrap(); + t.set_cursor(world_start + 3); + let (_x, y) = t.cursor_pos(area).unwrap(); + assert_eq!(y, 1); // world should be on second wrapped line + + // With state and small height, cursor is mapped onto visible row + let mut state = TextAreaState::default(); + let small_area = Rect::new(0, 0, 6, 1); + // First call: cursor not visible -> effective scroll ensures it is + let (_x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + assert_eq!(y, 0); + + // Render with state to update actual scroll value + let mut buf = Buffer::empty(small_area); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state); + // After render, state.scroll should be adjusted so cursor row fits + let effective_lines = t.desired_height(small_area.width); + assert!(state.scroll < effective_lines); + } + + #[test] + fn cursor_pos_with_state_basic_and_scroll_behaviors() { + // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. + let mut t = ta_with("hello world"); + t.set_cursor(3); + let area = Rect::new(2, 5, 20, 3); + // Even if an absurd scroll is provided, when content fits the area the + // effective scroll is 0 and the cursor position matches cursor_pos. + let bad_state = TextAreaState { scroll: 999 }; + let (x1, y1) = t.cursor_pos(area).unwrap(); + let (x2, y2) = t.cursor_pos_with_state(area, &bad_state).unwrap(); + assert_eq!((x2, y2), (x1, y1)); + + // Case 2: Cursor below the current window — y should be clamped to the + // bottom row (area.height - 1) after adjusting effective scroll. + let mut t = ta_with("one two three four five six"); + // Force wrapping to many visual lines. + let wrap_width = 4; + let _ = t.desired_height(wrap_width); + // Put cursor somewhere near the end so it's definitely below the first window. + t.set_cursor(t.text().len().saturating_sub(2)); + let small_area = Rect::new(0, 0, wrap_width, 2); + let state = TextAreaState { scroll: 0 }; + let (_x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + assert_eq!(y, small_area.y + small_area.height - 1); + + // Case 3: Cursor above the current window — y should be top row (0) + // when the provided scroll is too large. + let mut t = ta_with("alpha beta gamma delta epsilon zeta"); + let wrap_width = 5; + let lines = t.desired_height(wrap_width); + // Place cursor near start so an excessive scroll moves it to top row. + t.set_cursor(1); + let area = Rect::new(0, 0, wrap_width, 3); + let state = TextAreaState { + scroll: lines.saturating_mul(2), + }; + let (_x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!(y, area.y); + } + + #[test] + fn wrapped_navigation_across_visual_lines() { + let mut t = ta_with("abcdefghij"); + // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"] + let _ = t.desired_height(4); + + // From the very start, moving down should go to the start of the next wrapped line (index 4) + t.set_cursor(0); + t.move_cursor_down(); + assert_eq!(t.cursor(), 4); + + // Cursor at boundary index 4 should be displayed at start of second wrapped line + t.set_cursor(4); + let area = Rect::new(0, 0, 4, 10); + let (x, y) = t.cursor_pos(area).unwrap(); + assert_eq!((x, y), (0, 1)); + + // With state and small height, cursor should be visible at row 0, col 0 + let small_area = Rect::new(0, 0, 4, 1); + let state = TextAreaState::default(); + let (x, y) = t.cursor_pos_with_state(small_area, &state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' + t.set_cursor(6); + // Move up should go to same column on previous wrapped line -> index 2 ('c') + t.move_cursor_up(); + assert_eq!(t.cursor(), 2); + + // Move down should return to same position on the next wrapped line -> back to index 6 ('g') + t.move_cursor_down(); + assert_eq!(t.cursor(), 6); + + // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end + t.move_cursor_down(); + assert_eq!(t.cursor(), t.text().len()); + } + + #[test] + fn cursor_pos_with_state_after_movements() { + let mut t = ta_with("abcdefghij"); + // Wrap width 4 -> visual lines: abcd | efgh | ij + let _ = t.desired_height(4); + let area = Rect::new(0, 0, 4, 2); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(area); + + // Start at beginning + t.set_cursor(0); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x, y), (0, 1)); + + // Move down to third visual line; viewport scrolls and keeps cursor on bottom row + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x, y), (0, 1)); + + // Move up to second visual line; with current scroll, it appears on top row + t.move_cursor_up(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x, y) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x, y), (0, 0)); + + // Column preservation across moves: set to col 2 on first line, move down + t.set_cursor(2); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x0, y0) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x0, y0), (2, 0)); + t.move_cursor_down(); + ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); + let (x1, y1) = t.cursor_pos_with_state(area, &state).unwrap(); + assert_eq!((x1, y1), (2, 1)); + } + + #[test] + fn wrapped_navigation_with_newlines_and_spaces() { + // Include spaces and an explicit newline to exercise boundaries + let mut t = ta_with("word1 word2\nword3"); + // Width 6 will wrap "word1 " and then "word2" before the newline + let _ = t.desired_height(6); + + // Put cursor on the second wrapped line before the newline, at column 1 of "word2" + let start_word2 = t.text().find("word2").unwrap(); + t.set_cursor(start_word2 + 1); + + // Up should go to first wrapped line, column 1 -> index 1 + t.move_cursor_up(); + assert_eq!(t.cursor(), 1); + + // Down should return to the same visual column on "word2" + t.move_cursor_down(); + assert_eq!(t.cursor(), start_word2 + 1); + + // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed + t.move_cursor_down(); + let start_word3 = t.text().find("word3").unwrap(); + assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); + } + + #[test] + fn wrapped_navigation_with_wide_graphemes() { + // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries + let mut t = ta_with("👍👍👍👍"); + let _ = t.desired_height(3); + + // Put cursor after the second emoji (which should be on first wrapped line) + t.set_cursor("👍👍".len()); + + // Move down should go to the start of the next wrapped line (same column preserved but clamped) + t.move_cursor_down(); + // We expect to land somewhere within the third emoji or at the start of it + let pos_after_down = t.cursor(); + assert!(pos_after_down >= "👍👍".len()); + + // Moving up should take us back to the original position + t.move_cursor_up(); + assert_eq!(t.cursor(), "👍👍".len()); + } + + #[test] + fn fuzz_textarea_randomized() { + // Deterministic seed for reproducibility + // Seed the RNG based on the current day in Pacific Time (PST/PDT). This + // keeps the fuzz test deterministic within a day while still varying + // day-to-day to improve coverage. + #[allow(clippy::unwrap_used)] + let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8)) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp() as u64; + let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed); + + for _case in 0..10_000 { + let mut ta = TextArea::new(); + let mut state = TextAreaState::default(); + // Start with a random base string + let base_len = rng.gen_range(0..30); + let mut base = String::new(); + for _ in 0..base_len { + base.push_str(&rand_grapheme(&mut rng)); + } + ta.set_text(&base); + // Choose a valid char boundary for initial cursor + let mut boundaries: Vec = vec![0]; + boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + boundaries.push(ta.text().len()); + let init = boundaries[rng.gen_range(0..boundaries.len())]; + ta.set_cursor(init); + + let mut width: u16 = rng.gen_range(1..=12); + let mut height: u16 = rng.gen_range(1..=4); + + for _step in 0..200 { + // Mostly stable width/height, occasionally change + if rng.gen_bool(0.1) { + width = rng.gen_range(1..=12); + } + if rng.gen_bool(0.1) { + height = rng.gen_range(1..=4); + } + + // Pick an operation + match rng.gen_range(0..14) { + 0 => { + // insert small random string at cursor + let len = rng.gen_range(0..6); + let mut s = String::new(); + for _ in 0..len { + s.push_str(&rand_grapheme(&mut rng)); + } + ta.insert_str(&s); + } + 1 => { + // replace_range with small random slice + let mut b: Vec = vec![0]; + b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); + b.push(ta.text().len()); + let i1 = rng.gen_range(0..b.len()); + let i2 = rng.gen_range(0..b.len()); + let (start, end) = if b[i1] <= b[i2] { + (b[i1], b[i2]) + } else { + (b[i2], b[i1]) + }; + let insert_len = rng.gen_range(0..=4); + let mut s = String::new(); + for _ in 0..insert_len { + s.push_str(&rand_grapheme(&mut rng)); + } + let before = ta.text().len(); + ta.replace_range(start..end, &s); + let after = ta.text().len(); + assert_eq!( + after as isize, + before as isize + (s.len() as isize) - ((end - start) as isize) + ); + } + 2 => ta.delete_backward(rng.gen_range(0..=3)), + 3 => ta.delete_forward(rng.gen_range(0..=3)), + 4 => ta.delete_backward_word(), + 5 => ta.kill_to_beginning_of_line(), + 6 => ta.kill_to_end_of_line(), + 7 => ta.move_cursor_left(), + 8 => ta.move_cursor_right(), + 9 => ta.move_cursor_up(), + 10 => ta.move_cursor_down(), + 11 => ta.move_cursor_to_beginning_of_line(true), + 12 => ta.move_cursor_to_end_of_line(true), + _ => { + // Jump to word boundaries + if rng.gen_bool(0.5) { + let p = ta.beginning_of_previous_word(); + ta.set_cursor(p); + } else { + let p = ta.end_of_next_word(); + ta.set_cursor(p); + } + } + } + + // Sanity invariants + assert!(ta.cursor() <= ta.text().len()); + + // Render and compute cursor positions; ensure they are in-bounds and do not panic + let area = Rect::new(0, 0, width, height); + // Stateless render into an area tall enough for all wrapped lines + let total_lines = ta.desired_height(width); + let full_area = Rect::new(0, 0, width, total_lines.max(1)); + let mut buf = Buffer::empty(full_area); + ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); + + // cursor_pos: x must be within width when present + let _ = ta.cursor_pos(area); + + // cursor_pos_with_state: always within viewport rows + let (_x, _y) = ta + .cursor_pos_with_state(area, &state) + .unwrap_or((area.x, area.y)); + + // Stateful render should not panic, and updates scroll + let mut sbuf = Buffer::empty(area); + ratatui::widgets::StatefulWidgetRef::render_ref( + &(&ta), + area, + &mut sbuf, + &mut state, + ); + + // After wrapping, desired height equals the number of lines we would render without scroll + let total_lines = total_lines as usize; + // state.scroll must not exceed total_lines when content fits within area height + if (height as usize) >= total_lines { + assert_eq!(state.scroll, 0); + } + } + } + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 374a4b56..31151d56 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -509,6 +509,10 @@ impl ChatWidget<'_> { self.bottom_pane .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } + + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.bottom_pane.cursor_pos(area) + } } impl WidgetRef for &ChatWidget<'_> {