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:
parent
2e3943b89f
commit
f2c2c02a22
1 changed files with 83 additions and 49 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
// Uses ratatui + crossterm. The App struct holds all TUI state and
|
// Uses ratatui + crossterm. The App struct holds all TUI state and
|
||||||
// handles rendering. Input is processed from crossterm key events.
|
// handles rendering. Input is processed from crossterm key events.
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthChar;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
event::{EnableMouseCapture, DisableMouseCapture, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, MouseButton},
|
||||||
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
|
@ -17,7 +17,6 @@ use crossterm::{
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
backend::CrosstermBackend,
|
backend::CrosstermBackend,
|
||||||
buffer::Buffer,
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
|
|
@ -268,6 +267,79 @@ fn wrapped_height_line(line: &Line<'_>, width: usize) -> usize {
|
||||||
((w + width - 1) / width).max(1)
|
((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.
|
/// Parse markdown text into owned ratatui Lines.
|
||||||
fn parse_markdown(md: &str) -> Vec<Line<'static>> {
|
fn parse_markdown(md: &str) -> Vec<Line<'static>> {
|
||||||
tui_markdown::from_str(md)
|
tui_markdown::from_str(md)
|
||||||
|
|
@ -772,14 +844,11 @@ impl App {
|
||||||
// Draw conversation pane (with input line)
|
// Draw conversation pane (with input line)
|
||||||
let conv_active = self.active_pane == ActivePane::Conversation;
|
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 prompt = "you> ";
|
||||||
let full_input = format!("{}{}", prompt, &self.input);
|
let full_input = format!("{}{}", prompt, &self.input);
|
||||||
let input_width = conv_area.width as usize;
|
let input_width = conv_area.width as usize;
|
||||||
let input_height = full_input
|
let input_height = word_wrap_breaks(&full_input, input_width).len()
|
||||||
.split('\n')
|
|
||||||
.map(|line| wrapped_height(line, input_width))
|
|
||||||
.sum::<usize>()
|
|
||||||
.max(1)
|
.max(1)
|
||||||
.min(5) as u16;
|
.min(5) as u16;
|
||||||
|
|
||||||
|
|
@ -818,48 +887,13 @@ impl App {
|
||||||
let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false });
|
let input_para = Paragraph::new(input_lines).wrap(Wrap { trim: false });
|
||||||
frame.render_widget(input_para, input_area);
|
frame.render_widget(input_para, input_area);
|
||||||
|
|
||||||
// Cursor position: scan the rendered buffer to find where the cursor should be.
|
// Cursor position: simulate word wrap to find visual (col, row)
|
||||||
// This matches ratatui's actual word wrapping instead of trying to simulate it.
|
let cursor_char_pos = prompt.chars().count()
|
||||||
let buffer = frame.buffer_mut();
|
+ self.input[..self.cursor].chars().count();
|
||||||
|
let (cx, cy) = cursor_visual_pos(&full_input, cursor_char_pos, input_area.width);
|
||||||
// Convert byte index to character index for the input portion
|
let cursor_x = cx + input_area.x;
|
||||||
let input_chars_before_cursor = self.input[..self.cursor].chars().count();
|
let cursor_y = cy + input_area.y;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cursor_y < input_area.y + input_area.height {
|
if cursor_y < input_area.y + input_area.height {
|
||||||
frame.set_cursor_position((cursor_x, cursor_y));
|
frame.set_cursor_position((cursor_x, cursor_y));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue