1#![cfg_attr(not(test), forbid(unsafe_code))]
27#![cfg_attr(test, deny(unsafe_code))]
28#![warn(missing_docs)]
36#![warn(unreachable_pub)]
37#![deny(clippy::unwrap_in_result)]
38#![warn(clippy::unwrap_used)]
39#![warn(clippy::dbg_macro)]
40#![warn(clippy::print_stdout)]
41#![warn(clippy::print_stderr)]
42#![cfg_attr(docsrs, feature(doc_cfg))]
43
44pub mod anim;
96pub mod buffer;
98pub mod cell;
100pub mod chart;
102pub mod context;
104pub mod event;
106pub mod halfblock;
108#[cfg(feature = "crossterm")]
109mod iterm;
110pub mod keymap;
112pub mod layout;
114pub mod palette;
116pub mod rect;
118#[cfg(feature = "crossterm")]
119mod sixel;
120pub mod style;
122pub mod syntax;
124#[cfg(feature = "crossterm")]
125mod terminal;
126pub mod test_utils;
128pub mod widgets;
130
131use std::io;
132#[cfg(feature = "crossterm")]
133use std::io::IsTerminal;
134use std::time::{Duration, Instant};
135
136#[cfg(feature = "crossterm")]
140#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
141pub use crossterm;
142#[doc(hidden)]
143pub use layout::__bench_dim_buffer_around;
144#[doc(hidden)]
145pub use layout::__bench_wrap_segments;
146#[cfg(feature = "crossterm")]
147#[doc(hidden)]
148pub use terminal::__bench_flush_buffer_diff;
149#[cfg(feature = "crossterm")]
150#[doc(hidden)]
151pub use terminal::__bench_flush_buffer_diff_mut;
152#[cfg(feature = "crossterm")]
153#[doc(hidden)]
154pub use terminal::__bench_flush_buffer_diff_mut_with_buf;
155#[cfg(feature = "crossterm")]
156#[doc(hidden)]
157pub use terminal::__bench_flush_kitty;
158#[cfg(feature = "crossterm")]
159#[doc(hidden)]
160pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture};
161#[cfg(feature = "crossterm")]
162#[doc(hidden)]
163pub use terminal::{__BenchSprixelFixture, __bench_flush_sprixels, __bench_new_sprixel_fixture};
164#[cfg(feature = "crossterm")]
168#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
169pub use terminal::{Blitter, BlitterSupport, Capabilities, capabilities};
170#[cfg(feature = "crossterm")]
171#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
172pub use terminal::{ColorScheme, detect_color_scheme, read_clipboard};
173#[cfg(feature = "crossterm")]
178#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
179pub use terminal::{InlineTerminal, Terminal};
180
181pub use crate::test_utils::{EventBuilder, FrameRecord, TestBackend, TestSequence};
182#[cfg(feature = "pty-test")]
185#[cfg_attr(docsrs, doc(cfg(feature = "pty-test")))]
186pub use crate::test_utils::{PtyBackend, PtyFrame};
187pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
192pub use buffer::Buffer;
193pub use cell::Cell;
194pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
198pub use context::{
199 Anchor, Bar, BarChartConfig, BarDirection, BarGroup, Breadcrumb, CanvasContext, CodeBlock,
200 ContainerBuilder, Context, Gauge, GutterOpts, LineGauge, Memo, Response, State, TreemapItem,
201 Widget,
202};
203#[cfg(feature = "async")]
205#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
206pub use context::{TaskHandle, TaskOutcome};
207pub use event::{
208 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKey, MouseButton, MouseEvent,
209 MouseKind,
210};
211pub use halfblock::HalfBlockImage;
212pub use keymap::{Binding, KeyMap, PublishedKeymap, WidgetKeyHelp};
213pub use layout::Direction;
214pub use palette::Palette;
215pub use rect::Rect;
216#[cfg(feature = "theme-watch")]
217#[cfg_attr(docsrs, doc(cfg(feature = "theme-watch")))]
218pub use style::ThemeWatcher;
219pub use style::{
220 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, ColorParseError, Constraints,
221 ContainerStyle, HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, SyntaxPalette,
222 Theme, ThemeBuilder, ThemeColor, UnderlineStyle, WidgetColors, WidgetTheme, WidthSpec,
223};
224#[cfg(feature = "serde")]
225#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
226pub use style::{ThemeFile, ThemeLoadError};
227#[cfg(feature = "async")]
228#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
229pub use widgets::AsyncValidation;
230pub use widgets::validators;
231pub use widgets::{
232 AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalDate, CalendarSelect,
233 CalendarState, ChordState, ColorPickerState, CommandPaletteState, ContextItem,
234 DEFAULT_CHORD_TIMEOUT_TICKS, DirectoryTreeState, FileEntry, FilePickerScanError,
235 FilePickerScanOperation, FilePickerScanStatus, FilePickerState, FormField, FormState,
236 GaugeResponse, GridColumn, GutterResponse, HighlightRange, ListResponse, ListState, ModeState,
237 MultiSelectState, NumberInputState, PaginatorState, PaginatorStyle, PaletteCommand, PickerMode,
238 RadioState, RichLogEntry, RichLogState, SchedulerState, ScreenState, ScrollState, SelectState,
239 SliderOpts, SpinnerPreset, SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput,
240 StreamingMarkdownState, StreamingTextState, TableColumn, TableState, TabsState, TextInputState,
241 TextareaState, ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState,
242 Trend, ValidateTrigger, Validator,
243};
244
245pub trait Backend {
295 fn size(&self) -> (u32, u32);
297
298 fn buffer_mut(&mut self) -> &mut Buffer;
303
304 fn flush(&mut self) -> io::Result<()>;
310
311 #[doc(hidden)]
317 fn owns_terminal_session(&self) -> bool {
318 false
319 }
320}
321
322pub struct AppState {
334 pub(crate) inner: FrameState,
335}
336
337impl AppState {
338 pub fn new() -> Self {
340 Self {
341 inner: FrameState::default(),
342 }
343 }
344
345 pub fn tick(&self) -> u64 {
347 self.inner.diagnostics.tick
348 }
349
350 pub fn fps_f64(&self) -> f64 {
352 f64::from(self.inner.diagnostics.fps_ema)
353 }
354
355 #[deprecated(
357 since = "0.22.2",
358 note = "use AppState::fps_f64() to keep public float APIs on f64"
359 )]
360 pub fn fps(&self) -> f32 {
361 self.inner.diagnostics.fps_ema
362 }
363
364 pub fn set_debug(&mut self, enabled: bool) {
366 self.inner.diagnostics.debug_mode = enabled;
367 }
368}
369
370impl Default for AppState {
371 fn default() -> Self {
372 Self::new()
373 }
374}
375
376pub fn frame(
409 backend: &mut impl Backend,
410 state: &mut AppState,
411 config: &RunConfig,
412 events: &[Event],
413 f: &mut impl FnMut(&mut Context),
414) -> io::Result<bool> {
415 frame_owned(backend, state, config, events.to_vec(), f)
416}
417
418pub fn frame_owned(
437 backend: &mut impl Backend,
438 state: &mut AppState,
439 config: &RunConfig,
440 events: Vec<Event>,
441 f: &mut impl FnMut(&mut Context),
442) -> io::Result<bool> {
443 let terminal_side_effects = backend.owns_terminal_session();
444 run_frame(
445 backend,
446 &mut state.inner,
447 config,
448 events,
449 terminal_side_effects,
450 f,
451 )
452}
453
454#[cfg(feature = "crossterm")]
455type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;
456
457#[cfg(feature = "crossterm")]
458struct PanicHookState {
459 active_sessions: usize,
460 installed: bool,
461 previous: Option<PanicHook>,
462}
463
464#[cfg(feature = "crossterm")]
465static PANIC_HOOK_STATE: std::sync::Mutex<PanicHookState> = std::sync::Mutex::new(PanicHookState {
466 active_sessions: 0,
467 installed: false,
468 previous: None,
469});
470
471#[cfg(feature = "crossterm")]
472struct PanicHookGuard;
473
474#[cfg(feature = "crossterm")]
475impl Drop for PanicHookGuard {
476 fn drop(&mut self) {
477 let mut state = PANIC_HOOK_STATE
478 .lock()
479 .unwrap_or_else(std::sync::PoisonError::into_inner);
480 state.active_sessions = state.active_sessions.saturating_sub(1);
481
482 if state.active_sessions == 0 && state.installed && !std::thread::panicking() {
486 let dispatcher = std::panic::take_hook();
487 drop(dispatcher);
488 if let Some(previous) = state.previous.take() {
489 std::panic::set_hook(previous);
490 }
491 state.installed = false;
492 }
493 }
494}
495
496#[allow(clippy::print_stderr)]
497#[cfg(feature = "crossterm")]
498fn slt_panic_hook(panic_info: &std::panic::PanicHookInfo<'_>) {
499 let active = PANIC_HOOK_STATE
500 .lock()
501 .unwrap_or_else(std::sync::PoisonError::into_inner)
502 .active_sessions
503 > 0;
504 let restored = active && terminal::cleanup_after_panic();
505
506 if restored {
507 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
508 if let Some(location) = panic_info.location() {
509 eprintln!(
510 "\x1b[90m{}:{}:{}\x1b[0m",
511 location.file(),
512 location.line(),
513 location.column()
514 );
515 }
516 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
517 eprintln!("\x1b[1m{msg}\x1b[0m");
518 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
519 eprintln!("\x1b[1m{msg}\x1b[0m");
520 }
521 eprintln!(
522 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
523 );
524 }
525
526 let state = PANIC_HOOK_STATE
527 .lock()
528 .unwrap_or_else(std::sync::PoisonError::into_inner);
529 if let Some(previous) = state.previous.as_ref() {
530 previous(panic_info);
531 }
532}
533
534#[cfg(feature = "crossterm")]
535fn install_panic_hook() -> PanicHookGuard {
536 let mut state = PANIC_HOOK_STATE
537 .lock()
538 .unwrap_or_else(std::sync::PoisonError::into_inner);
539 if !state.installed {
540 state.previous = Some(std::panic::take_hook());
541 std::panic::set_hook(Box::new(slt_panic_hook));
542 state.installed = true;
543 }
544 state.active_sessions = state.active_sessions.saturating_add(1);
545 PanicHookGuard
546}
547
548#[cfg(feature = "crossterm")]
549fn with_session_panic_hook<T>(f: impl FnOnce() -> T) -> T {
550 let guard = install_panic_hook();
551 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
552 drop(guard);
553 match result {
554 Ok(value) => value,
555 Err(payload) => std::panic::resume_unwind(payload),
556 }
557}
558
559#[cfg(all(feature = "crossterm", unix))]
566struct SuspendGuard {
567 handle: signal_hook::iterator::Handle,
568 thread: Option<std::thread::JoinHandle<()>>,
569}
570
571#[cfg(all(feature = "crossterm", unix))]
572impl Drop for SuspendGuard {
573 fn drop(&mut self) {
574 self.handle.close();
576 if let Some(thread) = self.thread.take() {
577 let _ = thread.join();
578 }
579 }
580}
581
582#[cfg(all(feature = "crossterm", unix))]
590fn install_suspend_handler(snapshot: terminal::SessionSnapshot) -> io::Result<SuspendGuard> {
591 use signal_hook::consts::{SIGCONT, SIGTSTP};
592 use signal_hook::iterator::Signals;
593
594 let mut signals = Signals::new([SIGTSTP, SIGCONT])?;
595 let handle = signals.handle();
596 let thread = std::thread::Builder::new()
597 .name("slt-suspend".to_string())
598 .spawn(move || {
599 let mut has_terminal = true;
603 for signal in &mut signals {
604 match signal {
605 SIGTSTP if has_terminal => {
606 terminal::suspend_to_shell(&snapshot);
607 has_terminal = false;
608 let _ = signal_hook::low_level::emulate_default_handler(SIGTSTP);
611 }
612 SIGCONT if !has_terminal => {
613 terminal::resume_from_shell(&snapshot);
614 has_terminal = true;
615 }
616 _ => {}
620 }
621 }
622 })?;
623
624 Ok(SuspendGuard {
625 handle,
626 thread: Some(thread),
627 })
628}
629
630#[cfg(all(feature = "crossterm", unix))]
631fn suspend_current_session(snapshot: terminal::SessionSnapshot) -> io::Result<()> {
632 terminal::suspend_to_shell(&snapshot);
633 let result = signal_hook::low_level::emulate_default_handler(signal_hook::consts::SIGTSTP);
634 terminal::resume_from_shell(&snapshot);
635 result?;
636 Ok(())
637}
638
639#[cfg(all(feature = "crossterm", unix))]
644fn drain_resume_redraw(handle_resize: &mut impl FnMut() -> io::Result<()>) -> io::Result<()> {
645 use std::sync::atomic::Ordering;
646 if terminal::NEEDS_FULL_REDRAW.swap(false, Ordering::SeqCst) {
647 handle_resize()?;
648 }
649 Ok(())
650}
651
652#[non_exhaustive]
671#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
672pub struct RunConfig {
673 pub tick_rate: Duration,
678 pub mouse: bool,
683 pub kitty_keyboard: bool,
690 pub report_all_keys: bool,
704 pub theme: Theme,
708 pub color_depth: Option<ColorDepth>,
714 pub max_fps: Option<u32>,
719 pub scroll_speed: u32,
721 pub title: Option<String>,
723 pub widget_theme: style::WidgetTheme,
728 pub handle_ctrl_c: bool,
754 pub handle_suspend: bool,
779 pub async_message_budget: usize,
784}
785
786impl Default for RunConfig {
787 fn default() -> Self {
788 Self {
789 tick_rate: Duration::from_millis(16),
790 mouse: false,
791 kitty_keyboard: false,
792 report_all_keys: false,
793 theme: Theme::dark(),
794 color_depth: None,
795 max_fps: Some(60),
796 scroll_speed: 1,
797 title: None,
798 widget_theme: style::WidgetTheme::new(),
799 handle_ctrl_c: true,
800 handle_suspend: true,
801 async_message_budget: 100,
802 }
803 }
804}
805
806impl RunConfig {
807 pub fn tick_rate(mut self, rate: Duration) -> Self {
809 self.tick_rate = rate;
810 self
811 }
812
813 pub fn mouse(mut self, enabled: bool) -> Self {
815 self.mouse = enabled;
816 self
817 }
818
819 pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
821 self.kitty_keyboard = enabled;
822 self
823 }
824
825 pub fn report_all_keys(mut self, enabled: bool) -> Self {
843 self.report_all_keys = enabled;
844 self
845 }
846
847 pub fn theme(mut self, theme: Theme) -> Self {
849 self.theme = theme;
850 self
851 }
852
853 pub fn color_depth(mut self, depth: ColorDepth) -> Self {
855 self.color_depth = Some(depth);
856 self
857 }
858
859 pub fn max_fps(mut self, fps: u32) -> Self {
861 self.max_fps = Some(fps);
862 self
863 }
864
865 pub fn no_fps_cap(mut self) -> Self {
879 self.max_fps = None;
880 self
881 }
882
883 pub fn async_message_budget(mut self, messages: usize) -> Self {
888 self.async_message_budget = messages.max(1);
889 self
890 }
891
892 pub fn scroll_speed(mut self, lines: u32) -> Self {
894 self.scroll_speed = lines.max(1);
895 self
896 }
897
898 pub fn title(mut self, title: impl Into<String>) -> Self {
900 self.title = Some(title.into());
901 self
902 }
903
904 pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
906 self.widget_theme = widget_theme;
907 self
908 }
909
910 pub fn handle_ctrl_c(mut self, enabled: bool) -> Self {
924 self.handle_ctrl_c = enabled;
925 self
926 }
927
928 pub fn handle_suspend(mut self, enabled: bool) -> Self {
944 self.handle_suspend = enabled;
945 self
946 }
947}
948
949#[derive(Default)]
950pub(crate) struct FocusState {
951 pub focus_index: usize,
952 pub prev_focus_count: usize,
953 pub prev_modal_active: bool,
954 pub prev_modal_focus_start: usize,
955 pub prev_modal_focus_count: usize,
956 pub prev_focus_index: Option<usize>,
959 pub focus_name_map_prev: std::collections::HashMap<String, usize>,
963 pub pending_focus_name: Option<String>,
967}
968
969pub(crate) const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
972
973#[derive(Default)]
974pub(crate) struct LayoutFeedbackState {
975 pub prev_scroll_infos: Vec<(u32, u32, bool)>,
979 pub prev_scroll_rects: Vec<rect::Rect>,
980 pub prev_hit_map: Vec<rect::Rect>,
981 pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
982 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
983 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
984 pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
985 pub last_mouse_pos: Option<(u32, u32)>,
986 pub last_click_at: Option<std::time::Instant>,
991 pub last_click_pos: Option<(u32, u32)>,
994}
995
996#[derive(Default)]
997pub(crate) struct DiagnosticsState {
998 pub tick: u64,
999 pub notification_queue: Vec<(String, ToastLevel, u64)>,
1000 pub debug_mode: bool,
1001 pub debug_layer: DebugLayer,
1002 pub inspector_mode: bool,
1006 pub fps_ema: f32,
1007}
1008
1009#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1039pub enum DebugLayer {
1040 #[default]
1047 All,
1048 TopMost,
1055 BaseOnly,
1061}
1062
1063pub(crate) type FrameDeferredDrawSlot =
1067 Option<Box<dyn FnOnce(&mut crate::buffer::Buffer, crate::rect::Rect)>>;
1068
1069#[derive(Default)]
1070pub(crate) struct FrameState {
1071 pub hook_states: Vec<Box<dyn std::any::Any>>,
1072 pub named_states: std::collections::HashMap<&'static str, Box<dyn std::any::Any>>,
1073 pub keyed_states: std::collections::HashMap<String, Box<dyn std::any::Any>>,
1077 pub chord_states: widgets::ChordState,
1082 pub scheduler: widgets::SchedulerState,
1086 #[cfg(feature = "async")]
1091 pub async_tasks: context::AsyncTasks,
1092 pub screen_hook_map:
1093 std::collections::HashMap<u64, std::collections::HashMap<String, (usize, usize)>>,
1094 pub focus: FocusState,
1095 pub layout_feedback: LayoutFeedbackState,
1096 pub diagnostics: DiagnosticsState,
1097 pub commands_buf: Vec<crate::layout::Command>,
1101 pub frame_data: crate::layout::FrameData,
1104 pub context_stack_buf: Vec<Box<dyn std::any::Any>>,
1107 pub deferred_draws_buf: Vec<FrameDeferredDrawSlot>,
1110 pub group_stack_buf: Vec<std::sync::Arc<str>>,
1113 pub text_color_stack_buf: Vec<Option<crate::style::Color>>,
1116 pub pending_tooltips_buf: Vec<context::PendingTooltip>,
1119 pub hovered_groups_buf: std::collections::HashSet<std::sync::Arc<str>>,
1122 pub region_versions: Vec<u64>,
1132 pub region_versions_buf: Vec<u64>,
1137 #[cfg(feature = "crossterm")]
1138 pub selection: terminal::SelectionState,
1139}
1140
1141#[cfg(feature = "crossterm")]
1192#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1193pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
1194 run_with(RunConfig::default(), f)
1195}
1196
1197#[cfg(feature = "crossterm")]
1198fn validate_terminal_endpoints(
1199 stdin_is_terminal: bool,
1200 stdout_is_terminal: bool,
1201) -> io::Result<()> {
1202 if stdin_is_terminal && stdout_is_terminal {
1203 return Ok(());
1204 }
1205
1206 let unavailable = match (stdin_is_terminal, stdout_is_terminal) {
1207 (false, false) => "stdin and stdout are not terminals",
1208 (false, true) => "stdin is not a terminal",
1209 (true, false) => "stdout is not a terminal",
1210 (true, true) => unreachable!("validated above"),
1211 };
1212 Err(io::Error::new(
1213 io::ErrorKind::NotConnected,
1214 format!("interactive SLT runtime unavailable: {unavailable}"),
1215 ))
1216}
1217
1218#[cfg(feature = "crossterm")]
1219fn ensure_interactive_terminal() -> io::Result<()> {
1220 validate_terminal_endpoints(io::stdin().is_terminal(), io::stdout().is_terminal())
1221}
1222
1223#[cfg(feature = "crossterm")]
1224fn validate_inline_height(height: u32) -> io::Result<()> {
1225 if height == 0 {
1226 return Err(io::Error::new(
1227 io::ErrorKind::InvalidInput,
1228 "inline terminal height must be greater than zero",
1229 ));
1230 }
1231 Ok(())
1232}
1233
1234#[cfg(feature = "crossterm")]
1235fn set_terminal_title(title: &Option<String>) {
1236 if let Some(title) = title {
1237 use std::io::Write;
1238 let title = sanitize_terminal_text(title);
1239 let mut stdout = io::stdout();
1240 let _ = write!(stdout, "\x1b]2;{title}\x07");
1241 let _ = stdout.flush();
1242 }
1243}
1244
1245#[cfg(feature = "crossterm")]
1246fn sanitize_terminal_text(input: &str) -> String {
1247 input
1248 .chars()
1249 .map(|ch| {
1250 if ch.is_control() || ('\u{80}'..='\u{9f}').contains(&ch) {
1251 '?'
1252 } else {
1253 ch
1254 }
1255 })
1256 .collect()
1257}
1258
1259#[cfg(feature = "crossterm")]
1283#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1284pub fn run_with(config: RunConfig, f: impl FnMut(&mut Context)) -> io::Result<()> {
1285 ensure_interactive_terminal()?;
1286 with_session_panic_hook(|| run_with_inner(config, f))
1287}
1288
1289#[cfg(feature = "crossterm")]
1290fn run_with_inner(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
1291 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1292 let mut term = Terminal::new(
1293 config.mouse,
1294 config.kitty_keyboard,
1295 config.report_all_keys,
1296 color_depth,
1297 )?;
1298 set_terminal_title(&config.title);
1299 if config.theme.bg != Color::Reset {
1300 term.theme_bg = Some(config.theme.bg);
1301 }
1302 #[cfg(unix)]
1304 let _suspend_guard = if config.handle_suspend {
1305 Some(install_suspend_handler(term.session_snapshot())?)
1306 } else {
1307 None
1308 };
1309 let mut events: Vec<Event> = Vec::new();
1310 let mut state = FrameState::default();
1311
1312 loop {
1313 let frame_start = Instant::now();
1314 #[cfg(unix)]
1316 drain_resume_redraw(&mut || term.handle_resize())?;
1317 let (w, h) = term.size();
1318 if w > 0 && h > 0 {
1319 if !run_frame(
1320 &mut term,
1321 &mut state,
1322 &config,
1323 std::mem::take(&mut events),
1324 true,
1325 &mut f,
1326 )? {
1327 break;
1328 }
1329 discard_static_log(&mut state, "full-screen run()");
1333 }
1334
1335 #[cfg(unix)]
1336 let suspend_snapshot = term.session_snapshot();
1337 #[cfg(unix)]
1338 let mut on_suspend = || suspend_current_session(suspend_snapshot);
1339 #[cfg(not(unix))]
1340 let mut on_suspend = || Ok(());
1341
1342 if !poll_events(
1343 &mut events,
1344 &mut state,
1345 config.tick_rate,
1346 &mut || term.handle_resize(),
1347 config.handle_ctrl_c,
1348 config.handle_suspend,
1349 &mut on_suspend,
1350 )? {
1351 break;
1352 }
1353
1354 sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1355 }
1356
1357 Ok(())
1358}
1359
1360#[cfg(all(feature = "crossterm", feature = "async"))]
1362#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1363#[derive(Debug)]
1364pub enum AsyncRunError {
1365 Io(io::Error),
1367 Join(tokio::task::JoinError),
1369}
1370
1371#[cfg(all(feature = "crossterm", feature = "async"))]
1372impl std::fmt::Display for AsyncRunError {
1373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1374 match self {
1375 Self::Io(err) => write!(f, "async render loop failed: {err}"),
1376 Self::Join(err) => write!(f, "async render task failed: {err}"),
1377 }
1378 }
1379}
1380
1381#[cfg(all(feature = "crossterm", feature = "async"))]
1382impl std::error::Error for AsyncRunError {
1383 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1384 match self {
1385 Self::Io(err) => Some(err),
1386 Self::Join(err) => Some(err),
1387 }
1388 }
1389}
1390
1391#[cfg(all(feature = "crossterm", feature = "async"))]
1392#[derive(Default)]
1393struct AsyncWake {
1394 generation: std::sync::atomic::AtomicU64,
1395 notify: std::sync::Arc<tokio::sync::Notify>,
1396}
1397
1398#[cfg(all(feature = "crossterm", feature = "async"))]
1399impl AsyncWake {
1400 fn notify(&self) {
1401 self.generation
1402 .fetch_add(1, std::sync::atomic::Ordering::Release);
1403 self.notify.notify_one();
1404 }
1405
1406 fn generation(&self) -> u64 {
1407 self.generation.load(std::sync::atomic::Ordering::Acquire)
1408 }
1409}
1410
1411#[cfg(all(feature = "crossterm", feature = "async"))]
1413#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1414pub struct AsyncSender<M> {
1415 inner: tokio::sync::mpsc::Sender<M>,
1416 wake: std::sync::Arc<AsyncWake>,
1417}
1418
1419#[cfg(all(feature = "crossterm", feature = "async"))]
1420impl<M> Clone for AsyncSender<M> {
1421 fn clone(&self) -> Self {
1422 Self {
1423 inner: self.inner.clone(),
1424 wake: std::sync::Arc::clone(&self.wake),
1425 }
1426 }
1427}
1428
1429#[cfg(all(feature = "crossterm", feature = "async"))]
1430impl<M> Drop for AsyncSender<M> {
1431 fn drop(&mut self) {
1432 self.wake.notify();
1433 }
1434}
1435
1436#[cfg(all(feature = "crossterm", feature = "async"))]
1437impl<M> AsyncSender<M> {
1438 pub async fn send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::SendError<M>> {
1440 self.inner.send(message).await?;
1441 self.wake.notify();
1442 Ok(())
1443 }
1444
1445 pub fn try_send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::TrySendError<M>> {
1447 self.inner.try_send(message)?;
1448 self.wake.notify();
1449 Ok(())
1450 }
1451
1452 pub fn blocking_send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::SendError<M>> {
1454 self.inner.blocking_send(message)?;
1455 self.wake.notify();
1456 Ok(())
1457 }
1458
1459 pub fn is_closed(&self) -> bool {
1461 self.inner.is_closed()
1462 }
1463
1464 pub async fn closed(&self) {
1466 self.inner.closed().await;
1467 }
1468
1469 pub fn capacity(&self) -> usize {
1471 self.inner.capacity()
1472 }
1473
1474 pub fn max_capacity(&self) -> usize {
1476 self.inner.max_capacity()
1477 }
1478}
1479
1480#[cfg(all(feature = "crossterm", feature = "async"))]
1481impl<M> std::ops::Deref for AsyncSender<M> {
1482 type Target = tokio::sync::mpsc::Sender<M>;
1483
1484 fn deref(&self) -> &Self::Target {
1485 &self.inner
1486 }
1487}
1488
1489#[cfg(all(feature = "crossterm", feature = "async"))]
1496#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1497#[must_use = "dropping the handle cancels the render loop; join it to observe completion"]
1498pub struct AsyncRunHandle<M> {
1499 sender: Option<AsyncSender<M>>,
1500 join: Option<tokio::task::JoinHandle<io::Result<()>>>,
1501 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1502 wake: std::sync::Arc<AsyncWake>,
1503 cancel_on_drop: bool,
1504}
1505
1506#[cfg(all(feature = "crossterm", feature = "async"))]
1507impl<M> AsyncRunHandle<M> {
1508 pub fn sender(&self) -> AsyncSender<M> {
1510 self.sender
1511 .as_ref()
1512 .expect("sender is available until join starts")
1513 .clone()
1514 }
1515
1516 pub fn cancel(&self) {
1518 self.cancel
1519 .store(true, std::sync::atomic::Ordering::Release);
1520 self.wake.notify();
1521 }
1522
1523 pub fn is_finished(&self) -> bool {
1525 self.join
1526 .as_ref()
1527 .is_none_or(tokio::task::JoinHandle::is_finished)
1528 }
1529
1530 pub async fn join(mut self) -> Result<(), AsyncRunError> {
1535 self.sender.take();
1536 let join = self
1537 .join
1538 .take()
1539 .expect("join handle is consumed exactly once");
1540 join.await
1541 .map_err(AsyncRunError::Join)?
1542 .map_err(AsyncRunError::Io)
1543 }
1544
1545 pub async fn cancel_and_join(mut self) -> Result<(), AsyncRunError> {
1547 self.cancel();
1548 self.sender.take();
1549 let join = self
1550 .join
1551 .take()
1552 .expect("join handle is consumed exactly once");
1553 join.await
1554 .map_err(AsyncRunError::Join)?
1555 .map_err(AsyncRunError::Io)
1556 }
1557
1558 pub fn detach(mut self) -> AsyncSender<M> {
1563 self.cancel_on_drop = false;
1564 self.join.take();
1565 self.sender
1566 .take()
1567 .expect("sender is available until detach")
1568 }
1569}
1570
1571#[cfg(all(feature = "crossterm", feature = "async"))]
1572impl<M> std::ops::Deref for AsyncRunHandle<M> {
1573 type Target = AsyncSender<M>;
1574
1575 fn deref(&self) -> &Self::Target {
1576 self.sender
1577 .as_ref()
1578 .expect("sender is available until join starts")
1579 }
1580}
1581
1582#[cfg(all(feature = "crossterm", feature = "async"))]
1583impl<M> Drop for AsyncRunHandle<M> {
1584 fn drop(&mut self) {
1585 if self.cancel_on_drop {
1586 self.cancel
1587 .store(true, std::sync::atomic::Ordering::Release);
1588 self.wake.notify();
1589 }
1590 }
1591}
1592
1593#[cfg(all(feature = "crossterm", feature = "async"))]
1594impl<M: Send + 'static> std::future::IntoFuture for AsyncRunHandle<M> {
1595 type Output = Result<(), AsyncRunError>;
1596 type IntoFuture =
1597 std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'static>>;
1598
1599 fn into_future(self) -> Self::IntoFuture {
1600 Box::pin(self.join())
1601 }
1602}
1603
1604#[cfg(all(feature = "crossterm", feature = "async"))]
1630#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1631pub fn run_async<M: Send + 'static>(
1632 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1633) -> io::Result<AsyncRunHandle<M>> {
1634 run_async_with(RunConfig::default(), f)
1635}
1636
1637#[cfg(all(feature = "crossterm", feature = "async"))]
1644#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1645pub fn run_async_with<M: Send + 'static>(
1646 config: RunConfig,
1647 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1648) -> io::Result<AsyncRunHandle<M>> {
1649 ensure_interactive_terminal()?;
1650 let (tx, rx) = tokio::sync::mpsc::channel(100);
1651 let handle =
1652 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
1653 let wake = std::sync::Arc::new(AsyncWake::default());
1654 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1655
1656 let loop_handle = handle.clone();
1661 let loop_wake = std::sync::Arc::clone(&wake);
1662 let loop_cancel = std::sync::Arc::clone(&cancel);
1663 let join = handle
1664 .spawn_blocking(move || run_async_loop(config, f, rx, loop_handle, loop_wake, loop_cancel));
1665
1666 Ok(AsyncRunHandle {
1667 sender: Some(AsyncSender {
1668 inner: tx,
1669 wake: std::sync::Arc::clone(&wake),
1670 }),
1671 join: Some(join),
1672 cancel,
1673 wake,
1674 cancel_on_drop: true,
1675 })
1676}
1677
1678#[cfg(all(feature = "crossterm", feature = "async"))]
1679fn drain_async_messages<M>(
1680 rx: &mut tokio::sync::mpsc::Receiver<M>,
1681 messages: &mut Vec<M>,
1682 budget: usize,
1683) -> bool {
1684 let mut disconnected = false;
1685 for _ in 0..budget.max(1) {
1686 match rx.try_recv() {
1687 Ok(message) => messages.push(message),
1688 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1689 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1690 disconnected = true;
1691 break;
1692 }
1693 }
1694 }
1695 disconnected || (rx.is_closed() && rx.is_empty())
1696}
1697
1698#[cfg(all(feature = "crossterm", feature = "async"))]
1699fn run_async_loop<M: Send + 'static>(
1700 config: RunConfig,
1701 f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
1702 rx: tokio::sync::mpsc::Receiver<M>,
1703 runtime: tokio::runtime::Handle,
1704 wake: std::sync::Arc<AsyncWake>,
1705 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1706) -> io::Result<()> {
1707 with_session_panic_hook(move || run_async_loop_inner(config, f, rx, runtime, wake, cancel))
1708}
1709
1710#[cfg(all(feature = "crossterm", feature = "async"))]
1711fn run_async_loop_inner<M: Send + 'static>(
1712 config: RunConfig,
1713 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
1714 mut rx: tokio::sync::mpsc::Receiver<M>,
1715 runtime: tokio::runtime::Handle,
1716 wake: std::sync::Arc<AsyncWake>,
1717 cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1718) -> io::Result<()> {
1719 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1720 let mut term = Terminal::new(
1721 config.mouse,
1722 config.kitty_keyboard,
1723 config.report_all_keys,
1724 color_depth,
1725 )?;
1726 set_terminal_title(&config.title);
1727 if config.theme.bg != Color::Reset {
1728 term.theme_bg = Some(config.theme.bg);
1729 }
1730 #[cfg(unix)]
1732 let _suspend_guard = if config.handle_suspend {
1733 Some(install_suspend_handler(term.session_snapshot())?)
1734 } else {
1735 None
1736 };
1737 let mut events: Vec<Event> = Vec::new();
1738 let mut messages: Vec<M> = Vec::new();
1739 let mut state = FrameState::default();
1740 state.async_tasks.set_runtime(runtime.clone());
1744 state
1745 .async_tasks
1746 .set_waker(std::sync::Arc::clone(&wake.notify));
1747
1748 loop {
1749 if cancel.load(std::sync::atomic::Ordering::Acquire) {
1750 break;
1751 }
1752 let frame_start = Instant::now();
1753 #[cfg(unix)]
1755 drain_resume_redraw(&mut || term.handle_resize())?;
1756 let observed_wake = wake.generation();
1757 messages.clear();
1758 let input_disconnected =
1759 drain_async_messages(&mut rx, &mut messages, config.async_message_budget);
1760 if input_disconnected && messages.is_empty() {
1761 break;
1762 }
1763
1764 let (w, h) = term.size();
1765 if w > 0 && h > 0 {
1766 let mut render = |ctx: &mut Context| {
1767 f(ctx, &mut messages);
1768 };
1769 if !run_frame(
1770 &mut term,
1771 &mut state,
1772 &config,
1773 std::mem::take(&mut events),
1774 true,
1775 &mut render,
1776 )? {
1777 break;
1778 }
1779 discard_static_log(&mut state, "run_async()");
1782 if input_disconnected {
1783 break;
1784 }
1785 } else if input_disconnected {
1786 break;
1787 }
1788
1789 #[cfg(unix)]
1790 let suspend_snapshot = term.session_snapshot();
1791 #[cfg(unix)]
1792 let mut on_suspend = || suspend_current_session(suspend_snapshot);
1793 #[cfg(not(unix))]
1794 let mut on_suspend = || Ok(());
1795
1796 if !poll_events(
1797 &mut events,
1798 &mut state,
1799 if wake.generation() != observed_wake
1800 || runtime.block_on(async {
1801 tokio::time::timeout(Duration::ZERO, wake.notify.notified())
1802 .await
1803 .is_ok()
1804 })
1805 || cancel.load(std::sync::atomic::Ordering::Acquire)
1806 {
1807 Duration::ZERO
1808 } else {
1809 config.tick_rate.min(Duration::from_millis(4))
1810 },
1811 &mut || term.handle_resize(),
1812 config.handle_ctrl_c,
1813 config.handle_suspend,
1814 &mut on_suspend,
1815 )? {
1816 break;
1817 }
1818
1819 sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1820 }
1821
1822 Ok(())
1823}
1824
1825#[cfg(feature = "crossterm")]
1846#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1847pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
1848 run_inline_with(height, RunConfig::default(), f)
1849}
1850
1851#[cfg(feature = "crossterm")]
1856#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1857pub fn run_inline_with(
1858 height: u32,
1859 config: RunConfig,
1860 f: impl FnMut(&mut Context),
1861) -> io::Result<()> {
1862 validate_inline_height(height)?;
1863 ensure_interactive_terminal()?;
1864 with_session_panic_hook(|| run_inline_with_inner(height, config, f))
1865}
1866
1867#[cfg(feature = "crossterm")]
1868fn run_inline_with_inner(
1869 height: u32,
1870 config: RunConfig,
1871 mut f: impl FnMut(&mut Context),
1872) -> io::Result<()> {
1873 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1874 let mut term = InlineTerminal::new(
1875 height,
1876 config.mouse,
1877 config.kitty_keyboard,
1878 config.report_all_keys,
1879 color_depth,
1880 )?;
1881 set_terminal_title(&config.title);
1882 if config.theme.bg != Color::Reset {
1883 term.theme_bg = Some(config.theme.bg);
1884 }
1885 #[cfg(unix)]
1887 let _suspend_guard = if config.handle_suspend {
1888 Some(install_suspend_handler(term.session_snapshot())?)
1889 } else {
1890 None
1891 };
1892 let mut events: Vec<Event> = Vec::new();
1893 let mut state = FrameState::default();
1894
1895 loop {
1896 let frame_start = Instant::now();
1897 #[cfg(unix)]
1899 drain_resume_redraw(&mut || term.handle_resize())?;
1900 let (w, h) = term.size();
1901 if w > 0 && h > 0 {
1902 if !run_frame(
1903 &mut term,
1904 &mut state,
1905 &config,
1906 std::mem::take(&mut events),
1907 true,
1908 &mut f,
1909 )? {
1910 break;
1911 }
1912 discard_static_log(&mut state, "run_inline()");
1915 }
1916
1917 #[cfg(unix)]
1918 let suspend_snapshot = term.session_snapshot();
1919 #[cfg(unix)]
1920 let mut on_suspend = || suspend_current_session(suspend_snapshot);
1921 #[cfg(not(unix))]
1922 let mut on_suspend = || Ok(());
1923
1924 if !poll_events(
1925 &mut events,
1926 &mut state,
1927 config.tick_rate,
1928 &mut || term.handle_resize(),
1929 config.handle_ctrl_c,
1930 config.handle_suspend,
1931 &mut on_suspend,
1932 )? {
1933 break;
1934 }
1935
1936 sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1937 }
1938
1939 Ok(())
1940}
1941
1942#[cfg(feature = "crossterm")]
1952#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1953pub fn run_static(
1954 output: &mut StaticOutput,
1955 dynamic_height: u32,
1956 f: impl FnMut(&mut Context),
1957) -> io::Result<()> {
1958 run_static_with(output, dynamic_height, RunConfig::default(), f)
1959}
1960
1961#[cfg(feature = "crossterm")]
1966#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1967pub fn run_static_with(
1968 output: &mut StaticOutput,
1969 dynamic_height: u32,
1970 config: RunConfig,
1971 f: impl FnMut(&mut Context),
1972) -> io::Result<()> {
1973 validate_inline_height(dynamic_height)?;
1974 ensure_interactive_terminal()?;
1975 with_session_panic_hook(|| run_static_with_inner(output, dynamic_height, config, f))
1976}
1977
1978#[cfg(feature = "crossterm")]
1979fn run_static_with_inner(
1980 output: &mut StaticOutput,
1981 dynamic_height: u32,
1982 config: RunConfig,
1983 mut f: impl FnMut(&mut Context),
1984) -> io::Result<()> {
1985 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1986 let mut term = InlineTerminal::new(
1987 dynamic_height,
1988 config.mouse,
1989 config.kitty_keyboard,
1990 config.report_all_keys,
1991 color_depth,
1992 )?;
1993 term.write_scrollback(&output.drain_new())?;
1994 set_terminal_title(&config.title);
1995 if config.theme.bg != Color::Reset {
1996 term.theme_bg = Some(config.theme.bg);
1997 }
1998 #[cfg(unix)]
2000 let _suspend_guard = if config.handle_suspend {
2001 Some(install_suspend_handler(term.session_snapshot())?)
2002 } else {
2003 None
2004 };
2005
2006 let mut events: Vec<Event> = Vec::new();
2007 let mut state = FrameState::default();
2008
2009 loop {
2010 let frame_start = Instant::now();
2011 #[cfg(unix)]
2013 drain_resume_redraw(&mut || term.handle_resize())?;
2014 let (w, h) = term.size();
2015 term.write_scrollback(&output.drain_new())?;
2016 if w > 0 && h > 0 {
2017 let keep_running = run_frame(
2018 &mut term,
2019 &mut state,
2020 &config,
2021 std::mem::take(&mut events),
2022 true,
2023 &mut f,
2024 )?;
2025 for line in drain_static_log(&mut state) {
2028 output.println(line);
2029 }
2030 term.write_scrollback(&output.drain_new())?;
2031 if !keep_running {
2032 break;
2033 }
2034 }
2035
2036 #[cfg(unix)]
2037 let suspend_snapshot = term.session_snapshot();
2038 #[cfg(unix)]
2039 let mut on_suspend = || suspend_current_session(suspend_snapshot);
2040 #[cfg(not(unix))]
2041 let mut on_suspend = || Ok(());
2042
2043 if !poll_events(
2044 &mut events,
2045 &mut state,
2046 config.tick_rate,
2047 &mut || term.handle_resize(),
2048 config.handle_ctrl_c,
2049 config.handle_suspend,
2050 &mut on_suspend,
2051 )? {
2052 break;
2053 }
2054
2055 sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
2056 }
2057
2058 Ok(())
2059}
2060
2061#[cfg(all(feature = "crossterm", test))]
2062fn write_static_lines_to(stdout: &mut impl io::Write, lines: &[String]) -> io::Result<()> {
2063 for line in lines {
2064 let safe = sanitize_terminal_text(line);
2065 stdout.write_all(safe.as_bytes())?;
2066 stdout.write_all(b"\r\n")?;
2067 }
2068 stdout.flush()
2069}
2070
2071pub(crate) const STATIC_LOG_NAMED_STATE_KEY: &str = "__slt_static_log_pending";
2074
2075pub(crate) const KEYMAP_REGISTRY_NAMED_STATE_KEY: &str = "__slt_keymap_registry";
2078
2079pub(crate) fn clear_keymap_registry(state: &mut FrameState) {
2084 if let Some(boxed) = state.named_states.get_mut(KEYMAP_REGISTRY_NAMED_STATE_KEY)
2085 && let Some(vec) = boxed.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>()
2086 {
2087 vec.clear();
2088 }
2089}
2090
2091#[cfg(feature = "crossterm")]
2100pub(crate) fn drain_static_log(state: &mut FrameState) -> Vec<String> {
2101 if let Some(boxed) = state.named_states.get_mut(STATIC_LOG_NAMED_STATE_KEY)
2102 && let Some(buf) = boxed.downcast_mut::<Vec<String>>()
2103 {
2104 return std::mem::take(buf);
2105 }
2106 Vec::new()
2107}
2108
2109#[cfg(feature = "crossterm")]
2115fn discard_static_log(state: &mut FrameState, mode: &str) {
2116 let drained = drain_static_log(state);
2117 #[cfg(debug_assertions)]
2118 if !drained.is_empty() {
2119 #[allow(clippy::print_stderr)]
2120 {
2121 eprintln!(
2122 "[slt] {} static_log lines were dropped: {} runtime has no scrollback channel; use slt::run_static for streaming output",
2123 drained.len(),
2124 mode
2125 );
2126 }
2127 }
2128 #[cfg(not(debug_assertions))]
2129 {
2130 let _ = (drained, mode);
2131 }
2132}
2133
2134#[cfg(feature = "crossterm")]
2147pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
2148 match ev {
2149 Event::Mouse(m) => {
2150 state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
2151 }
2152 Event::FocusLost => {
2153 state.layout_feedback.last_mouse_pos = None;
2154 }
2155 Event::Key(event::KeyEvent {
2159 code: KeyCode::F(12),
2160 kind: event::KeyEventKind::Press,
2161 modifiers,
2162 }) if modifiers.contains(event::KeyModifiers::CONTROL) => {
2163 state.diagnostics.inspector_mode = !state.diagnostics.inspector_mode;
2164 }
2165 Event::Key(event::KeyEvent {
2171 code: KeyCode::F(12),
2172 kind: event::KeyEventKind::Press,
2173 modifiers,
2174 }) if modifiers.contains(event::KeyModifiers::SHIFT) => {
2175 state.diagnostics.debug_layer = match state.diagnostics.debug_layer {
2176 DebugLayer::All => DebugLayer::TopMost,
2177 DebugLayer::TopMost => DebugLayer::BaseOnly,
2178 DebugLayer::BaseOnly => DebugLayer::All,
2179 };
2180 }
2181 Event::Key(event::KeyEvent {
2182 code: KeyCode::F(12),
2183 kind: event::KeyEventKind::Press,
2184 modifiers,
2185 }) if *modifiers == event::KeyModifiers::NONE => {
2186 state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
2187 }
2188 Event::Resize(_, _) => {
2189 *has_resize = true;
2190 }
2191 _ => {}
2192 }
2193}
2194
2195#[cfg(feature = "crossterm")]
2209#[inline]
2210fn resize_invocations_for_batch(events: &[Event]) -> usize {
2211 usize::from(events.iter().any(|e| matches!(e, Event::Resize(_, _))))
2212}
2213
2214#[cfg(feature = "crossterm")]
2230fn poll_events(
2231 events: &mut Vec<Event>,
2232 state: &mut FrameState,
2233 tick_rate: Duration,
2234 on_resize: &mut impl FnMut() -> io::Result<()>,
2235 handle_ctrl_c: bool,
2236 handle_suspend: bool,
2237 on_suspend: &mut impl FnMut() -> io::Result<()>,
2238) -> io::Result<bool> {
2239 let mut has_resize = false;
2240 let batch_start = events.len();
2241
2242 fn process_ev(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
2243 process_run_loop_event(ev, state, has_resize);
2244 }
2245
2246 if crossterm::event::poll(tick_rate)? {
2247 let raw = crossterm::event::read()?;
2248 if let Some(ev) = event::from_crossterm(raw) {
2249 if handle_ctrl_c && is_ctrl_c(&ev) {
2250 return Ok(false);
2251 }
2252 if handle_suspend && is_ctrl_z(&ev) {
2253 on_suspend()?;
2254 return Ok(true);
2255 }
2256 process_ev(&ev, state, &mut has_resize);
2260 events.push(ev);
2261 }
2262
2263 while crossterm::event::poll(Duration::ZERO)? {
2264 let raw = crossterm::event::read()?;
2265 if let Some(ev) = event::from_crossterm(raw) {
2266 if handle_ctrl_c && is_ctrl_c(&ev) {
2267 return Ok(false);
2268 }
2269 if handle_suspend && is_ctrl_z(&ev) {
2270 on_suspend()?;
2271 return Ok(true);
2272 }
2273 process_ev(&ev, state, &mut has_resize);
2274 events.push(ev);
2275 }
2276 }
2277 }
2278
2279 debug_assert_eq!(
2283 usize::from(has_resize),
2284 resize_invocations_for_batch(&events[batch_start..]),
2285 "has_resize must agree with the coalescing helper"
2286 );
2287 if has_resize {
2288 on_resize()?;
2289 }
2290
2291 if has_resize {
2294 clear_frame_layout_cache(state);
2295 for ev in &events[batch_start..] {
2299 match ev {
2300 Event::Mouse(m) => {
2301 state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
2302 }
2303 Event::FocusLost => {
2304 state.layout_feedback.last_mouse_pos = None;
2305 }
2306 _ => {}
2307 }
2308 }
2309 }
2310
2311 Ok(true)
2312}
2313
2314struct FrameKernelResult {
2315 should_quit: bool,
2316 #[cfg(feature = "crossterm")]
2317 clipboard_text: Option<String>,
2318 #[cfg(feature = "crossterm")]
2319 should_copy_selection: bool,
2320}
2321
2322pub(crate) fn run_frame_kernel(
2323 buffer: &mut Buffer,
2324 state: &mut FrameState,
2325 config: &RunConfig,
2326 size: (u32, u32),
2327 events: Vec<event::Event>,
2328 is_real_terminal: bool,
2329 f: &mut impl FnMut(&mut context::Context),
2330) -> FrameKernelResult {
2331 let frame_start = Instant::now();
2332 let (w, h) = size;
2333 clear_keymap_registry(state);
2338 if !state.region_versions.is_empty() && events.iter().any(|e| matches!(e, Event::Resize(_, _)))
2347 {
2348 state.region_versions.clear();
2349 }
2350 let mut ctx = Context::new(events, w, h, state, config.theme);
2351 ctx.is_real_terminal = is_real_terminal;
2352 #[cfg(feature = "crossterm")]
2357 if is_real_terminal {
2358 ctx.capabilities = terminal::capabilities();
2359 }
2360 ctx.set_scroll_speed(config.scroll_speed);
2361 ctx.widget_theme = config.widget_theme;
2362
2363 f(&mut ctx);
2364 ctx.process_focus_keys();
2365 ctx.render_notifications();
2366 ctx.emit_pending_tooltips();
2367
2368 debug_assert_eq!(
2369 ctx.rollback.overlay_depth, 0,
2370 "overlay depth must settle back to zero before layout"
2371 );
2372 debug_assert_eq!(
2373 ctx.rollback.group_count, 0,
2374 "group count must settle back to zero before layout"
2375 );
2376 debug_assert!(
2377 ctx.rollback.group_stack.is_empty(),
2378 "group stack must be empty before layout"
2379 );
2380 debug_assert!(
2381 ctx.rollback.text_color_stack.is_empty(),
2382 "text color stack must be empty before layout"
2383 );
2384 debug_assert!(
2385 ctx.pending_tooltips.is_empty(),
2386 "pending tooltips must be emitted before layout"
2387 );
2388
2389 if ctx.should_quit {
2390 state.hook_states = ctx.hook_states;
2391 state.named_states = ctx.named_states;
2392 state.keyed_states = ctx.keyed_states;
2393 state.chord_states = ctx.chord;
2397 let mut scheduler = ctx.scheduler;
2399 scheduler.gc_untouched();
2400 state.scheduler = scheduler;
2401 #[cfg(feature = "async")]
2406 {
2407 ctx.async_tasks.maintain();
2411 state.async_tasks = ctx.async_tasks;
2412 }
2413 state.screen_hook_map = ctx.screen_hook_map;
2414 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
2415 state.diagnostics.debug_layer = ctx.debug_layer;
2416 state.diagnostics.inspector_mode = ctx.inspector_mode;
2418 state.focus.prev_focus_index = Some(ctx.focus_index);
2422 state.focus.focus_name_map_prev = ctx.focus_name_map;
2423 state.focus.pending_focus_name = ctx.pending_focus_name;
2424 ctx.deferred_draws.clear();
2430 state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
2431 state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
2432 state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
2433 state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
2434 state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
2435 state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
2436 state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
2442 state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
2443 ctx.commands.clear();
2449 state.commands_buf = std::mem::take(&mut ctx.commands);
2450 #[cfg(feature = "crossterm")]
2451 let clipboard_text = ctx.clipboard_text.take();
2452 #[cfg(feature = "crossterm")]
2453 let should_copy_selection = false;
2454 return FrameKernelResult {
2455 should_quit: true,
2456 #[cfg(feature = "crossterm")]
2457 clipboard_text,
2458 #[cfg(feature = "crossterm")]
2459 should_copy_selection,
2460 };
2461 }
2462 state.focus.prev_modal_active = ctx.rollback.modal_active;
2463 state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
2464 state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
2465 #[cfg(feature = "crossterm")]
2466 let clipboard_text = ctx.clipboard_text.take();
2467 #[cfg(not(feature = "crossterm"))]
2468 let _clipboard_text = ctx.clipboard_text.take();
2469
2470 #[cfg(feature = "crossterm")]
2471 let mut should_copy_selection = false;
2472 #[cfg(feature = "crossterm")]
2473 for ev in &ctx.events {
2474 if let Event::Mouse(mouse) = ev {
2475 match mouse.kind {
2476 event::MouseKind::Down(event::MouseButton::Left) => {
2477 state.selection.mouse_down(
2478 mouse.x,
2479 mouse.y,
2480 &state.layout_feedback.prev_content_map,
2481 );
2482 }
2483 event::MouseKind::Drag(event::MouseButton::Left) => {
2484 state.selection.mouse_drag(
2485 mouse.x,
2486 mouse.y,
2487 &state.layout_feedback.prev_content_map,
2488 );
2489 }
2490 event::MouseKind::Up(event::MouseButton::Left) => {
2491 should_copy_selection = state.selection.active;
2492 }
2493 _ => {}
2494 }
2495 }
2496 }
2497
2498 state.focus.focus_index = ctx.focus_index;
2499 state.focus.prev_focus_count = ctx.rollback.focus_count;
2500
2501 let mut tree = layout::build_tree(&mut ctx.commands);
2510 let area = crate::rect::Rect::new(0, 0, w, h);
2511 layout::compute(&mut tree, area);
2512
2513 let mut fd = std::mem::take(&mut state.frame_data);
2518 fd.swap_feedback(&mut state.layout_feedback);
2519 layout::collect_all(&tree, &mut fd);
2520 debug_assert_eq!(
2521 fd.scroll_infos.len(),
2522 fd.scroll_rects.len(),
2523 "scroll feedback vectors must stay aligned"
2524 );
2525 fd.swap_feedback(&mut state.layout_feedback);
2526 let mut raw_rects = std::mem::take(&mut fd.raw_draw_rects);
2527 layout::render(&tree, buffer);
2528 let mut deferred_draw_panic = None;
2529 for rdr in raw_rects.drain(..) {
2530 if rdr.rect.width == 0 || rdr.rect.height == 0 {
2531 continue;
2532 }
2533 let Some(cb) = ctx
2534 .deferred_draws
2535 .get_mut(rdr.draw_id)
2536 .and_then(|c| c.take())
2537 else {
2538 continue;
2539 };
2540 if let Err(panic) = context::invoke_deferred_draw(
2541 buffer,
2542 rdr.rect,
2543 rdr.left_clip_cols,
2544 rdr.top_clip_rows,
2545 rdr.original_width,
2546 rdr.original_height,
2547 cb,
2548 ) {
2549 deferred_draw_panic = Some(panic);
2550 break;
2551 }
2552 }
2553 raw_rects.clear();
2554 fd.raw_draw_rects = raw_rects;
2555 state.frame_data = fd;
2556 debug_assert!(
2557 buffer.kitty_clip_info_stack.is_empty(),
2558 "kitty_clip_info_stack must be empty at end of frame"
2559 );
2560 debug_assert!(
2561 buffer.kitty_horizontal_clip_stack.is_empty(),
2562 "kitty_horizontal_clip_stack must be empty at end of frame"
2563 );
2564 state.hook_states = ctx.hook_states;
2565 state.named_states = ctx.named_states;
2566 state.keyed_states = ctx.keyed_states;
2570 state.chord_states = ctx.chord;
2573 let mut scheduler = ctx.scheduler;
2576 scheduler.gc_untouched();
2577 state.scheduler = scheduler;
2578 #[cfg(feature = "async")]
2582 {
2583 ctx.async_tasks.maintain();
2587 state.async_tasks = ctx.async_tasks;
2588 }
2589 state.screen_hook_map = ctx.screen_hook_map;
2590 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
2591 state.diagnostics.debug_layer = ctx.debug_layer;
2593 state.diagnostics.inspector_mode = ctx.inspector_mode;
2595 state.focus.prev_focus_index = Some(ctx.focus_index);
2598 state.focus.focus_name_map_prev = ctx.focus_name_map;
2602 state.focus.pending_focus_name = ctx.pending_focus_name;
2603
2604 ctx.deferred_draws.clear();
2627 state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
2628 state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
2629 state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
2630 state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
2631 state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
2632 state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
2633 state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
2637 state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
2638 state.commands_buf = std::mem::take(&mut ctx.commands);
2644
2645 let frame_time = frame_start.elapsed();
2646 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
2647 let frame_secs = frame_time.as_secs_f32();
2648 let inst_fps = if frame_secs > 0.0 {
2649 1.0 / frame_secs
2650 } else {
2651 0.0
2652 };
2653 state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
2654 inst_fps
2655 } else {
2656 (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
2657 };
2658 if state.diagnostics.debug_mode {
2659 layout::render_debug_overlay(
2660 &tree,
2661 buffer,
2662 frame_time_us,
2663 state.diagnostics.fps_ema,
2664 state.diagnostics.debug_layer,
2665 );
2666 }
2667 if state.diagnostics.inspector_mode {
2673 let focus = layout::InspectorFocus {
2674 focus_index: state.focus.focus_index,
2675 focus_count: state.focus.prev_focus_count,
2676 names: &state.focus.focus_name_map_prev,
2677 theme: &config.theme,
2678 };
2679 layout::render_inspector(&tree, buffer, &focus);
2680 }
2681
2682 if let Some(panic) = deferred_draw_panic {
2686 std::panic::resume_unwind(panic);
2687 }
2688
2689 FrameKernelResult {
2690 should_quit: false,
2691 #[cfg(feature = "crossterm")]
2692 clipboard_text,
2693 #[cfg(feature = "crossterm")]
2694 should_copy_selection,
2695 }
2696}
2697
2698fn run_frame(
2699 term: &mut impl Backend,
2700 state: &mut FrameState,
2701 config: &RunConfig,
2702 events: Vec<event::Event>,
2703 terminal_side_effects: bool,
2704 f: &mut impl FnMut(&mut context::Context),
2705) -> io::Result<bool> {
2706 let size = term.size();
2707 let kernel = run_frame_kernel(
2708 term.buffer_mut(),
2709 state,
2710 config,
2711 size,
2712 events,
2713 terminal_side_effects,
2714 f,
2715 );
2716 if kernel.should_quit {
2717 return Ok(false);
2718 }
2719
2720 #[cfg(feature = "crossterm")]
2721 if state.selection.active {
2722 terminal::apply_selection_overlay(
2723 term.buffer_mut(),
2724 &state.selection,
2725 &state.layout_feedback.prev_content_map,
2726 );
2727 }
2728 #[cfg(feature = "crossterm")]
2729 if terminal_side_effects && kernel.should_copy_selection {
2730 let text = terminal::extract_selection_text(
2731 term.buffer_mut(),
2732 &state.selection,
2733 &state.layout_feedback.prev_content_map,
2734 );
2735 if !text.is_empty() {
2736 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
2737 }
2738 state.selection.clear();
2739 }
2740
2741 term.flush()?;
2742 #[cfg(feature = "crossterm")]
2743 if terminal_side_effects && let Some(text) = kernel.clipboard_text {
2744 #[allow(clippy::print_stderr)]
2745 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
2746 eprintln!("[slt] failed to copy to clipboard: {e}");
2747 }
2748 }
2749 state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
2750
2751 Ok(true)
2752}
2753
2754#[cfg(feature = "crossterm")]
2755fn clear_frame_layout_cache(state: &mut FrameState) {
2756 state.layout_feedback.prev_hit_map.clear();
2757 state.layout_feedback.prev_group_rects.clear();
2758 state.layout_feedback.prev_content_map.clear();
2759 state.layout_feedback.prev_focus_rects.clear();
2760 state.layout_feedback.prev_focus_groups.clear();
2761 state.layout_feedback.prev_scroll_infos.clear();
2762 state.layout_feedback.prev_scroll_rects.clear();
2763 state.layout_feedback.last_mouse_pos = None;
2764 state.region_versions.clear();
2769}
2770
2771#[cfg(feature = "crossterm")]
2772fn is_ctrl_c(ev: &Event) -> bool {
2773 matches!(
2774 ev,
2775 Event::Key(event::KeyEvent {
2776 code: KeyCode::Char('c'),
2777 modifiers,
2778 kind: event::KeyEventKind::Press,
2779 }) if modifiers.contains(KeyModifiers::CONTROL)
2780 )
2781}
2782
2783#[cfg(feature = "crossterm")]
2784fn is_ctrl_z(ev: &Event) -> bool {
2785 matches!(
2786 ev,
2787 Event::Key(event::KeyEvent {
2788 code: KeyCode::Char('z'),
2789 modifiers,
2790 kind: event::KeyEventKind::Press,
2791 }) if modifiers.contains(KeyModifiers::CONTROL)
2792 )
2793}
2794
2795#[cfg(feature = "crossterm")]
2796fn sleep_for_fps_cap(max_fps: Option<u32>, loop_elapsed: Duration) {
2797 if let Some(remaining) = fps_sleep_duration(max_fps, loop_elapsed) {
2798 std::thread::sleep(remaining);
2799 }
2800}
2801
2802#[cfg(feature = "crossterm")]
2803fn fps_sleep_duration(max_fps: Option<u32>, loop_elapsed: Duration) -> Option<Duration> {
2804 let fps = max_fps.filter(|fps| *fps > 0)?;
2805 let target = Duration::from_secs_f64(1.0 / fps as f64);
2806 (loop_elapsed < target).then(|| target - loop_elapsed)
2807}
2808
2809#[cfg(all(test, feature = "crossterm"))]
2810mod run_loop_tests {
2811 use super::*;
2815
2816 static PANIC_HOOK_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
2817
2818 fn key(modifiers: event::KeyModifiers) -> Event {
2819 Event::Key(event::KeyEvent {
2820 code: KeyCode::F(12),
2821 kind: event::KeyEventKind::Press,
2822 modifiers,
2823 })
2824 }
2825
2826 fn char_key(ch: char, modifiers: event::KeyModifiers) -> Event {
2827 Event::Key(event::KeyEvent {
2828 code: KeyCode::Char(ch),
2829 kind: event::KeyEventKind::Press,
2830 modifiers,
2831 })
2832 }
2833
2834 #[test]
2835 fn terminal_text_sanitizer_replaces_control_bytes() {
2836 assert_eq!(
2837 sanitize_terminal_text("safe\x1b]52;c;AAAA\x07text\u{9b}tail"),
2838 "safe?]52;c;AAAA?text?tail"
2839 );
2840 }
2841
2842 #[test]
2843 fn fps_pacing_accounts_for_polling_and_rendering_together() {
2844 let remaining = fps_sleep_duration(Some(60), Duration::from_millis(16))
2845 .expect("a sub-frame remainder remains");
2846 assert!(remaining < Duration::from_millis(1));
2847 assert_eq!(
2848 fps_sleep_duration(Some(60), Duration::from_millis(20)),
2849 None,
2850 "a slow whole-loop iteration must not sleep again"
2851 );
2852 assert_eq!(fps_sleep_duration(None, Duration::ZERO), None);
2853 }
2854
2855 #[test]
2856 fn terminal_endpoint_validation_reports_each_non_tty_case() {
2857 assert!(validate_terminal_endpoints(true, true).is_ok());
2858 for endpoints in [(false, true), (true, false), (false, false)] {
2859 let err = validate_terminal_endpoints(endpoints.0, endpoints.1).unwrap_err();
2860 assert_eq!(err.kind(), io::ErrorKind::NotConnected);
2861 }
2862 }
2863
2864 #[test]
2865 fn panic_hook_tracks_nested_session_ownership() {
2866 let _serial = PANIC_HOOK_TEST_GUARD
2867 .lock()
2868 .unwrap_or_else(std::sync::PoisonError::into_inner);
2869 let outer = install_panic_hook();
2870 let inner = install_panic_hook();
2871 {
2872 let state = PANIC_HOOK_STATE
2873 .lock()
2874 .unwrap_or_else(std::sync::PoisonError::into_inner);
2875 assert_eq!(state.active_sessions, 2);
2876 assert!(state.installed);
2877 }
2878 drop(inner);
2879 assert_eq!(
2880 PANIC_HOOK_STATE
2881 .lock()
2882 .unwrap_or_else(std::sync::PoisonError::into_inner)
2883 .active_sessions,
2884 1
2885 );
2886 drop(outer);
2887 let state = PANIC_HOOK_STATE
2888 .lock()
2889 .unwrap_or_else(std::sync::PoisonError::into_inner);
2890 assert_eq!(state.active_sessions, 0);
2891 assert!(!state.installed);
2892 assert!(state.previous.is_none());
2893 drop(state);
2894
2895 let caught = std::panic::catch_unwind(|| {
2896 with_session_panic_hook(|| panic!("session panic"));
2897 });
2898 assert!(caught.is_err());
2899 let state = PANIC_HOOK_STATE
2900 .lock()
2901 .unwrap_or_else(std::sync::PoisonError::into_inner);
2902 assert_eq!(state.active_sessions, 0);
2903 assert!(!state.installed);
2904 assert!(state.previous.is_none());
2905 }
2906
2907 #[test]
2908 fn static_lines_are_sanitized_before_scrollback_write() {
2909 let lines = vec!["ok\x1b[31mred\x07".to_string()];
2910 let mut out = Vec::new();
2911 write_static_lines_to(&mut out, &lines).unwrap();
2912 assert_eq!(out, b"ok?[31mred?\r\n");
2913 }
2914
2915 #[test]
2916 fn quit_frame_keeps_final_static_log_available_for_drain() {
2917 let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 2));
2918 let mut state = FrameState::default();
2919 let result = run_frame_kernel(
2920 &mut buffer,
2921 &mut state,
2922 &RunConfig::default(),
2923 (20, 2),
2924 Vec::new(),
2925 false,
2926 &mut |ui| {
2927 ui.static_log("final line");
2928 ui.quit();
2929 },
2930 );
2931
2932 assert!(result.should_quit);
2933 assert_eq!(drain_static_log(&mut state), vec!["final line"]);
2934 }
2935
2936 #[test]
2937 fn ctrl_z_suspend_key_is_detected_separately_from_ctrl_c() {
2938 assert!(is_ctrl_z(&char_key('z', event::KeyModifiers::CONTROL)));
2939 assert!(!is_ctrl_z(&char_key('z', event::KeyModifiers::NONE)));
2940 assert!(is_ctrl_c(&char_key('c', event::KeyModifiers::CONTROL)));
2941 assert!(!is_ctrl_c(&char_key('z', event::KeyModifiers::CONTROL)));
2942 }
2943
2944 #[test]
2945 fn plain_f12_toggles_debug_mode() {
2946 let mut state = FrameState::default();
2947 let mut has_resize = false;
2948 assert!(!state.diagnostics.debug_mode);
2949 process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2950 assert!(state.diagnostics.debug_mode);
2951 process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2952 assert!(!state.diagnostics.debug_mode);
2953 }
2954
2955 #[test]
2956 fn shift_f12_cycles_debug_layer_without_toggling_overlay() {
2957 let mut state = FrameState::default();
2958 let mut has_resize = false;
2959 assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2961 assert!(!state.diagnostics.debug_mode);
2962
2963 process_run_loop_event(
2964 &key(event::KeyModifiers::SHIFT),
2965 &mut state,
2966 &mut has_resize,
2967 );
2968 assert_eq!(state.diagnostics.debug_layer, DebugLayer::TopMost);
2969 assert!(!state.diagnostics.debug_mode);
2971
2972 process_run_loop_event(
2973 &key(event::KeyModifiers::SHIFT),
2974 &mut state,
2975 &mut has_resize,
2976 );
2977 assert_eq!(state.diagnostics.debug_layer, DebugLayer::BaseOnly);
2978
2979 process_run_loop_event(
2980 &key(event::KeyModifiers::SHIFT),
2981 &mut state,
2982 &mut has_resize,
2983 );
2984 assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2985 }
2986
2987 #[test]
2988 fn shift_f12_does_not_also_toggle_overlay() {
2989 let mut state = FrameState::default();
2993 let mut has_resize = false;
2994 let before = state.diagnostics.debug_mode;
2995 process_run_loop_event(
2996 &key(event::KeyModifiers::SHIFT),
2997 &mut state,
2998 &mut has_resize,
2999 );
3000 assert_eq!(
3001 state.diagnostics.debug_mode, before,
3002 "Shift+F12 must not flip the on/off toggle"
3003 );
3004 }
3005
3006 #[test]
3007 fn plain_f12_does_not_cycle_layer() {
3008 let mut state = FrameState::default();
3011 let mut has_resize = false;
3012 let before = state.diagnostics.debug_layer;
3013 process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
3014 assert_eq!(state.diagnostics.debug_layer, before);
3015 }
3016
3017 #[cfg(feature = "async")]
3018 #[test]
3019 fn async_message_drain_reports_disconnect_after_sender_drop() {
3020 let (tx, mut rx) = tokio::sync::mpsc::channel(4);
3021 tx.try_send(1u8).expect("channel has capacity");
3022 tx.try_send(2u8).expect("channel has capacity");
3023 drop(tx);
3024
3025 let mut messages = Vec::new();
3026 let disconnected = drain_async_messages(&mut rx, &mut messages, 4);
3027
3028 assert!(disconnected);
3029 assert_eq!(messages, vec![1, 2]);
3030 }
3031
3032 #[cfg(feature = "async")]
3033 #[test]
3034 fn async_message_drain_respects_per_frame_budget_and_fifo_order() {
3035 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
3036 for message in 0u8..6 {
3037 tx.try_send(message).expect("channel has capacity");
3038 }
3039
3040 let mut first = Vec::new();
3041 assert!(!drain_async_messages(&mut rx, &mut first, 2));
3042 assert_eq!(first, vec![0, 1]);
3043
3044 let mut second = Vec::new();
3045 assert!(!drain_async_messages(&mut rx, &mut second, 3));
3046 assert_eq!(second, vec![2, 3, 4]);
3047
3048 drop(tx);
3049 let mut final_batch = Vec::new();
3050 assert!(drain_async_messages(&mut rx, &mut final_batch, 3));
3051 assert_eq!(final_batch, vec![5]);
3052 }
3053
3054 #[test]
3055 fn async_message_budget_normalizes_zero_to_one() {
3056 assert_eq!(
3057 RunConfig::default()
3058 .async_message_budget(0)
3059 .async_message_budget,
3060 1
3061 );
3062 }
3063
3064 #[cfg(feature = "async")]
3065 fn test_async_handle(
3066 task: impl FnOnce(std::sync::Arc<std::sync::atomic::AtomicBool>) -> io::Result<()>
3067 + Send
3068 + 'static,
3069 ) -> AsyncRunHandle<()> {
3070 let (tx, _rx) = tokio::sync::mpsc::channel(1);
3071 let wake = std::sync::Arc::new(AsyncWake::default());
3072 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3073 let task_cancel = std::sync::Arc::clone(&cancel);
3074 let join = tokio::task::spawn_blocking(move || task(task_cancel));
3075 AsyncRunHandle {
3076 sender: Some(AsyncSender {
3077 inner: tx,
3078 wake: std::sync::Arc::clone(&wake),
3079 }),
3080 join: Some(join),
3081 cancel,
3082 wake,
3083 cancel_on_drop: true,
3084 }
3085 }
3086
3087 #[cfg(feature = "async")]
3088 #[tokio::test]
3089 async fn async_run_handle_observes_normal_and_io_completion() {
3090 test_async_handle(|_| Ok(())).await.unwrap();
3091
3092 let error = test_async_handle(|_| {
3093 Err(io::Error::new(
3094 io::ErrorKind::BrokenPipe,
3095 "deterministic failure",
3096 ))
3097 })
3098 .await
3099 .unwrap_err();
3100 assert!(matches!(
3101 error,
3102 AsyncRunError::Io(ref err) if err.kind() == io::ErrorKind::BrokenPipe
3103 ));
3104 }
3105
3106 #[cfg(feature = "async")]
3107 #[tokio::test]
3108 async fn async_run_handle_observes_panics_and_cancels_cooperatively() {
3109 let panic_error = test_async_handle(|_| panic!("render panic"))
3110 .await
3111 .unwrap_err();
3112 assert!(matches!(
3113 panic_error,
3114 AsyncRunError::Join(ref err) if err.is_panic()
3115 ));
3116
3117 let handle = test_async_handle(|cancel| {
3118 while !cancel.load(std::sync::atomic::Ordering::Acquire) {
3119 std::thread::sleep(Duration::from_millis(1));
3120 }
3121 Ok(())
3122 });
3123 handle.cancel_and_join().await.unwrap();
3124 }
3125
3126 #[cfg(feature = "async")]
3127 #[tokio::test]
3128 async fn async_sender_notifies_on_send_and_last_clone_drop() {
3129 let (tx, mut rx) = tokio::sync::mpsc::channel(2);
3130 let wake = std::sync::Arc::new(AsyncWake::default());
3131 let sender = AsyncSender {
3132 inner: tx,
3133 wake: std::sync::Arc::clone(&wake),
3134 };
3135
3136 let before_send = wake.generation();
3137 sender.send(7u8).await.unwrap();
3138 assert_ne!(wake.generation(), before_send);
3139 assert_eq!(rx.recv().await, Some(7));
3140
3141 let clone = sender.clone();
3142 let before_drop = wake.generation();
3143 drop(clone);
3144 assert_ne!(wake.generation(), before_drop);
3145 }
3146
3147 #[test]
3150 fn ctrl_f12_toggles_inspector_independently() {
3151 let mut state = FrameState::default();
3152 let mut has_resize = false;
3153 assert!(!state.diagnostics.inspector_mode);
3154
3155 process_run_loop_event(
3157 &key(event::KeyModifiers::CONTROL),
3158 &mut state,
3159 &mut has_resize,
3160 );
3161 assert!(state.diagnostics.inspector_mode);
3162 assert!(
3163 !state.diagnostics.debug_mode,
3164 "Ctrl+F12 must not toggle the F12 outline overlay"
3165 );
3166 assert_eq!(
3167 state.diagnostics.debug_layer,
3168 DebugLayer::All,
3169 "Ctrl+F12 must not cycle the debug layer"
3170 );
3171
3172 process_run_loop_event(
3174 &key(event::KeyModifiers::CONTROL),
3175 &mut state,
3176 &mut has_resize,
3177 );
3178 assert!(!state.diagnostics.inspector_mode);
3179 }
3180
3181 #[test]
3182 fn plain_and_shift_f12_do_not_touch_inspector() {
3183 let mut state = FrameState::default();
3184 let mut has_resize = false;
3185 process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
3187 assert!(state.diagnostics.debug_mode);
3188 assert!(!state.diagnostics.inspector_mode);
3189 process_run_loop_event(
3191 &key(event::KeyModifiers::SHIFT),
3192 &mut state,
3193 &mut has_resize,
3194 );
3195 assert!(!state.diagnostics.inspector_mode);
3196 }
3197
3198 #[test]
3201 fn handle_suspend_defaults_to_true() {
3202 assert!(RunConfig::default().handle_suspend);
3203 }
3204
3205 #[test]
3206 fn handle_suspend_builder_opts_out() {
3207 let cfg = RunConfig::default().handle_suspend(false);
3208 assert!(!cfg.handle_suspend);
3209 }
3210
3211 #[test]
3212 fn handle_suspend_builder_is_independent_of_ctrl_c() {
3213 let cfg = RunConfig::default()
3215 .handle_ctrl_c(false)
3216 .handle_suspend(false);
3217 assert!(!cfg.handle_ctrl_c);
3218 assert!(!cfg.handle_suspend);
3219
3220 let cfg = RunConfig::default().handle_suspend(true);
3221 assert!(cfg.handle_suspend);
3222 assert!(cfg.handle_ctrl_c, "Ctrl+C default preserved");
3223 }
3224
3225 fn resize(w: u32, h: u32) -> Event {
3228 Event::Resize(w, h)
3229 }
3230
3231 #[test]
3232 fn resize_batch_coalesces_to_single_invocation() {
3233 let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)];
3239 assert_eq!(
3240 resize_invocations_for_batch(&batch),
3241 1,
3242 "a burst of resizes must coalesce to one on_resize"
3243 );
3244 }
3245
3246 #[test]
3247 fn resize_batch_without_resize_invokes_zero_times() {
3248 let batch = vec![key(event::KeyModifiers::NONE)];
3250 assert_eq!(resize_invocations_for_batch(&batch), 0);
3251 assert_eq!(resize_invocations_for_batch(&[]), 0);
3253 }
3254
3255 #[test]
3256 fn resize_coalesce_uses_final_size_via_has_resize_flag() {
3257 let mut state = FrameState::default();
3263 let mut has_resize = false;
3264 let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)];
3265 for ev in &batch {
3266 process_run_loop_event(ev, &mut state, &mut has_resize);
3267 }
3268 assert!(has_resize, "any resize in the batch must set has_resize");
3269 assert_eq!(
3270 usize::from(has_resize),
3271 resize_invocations_for_batch(&batch)
3272 );
3273 }
3274
3275 #[cfg(unix)]
3285 #[test]
3286 fn suspend_handler_installs_delivers_and_tears_down() {
3287 let Ok(guard) = install_suspend_handler(terminal::test_session_snapshot()) else {
3290 return;
3291 };
3292
3293 let _ = signal_hook::low_level::raise(signal_hook::consts::SIGCONT);
3298 std::thread::sleep(Duration::from_millis(50));
3299
3300 drop(guard);
3304 }
3305}