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) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-19 01:09:55 -04:00
parent 2e3943b89f
commit f2c2c02a22

View file

@ -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<usize> {
let mut breaks = vec![0usize];
if width == 0 {
return breaks;
}
let chars: Vec<char> = text.chars().collect();
let mut col = 0usize;
let mut last_space: Option<usize> = 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<char> = 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<Line<'static>> {
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::<usize>()
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));
}