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    // modifyOtherKeys (xterm `CSI > 4 ; n m`).
94    if on(mode::MODE_KEYS_EXTENDED_2) {
95        out.extend_from_slice(b"\x1b[>4;2m");
96    } else if on(mode::MODE_KEYS_EXTENDED) {
97        out.extend_from_slice(b"\x1b[>4;1m");
98    }
99
100    // Cursor style (DECSCUSR). 0 == "terminal default" → leave untouched.
101    if cursor_style != 0 {
102        out.extend_from_slice(format!("\x1b[{cursor_style} q").as_bytes());
103    }
104}
105
106/// Appends a complete set of tracked terminal-mode transitions for a snapshot.
107///
108/// A web snapshot may be delivered to a browser terminal that already exists and
109/// missed earlier live bytes. This function therefore does not assume post-reset
110/// defaults: it explicitly clears transient or off-by-default modes before
111/// re-enabling the modes present in `mode_bits`. Synchronized output (`?2026`)
112/// is always ended, never started, because a static snapshot must not leave the
113/// browser waiting for a later batch terminator.
114pub fn render_dec_modes_for_snapshot(mode_bits: u32, cursor_style: u32, out: &mut Vec<u8>) {
115    let on = |bit: u32| mode_bits & bit != 0;
116
117    out.extend_from_slice(if on(mode::MODE_WRAP) {
118        b"\x1b[?7h".as_slice()
119    } else {
120        b"\x1b[?7l".as_slice()
121    });
122    out.extend_from_slice(if on(mode::MODE_INSERT) {
123        b"\x1b[4h".as_slice()
124    } else {
125        b"\x1b[4l".as_slice()
126    });
127    out.extend_from_slice(if on(mode::MODE_KCURSOR) {
128        b"\x1b[?1h".as_slice()
129    } else {
130        b"\x1b[?1l".as_slice()
131    });
132    out.extend_from_slice(if on(mode::MODE_KKEYPAD) {
133        b"\x1b=".as_slice()
134    } else {
135        b"\x1b>".as_slice()
136    });
137    out.extend_from_slice(if on(mode::MODE_CRLF) {
138        b"\x1b[20h".as_slice()
139    } else {
140        b"\x1b[20l".as_slice()
141    });
142    out.extend_from_slice(if on(mode::MODE_FOCUSON) {
143        b"\x1b[?1004h".as_slice()
144    } else {
145        b"\x1b[?1004l".as_slice()
146    });
147    out.extend_from_slice(if on(mode::MODE_BRACKETPASTE) {
148        b"\x1b[?2004h".as_slice()
149    } else {
150        b"\x1b[?2004l".as_slice()
151    });
152    out.extend_from_slice(if on(mode::MODE_THEME_UPDATES) {
153        b"\x1b[?2031h".as_slice()
154    } else {
155        b"\x1b[?2031l".as_slice()
156    });
157
158    out.extend_from_slice(b"\x1b[?2026l");
159
160    out.extend_from_slice(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l");
161    if on(mode::MODE_MOUSE_ALL) {
162        out.extend_from_slice(b"\x1b[?1003h");
163    } else if on(mode::MODE_MOUSE_BUTTON) {
164        out.extend_from_slice(b"\x1b[?1002h");
165    } else if on(mode::MODE_MOUSE_STANDARD) {
166        out.extend_from_slice(b"\x1b[?1000h");
167    }
168
169    out.extend_from_slice(b"\x1b[?1005l\x1b[?1006l");
170    if on(mode::MODE_MOUSE_SGR) {
171        out.extend_from_slice(b"\x1b[?1006h");
172    } else if on(mode::MODE_MOUSE_UTF8) {
173        out.extend_from_slice(b"\x1b[?1005h");
174    }
175
176    if on(mode::MODE_KEYS_EXTENDED_2) {
177        out.extend_from_slice(b"\x1b[>4;2m");
178    } else if on(mode::MODE_KEYS_EXTENDED) {
179        out.extend_from_slice(b"\x1b[>4;1m");
180    } else {
181        out.extend_from_slice(b"\x1b[>4;0m");
182    }
183
184    out.extend_from_slice(format!("\x1b[{cursor_style} q").as_bytes());
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn rendered(mode_bits: u32, cursor_style: u32) -> String {
192        let mut out = Vec::new();
193        render_dec_modes(mode_bits, cursor_style, &mut out);
194        String::from_utf8(out).expect("dec-mode sequences are ascii")
195    }
196
197    #[test]
198    fn post_reset_defaults_emit_nothing() {
199        // Cursor + wrap on, everything else off == post-DECSTR defaults.
200        assert_eq!(rendered(mode::MODE_CURSOR | mode::MODE_WRAP, 0), "");
201    }
202
203    #[test]
204    fn mouse_button_sgr_and_bracketed_paste_are_reasserted() {
205        let bits = mode::MODE_CURSOR
206            | mode::MODE_WRAP
207            | mode::MODE_MOUSE_BUTTON
208            | mode::MODE_MOUSE_SGR
209            | mode::MODE_BRACKETPASTE;
210        let out = rendered(bits, 0);
211        assert!(out.contains("\x1b[?1002h"), "{out:?}");
212        assert!(out.contains("\x1b[?1006h"), "{out:?}");
213        assert!(out.contains("\x1b[?2004h"), "{out:?}");
214    }
215
216    #[test]
217    fn highest_mouse_level_wins() {
218        let bits =
219            mode::MODE_CURSOR | mode::MODE_WRAP | mode::MODE_MOUSE_ALL | mode::MODE_MOUSE_BUTTON;
220        let out = rendered(bits, 0);
221        assert!(out.contains("\x1b[?1003h"), "{out:?}");
222        assert!(!out.contains("\x1b[?1002h"), "{out:?}");
223    }
224
225    #[test]
226    fn application_cursor_keys_and_keypad() {
227        let bits = mode::MODE_CURSOR | mode::MODE_WRAP | mode::MODE_KCURSOR | mode::MODE_KKEYPAD;
228        let out = rendered(bits, 0);
229        assert!(out.contains("\x1b[?1h"), "{out:?}");
230        assert!(out.contains("\x1b="), "{out:?}");
231    }
232
233    #[test]
234    fn cursor_hidden_when_mode_off() {
235        let out = rendered(mode::MODE_WRAP, 0); // MODE_CURSOR bit cleared
236        assert!(out.contains("\x1b[?25l"), "{out:?}");
237    }
238
239    #[test]
240    fn cursor_style_emitted_when_set() {
241        let out = rendered(mode::MODE_CURSOR | mode::MODE_WRAP, 4);
242        assert!(out.contains("\x1b[4 q"), "{out:?}");
243    }
244
245    #[test]
246    fn snapshot_mode_render_clears_stale_transient_modes() {
247        let mut out = Vec::new();
248        render_dec_modes_for_snapshot(mode::MODE_CURSOR | mode::MODE_WRAP, 0, &mut out);
249        let out = String::from_utf8(out).expect("snapshot modes are ascii");
250
251        assert!(out.contains("\x1b[?2026l"), "{out:?}");
252        assert!(out.contains("\x1b[?2004l"), "{out:?}");
253        assert!(out.contains("\x1b[?1006l"), "{out:?}");
254        assert!(out.contains("\x1b[>4;0m"), "{out:?}");
255        assert!(out.contains("\x1b[0 q"), "{out:?}");
256        assert!(!out.contains("\x1b[?2026h"), "{out:?}");
257    }
258
259    #[test]
260    fn snapshot_mode_render_sets_enabled_modes_after_clearing_family_state() {
261        let bits = mode::MODE_CURSOR
262            | mode::MODE_WRAP
263            | mode::MODE_MOUSE_BUTTON
264            | mode::MODE_MOUSE_SGR
265            | mode::MODE_BRACKETPASTE
266            | mode::MODE_KEYS_EXTENDED_2;
267        let mut out = Vec::new();
268        render_dec_modes_for_snapshot(bits, 4, &mut out);
269        let out = String::from_utf8(out).expect("snapshot modes are ascii");
270
271        assert!(out.contains("\x1b[?1002h"), "{out:?}");
272        assert!(out.contains("\x1b[?1006h"), "{out:?}");
273        assert!(out.contains("\x1b[?2004h"), "{out:?}");
274        assert!(out.contains("\x1b[>4;2m"), "{out:?}");
275        assert!(out.contains("\x1b[4 q"), "{out:?}");
276    }
277}