Skip to main content

omnyssh_core/utils/
scroll.rs

1//! Mouse-wheel handling for the embedded terminal.
2//!
3//! On the normal screen a wheel notch scrolls the local vt100 scrollback.
4//! On the alternate screen (vim, less, htop, ...) there is no scrollback, so
5//! the notch is forwarded to the foreground application instead — as native
6//! mouse-wheel sequences when it requested mouse reporting, or as cursor-key
7//! presses otherwise (xterm "alternate scroll" behaviour).
8
9use vt100::{MouseProtocolEncoding, MouseProtocolMode, Screen};
10
11/// Resolved effect of a single mouse-wheel notch.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ScrollAction {
14    /// Scroll the local vt100 scrollback by this signed line delta
15    /// (positive = towards older output).
16    Scrollback(i16),
17    /// Forward these bytes to the PTY (alternate screen is active).
18    Forward(Vec<u8>),
19}
20
21/// Decides what a wheel `delta` should do given the foreground app's state.
22///
23/// `delta` is positive for wheel-up and negative for wheel-down; its
24/// magnitude is the number of lines per notch.
25pub fn resolve_scroll(delta: i16, screen: &Screen) -> ScrollAction {
26    if delta == 0 || !screen.alternate_screen() {
27        return ScrollAction::Scrollback(delta);
28    }
29    let up = delta > 0;
30    if screen.mouse_protocol_mode() == MouseProtocolMode::None {
31        // No mouse reporting: emulate the wheel with cursor-key presses.
32        ScrollAction::Forward(arrow_key_seq(
33            up,
34            delta.unsigned_abs() as usize,
35            screen.application_cursor(),
36        ))
37    } else {
38        // Mouse reporting on: send a native wheel event.
39        ScrollAction::Forward(wheel_mouse_seq(up, screen.mouse_protocol_encoding()))
40    }
41}
42
43/// Builds a mouse-wheel report for an application that enabled mouse mode.
44/// The event is reported at cell (1, 1); for a wheel notch the exact position
45/// is irrelevant to every common consumer (vim, less, htop).
46fn wheel_mouse_seq(up: bool, encoding: MouseProtocolEncoding) -> Vec<u8> {
47    // Wheel-up = button 64, wheel-down = button 65 (xterm convention).
48    let button: u16 = if up { 64 } else { 65 };
49    match encoding {
50        MouseProtocolEncoding::Sgr => format!("\x1b[<{button};1;1M").into_bytes(),
51        // X10 / UTF-8: `ESC [ M` followed by button, column and row, each
52        // offset by 32. Cell (1, 1) keeps every value in the ASCII range,
53        // so the UTF-8 and X10 encodings coincide here.
54        _ => vec![0x1b, b'[', b'M', (button + 32) as u8, 1 + 32, 1 + 32],
55    }
56}
57
58/// Repeats a cursor up/down escape sequence `count` times. Uses the
59/// application-cursor form (`ESC O A/B`) when DECCKM is active.
60fn arrow_key_seq(up: bool, count: usize, application_cursor: bool) -> Vec<u8> {
61    let seq: &[u8] = match (up, application_cursor) {
62        (true, false) => b"\x1b[A",
63        (false, false) => b"\x1b[B",
64        (true, true) => b"\x1bOA",
65        (false, true) => b"\x1bOB",
66    };
67    seq.repeat(count.max(1))
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use vt100::Parser;
74
75    /// Builds a parser and feeds it the given escape sequences.
76    fn parser_with(seqs: &[&[u8]]) -> Parser {
77        let mut p = Parser::new(24, 80, 1000);
78        for s in seqs {
79            p.process(s);
80        }
81        p
82    }
83
84    #[test]
85    fn normal_screen_scrolls_scrollback() {
86        let p = parser_with(&[]);
87        assert_eq!(resolve_scroll(3, p.screen()), ScrollAction::Scrollback(3));
88        assert_eq!(resolve_scroll(-3, p.screen()), ScrollAction::Scrollback(-3));
89    }
90
91    #[test]
92    fn zero_delta_is_noop_scrollback() {
93        let p = parser_with(&[b"\x1b[?1049h"]);
94        assert_eq!(resolve_scroll(0, p.screen()), ScrollAction::Scrollback(0));
95    }
96
97    #[test]
98    fn alt_screen_without_mouse_sends_arrow_keys() {
99        let p = parser_with(&[b"\x1b[?1049h"]);
100        assert_eq!(
101            resolve_scroll(3, p.screen()),
102            ScrollAction::Forward(b"\x1b[A\x1b[A\x1b[A".to_vec())
103        );
104        assert_eq!(
105            resolve_scroll(-3, p.screen()),
106            ScrollAction::Forward(b"\x1b[B\x1b[B\x1b[B".to_vec())
107        );
108    }
109
110    #[test]
111    fn alt_screen_with_application_cursor_uses_ss3() {
112        let p = parser_with(&[b"\x1b[?1049h", b"\x1b[?1h"]);
113        assert_eq!(
114            resolve_scroll(3, p.screen()),
115            ScrollAction::Forward(b"\x1bOA\x1bOA\x1bOA".to_vec())
116        );
117    }
118
119    #[test]
120    fn alt_screen_with_mouse_sends_x10_wheel() {
121        let p = parser_with(&[b"\x1b[?1049h", b"\x1b[?1000h"]);
122        assert_eq!(
123            resolve_scroll(3, p.screen()),
124            ScrollAction::Forward(vec![0x1b, b'[', b'M', 96, 33, 33])
125        );
126        assert_eq!(
127            resolve_scroll(-3, p.screen()),
128            ScrollAction::Forward(vec![0x1b, b'[', b'M', 97, 33, 33])
129        );
130    }
131
132    #[test]
133    fn alt_screen_with_sgr_mouse_sends_sgr_wheel() {
134        let p = parser_with(&[b"\x1b[?1049h", b"\x1b[?1000h", b"\x1b[?1006h"]);
135        assert_eq!(
136            resolve_scroll(3, p.screen()),
137            ScrollAction::Forward(b"\x1b[<64;1;1M".to_vec())
138        );
139        assert_eq!(
140            resolve_scroll(-3, p.screen()),
141            ScrollAction::Forward(b"\x1b[<65;1;1M".to_vec())
142        );
143    }
144}