From f2c2c02a2227c09701a7cc9ccee9a4c4e3b4b690 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 19 Mar 2026 01:09:55 -0400 Subject: [PATCH] tui: fix cursor position with proper word-wrap simulation The previous approach scanned ratatui's rendered buffer to find the cursor position, but couldn't distinguish padding spaces from text spaces, causing incorrect cursor placement on wrapped lines. Replace with a word_wrap_breaks() function that computes soft line break positions by simulating ratatui's Wrap { trim: false } algorithm (break at word boundaries, fall back to character wrap for long words). cursor_visual_pos() then maps a character index to (col, row) using those break positions. Also fixes the input area height calculation to use word-wrap semantics instead of character-wrap, matching the actual Paragraph rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- poc-agent/src/tui.rs | 132 +++++++++++++++++++++++++++---------------- 1 file changed, 83 insertions(+), 49 deletions(-) diff --git a/poc-agent/src/tui.rs b/poc-agent/src/tui.rs index 88b095f..a6b3d6f 100644 --- a/poc-agent/src/tui.rs +++ b/poc-agent/src/tui.rs @@ -9,7 +9,7 @@ // Uses ratatui + crossterm. The App struct holds all TUI state and // handles rendering. Input is processed from crossterm key events. -use unicode_width::UnicodeWidthStr; +use unicode_width::UnicodeWidthChar; use crossterm::{ event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton}, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, @@ -17,7 +17,6 @@ use crossterm::{ }; use ratatui::{ backend::CrosstermBackend, - buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -268,6 +267,79 @@ fn wrapped_height_line(line: &Line<'_>, width: usize) -> usize { ((w + width - 1) / width).max(1) } +/// Compute soft line break positions for word-wrapped text. +/// Returns the character index where each soft line starts. +/// Matches ratatui Wrap { trim: false } — breaks at word boundaries. +fn word_wrap_breaks(text: &str, width: usize) -> Vec { + let mut breaks = vec![0usize]; + + if width == 0 { + return breaks; + } + + let chars: Vec = text.chars().collect(); + let mut col = 0usize; + let mut last_space: Option = None; + + for (i, &ch) in chars.iter().enumerate() { + if ch == '\n' { + breaks.push(i + 1); + col = 0; + last_space = None; + continue; + } + + let cw = UnicodeWidthChar::width(ch).unwrap_or(0); + + if col + cw > width && col > 0 { + if let Some(sp) = last_space { + breaks.push(sp); + col = 0; + last_space = None; + for j in sp..i { + col += UnicodeWidthChar::width(chars[j]).unwrap_or(0); + if chars[j] == ' ' { + last_space = Some(j + 1); + } + } + } else { + breaks.push(i); + col = 0; + } + } + + if ch == ' ' { + last_space = Some(i + 1); + } + col += cw; + } + + breaks +} + +/// Compute visual (col, row) for a character position in word-wrapped text. +fn cursor_visual_pos(text: &str, char_pos: usize, width: u16) -> (u16, u16) { + let breaks = word_wrap_breaks(text, width as usize); + let chars: Vec = text.chars().collect(); + + for r in 0..breaks.len() { + let start = breaks[r]; + let end = breaks.get(r + 1).copied().unwrap_or(chars.len()); + + if char_pos < end || r == breaks.len() - 1 { + let mut col = 0u16; + for j in start..char_pos.min(end) { + if chars[j] != '\n' { + col += UnicodeWidthChar::width(chars[j]).unwrap_or(0) as u16; + } + } + return (col, r as u16); + } + } + + (0, 0) +} + /// Parse markdown text into owned ratatui Lines. fn parse_markdown(md: &str) -> Vec> { tui_markdown::from_str(md) @@ -772,14 +844,11 @@ impl App { // Draw conversation pane (with input line) let conv_active = self.active_pane == ActivePane::Conversation; - // Calculate input height: account for both newlines and wrapping + // Calculate input height using word wrap (matches ratatui Wrap behavior) let prompt = "you> "; let full_input = format!("{}{}", prompt, &self.input); let input_width = conv_area.width as usize; - let input_height = full_input - .split('\n') - .map(|line| wrapped_height(line, input_width)) - .sum::() + let input_height = word_wrap_breaks(&full_input, input_width).len() .max(1) .min(5) as u16; @@ -818,48 +887,13 @@ impl App { let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false }); frame.render_widget(input_para, input_area); - // Cursor position: scan the rendered buffer to find where the cursor should be. - // This matches ratatui's actual word wrapping instead of trying to simulate it. - let buffer = frame.buffer_mut(); - - // Convert byte index to character index for the input portion - let input_chars_before_cursor = self.input[..self.cursor].chars().count(); - let cursor_char_pos = prompt.chars().count() + input_chars_before_cursor; - - let mut char_count = 0usize; - let mut cursor_x = input_area.x; - let mut cursor_y = input_area.y; - let mut found = false; - - // Walk through the rendered buffer, counting characters until we reach the cursor position - for y in input_area.y..input_area.y + input_area.height { - for x in input_area.x..input_area.x + input_area.width { - if let Some(cell) = buffer.cell((x, y)) { - let symbol = cell.symbol(); - // Count visible characters (skip zero-width and empty) - if !symbol.is_empty() { - if char_count == cursor_char_pos { - // Found the cursor position - this is where the next char would go - cursor_x = x; - cursor_y = y; - found = true; - break; - } - char_count += 1; - } else if char_count == cursor_char_pos { - // Empty cell but we've reached the cursor position - cursor_x = x; - cursor_y = y; - found = true; - break; - } - } - } - if found { - break; - } - } - + // Cursor position: simulate word wrap to find visual (col, row) + let cursor_char_pos = prompt.chars().count() + + self.input[..self.cursor].chars().count(); + let (cx, cy) = cursor_visual_pos(&full_input, cursor_char_pos, input_area.width); + let cursor_x = cx + input_area.x; + let cursor_y = cy + input_area.y; + if cursor_y < input_area.y + input_area.height { frame.set_cursor_position((cursor_x, cursor_y)); }