Skip to main content

tui_lipan/widgets/terminal/
events.rs

1use std::sync::Arc;
2
3use crate::core::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseKind};
4use crate::style::Span;
5use crate::utils::{GridSelection, GridSelectionEvent};
6
7/// Terminal input event source.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum TerminalInputKind {
10    /// Keyboard input encoded for the PTY.
11    Key,
12    /// Clipboard paste input encoded for the PTY.
13    Paste,
14    /// Focus-in notification.
15    FocusIn,
16    /// Focus-out notification.
17    FocusOut,
18}
19
20/// Terminal input event emitted by the framework.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct TerminalInputEvent {
23    /// Event source.
24    pub kind: TerminalInputKind,
25    /// Original key event (if applicable).
26    pub key: Option<KeyEvent>,
27    /// Encoded bytes suitable for PTY stdin.
28    pub bytes: Arc<[u8]>,
29}
30
31/// Terminal selection in grid coordinates.
32pub type TerminalSelection = GridSelection;
33
34/// Terminal selection event payload.
35pub type TerminalSelectionEvent = GridSelectionEvent;
36
37pub fn terminal_selection_text(lines: &[Vec<Span>], selection: &GridSelection) -> String {
38    if selection.is_empty() {
39        return String::new();
40    }
41
42    let mut row_strings = Vec::with_capacity(lines.len());
43    for line in lines {
44        let mut row = String::new();
45        for span in line {
46            row.push_str(span.content.as_ref());
47        }
48        row_strings.push(row);
49    }
50
51    selection.extract_text(&row_strings)
52}
53
54/// Encode a framework `KeyEvent` into terminal bytes.
55///
56/// This covers common printable keys and ANSI control sequences.
57pub fn key_event_to_bytes(key: KeyEvent) -> Option<Vec<u8>> {
58    let mut bytes = match key.code {
59        KeyCode::Char(ch) => {
60            if key.mods.ctrl {
61                vec![ctrl_char(ch)?]
62            } else {
63                ch.to_string().into_bytes()
64            }
65        }
66        KeyCode::Enter => vec![b'\r'],
67        KeyCode::Tab => vec![b'\t'],
68        KeyCode::BackTab => b"\x1b[Z".to_vec(),
69        KeyCode::Backspace => vec![0x7f],
70        KeyCode::Esc => vec![0x1b],
71        KeyCode::Up => b"\x1b[A".to_vec(),
72        KeyCode::Down => b"\x1b[B".to_vec(),
73        KeyCode::Right => b"\x1b[C".to_vec(),
74        KeyCode::Left => b"\x1b[D".to_vec(),
75        KeyCode::Home => b"\x1b[H".to_vec(),
76        KeyCode::End => b"\x1b[F".to_vec(),
77        KeyCode::PageUp => b"\x1b[5~".to_vec(),
78        KeyCode::PageDown => b"\x1b[6~".to_vec(),
79        KeyCode::Insert => b"\x1b[2~".to_vec(),
80        KeyCode::Delete => b"\x1b[3~".to_vec(),
81        KeyCode::F(n) => format!(
82            "\x1b[{}",
83            match n {
84                1 => "11~",
85                2 => "12~",
86                3 => "13~",
87                4 => "14~",
88                5 => "15~",
89                6 => "17~",
90                7 => "18~",
91                8 => "19~",
92                9 => "20~",
93                10 => "21~",
94                11 => "23~",
95                12 => "24~",
96                _ => return None,
97            }
98        )
99        .into_bytes(),
100    };
101
102    if key.mods.alt {
103        let mut alt_prefixed = Vec::with_capacity(bytes.len() + 1);
104        alt_prefixed.push(0x1b);
105        alt_prefixed.extend(bytes);
106        bytes = alt_prefixed;
107    }
108
109    Some(bytes)
110}
111
112fn ctrl_char(ch: char) -> Option<u8> {
113    if ch.is_ascii_alphabetic() {
114        return Some((ch.to_ascii_uppercase() as u8) - b'@');
115    }
116
117    match ch {
118        ' ' => Some(0),
119        '[' => Some(27),
120        '\\' => Some(28),
121        ']' => Some(29),
122        '^' => Some(30),
123        '_' => Some(31),
124        _ => None,
125    }
126}
127
128/// Mouse reporting mode requested by PTY application.
129#[cfg_attr(
130    feature = "terminal-serde",
131    derive(serde::Serialize, serde::Deserialize)
132)]
133#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134pub enum MouseMode {
135    /// No mouse reporting.
136    #[default]
137    None,
138    /// X10 compatibility mode (1000) - button press only.
139    X10,
140    /// Normal tracking (1002) - button press/release.
141    Normal,
142    /// Any-event tracking (1003) - all motion.
143    AnyEvent,
144}
145
146/// Mouse protocol encoding.
147#[cfg_attr(
148    feature = "terminal-serde",
149    derive(serde::Serialize, serde::Deserialize)
150)]
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum MouseEncoding {
153    /// Default X10 encoding (coordinates limited to 223).
154    #[default]
155    X10,
156    /// SGR extended encoding (1006) - no coordinate limits.
157    Sgr,
158    /// UTF-8 extended encoding (1005) - no coordinate limits.
159    Utf8,
160}
161
162/// Combined mouse mode state.
163#[cfg_attr(
164    feature = "terminal-serde",
165    derive(serde::Serialize, serde::Deserialize)
166)]
167#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
168pub struct MouseModeState {
169    /// Mouse reporting mode to enable in the PTY.
170    pub mode: MouseMode,
171    /// Wire encoding used for mouse reports.
172    pub encoding: MouseEncoding,
173    /// Whether focus reporting is enabled (CSI ? 1004 h).
174    pub focus_events_enabled: bool,
175}
176
177/// Encode a MouseEvent to bytes for PTY (SGR 1006 format).
178pub fn mouse_event_to_bytes(
179    event: MouseEvent,
180    encoding: MouseEncoding,
181    viewport_offset: (u16, u16),
182) -> Option<Vec<u8>> {
183    let (button_code, is_release) = match event.kind {
184        MouseKind::Down(btn) => (button_to_code(btn), false),
185        MouseKind::Up(btn) => (button_to_code(btn), true),
186        MouseKind::Drag(btn) => (button_to_code(btn).saturating_add(32), false),
187        MouseKind::ScrollUp => (64, false),
188        MouseKind::ScrollDown => (65, false),
189        // Motion without a pressed button: code 3 ("no button") + 32 (motion
190        // flag). Callers gate this on any-event tracking (1003) being active.
191        MouseKind::Moved => (35, false),
192    };
193
194    let mut cb = button_code;
195    if event.mods.shift {
196        cb = cb.saturating_add(4);
197    }
198    if event.mods.alt {
199        cb = cb.saturating_add(8);
200    }
201    if event.mods.ctrl {
202        cb = cb.saturating_add(16);
203    }
204
205    let cx = event.x.saturating_sub(viewport_offset.0).saturating_add(1);
206    let cy = event.y.saturating_sub(viewport_offset.1).saturating_add(1);
207
208    match encoding {
209        MouseEncoding::Sgr => {
210            let suffix = if is_release { 'm' } else { 'M' };
211            Some(format!("\x1b[<{};{};{}{}", cb, cx, cy, suffix).into_bytes())
212        }
213        MouseEncoding::X10 => {
214            if cx > 223 || cy > 223 {
215                return None;
216            }
217            let cb = cb.saturating_add(32);
218            let cx = cx.saturating_add(32) as u8;
219            let cy = cy.saturating_add(32) as u8;
220            Some(vec![0x1b, b'[', b'M', cb, cx, cy])
221        }
222        MouseEncoding::Utf8 => {
223            let mut out = Vec::with_capacity(6);
224            out.extend_from_slice(b"\x1b[M");
225            out.push(cb.saturating_add(32));
226            push_utf8_coord(&mut out, cx.saturating_add(32))?;
227            push_utf8_coord(&mut out, cy.saturating_add(32))?;
228            Some(out)
229        }
230    }
231}
232
233#[cfg(all(test, feature = "terminal-serde"))]
234mod terminal_serde_tests {
235    use super::*;
236
237    #[test]
238    fn mouse_mode_state_round_trips() {
239        let state = MouseModeState {
240            mode: MouseMode::AnyEvent,
241            encoding: MouseEncoding::Sgr,
242            focus_events_enabled: true,
243        };
244        let json = serde_json::to_string(&state).unwrap();
245        assert_eq!(
246            serde_json::from_str::<MouseModeState>(&json).unwrap(),
247            state
248        );
249    }
250}
251
252fn push_utf8_coord(out: &mut Vec<u8>, value: u16) -> Option<()> {
253    let mut buffer = [0u8; 4];
254    let ch = char::from_u32(u32::from(value))?;
255    let encoded = ch.encode_utf8(&mut buffer);
256    out.extend_from_slice(encoded.as_bytes());
257    Some(())
258}
259
260fn button_to_code(btn: MouseButton) -> u8 {
261    match btn {
262        MouseButton::Left => 0,
263        MouseButton::Middle => 1,
264        MouseButton::Right => 2,
265    }
266}
267
268/// Focus-in escape sequence.
269pub fn focus_in_sequence() -> &'static [u8] {
270    b"\x1b[I"
271}
272
273/// Focus-out escape sequence.
274pub fn focus_out_sequence() -> &'static [u8] {
275    b"\x1b[O"
276}
277
278/// Focus-related sequences (focus-in, focus-out).
279pub fn focus_sequences() -> (&'static [u8], &'static [u8]) {
280    (focus_in_sequence(), focus_out_sequence())
281}
282
283/// Wrap text in bracketed paste mode sequences.
284pub fn wrap_bracketed_paste(text: &str) -> Vec<u8> {
285    let (start, end) = paste_sequences();
286    let mut out = Vec::with_capacity(text.len() + start.len() + end.len());
287    out.extend_from_slice(start);
288    out.extend_from_slice(text.as_bytes());
289    out.extend_from_slice(end);
290    out
291}
292
293/// All paste-related sequences.
294pub fn paste_sequences() -> (&'static [u8], &'static [u8]) {
295    (b"\x1b[200~", b"\x1b[201~")
296}