use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::index::Line as TermLine;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{Color as VtColor, NamedColor, Processor, Rgb};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as TuiLine, Span};
use termwiz::escape::csi::KittyKeyboardFlags;
use termwiz::input::{KeyCodeEncodeModes, KeyboardEncoding};
pub trait TerminalBackend: Send {
fn feed(&mut self, bytes: &[u8]);
fn resize(&mut self, cols: u16, rows: u16);
fn lines(&self, no_color: bool) -> Vec<TuiLine<'static>>;
fn encode_key(&self, key: &KeyEvent) -> Option<Vec<u8>>;
fn encode_paste(&self, text: &str) -> Vec<u8>;
fn drain_responses(&mut self) -> Vec<u8>;
fn scroll(&mut self, up: bool, page: bool);
fn scroll_to_bottom(&mut self);
fn is_scrolled(&self) -> bool;
fn alt_screen(&self) -> bool;
fn mouse_enabled(&self) -> bool;
fn encode_mouse(
&self,
kind: MouseEventKind,
col: u16,
row: u16,
mods: KeyModifiers,
) -> Option<Vec<u8>>;
}
const WHEEL_STEP: i32 = 3;
pub struct AlacrittyBackend {
term: Term<Proxy>,
parser: Processor,
proxy: Proxy,
}
#[derive(Clone)]
struct Proxy {
responses: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl EventListener for Proxy {
fn send_event(&self, event: Event) {
if let Event::PtyWrite(text) = event
&& let Ok(mut buf) = self.responses.lock()
{
buf.extend_from_slice(text.as_bytes());
}
}
}
struct Size {
cols: usize,
rows: usize,
}
impl Dimensions for Size {
fn total_lines(&self) -> usize {
self.rows
}
fn screen_lines(&self) -> usize {
self.rows
}
fn columns(&self) -> usize {
self.cols
}
}
impl AlacrittyBackend {
pub fn new(cols: u16, rows: u16) -> Self {
let proxy = Proxy {
responses: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
};
let size = Size {
cols: cols.max(2) as usize,
rows: rows.max(1) as usize,
};
let config = Config {
kitty_keyboard: true,
..Config::default()
};
Self {
term: Term::new(config, &size, proxy.clone()),
parser: Processor::new(),
proxy,
}
}
}
impl TerminalBackend for AlacrittyBackend {
fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes);
}
fn resize(&mut self, cols: u16, rows: u16) {
self.term.resize(Size {
cols: cols.max(2) as usize,
rows: rows.max(1) as usize,
});
}
fn lines(&self, no_color: bool) -> Vec<TuiLine<'static>> {
let cols = self.term.columns();
let rows = self.term.screen_lines();
let cursor = self.term.grid().cursor.point;
let cursor_visible = self.term.mode().contains(TermMode::SHOW_CURSOR);
let offset = self.term.grid().display_offset() as i32;
let mut cells: Vec<Vec<Span<'static>>> = (0..rows)
.map(|_| vec![Span::raw(" ".to_string()); cols])
.collect();
for indexed in self.term.grid().display_iter() {
let point = indexed.point;
let row = point.line.0 + offset;
if row < 0 {
continue; }
let (row, col) = (row as usize, point.column.0);
if row >= rows || col >= cols {
continue;
}
let cell = &indexed.cell;
let mut style = if no_color {
Style::default()
} else {
Style::default().fg(vt_color(cell.fg)).bg(vt_color(cell.bg))
};
use alacritty_terminal::term::cell::Flags;
if cell.flags.contains(Flags::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if cell.flags.contains(Flags::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.flags.contains(Flags::UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.flags.contains(Flags::INVERSE) {
style = style.add_modifier(Modifier::REVERSED);
}
if cell.flags.contains(Flags::DIM) {
style = style.add_modifier(Modifier::DIM);
}
if cursor_visible
&& point.line == TermLine(cursor.line.0)
&& point.column == cursor.column
{
style = style.add_modifier(Modifier::REVERSED);
}
cells[row][col] = Span::styled(cell.c.to_string(), style);
}
cells.into_iter().map(TuiLine::from).collect()
}
fn encode_key(&self, key: &KeyEvent) -> Option<Vec<u8>> {
encode_key(key, self.term.mode())
}
fn encode_paste(&self, text: &str) -> Vec<u8> {
let text = text.replace("\r\n", "\n");
if self.term.mode().contains(TermMode::BRACKETED_PASTE) {
let mut bytes = b"\x1b[200~".to_vec();
bytes.extend_from_slice(text.as_bytes());
bytes.extend_from_slice(b"\x1b[201~");
bytes
} else {
text.into_bytes()
}
}
fn drain_responses(&mut self) -> Vec<u8> {
match self.proxy.responses.lock() {
Ok(mut buf) => std::mem::take(&mut *buf),
Err(_) => Vec::new(),
}
}
fn scroll(&mut self, up: bool, page: bool) {
let magnitude = if page {
self.term.screen_lines() as i32
} else {
WHEEL_STEP
};
let delta = if up { magnitude } else { -magnitude };
self.term.scroll_display(Scroll::Delta(delta));
}
fn scroll_to_bottom(&mut self) {
self.term.scroll_display(Scroll::Bottom);
}
fn is_scrolled(&self) -> bool {
self.term.grid().display_offset() != 0
}
fn alt_screen(&self) -> bool {
self.term.mode().contains(TermMode::ALT_SCREEN)
}
fn mouse_enabled(&self) -> bool {
self.term.mode().intersects(TermMode::MOUSE_MODE)
}
fn encode_mouse(
&self,
kind: MouseEventKind,
col: u16,
row: u16,
mods: KeyModifiers,
) -> Option<Vec<u8>> {
let mode = self.term.mode();
if !mode.intersects(TermMode::MOUSE_MODE) {
return None; }
let (mut cb, release, motion) = match kind {
MouseEventKind::Down(b) => (button_code(b), false, false),
MouseEventKind::Up(b) => (button_code(b), true, false),
MouseEventKind::Drag(b) => {
if !mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) {
return None;
}
(button_code(b), false, true)
}
MouseEventKind::Moved => {
if !mode.contains(TermMode::MOUSE_MOTION) {
return None;
}
(3, false, true) }
MouseEventKind::ScrollUp => (64, false, false),
MouseEventKind::ScrollDown => (65, false, false),
MouseEventKind::ScrollLeft => (66, false, false),
MouseEventKind::ScrollRight => (67, false, false),
};
if motion {
cb += 32;
}
if mods.contains(KeyModifiers::SHIFT) {
cb += 4;
}
if mods.contains(KeyModifiers::ALT) {
cb += 8;
}
if mods.contains(KeyModifiers::CONTROL) {
cb += 16;
}
if mode.contains(TermMode::SGR_MOUSE) {
let suffix = if release { 'm' } else { 'M' };
Some(format!("\x1b[<{cb};{col};{row}{suffix}").into_bytes())
} else {
let cb = if release { (cb & !0b11) | 0b11 } else { cb };
let byte = |v: u16| (v.min(223) as u8).saturating_add(32);
Some(vec![0x1b, b'[', b'M', byte(cb), byte(col), byte(row)])
}
}
}
fn button_code(b: MouseButton) -> u16 {
match b {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
fn vt_color(c: VtColor) -> Color {
match c {
VtColor::Spec(Rgb { r, g, b }) => Color::Rgb(r, g, b),
VtColor::Indexed(i) => Color::Indexed(i),
VtColor::Named(n) => match n {
NamedColor::Black => Color::Black,
NamedColor::Red => Color::Red,
NamedColor::Green => Color::Green,
NamedColor::Yellow => Color::Yellow,
NamedColor::Blue => Color::Blue,
NamedColor::Magenta => Color::Magenta,
NamedColor::Cyan => Color::Cyan,
NamedColor::White => Color::Gray,
NamedColor::BrightBlack => Color::DarkGray,
NamedColor::BrightRed => Color::LightRed,
NamedColor::BrightGreen => Color::LightGreen,
NamedColor::BrightYellow => Color::LightYellow,
NamedColor::BrightBlue => Color::LightBlue,
NamedColor::BrightMagenta => Color::LightMagenta,
NamedColor::BrightCyan => Color::LightCyan,
NamedColor::BrightWhite => Color::White,
NamedColor::Foreground | NamedColor::BrightForeground => Color::Reset,
NamedColor::Background => Color::Reset,
_ => Color::Reset,
},
}
}
pub fn encode_key(key: &KeyEvent, mode: &TermMode) -> Option<Vec<u8>> {
let mut mods = map_mods(key.modifiers);
if key.code == KeyCode::BackTab {
mods |= termwiz::input::Modifiers::SHIFT;
}
if mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) {
let event = wezterm_input_types::KeyEvent {
key: map_key_kitty(key.code)?,
modifiers: mods,
leds: wezterm_input_types::KeyboardLedStatus::empty(),
repeat_count: 1,
key_is_down: true,
raw: None,
#[cfg(windows)]
win32_uni_char: None,
};
let encoded = event.encode_kitty(kitty_flags(mode));
if !encoded.is_empty() {
return Some(encoded.into_bytes());
}
}
let modes = KeyCodeEncodeModes {
encoding: KeyboardEncoding::Xterm,
application_cursor_keys: mode.contains(TermMode::APP_CURSOR),
newline_mode: mode.contains(TermMode::LINE_FEED_NEW_LINE),
modify_other_keys: None,
};
map_key(key.code)?
.encode(mods, modes, true)
.ok()
.filter(|s| !s.is_empty())
.map(String::into_bytes)
}
fn map_key_kitty(code: KeyCode) -> Option<wezterm_input_types::KeyCode> {
use wezterm_input_types::KeyCode as Wz;
Some(match code {
KeyCode::Char(c) => Wz::Char(c),
KeyCode::Enter => Wz::Char('\r'),
KeyCode::Tab | KeyCode::BackTab => Wz::Char('\t'),
KeyCode::Esc => Wz::Char('\u{1b}'),
KeyCode::Backspace => Wz::Char('\u{8}'),
KeyCode::Delete => Wz::Char('\u{7f}'),
KeyCode::Up => Wz::UpArrow,
KeyCode::Down => Wz::DownArrow,
KeyCode::Left => Wz::LeftArrow,
KeyCode::Right => Wz::RightArrow,
KeyCode::Home => Wz::Home,
KeyCode::End => Wz::End,
KeyCode::PageUp => Wz::PageUp,
KeyCode::PageDown => Wz::PageDown,
KeyCode::Insert => Wz::Insert,
KeyCode::F(n) => Wz::Function(n),
_ => return None,
})
}
fn kitty_flags(mode: &TermMode) -> KittyKeyboardFlags {
let mut flags = KittyKeyboardFlags::NONE;
flags.set(
KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES,
mode.contains(TermMode::DISAMBIGUATE_ESC_CODES),
);
flags.set(
KittyKeyboardFlags::REPORT_EVENT_TYPES,
mode.contains(TermMode::REPORT_EVENT_TYPES),
);
flags.set(
KittyKeyboardFlags::REPORT_ALTERNATE_KEYS,
mode.contains(TermMode::REPORT_ALTERNATE_KEYS),
);
flags.set(
KittyKeyboardFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES,
mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC),
);
flags.set(
KittyKeyboardFlags::REPORT_ASSOCIATED_TEXT,
mode.contains(TermMode::REPORT_ASSOCIATED_TEXT),
);
flags
}
fn map_key(code: KeyCode) -> Option<termwiz::input::KeyCode> {
use termwiz::input::KeyCode as Tw;
Some(match code {
KeyCode::Char(c) => Tw::Char(c),
KeyCode::Enter => Tw::Enter,
KeyCode::Tab | KeyCode::BackTab => Tw::Tab,
KeyCode::Backspace => Tw::Backspace,
KeyCode::Esc => Tw::Escape,
KeyCode::Up => Tw::UpArrow,
KeyCode::Down => Tw::DownArrow,
KeyCode::Left => Tw::LeftArrow,
KeyCode::Right => Tw::RightArrow,
KeyCode::Home => Tw::Home,
KeyCode::End => Tw::End,
KeyCode::PageUp => Tw::PageUp,
KeyCode::PageDown => Tw::PageDown,
KeyCode::Insert => Tw::Insert,
KeyCode::Delete => Tw::Delete,
KeyCode::F(n) => Tw::Function(n),
_ => return None,
})
}
fn map_mods(m: KeyModifiers) -> termwiz::input::Modifiers {
use termwiz::input::Modifiers as Tw;
let mut out = Tw::NONE;
if m.contains(KeyModifiers::SHIFT) {
out |= Tw::SHIFT;
}
if m.contains(KeyModifiers::ALT) {
out |= Tw::ALT;
}
if m.contains(KeyModifiers::CONTROL) {
out |= Tw::CTRL;
}
if m.contains(KeyModifiers::SUPER) {
out |= Tw::SUPER;
}
out
}
const OSC52_CAP: usize = 256 * 1024;
#[derive(Default)]
pub struct Osc52Scanner {
pending: Vec<u8>,
state: ScanState,
}
#[derive(Default, PartialEq)]
enum ScanState {
#[default]
Ground,
Esc,
Osc,
OscEsc,
}
impl Osc52Scanner {
pub fn scan(&mut self, bytes: &[u8]) -> Vec<Vec<u8>> {
let mut found = Vec::new();
for &b in bytes {
match self.state {
ScanState::Ground => {
if b == 0x1b {
self.state = ScanState::Esc;
}
}
ScanState::Esc => {
if b == b']' {
self.state = ScanState::Osc;
self.pending.clear();
self.pending.extend_from_slice(&[0x1b, b']']);
} else {
self.state = ScanState::Ground;
}
}
ScanState::Osc => {
if self.pending.len() > OSC52_CAP {
self.pending.clear();
self.state = ScanState::Ground;
} else if b == 0x07 {
self.pending.push(b);
self.finish(&mut found);
} else if b == 0x1b {
self.state = ScanState::OscEsc;
} else {
self.pending.push(b);
}
}
ScanState::OscEsc => {
if b == b'\\' {
self.pending.extend_from_slice(&[0x1b, b'\\']);
self.finish(&mut found);
} else {
self.pending.clear();
self.state = ScanState::Ground;
if b == b']' {
self.state = ScanState::Osc;
self.pending.extend_from_slice(&[0x1b, b']']);
}
}
}
}
}
found
}
fn finish(&mut self, found: &mut Vec<Vec<u8>>) {
let seq = std::mem::take(&mut self.pending);
self.state = ScanState::Ground;
if seq.get(2..5) == Some(b"52;") {
found.push(seq);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(backend: &AlacrittyBackend) -> String {
backend
.lines(true)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn feed_renders_text_into_the_grid() {
let mut b = AlacrittyBackend::new(20, 4);
b.feed(b"hello\r\nworld");
let t = text(&b);
assert!(t.contains("hello"), "{t:?}");
assert!(t.contains("world"), "{t:?}");
}
#[test]
fn scrollback_pages_reveal_history_and_snap_back() {
let mut b = AlacrittyBackend::new(20, 4);
for i in 0..20 {
b.feed(format!("L{i}\r\n").as_bytes());
}
assert!(!b.is_scrolled(), "starts pinned to the live bottom");
let bottom = text(&b);
assert!(bottom.contains("L19"), "latest output visible: {bottom:?}");
assert!(
!bottom.contains("L0 "),
"oldest not visible yet: {bottom:?}"
);
for _ in 0..6 {
b.scroll(true, true);
}
assert!(b.is_scrolled(), "view is pinned into history");
let top = text(&b);
assert!(top.contains("L0"), "oldest output now visible: {top:?}");
assert!(!top.contains("L19"), "live tail scrolled away: {top:?}");
b.scroll_to_bottom();
assert!(!b.is_scrolled(), "back at the live bottom");
assert!(text(&b).contains("L19"), "latest output visible again");
}
#[test]
fn alt_screen_flag_tracks_the_inner_app() {
let mut b = AlacrittyBackend::new(20, 4);
assert!(!b.alt_screen(), "main screen by default");
b.feed(b"\x1b[?1049h"); assert!(b.alt_screen(), "alt screen negotiated");
b.feed(b"\x1b[?1049l"); assert!(!b.alt_screen(), "back to the main screen");
}
#[test]
fn mouse_enabled_tracks_reporting_modes() {
let mut b = AlacrittyBackend::new(20, 4);
assert!(!b.mouse_enabled(), "off until negotiated");
b.feed(b"\x1b[?1000h"); assert!(b.mouse_enabled());
b.feed(b"\x1b[?1000l");
assert!(!b.mouse_enabled(), "cleared when the app disables it");
}
#[test]
fn sgr_mouse_encodes_wheel_click_and_modifiers() {
let mut b = AlacrittyBackend::new(40, 10);
assert_eq!(
b.encode_mouse(MouseEventKind::ScrollUp, 5, 3, KeyModifiers::NONE),
None,
"nothing to encode until the app tracks the mouse"
);
b.feed(b"\x1b[?1003h\x1b[?1006h");
assert_eq!(
b.encode_mouse(MouseEventKind::ScrollUp, 5, 3, KeyModifiers::NONE),
Some(b"\x1b[<64;5;3M".to_vec()),
"wheel up is button 64 at pane-relative coords"
);
assert_eq!(
b.encode_mouse(MouseEventKind::ScrollDown, 5, 3, KeyModifiers::NONE),
Some(b"\x1b[<65;5;3M".to_vec())
);
assert_eq!(
b.encode_mouse(
MouseEventKind::Down(MouseButton::Left),
1,
1,
KeyModifiers::NONE
),
Some(b"\x1b[<0;1;1M".to_vec()),
"press ends in M"
);
assert_eq!(
b.encode_mouse(
MouseEventKind::Up(MouseButton::Left),
1,
1,
KeyModifiers::NONE
),
Some(b"\x1b[<0;1;1m".to_vec()),
"release ends in m"
);
assert_eq!(
b.encode_mouse(
MouseEventKind::Down(MouseButton::Left),
2,
2,
KeyModifiers::CONTROL
),
Some(b"\x1b[<16;2;2M".to_vec()),
"Ctrl adds 16"
);
assert_eq!(
b.encode_mouse(MouseEventKind::Moved, 4, 4, KeyModifiers::NONE),
Some(b"\x1b[<35;4;4M".to_vec()),
"bare motion is no-button (3) + motion flag (32)"
);
}
#[test]
fn mouse_events_gate_on_the_negotiated_tracking_mode() {
let mut b = AlacrittyBackend::new(40, 10);
b.feed(b"\x1b[?1000h\x1b[?1006h"); assert_eq!(
b.encode_mouse(MouseEventKind::Moved, 3, 3, KeyModifiers::NONE),
None,
"no bare motion under click-only tracking"
);
assert_eq!(
b.encode_mouse(
MouseEventKind::Drag(MouseButton::Left),
3,
3,
KeyModifiers::NONE
),
None,
"no drag under click-only tracking"
);
b.feed(b"\x1b[?1002h"); assert_eq!(
b.encode_mouse(
MouseEventKind::Drag(MouseButton::Left),
3,
3,
KeyModifiers::NONE
),
Some(b"\x1b[<32;3;3M".to_vec()),
"drag now reports button 0 + motion flag 32"
);
}
#[test]
fn legacy_mouse_encodes_without_sgr() {
let mut b = AlacrittyBackend::new(40, 10);
b.feed(b"\x1b[?1000h"); assert_eq!(
b.encode_mouse(MouseEventKind::ScrollUp, 1, 1, KeyModifiers::NONE),
Some(vec![0x1b, b'[', b'M', 96, 33, 33])
);
assert_eq!(
b.encode_mouse(
MouseEventKind::Up(MouseButton::Left),
1,
1,
KeyModifiers::NONE
),
Some(vec![0x1b, b'[', b'M', 35, 33, 33])
);
}
#[test]
fn sgr_colors_map_to_ratatui() {
let mut b = AlacrittyBackend::new(20, 2);
b.feed(b"\x1b[31mred\x1b[0m plain");
let lines = b.lines(false);
let red = &lines[0].spans[0];
assert_eq!(red.content.as_ref(), "r");
assert_eq!(red.style.fg, Some(Color::Red));
}
#[test]
fn paste_brackets_only_when_the_inner_app_asked() {
let mut backend = AlacrittyBackend::new(20, 4);
assert_eq!(
backend.encode_paste("a\r\nb"),
b"a\nb".to_vec(),
"raw (CRLF-normalized) without DEC 2004"
);
backend.feed(b"\x1b[?2004h");
assert_eq!(
backend.encode_paste("hi"),
b"\x1b[200~hi\x1b[201~".to_vec(),
"wrapped once the inner app enables bracketed paste"
);
}
#[test]
fn resize_reflows_the_grid() {
let mut b = AlacrittyBackend::new(10, 3);
b.feed(b"0123456789");
b.resize(20, 5);
assert_eq!(b.lines(true).len(), 5, "grid tracks the new size");
b.feed(b"\x1b[1;1Hafter-resize");
assert!(text(&b).contains("after-resize"));
}
#[test]
fn device_attribute_queries_are_answered() {
let mut b = AlacrittyBackend::new(10, 3);
b.feed(b"\x1b[c"); let resp = b.drain_responses();
assert!(
resp.starts_with(b"\x1b[?"),
"emulator answers DA so the inner app never probes the void: {resp:?}"
);
assert!(b.drain_responses().is_empty(), "drained");
}
#[test]
fn ctrl_c_encodes_to_interrupt_not_quit() {
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(encode_key(&key, &TermMode::default()), Some(vec![0x03]));
}
#[test]
fn arrows_honor_application_cursor_mode() {
let up = KeyEvent::from(KeyCode::Up);
assert_eq!(
encode_key(&up, &TermMode::default()),
Some(b"\x1b[A".to_vec())
);
let mut b = AlacrittyBackend::new(20, 4);
b.feed(b"\x1b[?1h");
assert_eq!(b.encode_key(&up), Some(b"\x1bOA".to_vec()));
let ctrl_up = KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL);
assert_eq!(b.encode_key(&ctrl_up), Some(b"\x1b[1;5A".to_vec()));
}
#[test]
fn plain_and_alt_keys_encode() {
let m = TermMode::default();
let a = KeyEvent::from(KeyCode::Char('a'));
assert_eq!(encode_key(&a, &m), Some(b"a".to_vec()));
let alt_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
assert_eq!(encode_key(&alt_a, &m), Some(vec![0x1b, b'a']));
let enter = KeyEvent::from(KeyCode::Enter);
assert_eq!(encode_key(&enter, &m), Some(vec![b'\r']));
let del = KeyEvent::from(KeyCode::Delete);
assert_eq!(encode_key(&del, &m), Some(b"\x1b[3~".to_vec()));
let f5 = KeyEvent::from(KeyCode::F(5));
assert_eq!(encode_key(&f5, &m), Some(b"\x1b[15~".to_vec()));
let utf8 = KeyEvent::from(KeyCode::Char('é'));
assert_eq!(encode_key(&utf8, &m), Some("é".as_bytes().to_vec()));
let shift_tab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
assert_eq!(encode_key(&shift_tab, &m), Some(b"\x1b[Z".to_vec()));
}
#[test]
fn kitty_negotiation_switches_the_encoder() {
let mut b = AlacrittyBackend::new(20, 4);
let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(b.encode_key(&shift_enter), Some(vec![b'\r']));
assert_eq!(b.encode_key(&ctrl_c), Some(vec![0x03]));
b.feed(b"\x1b[>1u");
assert_eq!(
b.encode_key(&shift_enter),
Some(b"\x1b[13;2u".to_vec()),
"Shift-Enter becomes expressible under kitty"
);
assert_eq!(
b.encode_key(&ctrl_c),
Some(b"\x1b[99;5u".to_vec()),
"Ctrl-C is CSI-u encoded — still an interrupt to the app"
);
let a = KeyEvent::from(KeyCode::Char('a'));
assert_eq!(b.encode_key(&a), Some(b"a".to_vec()));
b.feed(b"\x1b[<u");
assert_eq!(b.encode_key(&shift_enter), Some(vec![b'\r']));
assert_eq!(b.encode_key(&ctrl_c), Some(vec![0x03]));
}
#[test]
fn osc52_scanner_extracts_clipboard_sequences_across_chunks() {
let mut sc = Osc52Scanner::default();
let seq = b"\x1b]52;c;aGVsbG8=\x07";
let found1 = sc.scan(&seq[..7]);
assert!(found1.is_empty(), "incomplete — nothing forwarded yet");
let found2 = sc.scan(&seq[7..]);
assert_eq!(found2, vec![seq.to_vec()], "forwarded verbatim");
}
#[test]
fn osc52_scanner_ignores_titles_and_respects_st_terminator() {
let mut sc = Osc52Scanner::default();
assert!(
sc.scan(b"\x1b]0;window title\x07").is_empty(),
"titles stay inside the pane"
);
let st = b"\x1b]52;c;YQ==\x1b\\";
assert_eq!(sc.scan(st), vec![st.to_vec()], "ST-terminated form");
}
#[test]
fn osc52_scanner_drops_oversized_sequences() {
let mut sc = Osc52Scanner::default();
let mut big = b"\x1b]52;c;".to_vec();
big.extend(std::iter::repeat_n(b'A', OSC52_CAP + 10));
big.push(0x07);
assert!(sc.scan(&big).is_empty(), "capped, never ballooned");
let ok = b"\x1b]52;c;YQ==\x07";
assert_eq!(sc.scan(ok), vec![ok.to_vec()]);
}
}