Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/bottom_pane/textarea.rs
expression: "format!(\"cursor: {cursor:?}\\n{}\", terminal.backend())"
---
cursor: (62, 0)
"❌ Simulation formatter[large/dataset.py] 7.4 ms 8.1 ms -8.29% " Hidden by multi-width symbols: [(1, " ")]
69 changes: 65 additions & 4 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use std::borrow::Cow;
use std::cell::Ref;
use std::cell::RefCell;
use std::ops::Range;
Expand Down Expand Up @@ -75,6 +76,18 @@ fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
pieces
}

/// Replace tabs with the one-column representation used for rendering and wrapping.
///
/// A tab and a space are both one byte, so ranges computed from this text still index the original
/// editable text.
fn text_for_display(text: &str) -> Cow<'_, str> {
if text.contains('\t') {
Cow::Owned(text.replace('\t', " "))
} else {
Cow::Borrowed(text)
}
}

#[derive(Debug, Clone)]
struct TextElement {
id: u64,
Expand Down Expand Up @@ -1844,8 +1857,9 @@ impl TextArea {
None => true,
};
if needs_recalc {
let display_text = text_for_display(&self.text);
let lines = crate::wrapping::wrap_ranges(
&self.text,
display_text.as_ref(),
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
*cache = Some(WrapCache { width, lines });
Expand Down Expand Up @@ -1964,7 +1978,12 @@ impl TextArea {
let line_range = r.start..r.end - 1;
buf.set_style(Rect::new(area.x, y, area.width, 1), base_style);
// Draw base line with the provided style.
buf.set_string(area.x, y, &self.text[line_range.clone()], base_style);
buf.set_string(
area.x,
y,
text_for_display(&self.text[line_range.clone()]),
base_style,
);

// Overlay styled segments for elements that intersect this line.
for elem in &self.elements {
Expand All @@ -1977,7 +1996,7 @@ impl TextArea {
let styled = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
let style = base_style.fg(Color::Cyan);
buf.set_string(area.x + x_off, y, styled, style);
buf.set_string(area.x + x_off, y, text_for_display(styled), style);
}

// Overlay render-only highlight ranges last so transient search highlighting remains
Expand All @@ -1990,7 +2009,7 @@ impl TextArea {
}
let highlighted = &self.text[overlap_start..overlap_end];
let x_off = self.text[line_range.start..overlap_start].width() as u16;
buf.set_string(area.x + x_off, y, highlighted, *style);
buf.set_string(area.x + x_off, y, text_for_display(highlighted), *style);
}
}
}
Expand Down Expand Up @@ -3457,6 +3476,48 @@ mod tests {
);
}

#[test]
fn tabs_render_as_spaces_and_align_with_cursor_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;

let text = "❌\tSimulation\tformatter[large/dataset.py]\t7.4 ms\t8.1 ms\t-8.29%";
let mut t = ta_with(text);
t.set_cursor(text.len());

let mut terminal = Terminal::new(TestBackend::new(/*width*/ 100, /*height*/ 1)).unwrap();
terminal
.draw(|frame| {
ratatui::widgets::WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut());
})
.unwrap();

let cursor = t.cursor_pos(terminal.backend().buffer().area).unwrap();
assert_eq!(
terminal.backend().buffer()[(cursor.0 - 1, cursor.1)].symbol(),
"%"
);
insta::assert_snapshot!(
"textarea_tabs_render_as_spaces_and_align_with_cursor",
format!("cursor: {cursor:?}\n{}", terminal.backend())
);
}

#[test]
fn tabs_wrap_at_their_rendered_width() {
let text = "1234\t5";
let mut t = ta_with(text);
t.set_cursor(text.len());
let area = Rect::new(0, 0, /*width*/ 5, /*height*/ 2);
let mut buf = Buffer::empty(area);

ratatui::widgets::WidgetRef::render_ref(&(&t), area, &mut buf);

assert_eq!(t.desired_height(area.width), 2);
assert_eq!(t.cursor_pos(area), Some((1, 1)));
assert_eq!(buf[(0, 1)].symbol(), "5");
}

#[test]
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
Expand Down
Loading