mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Fix TUI layout for halfwidth Japanese sound marks (#35962)
## What changed - Use Ratatui-compatible terminal widths throughout TUI layout, preserving full-width counters for long lines. - Wrap and truncate on grapheme boundaries so halfwidth kana sound marks and emoji sequences stay intact. - Apply the corrected sizing to text input, selection popups, status and history cells, Markdown, hyperlinks, and scrollable cloud diffs. ## Testing - Add focused unit and snapshot coverage for wrapping, truncation, cursor and mask alignment, popup columns, status output, session headers, Markdown tables, process output, and scrollable diffs. GitOrigin-RevId: 294ec0bcff743c6bb16d55ec26308524ce741879
This commit is contained in:
1 parent
cef3910ea4
commit
3725f02cf3
38 files changed
+1335
-245
No files matched your search
@@ -17,6 +17,7 @@ use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::VimNormalKeymap;
|
||||
use crate::keymap::VimOperatorKeymap;
|
||||
use crate::keymap::VimTextObjectKeymap;
|
||||
use crate::width::display_width;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -35,7 +36,6 @@ use std::cell::RefCell;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
mod vim;
|
||||
use self::vim::VimMode;
|
||||
@@ -430,7 +430,7 @@ impl TextArea {
|
||||
let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll);
|
||||
let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?;
|
||||
let ls = &lines[i];
|
||||
let col = self.text[ls.start..self.cursor_pos].width() as u16;
|
||||
let col = display_width(&self.text[ls.start..self.cursor_pos]) as u16;
|
||||
let screen_row = i
|
||||
.saturating_sub(effective_scroll as usize)
|
||||
.try_into()
|
||||
@@ -444,7 +444,7 @@ impl TextArea {
|
||||
|
||||
fn current_display_col(&self) -> usize {
|
||||
let bol = self.beginning_of_current_line();
|
||||
self.text[bol..self.cursor_pos].width()
|
||||
display_width(&self.text[bol..self.cursor_pos])
|
||||
}
|
||||
|
||||
fn wrapped_line_index_by_start(lines: &[Range<usize>], pos: usize) -> Option<usize> {
|
||||
@@ -462,7 +462,7 @@ impl TextArea {
|
||||
) {
|
||||
let mut width_so_far = 0usize;
|
||||
for (i, g) in self.text[line_start..line_end].grapheme_indices(true) {
|
||||
width_so_far += g.width();
|
||||
width_so_far += display_width(g);
|
||||
if width_so_far > target_col {
|
||||
self.cursor_pos = line_start + i;
|
||||
// Avoid landing inside an element; round to nearest boundary
|
||||
@@ -1212,9 +1212,9 @@ impl TextArea {
|
||||
let lines = &cache.lines;
|
||||
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
|
||||
let cur_range = &lines[idx];
|
||||
let target_col = self
|
||||
.preferred_col
|
||||
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
display_width(&self.text[cur_range.start..self.cursor_pos])
|
||||
});
|
||||
if idx > 0 {
|
||||
let prev = &lines[idx - 1];
|
||||
let line_start = prev.start;
|
||||
@@ -1275,9 +1275,9 @@ impl TextArea {
|
||||
let lines = &cache.lines;
|
||||
if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) {
|
||||
let cur_range = &lines[idx];
|
||||
let target_col = self
|
||||
.preferred_col
|
||||
.unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width());
|
||||
let target_col = self.preferred_col.unwrap_or_else(|| {
|
||||
display_width(&self.text[cur_range.start..self.cursor_pos])
|
||||
});
|
||||
if idx + 1 < lines.len() {
|
||||
let next = &lines[idx + 1];
|
||||
let line_start = next.start;
|
||||
@@ -1994,7 +1994,7 @@ impl TextArea {
|
||||
continue;
|
||||
}
|
||||
let styled = &self.text[overlap_start..overlap_end];
|
||||
let x_off = self.text[line_range.start..overlap_start].width() as u16;
|
||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
||||
let style = base_style.fg(Color::Cyan);
|
||||
buf.set_string(area.x + x_off, y, text_for_display(styled), style);
|
||||
}
|
||||
@@ -2008,7 +2008,7 @@ impl TextArea {
|
||||
continue;
|
||||
}
|
||||
let highlighted = &self.text[overlap_start..overlap_end];
|
||||
let x_off = self.text[line_range.start..overlap_start].width() as u16;
|
||||
let x_off = display_width(&self.text[line_range.start..overlap_start]) as u16;
|
||||
buf.set_string(area.x + x_off, y, text_for_display(highlighted), *style);
|
||||
}
|
||||
}
|
||||
@@ -2027,8 +2027,8 @@ impl TextArea {
|
||||
let y = area.y + row as u16;
|
||||
let line_range = r.start..r.end - 1;
|
||||
let masked = self.text[line_range.clone()]
|
||||
.chars()
|
||||
.map(|_| mask_char)
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.flat_map(|grapheme| std::iter::repeat_n(mask_char, display_width(grapheme)))
|
||||
.collect::<String>();
|
||||
buf.set_string(area.x, y, &masked, Style::default());
|
||||
}
|
||||
@@ -3518,6 +3518,90 @@ mod tests {
|
||||
assert_eq!(buf[(0, 1)].symbol(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halfwidth_sound_marks_wrap_and_align_with_the_cursor() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let mut snapshots = Vec::new();
|
||||
for (label, text, width, cursor, cells) in [
|
||||
("dakuten", "12ガx", 4, (1, 1), [(2, 0, "ガ"), (0, 1, "x")]),
|
||||
("handakuten", "abパc", 3, (3, 1), [(0, 1, "パ"), (2, 1, "c")]),
|
||||
("standalone", "a ゙b", 2, (2, 1), [(0, 1, "゙"), (1, 1, "b")]),
|
||||
] {
|
||||
let mut t = ta_with(text);
|
||||
t.set_cursor(text.len());
|
||||
let area = Rect::new(0, 0, width, /*height*/ 2);
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
ratatui::widgets::WidgetRef::render_ref(
|
||||
&(&t),
|
||||
frame.area(),
|
||||
frame.buffer_mut(),
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 2);
|
||||
assert_eq!(t.cursor_pos(area), Some(cursor));
|
||||
for (x, y, expected) in cells {
|
||||
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), expected);
|
||||
}
|
||||
snapshots.push(format!(
|
||||
"{label}\ncursor: {:?}\n{}",
|
||||
t.cursor_pos(area),
|
||||
terminal.backend()
|
||||
));
|
||||
}
|
||||
|
||||
insta::assert_snapshot!(
|
||||
"textarea_halfwidth_sound_marks_wrap_and_align_with_cursor",
|
||||
snapshots.join("\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_graphemes_align_with_the_cursor() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let text = "界゙a";
|
||||
let mut t = ta_with(text);
|
||||
t.set_cursor(text.len());
|
||||
let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
|
||||
let mut state = TextAreaState::default();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
t.render_ref_masked(frame.area(), frame.buffer_mut(), &mut state, '*');
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t.cursor_pos(area), Some((4, 0)));
|
||||
insta::assert_snapshot!(
|
||||
"textarea_masked_graphemes_align_with_cursor",
|
||||
format!("cursor: {:?}\n{}", t.cursor_pos(area), terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwide_halfwidth_sound_marks_do_not_add_a_phantom_cursor_row() {
|
||||
let area = Rect::new(0, 0, /*width*/ 2, /*height*/ 2);
|
||||
|
||||
for grapheme in ["ガ゙", "界゙"] {
|
||||
let text = format!("{grapheme}ab");
|
||||
let mut t = ta_with(&text);
|
||||
t.set_cursor(text.len());
|
||||
|
||||
assert_eq!(t.desired_height(area.width), 2);
|
||||
assert_eq!(t.cursor_pos(area), Some((2, 1)));
|
||||
|
||||
t.set_cursor(grapheme.len());
|
||||
assert_eq!(t.cursor_pos(area), Some((0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
|
||||
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
|
||||
|
||||
Reference in new issue
Block a user