Repair cursor-style rendering on older JediTerm terminals (#41673)

## Why

Older JediTerm versions can print the space intermediate in `DECSCUSR`,
overwriting the glyph beneath a cursor-style command.

## What changed

- Apply cursor-style commands at a repairable, terminal-owned glyph and redraw
  that glyph with its original style and hyperlink before restoring the requested
  cursor position.
- Omit the style command when the viewport has no safe repair anchor.
- Handle skipped cells, wide glyphs, and single-column viewports without
  corrupting content or causing scrolling.

## Testing

Add cursor rendering tests for styled and cursor-only frames, skipped and wide
hyperlinked glyphs, single-column viewports, and viewports without owned cells.

GitOrigin-RevId: c8bbb74c53737b5980c533859af39da26f0e06cc
This commit is contained in:
Tamir Duberstein
2026-08-30 13:42:42 +00:00
committed by copyberry
parent cefa060695
commit da23e131e6
6 changed files with 234 additions and 26 deletions
+8 -26
View File
@@ -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<u8>,
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 };
@@ -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<B> Terminal<B>
where
B: Backend<Error = io::Error> + 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(())
}
}
@@ -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"
);
}
@@ -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
@@ -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
@@ -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