Skip to main content

rmux_core/
dec_modes.rs

1//! Re-assert DEC private terminal modes from a tracked mode bitmap.
2//!
3//! When a viewer (re)joins a live session — a reconnecting browser, a late
4//! web-share client — it receives a snapshot that paints the visible grid but,
5//! by itself, does not restore the *interactive* terminal modes the inner
6//! program had asserted (mouse reporting, bracketed paste, application cursor
7//! keys, cursor style, ...). Without them the reconstructed view looks right
8//! but behaves wrong: clicks are ignored, paste breaks, arrow keys misbehave.
9//!
10//! [`render_dec_modes`] emits the escape sequences that re-assert enabled modes
11//! from a [`crate::Screen`]'s mode bitmap. [`render_dec_modes_for_snapshot`]
12//! emits a complete mode state, including explicit resets for stale browser
13//! state after backlog resync.
14//!
15//! Adapted clean-room from the `render_dec_modes` helper in rmux PR #26
16//! (passthrough mode, by @gilescope): rewritten against this crate's `Screen`
17//! mode API and reused as a shared primitive for the web-share snapshot.
18
19use crate::input::mode;
20
21/// Appends the DEC private *interactive* mode setters implied by `mode_bits`
22/// (and the DECSCUSR `cursor_style`) to `out`.
23///
24/// Only persistent modes that differ from a terminal's post-soft-reset defaults
25/// are emitted: mouse tracking (1000/1002/1003 plus the 1005/1006 encodings),
26/// bracketed paste (2004), focus events (1004), application cursor keys and
27/// keypad, insert mode, CR/LF, theme updates (2031), `modifyOtherKeys`, and the
28/// cursor style. Synchronized output (2026) is intentionally excluded: it is a
29/// transient begin/end batch marker, never a persistent state to re-assert.
30///
31/// These modes are independent of grid painting, so a caller may emit them
32/// immediately after a reset prefix. Layout modes that interact with painting —
33/// alternate screen (1049), scroll region (DECSTBM), origin mode (6) — are
34/// intentionally *not* emitted here; a caller that needs them must order them
35/// around the painted content itself (see the web-share snapshot).
36pub fn render_dec_modes(mode_bits: u32, cursor_style: u32, out: &mut Vec<u8>) {
37    let on = |bit: u32| mode_bits & bit != 0;
38
39    // On-by-default modes: emit the reset only when currently off.
40    if !on(mode::MODE_CURSOR) {
41        out.extend_from_slice(b"\x1b[?25l");
42    }
43    if !on(mode::MODE_WRAP) {
44        out.extend_from_slice(b"\x1b[?7l");
45    }
46
47    // Off-by-default mode setters.
48    if on(mode::MODE_INSERT) {
49        out.extend_from_slice(b"\x1b[4h");
50    }
51    if on(mode::MODE_KCURSOR) {
52        // DECCKM — application cursor keys (arrows emit `\x1bOA` etc.).
53        out.extend_from_slice(b"\x1b[?1h");
54    }
55    if on(mode::MODE_KKEYPAD) {
56        // DECPAM — application keypad (note the `\x1b=` form, no CSI).
57        out.extend_from_slice(b"\x1b=");
58    }
59    if on(mode::MODE_CRLF) {
60        // LNM is an ANSI mode (CSI 20 h), not a DEC private mode (no `?`).
61        out.extend_from_slice(b"\x1b[20h");
62    }
63    if on(mode::MODE_FOCUSON) {
64        out.extend_from_slice(b"\x1b[?1004h");
65    }
66    if on(mode::MODE_BRACKETPASTE) {
67        out.extend_from_slice(b"\x1b[?2004h");
68    }
69    if on(mode::MODE_THEME_UPDATES) {
70        out.extend_from_slice(b"\x1b[?2031h");
71    }
72    // NOTE: synchronized output (?2026) is deliberately NOT re-asserted. It is a
73    // *transient* batch marker (begin/end pairs); re-emitting `?2026h` from a
74    // static snapshot would leave a conforming emulator (xterm.js) waiting
75    // forever for the matching end-of-batch and freeze/blank the screen.
76
77    // Mouse tracking family — the levels are mutually exclusive at the
78    // terminal, so pick the highest one that is set.
79    if on(mode::MODE_MOUSE_ALL) {
80        out.extend_from_slice(b"\x1b[?1003h");
81    } else if on(mode::MODE_MOUSE_BUTTON) {
82        out.extend_from_slice(b"\x1b[?1002h");
83    } else if on(mode::MODE_MOUSE_STANDARD) {
84        out.extend_from_slice(b"\x1b[?1000h");
85    }
86    // Mouse encoding (SGR preferred over the legacy UTF-8 form).
87    if on(mode::MODE_MOUSE_SGR) {
88        out.extend_from_slice(b"\x1b[?1006h");
89    } else if on(mode::MODE_MOUSE_UTF8) {
90        out.extend_from_slice(b"\x1b[?1005h");
91    }
92
93    // Keyboard enhancement protocols.
94    if on(mode::MODE_KEYS_KITTY) {
95        out.extend_from_slice(b"\x1b[>1u");
96    } else if on(mode::MODE_KEYS_EXTENDED_2) {
97        out.extend_from_slice(b"\x1b[>4;2m");
98    } else if on(mode::MODE_KEYS_EXTENDED) {
99        out.extend_from_slice(b"\x1b[>4;1m");
100    }
101
102    // Cursor style (DECSCUSR). 0 == "terminal default" → leave untouched.
103    if cursor_style != 0 {
104        out.extend_from_slice(format!("\x1b[{cursor_style} q").as_bytes());
105    }
106}
107
108/// Appends a complete set of tracked terminal-mode transitions for a snapshot.
109///
110/// A web snapshot may be delivered to a browser terminal that already exists and
111/// missed earlier live bytes. This function therefore does not assume post-reset
112/// defaults: it explicitly clears transient or off-by-default modes before
113/// re-enabling the modes present in `mode_bits`. Synchronized output (`?2026`)
114/// is always ended, never started, because a static snapshot must not leave the
115/// browser waiting for a later batch terminator.
116pub fn render_dec_modes_for_snapshot(mode_bits: u32, cursor_style: u32, out: &mut Vec<u8>) {
117    let on = |bit: u32| mode_bits & bit != 0;
118
119    out.extend_from_slice(if on(mode::MODE_WRAP) {
120        b"\x1b[?7h".as_slice()
121    } else {
122        b"\x1b[?7l".as_slice()
123    });
124    out.extend_from_slice(if on(mode::MODE_INSERT) {
125        b"\x1b[4h".as_slice()
126    } else {
127        b"\x1b[4l".as_slice()
128    });
129    out.extend_from_slice(if on(mode::MODE_KCURSOR) {
130        b"\x1b[?1h".as_slice()
131    } else {
132        b"\x1b[?1l".as_slice()
133    });
134    out.extend_from_slice(if on(mode::MODE_KKEYPAD) {
135        b"\x1b=".as_slice()
136    } else {
137        b"\x1b>".as_slice()
138    });
139    out.extend_from_slice(if on(mode::MODE_CRLF) {
140        b"\x1b[20h".as_slice()
141    } else {
142        b"\x1b[20l".as_slice()
143    });
144    out.extend_from_slice(if on(mode::MODE_FOCUSON) {
145        b"\x1b[?1004h".as_slice()
146    } else {
147        b"\x1b[?1004l".as_slice()
148    });
149    out.extend_from_slice(if on(mode::MODE_BRACKETPASTE) {
150        b"\x1b[?2004h".as_slice()
151    } else {
152        b"\x1b[?2004l".as_slice()
153    });
154    out.extend_from_slice(if on(mode::MODE_THEME_UPDATES) {
155        b"\x1b[?2031h".as_slice()
156    } else {
157        b"\x1b[?2031l".as_slice()
158    });
159
160    out.extend_from_slice(b"\x1b[?2026l");
161
162    out.extend_from_slice(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l");
163    if on(mode::MODE_MOUSE_ALL) {
164        out.extend_from_slice(b"\x1b[?1003h");
165    } else if on(mode::MODE_MOUSE_BUTTON) {
166        out.extend_from_slice(b"\x1b[?1002h");
167    } else if on(mode::MODE_MOUSE_STANDARD) {
168        out.extend_from_slice(b"\x1b[?1000h");
169    }
170
171    out.extend_from_slice(b"\x1b[?1005l\x1b[?1006l");
172    if on(mode::MODE_MOUSE_SGR) {
173        out.extend_from_slice(b"\x1b[?1006h");
174    } else if on(mode::MODE_MOUSE_UTF8) {
175        out.extend_from_slice(b"\x1b[?1005h");
176    }
177
178    if on(mode::MODE_KEYS_KITTY) {
179        out.extend_from_slice(b"\x1b[>1u");
180    } else if on(mode::MODE_KEYS_EXTENDED_2) {
181        out.extend_from_slice(b"\x1b[>4;2m");
182    } else if on(mode::MODE_KEYS_EXTENDED) {
183        out.extend_from_slice(b"\x1b[>4;1m");
184    } else {
185        out.extend_from_slice(b"\x1b[<u");
186        out.extend_from_slice(b"\x1b[>4;0m");
187    }
188
189    out.extend_from_slice(format!("\x1b[{cursor_style} q").as_bytes());
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn rendered(mode_bits: u32, cursor_style: u32) -> String {
197        let mut out = Vec::new();
198        render_dec_modes(mode_bits, cursor_style, &mut out);
199        String::from_utf8(out).expect("dec-mode sequences are ascii")
200    }
201
202    #[test]
203    fn post_reset_defaults_emit_nothing() {
204        // Cursor + wrap on, everything else off == post-DECSTR defaults.
205        assert_eq!(rendered(mode::MODE_CURSOR | mode::MODE_WRAP, 0), "");
206    }
207
208    #[test]
209    fn mouse_button_sgr_and_bracketed_paste_are_reasserted() {
210        let bits = mode::MODE_CURSOR
211            | mode::MODE_WRAP
212            | mode::MODE_MOUSE_BUTTON
213            | mode::MODE_MOUSE_SGR
214            | mode::MODE_BRACKETPASTE;
215        let out = rendered(bits, 0);
216        assert!(out.contains("\x1b[?1002h"), "{out:?}");
217        assert!(out.contains("\x1b[?1006h"), "{out:?}");
218        assert!(out.contains("\x1b[?2004h"), "{out:?}");
219    }
220
221    #[test]
222    fn highest_mouse_level_wins() {
223        let bits =
224            mode::MODE_CURSOR | mode::MODE_WRAP | mode::MODE_MOUSE_ALL | mode::MODE_MOUSE_BUTTON;
225        let out = rendered(bits, 0);
226        assert!(out.contains("\x1b[?1003h"), "{out:?}");
227        assert!(!out.contains("\x1b[?1002h"), "{out:?}");
228    }
229
230    #[test]
231    fn application_cursor_keys_and_keypad() {
232        let bits = mode::MODE_CURSOR | mode::MODE_WRAP | mode::MODE_KCURSOR | mode::MODE_KKEYPAD;
233        let out = rendered(bits, 0);
234        assert!(out.contains("\x1b[?1h"), "{out:?}");
235        assert!(out.contains("\x1b="), "{out:?}");
236    }
237
238    #[test]
239    fn cursor_hidden_when_mode_off() {
240        let out = rendered(mode::MODE_WRAP, 0); // MODE_CURSOR bit cleared
241        assert!(out.contains("\x1b[?25l"), "{out:?}");
242    }
243
244    #[test]
245    fn cursor_style_emitted_when_set() {
246        let out = rendered(mode::MODE_CURSOR | mode::MODE_WRAP, 4);
247        assert!(out.contains("\x1b[4 q"), "{out:?}");
248    }
249
250    #[test]
251    fn kitty_keyboard_mode_is_reasserted_as_csi_u() {
252        let bits = mode::MODE_CURSOR
253            | mode::MODE_WRAP
254            | mode::MODE_KEYS_EXTENDED_2
255            | mode::MODE_KEYS_KITTY;
256        let out = rendered(bits, 0);
257        assert!(out.contains("\x1b[>1u"), "{out:?}");
258        assert!(!out.contains("\x1b[>4;2m"), "{out:?}");
259    }
260
261    #[test]
262    fn snapshot_mode_render_clears_stale_transient_modes() {
263        let mut out = Vec::new();
264        render_dec_modes_for_snapshot(mode::MODE_CURSOR | mode::MODE_WRAP, 0, &mut out);
265        let out = String::from_utf8(out).expect("snapshot modes are ascii");
266
267        assert!(out.contains("\x1b[?2026l"), "{out:?}");
268        assert!(out.contains("\x1b[?2004l"), "{out:?}");
269        assert!(out.contains("\x1b[?1006l"), "{out:?}");
270        assert!(out.contains("\x1b[<u"), "{out:?}");
271        assert!(out.contains("\x1b[>4;0m"), "{out:?}");
272        assert!(out.contains("\x1b[0 q"), "{out:?}");
273        assert!(!out.contains("\x1b[?2026h"), "{out:?}");
274    }
275
276    #[test]
277    fn snapshot_mode_render_sets_enabled_modes_after_clearing_family_state() {
278        let bits = mode::MODE_CURSOR
279            | mode::MODE_WRAP
280            | mode::MODE_MOUSE_BUTTON
281            | mode::MODE_MOUSE_SGR
282            | mode::MODE_BRACKETPASTE
283            | mode::MODE_KEYS_EXTENDED_2;
284        let mut out = Vec::new();
285        render_dec_modes_for_snapshot(bits, 4, &mut out);
286        let out = String::from_utf8(out).expect("snapshot modes are ascii");
287
288        assert!(out.contains("\x1b[?1002h"), "{out:?}");
289        assert!(out.contains("\x1b[?1006h"), "{out:?}");
290        assert!(out.contains("\x1b[?2004h"), "{out:?}");
291        assert!(out.contains("\x1b[>4;2m"), "{out:?}");
292        assert!(out.contains("\x1b[4 q"), "{out:?}");
293    }
294
295    #[test]
296    fn snapshot_mode_render_prefers_kitty_keyboard_over_xterm_modifier_mode() {
297        let bits = mode::MODE_CURSOR
298            | mode::MODE_WRAP
299            | mode::MODE_KEYS_EXTENDED_2
300            | mode::MODE_KEYS_KITTY;
301        let mut out = Vec::new();
302        render_dec_modes_for_snapshot(bits, 0, &mut out);
303        let out = String::from_utf8(out).expect("snapshot modes are ascii");
304
305        assert!(out.contains("\x1b[>1u"), "{out:?}");
306        assert!(!out.contains("\x1b[>4;2m"), "{out:?}");
307    }
308}