From cb4010344e16aee112e7a2ccd19c1ee04f11aea3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:58:16 +0800 Subject: [PATCH] fix(terminal): a tab stop or a mode change no longer parks the cursor hide, move to 9;9, show -> cursor at 9;9 hide, move to 9;9, CSI g, show -> cursor back at 6;4 The scanner sorts CSI finals into ones that move the cursor and ones that leave it alone, and treats everything else as a paint. A paint clears `last_was_move`, which makes the following show "parked" -- the cursor is put back on the cell it was hidden on, because a frame that ended on an erase left it wherever the erase finished. Terminal settings are on neither list and were being counted as paints. Clearing a tab stop, or setting an ANSI mode, between a frame's last move and its show therefore dragged the cursor off the cell the frame had chosen. Demonstrated above before changing anything, with `CSI 4 l` doing the same. Which finals belong on the list came from `vte`'s own dispatch rather than from the spec: `g` clears tab stops, `h`/`l` set and unset ANSI modes -- their `?` forms return earlier, so this cannot disturb `?25h`/`?25l` -- and `k` is SCP. All four reach the emulator without touching the grid. `p` stays where it is, which is worth writing down because it looks wrong: `CSI ! p` is DECSTR and homes the cursor in the spec, but `vte` implements only the `$p` and `?$p` forms, so DECSTR never reaches the grid here. Classifying it as a move would describe a terminal this is not. The test holds all four finals against the position the frame chose, and keeps the erase case parking, which is what the pairing is for. --- src/terminal/parked_cursor.rs | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/src/terminal/parked_cursor.rs b/src/terminal/parked_cursor.rs index facad126..57eefd0a 100644 --- a/src/terminal/parked_cursor.rs +++ b/src/terminal/parked_cursor.rs @@ -72,9 +72,18 @@ enum State { /// the save/restore pair and DECSTBM, which homes it. const MOVES: &[u8] = b"ABCDEFGHIZ`adefsur"; /// CSI finals that leave the cursor alone: SGR, the reports and queries, window -/// operations, DECSCUSR. Anything else is a paint — erase, insert, delete, -/// scroll, repeat — and ends the run on something other than a move. -const NEUTRAL: &[u8] = b"mncpqtx"; +/// operations, DECSCUSR, and the settings that change how the terminal behaves +/// without touching the screen — tab stops (`g`, `k`) and the ANSI modes +/// (`h`, `l`, whose private `?` forms never reach here). Anything else is a +/// paint — erase, insert, delete, scroll, repeat — and ends the run on +/// something other than a move. +/// +/// The distinction is not decoration. A final counted as a paint clears +/// `last_was_move`, and a show after that is treated as parked: the cursor is +/// put back where it was hidden. Clearing a tab stop between a frame's last +/// move and its show was enough to drag the cursor off the cell the frame had +/// chosen. +const NEUTRAL: &[u8] = b"mncpqtxghkl"; /// A CSI longer than this is not one of ours; keep the buffer bounded. const MAX_CSI: usize = 32; @@ -283,6 +292,49 @@ mod tests { ); } + /// A setting between the last move and the show is not a paint. + /// + /// Whether a show is "parked" turns on what the frame did last: a move + /// means it chose where the cursor sits, a paint means the position is + /// wherever the painting ended. Anything not on either list counted as a + /// paint, and the terminal's own settings are on neither — clearing a tab + /// stop, or setting an ANSI mode, between the frame's last move and its + /// show was enough to drag the cursor back to the cell it was hidden on, + /// undoing a position the frame had chosen. + /// + /// The finals here are the ones `vte` dispatches without touching the + /// grid: `g` clears tab stops, `h`/`l` set and unset ANSI modes (the `?` + /// forms return before this), and `k` is SCP. + #[test] + fn a_setting_between_the_move_and_the_show_does_not_park_the_cursor() { + let chosen = cursor_after(b"\x1b[6;4H\x1b[?25l\x1b[9;9H\x1b[?25h"); + assert_eq!(chosen, (8, 8), "the frame chose this cell"); + + for settings in [ + &b"\x1b[g"[..], // TBC, clear a tab stop + &b"\x1b[3g"[..], // TBC, clear them all + &b"\x1b[4l"[..], // RM, leave insert mode + &b"\x1b[4h"[..], // SM, enter it + ] { + let mut stream = b"\x1b[6;4H\x1b[?25l\x1b[9;9H".to_vec(); + stream.extend_from_slice(settings); + stream.extend_from_slice(b"\x1b[?25h"); + assert_eq!( + cursor_after(&stream), + chosen, + "{:?} moved the cursor off the cell the frame chose", + String::from_utf8_lossy(settings) + ); + } + + // The paint case still parks, which is the whole point of the pairing. + assert_eq!( + cursor_after(b"\x1b[6;4H\x1b[?25l\x1b[9;9H\x1b[K\x1b[?25h"), + (5, 3), + "an erase leaves the cursor wherever it ended, so put it back" + ); + } + #[test] fn a_show_the_frame_moved_to_is_left_where_it_is() { assert_eq!(