diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index b04e6b9964..39eb5f1b08 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -48,6 +48,8 @@ use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::widgets::WidgetRef; +mod cursor; + fn osc8_hyperlink_parts(symbol: &str) -> Option<(&str, &str)> { let content = symbol.strip_prefix("\x1b]8;;")?; let destination_end = content.find('\x07')?; @@ -428,7 +430,7 @@ where match cursor_position { None => self.hide_cursor()?, Some(position) => { - self.set_cursor_style(cursor_style)?; + self.set_cursor_style_with_repair(cursor_style)?; self.set_cursor_position(position)?; self.show_cursor()?; } @@ -835,7 +837,9 @@ impl ModifierDiff { } } +// Keep nested #[path] modules discoverable by cargo-shear as well as rustc. #[cfg(test)] +#[path = "custom_terminal/tests"] mod tests { use super::*; use std::num::NonZeroU16; @@ -852,6 +856,9 @@ mod tests { use ratatui::widgets::Widget; use ratatui::widgets::Wrap; + #[path = "cursor_tests.rs"] + mod cursor; + struct CaptureBackend { output: Vec, size: Size, @@ -1232,31 +1239,6 @@ mod tests { ); } - #[test] - fn terminal_draw_applies_requested_cursor_style() { - let mut output = Vec::new(); - let mut terminal = - Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1)) - .expect("terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 2, 1)); - - terminal - .try_draw(|frame| { - frame.set_cursor_style(SetCursorStyle::SteadyBar); - frame.set_cursor_position((0, 0)); - io::Result::Ok(()) - }) - .expect("draw"); - - queue!(output, SetCursorStyle::SteadyBar).expect("queue style"); - let expected = String::from_utf8(output).expect("utf8"); - let actual = terminal.backend().output(); - assert!( - actual.contains(&expected), - "expected terminal output to contain cursor style {expected:?}, got {actual:?}" - ); - } - #[test] fn terminal_draw_moves_cursor_before_showing_it() { let cursor_position = Position { x: 1, y: 0 }; diff --git a/codex-rs/tui/src/custom_terminal/cursor.rs b/codex-rs/tui/src/custom_terminal/cursor.rs new file mode 100644 index 0000000000..34a99d3e4b --- /dev/null +++ b/codex-rs/tui/src/custom_terminal/cursor.rs @@ -0,0 +1,61 @@ +//! Repair the glyph beneath cursor-style commands on older JediTerm terminals. +//! +//! This helper issues cursor-style commands only at an owned, non-skipped glyph leader +//! with positive width that fits within its row, then redraws it with the same style and +//! hyperlink. Without a safe anchor, it omits the command. The caller must restore the +//! requested cursor position afterward. + +use std::io; +use std::io::Write; + +use crossterm::cursor::SetCursorStyle; +use ratatui::backend::Backend; +use ratatui::buffer::CellDiffOption; +use ratatui::buffer::CellWidth; +use ratatui::layout::Position; + +use super::DrawCommand; +use super::Terminal; +use super::draw; + +impl Terminal +where + B: Backend + Write, +{ + pub(super) fn set_cursor_style_with_repair( + &mut self, + cursor_style: SetCursorStyle, + ) -> io::Result<()> { + // JediTerm before 3.56 prints DECSCUSR's space intermediate at the cursor. + // Apply the style over an owned glyph, then repair it even on unchanged frames. + // https://github.com/JetBrains/jediterm/commit/0c4524f2978bddae65a46c35f264bf89e2ed58fd + let buffer = &self.buffers[self.current]; + let anchor = (0..buffer.area.height).find_map(|row| { + let row_start = usize::from(row) * usize::from(buffer.area.width); + let mut column = 0; + while column < usize::from(buffer.area.width) { + let cell = &buffer.content[row_start + column]; + let width = usize::from(cell.cell_width()); + let is_skip = cell.diff_option == CellDiffOption::Skip; + if !is_skip && width > 0 && column + width <= usize::from(buffer.area.width) { + let (x, y) = buffer.pos_of(row_start + column); + return Some((Position { x, y }, cell.clone())); + } + column += width.max(1); + } + None + }); + // Empty and externally owned viewports have no cell we can safely repair. + if let Some((anchor, cell)) = anchor { + self.set_cursor_position(anchor)?; + self.set_cursor_style(cursor_style)?; + let Position { x, y } = anchor; + draw( + &mut self.backend, + std::iter::once(DrawCommand::Put { x, y, cell }), + )?; + } + + Ok(()) + } +} diff --git a/codex-rs/tui/src/custom_terminal/tests/cursor_tests.rs b/codex-rs/tui/src/custom_terminal/tests/cursor_tests.rs new file mode 100644 index 0000000000..1d56c2a5e5 --- /dev/null +++ b/codex-rs/tui/src/custom_terminal/tests/cursor_tests.rs @@ -0,0 +1,144 @@ +use super::*; +use crate::terminal_palette::rgb_color; +use pretty_assertions::assert_eq; + +#[test] +fn terminal_draw_repairs_styled_anchor_on_cursor_only_frames() { + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 12, /*height*/ 2)).expect("terminal"); + let area = Rect::new( + /*x*/ 0, /*y*/ 1, /*width*/ 12, /*height*/ 1, + ); + terminal.set_viewport_area(area); + let mut frames = Vec::new(); + let mut parser = + vt100::Parser::new(/*rows*/ 2, /*cols*/ 12, /*scrollback_len*/ 0); + + for (x, style) in [ + (1, SetCursorStyle::DefaultUserShape), + (4, SetCursorStyle::DefaultUserShape), + (3, SetCursorStyle::SteadyBar), + (1, SetCursorStyle::SteadyBlock), + ] { + terminal.backend_mut().output.clear(); + terminal + .draw(|frame| { + Paragraph::new("ab cd ef") + .style(Style::default().bg(rgb_color((80, 80, 80))).bold()) + .render(area, frame.buffer_mut()); + frame.set_cursor_style(style); + frame.set_cursor_position((x, 1)); + }) + .expect("draw"); + + parser.process(&terminal.backend().output); + assert_eq!(parser.screen().contents(), "\nab cd ef "); + assert_eq!(parser.screen().cursor_position(), (1, x)); + for column in 0..area.width { + let cell = parser.screen().cell(1, column).expect("viewport cell"); + assert_eq!(cell.bgcolor(), vt100::Color::Rgb(80, 80, 80)); + assert!( + cell.bold(), + "lost anchor or trailing-cell modifier at {column}" + ); + } + frames.push(terminal.backend().output().escape_debug().to_string()); + } + assert_snapshot!("cursor_style_styled_frames", frames.join("\n")); +} + +#[test] +fn terminal_draw_repairs_owned_wide_hyperlink_after_skipped_glyphs() { + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 8, /*height*/ 1)).expect("terminal"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 1, + ); + terminal.set_viewport_area(area); + let mut frames = Vec::new(); + for _ in 0..2 { + terminal.backend_mut().output.clear(); + terminal + .draw(|frame| { + let buffer = frame.buffer_mut(); + buffer.set_string(0, 0, "中x界", Style::default().bg(Color::Blue)); + buffer[(0, 0)].diff_option = CellDiffOption::Skip; + buffer[(2, 0)].diff_option = CellDiffOption::Skip; + buffer[(3, 0)].set_symbol("\x1b]8;;https://example.com\x07界\x1b]8;;\x07"); + buffer[(3, 0)].diff_option = + CellDiffOption::ForcedWidth(NonZeroU16::new(/*n*/ 2).expect("wide glyph")); + frame.set_cursor_position((5, 0)); + }) + .expect("draw"); + frames.push(terminal.backend().output().escape_debug().to_string()); + } + assert_snapshot!("cursor_style_owned_wide_frames", frames.join("\n")); +} + +#[test] +fn terminal_draw_repairs_single_column_without_scrolling() { + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 1, /*height*/ 1)).expect("terminal"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1, + ); + terminal.set_viewport_area(area); + let mut frames = Vec::new(); + let mut parser = + vt100::Parser::new(/*rows*/ 1, /*cols*/ 1, /*scrollback_len*/ 1); + for _ in 0..3 { + terminal.backend_mut().output.clear(); + terminal + .draw(|frame| { + Paragraph::new("x") + .style(Style::default().bg(Color::Blue)) + .render(area, frame.buffer_mut()); + frame.set_cursor_position((0, 0)); + }) + .expect("draw"); + parser.process(&terminal.backend().output); + assert_eq!(parser.screen().contents(), "x"); + assert_eq!(parser.screen().cursor_position(), (0, 0)); + frames.push(terminal.backend().output().escape_debug().to_string()); + } + parser.screen_mut().set_scrollback(/*rows*/ 1); + assert_eq!(parser.screen().scrollback(), 0); + assert_snapshot!("cursor_style_single_column_frames", frames.join("\n")); +} + +#[test] +fn terminal_draw_omits_cursor_style_without_an_owned_glyph() { + let mut terminal = + Terminal::with_options(CaptureBackend::new(/*width*/ 2, /*height*/ 1)).expect("terminal"); + for width in [0, 2] { + terminal.set_viewport_area(Rect::new( + /*x*/ 0, /*y*/ 0, width, /*height*/ 1, + )); + for buffer in &mut terminal.buffers { + for cell in &mut buffer.content { + cell.diff_option = CellDiffOption::Skip; + } + } + terminal.backend_mut().output.clear(); + terminal + .draw(|frame| { + for cell in &mut frame.buffer_mut().content { + cell.diff_option = CellDiffOption::Skip; + } + frame.set_cursor_style(SetCursorStyle::SteadyBar); + frame.set_cursor_position((1, 0)); + }) + .expect("draw"); + assert_eq!( + terminal.backend().output(), + "\x1b[39m\x1b[49m\x1b[0m\x1b[1;2H\x1b[?25h" + ); + } + terminal.set_viewport_area(Rect::default()); + terminal.backend_mut().output.clear(); + terminal.draw(|_| {}).expect("hide cursor"); + assert_eq!( + terminal.backend().output(), + "\x1b[39m\x1b[49m\x1b[0m\x1b[?25l" + ); +} diff --git a/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_owned_wide_frames.snap b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_owned_wide_frames.snap new file mode 100644 index 0000000000..f7a668b9c2 --- /dev/null +++ b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_owned_wide_frames.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/custom_terminal/tests/cursor_tests.rs +expression: "frames.join(\"\\n\")" +--- +\u{1b}[1;4H\u{1b}[39;48;5;4m\u{1b}]8;;https://example.com\u{7}界\u{1b}]8;;\u{7}\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;4H\u{1b}[0 q\u{1b}[1;4H\u{1b}[39;48;5;4m\u{1b}]8;;https://example.com\u{7}界\u{1b}]8;;\u{7}\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;6H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;4H\u{1b}[0 q\u{1b}[1;4H\u{1b}[39;48;5;4m\u{1b}]8;;https://example.com\u{7}界\u{1b}]8;;\u{7}\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;6H\u{1b}[?25h diff --git a/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_single_column_frames.snap b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_single_column_frames.snap new file mode 100644 index 0000000000..d962c8781b --- /dev/null +++ b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_single_column_frames.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/custom_terminal/tests/cursor_tests.rs +expression: "frames.join(\"\\n\")" +--- +\u{1b}[1;1H\u{1b}[39;48;5;4mx\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[0 q\u{1b}[1;1H\u{1b}[39;48;5;4mx\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[0 q\u{1b}[1;1H\u{1b}[39;48;5;4mx\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[0 q\u{1b}[1;1H\u{1b}[39;48;5;4mx\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[1;1H\u{1b}[?25h diff --git a/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_styled_frames.snap b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_styled_frames.snap new file mode 100644 index 0000000000..25841a4597 --- /dev/null +++ b/codex-rs/tui/src/custom_terminal/tests/snapshots/codex_tui__custom_terminal__tests__cursor__cursor_style_styled_frames.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/custom_terminal/tests/cursor_tests.rs +expression: "frames.join(\"\\n\")" +--- +\u{1b}[2;1H\u{1b}[1m\u{1b}[39;48;2;80;80;80mab cd ef \u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;1H\u{1b}[0 q\u{1b}[2;1H\u{1b}[1m\u{1b}[39;48;2;80;80;80ma\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;2H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;1H\u{1b}[0 q\u{1b}[2;1H\u{1b}[1m\u{1b}[39;48;2;80;80;80ma\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;5H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;1H\u{1b}[6 q\u{1b}[2;1H\u{1b}[1m\u{1b}[39;48;2;80;80;80ma\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;4H\u{1b}[?25h +\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;1H\u{1b}[2 q\u{1b}[2;1H\u{1b}[1m\u{1b}[39;48;2;80;80;80ma\u{1b}[39m\u{1b}[49m\u{1b}[0m\u{1b}[2;2H\u{1b}[?25h