Skip to main content

tui_lipan/widgets/terminal/
mod.rs

1//! Terminal output view helpers.
2
3mod buffer;
4#[cfg(feature = "terminal")]
5mod copy_mode;
6mod events;
7#[cfg(feature = "terminal-images")]
8mod graphics;
9mod layout;
10mod mod_private;
11mod node;
12mod osc;
13mod pty;
14mod reconcile;
15mod screen;
16mod scrollback_ledger;
17#[cfg(feature = "terminal")]
18mod selection;
19
20pub use buffer::TerminalBuffer;
21#[cfg(feature = "terminal")]
22pub use copy_mode::{CopyModeAction, CopyModeGrid, TerminalCopyMode};
23pub use events::{
24    KittyKeyboardFlags, MouseEncoding, MouseMode, MouseModeState, TerminalInputEvent,
25    TerminalInputKind, TerminalKeyModes, TerminalPasteShortcutBehavior, encode_paste,
26    focus_sequences, key_event_to_bytes, mouse_event_to_bytes, paste_sequences,
27    terminal_selection_text,
28};
29#[cfg(feature = "terminal-images")]
30pub use graphics::{TerminalImage, TerminalImageCrop, TerminalImagePlacement};
31pub use mod_private::Terminal;
32pub use osc::{
33    TerminalCommandPhase, TerminalSemanticEvent, TerminalSemanticState, TerminalWorkingDirectory,
34    TerminalWorkingDirectorySource,
35};
36#[cfg(unix)]
37pub use pty::TerminalPtyHandoff;
38pub use pty::{TerminalPty, TerminalPtyConfig, TerminalPtyError, TerminalPtyEvent};
39pub use screen::{
40    SemanticMark, SemanticMarkKind, TerminalCellSize, TerminalColorPalette, TerminalDecoration,
41    TerminalRenderSnapshot, TerminalScreen, TerminalScreenHandle, TerminalViewport,
42};
43#[cfg(feature = "terminal")]
44pub use selection::{
45    ScrollbackLineage, TerminalPos, TerminalSelection, TerminalSelectionEvent, absolute_line,
46    from_viewport, to_viewport, viewport_row,
47};
48
49pub(crate) use layout::{measure_terminal, terminal_content_layout, terminal_mouse_content_rect};
50pub(crate) use node::{TerminalNode, apply_terminal_selection_input};
51pub(crate) use reconcile::reconcile_terminal;
52
53#[cfg(feature = "terminal")]
54pub(crate) fn terminal_node_selection_text(
55    node: &TerminalNode,
56    sel: &TerminalSelection,
57    endpoint: crate::utils::SelectionEnd,
58    trim_row_end: bool,
59) -> String {
60    if let Some(screen) = node.screen.as_ref() {
61        return screen.selection_display_text(sel, endpoint, trim_row_end);
62    }
63    let Some(projected) = to_viewport(
64        sel,
65        node.scrollback_offset,
66        node.total_scrollback_rows,
67        node.viewport_rows,
68    ) else {
69        return String::new();
70    };
71    events::terminal_selection_text_with(&node.lines, &projected, endpoint, trim_row_end)
72}
73
74use crate::callback::{Callback, KeyHandler};
75use crate::core::element::{Element, ElementKind};
76use crate::style::{
77    BorderStyle, CaretShape, Color, Length, Padding, ScrollbarConfig, ScrollbarVariant, Span,
78    Style, StyleSlot,
79};
80use crate::widgets::ScrollEvent;
81use std::sync::Arc;
82
83impl Default for Terminal {
84    fn default() -> Self {
85        Self {
86            content: Arc::from(""),
87            cursor_row: 0,
88            cursor_col: 0,
89            show_cursor: true,
90            cursor_shape: CaretShape::Block,
91            cursor_blinking: true,
92            caret_color: None,
93            color_lines: None,
94            color_cache_key: 0,
95            screen: None,
96            decorations: Arc::from([] as [TerminalDecoration; 0]),
97            scrollback_offset: 0,
98            total_scrollback_rows: 0,
99            mouse_mode: MouseModeState::default(),
100            key_modes: TerminalKeyModes::default(),
101            #[cfg(feature = "terminal-images")]
102            images: Arc::from([]),
103            paste_shortcut_behavior: TerminalPasteShortcutBehavior::Forward,
104            selection: None,
105            selection_controlled: false,
106            selection_style: StyleSlot::Inherit,
107            on_selection: None,
108            on_resize: None,
109            on_mouse_forward: None,
110            scroll_wheel: true,
111            on_scroll: None,
112            on_scroll_to: None,
113            style: Style::default(),
114            hover_style: StyleSlot::Inherit,
115            focus_style: StyleSlot::Inherit,
116            focus_content_style: Style::default(),
117            border: false,
118            border_style: BorderStyle::default(),
119            padding: Padding::default(),
120            scrollbar: true,
121            scrollbar_variant: ScrollbarVariant::default(),
122            scrollbar_gap: 0,
123            scrollbar_thumb: None,
124            scrollbar_thumb_style: None,
125            scrollbar_thumb_focus_style: None,
126            scrollbar_track_style: None,
127            h_scrollbar: true,
128            h_scrollbar_variant: ScrollbarVariant::default(),
129            width: Length::Flex(1),
130            height: Length::Flex(1),
131            focusable: true,
132            tab_stop: true,
133            on_focus: None,
134            on_blur: None,
135            on_key: None,
136            on_input: None,
137        }
138    }
139}
140
141impl Terminal {
142    /// Create an empty terminal view.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Replace visible content.
148    pub fn content(mut self, content: impl Into<Arc<str>>) -> Self {
149        self.content = content.into();
150        self
151    }
152
153    /// Set cursor byte position in content.
154    pub fn cursor(mut self, cursor: usize) -> Self {
155        let (row, col) = byte_to_row_col(self.content.as_ref(), cursor);
156        self.cursor_row = row;
157        self.cursor_col = col;
158        self
159    }
160
161    /// Set cursor row/column in the visible viewport.
162    pub fn cursor_position(mut self, row: u16, col: u16) -> Self {
163        self.cursor_row = row;
164        self.cursor_col = col;
165        self
166    }
167
168    /// Toggle cursor rendering.
169    pub fn show_cursor(mut self, show_cursor: bool) -> Self {
170        self.show_cursor = show_cursor;
171        self
172    }
173
174    /// Set the cursor shape (block, bar, or underline).
175    pub fn cursor_shape(mut self, shape: CaretShape) -> Self {
176        self.cursor_shape = shape;
177        self
178    }
179
180    /// Set whether the cursor should blink.
181    pub fn cursor_blinking(mut self, blinking: bool) -> Self {
182        self.cursor_blinking = blinking;
183        self
184    }
185
186    /// Set the hardware caret color through OSC 12.
187    pub fn caret_color(mut self, color: Color) -> Self {
188        self.caret_color = Some(color);
189        self
190    }
191
192    /// Set precomputed colored lines (must match `content` line lengths).
193    ///
194    /// `cache_key` is advisory metadata for the caller. Terminal reconciliation does not use it
195    /// to skip work; use [`TerminalRenderSnapshot::decorated`] when deriving styled snapshots.
196    pub fn color_lines(mut self, color_lines: Arc<[Vec<Span>]>, cache_key: u64) -> Self {
197        self.color_lines = Some(color_lines);
198        self.color_cache_key = cache_key;
199        self
200    }
201
202    /// Read a live [`TerminalScreen`] instead of being handed a snapshot.
203    ///
204    /// Prefer this whenever the app owns the screen and feeds it output: the element stops carrying
205    /// the screen's contents, so output that used to force a `view()` + layout pass can be answered
206    /// with [`Update::paint`]. The runtime pulls the current snapshot immediately before each draw.
207    ///
208    /// [`snapshot`](Self::snapshot) still wins if both are set, so a caller that needs to hand over
209    /// a doctored snapshot can. To overlay a live screen instead, use
210    /// [`decorations`](Self::decorations).
211    ///
212    /// [`TerminalScreen`]: crate::widgets::TerminalScreen
213    /// [`Update::paint`]: crate::Update::paint
214    pub fn screen(mut self, screen: impl Into<TerminalScreenHandle>) -> Self {
215        self.screen = Some(screen.into());
216        self
217    }
218
219    /// Overlay decorations on the live screen's snapshot (search hits, hint labels).
220    ///
221    /// Applied on the way to the node, so they survive the paint-only refresh a live screen enables.
222    /// Ignored unless [`screen`](Self::screen) is set; a caller passing a snapshot applies
223    /// [`TerminalRenderSnapshot::decorated`] itself.
224    pub fn decorations(mut self, decorations: impl Into<Arc<[TerminalDecoration]>>) -> Self {
225        self.decorations = decorations.into();
226        self
227    }
228
229    /// Apply a full terminal render snapshot.
230    pub fn snapshot(mut self, snapshot: TerminalRenderSnapshot) -> Self {
231        self.content = snapshot.text;
232        self.cursor_row = snapshot.cursor_row;
233        self.cursor_col = snapshot.cursor_col;
234        self.show_cursor = snapshot.cursor_visible;
235        self.cursor_shape = snapshot.cursor_shape;
236        self.cursor_blinking = snapshot.cursor_blinking;
237        self.color_lines = Some(snapshot.color_lines);
238        self.color_cache_key = snapshot.sequence;
239        self.scrollback_offset = snapshot.scrollback_offset;
240        self.total_scrollback_rows = snapshot.total_scrollback_rows;
241        self.mouse_mode = snapshot.mouse_mode;
242        self.key_modes = snapshot.key_modes;
243        #[cfg(feature = "terminal-images")]
244        {
245            self.images = snapshot.images;
246        }
247        self
248    }
249
250    /// Set the child's input-affecting DEC modes directly.
251    ///
252    /// [`snapshot`](Self::snapshot) already carries these. Use this only when driving the widget
253    /// from something other than a `TerminalRenderSnapshot`.
254    pub fn key_modes(mut self, key_modes: TerminalKeyModes) -> Self {
255        self.key_modes = key_modes;
256        self
257    }
258
259    /// Configure direct `Ctrl+V` handling while this terminal has focus.
260    pub fn paste_shortcut_behavior(mut self, behavior: TerminalPasteShortcutBehavior) -> Self {
261        self.paste_shortcut_behavior = behavior;
262        self
263    }
264
265    /// Set base style.
266    pub fn style(mut self, style: Style) -> Self {
267        self.style = style;
268        self
269    }
270
271    /// Set hover style.
272    pub fn hover_style(mut self, style: Style) -> Self {
273        self.hover_style = StyleSlot::Replace(style);
274        self
275    }
276
277    /// Extend the active theme's hover style with additional fields.
278    pub fn extend_hover_style(mut self, style: Style) -> Self {
279        self.hover_style = StyleSlot::Extend(style);
280        self
281    }
282
283    /// Inherit hover style from the active theme.
284    pub fn inherit_hover_style(mut self) -> Self {
285        self.hover_style = StyleSlot::Inherit;
286        self
287    }
288
289    /// Set hover style slot directly for composite forwarding.
290    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
291        self.hover_style = slot;
292        self
293    }
294
295    /// Set focus chrome style.
296    pub fn focus_style(mut self, style: Style) -> Self {
297        self.focus_style = StyleSlot::Replace(style);
298        self
299    }
300
301    /// Extend the active theme's focus style with additional fields.
302    pub fn extend_focus_style(mut self, style: Style) -> Self {
303        self.focus_style = StyleSlot::Extend(style);
304        self
305    }
306
307    /// Inherit focus style from the active theme.
308    pub fn inherit_focus_style(mut self) -> Self {
309        self.focus_style = StyleSlot::Inherit;
310        self
311    }
312
313    /// Set focus style slot directly for composite forwarding.
314    pub fn focus_style_slot(mut self, slot: StyleSlot) -> Self {
315        self.focus_style = slot;
316        self
317    }
318
319    /// Set focused content text style.
320    pub fn focus_content_style(mut self, style: Style) -> Self {
321        self.focus_content_style = style;
322        self
323    }
324
325    /// Toggle border.
326    pub fn border(mut self, border: bool) -> Self {
327        self.border = border;
328        self
329    }
330
331    /// Set border style.
332    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
333        self.border_style = border_style;
334        self
335    }
336
337    /// Set inner padding.
338    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
339        self.padding = padding.into();
340        self
341    }
342
343    /// Toggle vertical scrollbar.
344    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
345        self.scrollbar = scrollbar;
346        self
347    }
348
349    /// Set scrollbar configuration.
350    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
351        self.scrollbar_variant = config.variant;
352        self.scrollbar_gap = config.gap;
353        self.scrollbar_thumb = config.thumb;
354        self.scrollbar_thumb_style = config.thumb_style;
355        self.scrollbar_thumb_focus_style = config.thumb_focus_style;
356        self.scrollbar_track_style = config.track_style;
357        self
358    }
359
360    /// Toggle horizontal scrollbar.
361    pub fn h_scrollbar(mut self, h_scrollbar: bool) -> Self {
362        self.h_scrollbar = h_scrollbar;
363        self
364    }
365
366    /// Set horizontal scrollbar style.
367    pub fn h_scrollbar_variant(mut self, style: ScrollbarVariant) -> Self {
368        self.h_scrollbar_variant = style;
369        self
370    }
371
372    /// Toggle mouse wheel scrolling through scrollback history.
373    pub fn scroll_wheel(mut self, scroll_wheel: bool) -> Self {
374        self.scroll_wheel = scroll_wheel;
375        self
376    }
377
378    /// Set callback for scroll events with full metrics.
379    pub fn on_scroll(mut self, cb: Callback<ScrollEvent>) -> Self {
380        self.on_scroll = Some(cb);
381        self
382    }
383
384    /// Set callback emitting the new scrollback offset on scroll.
385    ///
386    /// The offset is in scrollback rows: 0 = live (bottom), positive
387    /// values = scrolled into history. Use this to call
388    /// `TerminalScreen::set_scrollback(offset)` in your component.
389    pub fn on_scroll_to(mut self, cb: Callback<usize>) -> Self {
390        self.on_scroll_to = Some(cb);
391        self
392    }
393
394    /// Set selection highlight style.
395    pub fn selection_style(mut self, style: Style) -> Self {
396        self.selection_style = StyleSlot::Replace(style);
397        self
398    }
399
400    /// Extend the active theme's selection style with additional fields.
401    pub fn extend_selection_style(mut self, style: Style) -> Self {
402        self.selection_style = StyleSlot::Extend(style);
403        self
404    }
405
406    /// Inherit selection style from the active theme.
407    pub fn inherit_selection_style(mut self) -> Self {
408        self.selection_style = StyleSlot::Inherit;
409        self
410    }
411
412    /// Set selection style slot directly for composite forwarding.
413    pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
414        self.selection_style = slot;
415        self
416    }
417
418    /// Set current selection.
419    pub fn selection(mut self, selection: Option<TerminalSelection>) -> Self {
420        self.selection = selection;
421        self.selection_controlled = true;
422        self
423    }
424
425    /// Set selection change callback.
426    pub fn on_selection(mut self, cb: Callback<TerminalSelectionEvent>) -> Self {
427        self.on_selection = Some(cb);
428        self
429    }
430
431    /// Set callback fired synchronously when reconciliation observes a changed terminal viewport.
432    ///
433    /// The native runner may coalesce consecutive host resize events before reconciliation, so
434    /// this callback reports reconciled viewport sizes rather than one event for every host event.
435    pub fn on_resize(mut self, cb: Callback<TerminalViewport>) -> Self {
436        self.on_resize = Some(cb);
437        self
438    }
439
440    /// Set callback to forward mouse bytes to PTY.
441    pub fn on_mouse_forward(mut self, cb: Callback<Vec<u8>>) -> Self {
442        self.on_mouse_forward = Some(cb);
443        self
444    }
445
446    /// Set width.
447    pub fn width(mut self, width: Length) -> Self {
448        self.width = width;
449        self
450    }
451
452    /// Set height.
453    pub fn height(mut self, height: Length) -> Self {
454        self.height = height;
455        self
456    }
457
458    /// Control focusability.
459    pub fn focusable(mut self, focusable: bool) -> Self {
460        self.focusable = focusable;
461        self
462    }
463
464    /// Control whether the terminal participates in tab focus traversal.
465    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
466        self.tab_stop = tab_stop;
467        self
468    }
469
470    /// Set the callback fired when the terminal gains focus.
471    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
472        self.on_focus = Some(cb);
473        self
474    }
475
476    /// Set the callback fired when the terminal loses focus.
477    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
478        self.on_blur = Some(cb);
479        self
480    }
481
482    /// Set raw key handler.
483    pub fn on_key(mut self, handler: KeyHandler) -> Self {
484        self.on_key = Some(handler);
485        self
486    }
487
488    /// Set callback for terminal-encoded key input.
489    pub fn on_input(mut self, cb: Callback<TerminalInputEvent>) -> Self {
490        self.on_input = Some(cb);
491        self
492    }
493}
494
495impl From<Terminal> for Element {
496    fn from(mut terminal: Terminal) -> Self {
497        let on_input = terminal.on_input.clone();
498        let fallback_on_key = terminal.on_key.clone();
499        let key_modes = terminal.key_modes;
500        terminal.on_key = if on_input.is_some() || fallback_on_key.is_some() {
501            Some(KeyHandler::new(move |key| {
502                let mut handled = false;
503
504                if let Some(on_input) = on_input.as_ref()
505                    && let Some(bytes) = key_event_to_bytes(key, key_modes)
506                {
507                    on_input.emit(TerminalInputEvent {
508                        kind: TerminalInputKind::Key,
509                        key: Some(key),
510                        bytes: bytes.into(),
511                    });
512                    handled = true;
513                }
514
515                if let Some(handler) = fallback_on_key.as_ref() {
516                    handled = handler.handle(key) || handled;
517                }
518
519                handled
520            }))
521        } else {
522            None
523        };
524
525        Element::new(ElementKind::Terminal(terminal))
526    }
527}
528
529fn byte_to_row_col(value: &str, cursor: usize) -> (u16, u16) {
530    let cursor = cursor.min(value.len());
531    let mut row = 0u16;
532    let mut col = 0u16;
533    let mut seen = 0usize;
534
535    for ch in value.chars() {
536        if seen >= cursor {
537            break;
538        }
539        seen = seen.saturating_add(ch.len_utf8());
540        if ch == '\n' {
541            row = row.saturating_add(1);
542            col = 0;
543        } else {
544            col = col.saturating_add(1);
545        }
546    }
547
548    (row, col)
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::core::event::{KeyCode, KeyEvent, KeyMods, MouseButton, MouseEvent, MouseKind};
555
556    /// Encode a chord for a child that negotiated nothing (a shell, an editor).
557    fn enc(code: KeyCode, mods: KeyMods) -> Option<Vec<u8>> {
558        key_event_to_bytes(KeyEvent { code, mods }, TerminalKeyModes::default())
559    }
560
561    /// Encode a chord for a child that pushed the Kitty protocol's disambiguate flag, as
562    /// tui-lipan's own backend does on startup.
563    fn kitty(code: KeyCode, mods: KeyMods) -> Option<Vec<u8>> {
564        let modes = TerminalKeyModes {
565            kitty_keyboard: KittyKeyboardFlags {
566                disambiguate_escape_codes: true,
567                ..KittyKeyboardFlags::default()
568            },
569            ..TerminalKeyModes::default()
570        };
571        key_event_to_bytes(KeyEvent { code, mods }, modes)
572    }
573
574    const CTRL_SHIFT: KeyMods = KeyMods {
575        ctrl: true,
576        shift: true,
577        alt: false,
578        super_key: false,
579    };
580    const CTRL_ALT: KeyMods = KeyMods {
581        ctrl: true,
582        alt: true,
583        shift: false,
584        super_key: false,
585    };
586    const ALT_SHIFT: KeyMods = KeyMods {
587        alt: true,
588        shift: true,
589        ctrl: false,
590        super_key: false,
591    };
592
593    #[test]
594    fn ctrl_mapping_works() {
595        assert_eq!(enc(KeyCode::Char('c'), KeyMods::CTRL), Some(vec![3]));
596    }
597
598    #[test]
599    fn alt_prefixes_escape() {
600        assert_eq!(
601            enc(KeyCode::Char('x'), KeyMods::ALT),
602            Some(vec![0x1b, b'x'])
603        );
604    }
605
606    #[test]
607    fn ctrl_arrows_encode_xterm_modifier_param() {
608        // Ctrl+Left/Right drive word-wise motion in TUIs and must not collapse to a bare arrow.
609        for (code, expected) in [
610            (KeyCode::Left, "\x1b[1;5D"),
611            (KeyCode::Right, "\x1b[1;5C"),
612            (KeyCode::Up, "\x1b[1;5A"),
613            (KeyCode::Down, "\x1b[1;5B"),
614            (KeyCode::Home, "\x1b[1;5H"),
615            (KeyCode::End, "\x1b[1;5F"),
616        ] {
617            assert_eq!(
618                enc(code, KeyMods::CTRL),
619                Some(expected.as_bytes().to_vec()),
620                "ctrl {code:?}"
621            );
622        }
623    }
624
625    #[test]
626    fn shift_and_combined_modifiers_encode_params() {
627        assert_eq!(
628            enc(KeyCode::Left, KeyMods::SHIFT),
629            Some(b"\x1b[1;2D".to_vec())
630        );
631        // Ctrl+Shift => param 6; Ctrl+Alt => param 7 (no separate ESC prefix).
632        assert_eq!(enc(KeyCode::Right, CTRL_SHIFT), Some(b"\x1b[1;6C".to_vec()));
633        assert_eq!(enc(KeyCode::Up, CTRL_ALT), Some(b"\x1b[1;7A".to_vec()));
634        // Alt+Shift => param 4. Alt only suppresses the parameterized form when it stands alone,
635        // so this takes the CSI path rather than the ESC-prefixed one.
636        assert_eq!(enc(KeyCode::Left, ALT_SHIFT), Some(b"\x1b[1;4D".to_vec()));
637    }
638
639    #[test]
640    fn shift_only_emulator_reserved_keys_keep_plain_form() {
641        // Shift+Insert pastes and Shift+PageUp/PageDown page the scrollback by convention. The
642        // widget forwards them instead of consuming them, and children do not understand the
643        // parameterized form, so the plain bytes must survive or the key becomes a no-op.
644        for (code, expected) in [
645            (KeyCode::Insert, "\x1b[2~"),
646            (KeyCode::PageUp, "\x1b[5~"),
647            (KeyCode::PageDown, "\x1b[6~"),
648        ] {
649            assert_eq!(
650                enc(code, KeyMods::SHIFT),
651                Some(expected.as_bytes().to_vec()),
652                "shift {code:?}"
653            );
654        }
655
656        // Delete is not emulator-reserved, and adding Ctrl lifts the exemption entirely.
657        assert_eq!(
658            enc(KeyCode::Delete, KeyMods::SHIFT),
659            Some(b"\x1b[3;2~".to_vec())
660        );
661        assert_eq!(
662            enc(KeyCode::PageUp, CTRL_SHIFT),
663            Some(b"\x1b[5;6~".to_vec())
664        );
665    }
666
667    #[test]
668    fn ctrl_tilde_keys_encode_params() {
669        assert_eq!(
670            enc(KeyCode::Delete, KeyMods::CTRL),
671            Some(b"\x1b[3;5~".to_vec())
672        );
673        assert_eq!(
674            enc(KeyCode::PageUp, KeyMods::CTRL),
675            Some(b"\x1b[5;5~".to_vec())
676        );
677        // F5 uses param 15 in the tilde scheme; Shift keeps that number.
678        assert_eq!(
679            enc(KeyCode::F(5), KeyMods::SHIFT),
680            Some(b"\x1b[15;2~".to_vec())
681        );
682    }
683
684    #[test]
685    fn high_function_keys_encode_through_f20() {
686        // The tilde scheme skips 16, 22, 27 and 30, so F15 is 28 rather than 27.
687        assert_eq!(
688            enc(KeyCode::F(13), KeyMods::NONE),
689            Some(b"\x1b[25~".to_vec())
690        );
691        assert_eq!(
692            enc(KeyCode::F(15), KeyMods::NONE),
693            Some(b"\x1b[28~".to_vec())
694        );
695        assert_eq!(
696            enc(KeyCode::F(20), KeyMods::CTRL),
697            Some(b"\x1b[34;5~".to_vec())
698        );
699        // Past F20 there is no sequence to send, so the key is left for the app.
700        assert_eq!(enc(KeyCode::F(21), KeyMods::NONE), None);
701    }
702
703    #[test]
704    fn ctrl_backspace_sends_backward_kill_word() {
705        // Ctrl+Backspace has no native PTY encoding; emit ESC DEL (readline backward-kill-word,
706        // the same bytes as Alt+Backspace) so it deletes the previous word, not one character.
707        assert_eq!(
708            enc(KeyCode::Backspace, KeyMods::CTRL),
709            Some(vec![0x1b, 0x7f])
710        );
711        // Plain Backspace stays a bare DEL.
712        assert_eq!(enc(KeyCode::Backspace, KeyMods::NONE), Some(vec![0x7f]));
713        // Alt+Backspace keeps its historical ESC DEL form (the same word-delete chord).
714        assert_eq!(
715            enc(KeyCode::Backspace, KeyMods::ALT),
716            Some(vec![0x1b, 0x7f])
717        );
718    }
719
720    #[test]
721    fn legacy_children_keep_legacy_enter_tab_and_lose_ctrl_digits() {
722        // A child that never negotiated the Kitty protocol gets exactly the bytes it did before.
723        // Ctrl+Enter is indistinguishable from Enter, and Ctrl+1 has no encoding to send at all --
724        // inventing one (modifyOtherKeys) would hand crossterm children a sequence their parser
725        // rejects and silently discards, which is strictly worse than dropping the key here.
726        assert_eq!(enc(KeyCode::Enter, KeyMods::NONE), Some(vec![b'\r']));
727        assert_eq!(enc(KeyCode::Enter, KeyMods::CTRL), Some(vec![b'\r']));
728        assert_eq!(enc(KeyCode::Enter, CTRL_SHIFT), Some(vec![b'\r']));
729        assert_eq!(enc(KeyCode::Tab, KeyMods::NONE), Some(vec![b'\t']));
730        assert_eq!(enc(KeyCode::Tab, KeyMods::CTRL), Some(vec![b'\t']));
731        assert_eq!(
732            enc(KeyCode::BackTab, KeyMods::SHIFT),
733            Some(b"\x1b[Z".to_vec())
734        );
735        assert_eq!(enc(KeyCode::Esc, KeyMods::NONE), Some(vec![0x1b]));
736        // Alt alone keeps the ESC prefix, matching the arrows.
737        assert_eq!(enc(KeyCode::Enter, KeyMods::ALT), Some(vec![0x1b, b'\r']));
738        assert_eq!(enc(KeyCode::Char('1'), KeyMods::CTRL), None);
739    }
740
741    #[test]
742    fn kitty_children_get_csi_u_for_chords_with_no_legacy_encoding() {
743        // tui-lipan's own backend pushes DISAMBIGUATE_ESCAPE_CODES on startup, so a tui-lipan app
744        // running inside a Terminal widget lands here. Ctrl+1..Ctrl+9 are the motivating case:
745        // they have no legacy bytes, so before the protocol they simply never reached the child.
746        for (ch, expected) in [
747            ('1', "\x1b[49;5u"),
748            ('2', "\x1b[50;5u"),
749            ('9', "\x1b[57;5u"),
750        ] {
751            assert_eq!(
752                kitty(KeyCode::Char(ch), KeyMods::CTRL),
753                Some(expected.as_bytes().to_vec())
754            );
755        }
756
757        // Enter, Tab and Backspace become distinguishable from their unmodified selves.
758        assert_eq!(
759            kitty(KeyCode::Enter, KeyMods::CTRL),
760            Some(b"\x1b[13;5u".to_vec())
761        );
762        assert_eq!(
763            kitty(KeyCode::Enter, KeyMods::SHIFT),
764            Some(b"\x1b[13;2u".to_vec())
765        );
766        assert_eq!(
767            kitty(KeyCode::Tab, KeyMods::CTRL),
768            Some(b"\x1b[9;5u".to_vec())
769        );
770        assert_eq!(
771            kitty(KeyCode::Backspace, KeyMods::CTRL),
772            Some(b"\x1b[127;5u".to_vec())
773        );
774        // BackTab is Shift+Tab; the shift belongs in the parameter.
775        assert_eq!(
776            kitty(KeyCode::BackTab, KeyMods::NONE),
777            Some(b"\x1b[9;2u".to_vec())
778        );
779        // A lone Esc is escaped -- that is what "disambiguate escape codes" buys.
780        assert_eq!(
781            kitty(KeyCode::Esc, KeyMods::NONE),
782            Some(b"\x1b[27u".to_vec())
783        );
784
785        // The codepoint is the key as engraved, so Ctrl+Shift+C reports `c` with shift in the
786        // parameter and is finally distinct from Ctrl+C.
787        assert_eq!(
788            kitty(KeyCode::Char('c'), KeyMods::CTRL),
789            Some(b"\x1b[99;5u".to_vec())
790        );
791        assert_eq!(
792            kitty(KeyCode::Char('C'), CTRL_SHIFT),
793            Some(b"\x1b[99;6u".to_vec())
794        );
795        assert_eq!(
796            kitty(KeyCode::Char('x'), KeyMods::ALT),
797            Some(b"\x1b[120;3u".to_vec())
798        );
799    }
800
801    #[test]
802    fn kitty_children_keep_legacy_bytes_for_text_and_functional_keys() {
803        // Text still arrives as text: Shift alone does not promote a key to the escape form.
804        assert_eq!(
805            kitty(KeyCode::Char('a'), KeyMods::NONE),
806            Some(b"a".to_vec())
807        );
808        assert_eq!(
809            kitty(KeyCode::Char('A'), KeyMods::SHIFT),
810            Some(b"A".to_vec())
811        );
812        assert_eq!(kitty(KeyCode::Enter, KeyMods::NONE), Some(vec![b'\r']));
813        assert_eq!(kitty(KeyCode::Tab, KeyMods::NONE), Some(vec![b'\t']));
814        assert_eq!(kitty(KeyCode::Backspace, KeyMods::NONE), Some(vec![0x7f]));
815
816        // At this protocol level the arrows, tilde keys and function keys keep their unambiguous
817        // legacy sequences; Kitty only escapes them under `report_all_keys_as_escape_codes`.
818        assert_eq!(
819            kitty(KeyCode::Left, KeyMods::NONE),
820            Some(b"\x1b[D".to_vec())
821        );
822        assert_eq!(
823            kitty(KeyCode::Left, KeyMods::CTRL),
824            Some(b"\x1b[1;5D".to_vec())
825        );
826        assert_eq!(
827            kitty(KeyCode::PageUp, KeyMods::CTRL),
828            Some(b"\x1b[5;5~".to_vec())
829        );
830        assert_eq!(
831            kitty(KeyCode::F(5), KeyMods::NONE),
832            Some(b"\x1b[15~".to_vec())
833        );
834
835        // Super is still dropped: the protocol has a bit for it, but the chord belongs to the app.
836        assert_eq!(
837            kitty(
838                KeyCode::Char('1'),
839                KeyMods {
840                    super_key: true,
841                    ..KeyMods::default()
842                }
843            ),
844            None
845        );
846    }
847
848    #[test]
849    fn ctrl_punctuation_maps_to_control_codes() {
850        // Ctrl+/ and Ctrl+_ are both US (0x1f), which readline binds to `undo`. Before this these
851        // returned None and the key was dropped before it ever reached the child.
852        assert_eq!(enc(KeyCode::Char('/'), KeyMods::CTRL), Some(vec![0x1f]));
853        assert_eq!(enc(KeyCode::Char('_'), KeyMods::CTRL), Some(vec![0x1f]));
854        assert_eq!(enc(KeyCode::Char('?'), KeyMods::CTRL), Some(vec![0x7f]));
855        assert_eq!(enc(KeyCode::Char('@'), KeyMods::CTRL), Some(vec![0x00]));
856        assert_eq!(enc(KeyCode::Char(' '), KeyMods::CTRL), Some(vec![0x00]));
857        // xterm's digit aliases.
858        assert_eq!(enc(KeyCode::Char('2'), KeyMods::CTRL), Some(vec![0x00]));
859        assert_eq!(enc(KeyCode::Char('8'), KeyMods::CTRL), Some(vec![0x7f]));
860        // Ctrl+1 has no control code, so the app keeps the chord.
861        assert_eq!(enc(KeyCode::Char('1'), KeyMods::CTRL), None);
862    }
863
864    #[test]
865    fn super_modified_keys_are_dropped() {
866        // Super has no encoding. Sending the bare key would type a character the user never asked
867        // for, so the chord is left for the app to bind.
868        let sup = KeyMods {
869            super_key: true,
870            ..KeyMods::default()
871        };
872        assert_eq!(enc(KeyCode::Char('c'), sup), None);
873        assert_eq!(enc(KeyCode::Left, sup), None);
874        assert_eq!(
875            enc(
876                KeyCode::Char('v'),
877                KeyMods {
878                    super_key: true,
879                    ctrl: true,
880                    ..KeyMods::default()
881                }
882            ),
883            None
884        );
885    }
886
887    #[test]
888    fn plain_arrows_are_unmodified() {
889        assert_eq!(enc(KeyCode::Left, KeyMods::NONE), Some(b"\x1b[D".to_vec()));
890        // Alt alone keeps the historical ESC-prefix form.
891        assert_eq!(
892            enc(KeyCode::Left, KeyMods::ALT),
893            Some(b"\x1b\x1b[D".to_vec())
894        );
895    }
896
897    #[test]
898    fn app_cursor_mode_switches_unmodified_arrows_to_ss3() {
899        // DECCKM: ncurses emits `smkx` on startup and then matches arrows against terminfo's
900        // `kcuu1=\EOA`, so a child in application mode expects SS3 rather than CSI.
901        let modes = TerminalKeyModes {
902            app_cursor: true,
903            ..TerminalKeyModes::default()
904        };
905        let app = |code| {
906            key_event_to_bytes(
907                KeyEvent {
908                    code,
909                    mods: KeyMods::NONE,
910                },
911                modes,
912            )
913        };
914        assert_eq!(app(KeyCode::Up), Some(b"\x1bOA".to_vec()));
915        assert_eq!(app(KeyCode::Down), Some(b"\x1bOB".to_vec()));
916        assert_eq!(app(KeyCode::Right), Some(b"\x1bOC".to_vec()));
917        assert_eq!(app(KeyCode::Left), Some(b"\x1bOD".to_vec()));
918        assert_eq!(app(KeyCode::Home), Some(b"\x1bOH".to_vec()));
919        assert_eq!(app(KeyCode::End), Some(b"\x1bOF".to_vec()));
920
921        // Modified cursor keys stay on the CSI parameterized form regardless of DECCKM.
922        assert_eq!(
923            key_event_to_bytes(
924                KeyEvent {
925                    code: KeyCode::Left,
926                    mods: KeyMods::CTRL
927                },
928                modes
929            ),
930            Some(b"\x1b[1;5D".to_vec())
931        );
932        // The tilde keys are unaffected too.
933        assert_eq!(
934            key_event_to_bytes(
935                KeyEvent {
936                    code: KeyCode::PageUp,
937                    mods: KeyMods::NONE
938                },
939                modes
940            ),
941            Some(b"\x1b[5~".to_vec())
942        );
943    }
944
945    #[test]
946    fn paste_is_bracketed_only_when_the_child_asked_for_it() {
947        let off = TerminalKeyModes::default();
948        assert_eq!(encode_paste("hi", off), b"hi".to_vec());
949
950        let on = TerminalKeyModes {
951            bracketed_paste: true,
952            ..TerminalKeyModes::default()
953        };
954        assert_eq!(encode_paste("hi", on), b"\x1b[200~hi\x1b[201~".to_vec());
955    }
956
957    #[test]
958    fn terminal_buffer_trims_lines() {
959        let mut buffer = TerminalBuffer::new(2);
960        buffer.push_text("a\nb\nc\n");
961        let snapshot = buffer.snapshot();
962        assert_eq!(snapshot.as_ref(), "b\nc");
963    }
964
965    #[test]
966    fn terminal_screen_applies_vt_sequences() {
967        let mut screen = TerminalScreen::new(4, 20, 128);
968        screen.process_bytes(b"\x1b[31mhello\x1b[0m\nworld");
969        let snapshot = screen.snapshot();
970        let mut lines = snapshot.lines().map(str::trim);
971        assert_eq!(lines.next(), Some("hello"));
972        assert_eq!(lines.next(), Some("world"));
973    }
974
975    #[test]
976    fn mouse_event_to_bytes_sgr_encodes_coordinates() {
977        let event = MouseEvent {
978            x: 2,
979            y: 3,
980            kind: MouseKind::Down(MouseButton::Left),
981            mods: KeyMods::default(),
982        };
983        use super::events::MouseEncoding;
984
985        let bytes = mouse_event_to_bytes(event, MouseEncoding::Sgr, (0, 0)).expect("mouse bytes");
986        assert_eq!(String::from_utf8(bytes).unwrap(), "\u{1b}[<0;3;4M");
987    }
988
989    #[test]
990    fn mouse_event_to_bytes_sgr_encodes_plain_motion() {
991        // Any-event tracking (1003) reports motion without a pressed button as
992        // code 35 (3 "no button" + 32 motion flag). Dropping these leaves apps
993        // in the pane without hover positions.
994        let event = MouseEvent {
995            x: 26,
996            y: 5,
997            kind: MouseKind::Moved,
998            mods: KeyMods::default(),
999        };
1000        use super::events::MouseEncoding;
1001
1002        let bytes = mouse_event_to_bytes(event, MouseEncoding::Sgr, (0, 0)).expect("mouse bytes");
1003        assert_eq!(String::from_utf8(bytes).unwrap(), "\u{1b}[<35;27;6M");
1004    }
1005
1006    #[test]
1007    fn grid_selection_extracts_text() {
1008        use crate::utils::{GridPos, GridSelection};
1009        let selection = GridSelection {
1010            anchor: GridPos { row: 0, col: 1 },
1011            cursor: GridPos { row: 1, col: 2 },
1012        };
1013        let lines = vec!["abcd", "efgh", "ijkl"];
1014        assert_eq!(selection.extract_text(&lines), "bcd\nef");
1015    }
1016}