tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
use crate::Screen;
use crate::input::{KeyEvent, MouseEvent, MouseEventKind};
use crate::keys::{CoordEncoding, key_to_bytes, key_to_bytes_kitty, mouse_to_bytes};
use crate::screen::{CellPixelSize, TerminalMode};

/// Active [xterm mouse-reporting][xterm-ctlseqs] level, as derived from
/// DEC private modes `?9` (X10), `?1000` (button), `?1002` (cell-motion /
/// drag), and `?1003` (any-motion).
///
/// xterm mode bits are not mutually exclusive on the wire, but the effective
/// reporting behavior is: ?1003 dominates ?1002 dominates ?1000 dominates ?9.
/// Pixel mode (?1016) is an orthogonal coordinate encoding handled
/// elsewhere, not a level.
///
/// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum MouseReportLevel {
    #[default]
    None,
    /// `?9h`: X10 compatibility -- button-press events only. Release,
    /// motion, and wheel events are dropped at the encoder boundary.
    X10,
    /// `?1000h`: button press/release only. Motion (drag or pure) is dropped.
    Click,
    /// `?1002h`: button press/release plus motion while a button is held.
    Drag,
    /// `?1003h`: button events plus all motion regardless of button state.
    Any,
}

impl MouseReportLevel {
    fn from_screen(screen: &Screen) -> Self {
        if screen.mode(TerminalMode::MouseReportAllMotion) {
            Self::Any
        } else if screen.mode(TerminalMode::MouseReportCellMotion) {
            Self::Drag
        } else if screen.mode(TerminalMode::MouseReportClick) {
            Self::Click
        } else if screen.mode(TerminalMode::MouseReportX10) {
            Self::X10
        } else {
            Self::None
        }
    }

    fn allows(self, kind: MouseEventKind) -> bool {
        match self {
            Self::None => false,
            Self::X10 => matches!(kind, MouseEventKind::Down(_)),
            Self::Click => !matches!(kind, MouseEventKind::Drag(_) | MouseEventKind::Moved),
            Self::Drag => !matches!(kind, MouseEventKind::Moved),
            Self::Any => true,
        }
    }
}

/// Snapshot of screen state relevant to key encoding.
///
/// Cached by [`KeyEncoder::sync`] and exposed through
/// [`KeyEncoder::screen_state`]. Embedders that need to react to the same
/// state in higher-level callbacks (for example to override an encoded key)
/// receive this struct directly.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct KeyScreenState {
    /// Active Kitty keyboard enhancement flags (0 = legacy mode).
    pub kitty_keyboard_flags: u8,
    /// Whether the terminal is in application cursor mode (DECCKM).
    pub application_cursor: bool,
    /// Whether DECBKM (DECSET 67) is active.
    ///
    /// Affects only the legacy encoding path; the Kitty keyboard protocol
    /// delivers Backspace as a key event, not as an interpreted byte.
    pub backspace_bs: bool,
    /// Whether LNM (Line Feed/New Line Mode, CSI 20 h/l) is active.
    ///
    /// Affects only legacy Enter encoding; the Kitty keyboard protocol
    /// delivers Enter as a key event.
    pub line_feed_new_line: bool,
}

/// Standalone key encoder that can be synced from terminal state.
///
/// Decouples key encoding from the session, allowing callers to encode
/// keys without holding a reference to `Terminal`. Sync the encoder
/// from the screen whenever the terminal state may have changed (typically
/// on each frame or event loop iteration).
///
/// # Example
///
/// ```rust
/// # use tastty_core::{KeyEncoder, Parser, TerminalSize};
/// # use tastty_core::input::{KeyCode, KeyEvent, KeyModifiers};
/// let mut parser = Parser::new(TerminalSize { rows: 24, cols: 80 }, 0);
/// parser.process(b"\x1b[?1h");
///
/// let mut encoder = KeyEncoder::new();
/// encoder.sync(parser.screen());
///
/// let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
/// assert_eq!(encoder.encode_key(&key), Some(b"\x1bOA".to_vec()));
/// ```
///
/// # References
///
/// - [Kitty keyboard protocol][kitty-kbd]: progressive enhancement flags and the CSI u event format used when any flag is active.
/// - [xterm Control Sequences][xterm-ctlseqs]: legacy F-key / arrow-key encoding, DECCKM application-cursor mode, and modifyOtherKeys.
///
/// [kitty-kbd]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
/// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[derive(Clone, Debug, Default)]
pub struct KeyEncoder {
    state: KeyScreenState,
}

impl KeyEncoder {
    /// Create a new key encoder with default (legacy) terminal state.
    pub fn new() -> Self {
        Self::default()
    }

    /// Update the encoder state from a terminal screen snapshot.
    pub fn sync(&mut self, screen: &Screen) {
        self.state = KeyScreenState {
            kitty_keyboard_flags: screen.kitty_keyboard_flags(),
            application_cursor: screen.mode(TerminalMode::ApplicationCursor),
            backspace_bs: screen.mode(TerminalMode::BackspaceBs),
            line_feed_new_line: screen.mode(TerminalMode::LineFeedNewLine),
        };
    }

    /// Cached screen state captured at the most recent [`sync`](Self::sync).
    #[must_use]
    pub fn screen_state(&self) -> &KeyScreenState {
        &self.state
    }

    /// Encode a key event into the bytes that should be sent to the PTY.
    ///
    /// Returns `None` if no encoding is available: either legacy mode has no
    /// mapping for this key, or Kitty keyboard mode received an unrecognized
    /// `KeyCode` variant. `None` means drop the key; it must not be substituted with an
    /// empty or zero-valued byte sequence.
    pub fn encode_key(&self, key: &KeyEvent) -> Option<Vec<u8>> {
        if self.state.kitty_keyboard_flags != 0 {
            key_to_bytes_kitty(key, self.state.kitty_keyboard_flags)
        } else {
            key_to_bytes(
                key,
                self.state.application_cursor,
                self.state.backspace_bs,
                self.state.line_feed_new_line,
            )
        }
    }
}

/// Standalone mouse encoder that can be synced from terminal state.
///
/// Decouples mouse encoding from the session, allowing callers to encode
/// and filter mouse events independently.
///
/// # Example
///
/// ```rust
/// # use tastty_core::{MouseEncoder, Parser, TerminalSize};
/// # use tastty_core::input::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
/// let mut parser = Parser::new(TerminalSize { rows: 24, cols: 80 }, 0);
/// parser.process(b"\x1b[?1000h\x1b[?1006h");
///
/// let mut encoder = MouseEncoder::new();
/// encoder.sync(parser.screen());
///
/// let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 4, 9, KeyModifiers::NONE);
/// assert_eq!(encoder.encode_mouse(&event), Some(b"\x1b[<0;10;5M".to_vec()));
/// ```
///
/// # References
///
/// - [xterm Control Sequences][xterm-ctlseqs]: mouse tracking modes (?9 X10, ?1000 button, ?1002 cell-motion, ?1003 any-motion), SGR (?1006) and SGR-pixel (?1016) coordinate encodings, and alternate-scroll (?1007) wheel translation.
///
/// [xterm-ctlseqs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[derive(Clone, Debug, Default)]
pub struct MouseEncoder {
    level: MouseReportLevel,
    sgr_mouse: bool,
    sgr_pixel_mouse: bool,
    cell_pixel_size: CellPixelSize,
    alternate_scroll: bool,
    alternate_screen: bool,
    application_cursor: bool,
}

impl MouseEncoder {
    /// Create a new mouse encoder with default (no mouse reporting) state.
    pub fn new() -> Self {
        Self::default()
    }

    /// Update the encoder state from a terminal screen snapshot.
    pub fn sync(&mut self, screen: &Screen) {
        self.level = MouseReportLevel::from_screen(screen);
        self.sgr_mouse = screen.mode(TerminalMode::SgrMouse);
        self.sgr_pixel_mouse = screen.mode(TerminalMode::SgrPixelMouse);
        self.cell_pixel_size = screen.pixel_cell_size();
        self.alternate_scroll = screen.mode(TerminalMode::AlternateScroll);
        self.alternate_screen = screen.mode(TerminalMode::AlternateScreen);
        self.application_cursor = screen.mode(TerminalMode::ApplicationCursor);
    }

    /// Whether the terminal is currently accepting mouse events.
    #[must_use]
    pub fn mouse_enabled(&self) -> bool {
        !matches!(self.level, MouseReportLevel::None)
    }

    /// Per-cell pixel dimensions cached at last [`sync`](Self::sync).
    ///
    /// Sourced from [`Screen::pixel_cell_size`]. A zero dimension means the
    /// embedder has not configured pixel sizing; pixel-mode events fall back
    /// to cell-coordinate SGR encoding in that case.
    #[must_use]
    pub fn cell_pixel_size(&self) -> CellPixelSize {
        self.cell_pixel_size
    }

    /// Encode a mouse event into the bytes that should be sent to the PTY.
    ///
    /// Returns `None` if mouse reporting is disabled, if the active reporting
    /// level does not request this event kind (e.g. pure motion under
    /// `?1000h`), or if the event cannot be encoded (X10 coordinates out of
    /// range).
    ///
    /// When `?1016h` is active and a non-zero [`CellPixelSize`] is cached,
    /// coordinates are emitted as 1-based pixels (`x = col * cw + 1`,
    /// `y = row * ch + 1`); otherwise SGR cell coordinates are used. Pixel
    /// mode is orthogonal to the reporting level: drag and pure motion are
    /// still filtered as the level requires.
    pub fn encode_mouse(&self, event: &MouseEvent) -> Option<Vec<u8>> {
        if let Some(bytes) = self.alt_scroll_translation(event.kind) {
            return Some(bytes);
        }
        if !self.level.allows(event.kind) {
            return None;
        }
        mouse_to_bytes(event, self.coord_encoding())
    }

    /// DECSET 1007 wheel-to-arrow translation.
    ///
    /// Mouse reporting takes precedence: when any level is active
    /// the program asked for raw wheel bytes and ?1007 stands aside.
    /// Horizontal wheel events fall through because most programs
    /// do not bind them under ?1007.
    fn alt_scroll_translation(&self, kind: MouseEventKind) -> Option<Vec<u8>> {
        if !(self.alternate_scroll
            && self.alternate_screen
            && matches!(self.level, MouseReportLevel::None))
        {
            return None;
        }
        match (kind, self.application_cursor) {
            (MouseEventKind::ScrollUp, true) => Some(b"\x1bOA".to_vec()),
            (MouseEventKind::ScrollUp, false) => Some(b"\x1b[A".to_vec()),
            (MouseEventKind::ScrollDown, true) => Some(b"\x1bOB".to_vec()),
            (MouseEventKind::ScrollDown, false) => Some(b"\x1b[B".to_vec()),
            _ => None,
        }
    }

    fn coord_encoding(&self) -> CoordEncoding {
        if self.sgr_pixel_mouse
            && self.cell_pixel_size.width != 0
            && self.cell_pixel_size.height != 0
        {
            CoordEncoding::SgrPixel(self.cell_pixel_size)
        } else if self.sgr_mouse || self.sgr_pixel_mouse {
            CoordEncoding::Sgr
        } else {
            CoordEncoding::X10
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::input::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
    use crate::{Parser, TerminalSize};

    fn encoder_after(setup: &[u8]) -> MouseEncoder {
        let mut parser = Parser::new(TerminalSize { rows: 24, cols: 80 }, 0);
        parser.process(setup);
        let mut enc = MouseEncoder::new();
        enc.sync(parser.screen());
        enc
    }

    fn encoder_with_cell_size(setup: &[u8], cell: crate::screen::CellPixelSize) -> MouseEncoder {
        let mut parser = Parser::new(TerminalSize { rows: 24, cols: 80 }, 0);
        parser.screen_mut().set_pixel_cell_size(cell);
        parser.process(setup);
        let mut enc = MouseEncoder::new();
        enc.sync(parser.screen());
        enc
    }

    fn at(kind: MouseEventKind) -> MouseEvent {
        MouseEvent {
            kind,
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        }
    }

    #[test]
    fn key_encoder_legacy_char() {
        let enc = KeyEncoder::new();
        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
        assert_eq!(enc.encode_key(&key), Some(b"a".to_vec()));
    }

    #[test]
    fn key_encoder_application_cursor() {
        let mut enc = KeyEncoder::new();
        enc.state.application_cursor = true;
        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
        assert_eq!(enc.encode_key(&key), Some(b"\x1bOA".to_vec()));
    }

    #[test]
    fn key_encoder_kitty_mode() {
        let mut enc = KeyEncoder::new();
        enc.state.kitty_keyboard_flags = 1; // FLAG_DISAMBIGUATE
        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
        assert_eq!(enc.encode_key(&key), Some(b"\x1b[27u".to_vec()));
    }

    #[test]
    fn key_encoder_sync_picks_up_runtime_decbkm_flip() {
        // The encoder caches DECBKM at sync() time. After the inner app
        // toggles DECSET 67 at runtime, callers must call sync() again or
        // the encoder will keep emitting bytes for the stale mode. This
        // test pins both halves: the pre-sync staleness, and the post-sync
        // pickup.
        let mut parser = Parser::new(TerminalSize { rows: 24, cols: 80 }, 0);
        let mut enc = KeyEncoder::new();
        enc.sync(parser.screen());
        assert!(!enc.screen_state().backspace_bs);

        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
        assert_eq!(enc.encode_key(&key), Some(vec![0x7f]));

        parser.process(b"\x1b[?67h");
        assert!(parser.screen().mode(TerminalMode::BackspaceBs));
        assert_eq!(
            enc.encode_key(&key),
            Some(vec![0x7f]),
            "encoder must keep emitting the stale byte until sync() runs",
        );

        enc.sync(parser.screen());
        assert!(enc.screen_state().backspace_bs);
        assert_eq!(
            enc.encode_key(&key),
            Some(vec![0x08]),
            "after sync(), encoder must honor the runtime DECBKM flip",
        );
    }

    #[test]
    fn key_encoder_kitty_path_ignores_decbkm() {
        // The Kitty keyboard protocol delivers Backspace as a key event
        // with codepoint 127, not as an interpreted byte. DECBKM is a
        // legacy-encoding concept; the kitty path must produce the same
        // CSI u sequence whether DECBKM is on or off.
        let mut enc = KeyEncoder::new();
        enc.state.kitty_keyboard_flags = 1; // FLAG_DISAMBIGUATE
        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
        let baseline = enc.encode_key(&key);
        assert_eq!(baseline, Some(b"\x1b[127u".to_vec()));

        enc.state.backspace_bs = true;
        assert_eq!(
            enc.encode_key(&key),
            baseline,
            "kitty path must not honor DECBKM",
        );
    }

    #[test]
    fn mouse_encoder_disabled() {
        let enc = MouseEncoder::new();
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), None);
    }

    #[test]
    fn mouse_encoder_sgr() {
        let enc = encoder_after(b"\x1b[?1000h\x1b[?1006h");
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;10;5M".to_vec()));
    }

    #[test]
    fn mouse_encoder_click_level_drops_motion() {
        // ?1000h enables button press/release reporting only.
        let enc = encoder_after(b"\x1b[?1000h\x1b[?1006h");
        assert!(enc.mouse_enabled());
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Down(MouseButton::Left)))
                .is_some(),
            "press must be emitted at click level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Up(MouseButton::Left)))
                .is_some(),
            "release must be emitted at click level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_none(),
            "drag motion must be dropped at click level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_none(),
            "pure motion must be dropped at click level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::ScrollUp)).is_some(),
            "scroll wheel must be emitted at click level"
        );
    }

    #[test]
    fn mouse_encoder_drag_level_drops_pure_motion() {
        // ?1002h enables button events plus motion while a button is held.
        let enc = encoder_after(b"\x1b[?1002h\x1b[?1006h");
        assert!(enc.mouse_enabled());
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Down(MouseButton::Left)))
                .is_some()
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Up(MouseButton::Left)))
                .is_some()
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_some(),
            "drag motion must be emitted at drag level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_none(),
            "pure motion must be dropped at drag level"
        );
    }

    #[test]
    fn mouse_encoder_any_level_emits_pure_motion() {
        // ?1003h enables all motion reporting regardless of button state.
        let enc = encoder_after(b"\x1b[?1003h\x1b[?1006h");
        assert!(enc.mouse_enabled());
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_some()
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_some(),
            "pure motion must be emitted at any level"
        );
    }

    #[test]
    fn mouse_encoder_any_level_overrides_lower_modes() {
        // xterm precedence: ?1003 wins when more than one level bit is set.
        let enc = encoder_after(b"\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h");
        assert!(enc.encode_mouse(&at(MouseEventKind::Moved)).is_some());
    }

    #[test]
    fn mouse_encoder_disabled_after_reset() {
        // Enable, then disable; encoder must report no reporting active.
        let enc = encoder_after(b"\x1b[?1003h\x1b[?1003l");
        assert!(!enc.mouse_enabled());
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Down(MouseButton::Left)))
                .is_none()
        );
    }

    #[test]
    fn key_encoder_unshifted_codepoint() {
        let mut enc = KeyEncoder::new();
        enc.state.kitty_keyboard_flags = 1 | 4; // DISAMBIGUATE | REPORT_ALTERNATE
        // Simulate Shift+1 producing '!' with unshifted '1'
        let key =
            KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT).with_unshifted_codepoint('1');
        let bytes = enc.encode_key(&key).unwrap();
        let s = String::from_utf8(bytes).unwrap();
        // Base codepoint should be '1' (49), shifted should be '!' (33)
        assert_eq!(s, "\x1b[49:33;2u");
    }

    #[test]
    fn key_encoder_consumed_modifiers() {
        let mut enc = KeyEncoder::new();
        enc.state.kitty_keyboard_flags = 1; // DISAMBIGUATE
        // Shift+a producing 'A', shift is consumed
        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)
            .with_unshifted_codepoint('a')
            .with_consumed_modifiers(KeyModifiers::SHIFT);
        let bytes = enc.encode_key(&key).unwrap();
        let s = String::from_utf8(bytes).unwrap();
        // SHIFT is consumed, so effective modifiers = 1 (none), literal 'A'
        assert_eq!(s, "A");
    }

    #[test]
    fn mouse_encoder_sgr_pixel_basic() {
        // ?1006h + ?1016h with a 10x20 cell. Click at zero-based (col=5, row=3)
        // -> 1-based pixel coords x = 5*10 + 1 = 51, y = 3*20 + 1 = 61.
        let cell = crate::screen::CellPixelSize {
            width: 10,
            height: 20,
        };
        let enc = encoder_with_cell_size(b"\x1b[?1000h\x1b[?1006h\x1b[?1016h", cell);
        assert_eq!(enc.cell_pixel_size(), cell);
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 3,
            col: 5,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;51;61M".to_vec()));
    }

    #[test]
    fn mouse_encoder_sgr_pixel_implies_sgr_without_1006() {
        // ?1016h alone (without ?1006h) must still emit SGR-pixel format:
        // the protocol carries pixel coordinates that overflow legacy X10 bytes,
        // so ?1016 implies SGR encoding without needing ?1006.
        let cell = crate::screen::CellPixelSize {
            width: 8,
            height: 16,
        };
        let enc = encoder_with_cell_size(b"\x1b[?1000h\x1b[?1016h", cell);
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 0,
            col: 0,
            modifiers: KeyModifiers::NONE,
        };
        // x = 0*8 + 1 = 1, y = 0*16 + 1 = 1.
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;1;1M".to_vec()));
    }

    #[test]
    fn mouse_encoder_sgr_pixel_falls_back_when_cell_size_unknown() {
        // ?1006h + ?1016h but the embedder never configured cell pixel size.
        // The encoder cannot honor pixel precision, so it falls back to SGR
        // cell-coord encoding rather than emit nonsense pixel coords.
        let enc = encoder_after(b"\x1b[?1000h\x1b[?1006h\x1b[?1016h");
        assert_eq!(
            enc.cell_pixel_size(),
            crate::screen::CellPixelSize::default()
        );
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        // Cell-coord SGR: 1-based col=10, row=5.
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;10;5M".to_vec()));
    }

    #[test]
    fn mouse_encoder_sgr_pixel_release_uses_lowercase_m() {
        let cell = crate::screen::CellPixelSize {
            width: 10,
            height: 20,
        };
        let enc = encoder_with_cell_size(b"\x1b[?1000h\x1b[?1016h", cell);
        let event = MouseEvent {
            kind: MouseEventKind::Up(MouseButton::Left),
            row: 1,
            col: 2,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;21;21m".to_vec()));
    }

    #[test]
    fn mouse_encoder_alt_scroll_translates_wheel_to_arrows_on_alt_screen() {
        let enc = encoder_after(b"\x1b[?1049h\x1b[?1007h");
        assert!(!enc.mouse_enabled());
        assert_eq!(
            enc.encode_mouse(&at(MouseEventKind::ScrollUp)),
            Some(b"\x1b[A".to_vec()),
        );
        assert_eq!(
            enc.encode_mouse(&at(MouseEventKind::ScrollDown)),
            Some(b"\x1b[B".to_vec()),
        );
    }

    #[test]
    fn mouse_encoder_alt_scroll_honors_application_cursor() {
        // DECCKM (?1) shifts the arrow encoding from CSI (`\x1b[A/B`) to
        // SS3 (`\x1bOA/B`). The wheel-to-arrow translation must follow.
        let enc = encoder_after(b"\x1b[?1h\x1b[?1049h\x1b[?1007h");
        assert!(!enc.mouse_enabled());
        assert_eq!(
            enc.encode_mouse(&at(MouseEventKind::ScrollUp)),
            Some(b"\x1bOA".to_vec()),
        );
        assert_eq!(
            enc.encode_mouse(&at(MouseEventKind::ScrollDown)),
            Some(b"\x1bOB".to_vec()),
        );
    }

    #[test]
    fn mouse_encoder_alt_scroll_inactive_off_alt_screen() {
        // ?1007 alone on the primary screen must not translate. Without
        // mouse reporting either, the existing path drops the wheel; if
        // the gate forgot the alternate-screen condition, it would fire
        // and surface arrow bytes instead.
        let enc = encoder_after(b"\x1b[?1007h");
        assert!(!enc.mouse_enabled());
        assert_eq!(enc.encode_mouse(&at(MouseEventKind::ScrollUp)), None);
        assert_eq!(enc.encode_mouse(&at(MouseEventKind::ScrollDown)), None);
    }

    #[test]
    fn mouse_encoder_alt_scroll_inactive_when_mouse_reporting_active() {
        let enc = encoder_after(b"\x1b[?1049h\x1b[?1007h\x1b[?1000h\x1b[?1006h");
        let event = MouseEvent {
            kind: MouseEventKind::ScrollUp,
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<64;10;5M".to_vec()));
    }

    #[test]
    fn mouse_encoder_alt_scroll_horizontal_wheel_passes_through() {
        // With reporting off, the existing path drops these; a `None`
        // result therefore proves the alt-scroll gate did not fire on
        // a horizontal kind.
        let enc = encoder_after(b"\x1b[?1049h\x1b[?1007h");
        assert_eq!(enc.encode_mouse(&at(MouseEventKind::ScrollLeft)), None);
        assert_eq!(enc.encode_mouse(&at(MouseEventKind::ScrollRight)), None);
    }

    #[test]
    fn mouse_encoder_x10_level_press_only() {
        // ?9h enables X10 compatibility mode: button presses surface,
        // release / motion / wheel events are dropped at the encoder.
        let enc = encoder_after(b"\x1b[?9h");
        assert!(enc.mouse_enabled());
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Down(MouseButton::Left)))
                .is_some(),
            "press must be emitted at X10 level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Up(MouseButton::Left)))
                .is_none(),
            "release must be dropped at X10 level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_none(),
            "drag must be dropped at X10 level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_none(),
            "pure motion must be dropped at X10 level"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::ScrollUp)).is_none(),
            "wheel must be dropped at X10 level"
        );
    }

    #[test]
    fn mouse_encoder_x10_dominated_by_click() {
        // ?1000h dominates ?9h: release events surface again, drag/motion
        // remain dropped per Click semantics.
        let enc = encoder_after(b"\x1b[?9h\x1b[?1000h");
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Up(MouseButton::Left)))
                .is_some(),
            "Click level must surface release events even with X10 also set"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_none(),
            "Click level must drop drag even with X10 also set"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::ScrollUp)).is_some(),
            "Click level must surface wheel events even with X10 also set"
        );
    }

    #[test]
    fn mouse_encoder_x10_dominated_by_drag() {
        // ?1002h dominates ?9h: drag motion surfaces.
        let enc = encoder_after(b"\x1b[?9h\x1b[?1002h");
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_some(),
            "Drag level must surface drag motion even with X10 also set"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_none(),
            "Drag level must drop pure motion even with X10 also set"
        );
    }

    #[test]
    fn mouse_encoder_x10_dominated_by_any() {
        // ?1003h dominates ?9h: pure motion surfaces.
        let enc = encoder_after(b"\x1b[?9h\x1b[?1003h");
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_some(),
            "Any level must surface pure motion even with X10 also set"
        );
    }

    #[test]
    fn mouse_encoder_x10_uses_x10_wire_format_by_default() {
        // ?9h alone (no ?1006, no ?1016): the wire format defaults to X10
        // coordinate encoding, signalled by the legacy `\x1b[M` prefix.
        // X10 mode must not force its own encoding choice.
        let enc = encoder_after(b"\x1b[?9h");
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        let bytes = enc.encode_mouse(&event).expect("press must encode");
        assert!(
            bytes.starts_with(b"\x1b[M"),
            "expected legacy X10 wire prefix, got {bytes:?}"
        );
    }

    #[test]
    fn mouse_encoder_x10_with_sgr_uses_sgr_wire_format() {
        // ?9h + ?1006h: SGR wire format takes over, exactly as it does
        // for any other reporting level. X10 mode does not lock the
        // coordinate encoding.
        let enc = encoder_after(b"\x1b[?9h\x1b[?1006h");
        let event = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            row: 4,
            col: 9,
            modifiers: KeyModifiers::NONE,
        };
        assert_eq!(enc.encode_mouse(&event), Some(b"\x1b[<0;10;5M".to_vec()));
    }

    #[test]
    fn mouse_encoder_sgr_pixel_respects_level() {
        // Pixel mode is orthogonal to MouseReportLevel: ?1016 + Click level
        // still drops drag and pure motion. Otherwise C3's level invariant
        // would regress under pixel mode.
        let cell = crate::screen::CellPixelSize {
            width: 10,
            height: 20,
        };
        let enc = encoder_with_cell_size(b"\x1b[?1000h\x1b[?1016h", cell);
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Down(MouseButton::Left)))
                .is_some()
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Drag(MouseButton::Left)))
                .is_none(),
            "drag must be dropped at click level even with ?1016"
        );
        assert!(
            enc.encode_mouse(&at(MouseEventKind::Moved)).is_none(),
            "pure motion must be dropped at click level even with ?1016"
        );
    }
}