1use std::io::{self, IsTerminal, Write};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crossterm::{
11 event::{self, Event, KeyEvent, MouseEvent},
12 terminal as cterm,
13};
14use thiserror::Error;
15
16const fn mouse_tracking_enabled_for(is_macos: bool) -> bool {
19 !is_macos
20}
21
22const fn mouse_tracking_enabled_by_default() -> bool {
23 mouse_tracking_enabled_for(cfg!(target_os = "macos"))
24}
25
26#[cfg(any(unix, test))]
30fn is_ime_incompatible_terminal(term_program: Option<&str>, term: Option<&str>) -> bool {
31 let names_otty = |value: Option<&str>| {
32 value.is_some_and(|value| value.to_ascii_lowercase().contains("otty"))
33 };
34 names_otty(term_program) || names_otty(term)
35}
36
37#[cfg(any(unix, test))]
44fn keyboard_protocol_enabled_for(
45 is_macos: bool,
46 term_program: Option<&str>,
47 term: Option<&str>,
48) -> bool {
49 !is_macos || !is_ime_incompatible_terminal(term_program, term)
50}
51
52#[cfg(unix)]
56fn keyboard_protocol_enabled() -> bool {
57 if let Some(value) = std::env::var_os("RPI_KITTY_KEYBOARD") {
58 return value != "0";
59 }
60 keyboard_protocol_enabled_for(
61 cfg!(target_os = "macos"),
62 std::env::var("TERM_PROGRAM").ok().as_deref(),
63 std::env::var("TERM").ok().as_deref(),
64 )
65}
66
67#[derive(Debug, Error)]
69pub enum TerminalError {
70 #[error("IO error: {0}")]
71 Io(#[from] io::Error),
72 #[error("Terminal error: {0}")]
73 Terminal(String),
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct TerminalInfo {
79 pub columns: usize,
80 pub rows: usize,
81 pub supports_true_color: bool,
82 pub supports_mouse: bool,
83 pub supports_kitty_keyboard: bool,
84}
85
86#[derive(Debug, Clone)]
88pub enum InputEvent {
89 Key(KeyEvent),
90 Mouse(MouseEvent),
91 Resize(usize, usize),
92 FocusGained,
93 FocusLost,
94}
95
96pub trait Terminal: Send + Sync {
100 fn info(&self) -> TerminalInfo;
102
103 fn columns(&self) -> usize {
105 self.info().columns
106 }
107
108 fn rows(&self) -> usize {
110 self.info().rows
111 }
112
113 fn write(&self, data: &str);
115
116 fn hide_cursor(&self);
118
119 fn show_cursor(&self);
121
122 fn move_cursor(&self, row: usize, col: usize);
124
125 fn clear_screen(&self);
127
128 fn set_title(&self, title: &str);
130
131 fn enable_mouse(&self);
133
134 fn disable_mouse(&self);
136
137 fn enter_raw_mode(&self);
145
146 fn exit_raw_mode(&self);
150
151 fn refresh_size(&self);
154
155 fn start(
157 &self,
158 on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
159 on_resize: Box<dyn Fn() + Send + Sync>,
160 );
161
162 fn stop(&self);
164
165 fn is_tty(&self) -> bool;
167
168 fn set_progress(&self, active: bool);
170
171 fn flush(&self);
173}
174
175pub struct ProcessTerminal {
177 info: Mutex<TerminalInfo>,
178 running: Mutex<bool>,
179 writer: Mutex<Box<dyn Write + Send + Sync>>,
180 #[cfg(any(unix, test))]
181 keyboard: Mutex<KeyboardProtocolState>,
182}
183
184#[cfg(any(unix, test))]
185#[derive(Clone, Copy)]
186enum KeyboardProtocol {
187 Kitty,
188 Legacy,
189}
190
191#[cfg(any(unix, test))]
192#[derive(Default)]
193struct KeyboardProtocolState {
194 detected: Option<bool>,
195 active: Option<KeyboardProtocol>,
196}
197
198impl ProcessTerminal {
199 pub fn new() -> Self {
201 let (cols, rows) = cterm::size().unwrap_or((80, 24));
202 let info = TerminalInfo {
203 columns: cols as usize,
204 rows: rows as usize,
205 supports_true_color: true, supports_mouse: true,
207 supports_kitty_keyboard: false, };
209
210 Self {
211 info: Mutex::new(info),
212 running: Mutex::new(false),
213 writer: Mutex::new(Box::new(io::stdout())),
214 #[cfg(any(unix, test))]
215 keyboard: Mutex::new(KeyboardProtocolState::default()),
216 }
217 }
218
219 fn update_size(&self) {
221 if let Ok(size) = cterm::size() {
222 if let Ok(mut info) = self.info.lock() {
223 info.columns = size.0 as usize;
224 info.rows = size.1 as usize;
225 }
226 }
227 }
228
229 fn configure_mouse_tracking(&self) {
230 if mouse_tracking_enabled_by_default() {
231 self.enable_mouse();
232 } else {
233 self.disable_mouse();
234 }
235 }
236
237 #[cfg(any(unix, test))]
240 fn enable_keyboard_protocol(&self, detect: impl FnOnce() -> bool) {
241 let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
242 if keyboard.active.is_some() {
243 return;
244 }
245 let supported = match keyboard.detected {
246 Some(supported) => supported,
247 None => {
248 self.write("\x1b[>7u");
252 self.flush();
253 let supported = detect();
254 keyboard.detected = Some(supported);
255 if !supported {
256 self.write("\x1b[<u");
257 }
258 if let Ok(mut info) = self.info.lock() {
259 info.supports_kitty_keyboard = supported;
260 }
261 if supported {
262 keyboard.active = Some(KeyboardProtocol::Kitty);
263 return;
264 }
265 false
266 }
267 };
268 let protocol = if supported {
269 self.write("\x1b[>7u");
270 KeyboardProtocol::Kitty
271 } else {
272 KeyboardProtocol::Legacy
276 };
277 keyboard.active = Some(protocol);
278 }
279
280 #[cfg(any(unix, test))]
281 fn disable_keyboard_protocol(&self) {
282 let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
283 match keyboard.active.take() {
284 Some(KeyboardProtocol::Kitty) => self.write("\x1b[<u"),
285 Some(KeyboardProtocol::Legacy) | None => {}
286 }
287 }
288}
289
290impl Default for ProcessTerminal {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296impl Terminal for ProcessTerminal {
297 fn info(&self) -> TerminalInfo {
298 self.info.lock().map(|i| i.clone()).unwrap_or_default()
299 }
300
301 fn write(&self, data: &str) {
302 if let Ok(mut writer) = self.writer.lock() {
303 let _ = writer.write_all(data.as_bytes());
304 }
305 }
306
307 fn hide_cursor(&self) {
308 self.write("\x1b[?25l");
309 }
310
311 fn show_cursor(&self) {
312 self.write("\x1b[?25h");
313 }
314
315 fn move_cursor(&self, row: usize, col: usize) {
316 self.write(&format!("\x1b[{};{}H", row + 1, col + 1));
318 }
319
320 fn clear_screen(&self) {
321 self.write("\x1b[2J\x1b[H");
322 }
323
324 fn set_title(&self, title: &str) {
325 self.write(&format!("\x1b]2;{}\x07", title));
326 }
327
328 fn enable_mouse(&self) {
329 self.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
331 }
332
333 fn disable_mouse(&self) {
334 self.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
335 }
336
337 fn enter_raw_mode(&self) {
338 let _ = cterm::enable_raw_mode();
339 if let Ok(mut running) = self.running.lock() {
340 *running = true;
341 }
342 self.configure_mouse_tracking();
343 self.hide_cursor();
344 self.write("\x1b[?2004h");
348 #[cfg(unix)]
349 if keyboard_protocol_enabled() && self.is_tty() && io::stdin().is_terminal() {
350 self.enable_keyboard_protocol(|| {
351 cterm::supports_keyboard_enhancement().unwrap_or(false)
352 });
353 }
354 self.update_size();
355 self.flush();
356 }
357
358 fn exit_raw_mode(&self) {
359 self.write("\x1b[?2004l");
362 #[cfg(unix)]
363 self.disable_keyboard_protocol();
364 self.flush();
365 let _ = cterm::disable_raw_mode();
366 }
367
368 fn refresh_size(&self) {
369 self.update_size();
370 }
371
372 fn start(
373 &self,
374 on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
375 on_resize: Box<dyn Fn() + Send + Sync>,
376 ) {
377 self.enter_raw_mode();
378
379 self.update_size();
381
382 let running = Arc::new(Mutex::new(true));
384 let running_clone = running.clone();
385 let info_clone = Arc::new(Mutex::new(self.info()));
386
387 std::thread::spawn(move || {
388 loop {
389 if !*running_clone.lock().unwrap() {
391 break;
392 }
393
394 match event::poll(Duration::from_millis(100)) {
396 Ok(true) => {
397 if let Ok(event) = event::read() {
398 match event {
399 Event::Key(key) => {
400 on_input(InputEvent::Key(normalize_key_event(key)))
401 }
402 Event::Mouse(mouse) => on_input(InputEvent::Mouse(mouse)),
403 Event::Resize(cols, rows) => {
404 if let Ok(mut info) = info_clone.lock() {
405 info.columns = cols as usize;
406 info.rows = rows as usize;
407 }
408 on_resize();
409 }
410 Event::FocusGained => on_input(InputEvent::FocusGained),
411 Event::FocusLost => on_input(InputEvent::FocusLost),
412 Event::Paste(_) => { }
413 }
414 }
415 }
416 Ok(false) => {} Err(_) => break, }
419 }
420 });
421
422 if let Ok(mut r) = self.running.lock() {
424 *r = true;
425 }
426 }
427
428 fn stop(&self) {
429 if let Ok(mut running) = self.running.lock() {
430 *running = false;
431 }
432
433 self.disable_mouse();
434 self.show_cursor();
435 self.write("\x1b[?2004l");
437 #[cfg(unix)]
438 self.disable_keyboard_protocol();
439 self.flush();
440
441 let _ = cterm::disable_raw_mode();
443 }
444
445 fn is_tty(&self) -> bool {
446 std::io::stdout().is_terminal()
447 }
448
449 fn set_progress(&self, active: bool) {
450 let state = if active { 1 } else { 0 };
455 self.write(&format!("\x1b]9;4;{state};0\x07"));
456 self.flush();
457 }
458
459 fn flush(&self) {
460 if let Ok(mut writer) = self.writer.lock() {
461 let _ = writer.flush();
462 }
463 }
464}
465
466pub fn normalize_key_event(key: KeyEvent) -> KeyEvent {
470 #[cfg(target_os = "macos")]
471 if key.code == event::KeyCode::Enter
472 && key.modifiers.is_empty()
473 && local_apple_terminal(
474 std::env::var("TERM_PROGRAM").ok().as_deref(),
475 ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
476 .iter()
477 .any(|name| std::env::var_os(name).is_some()),
478 )
479 {
480 #[link(name = "CoreGraphics", kind = "framework")]
481 extern "C" {
482 fn CGEventSourceFlagsState(state_id: i32) -> u64;
483 }
484 let shift_pressed = unsafe { CGEventSourceFlagsState(0) } & (1 << 17) != 0;
487 return normalize_native_shift_enter(key, shift_pressed);
488 }
489 key
490}
491
492#[cfg(any(target_os = "macos", test))]
493fn local_apple_terminal(term_program: Option<&str>, ssh: bool) -> bool {
494 term_program == Some("Apple_Terminal") && !ssh
495}
496
497#[cfg(any(target_os = "macos", test))]
498fn normalize_native_shift_enter(mut key: KeyEvent, shift_pressed: bool) -> KeyEvent {
499 if key.code == event::KeyCode::Enter && key.modifiers.is_empty() && shift_pressed {
500 key.modifiers.insert(event::KeyModifiers::SHIFT);
501 }
502 key
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[derive(Clone)]
510 struct RecordedOutput(Arc<Mutex<Vec<u8>>>);
511 impl Write for RecordedOutput {
512 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
513 self.0.lock().unwrap().extend_from_slice(bytes);
514 Ok(bytes.len())
515 }
516 fn flush(&mut self) -> io::Result<()> {
517 Ok(())
518 }
519 }
520
521 #[test]
522 fn kitty_protocol_preserves_ime_text_and_restores_once_per_activation() {
523 let terminal = ProcessTerminal::new();
524 let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
525 *terminal.writer.lock().unwrap() = Box::new(output.clone());
526 terminal.enable_keyboard_protocol(|| true);
527 assert!(terminal.info().supports_kitty_keyboard);
528 terminal.enable_keyboard_protocol(|| panic!("active protocol must not be pushed twice"));
529 terminal.disable_keyboard_protocol();
530 terminal.disable_keyboard_protocol();
531 terminal.enable_keyboard_protocol(|| panic!("resume must not query stdin"));
532 terminal.disable_keyboard_protocol();
533 let bytes = output.0.lock().unwrap().clone();
534 assert_eq!(bytes, b"\x1b[>7u\x1b[<u\x1b[>7u\x1b[<u");
535 let sequence = std::str::from_utf8(&bytes)
537 .unwrap()
538 .split('u')
539 .next()
540 .unwrap();
541 let mask: u8 = sequence.strip_prefix("\x1b[>").unwrap().parse().unwrap();
542 let flags = event::KeyboardEnhancementFlags::from_bits(mask).unwrap();
543 assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
544 assert!(flags.contains(event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
545 assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
546 assert!(!flags.contains(event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
547 assert_eq!(mask & 16, 0);
549 }
550
551 #[test]
552 fn legacy_terminal_does_not_enable_an_encoding_the_reader_cannot_parse() {
553 let terminal = ProcessTerminal::new();
554 let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
555 *terminal.writer.lock().unwrap() = Box::new(output.clone());
556 terminal.enable_keyboard_protocol(|| false);
557 assert!(!terminal.info().supports_kitty_keyboard);
558 terminal.disable_keyboard_protocol();
559 terminal.enable_keyboard_protocol(|| panic!("resume must reuse detection"));
560 terminal.disable_keyboard_protocol();
561 assert_eq!(output.0.lock().unwrap().as_slice(), b"\x1b[>7u\x1b[<u");
562 }
563
564 #[test]
565 fn native_shift_enter_only_changes_unmodified_enter_in_local_apple_terminal() {
566 use event::{KeyCode, KeyModifiers};
567 assert!(local_apple_terminal(Some("Apple_Terminal"), false));
568 assert!(!local_apple_terminal(Some("Apple_Terminal"), true));
569 assert!(!local_apple_terminal(Some("iTerm.app"), false));
570 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
571 assert_eq!(
572 normalize_native_shift_enter(enter, true).modifiers,
573 KeyModifiers::SHIFT
574 );
575 assert_eq!(normalize_native_shift_enter(enter, false), enter);
576 for key in [
577 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
578 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
579 KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
580 ] {
581 assert_eq!(normalize_native_shift_enter(key, true), key);
582 }
583 }
584
585 #[test]
586 fn native_shift_enter_inserts_a_line_before_plain_enter_submits() {
587 use event::{KeyCode, KeyModifiers};
588 let editor = crate::Editor::simple();
589 let submitted = Arc::new(Mutex::new(Vec::new()));
590 let capture = submitted.clone();
591 editor.on_submit(Arc::new(move |text| {
592 capture.lock().unwrap().push(text.to_string())
593 }));
594 editor.insert("first");
595 let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
596 editor.handle_key(normalize_native_shift_enter(enter, true));
597 editor.insert("second");
598 assert_eq!(editor.get_text(), "first\nsecond");
599 assert!(submitted.lock().unwrap().is_empty());
600 editor.handle_key(normalize_native_shift_enter(enter, false));
601 assert_eq!(submitted.lock().unwrap().as_slice(), &["first\nsecond"]);
602 }
603
604 #[test]
605 fn mouse_tracking_default_preserves_native_selection_on_macos() {
606 assert!(!mouse_tracking_enabled_for(true));
607 assert!(mouse_tracking_enabled_for(false));
608 }
609
610 #[test]
611 fn macos_keeps_the_kitty_protocol_except_where_it_breaks_ime() {
612 assert!(keyboard_protocol_enabled_for(
615 true,
616 Some("iTerm.app"),
617 Some("xterm-256color")
618 ));
619 assert!(keyboard_protocol_enabled_for(true, Some("ghostty"), None));
620 assert!(keyboard_protocol_enabled_for(
621 true,
622 Some("Apple_Terminal"),
623 None
624 ));
625 assert!(keyboard_protocol_enabled_for(
628 true,
629 None,
630 Some("xterm-256color")
631 ));
632 assert!(!keyboard_protocol_enabled_for(
634 true,
635 Some("Otty"),
636 Some("xterm-256color")
637 ));
638 assert!(!keyboard_protocol_enabled_for(true, None, Some("otty")));
639 assert!(keyboard_protocol_enabled_for(
641 false,
642 Some("Otty"),
643 Some("otty")
644 ));
645 }
646
647 #[test]
648 fn ime_committed_utf8_text_reaches_editor_without_submitting() {
649 use event::{KeyCode, KeyModifiers};
650 let editor = crate::Editor::simple();
651 let submitted = Arc::new(Mutex::new(Vec::new()));
652 let capture = submitted.clone();
653 editor.on_submit(Arc::new(move |text| {
654 capture.lock().unwrap().push(text.to_string())
655 }));
656 let committed = "你好,世界𠮷 abc";
657 for ch in committed.chars() {
658 editor.handle_key(normalize_key_event(KeyEvent::new(
659 KeyCode::Char(ch),
660 KeyModifiers::NONE,
661 )));
662 }
663 assert_eq!(editor.get_text(), committed);
664 assert!(submitted.lock().unwrap().is_empty());
665 editor.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
666 assert_eq!(submitted.lock().unwrap().as_slice(), &[committed]);
667 }
668}