tui: fix cursor desync by scanning rendered buffer

Instead of simulating ratatui's word wrapping algorithm, scan the
rendered buffer to find the actual cursor position. This correctly
handles word wrapping, unicode widths, and any other rendering
nuances that ratatui applies.

The old code computed wrapped_height() and cursor position based on
simple character counting, which diverged from ratatui's WordWrapper
that respects word boundaries.

Now we render first, then walk the buffer counting visible characters
until we reach self.cursor. This is O(area) but the input area is
small (typically < 200 cells), so it's negligible.
This commit is contained in:
ProofOfConcept 2026-03-19 00:40:05 -04:00
parent 5308c8e3a4
commit ec79d60fbd
4 changed files with 52 additions and 133 deletions

View file

@ -32,3 +32,4 @@ figment = { version = "0.10", features = ["env"] }
json5 = "0.4"
clap = { version = "4", features = ["derive"] }
tui-markdown = "0.3"
unicode-width = "0.2.2"

View file

@ -9,6 +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 crossterm::{
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
@ -16,6 +17,7 @@ use crossterm::{
};
use ratatui::{
backend::CrosstermBackend,
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
@ -816,21 +818,36 @@ impl App {
let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false });
frame.render_widget(input_para, input_area);
// Cursor position: walk through text up to cursor, tracking visual row/col
let cursor_text = format!("{}{}", prompt, &self.input[..self.cursor]);
let w = input_area.width as usize;
let cursor_lines: Vec<&str> = cursor_text.split('\n').collect();
let n = cursor_lines.len();
let mut visual_row = 0u16;
for line in &cursor_lines[..n - 1] {
visual_row += wrapped_height(line, w) as u16;
// 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();
let mut char_count = 0usize;
let mut cursor_x = input_area.x;
let mut cursor_y = input_area.y;
// 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() {
let width = symbol.width();
if char_count + width > self.cursor {
// Found the cursor position
cursor_x = x;
cursor_y = y;
break;
}
char_count += width;
}
}
}
if cursor_x != input_area.x || cursor_y != input_area.y {
break; // Found it
}
}
// Use unicode display width to match ratatui's wrapping
let last_width = ratatui::text::Line::raw(cursor_lines[n - 1]).width();
let col = if w > 0 { last_width % w } else { last_width };
visual_row += if w > 0 { (last_width / w) as u16 } else { 0 };
let cursor_x = col as u16 + input_area.x;
let cursor_y = visual_row + input_area.y;
if cursor_y < input_area.y + input_area.height {
frame.set_cursor_position((cursor_x, cursor_y));
}