1#![forbid(unsafe_code)]
2
3use std::env;
78use std::io::{self, Write};
79use std::sync::OnceLock;
80use std::time::Duration;
81
82use crate::event::Event;
83
84const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
85const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
86const SYNC_END: &[u8] = b"\x1b[?2026l";
87
88#[cfg(unix)]
89use signal_hook::consts::signal::{SIGINT, SIGTERM, SIGWINCH};
90#[cfg(unix)]
91use signal_hook::iterator::Signals;
92
93#[derive(Debug, Clone, Default)]
116pub struct SessionOptions {
117 pub alternate_screen: bool,
126
127 pub mouse_capture: bool,
134
135 pub bracketed_paste: bool,
143
144 pub focus_events: bool,
150
151 pub kitty_keyboard: bool,
156}
157
158#[derive(Debug)]
204pub struct TerminalSession {
205 options: SessionOptions,
206 alternate_screen_enabled: bool,
208 mouse_enabled: bool,
209 bracketed_paste_enabled: bool,
210 focus_events_enabled: bool,
211 kitty_keyboard_enabled: bool,
212 #[cfg(unix)]
213 signal_guard: Option<SignalGuard>,
214}
215
216impl TerminalSession {
217 pub fn new(options: SessionOptions) -> io::Result<Self> {
223 install_panic_hook();
224
225 #[cfg(unix)]
229 let signal_guard = Some(SignalGuard::new()?);
230
231 crossterm::terminal::enable_raw_mode()?;
233 #[cfg(feature = "tracing")]
234 tracing::info!("terminal raw mode enabled");
235
236 let mut session = Self {
237 options: options.clone(),
238 alternate_screen_enabled: false,
239 mouse_enabled: false,
240 bracketed_paste_enabled: false,
241 focus_events_enabled: false,
242 kitty_keyboard_enabled: false,
243 #[cfg(unix)]
244 signal_guard,
245 };
246
247 let mut stdout = io::stdout();
249
250 if options.alternate_screen {
251 crossterm::execute!(
256 stdout,
257 crossterm::terminal::EnterAlternateScreen,
258 crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
259 crossterm::cursor::MoveTo(0, 0)
260 )?;
261 session.alternate_screen_enabled = true;
262 #[cfg(feature = "tracing")]
263 tracing::info!("alternate screen enabled (with clear)");
264 }
265
266 if options.mouse_capture {
267 crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
268 session.mouse_enabled = true;
269 #[cfg(feature = "tracing")]
270 tracing::info!("mouse capture enabled");
271 }
272
273 if options.bracketed_paste {
274 crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste)?;
275 session.bracketed_paste_enabled = true;
276 #[cfg(feature = "tracing")]
277 tracing::info!("bracketed paste enabled");
278 }
279
280 if options.focus_events {
281 crossterm::execute!(stdout, crossterm::event::EnableFocusChange)?;
282 session.focus_events_enabled = true;
283 #[cfg(feature = "tracing")]
284 tracing::info!("focus events enabled");
285 }
286
287 if options.kitty_keyboard {
288 Self::enable_kitty_keyboard(&mut stdout)?;
289 session.kitty_keyboard_enabled = true;
290 #[cfg(feature = "tracing")]
291 tracing::info!("kitty keyboard enabled");
292 }
293
294 Ok(session)
295 }
296
297 #[cfg(feature = "test-helpers")]
302 pub fn new_for_tests(options: SessionOptions) -> io::Result<Self> {
303 install_panic_hook();
304 #[cfg(unix)]
305 let signal_guard = None;
306
307 Ok(Self {
308 options,
309 alternate_screen_enabled: false,
310 mouse_enabled: false,
311 bracketed_paste_enabled: false,
312 focus_events_enabled: false,
313 kitty_keyboard_enabled: false,
314 #[cfg(unix)]
315 signal_guard,
316 })
317 }
318
319 pub fn minimal() -> io::Result<Self> {
321 Self::new(SessionOptions::default())
322 }
323
324 pub fn size(&self) -> io::Result<(u16, u16)> {
326 let (w, h) = crossterm::terminal::size()?;
327 if w > 1 && h > 1 {
328 return Ok((w, h));
329 }
330
331 if let Some((env_w, env_h)) = size_from_env() {
333 return Ok((env_w, env_h));
334 }
335
336 std::thread::sleep(Duration::from_millis(10));
338 let (w2, h2) = crossterm::terminal::size()?;
339 if w2 > 1 && h2 > 1 {
340 return Ok((w2, h2));
341 }
342
343 let final_w = w.max(2);
346 let final_h = h.max(2);
347 Ok((final_w, final_h))
348 }
349
350 pub fn poll_event(&self, timeout: std::time::Duration) -> io::Result<bool> {
354 crossterm::event::poll(timeout)
355 }
356
357 pub fn read_event(&self) -> io::Result<Option<Event>> {
362 let event = crossterm::event::read()?;
363 Ok(Event::from_crossterm(event))
364 }
365
366 pub fn show_cursor(&self) -> io::Result<()> {
368 crossterm::execute!(io::stdout(), crossterm::cursor::Show)
369 }
370
371 pub fn hide_cursor(&self) -> io::Result<()> {
373 crossterm::execute!(io::stdout(), crossterm::cursor::Hide)
374 }
375
376 pub fn options(&self) -> &SessionOptions {
378 &self.options
379 }
380
381 fn cleanup(&mut self) {
383 #[cfg(unix)]
384 let _ = self.signal_guard.take();
385
386 let mut stdout = io::stdout();
387
388 let _ = stdout.write_all(SYNC_END);
390
391 if self.kitty_keyboard_enabled {
393 let _ = Self::disable_kitty_keyboard(&mut stdout);
394 self.kitty_keyboard_enabled = false;
395 #[cfg(feature = "tracing")]
396 tracing::info!("kitty keyboard disabled");
397 }
398
399 if self.focus_events_enabled {
400 let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
401 self.focus_events_enabled = false;
402 #[cfg(feature = "tracing")]
403 tracing::info!("focus events disabled");
404 }
405
406 if self.bracketed_paste_enabled {
407 let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
408 self.bracketed_paste_enabled = false;
409 #[cfg(feature = "tracing")]
410 tracing::info!("bracketed paste disabled");
411 }
412
413 if self.mouse_enabled {
414 let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
415 self.mouse_enabled = false;
416 #[cfg(feature = "tracing")]
417 tracing::info!("mouse capture disabled");
418 }
419
420 let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
422
423 if self.alternate_screen_enabled {
424 let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
425 self.alternate_screen_enabled = false;
426 #[cfg(feature = "tracing")]
427 tracing::info!("alternate screen disabled");
428 }
429
430 let _ = crossterm::terminal::disable_raw_mode();
432 #[cfg(feature = "tracing")]
433 tracing::info!("terminal raw mode disabled");
434
435 let _ = stdout.flush();
437 }
438
439 fn enable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
440 writer.write_all(KITTY_KEYBOARD_ENABLE)?;
441 writer.flush()
442 }
443
444 fn disable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
445 writer.write_all(KITTY_KEYBOARD_DISABLE)?;
446 writer.flush()
447 }
448}
449
450impl Drop for TerminalSession {
451 fn drop(&mut self) {
452 self.cleanup();
453 }
454}
455
456fn size_from_env() -> Option<(u16, u16)> {
457 let cols = env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
458 let rows = env::var("LINES").ok()?.parse::<u16>().ok()?;
459 if cols > 1 && rows > 1 {
460 Some((cols, rows))
461 } else {
462 None
463 }
464}
465
466fn install_panic_hook() {
467 static HOOK: OnceLock<()> = OnceLock::new();
468 HOOK.get_or_init(|| {
469 let previous = std::panic::take_hook();
470 std::panic::set_hook(Box::new(move |info| {
471 best_effort_cleanup();
472 previous(info);
473 }));
474 });
475}
476
477pub fn best_effort_cleanup_for_exit() {
482 best_effort_cleanup();
483}
484
485fn best_effort_cleanup() {
486 let mut stdout = io::stdout();
487
488 let _ = stdout.write_all(SYNC_END);
491
492 let _ = TerminalSession::disable_kitty_keyboard(&mut stdout);
493 let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
494 let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
495 let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
496 let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
497 let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
498 let _ = crossterm::terminal::disable_raw_mode();
499 let _ = stdout.flush();
500}
501
502#[cfg(unix)]
503#[derive(Debug)]
504struct SignalGuard {
505 handle: signal_hook::iterator::Handle,
506 thread: Option<std::thread::JoinHandle<()>>,
507}
508
509#[cfg(unix)]
510impl SignalGuard {
511 fn new() -> io::Result<Self> {
512 let mut signals = Signals::new([SIGINT, SIGTERM, SIGWINCH]).map_err(io::Error::other)?;
513 let handle = signals.handle();
514 let thread = std::thread::spawn(move || {
515 for signal in signals.forever() {
516 match signal {
517 SIGWINCH => {
518 #[cfg(feature = "tracing")]
519 tracing::debug!("SIGWINCH received");
520 }
521 SIGINT | SIGTERM => {
522 #[cfg(feature = "tracing")]
523 tracing::warn!("termination signal received, cleaning up");
524 best_effort_cleanup();
525 std::process::exit(128 + signal);
526 }
527 _ => {}
528 }
529 }
530 });
531 Ok(Self {
532 handle,
533 thread: Some(thread),
534 })
535 }
536}
537
538#[cfg(unix)]
539impl Drop for SignalGuard {
540 fn drop(&mut self) {
541 self.handle.close();
542 if let Some(thread) = self.thread.take() {
543 let _ = thread.join();
544 }
545 }
546}
547
548#[doc(hidden)]
585pub const _SPIKE_NOTES: () = ();
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 #[cfg(unix)]
591 use portable_pty::{CommandBuilder, PtySize};
592 #[cfg(unix)]
593 use std::io::{self, Read, Write};
594 #[cfg(unix)]
595 use std::sync::mpsc;
596 #[cfg(unix)]
597 use std::thread;
598 #[cfg(unix)]
599 use std::time::{Duration, Instant};
600
601 #[test]
602 fn session_options_default_is_minimal() {
603 let opts = SessionOptions::default();
604 assert!(!opts.alternate_screen);
605 assert!(!opts.mouse_capture);
606 assert!(!opts.bracketed_paste);
607 assert!(!opts.focus_events);
608 assert!(!opts.kitty_keyboard);
609 }
610
611 #[test]
612 fn session_options_clone() {
613 let opts = SessionOptions {
614 alternate_screen: true,
615 mouse_capture: true,
616 bracketed_paste: false,
617 focus_events: true,
618 kitty_keyboard: false,
619 };
620 let cloned = opts.clone();
621 assert_eq!(cloned.alternate_screen, opts.alternate_screen);
622 assert_eq!(cloned.mouse_capture, opts.mouse_capture);
623 assert_eq!(cloned.bracketed_paste, opts.bracketed_paste);
624 assert_eq!(cloned.focus_events, opts.focus_events);
625 assert_eq!(cloned.kitty_keyboard, opts.kitty_keyboard);
626 }
627
628 #[test]
629 fn session_options_debug() {
630 let opts = SessionOptions::default();
631 let debug = format!("{:?}", opts);
632 assert!(debug.contains("SessionOptions"));
633 assert!(debug.contains("alternate_screen"));
634 }
635
636 #[test]
637 fn kitty_keyboard_escape_sequences() {
638 assert_eq!(KITTY_KEYBOARD_ENABLE, b"\x1b[>15u");
640 assert_eq!(KITTY_KEYBOARD_DISABLE, b"\x1b[<u");
641 }
642
643 #[test]
644 fn session_options_partial_config() {
645 let opts = SessionOptions {
646 alternate_screen: true,
647 mouse_capture: false,
648 bracketed_paste: true,
649 ..Default::default()
650 };
651 assert!(opts.alternate_screen);
652 assert!(!opts.mouse_capture);
653 assert!(opts.bracketed_paste);
654 assert!(!opts.focus_events);
655 assert!(!opts.kitty_keyboard);
656 }
657
658 #[cfg(unix)]
659 enum ReaderMsg {
660 Data(Vec<u8>),
661 Eof,
662 Err(std::io::Error),
663 }
664
665 #[cfg(unix)]
666 fn read_until_pattern(
667 rx: &mpsc::Receiver<ReaderMsg>,
668 captured: &mut Vec<u8>,
669 pattern: &[u8],
670 timeout: Duration,
671 ) -> std::io::Result<()> {
672 let deadline = Instant::now() + timeout;
673 while Instant::now() < deadline {
674 let remaining = deadline.saturating_duration_since(Instant::now());
675 let wait = remaining.min(Duration::from_millis(50));
676 match rx.recv_timeout(wait) {
677 Ok(ReaderMsg::Data(chunk)) => {
678 captured.extend_from_slice(&chunk);
679 if captured.windows(pattern.len()).any(|w| w == pattern) {
680 return Ok(());
681 }
682 }
683 Ok(ReaderMsg::Eof) => break,
684 Ok(ReaderMsg::Err(err)) => return Err(err),
685 Err(mpsc::RecvTimeoutError::Timeout) => continue,
686 Err(mpsc::RecvTimeoutError::Disconnected) => break,
687 }
688 }
689 Err(std::io::Error::other(
690 "timeout waiting for PTY output marker",
691 ))
692 }
693
694 #[cfg(unix)]
695 fn assert_contains_any(output: &[u8], options: &[&[u8]], label: &str) {
696 let found = options
697 .iter()
698 .any(|needle| output.windows(needle.len()).any(|w| w == *needle));
699 assert!(found, "expected cleanup sequence for {label}");
700 }
701
702 #[cfg(unix)]
703 #[test]
704 fn terminal_session_panic_cleanup_idempotent() {
705 const MARKER: &[u8] = b"PANIC_CAUGHT";
706 const TEST_NAME: &str =
707 "terminal_session::tests::terminal_session_panic_cleanup_idempotent";
708 const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
709 const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
710 b"\x1b[?1000;1002;1006l",
711 b"\x1b[?1000;1002l",
712 b"\x1b[?1000l",
713 ];
714 const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
715 const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
716 const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
717 const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
718
719 if std::env::var("FTUI_CORE_PANIC_CHILD").is_ok() {
720 let _ = std::panic::catch_unwind(|| {
721 let _session = TerminalSession::new(SessionOptions {
722 alternate_screen: true,
723 mouse_capture: true,
724 bracketed_paste: true,
725 focus_events: true,
726 kitty_keyboard: true,
727 })
728 .expect("TerminalSession::new should succeed in PTY");
729 panic!("intentional panic to exercise cleanup");
730 });
731
732 best_effort_cleanup_for_exit();
735
736 let _ = io::stdout().write_all(MARKER);
737 let _ = io::stdout().flush();
738 return;
739 }
740
741 let exe = std::env::current_exe().expect("current_exe");
742 let mut cmd = CommandBuilder::new(exe);
743 cmd.args(["--exact", TEST_NAME, "--nocapture"]);
744 cmd.env("FTUI_CORE_PANIC_CHILD", "1");
745 cmd.env("RUST_BACKTRACE", "0");
746
747 let pty_system = portable_pty::native_pty_system();
748 let pair = pty_system
749 .openpty(PtySize {
750 rows: 24,
751 cols: 80,
752 pixel_width: 0,
753 pixel_height: 0,
754 })
755 .expect("openpty");
756
757 let mut child = pair.slave.spawn_command(cmd).expect("spawn PTY child");
758 drop(pair.slave);
759
760 let mut reader = pair.master.try_clone_reader().expect("clone PTY reader");
761 let _writer = pair.master.take_writer().expect("take PTY writer");
762
763 let (tx, rx) = mpsc::channel::<ReaderMsg>();
764 let reader_thread = thread::spawn(move || {
765 let mut buf = [0u8; 4096];
766 loop {
767 match reader.read(&mut buf) {
768 Ok(0) => {
769 let _ = tx.send(ReaderMsg::Eof);
770 break;
771 }
772 Ok(n) => {
773 let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
774 }
775 Err(err) => {
776 let _ = tx.send(ReaderMsg::Err(err));
777 break;
778 }
779 }
780 }
781 });
782
783 let mut captured = Vec::new();
784 read_until_pattern(&rx, &mut captured, MARKER, Duration::from_secs(5))
785 .expect("expected marker from child");
786
787 let status = child.wait().expect("child wait");
788 let _ = reader_thread.join();
789
790 assert!(status.success(), "child should exit successfully");
791 assert!(
792 captured.windows(MARKER.len()).any(|w| w == MARKER),
793 "expected panic marker in PTY output"
794 );
795 assert_contains_any(&captured, ALT_SCREEN_EXIT_SEQS, "alt-screen exit");
796 assert_contains_any(&captured, MOUSE_DISABLE_SEQS, "mouse disable");
797 assert_contains_any(
798 &captured,
799 BRACKETED_PASTE_DISABLE_SEQS,
800 "bracketed paste disable",
801 );
802 assert_contains_any(&captured, FOCUS_DISABLE_SEQS, "focus disable");
803 assert_contains_any(&captured, KITTY_DISABLE_SEQS, "kitty disable");
804 assert_contains_any(&captured, CURSOR_SHOW_SEQS, "cursor show");
805 }
806}