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 pub fn minimal() -> io::Result<Self> {
299 Self::new(SessionOptions::default())
300 }
301
302 pub fn size(&self) -> io::Result<(u16, u16)> {
304 let (w, h) = crossterm::terminal::size()?;
305 if w > 1 && h > 1 {
306 return Ok((w, h));
307 }
308
309 if let Some((env_w, env_h)) = size_from_env() {
311 return Ok((env_w, env_h));
312 }
313
314 std::thread::sleep(Duration::from_millis(10));
316 let (w2, h2) = crossterm::terminal::size()?;
317 if w2 > 1 && h2 > 1 {
318 return Ok((w2, h2));
319 }
320
321 let final_w = w.max(2);
324 let final_h = h.max(2);
325 Ok((final_w, final_h))
326 }
327
328 pub fn poll_event(&self, timeout: std::time::Duration) -> io::Result<bool> {
332 crossterm::event::poll(timeout)
333 }
334
335 pub fn read_event(&self) -> io::Result<Option<Event>> {
340 let event = crossterm::event::read()?;
341 Ok(Event::from_crossterm(event))
342 }
343
344 pub fn show_cursor(&self) -> io::Result<()> {
346 crossterm::execute!(io::stdout(), crossterm::cursor::Show)
347 }
348
349 pub fn hide_cursor(&self) -> io::Result<()> {
351 crossterm::execute!(io::stdout(), crossterm::cursor::Hide)
352 }
353
354 pub fn options(&self) -> &SessionOptions {
356 &self.options
357 }
358
359 fn cleanup(&mut self) {
361 #[cfg(unix)]
362 let _ = self.signal_guard.take();
363
364 let mut stdout = io::stdout();
365
366 let _ = stdout.write_all(SYNC_END);
368
369 if self.kitty_keyboard_enabled {
371 let _ = Self::disable_kitty_keyboard(&mut stdout);
372 self.kitty_keyboard_enabled = false;
373 #[cfg(feature = "tracing")]
374 tracing::info!("kitty keyboard disabled");
375 }
376
377 if self.focus_events_enabled {
378 let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
379 self.focus_events_enabled = false;
380 #[cfg(feature = "tracing")]
381 tracing::info!("focus events disabled");
382 }
383
384 if self.bracketed_paste_enabled {
385 let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
386 self.bracketed_paste_enabled = false;
387 #[cfg(feature = "tracing")]
388 tracing::info!("bracketed paste disabled");
389 }
390
391 if self.mouse_enabled {
392 let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
393 self.mouse_enabled = false;
394 #[cfg(feature = "tracing")]
395 tracing::info!("mouse capture disabled");
396 }
397
398 let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
400
401 if self.alternate_screen_enabled {
402 let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
403 self.alternate_screen_enabled = false;
404 #[cfg(feature = "tracing")]
405 tracing::info!("alternate screen disabled");
406 }
407
408 let _ = crossterm::terminal::disable_raw_mode();
410 #[cfg(feature = "tracing")]
411 tracing::info!("terminal raw mode disabled");
412
413 let _ = stdout.flush();
415 }
416
417 fn enable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
418 writer.write_all(KITTY_KEYBOARD_ENABLE)?;
419 writer.flush()
420 }
421
422 fn disable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
423 writer.write_all(KITTY_KEYBOARD_DISABLE)?;
424 writer.flush()
425 }
426}
427
428impl Drop for TerminalSession {
429 fn drop(&mut self) {
430 self.cleanup();
431 }
432}
433
434fn size_from_env() -> Option<(u16, u16)> {
435 let cols = env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
436 let rows = env::var("LINES").ok()?.parse::<u16>().ok()?;
437 if cols > 1 && rows > 1 {
438 Some((cols, rows))
439 } else {
440 None
441 }
442}
443
444fn install_panic_hook() {
445 static HOOK: OnceLock<()> = OnceLock::new();
446 HOOK.get_or_init(|| {
447 let previous = std::panic::take_hook();
448 std::panic::set_hook(Box::new(move |info| {
449 best_effort_cleanup();
450 previous(info);
451 }));
452 });
453}
454
455pub fn best_effort_cleanup_for_exit() {
460 best_effort_cleanup();
461}
462
463fn best_effort_cleanup() {
464 let mut stdout = io::stdout();
465
466 let _ = stdout.write_all(SYNC_END);
469
470 let _ = TerminalSession::disable_kitty_keyboard(&mut stdout);
471 let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
472 let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
473 let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
474 let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
475 let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
476 let _ = crossterm::terminal::disable_raw_mode();
477 let _ = stdout.flush();
478}
479
480#[cfg(unix)]
481#[derive(Debug)]
482struct SignalGuard {
483 handle: signal_hook::iterator::Handle,
484 thread: Option<std::thread::JoinHandle<()>>,
485}
486
487#[cfg(unix)]
488impl SignalGuard {
489 fn new() -> io::Result<Self> {
490 let mut signals = Signals::new([SIGINT, SIGTERM, SIGWINCH]).map_err(io::Error::other)?;
491 let handle = signals.handle();
492 let thread = std::thread::spawn(move || {
493 for signal in signals.forever() {
494 match signal {
495 SIGWINCH => {
496 #[cfg(feature = "tracing")]
497 tracing::debug!("SIGWINCH received");
498 }
499 SIGINT | SIGTERM => {
500 #[cfg(feature = "tracing")]
501 tracing::warn!("termination signal received, cleaning up");
502 best_effort_cleanup();
503 std::process::exit(128 + signal);
504 }
505 _ => {}
506 }
507 }
508 });
509 Ok(Self {
510 handle,
511 thread: Some(thread),
512 })
513 }
514}
515
516#[cfg(unix)]
517impl Drop for SignalGuard {
518 fn drop(&mut self) {
519 self.handle.close();
520 if let Some(thread) = self.thread.take() {
521 let _ = thread.join();
522 }
523 }
524}
525
526#[doc(hidden)]
563pub const _SPIKE_NOTES: () = ();
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 #[cfg(unix)]
569 use portable_pty::{CommandBuilder, PtySize};
570 #[cfg(unix)]
571 use std::io::{self, Read, Write};
572 #[cfg(unix)]
573 use std::sync::mpsc;
574 #[cfg(unix)]
575 use std::thread;
576 #[cfg(unix)]
577 use std::time::{Duration, Instant};
578
579 #[test]
580 fn session_options_default_is_minimal() {
581 let opts = SessionOptions::default();
582 assert!(!opts.alternate_screen);
583 assert!(!opts.mouse_capture);
584 assert!(!opts.bracketed_paste);
585 assert!(!opts.focus_events);
586 assert!(!opts.kitty_keyboard);
587 }
588
589 #[test]
590 fn session_options_clone() {
591 let opts = SessionOptions {
592 alternate_screen: true,
593 mouse_capture: true,
594 bracketed_paste: false,
595 focus_events: true,
596 kitty_keyboard: false,
597 };
598 let cloned = opts.clone();
599 assert_eq!(cloned.alternate_screen, opts.alternate_screen);
600 assert_eq!(cloned.mouse_capture, opts.mouse_capture);
601 assert_eq!(cloned.bracketed_paste, opts.bracketed_paste);
602 assert_eq!(cloned.focus_events, opts.focus_events);
603 assert_eq!(cloned.kitty_keyboard, opts.kitty_keyboard);
604 }
605
606 #[test]
607 fn session_options_debug() {
608 let opts = SessionOptions::default();
609 let debug = format!("{:?}", opts);
610 assert!(debug.contains("SessionOptions"));
611 assert!(debug.contains("alternate_screen"));
612 }
613
614 #[test]
615 fn kitty_keyboard_escape_sequences() {
616 assert_eq!(KITTY_KEYBOARD_ENABLE, b"\x1b[>15u");
618 assert_eq!(KITTY_KEYBOARD_DISABLE, b"\x1b[<u");
619 }
620
621 #[test]
622 fn session_options_partial_config() {
623 let opts = SessionOptions {
624 alternate_screen: true,
625 mouse_capture: false,
626 bracketed_paste: true,
627 ..Default::default()
628 };
629 assert!(opts.alternate_screen);
630 assert!(!opts.mouse_capture);
631 assert!(opts.bracketed_paste);
632 assert!(!opts.focus_events);
633 assert!(!opts.kitty_keyboard);
634 }
635
636 #[cfg(unix)]
637 enum ReaderMsg {
638 Data(Vec<u8>),
639 Eof,
640 Err(std::io::Error),
641 }
642
643 #[cfg(unix)]
644 fn read_until_pattern(
645 rx: &mpsc::Receiver<ReaderMsg>,
646 captured: &mut Vec<u8>,
647 pattern: &[u8],
648 timeout: Duration,
649 ) -> std::io::Result<()> {
650 let deadline = Instant::now() + timeout;
651 while Instant::now() < deadline {
652 let remaining = deadline.saturating_duration_since(Instant::now());
653 let wait = remaining.min(Duration::from_millis(50));
654 match rx.recv_timeout(wait) {
655 Ok(ReaderMsg::Data(chunk)) => {
656 captured.extend_from_slice(&chunk);
657 if captured.windows(pattern.len()).any(|w| w == pattern) {
658 return Ok(());
659 }
660 }
661 Ok(ReaderMsg::Eof) => break,
662 Ok(ReaderMsg::Err(err)) => return Err(err),
663 Err(mpsc::RecvTimeoutError::Timeout) => continue,
664 Err(mpsc::RecvTimeoutError::Disconnected) => break,
665 }
666 }
667 Err(std::io::Error::other(
668 "timeout waiting for PTY output marker",
669 ))
670 }
671
672 #[cfg(unix)]
673 fn assert_contains_any(output: &[u8], options: &[&[u8]], label: &str) {
674 let found = options
675 .iter()
676 .any(|needle| output.windows(needle.len()).any(|w| w == *needle));
677 assert!(found, "expected cleanup sequence for {label}");
678 }
679
680 #[cfg(unix)]
681 #[test]
682 fn terminal_session_panic_cleanup_idempotent() {
683 const MARKER: &[u8] = b"PANIC_CAUGHT";
684 const TEST_NAME: &str =
685 "terminal_session::tests::terminal_session_panic_cleanup_idempotent";
686 const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
687 const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
688 b"\x1b[?1000;1002;1006l",
689 b"\x1b[?1000;1002l",
690 b"\x1b[?1000l",
691 ];
692 const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
693 const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
694 const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
695 const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
696
697 if std::env::var("FTUI_CORE_PANIC_CHILD").is_ok() {
698 let _ = std::panic::catch_unwind(|| {
699 let _session = TerminalSession::new(SessionOptions {
700 alternate_screen: true,
701 mouse_capture: true,
702 bracketed_paste: true,
703 focus_events: true,
704 kitty_keyboard: true,
705 })
706 .expect("TerminalSession::new should succeed in PTY");
707 panic!("intentional panic to exercise cleanup");
708 });
709
710 best_effort_cleanup_for_exit();
713
714 let _ = io::stdout().write_all(MARKER);
715 let _ = io::stdout().flush();
716 return;
717 }
718
719 let exe = std::env::current_exe().expect("current_exe");
720 let mut cmd = CommandBuilder::new(exe);
721 cmd.args(["--exact", TEST_NAME, "--nocapture"]);
722 cmd.env("FTUI_CORE_PANIC_CHILD", "1");
723 cmd.env("RUST_BACKTRACE", "0");
724
725 let pty_system = portable_pty::native_pty_system();
726 let pair = pty_system
727 .openpty(PtySize {
728 rows: 24,
729 cols: 80,
730 pixel_width: 0,
731 pixel_height: 0,
732 })
733 .expect("openpty");
734
735 let mut child = pair.slave.spawn_command(cmd).expect("spawn PTY child");
736 drop(pair.slave);
737
738 let mut reader = pair.master.try_clone_reader().expect("clone PTY reader");
739 let _writer = pair.master.take_writer().expect("take PTY writer");
740
741 let (tx, rx) = mpsc::channel::<ReaderMsg>();
742 let reader_thread = thread::spawn(move || {
743 let mut buf = [0u8; 4096];
744 loop {
745 match reader.read(&mut buf) {
746 Ok(0) => {
747 let _ = tx.send(ReaderMsg::Eof);
748 break;
749 }
750 Ok(n) => {
751 let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
752 }
753 Err(err) => {
754 let _ = tx.send(ReaderMsg::Err(err));
755 break;
756 }
757 }
758 }
759 });
760
761 let mut captured = Vec::new();
762 read_until_pattern(&rx, &mut captured, MARKER, Duration::from_secs(5))
763 .expect("expected marker from child");
764
765 let status = child.wait().expect("child wait");
766 let _ = reader_thread.join();
767
768 assert!(status.success(), "child should exit successfully");
769 assert!(
770 captured.windows(MARKER.len()).any(|w| w == MARKER),
771 "expected panic marker in PTY output"
772 );
773 assert_contains_any(&captured, ALT_SCREEN_EXIT_SEQS, "alt-screen exit");
774 assert_contains_any(&captured, MOUSE_DISABLE_SEQS, "mouse disable");
775 assert_contains_any(
776 &captured,
777 BRACKETED_PASTE_DISABLE_SEQS,
778 "bracketed paste disable",
779 );
780 assert_contains_any(&captured, FOCUS_DISABLE_SEQS, "focus disable");
781 assert_contains_any(&captured, KITTY_DISABLE_SEQS, "kitty disable");
782 assert_contains_any(&captured, CURSOR_SHOW_SEQS, "cursor show");
783 }
784}