fix: hide the painting cursor and synchronize terminal frames

This commit is contained in:
2026-10-10 00:05:48 +02:00
parent d310be6e6e
commit 05c044bd02
3 changed files with 105 additions and 4 deletions
+101 -3
View File
@@ -15,7 +15,10 @@ use crossterm::{
KeyModifiers,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
terminal::{
BeginSynchronizedUpdate, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen,
disable_raw_mode, enable_raw_mode,
},
};
use ratatui::{
Frame, Terminal,
@@ -56,6 +59,7 @@ fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
EndSynchronizedUpdate,
DisableBracketedPaste,
LeaveAlternateScreen,
Show
@@ -134,6 +138,19 @@ impl BufferViewports {
}
}
fn draw_frame<W: io::Write>(
terminal: &mut Terminal<CrosstermBackend<W>>,
render: impl FnOnce(&mut Frame),
) -> io::Result<()> {
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
// Terminals without synchronized updates still hide the painting cursor.
let result = terminal
.hide_cursor()
.and_then(|()| terminal.draw(render).map(|_| ()));
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
result.and(finish)
}
/// Renders session state and translates terminal input; the session owns plugin
/// execution and retirement, so the adapter needs no lifecycle callback.
pub fn run(session: &mut EditorSession) -> io::Result<()> {
@@ -145,7 +162,9 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> {
while !session.editor().should_quit() {
redraw |= session.poll();
if redraw {
terminal.draw(|frame| viewports.draw(frame, session.editor()))?;
draw_frame(&mut terminal, |frame| {
viewports.draw(frame, session.editor())
})?;
redraw = false;
}
if !event::poll(Duration::from_millis(25))? {
@@ -4104,6 +4123,74 @@ end
);
}
#[test]
fn scrolling_never_paints_with_an_exposed_cursor_even_without_synchronized_updates() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor
.apply(vec![crate::editor::Effect::Replace(
(0..40).map(|row| format!("row {row}\n")).collect(),
)])
.unwrap();
let mut output = Vec::new();
let mut terminal = Terminal::with_options(
CrosstermBackend::new(&mut output),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)
.unwrap();
let mut viewports = BufferViewports::default();
for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) {
editor.handle(Input::Char(key));
draw_frame(&mut terminal, |frame| viewports.draw(frame, &editor)).unwrap();
}
drop(terminal);
let output = std::str::from_utf8(&output).unwrap();
for supports_sync in [false, true] {
let (mut visible, mut synchronized) = (true, false);
let mut screen = [[b' '; 20]; 5];
let (mut x, mut y) = (0, 0);
for escape in output.split('\x1b').skip(1) {
let rest = escape.strip_prefix('[').unwrap();
let end = rest.find(|ch| ('@'..='~').contains(&ch)).unwrap();
let (command, text) = rest.split_at(end + 1);
match command {
"?25h" => visible = true,
"?25l" => visible = false,
"?2026h" => synchronized = supports_sync,
"?2026l" => synchronized = false,
_ if command.ends_with('H') => {
let (row, column) = command[..command.len() - 1].split_once(';').unwrap();
y = row.parse::<usize>().unwrap() - 1;
x = column.parse::<usize>().unwrap() - 1;
}
_ => {}
}
for byte in text.bytes().filter(|&byte| byte >= b' ' && byte != 127) {
assert!(
if supports_sync {
synchronized
} else {
!visible
},
"partial frame exposed during redraw (sync={supports_sync}): {text:?}",
);
if y < screen.len() && x < screen[y].len() {
screen[y][x] = byte;
}
x += 1;
}
}
assert!(visible);
assert!(!synchronized);
for (row, expected) in [(0, "row 0"), (1, "row 1"), (2, "row 2")] {
let line = std::str::from_utf8(&screen[row]).unwrap().trim_end();
assert!(line.ends_with(expected), "{line:?}");
}
assert_eq!(screen[y][x], b'r', "caret was not restored to the source");
}
}
#[cfg(unix)]
#[test]
fn terminal_session_scopes_the_callers_panic_hook() {
@@ -4125,7 +4212,15 @@ end
match mode.as_str() {
"normal" => Ok(()),
"error" => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
"panic" => panic::panic_any(73_u64),
"panic" => {
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
},
)?;
draw_frame(&mut terminal, |_| panic::panic_any(73_u64))
}
"keys" => {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.handle(Input::Ctrl('p'));
@@ -4244,6 +4339,9 @@ end
"{mode}: {before:?}"
);
if mode == "panic" {
let begin = before.find("\x1b[?2026h").unwrap();
let end = before.find("\x1b[?2026l").unwrap();
assert!(begin < end && end < before.find("CALLER_HOOK").unwrap());
assert!(
before.find("\x1b[?1049l").unwrap() < before.find("CALLER_HOOK").unwrap(),
"{before:?}",