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.
This commit is contained in:
l0ng-ai
2026-08-16 14:58:16 +08:00
parent 858cd45615
commit cb4010344e
+55 -3
View File
@@ -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!(