Skip to main content

omp_tui/
input.rs

1//! Terminal-agnostic keyboard and mouse input primitives.
2
3use std::time::{Duration, Instant};
4
5use omp_core::Str;
6use xutf::{IntoUnicodeNormalized, Text};
7
8use crate::rich::cell_width;
9
10// ---------------------------------------------------------------- events
11
12/// Decoded keyboard input, terminal-agnostic.
13#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
14pub enum Key {
15	/// Arrow up: widget-local cursor until the top edge, then focus ring.
16	Up,
17	/// Arrow down: widget-local cursor until the bottom edge, then ring.
18	Down,
19	/// Arrow left: chips/enums/number fields consume; else focus ring.
20	Left,
21	/// Arrow right: chips/enums/number fields consume; else focus ring.
22	Right,
23	/// Next focusable widget (always escapes the current widget).
24	Tab,
25	/// Previous focusable widget.
26	BackTab,
27	/// Activate / choose / newline (editor) / press (button).
28	Enter,
29	/// Toggle (checkbox/multi) or activate; literal space in text entry.
30	Space,
31	/// Close popup, then clear filter, then cancel the whole dialog.
32	Esc,
33	/// Delete before the cursor in text entry.
34	Backspace,
35	/// Delete under the cursor in text entry.
36	Delete,
37	/// Insert at the cursor.
38	Insert,
39	/// Jump to line/list start.
40	Home,
41	/// Jump to line/list end.
42	End,
43	/// Scroll one viewport up.
44	PageUp,
45	/// Scroll one viewport down.
46	PageDown,
47	/// Function key, numbered from F1 through F12.
48	Function(u8),
49	/// Ctrl-chord with a letter, normalized to lowercase. Text widgets
50	/// implement the readline set (`a e k u w b f d`); others ignore.
51	Ctrl(char),
52	/// Alt-chord with a letter, normalized to lowercase, for chords without
53	/// a canonical cross-terminal meaning (e.g. pi binds `alt+y` yank-pop).
54	/// Encoding variants of one physical intent (`alt+f`/`alt+b` word
55	/// motion, `alt+d` word delete, ESC-CR newline) normalize to their
56	/// semantic keys instead and never reach this variant.
57	Alt(char),
58	/// Ctrl+Alt chord, normalized to lowercase. Used by pi's backward
59	/// character-jump binding and available to embedders for other chords.
60	CtrlAlt(char),
61	/// Shift+Enter: literal newline in multiline text entry.
62	ShiftEnter,
63	/// Ctrl/Alt+Left: previous word boundary.
64	WordLeft,
65	/// Ctrl/Alt+Right: next word boundary.
66	WordRight,
67	/// Alt+D / Alt+Delete: delete forward through the next word end.
68	WordDelete,
69	/// Ctrl+V: host-driven clipboard paste, preferring images (see the runtime's
70	/// clipboard fallback).
71	Paste,
72	/// Ctrl+Shift+V: host-driven clipboard paste of text only, inserted
73	/// verbatim ([`crate::Component::paste_raw`]) — no image or file-URL
74	/// interpretation, no drop classification, no large-paste collapse.
75	PasteRaw,
76	/// Printable input: text entry, type-to-search (`/`), shortcuts.
77	Char(char),
78}
79
80const INPUT_TIMEOUT: Duration = Duration::from_millis(75);
81const PARTIAL_HOLD_TIMEOUT: Duration = Duration::from_millis(150);
82const PASTE_INACTIVITY_TIMEOUT: Duration = Duration::from_millis(1000);
83const KITTY_DEDUP_TIMEOUT: Duration = Duration::from_millis(25);
84const MAX_CSI_BYTES: usize = 4096;
85const MAX_STRING_SEQ_BYTES: usize = 16 * 1024 * 1024;
86const MAX_PASTE_BYTES: usize = 64 * 1024 * 1024;
87const PASTE_END: &[u8] = b"\x1b[201~";
88
89/// A terminal-generated reply separated from user key input.
90#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
91pub enum TerminalResponse {
92	/// Primary or secondary device attributes (`DA`).
93	DeviceAttributes(Str),
94	/// DEC private-mode report (`DECRPM`).
95	ModeReport {
96		/// Queried DEC mode.
97		mode:   u16,
98		/// Mode status reported by the terminal.
99		status: u8,
100	},
101	/// Device-status report (`DSR`), including cursor position.
102	DeviceStatus(Str),
103	/// Kitty keyboard protocol flags.
104	KittyKeyboardFlags(u8),
105	/// Operating-system command reply, without its framing bytes.
106	Osc(Str),
107	/// Kitty graphics APC reply, without its framing bytes.
108	KittyGraphics(Str),
109	/// Device-control string reply, without its framing bytes.
110	DeviceControlString(Str),
111	/// OSC 11 terminal background-color report.
112	OscColor {
113		/// OSC color-table index (11 for the terminal background).
114		index: u8,
115		/// Red component normalized to 16 bits.
116		r:     u16,
117		/// Green component normalized to 16 bits.
118		g:     u16,
119		/// Blue component normalized to 16 bits.
120		b:     u16,
121	},
122	/// DEC mode 2031 appearance notification (`1` dark, `2` light).
123	AppearanceChanged(u8),
124	/// DEC mode 2048 in-band resize report.
125	InBandResize {
126		/// Terminal rows.
127		rows: u16,
128		/// Terminal columns.
129		cols: u16,
130		/// Cell width in pixels.
131		x_px: u16,
132		/// Cell height in pixels.
133		y_px: u16,
134	},
135	/// Non-kitty application-program command reply, without its framing bytes.
136	ApplicationProgramCommand(Str),
137}
138/// Physical button encoded by an SGR mouse report.
139#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
140pub enum MouseButton {
141	/// Left button.
142	Left,
143	/// Middle button.
144	Middle,
145	/// Right button.
146	Right,
147	/// Vertical wheel up.
148	WheelUp,
149	/// Vertical wheel down.
150	WheelDown,
151	/// Horizontal wheel left.
152	WheelLeft,
153	/// Horizontal wheel right.
154	WheelRight,
155	/// Motion without a pressed button, or an unknown button code.
156	None,
157}
158
159/// Modifier bits attached to terminal input.
160#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
161pub struct Mods {
162	/// Shift was held.
163	pub shift:     bool,
164	/// Alt was held.
165	pub alt:       bool,
166	/// Control was held.
167	pub ctrl:      bool,
168	/// Super (Command/Windows) was held.
169	pub super_key: bool,
170	/// Hyper was held.
171	pub hyper:     bool,
172	/// Meta was held.
173	pub meta:      bool,
174}
175
176/// Lossless SGR mouse report with its routable gesture kind.
177#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
178pub struct MouseReport {
179	/// Gesture routed to widgets.
180	pub kind:    Mouse,
181	/// Zero-based column.
182	pub col:     u16,
183	/// Zero-based row.
184	pub row:     u16,
185	/// Physical button or wheel direction.
186	pub button:  MouseButton,
187	/// Keyboard modifiers encoded in the button bitfield.
188	pub mods:    Mods,
189	/// `true` for an `M` report and `false` for an `m` release report.
190	pub pressed: bool,
191}
192
193/// One framed event from the streaming terminal input decoder.
194#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
195pub enum InputEvent {
196	/// Keyboard input.
197	Key(Key),
198	/// Lossless SGR mouse input.
199	Mouse(MouseReport),
200	/// Sanitized bracketed-paste text.
201	Paste(Str),
202	/// Focus gained (`true`) or lost (`false`).
203	Focus(bool),
204	/// A terminal-generated capability or status reply.
205	Response(TerminalResponse),
206}
207
208/// Stateful terminal input framer.
209///
210/// Incomplete escape sequences and UTF-8 scalars remain buffered until a
211/// later [`feed`](Self::feed) call completes them or [`tick`](Self::tick)
212/// reaches their deterministic timeout.
213#[derive(Default)]
214pub struct InputDecoder {
215	keymap:                Keymap,
216	buffer:                Vec<u8>,
217	incomplete_since:      Option<Instant>,
218	kitty_keyboard_active: bool,
219	pending_kitty_print:   Option<(u32, Instant)>,
220	paste:                 Vec<u8>,
221	paste_active:          bool,
222	paste_last_input:      Option<Instant>,
223	paste_scan_from:       usize,
224}
225
226impl InputDecoder {
227	/// Creates an empty decoder using pi-compatible timeout and size limits.
228	pub fn new() -> Self {
229		Self {
230			keymap:                Keymap::default(),
231			buffer:                Vec::new(),
232			incomplete_since:      None,
233			kitty_keyboard_active: false,
234			pending_kitty_print:   None,
235			paste:                 Vec::new(),
236			paste_active:          false,
237			paste_last_input:      None,
238			paste_scan_from:       0,
239		}
240	}
241
242	/// Returns the active chord-to-key map.
243	pub const fn keymap(&self) -> &Keymap {
244		&self.keymap
245	}
246
247	/// Returns the active chord-to-key map for rebinding.
248	///
249	/// Changes apply to the next chord emitted by the decoder.
250	pub const fn keymap_mut(&mut self) -> &mut Keymap {
251		&mut self.keymap
252	}
253
254	/// Tells the framer whether Kitty keyboard reporting is active.
255	///
256	/// Active Kitty mode extends the hold for every partial escape because a
257	/// bare escape is then itself reported as CSI-u.
258	pub const fn set_kitty_keyboard(&mut self, active: bool) {
259		self.kitty_keyboard_active = active;
260	}
261
262	/// Feeds one arbitrary byte chunk and appends every completed event to
263	/// `out`.
264	pub fn feed(&mut self, bytes: &[u8], now: Instant, out: &mut Vec<InputEvent>) {
265		self.expire_paste(now, out);
266		self.expire_partial(now, out);
267		if self.paste_active {
268			self.paste.extend_from_slice(bytes);
269			self.paste_last_input = Some(now);
270			self.process_paste(now, out);
271			return;
272		}
273		self.buffer.extend_from_slice(bytes);
274		self.process_buffer(now, out);
275	}
276
277	/// Advances timeout-driven recovery without reading input.
278	pub fn tick(&mut self, now: Instant, out: &mut Vec<InputEvent>) {
279		self.expire_paste(now, out);
280		self.expire_partial(now, out);
281	}
282
283	/// Earliest instant at which [`tick`](Self::tick) could release buffered
284	/// input, or `None` when nothing is pending. May be conservative; `tick`
285	/// re-checks the active deadline.
286	pub fn deadline(&self) -> Option<Instant> {
287		[
288			self.incomplete_since.map(|at| at + INPUT_TIMEOUT),
289			self
290				.paste_last_input
291				.map(|at| at + PASTE_INACTIVITY_TIMEOUT),
292			self
293				.pending_kitty_print
294				.map(|(_, at)| at + KITTY_DEDUP_TIMEOUT),
295		]
296		.into_iter()
297		.flatten()
298		.min()
299	}
300
301	fn process_buffer(&mut self, now: Instant, out: &mut Vec<InputEvent>) {
302		loop {
303			if self.buffer.is_empty() {
304				self.incomplete_since = None;
305				return;
306			}
307			if self.buffer.starts_with(b"\x1b\x1b[<") {
308				self.emit(Decoded::Chord(Chord::plain(Key::Esc)), now, out);
309				self.buffer.drain(..1);
310				continue;
311			}
312			let resolution = resolve_frame(&self.buffer);
313			match resolution {
314				FrameResolution::Incomplete => {
315					self.incomplete_since.get_or_insert(now);
316					return;
317				},
318				FrameResolution::Overflow(length) => {
319					if !emit_unterminated_response(&self.buffer[..length], out) {
320						emit_raw(&self.buffer[..length], &self.keymap, out);
321					}
322					self.pending_kitty_print = None;
323					self.buffer.drain(..length);
324					self.incomplete_since = None;
325				},
326				FrameResolution::Complete(length) => {
327					let decoded = decode_frame(&self.buffer[..length]);
328					self.buffer.drain(..length);
329					self.incomplete_since = None;
330					if matches!(decoded, Decoded::PasteStart) {
331						self.paste_active = true;
332						self.paste_last_input = Some(now);
333						self.paste_scan_from = 0;
334						self.paste.clear();
335						self.paste.append(&mut self.buffer);
336						self.process_paste(now, out);
337						if self.paste_active {
338							return;
339						}
340					} else {
341						self.emit(decoded, now, out);
342					}
343				},
344			}
345		}
346	}
347
348	fn process_paste(&mut self, now: Instant, out: &mut Vec<InputEvent>) {
349		let start = self.paste_scan_from.saturating_sub(PASTE_END.len() - 1);
350		let end = self.paste[start..]
351			.windows(PASTE_END.len())
352			.position(|window| window == PASTE_END)
353			.map(|offset| start + offset);
354		if let Some(end) = end {
355			let remaining = self.paste.split_off(end + PASTE_END.len());
356			self.paste.truncate(end);
357			self.finish_paste(out);
358			self.buffer = remaining;
359			self.process_buffer(now, out);
360			return;
361		}
362		self.paste_scan_from = self.paste.len();
363		if self.paste.len() > MAX_PASTE_BYTES {
364			self.finish_paste(out);
365		}
366	}
367
368	fn finish_paste(&mut self, out: &mut Vec<InputEvent>) {
369		let bytes = std::mem::take(&mut self.paste);
370		let decoded = decode_reencoded_paste_controls(&bytes);
371		out.push(InputEvent::Paste(Str::from(sanitize_paste(&decoded))));
372		self.paste_active = false;
373		self.paste_last_input = None;
374		self.paste_scan_from = 0;
375		self.pending_kitty_print = None;
376	}
377
378	fn expire_paste(&mut self, now: Instant, out: &mut Vec<InputEvent>) {
379		if self.paste_active
380			&& self
381				.paste_last_input
382				.is_some_and(|last| now.saturating_duration_since(last) >= PASTE_INACTIVITY_TIMEOUT)
383		{
384			self.finish_paste(out);
385		}
386	}
387
388	fn expire_partial(&mut self, now: Instant, out: &mut Vec<InputEvent>) {
389		let Some(since) = self.incomplete_since else {
390			return;
391		};
392		let extended = self.kitty_keyboard_active || is_sgr_mouse_partial(&self.buffer);
393		let timeout = if extended {
394			INPUT_TIMEOUT + PARTIAL_HOLD_TIMEOUT
395		} else {
396			INPUT_TIMEOUT
397		};
398		if now.saturating_duration_since(since) < timeout {
399			return;
400		}
401		let buffered = std::mem::take(&mut self.buffer);
402		self.incomplete_since = None;
403		self.pending_kitty_print = None;
404		if !emit_unterminated_response(&buffered, out) {
405			if buffered == b"\x1b\x1b" {
406				emit_chord(&self.keymap, Chord::plain(Key::Esc), out);
407				emit_chord(&self.keymap, Chord::plain(Key::Esc), out);
408			} else {
409				emit_raw(&buffered, &self.keymap, out);
410			}
411		}
412	}
413
414	fn emit(&mut self, decoded: Decoded, now: Instant, out: &mut Vec<InputEvent>) {
415		let (event, chord, kitty_printable) = match decoded {
416			Decoded::Event(event) => (Some(event), None, false),
417			Decoded::Chord(chord) => (None, Some(chord), false),
418			Decoded::KittyChord(chord) => (None, Some(chord), true),
419			Decoded::PasteStart | Decoded::None => return,
420		};
421		let printable = chord.and_then(chord_printable_codepoint);
422		if printable.is_some_and(|printable| {
423			self.pending_kitty_print.is_some_and(|(codepoint, at)| {
424				printable == codepoint && now.saturating_duration_since(at) <= KITTY_DEDUP_TIMEOUT
425			})
426		}) {
427			self.pending_kitty_print = None;
428			return;
429		}
430		self.pending_kitty_print = if kitty_printable {
431			printable.map(|codepoint| (codepoint, now))
432		} else {
433			None
434		};
435		if let Some(InputEvent::Response(TerminalResponse::KittyKeyboardFlags(flags))) = event {
436			self.kitty_keyboard_active = flags != 0;
437			out.push(InputEvent::Response(TerminalResponse::KittyKeyboardFlags(flags)));
438		} else if let Some(event) = event {
439			out.push(event);
440		} else if let Some(chord) = chord {
441			emit_chord(&self.keymap, chord, out);
442		}
443	}
444}
445
446#[derive(Clone, Debug)]
447enum Decoded {
448	Event(InputEvent),
449	Chord(Chord),
450	KittyChord(Chord),
451	PasteStart,
452	None,
453}
454
455enum FrameResolution {
456	Complete(usize),
457	Incomplete,
458	Overflow(usize),
459}
460
461fn resolve_frame(bytes: &[u8]) -> FrameResolution {
462	if bytes[0] != 0x1b {
463		let width = utf8_width(bytes[0]);
464		return if width > bytes.len() {
465			FrameResolution::Incomplete
466		} else {
467			FrameResolution::Complete(width.max(1))
468		};
469	}
470	if bytes.len() == 1 {
471		return FrameResolution::Incomplete;
472	}
473	if bytes[1] == 0x1b {
474		if bytes.len() == 2 {
475			return FrameResolution::Incomplete;
476		}
477		if matches!(bytes[2], b'[' | b'O') {
478			return match resolve_escape(&bytes[1..]) {
479				FrameResolution::Complete(length) => FrameResolution::Complete(length + 1),
480				FrameResolution::Overflow(length) => FrameResolution::Overflow(length + 1),
481				FrameResolution::Incomplete => FrameResolution::Incomplete,
482			};
483		}
484		return FrameResolution::Complete(1);
485	}
486	resolve_escape(bytes)
487}
488
489fn resolve_escape(bytes: &[u8]) -> FrameResolution {
490	match bytes[1] {
491		b'[' => {
492			if bytes.len() < 3 {
493				return FrameResolution::Incomplete;
494			}
495			if bytes[2] == b'M' {
496				return if bytes.len() >= 6 {
497					FrameResolution::Complete(6)
498				} else {
499					FrameResolution::Incomplete
500				};
501			}
502			let limit = bytes.len().min(MAX_CSI_BYTES);
503			let sgr = bytes[2] == b'<';
504			for index in 2..limit {
505				if (0x40..=0x7e).contains(&bytes[index]) {
506					if !sgr {
507						return FrameResolution::Complete(index + 1);
508					}
509					if matches!(bytes[index], b'M' | b'm') && valid_sgr_body(&bytes[2..=index]) {
510						return FrameResolution::Complete(index + 1);
511					}
512				}
513			}
514			if bytes.len() >= MAX_CSI_BYTES {
515				FrameResolution::Overflow(MAX_CSI_BYTES)
516			} else {
517				FrameResolution::Incomplete
518			}
519		},
520		b']' | b'P' | b'_' => {
521			let limit = bytes.len().min(MAX_STRING_SEQ_BYTES);
522			for index in 2..limit {
523				if bytes[1] == b']' && bytes[index] == 0x07 {
524					return FrameResolution::Complete(index + 1);
525				}
526				if bytes[index] == 0x1b && index + 1 < limit && bytes[index + 1] == b'\\' {
527					return FrameResolution::Complete(index + 2);
528				}
529			}
530			if bytes.len() >= MAX_STRING_SEQ_BYTES {
531				FrameResolution::Overflow(MAX_STRING_SEQ_BYTES)
532			} else {
533				FrameResolution::Incomplete
534			}
535		},
536		b'O' => {
537			if bytes.len() >= 3 {
538				FrameResolution::Complete(3)
539			} else {
540				FrameResolution::Incomplete
541			}
542		},
543		_ => {
544			let width = utf8_width(bytes[1]);
545			if bytes.len() > width {
546				FrameResolution::Complete(width + 1)
547			} else {
548				FrameResolution::Incomplete
549			}
550		},
551	}
552}
553
554fn decode_frame(bytes: &[u8]) -> Decoded {
555	if bytes[0] != 0x1b {
556		return decode_plain(bytes, false).map_or(Decoded::None, Decoded::Chord);
557	}
558	if bytes == b"\x1b" {
559		return Decoded::Chord(Chord::plain(Key::Esc));
560	}
561	let (sequence, meta) = if bytes.starts_with(b"\x1b\x1b") {
562		(&bytes[1..], true)
563	} else {
564		(bytes, false)
565	};
566	match sequence.get(1) {
567		Some(b'[') => {
568			decode_csi(&sequence[2..sequence.len() - 1], sequence[sequence.len() - 1], meta)
569		},
570		Some(b'O') => {
571			let key = match sequence[2] {
572				b'A' => Some(Key::Up),
573				b'B' => Some(Key::Down),
574				b'C' => Some(Key::Right),
575				b'D' => Some(Key::Left),
576				b'H' => Some(Key::Home),
577				b'F' => Some(Key::End),
578				b'P'..=b'S' => Some(Key::Function(sequence[2] - b'P' + 1)),
579				_ => None,
580			};
581			key.map_or(Decoded::None, |key| {
582				Decoded::Chord(Chord::with_modifiers(key, u32::from(meta) * 2))
583			})
584		},
585		Some(b']') => {
586			let end = if sequence.ends_with(b"\x1b\\") {
587				sequence.len() - 2
588			} else {
589				sequence.len() - 1
590			};
591			let payload = &sequence[2..end];
592			if let Some((index, r, g, b)) = parse_osc_color(payload) {
593				Decoded::Event(InputEvent::Response(TerminalResponse::OscColor { index, r, g, b }))
594			} else {
595				Decoded::Event(InputEvent::Response(TerminalResponse::Osc(decode_text(payload))))
596			}
597		},
598		Some(b'P') => {
599			let end = sequence.len().saturating_sub(2);
600			Decoded::Event(InputEvent::Response(TerminalResponse::DeviceControlString(decode_text(
601				&sequence[2..end],
602			))))
603		},
604		Some(b'_') => {
605			let end = sequence.len().saturating_sub(2);
606			let payload = &sequence[2..end];
607			if let Some(payload) = payload.strip_prefix(b"G") {
608				Decoded::Event(InputEvent::Response(TerminalResponse::KittyGraphics(decode_text(
609					payload,
610				))))
611			} else {
612				Decoded::Event(InputEvent::Response(TerminalResponse::ApplicationProgramCommand(
613					decode_text(payload),
614				)))
615			}
616		},
617		_ => decode_plain(&sequence[1..], true).map_or(Decoded::None, Decoded::Chord),
618	}
619}
620
621fn decode_csi(body: &[u8], final_byte: u8, meta: bool) -> Decoded {
622	if final_byte == b'c' && matches!(body.first(), Some(b'?' | b'>')) {
623		return Decoded::Event(InputEvent::Response(TerminalResponse::DeviceAttributes(
624			decode_text(body),
625		)));
626	}
627	if final_byte == b'y'
628		&& let Some(fields) = body
629			.strip_prefix(b"?")
630			.and_then(|body| body.strip_suffix(b"$"))
631	{
632		let mut fields = fields.split(|byte| *byte == b';');
633		if let (Some(mode), Some(status)) =
634			(fields.next().and_then(parse_decimal_u16), fields.next().and_then(parse_decimal_u8))
635		{
636			return Decoded::Event(InputEvent::Response(TerminalResponse::ModeReport {
637				mode,
638				status,
639			}));
640		}
641	}
642	if final_byte == b'u'
643		&& let Some(flags) = body.strip_prefix(b"?").and_then(parse_decimal_u8)
644	{
645		return Decoded::Event(InputEvent::Response(TerminalResponse::KittyKeyboardFlags(flags)));
646	}
647	if final_byte == b'n'
648		&& let Some(appearance) = parse_appearance_response(body)
649	{
650		return Decoded::Event(InputEvent::Response(TerminalResponse::AppearanceChanged(appearance)));
651	}
652	if final_byte == b't'
653		&& let Some((rows, cols, x_px, y_px)) = parse_in_band_resize(body)
654	{
655		return Decoded::Event(InputEvent::Response(TerminalResponse::InBandResize {
656			rows,
657			cols,
658			x_px,
659			y_px,
660		}));
661	}
662	if matches!(final_byte, b'n' | b'R') {
663		return Decoded::Event(InputEvent::Response(TerminalResponse::DeviceStatus(decode_text(
664			body,
665		))));
666	}
667	if body.is_empty() && matches!(final_byte, b'I' | b'O') {
668		return Decoded::Event(InputEvent::Focus(final_byte == b'I'));
669	}
670	if final_byte == b'~' && body == b"200" {
671		return Decoded::PasteStart;
672	}
673	if body.starts_with(b"<") && matches!(final_byte, b'M' | b'm') {
674		return decode_sgr_mouse(body, final_byte);
675	}
676	if final_byte == b'u' {
677		return decode_kitty_key(body, meta);
678	}
679	if final_byte == b'~' {
680		return decode_tilde_key(body, meta);
681	}
682	let mut fields = body.split(|byte| *byte == b';');
683	let first = fields.next().unwrap_or_default();
684	let modifiers = if first == b"1" {
685		fields.next().and_then(parse_modifier).unwrap_or(0)
686	} else {
687		0
688	} | if meta { 2 } else { 0 };
689	let key = match final_byte {
690		b'A' => Some(Key::Up),
691		b'B' => Some(Key::Down),
692		b'C' => Some(Key::Right),
693		b'D' => Some(Key::Left),
694		b'H' => Some(Key::Home),
695		b'F' => Some(Key::End),
696		b'Z' => Some(Key::Tab),
697		_ => None,
698	};
699	let modifiers = modifiers | u32::from(final_byte == b'Z');
700	key.map_or(Decoded::None, |key| Decoded::Chord(Chord::with_modifiers(key, modifiers)))
701}
702
703fn decode_kitty_key(body: &[u8], meta: bool) -> Decoded {
704	let mut fields = body.split(|byte| *byte == b';');
705	let codepoints = fields.next().unwrap_or_default();
706	let mut codepoints = codepoints.split(|byte| *byte == b':');
707	let primary = codepoints.next().and_then(parse_decimal).unwrap_or(0);
708	let shifted = codepoints.next().and_then(parse_decimal);
709	let modifier_field = fields.next().unwrap_or(b"1");
710	let mut modifier_parts = modifier_field.split(|byte| *byte == b':');
711	let mut modifiers = modifier_parts.next().and_then(parse_modifier).unwrap_or(0);
712	if meta {
713		modifiers |= 0b0000_0010;
714	}
715	let event_type = modifier_parts
716		.next()
717		.and_then(parse_decimal_u8)
718		.unwrap_or(1);
719	if event_type == 3 {
720		return Decoded::None;
721	}
722	let codepoint = if modifiers & 0b0000_0001 != 0 {
723		shifted.unwrap_or(primary)
724	} else {
725		primary
726	};
727	let Some(chord) = chord_from_codepoint(codepoint, modifiers) else {
728		return Decoded::None;
729	};
730	let printable = modifiers == 0 && codepoint >= 32;
731	if printable {
732		Decoded::KittyChord(chord)
733	} else {
734		Decoded::Chord(chord)
735	}
736}
737
738fn chord_printable_codepoint(chord: Chord) -> Option<u32> {
739	match chord.key {
740		Key::Char(character) => Some(u32::from(character)),
741		Key::Space => Some(32),
742		_ => None,
743	}
744}
745
746fn decode_tilde_key(body: &[u8], meta: bool) -> Decoded {
747	let mut fields = body.split(|byte| *byte == b';');
748	let first = fields.next().unwrap_or_default();
749	let second = fields.next();
750	let third = fields.next();
751	if first == b"27"
752		&& let (Some(modifiers), Some(codepoint), None) = (second, third, fields.next())
753	{
754		let modifiers = parse_modifier(modifiers).unwrap_or(0) | if meta { 2 } else { 0 };
755		let codepoint = parse_decimal(codepoint).unwrap_or(0);
756		return chord_from_codepoint(codepoint, modifiers).map_or(Decoded::None, Decoded::Chord);
757	}
758	let number = parse_decimal_u8(first).unwrap_or(0);
759	let modifiers = second.and_then(parse_modifier).unwrap_or(0) | if meta { 2 } else { 0 };
760	let key = match number {
761		1 | 7 => Some(Key::Home),
762		2 => Some(Key::Insert),
763		3 => Some(Key::Delete),
764		4 | 8 => Some(Key::End),
765		5 => Some(Key::PageUp),
766		6 => Some(Key::PageDown),
767		11..=15 => Some(Key::Function(number - 10)),
768		17..=21 => Some(Key::Function(number - 11)),
769		23 | 24 => Some(Key::Function(number - 12)),
770		_ => None,
771	};
772	key.map(|key| Chord::with_modifiers(key, modifiers))
773		.map_or(Decoded::None, Decoded::Chord)
774}
775
776fn decode_sgr_mouse(body: &[u8], final_byte: u8) -> Decoded {
777	let mut fields = body[1..].split(|byte| *byte == b';');
778	let Some(bits) = fields.next().and_then(parse_decimal_u16) else {
779		return Decoded::None;
780	};
781	let Some(column) = fields.next().and_then(parse_decimal_u16) else {
782		return Decoded::None;
783	};
784	let Some(row) = fields.next().and_then(parse_decimal_u16) else {
785		return Decoded::None;
786	};
787	let button = if bits & 0b0100_0000 != 0 {
788		match bits & 0b0000_0011 {
789			0 => MouseButton::WheelUp,
790			1 => MouseButton::WheelDown,
791			2 => MouseButton::WheelLeft,
792			_ => MouseButton::WheelRight,
793		}
794	} else {
795		match bits & 0b0000_0011 {
796			0 => MouseButton::Left,
797			1 => MouseButton::Middle,
798			2 => MouseButton::Right,
799			_ => MouseButton::None,
800		}
801	};
802	let kind = match button {
803		MouseButton::WheelUp => Mouse::WheelUp,
804		MouseButton::WheelDown => Mouse::WheelDown,
805		MouseButton::WheelLeft => Mouse::WheelLeft,
806		MouseButton::WheelRight => Mouse::WheelRight,
807		_ if final_byte == b'm' => Mouse::Release,
808		MouseButton::None if bits & 0b0010_0000 != 0 => Mouse::Move,
809		_ if bits & 0b0010_0000 != 0 => Mouse::Drag,
810		MouseButton::Left => Mouse::Click,
811		MouseButton::Middle => Mouse::MiddleClick,
812		MouseButton::Right => Mouse::RightClick,
813		MouseButton::None => Mouse::Move,
814	};
815	Decoded::Event(InputEvent::Mouse(MouseReport {
816		kind,
817		col: column.saturating_sub(1),
818		row: row.saturating_sub(1),
819		button,
820		mods: Mods {
821			shift: bits & 0b0000_0100 != 0,
822			alt: bits & 0b0000_1000 != 0,
823			ctrl: bits & 0b0001_0000 != 0,
824			..Mods::default()
825		},
826		pressed: final_byte == b'M',
827	}))
828}
829
830fn chord_from_codepoint(codepoint: u32, modifiers: u32) -> Option<Chord> {
831	let key = match codepoint {
832		57344 | 27 => Some(Key::Esc),
833		57345 | 10 | 13 => Some(Key::Enter),
834		57346 | 9 => Some(Key::Tab),
835		57347 | 127 => Some(Key::Backspace),
836		57348 => Some(Key::Insert),
837		57349 => Some(Key::Delete),
838		57350 => Some(Key::Left),
839		57351 => Some(Key::Right),
840		57352 => Some(Key::Up),
841		57353 => Some(Key::Down),
842		57354 => Some(Key::PageUp),
843		57355 => Some(Key::PageDown),
844		57356 => Some(Key::Home),
845		57357 => Some(Key::End),
846		57364..=57375 => Some(Key::Function(u8::try_from(codepoint - 57363).ok()?)),
847		_ => character_to_key(char::from_u32(codepoint)?),
848	}?;
849	Some(Chord::with_modifiers(key, modifiers))
850}
851
852fn decode_plain(bytes: &[u8], alt: bool) -> Option<Chord> {
853	let mut chord = if bytes[0] < 0x20 || bytes[0] == 0x7f {
854		decode_control(bytes[0])?
855	} else {
856		let character = std::str::from_utf8(bytes).ok()?.chars().next()?;
857		Chord::plain(character_to_key(character)?)
858	};
859	chord.mods.alt |= alt;
860	Some(chord)
861}
862
863fn decode_control(byte: u8) -> Option<Chord> {
864	let chord = match byte {
865		b'\t' => Chord::plain(Key::Tab),
866		b'\r' | b'\n' => Chord::plain(Key::Enter),
867		0x7f | 0x08 => Chord::plain(Key::Backspace),
868		0x01..=0x1a => {
869			Chord::new(Key::Char(char::from(b'a' + byte - 1)), Mods { ctrl: true, ..Mods::default() })
870		},
871		0x1b => Chord::plain(Key::Esc),
872		_ => return None,
873	};
874	Some(chord)
875}
876
877const fn character_to_key(character: char) -> Option<Key> {
878	match character {
879		' ' => Some(Key::Space),
880		'\r' | '\n' => Some(Key::Enter),
881		_ if !character.is_control() => Some(Key::Char(character)),
882		_ => None,
883	}
884}
885
886const fn utf8_width(byte: u8) -> usize {
887	match byte {
888		0x00..=0x7f => 1,
889		0xc2..=0xdf => 2,
890		0xe0..=0xef => 3,
891		0xf0..=0xf4 => 4,
892		_ => 1,
893	}
894}
895
896fn valid_sgr_body(body: &[u8]) -> bool {
897	let Some(body) = body.strip_prefix(b"<") else {
898		return false;
899	};
900	let Some(body) = body.strip_suffix(b"M").or_else(|| body.strip_suffix(b"m")) else {
901		return false;
902	};
903	let mut fields = body.split(|byte| *byte == b';');
904	(0..3).all(|_| fields.next().and_then(parse_decimal).is_some()) && fields.next().is_none()
905}
906
907fn is_sgr_mouse_partial(bytes: &[u8]) -> bool {
908	bytes.starts_with(b"\x1b[<")
909		&& bytes[3..]
910			.iter()
911			.all(|byte| byte.is_ascii_digit() || *byte == b';')
912}
913
914fn parse_modifier(bytes: &[u8]) -> Option<u32> {
915	parse_decimal(bytes).map(|modifier| modifier.saturating_sub(1))
916}
917
918fn parse_decimal(bytes: &[u8]) -> Option<u32> {
919	(!bytes.is_empty() && bytes.iter().all(u8::is_ascii_digit))
920		.then(|| std::str::from_utf8(bytes).ok()?.parse().ok())
921		.flatten()
922}
923
924fn parse_decimal_u16(bytes: &[u8]) -> Option<u16> {
925	parse_decimal(bytes).and_then(|number| u16::try_from(number).ok())
926}
927
928fn parse_decimal_u8(bytes: &[u8]) -> Option<u8> {
929	parse_decimal(bytes).and_then(|number| u8::try_from(number).ok())
930}
931
932fn decode_text(bytes: &[u8]) -> Str {
933	Str::from_utf8_lossy(bytes)
934}
935fn parse_appearance_response(body: &[u8]) -> Option<u8> {
936	let mut fields = body.strip_prefix(b"?997;")?.split(|byte| *byte == b';');
937	let appearance = fields.next().and_then(parse_decimal_u8)?;
938	(matches!(appearance, 1 | 2) && fields.next().is_none()).then_some(appearance)
939}
940
941fn parse_in_band_resize(body: &[u8]) -> Option<(u16, u16, u16, u16)> {
942	let body = body.strip_suffix(b" ")?;
943	let mut fields = body.split(|byte| *byte == b';');
944	if fields.next()? != b"48" {
945		return None;
946	}
947	let mut number = || {
948		fields
949			.next()
950			.and_then(|field| field.split(|byte| *byte == b':').next())
951			.and_then(parse_decimal_u16)
952	};
953	let rows = number()?;
954	let cols = number()?;
955	let y_px = number()?;
956	let x_px = number()?;
957	(fields.next().is_none()).then_some((rows, cols, x_px, y_px))
958}
959
960fn parse_osc_color(payload: &[u8]) -> Option<(u8, u16, u16, u16)> {
961	let separator = payload.iter().position(|byte| *byte == b';')?;
962	let (index, color) = (&payload[..separator], &payload[separator + 1..]);
963	let index = parse_decimal_u8(index)?;
964	let color = color
965		.strip_prefix(b"rgb:")
966		.or_else(|| color.strip_prefix(b"rgba:"))?;
967	let mut components = color.split(|byte| *byte == b'/');
968	let r = components.next().and_then(parse_hex_component)?;
969	let g = components.next().and_then(parse_hex_component)?;
970	let b = components.next().and_then(parse_hex_component)?;
971	components.next().is_none().then_some((index, r, g, b))
972}
973
974fn parse_hex_component(bytes: &[u8]) -> Option<u16> {
975	if !matches!(bytes.len(), 2 | 4) || !bytes.iter().all(u8::is_ascii_hexdigit) {
976		return None;
977	}
978	let value = u16::from_str_radix(std::str::from_utf8(bytes).ok()?, 16).ok()?;
979	Some(if bytes.len() == 2 {
980		value * 0x101
981	} else {
982		value
983	})
984}
985
986fn emit_unterminated_response(bytes: &[u8], out: &mut Vec<InputEvent>) -> bool {
987	let response = if let Some(payload) = bytes.strip_prefix(b"\x1b]") {
988		TerminalResponse::Osc(decode_text(payload))
989	} else if let Some(payload) = bytes.strip_prefix(b"\x1b_G") {
990		TerminalResponse::KittyGraphics(decode_text(payload))
991	} else if let Some(payload) = bytes.strip_prefix(b"\x1b_") {
992		TerminalResponse::ApplicationProgramCommand(decode_text(payload))
993	} else if let Some(payload) = bytes.strip_prefix(b"\x1bP") {
994		TerminalResponse::DeviceControlString(decode_text(payload))
995	} else {
996		return false;
997	};
998	out.push(InputEvent::Response(response));
999	true
1000}
1001
1002fn emit_raw(bytes: &[u8], keymap: &Keymap, out: &mut Vec<InputEvent>) {
1003	let mut cursor = 0;
1004	while cursor < bytes.len() {
1005		let width = utf8_width(bytes[cursor]).min(bytes.len() - cursor).max(1);
1006		if let Some(chord) = decode_plain(&bytes[cursor..cursor + width], false) {
1007			emit_chord(keymap, chord, out);
1008		}
1009		cursor += width;
1010	}
1011}
1012
1013fn emit_chord(keymap: &Keymap, chord: Chord, out: &mut Vec<InputEvent>) {
1014	if let Some(key) = keymap.resolve(chord) {
1015		out.push(InputEvent::Key(key));
1016	}
1017}
1018
1019fn decode_reencoded_paste_controls(bytes: &[u8]) -> String {
1020	let mut decoded = Vec::with_capacity(bytes.len());
1021	let mut cursor = 0;
1022	while cursor < bytes.len() {
1023		if bytes[cursor..].starts_with(b"\x1b[") {
1024			let tail = &bytes[cursor + 2..];
1025			if let Some(end) = tail.iter().position(|byte| matches!(byte, b'u' | b'~')) {
1026				let body = &tail[..end];
1027				let final_byte = tail[end];
1028				let mut fields = body.split(|byte| *byte == b';');
1029				let codepoint = if final_byte == b'u' {
1030					match (fields.next(), fields.next(), fields.next()) {
1031						(Some(codepoint), Some(b"5"), None) => parse_decimal(codepoint),
1032						_ => None,
1033					}
1034				} else {
1035					match (fields.next(), fields.next(), fields.next(), fields.next()) {
1036						(Some(b"27"), Some(b"5"), Some(codepoint), None) => parse_decimal(codepoint),
1037						_ => None,
1038					}
1039				};
1040				if let Some(codepoint @ (65..=90 | 97..=122)) = codepoint {
1041					let control = if codepoint >= 97 {
1042						codepoint - 96
1043					} else {
1044						codepoint - 64
1045					};
1046					decoded.push(u8::try_from(control).expect("control byte fits"));
1047					cursor += 2 + end + 1;
1048					continue;
1049				}
1050			}
1051		}
1052		decoded.push(bytes[cursor]);
1053		cursor += 1;
1054	}
1055	String::from_utf8_lossy(&decoded).into_owned()
1056}
1057
1058/// Decodes a complete byte slice without retaining partial framing state.
1059///
1060/// Prefer [`InputDecoder`] for PTY, SSH, or multiplexer streams where an
1061/// escape sequence may be split between reads.
1062pub fn decode_keys(bytes: &[u8], output: &mut Vec<Key>) {
1063	let mut decoder = InputDecoder::new();
1064	let now = Instant::now();
1065	let mut events = Vec::new();
1066	decoder.feed(bytes, now, &mut events);
1067	decoder.tick(now + INPUT_TIMEOUT + PARTIAL_HOLD_TIMEOUT, &mut events);
1068	output.extend(events.into_iter().filter_map(|event| match event {
1069		InputEvent::Key(key) => Some(key),
1070		_ => None,
1071	}));
1072}
1073
1074/// A terminal chord exactly as decoded: native key plus full modifiers.
1075///
1076/// Nothing is folded here — lookup canonicalization happens inside
1077/// [`Keymap::resolve`], where an exact binding always wins over the
1078/// shift-folded spelling.
1079#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1080pub struct Chord {
1081	/// The decoded native key.
1082	pub key:  Key,
1083	/// Full modifier set.
1084	pub mods: Mods,
1085}
1086
1087impl Chord {
1088	/// Creates a chord from a native key and its terminal modifiers.
1089	pub const fn new(key: Key, mods: Mods) -> Self {
1090		Self { key, mods }
1091	}
1092
1093	const fn plain(key: Key) -> Self {
1094		Self::new(key, Mods {
1095			shift:     false,
1096			alt:       false,
1097			ctrl:      false,
1098			super_key: false,
1099			hyper:     false,
1100			meta:      false,
1101		})
1102	}
1103
1104	const fn with_modifiers(key: Key, modifiers: u32) -> Self {
1105		Self::new(key, Mods {
1106			shift:     modifiers & 0b0000_0001 != 0,
1107			alt:       modifiers & 0b0000_0010 != 0,
1108			ctrl:      modifiers & 0b0000_0100 != 0,
1109			super_key: modifiers & 0b0000_1000 != 0,
1110			hyper:     modifiers & 0b0001_0000 != 0,
1111			meta:      modifiers & 0b0010_0000 != 0,
1112		})
1113	}
1114
1115	/// The shift-folded spelling used as a lookup fallback: letters under
1116	/// Ctrl/Alt/Super lowercase and drop Shift, so `Alt+Shift+Y` also finds
1117	/// an `Alt+y` binding. `None` when already canonical.
1118	fn folded(self) -> Option<Self> {
1119		if !(self.mods.ctrl || self.mods.alt || self.mods.super_key) {
1120			return None;
1121		}
1122		let Key::Char(ch) = self.key else {
1123			return None;
1124		};
1125		let lowered = ch.to_ascii_lowercase();
1126		let mut mods = self.mods;
1127		mods.shift = false;
1128		(lowered != ch || mods != self.mods).then_some(Self { key: Key::Char(lowered), mods })
1129	}
1130}
1131
1132/// Chord-to-action table consulted before the identity fallbacks.
1133///
1134/// All semantic defaults (word motion, word deletes, newline spellings, the
1135/// legacy `Shift+F3` alias) live here, so embedders can rebind, [`disable`],
1136/// or [`unbind`] any of them.
1137///
1138/// [`disable`]: Keymap::disable
1139/// [`unbind`]: Keymap::unbind
1140#[derive(Clone)]
1141pub struct Keymap {
1142	bindings: Vec<(Chord, Option<Key>)>,
1143}
1144
1145/// Default chord table. Mirrors pi's defaults: word motion/delete spellings
1146/// (including macOS `super+alt+…`), the readline rubouts, every modified-Enter
1147/// newline encoding, and smart/raw clipboard paste.
1148///
1149/// Modifier bits are `1 = Shift`, `2 = Alt`, `4 = Ctrl`, and `8 = Super`.
1150const DEFAULT_BINDINGS: &[(Key, u8, Key)] = &[
1151	(Key::Tab, 1, Key::BackTab),
1152	(Key::Left, 2, Key::WordLeft),
1153	(Key::Right, 2, Key::WordRight),
1154	(Key::Left, 4, Key::WordLeft),
1155	(Key::Right, 4, Key::WordRight),
1156	(Key::Char('f'), 2, Key::WordRight),
1157	(Key::Char('b'), 2, Key::WordLeft),
1158	(Key::Char('d'), 2, Key::WordDelete),
1159	(Key::Delete, 2, Key::WordDelete),
1160	(Key::Char('d'), 10, Key::WordDelete),
1161	(Key::Delete, 10, Key::WordDelete),
1162	(Key::Backspace, 4, Key::Ctrl('w')),
1163	(Key::Backspace, 2, Key::Ctrl('w')),
1164	(Key::Backspace, 10, Key::Ctrl('w')),
1165	(Key::Char('v'), 4, Key::Paste),
1166	(Key::Char('v'), 5, Key::PasteRaw),
1167	// xterm modifyOtherKeys emits the shifted codepoint, so this exact row must win
1168	// before shift-folding `Ctrl+Shift+V` into the smart-paste `Ctrl+v` row.
1169	(Key::Char('V'), 5, Key::PasteRaw),
1170	// pi tui.input.newLine: every modified-Enter spelling; rows cover
1171	// each shift/ctrl/alt combination so the semantics stay table-owned
1172	(Key::Char('j'), 4, Key::ShiftEnter),
1173	(Key::Enter, 1, Key::ShiftEnter),
1174	(Key::Enter, 2, Key::ShiftEnter),
1175	(Key::Enter, 3, Key::ShiftEnter),
1176	(Key::Enter, 4, Key::ShiftEnter),
1177	(Key::Enter, 5, Key::ShiftEnter),
1178	(Key::Enter, 6, Key::ShiftEnter),
1179	(Key::Enter, 7, Key::ShiftEnter),
1180	// legacy `CSI 13;2~` is byte-identical for Shift+Enter and Shift+F3;
1181	// pi resolves the same ambiguity to newline
1182	(Key::Function(3), 1, Key::ShiftEnter),
1183];
1184
1185const fn mods_from_bits(bits: u8) -> Mods {
1186	Mods {
1187		shift:     bits & 0b0000_0001 != 0,
1188		alt:       bits & 0b0000_0010 != 0,
1189		ctrl:      bits & 0b0000_0100 != 0,
1190		super_key: bits & 0b0000_1000 != 0,
1191		hyper:     false,
1192		meta:      false,
1193	}
1194}
1195
1196impl Default for Keymap {
1197	fn default() -> Self {
1198		Self {
1199			bindings: DEFAULT_BINDINGS
1200				.iter()
1201				.map(|&(key, bits, mapped)| (Chord::new(key, mods_from_bits(bits)), Some(mapped)))
1202				.collect(),
1203		}
1204	}
1205}
1206
1207impl Keymap {
1208	/// Adds or replaces the binding for `chord`.
1209	pub fn bind(&mut self, chord: Chord, key: Key) {
1210		self.set(chord, Some(key));
1211	}
1212
1213	/// Masks `chord` entirely: [`Keymap::resolve`] returns `None` even when
1214	/// an identity fallback (`Ctrl+letter`, plain typing) would apply.
1215	pub fn disable(&mut self, chord: Chord) {
1216		self.set(chord, None);
1217	}
1218
1219	/// Removes any entry for `chord`, restoring identity-fallback handling.
1220	pub fn unbind(&mut self, chord: Chord) {
1221		self.bindings.retain(|(bound, _)| *bound != chord);
1222	}
1223
1224	fn set(&mut self, chord: Chord, key: Option<Key>) {
1225		match self.bindings.iter_mut().find(|(bound, _)| *bound == chord) {
1226			Some(slot) => slot.1 = key,
1227			None => self.bindings.push((chord, key)),
1228		}
1229	}
1230
1231	fn entry(&self, chord: Chord) -> Option<&(Chord, Option<Key>)> {
1232		self.bindings.iter().find(|(bound, _)| *bound == chord)
1233	}
1234
1235	/// Resolves a native chord. Precedence: the exact chord's table entry,
1236	/// the shift-folded spelling's entry, then identity fallbacks.
1237	///
1238	/// OS shortcut modifiers are discarded unless explicitly bound. A
1239	/// [`Keymap::disable`]d chord resolves to `None` before any fallback.
1240	pub fn resolve(&self, exact: Chord) -> Option<Key> {
1241		let folded = exact.folded();
1242		if let Some((_, entry)) = self
1243			.entry(exact)
1244			.or_else(|| folded.and_then(|chord| self.entry(chord)))
1245		{
1246			return *entry;
1247		}
1248		let chord = folded.unwrap_or(exact);
1249		if chord.mods.super_key || chord.mods.hyper || chord.mods.meta {
1250			return None;
1251		}
1252		if chord.mods.ctrl && chord.mods.alt {
1253			return match chord.key {
1254				Key::Char(ch) => Some(Key::CtrlAlt(ch.to_ascii_lowercase())),
1255				_ => None,
1256			};
1257		}
1258		if chord.mods.ctrl {
1259			return match chord.key {
1260				Key::Char(ch) => Some(Key::Ctrl(ch.to_ascii_lowercase())),
1261				_ => None,
1262			};
1263		}
1264		if chord.mods.alt {
1265			return match chord.key {
1266				Key::Char(ch) if ch.is_alphanumeric() => Some(Key::Alt(ch.to_ascii_lowercase())),
1267				_ => None,
1268			};
1269		}
1270		if chord.mods.shift && matches!(chord.key, Key::Function(_)) {
1271			return None;
1272		}
1273		Some(match chord.key {
1274			Key::Char(' ') => Key::Space,
1275			Key::Char(ch) if chord.mods.shift => Key::Char(ch.to_ascii_uppercase()),
1276			key => key,
1277		})
1278	}
1279}
1280
1281/// Mouse gestures in document cell coordinates.
1282#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
1283pub enum Mouse {
1284	/// Left-button press: focuses and activates the hit target.
1285	Click,
1286	/// Right-button press.
1287	RightClick,
1288	/// Middle-button press.
1289	MiddleClick,
1290	/// Pointer motion without a pressed button: drives hover highlights.
1291	Move,
1292	/// Pointer motion with a pressed button.
1293	Drag,
1294	/// Button release.
1295	Release,
1296	/// Wheel up: scroll viewports first, then list cursors.
1297	WheelUp,
1298	/// Wheel down: scroll viewports first, then list cursors.
1299	WheelDown,
1300	/// Horizontal wheel left.
1301	WheelLeft,
1302	/// Horizontal wheel right.
1303	WheelRight,
1304}
1305
1306/// Outcome of one input event.
1307#[derive(Clone, Debug, Eq, PartialEq)]
1308pub enum UiEvent {
1309	/// Nothing to report; the frame may still have changed.
1310	None,
1311	/// A `submit` button fired (or a confirm completed on one).
1312	Submit,
1313	/// Esc at the top level or a `cancel` button fired.
1314	Cancel,
1315	/// A plain `id`-carrying button fired.
1316	Pressed(Str),
1317	/// An `id`-carrying select's cursor rested on a new option.
1318	Highlighted {
1319		/// The select's `id`.
1320		id:    Str,
1321		/// Value of the option under the cursor.
1322		value: Str,
1323	},
1324	/// An `id`-carrying select committed the option under its cursor.
1325	Changed {
1326		/// The select's `id`.
1327		id:    Str,
1328		/// Value of the committed option.
1329		value: Str,
1330	},
1331	/// An `id`-carrying filterable select's query changed.
1332	Filtered {
1333		/// The select's `id`.
1334		id:    Str,
1335		/// The new filter query.
1336		query: Str,
1337		/// Value of the option under the cursor after re-filtering;
1338		/// `None` when nothing matches.
1339		value: Option<Str>,
1340	},
1341}
1342
1343/// Grapheme-safe byte offset for a cell-column cursor within `text`.
1344pub fn byte_at_column(text: &str, column: u16) -> usize {
1345	let mut cells = 0u16;
1346	for (offset, grapheme) in text.grapheme_indices() {
1347		if cells >= column {
1348			return offset;
1349		}
1350		cells += cell_width(grapheme);
1351	}
1352	text.len()
1353}
1354#[derive(Clone, Copy, Eq, PartialEq)]
1355enum WordClass {
1356	Word,
1357	Whitespace,
1358	Cjk,
1359	Delimiter,
1360}
1361
1362const fn is_cjk(character: char) -> bool {
1363	matches!(
1364		character as u32,
1365		0x2E80..=0x2FFF
1366			| 0x3040..=0x30FF
1367			| 0x3100..=0x312F
1368			| 0x3130..=0x318F
1369			| 0x31A0..=0x31BF
1370			| 0x31F0..=0x31FF
1371			| 0x3400..=0x4DBF
1372			| 0x4E00..=0x9FFF
1373			| 0xA960..=0xA97F
1374			| 0xAC00..=0xD7AF
1375			| 0xF900..=0xFAFF
1376			| 0x20000..=0x2FA1F
1377	)
1378}
1379
1380fn word_class(grapheme: &str) -> WordClass {
1381	let Some(character) = grapheme.chars().next() else {
1382		return WordClass::Delimiter;
1383	};
1384	if character.is_whitespace() {
1385		WordClass::Whitespace
1386	} else if is_cjk(character) {
1387		WordClass::Cjk
1388	} else if character.is_alphanumeric() || character == '_' {
1389		WordClass::Word
1390	} else {
1391		WordClass::Delimiter
1392	}
1393}
1394
1395fn is_word_joiner(grapheme: &str) -> bool {
1396	matches!(grapheme, "'" | "’" | "-" | "‐" | "‑")
1397}
1398
1399fn word_left_byte(text: &str, at: usize) -> usize {
1400	let mut graphemes = text[..at].grapheme_indices().rev().peekable();
1401	while graphemes
1402		.peek()
1403		.is_some_and(|(_, grapheme)| word_class(grapheme) == WordClass::Whitespace)
1404	{
1405		graphemes.next();
1406	}
1407	let Some((start, first)) = graphemes.next() else {
1408		return 0;
1409	};
1410	let class = word_class(first);
1411	if class == WordClass::Cjk {
1412		return start;
1413	}
1414	if class != WordClass::Word {
1415		let mut target = start;
1416		while let Some(&(offset, grapheme)) = graphemes.peek() {
1417			if word_class(grapheme) != class {
1418				break;
1419			}
1420			target = offset;
1421			graphemes.next();
1422		}
1423		return target;
1424	}
1425	let mut target = start;
1426	while let Some((offset, grapheme)) = graphemes.next() {
1427		if word_class(grapheme) == WordClass::Word {
1428			target = offset;
1429		} else if is_word_joiner(grapheme)
1430			&& graphemes
1431				.peek()
1432				.is_some_and(|(_, left)| word_class(left) == WordClass::Word)
1433		{
1434			let (left_offset, _) = graphemes.next().expect("peeked left word");
1435			target = left_offset;
1436		} else {
1437			break;
1438		}
1439	}
1440	target
1441}
1442
1443fn word_right_byte(text: &str, at: usize) -> usize {
1444	let mut graphemes = text[at..].grapheme_indices().peekable();
1445	while graphemes
1446		.peek()
1447		.is_some_and(|(_, grapheme)| word_class(grapheme) == WordClass::Whitespace)
1448	{
1449		graphemes.next();
1450	}
1451	let Some((first_offset, first)) = graphemes.next() else {
1452		return text.len();
1453	};
1454	let class = word_class(first);
1455	let mut end = at + first_offset + first.len();
1456	if class == WordClass::Cjk {
1457		return end;
1458	}
1459	if class != WordClass::Word {
1460		while let Some(&(_, grapheme)) = graphemes.peek() {
1461			if word_class(grapheme) != class {
1462				break;
1463			}
1464			let (offset, grapheme) = graphemes.next().expect("peeked delimiter");
1465			end = at + offset + grapheme.len();
1466		}
1467		return end;
1468	}
1469	while let Some((offset, grapheme)) = graphemes.next() {
1470		if word_class(grapheme) == WordClass::Word
1471			|| (is_word_joiner(grapheme)
1472				&& graphemes
1473					.peek()
1474					.is_some_and(|(_, right)| word_class(right) == WordClass::Word))
1475		{
1476			end = at + offset + grapheme.len();
1477		} else {
1478			break;
1479		}
1480	}
1481	end
1482}
1483
1484/// Cell column of the previous coarse word start before `column`.
1485pub fn word_left_column(text: &str, column: u16) -> u16 {
1486	cell_width(&text[..word_left_byte(text, byte_at_column(text, column))])
1487}
1488
1489/// Cell column just past the next coarse word after `column`.
1490pub fn word_right_column(text: &str, column: u16) -> u16 {
1491	cell_width(&text[..word_right_byte(text, byte_at_column(text, column))])
1492}
1493
1494/// Byte start of the coarse word before `at` (pi `deleteWordBackward`).
1495pub fn word_rubout_start(text: &str, at: usize) -> usize {
1496	word_left_byte(text, at)
1497}
1498
1499/// Normalizes terminal paste input before inserting it into a widget.
1500pub fn sanitize_paste(text: &str) -> String {
1501	let normalized_newlines = text.replace("\r\n", "\n").replace('\r', "\n");
1502	normalized_newlines
1503		.chars()
1504		.filter(|character| !character.is_control() || matches!(character, '\n' | '\t'))
1505		.collect::<String>()
1506		.into_nfc()
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511	use std::time::{Duration, Instant};
1512
1513	use super::{
1514		Chord, InputDecoder, InputEvent, Key, Keymap, Mods, Mouse, MouseButton, MouseReport,
1515		TerminalResponse, decode_keys, mods_from_bits,
1516	};
1517
1518	fn drip(bytes: &[u8]) -> Vec<InputEvent> {
1519		let start = Instant::now();
1520		let mut decoder = InputDecoder::new();
1521		let mut events = Vec::new();
1522		for (offset, byte) in bytes.iter().enumerate() {
1523			decoder.feed(
1524				std::slice::from_ref(byte),
1525				start + Duration::from_millis(u64::try_from(offset).unwrap()),
1526				&mut events,
1527			);
1528		}
1529		events
1530	}
1531
1532	#[test]
1533	fn native_keymap_covers_chords_and_motion() {
1534		let cases: &[(Key, u8, Key)] = &[
1535			(Key::Char('a'), 4, Key::Ctrl('a')),
1536			(Key::Char('W'), 5, Key::Ctrl('w')),
1537			(Key::Char('k'), 5, Key::Ctrl('k')),
1538			(Key::Left, 2, Key::WordLeft),
1539			(Key::Right, 4, Key::WordRight),
1540			(Key::Enter, 1, Key::ShiftEnter),
1541			(Key::Enter, 0, Key::Enter),
1542			(Key::Char(' '), 0, Key::Space),
1543			(Key::Char('Z'), 1, Key::Char('Z')),
1544			(Key::BackTab, 1, Key::BackTab),
1545			(Key::PageDown, 0, Key::PageDown),
1546			(Key::Enter, 2, Key::ShiftEnter),
1547			(Key::Enter, 4, Key::ShiftEnter),
1548			(Key::Char('j'), 4, Key::ShiftEnter),
1549			(Key::Function(3), 1, Key::ShiftEnter),
1550			(Key::Char('d'), 2, Key::WordDelete),
1551			(Key::Delete, 2, Key::WordDelete),
1552			(Key::Backspace, 4, Key::Ctrl('w')),
1553			(Key::Backspace, 10, Key::Ctrl('w')),
1554			(Key::Char('d'), 10, Key::WordDelete),
1555			(Key::Char('f'), 2, Key::WordRight),
1556			(Key::Char('b'), 2, Key::WordLeft),
1557			(Key::Char('d'), 4, Key::Ctrl('d')),
1558			(Key::Char('y'), 2, Key::Alt('y')),
1559			(Key::Char('Y'), 3, Key::Alt('y')),
1560			(Key::Char(']'), 6, Key::CtrlAlt(']')),
1561		];
1562		let keymap = Keymap::default();
1563		for &(key, bits, expected) in cases {
1564			let chord = Chord::new(key, mods_from_bits(bits));
1565			assert_eq!(keymap.resolve(chord), Some(expected), "{chord:?}");
1566		}
1567	}
1568
1569	#[test]
1570	fn keymap_resolves_smart_and_raw_paste_chords() {
1571		let mut keymap = Keymap::default();
1572		let smart = Chord::new(Key::Char('v'), mods_from_bits(4));
1573		assert_eq!(keymap.resolve(smart), Some(Key::Paste));
1574		assert_eq!(
1575			keymap.resolve(Chord::new(Key::Char('v'), mods_from_bits(5))),
1576			Some(Key::PasteRaw)
1577		);
1578		assert_eq!(
1579			keymap.resolve(Chord::new(Key::Char('V'), mods_from_bits(5))),
1580			Some(Key::PasteRaw)
1581		);
1582
1583		keymap.unbind(smart);
1584		assert_eq!(keymap.resolve(smart), Some(Key::Ctrl('v')));
1585	}
1586
1587	#[test]
1588	fn decoder_normalizes_kitty_shifted_letters() {
1589		let cases: &[(&[u8], Key)] = &[
1590			(b"\x1b[97;2u", Key::Char('A')),
1591			(b"\x1b[65;2u", Key::Char('A')),
1592			(b"\x1b[49;2u", Key::Char('1')),
1593			(b"\x1b[97;5u", Key::Ctrl('a')),
1594			(b"A", Key::Char('A')),
1595		];
1596		for &(bytes, expected) in cases {
1597			let mut keys = Vec::new();
1598			decode_keys(bytes, &mut keys);
1599			assert_eq!(keys, [expected], "{bytes:?}");
1600		}
1601	}
1602
1603	#[test]
1604	fn decoder_filters_releases_and_keymap_filters_os_chords() {
1605		let mut keys = Vec::new();
1606		decode_keys(b"\x1b[97;1:3u", &mut keys);
1607		assert!(keys.is_empty(), "kitty release must not become input");
1608
1609		let keymap = Keymap::default();
1610		let os_mods = [
1611			Mods { super_key: true, ..Mods::default() },
1612			Mods { hyper: true, ..Mods::default() },
1613			Mods { meta: true, ..Mods::default() },
1614		];
1615		for mods in os_mods {
1616			assert_eq!(
1617				keymap.resolve(Chord::new(Key::Char('c'), mods)),
1618				None,
1619				"OS shortcuts must never type ({mods:?})"
1620			);
1621		}
1622		let hyper = Chord::new(Key::Char('c'), Mods { hyper: true, ..Mods::default() });
1623		let mut keymap = Keymap::default();
1624		keymap.bind(hyper, Key::Esc);
1625		assert_eq!(keymap.resolve(hyper), Some(Key::Esc));
1626	}
1627
1628	#[test]
1629	fn keymap_bindings_are_customizable() {
1630		let mut keymap = Keymap::default();
1631		let legacy = Chord::new(Key::Function(3), mods_from_bits(1));
1632		assert_eq!(keymap.resolve(legacy), Some(Key::ShiftEnter));
1633		keymap.unbind(legacy);
1634		assert_eq!(keymap.resolve(legacy), None);
1635
1636		let function = Chord::new(Key::Function(5), Mods::default());
1637		keymap.bind(function, Key::Ctrl('r'));
1638		assert_eq!(keymap.resolve(function), Some(Key::Ctrl('r')));
1639		keymap.bind(function, Key::Esc);
1640		assert_eq!(keymap.resolve(function), Some(Key::Esc));
1641
1642		let word = Chord::new(Key::Right, mods_from_bits(2));
1643		keymap.unbind(word);
1644		assert_eq!(keymap.resolve(word), None);
1645
1646		let quit = Chord::new(Key::Char('q'), Mods::default());
1647		keymap.disable(quit);
1648		assert_eq!(keymap.resolve(quit), None);
1649		keymap.unbind(quit);
1650		assert_eq!(keymap.resolve(quit), Some(Key::Char('q')));
1651
1652		let exact = Chord::new(Key::Char('Y'), mods_from_bits(3));
1653		keymap.bind(exact, Key::PageUp);
1654		assert_eq!(keymap.resolve(exact), Some(Key::PageUp));
1655		assert_eq!(
1656			keymap.resolve(Chord::new(Key::Char('y'), mods_from_bits(2))),
1657			Some(Key::Alt('y')),
1658			"lowercase spelling still uses the identity fallback"
1659		);
1660	}
1661
1662	#[test]
1663	fn decoder_applies_live_keymap_changes_once() {
1664		let start = Instant::now();
1665		let chord = Chord::new(Key::Char('f'), Mods { alt: true, ..Mods::default() });
1666		let mut decoder = InputDecoder::new();
1667		let mut events = Vec::new();
1668
1669		decoder.feed(b"\x1bf", start, &mut events);
1670		assert_eq!(events, [InputEvent::Key(Key::WordRight)]);
1671
1672		events.clear();
1673		decoder.keymap_mut().disable(chord);
1674		decoder.feed(b"\x1bf", start, &mut events);
1675		assert_eq!(events, [] as [InputEvent; 0]);
1676
1677		decoder.keymap_mut().bind(chord, Key::PageDown);
1678		decoder.feed(b"\x1bf", start, &mut events);
1679		assert_eq!(events, [InputEvent::Key(Key::PageDown)]);
1680
1681		events.clear();
1682		decoder.feed(b"x", start, &mut events);
1683		assert_eq!(events, [InputEvent::Key(Key::Char('x'))]);
1684	}
1685
1686	#[test]
1687	fn raw_key_decoder_covers_terminal_sequence_families_and_utf8() {
1688		let mut keys = Vec::new();
1689		decode_keys(
1690			b"\x1b[A\x1bOB\x1b[5~\x1b[6~\x1b[H\x1b[F\x1b[3~\x1b[13;2u\x01\x1bx\xc3\xa9\x1b",
1691			&mut keys,
1692		);
1693		assert_eq!(keys, [
1694			Key::Up,
1695			Key::Down,
1696			Key::PageUp,
1697			Key::PageDown,
1698			Key::Home,
1699			Key::End,
1700			Key::Delete,
1701			Key::ShiftEnter,
1702			Key::Ctrl('a'),
1703			Key::Alt('x'),
1704			Key::Char('é'),
1705			Key::Esc,
1706		]);
1707	}
1708
1709	#[test]
1710	fn streaming_decoder_holds_split_escapes_until_timeout() {
1711		let start = Instant::now();
1712		let mut decoder = InputDecoder::new();
1713		let mut events = Vec::new();
1714		decoder.feed(b"\x1b", start, &mut events);
1715		decoder.feed(b"[A", start + Duration::from_millis(74), &mut events);
1716		assert_eq!(events, [InputEvent::Key(Key::Up)]);
1717
1718		events.clear();
1719		let mut decoder = InputDecoder::new();
1720		decoder.feed(b"\x1b", start, &mut events);
1721		decoder.feed(b"[A", start + Duration::from_millis(76), &mut events);
1722		assert_eq!(events, [
1723			InputEvent::Key(Key::Esc),
1724			InputEvent::Key(Key::Char('[')),
1725			InputEvent::Key(Key::Char('A')),
1726		]);
1727
1728		events.clear();
1729		let mut decoder = InputDecoder::new();
1730		decoder.set_kitty_keyboard(true);
1731		decoder.feed(b"\x1b", start, &mut events);
1732		decoder.tick(start + Duration::from_millis(224), &mut events);
1733		assert_eq!(events, [] as [InputEvent; 0]);
1734		decoder.tick(start + Duration::from_millis(225), &mut events);
1735		assert_eq!(events, [InputEvent::Key(Key::Esc)]);
1736	}
1737	#[test]
1738	fn decoder_deadline_tracks_pending_partial_input() {
1739		let start = Instant::now();
1740		let mut decoder = InputDecoder::new();
1741		let mut events = Vec::new();
1742		assert_eq!(decoder.deadline(), None);
1743
1744		decoder.feed(b"\x1b[", start, &mut events);
1745		assert_eq!(events, [] as [InputEvent; 0]);
1746		assert_eq!(decoder.deadline(), Some(start + Duration::from_millis(75)));
1747
1748		decoder.tick(start + Duration::from_millis(75), &mut events);
1749		assert_eq!(events, [InputEvent::Key(Key::Esc), InputEvent::Key(Key::Char('['))]);
1750		assert_eq!(decoder.deadline(), None);
1751	}
1752
1753	#[test]
1754	fn streaming_decoder_disambiguates_alt_and_meta_escape_prefixes() {
1755		let start = Instant::now();
1756		let mut decoder = InputDecoder::new();
1757		let mut events = Vec::new();
1758		decoder.feed(b"\x1b\x1bd\x1b\x1b[D\x1b\x1b", start, &mut events);
1759		decoder.tick(start + Duration::from_millis(75), &mut events);
1760		assert_eq!(events, [
1761			InputEvent::Key(Key::Esc),
1762			InputEvent::Key(Key::WordDelete),
1763			InputEvent::Key(Key::WordLeft),
1764			InputEvent::Key(Key::Esc),
1765			InputEvent::Key(Key::Esc),
1766		]);
1767	}
1768
1769	#[test]
1770	fn streaming_decoder_filters_late_replies_and_decodes_key_families() {
1771		let start = Instant::now();
1772		let mut decoder = InputDecoder::new();
1773		let mut events = Vec::new();
1774		decoder.feed(b"x\x1b[?1;2c\x1b[15~\x1b[24~\x1b[1;5D\x1b[I\x1b[O", start, &mut events);
1775		assert_eq!(events, [
1776			InputEvent::Key(Key::Char('x')),
1777			InputEvent::Response(TerminalResponse::DeviceAttributes("?1;2".into())),
1778			InputEvent::Key(Key::Function(5)),
1779			InputEvent::Key(Key::Function(12)),
1780			InputEvent::Key(Key::WordLeft),
1781			InputEvent::Focus(true),
1782			InputEvent::Focus(false),
1783		]);
1784	}
1785
1786	#[test]
1787	fn kitty_csi_u_suppresses_release_delivers_repeat_and_deduplicates_printable() {
1788		let start = Instant::now();
1789		let mut decoder = InputDecoder::new();
1790		let mut events = Vec::new();
1791		decoder.feed(b"\x1b[97;1:3u\x1b[98;1:2u\x1b[97u", start, &mut events);
1792		decoder.feed(b"a", start + Duration::from_millis(25), &mut events);
1793		decoder.feed(b"a", start + Duration::from_millis(26), &mut events);
1794		decoder.feed(b"\x1b[32u ", start + Duration::from_millis(27), &mut events);
1795		assert_eq!(events, [
1796			InputEvent::Key(Key::Char('b')),
1797			InputEvent::Key(Key::Char('a')),
1798			InputEvent::Key(Key::Char('a')),
1799			InputEvent::Key(Key::Space),
1800		]);
1801	}
1802
1803	#[test]
1804	fn bracketed_paste_reassembles_recovers_and_decodes_tmux_controls() {
1805		let start = Instant::now();
1806		let mut decoder = InputDecoder::new();
1807		let mut events = Vec::new();
1808		decoder.feed(b"\x1b[20", start, &mut events);
1809		decoder.feed(b"0~one\r", start + Duration::from_millis(10), &mut events);
1810		decoder.feed(b"\ntwo\x1b[201~", start + Duration::from_millis(20), &mut events);
1811		assert_eq!(events, [InputEvent::Paste("one\ntwo".into())]);
1812
1813		events.clear();
1814		decoder.feed(
1815			b"\x1b[200~a\x1b[106;5ub\x1b[27;5;105~c\x1b[201~",
1816			start + Duration::from_millis(30),
1817			&mut events,
1818		);
1819		assert_eq!(events, [InputEvent::Paste("a\nb\tc".into())]);
1820
1821		events.clear();
1822		decoder.feed(b"\x1b[200~unterminated", start + Duration::from_millis(40), &mut events);
1823		decoder.tick(start + Duration::from_millis(1040), &mut events);
1824		assert_eq!(events, [InputEvent::Paste("unterminated".into())]);
1825	}
1826
1827	#[test]
1828	fn sgr_mouse_reports_hold_splits_and_preserve_raw_details() {
1829		let start = Instant::now();
1830		let mut decoder = InputDecoder::new();
1831		let mut events = Vec::new();
1832		decoder.feed(b"\x1b[<60;1", start, &mut events);
1833		decoder.feed(b"0;4M", start + Duration::from_millis(150), &mut events);
1834		decoder.feed(b"\x1b[<65;3;2M\x1b[<32;7;8M", start + Duration::from_millis(151), &mut events);
1835		assert_eq!(events, [
1836			InputEvent::Mouse(MouseReport {
1837				kind:    Mouse::Drag,
1838				col:     9,
1839				row:     3,
1840				button:  MouseButton::Left,
1841				mods:    Mods { shift: true, alt: true, ctrl: true, ..Mods::default() },
1842				pressed: true,
1843			}),
1844			InputEvent::Mouse(MouseReport {
1845				kind:    Mouse::WheelDown,
1846				col:     2,
1847				row:     1,
1848				button:  MouseButton::WheelDown,
1849				mods:    Mods::default(),
1850				pressed: true,
1851			}),
1852			InputEvent::Mouse(MouseReport {
1853				kind:    Mouse::Drag,
1854				col:     6,
1855				row:     7,
1856				button:  MouseButton::Left,
1857				mods:    Mods::default(),
1858				pressed: true,
1859			}),
1860		]);
1861	}
1862
1863	#[test]
1864	fn sgr_mouse_maps_buttons_wheels_drag_and_release() {
1865		let cases = [
1866			(b"\x1b[<2;4;5M".as_slice(), Mouse::RightClick),
1867			(b"\x1b[<1;4;5M".as_slice(), Mouse::MiddleClick),
1868			(b"\x1b[<66;4;5M".as_slice(), Mouse::WheelLeft),
1869			(b"\x1b[<67;4;5M".as_slice(), Mouse::WheelRight),
1870			(b"\x1b[<32;4;5M".as_slice(), Mouse::Drag),
1871			(b"\x1b[<0;4;5m".as_slice(), Mouse::Release),
1872		];
1873		for (bytes, kind) in cases {
1874			let start = Instant::now();
1875			let mut decoder = InputDecoder::new();
1876			let mut events = Vec::new();
1877			decoder.feed(bytes, start, &mut events);
1878			assert_eq!(events.len(), 1);
1879			let InputEvent::Mouse(report) = events[0] else {
1880				panic!("expected mouse report");
1881			};
1882			assert_eq!(report.kind, kind);
1883			assert_eq!((report.col, report.row), (3, 4));
1884		}
1885	}
1886
1887	#[test]
1888	fn capability_responses_parse_whole_and_byte_dripped() {
1889		let cases = [
1890			(
1891				b"\x1b]11;rgb:ffff/0000/8080\x07".as_slice(),
1892				InputEvent::Response(TerminalResponse::OscColor {
1893					index: 11,
1894					r:     0xffff,
1895					g:     0,
1896					b:     0x8080,
1897				}),
1898			),
1899			(
1900				b"\x1b]11;rgba:ff/00/80\x1b\\".as_slice(),
1901				InputEvent::Response(TerminalResponse::OscColor {
1902					index: 11,
1903					r:     0xffff,
1904					g:     0,
1905					b:     0x8080,
1906				}),
1907			),
1908			(b"\x1b[?997;1n".as_slice(), InputEvent::Response(TerminalResponse::AppearanceChanged(1))),
1909			(
1910				b"\x1b[48;24;80;1600;800 t".as_slice(),
1911				InputEvent::Response(TerminalResponse::InBandResize {
1912					rows: 24,
1913					cols: 80,
1914					x_px: 800,
1915					y_px: 1600,
1916				}),
1917			),
1918		];
1919		for (bytes, expected) in cases {
1920			let start = Instant::now();
1921			let mut decoder = InputDecoder::new();
1922			let mut events = Vec::new();
1923			decoder.feed(bytes, start, &mut events);
1924			assert_eq!(events.as_slice(), std::slice::from_ref(&expected));
1925			assert_eq!(drip(bytes), [expected]);
1926		}
1927	}
1928
1929	#[test]
1930	fn decoder_maps_mouse_gestures_with_position() {
1931		let cases = [
1932			(b"\x1b[<0;4;8M".as_slice(), Mouse::Click),
1933			(b"\x1b[<64;4;8M".as_slice(), Mouse::WheelUp),
1934			(b"\x1b[<35;4;8M".as_slice(), Mouse::Move),
1935			(b"\x1b[<2;4;8M".as_slice(), Mouse::RightClick),
1936			(b"\x1b[<1;4;8M".as_slice(), Mouse::MiddleClick),
1937			(b"\x1b[<32;4;8M".as_slice(), Mouse::Drag),
1938			(b"\x1b[<0;4;8m".as_slice(), Mouse::Release),
1939			(b"\x1b[<66;4;8M".as_slice(), Mouse::WheelLeft),
1940			(b"\x1b[<67;4;8M".as_slice(), Mouse::WheelRight),
1941		];
1942		for (bytes, expected) in cases {
1943			let events = drip(bytes);
1944			let [InputEvent::Mouse(report)] = events.as_slice() else {
1945				panic!("expected one mouse event");
1946			};
1947			assert_eq!(report.kind, expected);
1948			assert_eq!((report.col, report.row), (3, 7));
1949		}
1950	}
1951
1952	#[test]
1953	fn word_helpers_follow_pi_coarse_semantics() {
1954		use super::{word_left_column, word_right_column, word_rubout_start};
1955		let text = "foo-bar baz";
1956		assert_eq!(word_left_column(text, 11), 8);
1957		assert_eq!(word_left_column(text, 8), 0);
1958		assert_eq!(word_right_column(text, 0), 7);
1959		assert_eq!(word_right_column(text, 7), 11);
1960		assert_eq!(word_rubout_start(text, 7), 0);
1961		assert_eq!(word_left_column("中文", 4), 2);
1962		assert_eq!(word_right_column("中文", 0), 2);
1963	}
1964}