Skip to main content

slt/
lib.rs

1//! SuperLightTUI — an immediate-mode flexbox-layout terminal UI library.
2//!
3//! Build a TUI as easily as a web page: write a closure, SLT calls it
4//! every frame. State lives in your code; layout is described every
5//! frame; styling uses Tailwind-inspired shorthand; focus and events are
6//! threaded through a single [`Context`] parameter.
7//!
8//! See `docs/QUICK_START.md` for a 5-minute introduction and
9//! `docs/DESIGN_PRINCIPLES.md` for the principles every public API
10//! follows.
11//!
12//! # Example
13//!
14//! ```no_run
15//! fn main() -> std::io::Result<()> {
16//!     slt::run(|ui| {
17//!         ui.text("hello, world");
18//!     })
19//! }
20//! ```
21
22// Safety
23#![forbid(unsafe_code)]
24// Documentation
25#![cfg_attr(docsrs, feature(doc_cfg))]
26#![warn(rustdoc::broken_intra_doc_links)]
27#![warn(missing_docs)]
28#![warn(rustdoc::private_intra_doc_links)]
29// Correctness
30#![deny(clippy::unwrap_in_result)]
31#![warn(clippy::unwrap_used)]
32// Library hygiene — a library must not write to stdout/stderr
33#![warn(clippy::dbg_macro)]
34#![warn(clippy::print_stdout)]
35#![warn(clippy::print_stderr)]
36
37//! # SLT — Super Light TUI
38//!
39//! Immediate-mode terminal UI for Rust. Small core. Zero `unsafe`.
40//!
41//! SLT gives you an egui-style API for terminals: your closure runs each frame,
42//! you describe your UI, and SLT handles layout, diffing, and rendering.
43//!
44//! ## Quick Start
45//!
46//! ```no_run
47//! fn main() -> std::io::Result<()> {
48//!     slt::run(|ui| {
49//!         ui.text("hello, world");
50//!     })
51//! }
52//! ```
53//!
54//! ## Features
55//!
56//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
57//! - **50+ built-in widgets** — input, textarea, table, list, tabs, button, checkbox, toggle, spinner, progress, toast, slider, separator, help bar, scrollable, chart, bar chart, stacked bar chart, sparkline, histogram, heatmap, treemap, candlestick, canvas, grid, select, radio, multi-select, tree, virtual list, command palette, markdown, alert, badge, stat, breadcrumb, accordion, code block, big text, image, modal, tooltip, form, calendar, file picker, qr code
58//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
59//! - **Mouse** — click, hover, drag-to-scroll
60//! - **Focus** — automatic Tab/Shift+Tab cycling
61//! - **Theming** — 10 presets, semantic tokens (`ThemeColor`), spacing scale, contrast helpers
62//! - **Animation** — tween and spring primitives with 9 easing functions
63//! - **Inline mode** — render below your prompt, no alternate screen
64//! - **Async** — optional tokio integration via `async` feature
65//! - **Layout debugger** — F12 to visualize container bounds
66//!
67//! ## Feature Flags
68//!
69//! | Flag | Description |
70//! |------|-------------|
71//! | `crossterm` | Built-in terminal runtime (`run`, `run_inline`, clipboard query helpers). Enabled by default. |
72//! | `bidi` | Reorder right-to-left text (Hebrew, Arabic, …) to visual order per UAX #9 before rendering. Enabled by default; pure-LTR text takes a zero-cost fast path. Since 0.21.0. |
73//! | `async` | Enable `run_async()` with tokio channel-based message passing |
74//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
75//! | `image` | Enable image-loading helpers for terminal image widgets |
76//! | `qrcode` | Enable `ui.qr_code(...)` |
77//! | `syntax` / `syntax-*` | Enable tree-sitter syntax highlighting |
78//!
79//! ## Learn More
80//!
81//! - Guides index: <https://github.com/subinium/SuperLightTUI/blob/main/docs/README.md>
82//! - Quick start: <https://github.com/subinium/SuperLightTUI/blob/main/docs/QUICK_START.md>
83//! - Backends and run loops: <https://github.com/subinium/SuperLightTUI/blob/main/docs/BACKENDS.md>
84//! - Testing: <https://github.com/subinium/SuperLightTUI/blob/main/docs/TESTING.md>
85//! - Debugging: <https://github.com/subinium/SuperLightTUI/blob/main/docs/DEBUGGING.md>
86
87/// Animation primitives: tween, spring, keyframes, sequence, stagger.
88pub mod anim;
89/// Double-buffered cell grid with clip stack and diff tracking.
90pub mod buffer;
91/// Terminal cell representation.
92pub mod cell;
93/// Chart and data visualization widgets.
94pub mod chart;
95/// UI context, container builder, and widget rendering.
96pub mod context;
97/// Input events (keyboard, mouse, resize, paste).
98pub mod event;
99/// Half-block image rendering.
100pub mod halfblock;
101#[cfg(feature = "crossterm")]
102mod iterm;
103/// Keyboard shortcut mapping.
104pub mod keymap;
105/// Flexbox layout engine and command tree.
106pub mod layout;
107/// Color palettes (Tailwind-style).
108pub mod palette;
109/// Rectangular region type used throughout SLT layout.
110pub mod rect;
111#[cfg(feature = "crossterm")]
112mod sixel;
113/// Styling: colors, borders, padding, margins, themes, constraints.
114pub mod style;
115/// Tree-sitter syntax highlighting integration.
116pub mod syntax;
117#[cfg(feature = "crossterm")]
118mod terminal;
119/// Headless test utilities for unit-testing TUI closures.
120pub mod test_utils;
121/// Widget state types (list, table, input, select, etc.).
122pub mod widgets;
123
124use std::io;
125#[cfg(feature = "crossterm")]
126use std::io::IsTerminal;
127#[cfg(feature = "crossterm")]
128use std::io::Write;
129#[cfg(feature = "crossterm")]
130use std::sync::Once;
131use std::time::{Duration, Instant};
132
133#[doc(hidden)]
134pub use layout::__bench_dim_buffer_around;
135#[doc(hidden)]
136pub use layout::__bench_wrap_segments;
137#[cfg(feature = "crossterm")]
138#[doc(hidden)]
139pub use terminal::__bench_flush_buffer_diff;
140#[cfg(feature = "crossterm")]
141#[doc(hidden)]
142pub use terminal::__bench_flush_buffer_diff_mut;
143#[cfg(feature = "crossterm")]
144#[doc(hidden)]
145pub use terminal::__bench_flush_buffer_diff_mut_with_buf;
146#[cfg(feature = "crossterm")]
147#[doc(hidden)]
148pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture};
149/// Runtime terminal capability probe (issue #264): read-only [`Capabilities`]
150/// snapshot plus the [`Blitter`] ladder it drives. Diagnostics-only — image
151/// rendering routes through the ladder automatically.
152#[cfg(feature = "crossterm")]
153pub use terminal::{capabilities, Blitter, BlitterSupport, Capabilities};
154#[cfg(feature = "crossterm")]
155pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
156#[cfg(feature = "crossterm")]
157use terminal::{InlineTerminal, Terminal};
158
159pub use crate::test_utils::{EventBuilder, FrameRecord, TestBackend, TestSequence};
160/// PTY/sink test harness for end-to-end escape-byte assertions (issue #274).
161/// Gated behind the dev-only `pty-test` feature; absent from default builds.
162#[cfg(feature = "pty-test")]
163pub use crate::test_utils::{PtyBackend, PtyFrame};
164// Animation primitives (builder types) are re-exported at crate root for
165// ergonomic `use slt::{Tween, Spring, ...}`. The easing functions and `lerp`
166// live under `slt::anim::*` — they are rarely imported in isolation and
167// keeping them out of the root shrinks the top-level surface.
168pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
169pub use buffer::Buffer;
170pub use cell::Cell;
171// Chart user-facing types at crate root; internals (`ChartRenderer`,
172// `RenderedLine`, `ColorSpan`, `DatasetEntry`, `HistogramBuilder`,
173// `GraphType`, `Axis`) live under `slt::chart::*`.
174pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
175pub use context::{
176    Anchor, Bar, BarChartConfig, BarDirection, BarGroup, Breadcrumb, CanvasContext, CodeBlock,
177    ContainerBuilder, Context, Gauge, GutterOpts, LineGauge, Memo, Response, State, TreemapItem,
178    Widget,
179};
180// Issue #234: opaque handle from `Context::spawn`, gated behind `async`.
181#[cfg(feature = "async")]
182pub use context::TaskHandle;
183pub use event::{
184    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKey, MouseButton, MouseEvent,
185    MouseKind,
186};
187pub use halfblock::HalfBlockImage;
188pub use keymap::{Binding, KeyMap, PublishedKeymap, WidgetKeyHelp};
189pub use layout::Direction;
190pub use palette::Palette;
191pub use rect::Rect;
192#[cfg(feature = "theme-watch")]
193pub use style::ThemeWatcher;
194pub use style::{
195    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
196    HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, SyntaxPalette, Theme,
197    ThemeBuilder, ThemeColor, UnderlineStyle, WidgetColors, WidgetTheme, WidthSpec,
198};
199#[cfg(feature = "serde")]
200pub use style::{ThemeFile, ThemeLoadError};
201pub use widgets::validators;
202#[cfg(feature = "async")]
203pub use widgets::AsyncValidation;
204pub use widgets::{
205    AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalDate, CalendarSelect,
206    CalendarState, ChordState, ColorPickerState, CommandPaletteState, ContextItem,
207    DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, GaugeResponse,
208    GridColumn, GutterResponse, HighlightRange, ListState, ModeState, MultiSelectState,
209    NumberInputState, PaginatorState, PaginatorStyle, PaletteCommand, PickerMode, RadioState,
210    RichLogEntry, RichLogState, SchedulerState, ScreenState, ScrollState, SelectState,
211    SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput, StreamingMarkdownState,
212    StreamingTextState, TableColumn, TableState, TabsState, TextInputState, TextareaState,
213    ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
214    ValidateTrigger, Validator, DEFAULT_CHORD_TIMEOUT_TICKS,
215};
216
217/// Rendering backend for SLT.
218///
219/// Implement this trait to render SLT UIs to custom targets — alternative
220/// terminals, GUI embeds, test harnesses, WASM canvas, etc.
221///
222/// The built-in terminal backend ([`run()`], [`run_with()`]) handles setup,
223/// teardown, and event polling automatically. For custom backends, pair this
224/// trait with [`AppState`] and [`frame()`] to drive the render loop yourself.
225///
226/// # Example
227///
228/// ```ignore
229/// use slt::{Backend, AppState, Buffer, Rect, RunConfig, Context, Event};
230///
231/// struct MyBackend {
232///     buffer: Buffer,
233/// }
234///
235/// impl Backend for MyBackend {
236///     fn size(&self) -> (u32, u32) {
237///         (self.buffer.area.width, self.buffer.area.height)
238///     }
239///     fn buffer_mut(&mut self) -> &mut Buffer {
240///         &mut self.buffer
241///     }
242///     fn flush(&mut self) -> std::io::Result<()> {
243///         // Render self.buffer to your target
244///         Ok(())
245///     }
246/// }
247///
248/// fn main() -> std::io::Result<()> {
249///     let mut backend = MyBackend {
250///         buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
251///     };
252///     let mut state = AppState::new();
253///     let config = RunConfig::default();
254///
255///     loop {
256///         let events: Vec<Event> = vec![]; // Collect your own events
257///         if !slt::frame(&mut backend, &mut state, &config, &events, &mut |ui| {
258///             ui.text("Hello from custom backend!");
259///         })? {
260///             break;
261///         }
262///     }
263///     Ok(())
264/// }
265/// ```
266pub trait Backend {
267    /// Returns the current display size as `(width, height)` in cells.
268    fn size(&self) -> (u32, u32);
269
270    /// Returns a mutable reference to the display buffer.
271    ///
272    /// SLT writes the UI into this buffer each frame. After [`frame()`]
273    /// returns, call [`flush()`](Backend::flush) to present the result.
274    fn buffer_mut(&mut self) -> &mut Buffer;
275
276    /// Flush the buffer contents to the display.
277    ///
278    /// Called automatically at the end of each [`frame()`] call. Implementations
279    /// should present the current buffer to the user — by writing ANSI escapes,
280    /// drawing to a canvas, updating a texture, etc.
281    fn flush(&mut self) -> io::Result<()>;
282}
283
284/// Opaque per-session state that persists between frames.
285///
286/// Tracks focus, scroll positions, hook state, and other frame-to-frame data.
287/// Create with [`AppState::new()`] and pass to [`frame()`] each iteration.
288///
289/// # Example
290///
291/// ```ignore
292/// let mut state = slt::AppState::new();
293/// // state is passed to slt::frame() in your render loop
294/// ```
295pub struct AppState {
296    pub(crate) inner: FrameState,
297}
298
299impl AppState {
300    /// Create a new empty application state.
301    pub fn new() -> Self {
302        Self {
303            inner: FrameState::default(),
304        }
305    }
306
307    /// Returns the current frame tick count (increments each frame).
308    pub fn tick(&self) -> u64 {
309        self.inner.diagnostics.tick
310    }
311
312    /// Returns the smoothed FPS estimate (exponential moving average).
313    pub fn fps(&self) -> f32 {
314        self.inner.diagnostics.fps_ema
315    }
316
317    /// Toggle the debug overlay (same as pressing F12).
318    pub fn set_debug(&mut self, enabled: bool) {
319        self.inner.diagnostics.debug_mode = enabled;
320    }
321}
322
323impl Default for AppState {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329/// Process a single UI frame with a custom [`Backend`].
330///
331/// This is the low-level entry point for custom backends. For standard terminal
332/// usage, prefer [`run()`] or [`run_with()`] which handle the event loop,
333/// terminal setup, and teardown automatically.
334///
335/// Returns `Ok(true)` to continue, `Ok(false)` when [`Context::quit()`] was
336/// called.
337///
338/// # Arguments
339///
340/// * `backend` — Your [`Backend`] implementation
341/// * `state` — Persistent [`AppState`] (reuse across frames)
342/// * `config` — [`RunConfig`] (theme, tick rate, etc.)
343/// * `events` — Input events for this frame (keyboard, mouse, resize)
344/// * `f` — Your UI closure, called once per frame
345///
346/// Build a fresh event slice each frame in your outer loop, then pass it here.
347/// `frame()` reads from that slice but does not own your event source.
348/// Reuse the same [`AppState`] for the lifetime of the session.
349///
350/// # Example
351///
352/// ```ignore
353/// let keep_going = slt::frame(
354///     &mut my_backend,
355///     &mut state,
356///     &config,
357///     &events,
358///     &mut |ui| { ui.text("hello"); },
359/// )?;
360/// ```
361pub fn frame(
362    backend: &mut impl Backend,
363    state: &mut AppState,
364    config: &RunConfig,
365    events: &[Event],
366    f: &mut impl FnMut(&mut Context),
367) -> io::Result<bool> {
368    frame_owned(backend, state, config, events.to_vec(), f)
369}
370
371/// Process a single UI frame, taking ownership of the events `Vec` (zero-copy).
372///
373/// Like [`frame`], but accepts an owned `Vec<Event>` to avoid the `to_vec()`
374/// copy `frame` performs internally. Prefer this in high-frequency custom
375/// render loops where you already own the event buffer.
376///
377/// # Example
378///
379/// ```ignore
380/// let events: Vec<slt::Event> = collect_events();
381/// let keep_going = slt::frame_owned(
382///     &mut my_backend,
383///     &mut state,
384///     &config,
385///     events,
386///     &mut |ui| { ui.text("hello"); },
387/// )?;
388/// ```
389pub fn frame_owned(
390    backend: &mut impl Backend,
391    state: &mut AppState,
392    config: &RunConfig,
393    events: Vec<Event>,
394    f: &mut impl FnMut(&mut Context),
395) -> io::Result<bool> {
396    run_frame(backend, &mut state.inner, config, events, f)
397}
398
399#[cfg(feature = "crossterm")]
400static PANIC_HOOK_ONCE: Once = Once::new();
401
402#[allow(clippy::print_stderr)]
403#[cfg(feature = "crossterm")]
404fn install_panic_hook() {
405    PANIC_HOOK_ONCE.call_once(|| {
406        let original = std::panic::take_hook();
407        std::panic::set_hook(Box::new(move |panic_info| {
408            let _ = crossterm::terminal::disable_raw_mode();
409            let mut stdout = io::stdout();
410            let _ = crossterm::execute!(
411                stdout,
412                crossterm::terminal::LeaveAlternateScreen,
413                crossterm::cursor::Show,
414                crossterm::event::DisableMouseCapture,
415                crossterm::event::DisableBracketedPaste,
416                crossterm::style::ResetColor,
417                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
418            );
419
420            // Print friendly panic header
421            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
422
423            // Print location if available
424            if let Some(location) = panic_info.location() {
425                eprintln!(
426                    "\x1b[90m{}:{}:{}\x1b[0m",
427                    location.file(),
428                    location.line(),
429                    location.column()
430                );
431            }
432
433            // Print message
434            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
435                eprintln!("\x1b[1m{}\x1b[0m", msg);
436            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
437                eprintln!("\x1b[1m{}\x1b[0m", msg);
438            }
439
440            eprintln!(
441                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
442            );
443
444            original(panic_info);
445        }));
446    });
447}
448
449/// RAII guard owning the unix suspend/resume (`SIGTSTP`/`SIGCONT`) handler
450/// thread for the duration of a run loop (issue #263).
451///
452/// Dropping the guard closes the `signal-hook` registration so the background
453/// thread breaks out of `Signals::forever()` and is joined, leaving no signal
454/// handlers installed after the loop exits.
455#[cfg(all(feature = "crossterm", unix))]
456struct SuspendGuard {
457    handle: signal_hook::iterator::Handle,
458    thread: Option<std::thread::JoinHandle<()>>,
459}
460
461#[cfg(all(feature = "crossterm", unix))]
462impl Drop for SuspendGuard {
463    fn drop(&mut self) {
464        // Closing the handle wakes `Signals::forever()` so the thread returns.
465        self.handle.close();
466        if let Some(thread) = self.thread.take() {
467            let _ = thread.join();
468        }
469    }
470}
471
472/// Install the unix job-control suspend/resume handler for one run loop.
473///
474/// Spawns a `signal-hook` background thread that, on `SIGTSTP`, restores the
475/// terminal and re-raises the default-disposition stop, and on `SIGCONT`
476/// re-enters the session and flags a full redraw. Uses only signal-hook's safe
477/// API, preserving `#![forbid(unsafe_code)]`. Returns the guard that owns the
478/// thread; dropping it uninstalls the handler.
479#[cfg(all(feature = "crossterm", unix))]
480fn install_suspend_handler(snapshot: terminal::SessionSnapshot) -> io::Result<SuspendGuard> {
481    use signal_hook::consts::{SIGCONT, SIGTSTP};
482    use signal_hook::iterator::Signals;
483
484    let mut signals = Signals::new([SIGTSTP, SIGCONT])?;
485    let handle = signals.handle();
486    let thread = std::thread::Builder::new()
487        .name("slt-suspend".to_string())
488        .spawn(move || {
489            // `has_terminal` tracks whether the TUI session is currently
490            // entered, so a stray SIGCONT (no prior SIGTSTP) or a repeated
491            // SIGTSTP cannot double-leave / double-enter (idempotency).
492            let mut has_terminal = true;
493            for signal in &mut signals {
494                match signal {
495                    SIGTSTP if has_terminal => {
496                        terminal::suspend_to_shell(&snapshot);
497                        has_terminal = false;
498                        // Genuinely stop the process now that the terminal is
499                        // restored; control returns to the shell.
500                        let _ = signal_hook::low_level::emulate_default_handler(SIGTSTP);
501                    }
502                    SIGCONT if !has_terminal => {
503                        terminal::resume_from_shell(&snapshot);
504                        has_terminal = true;
505                    }
506                    // Repeated SIGTSTP/SIGCONT or out-of-order delivery is a
507                    // no-op — the `has_terminal` guard keeps enter/leave
508                    // balanced (idempotency, issue #263).
509                    _ => {}
510                }
511            }
512        })?;
513
514    Ok(SuspendGuard {
515        handle,
516        thread: Some(thread),
517    })
518}
519
520/// Consume the pending full-redraw request raised by a `SIGCONT` resume and, if
521/// set, clear + repaint the whole frame (issue #263).
522///
523/// Called at the top of each run-loop iteration. No-op on non-unix builds.
524#[cfg(all(feature = "crossterm", unix))]
525fn drain_resume_redraw(handle_resize: &mut impl FnMut() -> io::Result<()>) -> io::Result<()> {
526    use std::sync::atomic::Ordering;
527    if terminal::NEEDS_FULL_REDRAW.swap(false, Ordering::SeqCst) {
528        handle_resize()?;
529    }
530    Ok(())
531}
532
533/// Configuration for a TUI run loop.
534///
535/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
536/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
537/// This type is `#[non_exhaustive]`, so prefer builder methods instead of struct literals.
538///
539/// # Example
540///
541/// ```no_run
542/// use slt::{RunConfig, Theme};
543/// use std::time::Duration;
544///
545/// let config = RunConfig::default()
546///     .tick_rate(Duration::from_millis(50))
547///     .mouse(true)
548///     .theme(Theme::light())
549///     .max_fps(60);
550/// ```
551#[non_exhaustive]
552#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
553pub struct RunConfig {
554    /// How long to wait for input before triggering a tick with no events.
555    ///
556    /// Lower values give smoother animations at the cost of more CPU usage.
557    /// Defaults to 16ms (60fps).
558    pub tick_rate: Duration,
559    /// Whether to enable mouse event reporting.
560    ///
561    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
562    /// Defaults to `false`.
563    pub mouse: bool,
564    /// Whether to enable the Kitty keyboard protocol for enhanced input.
565    ///
566    /// When `true`, enables disambiguated key events, key release events,
567    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
568    /// Terminals that don't support it silently ignore the request.
569    /// Defaults to `false`.
570    pub kitty_keyboard: bool,
571    /// Whether to request modifier-only key events (bare Ctrl/Shift/Alt/Super
572    /// presses and releases, with no accompanying character).
573    ///
574    /// Has **no effect** unless [`kitty_keyboard`](Self::kitty_keyboard) is also
575    /// `true`: it OR-es the Kitty `REPORT_ALL_KEYS_AS_ESCAPE_CODES`
576    /// progressive-enhancement flag into the pushed flag set. On supporting
577    /// terminals (kitty, Ghostty, WezTerm) this makes bare modifier presses
578    /// arrive as [`KeyCode::Modifier`] events; other terminals never emit them.
579    ///
580    /// Kept opt-in to avoid flooding apps with modifier events they don't want.
581    /// Defaults to `false`.
582    ///
583    /// Since 0.21.0.
584    pub report_all_keys: bool,
585    /// The color theme applied to all widgets automatically.
586    ///
587    /// Defaults to [`Theme::dark()`].
588    pub theme: Theme,
589    /// Color depth override.
590    ///
591    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
592    /// variables. Set explicitly to force a specific color depth regardless
593    /// of terminal capabilities.
594    pub color_depth: Option<ColorDepth>,
595    /// Optional maximum frame rate.
596    ///
597    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
598    /// loop iteration to target that frame time.
599    pub max_fps: Option<u32>,
600    /// Lines scrolled per mouse scroll event. Defaults to 1.
601    pub scroll_speed: u32,
602    /// Optional terminal window title (set via OSC 2).
603    pub title: Option<String>,
604    /// Default colors applied to all instances of each widget type.
605    ///
606    /// Per-callsite `_colored()` overrides still take precedence.
607    /// Defaults to all-`None` (use theme colors).
608    pub widget_theme: style::WidgetTheme,
609    /// Whether the runtime intercepts Ctrl+C and exits the loop cleanly.
610    ///
611    /// When `true` (the default), Ctrl+C is treated as a quit signal —
612    /// matching the v0.19 behavior. When `false`, the Ctrl+C key event flows
613    /// through to the frame closure as a regular [`Event::Key`], matching
614    /// RataTUI's raw-mode semantics. The user is then responsible for
615    /// deciding whether to call [`Context::quit`] or treat it as any other
616    /// shortcut (e.g. clear input, cancel current operation).
617    ///
618    /// Set this to `false` when migrating code from RataTUI that already
619    /// handles Ctrl+C explicitly, or when implementing a graceful-shutdown
620    /// prompt (e.g. "save unsaved changes?").
621    ///
622    /// # Example
623    ///
624    /// ```no_run
625    /// # use slt::{KeyCode, KeyModifiers, RunConfig};
626    /// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
627    ///     // Ctrl+C now reaches your closure as a normal key event.
628    ///     if ui.key_mod('c', KeyModifiers::CONTROL) {
629    ///         // Decide what to do — clear input, prompt to save, quit, etc.
630    ///         ui.quit();
631    ///     }
632    /// }).unwrap();
633    /// ```
634    pub handle_ctrl_c: bool,
635    /// Whether the runtime restores the terminal on Ctrl+Z (`SIGTSTP`) and
636    /// re-enters it on resume (`SIGCONT`).
637    ///
638    /// When `true` (the default) on Unix, pressing Ctrl+Z runs the full
639    /// session teardown — leave the alternate screen (fullscreen only), show
640    /// the cursor, disable raw mode / bracketed paste / focus / mouse / kitty
641    /// — *before* the process is suspended, so the shell prompt returns to a
642    /// clean terminal. Resuming with `fg` re-enters the same session and forces
643    /// a full redraw. This matches helix/zellij/bubbletea job-control behavior.
644    ///
645    /// When `false`, no signal handler is installed and Ctrl+Z falls through to
646    /// crossterm as a regular key event in raw mode (the pre-0.21 behavior).
647    ///
648    /// Unix only; ignored on Windows, WASM, and non-`crossterm` builds where
649    /// there is no `SIGTSTP`. Defaults to `true`.
650    ///
651    /// # Example
652    ///
653    /// ```no_run
654    /// use slt::RunConfig;
655    /// // Opt out: let Ctrl+Z reach the frame closure as a key event.
656    /// let cfg = RunConfig::default().handle_suspend(false);
657    /// assert!(!cfg.handle_suspend);
658    /// ```
659    pub handle_suspend: bool,
660}
661
662impl Default for RunConfig {
663    fn default() -> Self {
664        Self {
665            tick_rate: Duration::from_millis(16),
666            mouse: false,
667            kitty_keyboard: false,
668            report_all_keys: false,
669            theme: Theme::dark(),
670            color_depth: None,
671            max_fps: Some(60),
672            scroll_speed: 1,
673            title: None,
674            widget_theme: style::WidgetTheme::new(),
675            handle_ctrl_c: true,
676            handle_suspend: true,
677        }
678    }
679}
680
681impl RunConfig {
682    /// Set the tick rate (input polling interval).
683    pub fn tick_rate(mut self, rate: Duration) -> Self {
684        self.tick_rate = rate;
685        self
686    }
687
688    /// Enable or disable mouse event reporting.
689    pub fn mouse(mut self, enabled: bool) -> Self {
690        self.mouse = enabled;
691        self
692    }
693
694    /// Enable or disable Kitty keyboard protocol.
695    pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
696        self.kitty_keyboard = enabled;
697        self
698    }
699
700    /// Enable or disable modifier-only key reporting (Kitty
701    /// `REPORT_ALL_KEYS_AS_ESCAPE_CODES`).
702    ///
703    /// Requires [`kitty_keyboard(true)`](Self::kitty_keyboard) to have any
704    /// effect. When enabled on a supporting terminal, bare modifier presses
705    /// and releases arrive as [`KeyCode::Modifier`] events. Defaults to
706    /// `false`.
707    ///
708    /// Since 0.21.0.
709    ///
710    /// # Example
711    ///
712    /// ```no_run
713    /// use slt::RunConfig;
714    /// let cfg = RunConfig::default().kitty_keyboard(true).report_all_keys(true);
715    /// assert!(cfg.report_all_keys);
716    /// ```
717    pub fn report_all_keys(mut self, enabled: bool) -> Self {
718        self.report_all_keys = enabled;
719        self
720    }
721
722    /// Set the color theme.
723    pub fn theme(mut self, theme: Theme) -> Self {
724        self.theme = theme;
725        self
726    }
727
728    /// Override the color depth.
729    pub fn color_depth(mut self, depth: ColorDepth) -> Self {
730        self.color_depth = Some(depth);
731        self
732    }
733
734    /// Set the maximum frame rate.
735    pub fn max_fps(mut self, fps: u32) -> Self {
736        self.max_fps = Some(fps);
737        self
738    }
739
740    /// Disable the frame rate cap (unlimited FPS).
741    ///
742    /// By default, [`RunConfig`] caps rendering at 60 fps. Call this to remove
743    /// the cap entirely — useful when controlling external sleep/vsync.
744    ///
745    /// # Example
746    ///
747    /// ```no_run
748    /// slt::run_with(
749    ///     slt::RunConfig::default().no_fps_cap(),
750    ///     |ui| { ui.text("uncapped"); },
751    /// ).unwrap();
752    /// ```
753    pub fn no_fps_cap(mut self) -> Self {
754        self.max_fps = None;
755        self
756    }
757
758    /// Set the scroll speed (lines per scroll event).
759    pub fn scroll_speed(mut self, lines: u32) -> Self {
760        self.scroll_speed = lines.max(1);
761        self
762    }
763
764    /// Set the terminal window title.
765    pub fn title(mut self, title: impl Into<String>) -> Self {
766        self.title = Some(title.into());
767        self
768    }
769
770    /// Set default widget colors for all widget types.
771    pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
772        self.widget_theme = widget_theme;
773        self
774    }
775
776    /// Configure whether the runtime auto-exits on Ctrl+C.
777    ///
778    /// Defaults to `true` (current v0.19 behavior). Set to `false` to
779    /// receive Ctrl+C as a regular [`Event::Key`] inside the frame closure
780    /// — see [`RunConfig::handle_ctrl_c`] for the full migration story.
781    ///
782    /// # Example
783    ///
784    /// ```no_run
785    /// use slt::RunConfig;
786    /// let cfg = RunConfig::default().handle_ctrl_c(false);
787    /// assert!(!cfg.handle_ctrl_c);
788    /// ```
789    pub fn handle_ctrl_c(mut self, enabled: bool) -> Self {
790        self.handle_ctrl_c = enabled;
791        self
792    }
793
794    /// Configure whether the runtime restores the terminal on Ctrl+Z
795    /// (`SIGTSTP`) and re-enters it on resume (`SIGCONT`).
796    ///
797    /// Defaults to `true`. Set to `false` to disable the suspend handler so
798    /// Ctrl+Z falls through to crossterm as a regular key event — see
799    /// [`RunConfig::handle_suspend`] for the full behavior. Unix only; ignored
800    /// elsewhere.
801    ///
802    /// # Example
803    ///
804    /// ```no_run
805    /// use slt::RunConfig;
806    /// let cfg = RunConfig::default().handle_suspend(false);
807    /// assert!(!cfg.handle_suspend);
808    /// ```
809    pub fn handle_suspend(mut self, enabled: bool) -> Self {
810        self.handle_suspend = enabled;
811        self
812    }
813}
814
815#[derive(Default)]
816pub(crate) struct FocusState {
817    pub focus_index: usize,
818    pub prev_focus_count: usize,
819    pub prev_modal_active: bool,
820    pub prev_modal_focus_start: usize,
821    pub prev_modal_focus_count: usize,
822    /// Issue #208: focus index at the end of the previous frame. `None` on
823    /// the first frame so widgets do not falsely report `gained_focus`.
824    pub prev_focus_index: Option<usize>,
825    /// Issue #217: persisted `name → focus_index` map from the most recent
826    /// completed frame. Used at frame start to resolve a pending
827    /// `focus_by_name(...)` against the previous render's registrations.
828    pub focus_name_map_prev: std::collections::HashMap<String, usize>,
829    /// Issue #217: a name passed to `focus_by_name(...)` that has not yet
830    /// been resolved. Consumed once the matching registration is found in
831    /// `focus_name_map_prev`.
832    pub pending_focus_name: Option<String>,
833}
834
835#[derive(Default)]
836pub(crate) struct LayoutFeedbackState {
837    /// `(content_extent, viewport_extent, is_horizontal)` per scrollable last
838    /// frame (#247). `is_horizontal` selects which `ScrollState` axis the
839    /// `scrollable` binding updates.
840    pub prev_scroll_infos: Vec<(u32, u32, bool)>,
841    pub prev_scroll_rects: Vec<rect::Rect>,
842    pub prev_hit_map: Vec<rect::Rect>,
843    pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
844    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
845    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
846    pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
847    pub last_mouse_pos: Option<(u32, u32)>,
848}
849
850#[derive(Default)]
851pub(crate) struct DiagnosticsState {
852    pub tick: u64,
853    pub notification_queue: Vec<(String, ToastLevel, u64)>,
854    pub debug_mode: bool,
855    pub debug_layer: DebugLayer,
856    /// Issue #268: whether the devtools inspector panel (Ctrl+F12) is active.
857    /// Independent of `debug_mode`/`debug_layer`. Round-trips through
858    /// `Context::inspector_mode` like `debug_layer` so `set_inspector` persists.
859    pub inspector_mode: bool,
860    pub fps_ema: f32,
861}
862
863/// Which layers the F12 debug overlay should outline (issue #201).
864///
865/// `All` (the default) outlines both the base layer and any active
866/// overlays/modals — matching the user's expectation for "show everything
867/// the renderer is producing this frame." `TopMost` only outlines the
868/// topmost overlay (or the base if no overlay is active), and `BaseOnly`
869/// keeps the legacy pre-fix behavior of skipping overlays entirely.
870///
871/// At runtime, **Shift+F12** cycles `All → TopMost → BaseOnly → All` so a
872/// developer debugging a stacked modal can shrink the visible outlines to
873/// just the layer they care about without leaving the keyboard. Plain
874/// **F12** independently toggles the overlay on/off.
875///
876/// # Example
877///
878/// ```no_run
879/// use slt::{Context, DebugLayer};
880///
881/// slt::run(|ui: &mut Context| {
882///     // Match on the current layer to drive bespoke debug UI.
883///     let label = match ui.debug_layer() {
884///         DebugLayer::All => "showing base + overlays",
885///         DebugLayer::TopMost => "showing topmost overlay only",
886///         DebugLayer::BaseOnly => "showing base layer only",
887///     };
888///     ui.text(label);
889/// })
890/// .unwrap();
891/// ```
892#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
893pub enum DebugLayer {
894    /// Outline both the base tree and every active overlay/modal.
895    ///
896    /// Default. Matches the reporter expectation that F12 reflects
897    /// everything the renderer is producing this frame. Each layer family
898    /// gets its own hue so a glance distinguishes base, overlay, and modal
899    /// containers.
900    #[default]
901    All,
902    /// Outline only the topmost overlay (or the base if no overlay is
903    /// active).
904    ///
905    /// Useful when modals or popovers stack and you only care about the
906    /// active dialog — base-tree outlines become noise underneath an open
907    /// modal.
908    TopMost,
909    /// Outline only the base layer (legacy v0.19.x behavior).
910    ///
911    /// Skips overlays and modals entirely. Use when an overlay is
912    /// confirmed correct and you want to inspect the base layout
913    /// underneath it.
914    BaseOnly,
915}
916
917/// Type alias matching `context::core::RawDrawCallback` (private over there);
918/// used inside `FrameState` for the recycled-Vec field for issue #204. Kept
919/// in lib.rs to avoid leaking a public type alias.
920pub(crate) type FrameDeferredDrawSlot =
921    Option<Box<dyn FnOnce(&mut crate::buffer::Buffer, crate::rect::Rect)>>;
922
923#[derive(Default)]
924pub(crate) struct FrameState {
925    pub hook_states: Vec<Box<dyn std::any::Any>>,
926    pub named_states: std::collections::HashMap<&'static str, Box<dyn std::any::Any>>,
927    /// Issue #215: runtime-string-keyed parallel of `named_states`. Persisted
928    /// across frames; survives panics inside `error_boundary` (matching the
929    /// `named_states` policy).
930    pub keyed_states: std::collections::HashMap<String, Box<dyn std::any::Any>>,
931    /// Issue #262: cross-frame partial-chord buffer for [`Context::key_chord`].
932    /// Round-trips across frames using the same `std::mem::take` out/in policy
933    /// as `keyed_states` (moved out in `Context::new`, restored at frame end in
934    /// `run_frame_kernel`).
935    pub chord_states: widgets::ChordState,
936    /// Issue #248: persistent frame-clock timer table. Round-tripped through
937    /// `Context` exactly like `named_states` — moved out at frame start, moved
938    /// back at frame end where untouched slots are garbage-collected.
939    pub scheduler: widgets::SchedulerState,
940    /// Issue #234: persistent async task registry backing `Context::spawn` /
941    /// `Context::poll`. Round-tripped through `Context` exactly like
942    /// `scheduler` — moved out at frame start, moved back at frame end. Gated
943    /// behind `async`; absent (zero overhead) when the feature is off.
944    #[cfg(feature = "async")]
945    pub async_tasks: context::AsyncTasks,
946    pub screen_hook_map: std::collections::HashMap<String, (usize, usize)>,
947    pub focus: FocusState,
948    pub layout_feedback: LayoutFeedbackState,
949    pub diagnostics: DiagnosticsState,
950    /// Recycled command Vec (issue #150). `Context::new` swaps this into the
951    /// new context (capacity preserved, len reset to 0). After `build_tree`
952    /// drains the commands, the now-empty Vec is reclaimed back here.
953    pub commands_buf: Vec<crate::layout::Command>,
954    /// Recycled per-frame layout collection scratch (issue #155). Same
955    /// pattern as `commands_buf`: clear before use, restore after.
956    pub frame_data: crate::layout::FrameData,
957    /// Recycled `Context::context_stack` Vec (issue #204). Empty/cleared at
958    /// frame end (same pattern as `commands_buf`).
959    pub context_stack_buf: Vec<Box<dyn std::any::Any>>,
960    /// Recycled `Context::deferred_draws` Vec (issue #204). Slots are emptied
961    /// (set to `None`) when callbacks fire; we clear before reuse.
962    pub deferred_draws_buf: Vec<FrameDeferredDrawSlot>,
963    /// Recycled `rollback.group_stack` Vec (issue #204). Asserted empty at
964    /// frame end before reclamation.
965    pub group_stack_buf: Vec<std::sync::Arc<str>>,
966    /// Recycled `rollback.text_color_stack` Vec (issue #204). Asserted empty
967    /// at frame end before reclamation.
968    pub text_color_stack_buf: Vec<Option<crate::style::Color>>,
969    /// Recycled `Context::pending_tooltips` Vec (issue #204). Asserted empty
970    /// at frame end before reclamation.
971    pub pending_tooltips_buf: Vec<context::PendingTooltip>,
972    /// Recycled `Context::hovered_groups` set (issue #204). Cleared at the
973    /// start of each frame by `build_hovered_groups`.
974    pub hovered_groups_buf: std::collections::HashSet<std::sync::Arc<str>>,
975    /// Issue #273: per-call-site version keys recorded by
976    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) on the
977    /// previous frame, indexed by the order `cached` regions were declared.
978    /// Compared against this frame's keys to classify each cached region as a
979    /// hit (key unchanged) or miss (key changed / new slot / first frame).
980    /// Cleared on resize by [`clear_frame_layout_cache`] so every cached
981    /// region misses after a geometry change. Round-trips through `Context`
982    /// exactly like `commands_buf` (moved out at frame start, moved back at
983    /// frame end). Empty (zero overhead) for apps that never call `cached`.
984    pub region_versions: Vec<u64>,
985    /// Issue #273: recycled scratch Vec for the CURRENT frame's `cached`
986    /// region keys (same alloc-reuse discipline as `commands_buf`). Cleared
987    /// before reuse; swapped into `region_versions` at frame end so the keys
988    /// recorded this frame become next frame's comparison baseline.
989    pub region_versions_buf: Vec<u64>,
990    #[cfg(feature = "crossterm")]
991    pub selection: terminal::SelectionState,
992}
993
994/// Run the TUI loop with default configuration.
995///
996/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
997/// Ctrl+C or when [`Context::quit`] is called.
998///
999/// # Raw mode is handled for you
1000///
1001/// SLT enters raw mode automatically inside [`run`] / [`run_with`] /
1002/// [`run_inline`] / [`run_async`]. Wrapping these with manual
1003/// `crossterm::terminal::enable_raw_mode()` and `disable_raw_mode()` is
1004/// **redundant** — the calls are idempotent so no harm comes of it, but it
1005/// suggests a misunderstood lifecycle. Drop the wrapper calls:
1006///
1007/// ```no_run
1008/// // Don't do this — it's already handled internally:
1009/// // crossterm::terminal::enable_raw_mode()?;
1010/// slt::run(|ui| { ui.text("hi"); })?;
1011/// // crossterm::terminal::disable_raw_mode()?;
1012/// # Ok::<_, std::io::Error>(())
1013/// ```
1014///
1015/// # Ctrl+C opt-out (issue #238)
1016///
1017/// By default, Ctrl+C exits the loop cleanly — matching the v0.19 contract
1018/// and the convention most TUIs follow. To match RataTUI's raw-mode
1019/// semantics (Ctrl+C delivered as a regular `Event::Key`), set
1020/// [`RunConfig::handle_ctrl_c(false)`](RunConfig::handle_ctrl_c) and decide
1021/// inside the frame closure whether to call [`Context::quit`]:
1022///
1023/// ```no_run
1024/// use slt::{KeyModifiers, RunConfig};
1025///
1026/// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
1027///     if ui.key_mod('c', KeyModifiers::CONTROL) {
1028///         // e.g. clear input, prompt to save, then quit:
1029///         ui.quit();
1030///     }
1031/// })?;
1032/// # Ok::<_, std::io::Error>(())
1033/// ```
1034///
1035/// # Example
1036///
1037/// ```no_run
1038/// fn main() -> std::io::Result<()> {
1039///     slt::run(|ui| {
1040///         ui.text("Press Ctrl+C to exit");
1041///     })
1042/// }
1043/// ```
1044#[cfg(feature = "crossterm")]
1045pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
1046    run_with(RunConfig::default(), f)
1047}
1048
1049#[cfg(feature = "crossterm")]
1050fn set_terminal_title(title: &Option<String>) {
1051    if let Some(title) = title {
1052        use std::io::Write;
1053        let mut stdout = io::stdout();
1054        let _ = write!(stdout, "\x1b]2;{title}\x07");
1055        let _ = stdout.flush();
1056    }
1057}
1058
1059/// Run the TUI loop with custom configuration.
1060///
1061/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
1062/// support, and theming.
1063///
1064/// # Example
1065///
1066/// ```no_run
1067/// use slt::{RunConfig, Theme};
1068///
1069/// fn main() -> std::io::Result<()> {
1070///     slt::run_with(
1071///         RunConfig::default().theme(Theme::light()),
1072///         |ui| {
1073///             ui.text("Light theme!");
1074///         },
1075///     )
1076/// }
1077/// ```
1078#[cfg(feature = "crossterm")]
1079pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
1080    if !io::stdout().is_terminal() {
1081        return Ok(());
1082    }
1083
1084    install_panic_hook();
1085    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1086    let mut term = Terminal::new(
1087        config.mouse,
1088        config.kitty_keyboard,
1089        config.report_all_keys,
1090        color_depth,
1091    )?;
1092    set_terminal_title(&config.title);
1093    if config.theme.bg != Color::Reset {
1094        term.theme_bg = Some(config.theme.bg);
1095    }
1096    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1097    #[cfg(unix)]
1098    let _suspend_guard = if config.handle_suspend {
1099        Some(install_suspend_handler(term.session_snapshot())?)
1100    } else {
1101        None
1102    };
1103    let mut events: Vec<Event> = Vec::new();
1104    let mut state = FrameState::default();
1105
1106    loop {
1107        let frame_start = Instant::now();
1108        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1109        #[cfg(unix)]
1110        drain_resume_redraw(&mut || term.handle_resize())?;
1111        let (w, h) = term.size();
1112        if w == 0 || h == 0 {
1113            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1114            continue;
1115        }
1116
1117        if !run_frame(
1118            &mut term,
1119            &mut state,
1120            &config,
1121            std::mem::take(&mut events),
1122            &mut f,
1123        )? {
1124            break;
1125        }
1126        // Issue #233: full-screen mode has no scrollback channel — warn and
1127        // drop any `ui.static_log(...)` lines so they do not leak into the
1128        // next frame's named_states.
1129        discard_static_log(&mut state, "full-screen run()");
1130        let render_elapsed = frame_start.elapsed();
1131
1132        if !poll_events(
1133            &mut events,
1134            &mut state,
1135            config.tick_rate,
1136            &mut || term.handle_resize(),
1137            config.handle_ctrl_c,
1138        )? {
1139            break;
1140        }
1141
1142        sleep_for_fps_cap(config.max_fps, render_elapsed);
1143    }
1144
1145    Ok(())
1146}
1147
1148/// Run the TUI loop asynchronously with default configuration.
1149///
1150/// Requires the `async` feature. Spawns the render loop in a blocking thread
1151/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
1152/// from async tasks into the UI closure.
1153///
1154/// # Example
1155///
1156/// ```no_run
1157/// # #[cfg(feature = "async")]
1158/// # async fn example() -> std::io::Result<()> {
1159/// let tx = slt::run_async::<String>(|ui, messages| {
1160///     for msg in messages.drain(..) {
1161///         ui.text(msg);
1162///     }
1163/// })?;
1164/// tx.send("hello from async".to_string()).await.ok();
1165/// # Ok(())
1166/// # }
1167/// ```
1168#[cfg(all(feature = "crossterm", feature = "async"))]
1169pub fn run_async<M: Send + 'static>(
1170    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1171) -> io::Result<tokio::sync::mpsc::Sender<M>> {
1172    run_async_with(RunConfig::default(), f)
1173}
1174
1175/// Run the TUI loop asynchronously with custom configuration.
1176///
1177/// Requires the `async` feature. Like [`run_async`], but accepts a
1178/// [`RunConfig`] to control tick rate, mouse support, and theming.
1179///
1180/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
1181#[cfg(all(feature = "crossterm", feature = "async"))]
1182pub fn run_async_with<M: Send + 'static>(
1183    config: RunConfig,
1184    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1185) -> io::Result<tokio::sync::mpsc::Sender<M>> {
1186    let (tx, rx) = tokio::sync::mpsc::channel(100);
1187    let handle =
1188        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
1189
1190    // Issue #234: clone the runtime handle into the render loop so
1191    // `Context::spawn` has a runtime to launch tasks onto. The render loop runs
1192    // on `spawn_blocking` (no ambient runtime), so the handle must be passed
1193    // explicitly rather than recovered via `Handle::try_current()` inside.
1194    let loop_handle = handle.clone();
1195    handle.spawn_blocking(move || {
1196        let _ = run_async_loop(config, f, rx, loop_handle);
1197    });
1198
1199    Ok(tx)
1200}
1201
1202#[cfg(all(feature = "crossterm", feature = "async"))]
1203fn run_async_loop<M: Send + 'static>(
1204    config: RunConfig,
1205    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
1206    mut rx: tokio::sync::mpsc::Receiver<M>,
1207    runtime: tokio::runtime::Handle,
1208) -> io::Result<()> {
1209    if !io::stdout().is_terminal() {
1210        return Ok(());
1211    }
1212
1213    install_panic_hook();
1214    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1215    let mut term = Terminal::new(
1216        config.mouse,
1217        config.kitty_keyboard,
1218        config.report_all_keys,
1219        color_depth,
1220    )?;
1221    set_terminal_title(&config.title);
1222    if config.theme.bg != Color::Reset {
1223        term.theme_bg = Some(config.theme.bg);
1224    }
1225    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1226    #[cfg(unix)]
1227    let _suspend_guard = if config.handle_suspend {
1228        Some(install_suspend_handler(term.session_snapshot())?)
1229    } else {
1230        None
1231    };
1232    let mut events: Vec<Event> = Vec::new();
1233    let mut messages: Vec<M> = Vec::new();
1234    let mut state = FrameState::default();
1235    // Issue #234: inject the ambient runtime so `Context::spawn` works inside
1236    // the frame closure. Set once before the loop; round-tripped through
1237    // `Context` from here on (see `run_frame_kernel`).
1238    state.async_tasks.set_runtime(runtime);
1239
1240    loop {
1241        let frame_start = Instant::now();
1242        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1243        #[cfg(unix)]
1244        drain_resume_redraw(&mut || term.handle_resize())?;
1245        messages.clear();
1246        while let Ok(message) = rx.try_recv() {
1247            messages.push(message);
1248        }
1249
1250        let (w, h) = term.size();
1251        if w == 0 || h == 0 {
1252            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1253            continue;
1254        }
1255
1256        let mut render = |ctx: &mut Context| {
1257            f(ctx, &mut messages);
1258        };
1259        if !run_frame(
1260            &mut term,
1261            &mut state,
1262            &config,
1263            std::mem::take(&mut events),
1264            &mut render,
1265        )? {
1266            break;
1267        }
1268        // Issue #233: full-screen async mode has no scrollback channel — warn
1269        // and drop any pending static_log lines.
1270        discard_static_log(&mut state, "run_async()");
1271        let render_elapsed = frame_start.elapsed();
1272
1273        if !poll_events(
1274            &mut events,
1275            &mut state,
1276            config.tick_rate,
1277            &mut || term.handle_resize(),
1278            config.handle_ctrl_c,
1279        )? {
1280            break;
1281        }
1282
1283        sleep_for_fps_cap(config.max_fps, render_elapsed);
1284    }
1285
1286    Ok(())
1287}
1288
1289/// Run the TUI in inline mode with default configuration.
1290///
1291/// Renders `height` rows directly below the current cursor position without
1292/// entering alternate screen mode. Useful for CLI tools that want a small
1293/// interactive widget below the prompt.
1294///
1295/// `height` is the reserved inline render area in terminal rows.
1296/// The rest of the terminal stays in normal scrollback mode.
1297///
1298/// # Example
1299///
1300/// ```no_run
1301/// fn main() -> std::io::Result<()> {
1302///     slt::run_inline(3, |ui| {
1303///         ui.text("Inline TUI — no alternate screen");
1304///     })
1305/// }
1306/// ```
1307#[cfg(feature = "crossterm")]
1308pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
1309    run_inline_with(height, RunConfig::default(), f)
1310}
1311
1312/// Run the TUI in inline mode with custom configuration.
1313///
1314/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
1315/// mouse support, and theming.
1316#[cfg(feature = "crossterm")]
1317pub fn run_inline_with(
1318    height: u32,
1319    config: RunConfig,
1320    mut f: impl FnMut(&mut Context),
1321) -> io::Result<()> {
1322    if !io::stdout().is_terminal() {
1323        return Ok(());
1324    }
1325
1326    install_panic_hook();
1327    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1328    let mut term = InlineTerminal::new(
1329        height,
1330        config.mouse,
1331        config.kitty_keyboard,
1332        config.report_all_keys,
1333        color_depth,
1334    )?;
1335    set_terminal_title(&config.title);
1336    if config.theme.bg != Color::Reset {
1337        term.theme_bg = Some(config.theme.bg);
1338    }
1339    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1340    #[cfg(unix)]
1341    let _suspend_guard = if config.handle_suspend {
1342        Some(install_suspend_handler(term.session_snapshot())?)
1343    } else {
1344        None
1345    };
1346    let mut events: Vec<Event> = Vec::new();
1347    let mut state = FrameState::default();
1348
1349    loop {
1350        let frame_start = Instant::now();
1351        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1352        #[cfg(unix)]
1353        drain_resume_redraw(&mut || term.handle_resize())?;
1354        let (w, h) = term.size();
1355        if w == 0 || h == 0 {
1356            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1357            continue;
1358        }
1359
1360        if !run_frame(
1361            &mut term,
1362            &mut state,
1363            &config,
1364            std::mem::take(&mut events),
1365            &mut f,
1366        )? {
1367            break;
1368        }
1369        // Issue #233: inline mode without `StaticOutput` has no scrollback
1370        // channel either — warn and drop any pending lines.
1371        discard_static_log(&mut state, "run_inline()");
1372        let render_elapsed = frame_start.elapsed();
1373
1374        if !poll_events(
1375            &mut events,
1376            &mut state,
1377            config.tick_rate,
1378            &mut || term.handle_resize(),
1379            config.handle_ctrl_c,
1380        )? {
1381            break;
1382        }
1383
1384        sleep_for_fps_cap(config.max_fps, render_elapsed);
1385    }
1386
1387    Ok(())
1388}
1389
1390/// Run the TUI in static-output mode.
1391///
1392/// Static lines written through [`StaticOutput`] are printed into terminal
1393/// scrollback, while the interactive UI stays rendered in a fixed-height inline
1394/// area at the bottom.
1395///
1396/// Use this when you want a log-style output stream above a live inline UI.
1397#[cfg(feature = "crossterm")]
1398pub fn run_static(
1399    output: &mut StaticOutput,
1400    dynamic_height: u32,
1401    f: impl FnMut(&mut Context),
1402) -> io::Result<()> {
1403    run_static_with(output, dynamic_height, RunConfig::default(), f)
1404}
1405
1406/// Run the TUI in static-output mode with custom configuration.
1407///
1408/// Like [`run_static`] but accepts a [`RunConfig`] for theme, mouse, tick rate,
1409/// and other settings.
1410#[cfg(feature = "crossterm")]
1411pub fn run_static_with(
1412    output: &mut StaticOutput,
1413    dynamic_height: u32,
1414    config: RunConfig,
1415    mut f: impl FnMut(&mut Context),
1416) -> io::Result<()> {
1417    if !io::stdout().is_terminal() {
1418        return Ok(());
1419    }
1420
1421    install_panic_hook();
1422
1423    let initial_lines = output.drain_new();
1424    write_static_lines(&initial_lines)?;
1425
1426    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1427    let mut term = InlineTerminal::new(
1428        dynamic_height,
1429        config.mouse,
1430        config.kitty_keyboard,
1431        config.report_all_keys,
1432        color_depth,
1433    )?;
1434    set_terminal_title(&config.title);
1435    if config.theme.bg != Color::Reset {
1436        term.theme_bg = Some(config.theme.bg);
1437    }
1438    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1439    #[cfg(unix)]
1440    let _suspend_guard = if config.handle_suspend {
1441        Some(install_suspend_handler(term.session_snapshot())?)
1442    } else {
1443        None
1444    };
1445
1446    let mut events: Vec<Event> = Vec::new();
1447    let mut state = FrameState::default();
1448
1449    loop {
1450        let frame_start = Instant::now();
1451        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1452        #[cfg(unix)]
1453        drain_resume_redraw(&mut || term.handle_resize())?;
1454        let (w, h) = term.size();
1455        if w == 0 || h == 0 {
1456            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1457            continue;
1458        }
1459
1460        let new_lines = output.drain_new();
1461        write_static_lines(&new_lines)?;
1462
1463        if !run_frame(
1464            &mut term,
1465            &mut state,
1466            &config,
1467            std::mem::take(&mut events),
1468            &mut f,
1469        )? {
1470            break;
1471        }
1472        // Issue #233: drain any `ui.static_log(...)` lines queued during the
1473        // frame closure into `output`; the next loop iteration flushes them
1474        // above the inline area via `write_static_lines`.
1475        for line in drain_static_log(&mut state) {
1476            output.println(line);
1477        }
1478        let render_elapsed = frame_start.elapsed();
1479
1480        if !poll_events(
1481            &mut events,
1482            &mut state,
1483            config.tick_rate,
1484            &mut || term.handle_resize(),
1485            config.handle_ctrl_c,
1486        )? {
1487            break;
1488        }
1489
1490        sleep_for_fps_cap(config.max_fps, render_elapsed);
1491    }
1492
1493    Ok(())
1494}
1495
1496#[cfg(feature = "crossterm")]
1497fn write_static_lines(lines: &[String]) -> io::Result<()> {
1498    if lines.is_empty() {
1499        return Ok(());
1500    }
1501
1502    let mut stdout = io::stdout();
1503    for line in lines {
1504        stdout.write_all(line.as_bytes())?;
1505        stdout.write_all(b"\r\n")?;
1506    }
1507    stdout.flush()
1508}
1509
1510/// Reserved sentinel key used by [`Context::static_log`] (issue #233).
1511/// Re-exported into `context::runtime` so reads/writes never drift.
1512pub(crate) const STATIC_LOG_NAMED_STATE_KEY: &str = "__slt_static_log_pending";
1513
1514/// Reserved sentinel key used by [`Context::publish_keymap`] (issue #236).
1515/// Re-exported into `context::runtime` so reads/writes never drift.
1516pub(crate) const KEYMAP_REGISTRY_NAMED_STATE_KEY: &str = "__slt_keymap_registry";
1517
1518/// Clear the per-frame keymap registry stored in [`FrameState::named_states`]
1519/// (issue #236). Called at the start of every kernel iteration so that
1520/// `Context::publish_keymap` always sees a fresh empty buffer. Capacity is
1521/// preserved by clearing the inner `Vec` rather than removing the entry.
1522pub(crate) fn clear_keymap_registry(state: &mut FrameState) {
1523    if let Some(boxed) = state.named_states.get_mut(KEYMAP_REGISTRY_NAMED_STATE_KEY) {
1524        if let Some(vec) = boxed.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>() {
1525            vec.clear();
1526        }
1527    }
1528}
1529
1530/// Drain any [`Context::static_log`] lines accumulated during the most recent
1531/// frame from the persisted [`FrameState`] (issue #233).
1532///
1533/// After [`run_frame_kernel`] returns, `state.named_states` owns the buffer.
1534/// This helper drains it back to a `Vec<String>` so the runtime can flush
1535/// the lines through whichever scrollback mechanism is appropriate
1536/// (`run_static_with` writes them above the inline region; other run modes
1537/// drop them with a debug warning).
1538#[cfg(feature = "crossterm")]
1539pub(crate) fn drain_static_log(state: &mut FrameState) -> Vec<String> {
1540    if let Some(boxed) = state.named_states.get_mut(STATIC_LOG_NAMED_STATE_KEY) {
1541        if let Some(buf) = boxed.downcast_mut::<Vec<String>>() {
1542            return std::mem::take(buf);
1543        }
1544    }
1545    Vec::new()
1546}
1547
1548/// Discard any [`Context::static_log`] lines that accumulated during the
1549/// most recent frame and emit a debug warning (issue #233).
1550///
1551/// Used by run modes that have no scrollback channel (full-screen,
1552/// inline-without-static, async). Release builds silently drop the buffer.
1553#[cfg(feature = "crossterm")]
1554fn discard_static_log(state: &mut FrameState, mode: &str) {
1555    let drained = drain_static_log(state);
1556    #[cfg(debug_assertions)]
1557    if !drained.is_empty() {
1558        #[allow(clippy::print_stderr)]
1559        {
1560            eprintln!(
1561                "[slt] {} static_log lines were dropped: {} runtime has no scrollback channel; use slt::run_static for streaming output",
1562                drained.len(),
1563                mode
1564            );
1565        }
1566    }
1567    #[cfg(not(debug_assertions))]
1568    {
1569        let _ = (drained, mode);
1570    }
1571}
1572
1573/// Apply a single terminal event to `FrameState`, mutating tracked
1574/// diagnostics fields (debug overlay toggle, mouse position cache,
1575/// resize flag) accordingly.
1576///
1577/// Issue #201: handles **F12** (toggle overlay on/off) and **Shift+F12**
1578/// (cycle [`DebugLayer`] across `All → TopMost → BaseOnly`). The two
1579/// keybindings are independent — toggling the overlay does not change
1580/// the active layer.
1581///
1582/// Extracted from `poll_events` so the keybinding behavior can be
1583/// exercised by unit tests without standing up a real crossterm event
1584/// stream.
1585#[cfg(feature = "crossterm")]
1586pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
1587    match ev {
1588        Event::Mouse(m) => {
1589            state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
1590        }
1591        Event::FocusLost => {
1592            state.layout_feedback.last_mouse_pos = None;
1593        }
1594        // Issue #268: Ctrl+F12 toggles the devtools inspector panel
1595        // independently of the F12 outline overlay and the Shift+F12 layer
1596        // cycle. Match before the Shift/NONE arms so the Control branch wins.
1597        Event::Key(event::KeyEvent {
1598            code: KeyCode::F(12),
1599            kind: event::KeyEventKind::Press,
1600            modifiers,
1601        }) if modifiers.contains(event::KeyModifiers::CONTROL) => {
1602            state.diagnostics.inspector_mode = !state.diagnostics.inspector_mode;
1603        }
1604        // Issue #201: Shift+F12 cycles the active `DebugLayer`. Match
1605        // before the plain-F12 arm so the modifier branch wins. Plain
1606        // F12 keeps its legacy on/off toggle when no modifiers are
1607        // held; we explicitly require `KeyModifiers::NONE` so the two
1608        // arms do not double-fire on the same press.
1609        Event::Key(event::KeyEvent {
1610            code: KeyCode::F(12),
1611            kind: event::KeyEventKind::Press,
1612            modifiers,
1613        }) if modifiers.contains(event::KeyModifiers::SHIFT) => {
1614            state.diagnostics.debug_layer = match state.diagnostics.debug_layer {
1615                DebugLayer::All => DebugLayer::TopMost,
1616                DebugLayer::TopMost => DebugLayer::BaseOnly,
1617                DebugLayer::BaseOnly => DebugLayer::All,
1618            };
1619        }
1620        Event::Key(event::KeyEvent {
1621            code: KeyCode::F(12),
1622            kind: event::KeyEventKind::Press,
1623            modifiers,
1624        }) if *modifiers == event::KeyModifiers::NONE => {
1625            state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
1626        }
1627        Event::Resize(_, _) => {
1628            *has_resize = true;
1629        }
1630        _ => {}
1631    }
1632}
1633
1634/// Poll for terminal events, handling resize, Ctrl-C, F12 debug toggle,
1635/// and layout cache invalidation. Returns `Ok(false)` when the loop should exit.
1636///
1637/// `handle_ctrl_c` controls whether Ctrl+C exits the loop (`true`, default
1638/// v0.19 behavior) or is delivered to the frame closure as a regular key
1639/// event (`false`, RataTUI parity, issue #238).
1640#[cfg(feature = "crossterm")]
1641fn poll_events(
1642    events: &mut Vec<Event>,
1643    state: &mut FrameState,
1644    tick_rate: Duration,
1645    on_resize: &mut impl FnMut() -> io::Result<()>,
1646    handle_ctrl_c: bool,
1647) -> io::Result<bool> {
1648    let mut has_resize = false;
1649
1650    fn process_ev(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
1651        process_run_loop_event(ev, state, has_resize);
1652    }
1653
1654    if crossterm::event::poll(tick_rate)? {
1655        let raw = crossterm::event::read()?;
1656        if let Some(ev) = event::from_crossterm(raw) {
1657            if handle_ctrl_c && is_ctrl_c(&ev) {
1658                return Ok(false);
1659            }
1660            if matches!(ev, Event::Resize(_, _)) {
1661                on_resize()?;
1662            }
1663            process_ev(&ev, state, &mut has_resize);
1664            events.push(ev);
1665        }
1666
1667        while crossterm::event::poll(Duration::ZERO)? {
1668            let raw = crossterm::event::read()?;
1669            if let Some(ev) = event::from_crossterm(raw) {
1670                if handle_ctrl_c && is_ctrl_c(&ev) {
1671                    return Ok(false);
1672                }
1673                if matches!(ev, Event::Resize(_, _)) {
1674                    on_resize()?;
1675                }
1676                process_ev(&ev, state, &mut has_resize);
1677                events.push(ev);
1678            }
1679        }
1680    }
1681
1682    // #90: clear cache first (which also resets last_mouse_pos to None),
1683    // then re-apply latest mouse pos so Resize+Mouse frames keep coords.
1684    if has_resize {
1685        clear_frame_layout_cache(state);
1686        // After clearing, re-walk events to restore the latest mouse pos
1687        // (process_ev already set it during collection, but
1688        // clear_frame_layout_cache wiped it).
1689        for ev in events.iter() {
1690            match ev {
1691                Event::Mouse(m) => {
1692                    state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
1693                }
1694                Event::FocusLost => {
1695                    state.layout_feedback.last_mouse_pos = None;
1696                }
1697                _ => {}
1698            }
1699        }
1700    }
1701
1702    Ok(true)
1703}
1704
1705struct FrameKernelResult {
1706    should_quit: bool,
1707    #[cfg(feature = "crossterm")]
1708    clipboard_text: Option<String>,
1709    #[cfg(feature = "crossterm")]
1710    should_copy_selection: bool,
1711}
1712
1713pub(crate) fn run_frame_kernel(
1714    buffer: &mut Buffer,
1715    state: &mut FrameState,
1716    config: &RunConfig,
1717    size: (u32, u32),
1718    events: Vec<event::Event>,
1719    is_real_terminal: bool,
1720    f: &mut impl FnMut(&mut context::Context),
1721) -> FrameKernelResult {
1722    let frame_start = Instant::now();
1723    let (w, h) = size;
1724    // Issue #236: reset the per-frame keymap registry before constructing
1725    // `Context`. Widgets that call `publish_keymap` accumulate fresh
1726    // entries; entries from the previous frame must not leak through
1727    // `named_states` persistence.
1728    clear_keymap_registry(state);
1729    // Issue #273: invalidate every `cached` region's persisted version key on a
1730    // resize. The real run loop also clears region keys via
1731    // `clear_frame_layout_cache` (driven by its `has_resize` flag), but the
1732    // headless `TestBackend` / `frame_owned` paths feed the kernel directly
1733    // and never run that flag, so we detect the resize event here too. This
1734    // keeps the "resize forces a cache miss for all cached regions" invariant
1735    // path-independent: a geometry change cannot be silently treated as a hit.
1736    // Cheap when unused — `region_versions` is empty for apps without `cached`.
1737    if !state.region_versions.is_empty() && events.iter().any(|e| matches!(e, Event::Resize(_, _)))
1738    {
1739        state.region_versions.clear();
1740    }
1741    let mut ctx = Context::new(events, w, h, state, config.theme);
1742    ctx.is_real_terminal = is_real_terminal;
1743    // Issue #264: surface the negotiated capability snapshot read-only. The
1744    // probe ran once at session enter (cached in a `OnceLock`); on a headless
1745    // backend it never ran, so we keep the conservative default rather than
1746    // forcing a probe that would block on stdin.
1747    #[cfg(feature = "crossterm")]
1748    if is_real_terminal {
1749        ctx.capabilities = terminal::capabilities();
1750    }
1751    ctx.set_scroll_speed(config.scroll_speed);
1752    ctx.widget_theme = config.widget_theme;
1753
1754    f(&mut ctx);
1755    ctx.process_focus_keys();
1756    ctx.render_notifications();
1757    ctx.emit_pending_tooltips();
1758
1759    debug_assert_eq!(
1760        ctx.rollback.overlay_depth, 0,
1761        "overlay depth must settle back to zero before layout"
1762    );
1763    debug_assert_eq!(
1764        ctx.rollback.group_count, 0,
1765        "group count must settle back to zero before layout"
1766    );
1767    debug_assert!(
1768        ctx.rollback.group_stack.is_empty(),
1769        "group stack must be empty before layout"
1770    );
1771    debug_assert!(
1772        ctx.rollback.text_color_stack.is_empty(),
1773        "text color stack must be empty before layout"
1774    );
1775    debug_assert!(
1776        ctx.pending_tooltips.is_empty(),
1777        "pending tooltips must be emitted before layout"
1778    );
1779
1780    if ctx.should_quit {
1781        state.hook_states = ctx.hook_states;
1782        state.named_states = ctx.named_states;
1783        state.keyed_states = ctx.keyed_states;
1784        // Issue #262: persist the partial-chord buffer on quit too (TestBackend
1785        // reuses `FrameState` across `render()` calls — same rationale as the
1786        // keyed-state reclaim).
1787        state.chord_states = ctx.chord;
1788        // Issue #248: hand the scheduler table back and GC abandoned timers.
1789        let mut scheduler = ctx.scheduler;
1790        scheduler.gc_untouched();
1791        state.scheduler = scheduler;
1792        // Issue #234: hand the async task registry back so in-flight tasks and
1793        // pending results survive to the next frame (TestBackend reuses
1794        // `FrameState` across `render()` calls — same rationale as the
1795        // scheduler reclaim).
1796        #[cfg(feature = "async")]
1797        {
1798            // Pump the registry every frame so a handle dropped on a frame that
1799            // calls neither spawn nor poll still has its cancellation processed
1800            // (and completed results moved in) before the round-trip.
1801            ctx.async_tasks.maintain();
1802            state.async_tasks = ctx.async_tasks;
1803        }
1804        state.screen_hook_map = ctx.screen_hook_map;
1805        state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1806        state.diagnostics.debug_layer = ctx.debug_layer;
1807        // Issue #268: persist any in-frame `set_inspector` change on quit too.
1808        state.diagnostics.inspector_mode = ctx.inspector_mode;
1809        // Issue #208 / #217: persist focus tracking state on quit so a later
1810        // resumed run starts in a sensible place. (Real TUI exits before
1811        // resuming, but tests reuse `FrameState` across calls.)
1812        state.focus.prev_focus_index = Some(ctx.focus_index);
1813        state.focus.focus_name_map_prev = ctx.focus_name_map;
1814        state.focus.pending_focus_name = ctx.pending_focus_name;
1815        // Issue #204: reclaim the 6 alloc-reuse buffers on the quit path
1816        // too. Real TUI exits ignore this, but TestBackend reuses the same
1817        // FrameState across `render()` calls — without the reclaim the next
1818        // frame's `Context::new` `mem::take`s an empty Vec and silently
1819        // reverts to v0.19 per-frame allocation.
1820        ctx.deferred_draws.clear();
1821        state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
1822        state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
1823        state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
1824        state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
1825        state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
1826        state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
1827        // Issue #273: reclaim the region-cache key buffers on quit too
1828        // (TestBackend reuses `FrameState` across `render()` calls — same
1829        // rationale as #204). The quit path skips `build_tree`, but the keys
1830        // recorded by any `cached` regions before `quit()` are still valid as
1831        // next frame's baseline.
1832        state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
1833        state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
1834        // Issue #150: reclaim `commands` on quit too (TestBackend reuses
1835        // `FrameState` across `render()` calls — same rationale as #204).
1836        // The Vec was never `build_tree`'d on the quit path so it may still
1837        // hold the recorded commands; clearing here drops them and keeps
1838        // capacity for the next frame.
1839        ctx.commands.clear();
1840        state.commands_buf = std::mem::take(&mut ctx.commands);
1841        #[cfg(feature = "crossterm")]
1842        let clipboard_text = ctx.clipboard_text.take();
1843        #[cfg(feature = "crossterm")]
1844        let should_copy_selection = false;
1845        return FrameKernelResult {
1846            should_quit: true,
1847            #[cfg(feature = "crossterm")]
1848            clipboard_text,
1849            #[cfg(feature = "crossterm")]
1850            should_copy_selection,
1851        };
1852    }
1853    state.focus.prev_modal_active = ctx.rollback.modal_active;
1854    state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
1855    state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
1856    #[cfg(feature = "crossterm")]
1857    let clipboard_text = ctx.clipboard_text.take();
1858    #[cfg(not(feature = "crossterm"))]
1859    let _clipboard_text = ctx.clipboard_text.take();
1860
1861    #[cfg(feature = "crossterm")]
1862    let mut should_copy_selection = false;
1863    #[cfg(feature = "crossterm")]
1864    for ev in &ctx.events {
1865        if let Event::Mouse(mouse) = ev {
1866            match mouse.kind {
1867                event::MouseKind::Down(event::MouseButton::Left) => {
1868                    state.selection.mouse_down(
1869                        mouse.x,
1870                        mouse.y,
1871                        &state.layout_feedback.prev_content_map,
1872                    );
1873                }
1874                event::MouseKind::Drag(event::MouseButton::Left) => {
1875                    state.selection.mouse_drag(
1876                        mouse.x,
1877                        mouse.y,
1878                        &state.layout_feedback.prev_content_map,
1879                    );
1880                }
1881                event::MouseKind::Up(event::MouseButton::Left) => {
1882                    should_copy_selection = state.selection.active;
1883                }
1884                _ => {}
1885            }
1886        }
1887    }
1888
1889    state.focus.focus_index = ctx.focus_index;
1890    state.focus.prev_focus_count = ctx.rollback.focus_count;
1891
1892    // Issue #150: `state.commands_buf` is swapped into `ctx.commands` on
1893    // entry (see `Context::new`), so the per-frame `Vec::new()` allocation
1894    // for the command list is amortized to one allocation across the
1895    // session. `build_tree` now takes `&mut Vec<Command>` and `drain`s it,
1896    // leaving the Vec at `len == 0` with capacity preserved. We reclaim
1897    // that Vec into `state.commands_buf` after the frame so the next call
1898    // to `Context::new` can pick it up via `mem::take` (matches the #204
1899    // pattern for the other six recycled buffers).
1900    let mut tree = layout::build_tree(&mut ctx.commands);
1901    let area = crate::rect::Rect::new(0, 0, w, h);
1902    layout::compute(&mut tree, area);
1903
1904    // Issue #155: reuse `state.frame_data` across frames. `collect_all` calls
1905    // `fd.clear()` first so the Vecs reset to len=0 with capacity preserved
1906    // from the prior frame, then refills them.
1907    let mut fd = std::mem::take(&mut state.frame_data);
1908    layout::collect_all(&tree, &mut fd);
1909    debug_assert_eq!(
1910        fd.scroll_infos.len(),
1911        fd.scroll_rects.len(),
1912        "scroll feedback vectors must stay aligned"
1913    );
1914    let raw_rects = std::mem::take(&mut fd.raw_draw_rects);
1915    state.layout_feedback.prev_scroll_infos = std::mem::take(&mut fd.scroll_infos);
1916    state.layout_feedback.prev_scroll_rects = std::mem::take(&mut fd.scroll_rects);
1917    state.layout_feedback.prev_hit_map = std::mem::take(&mut fd.hit_areas);
1918    state.layout_feedback.prev_group_rects = std::mem::take(&mut fd.group_rects);
1919    state.layout_feedback.prev_content_map = std::mem::take(&mut fd.content_areas);
1920    state.layout_feedback.prev_focus_rects = std::mem::take(&mut fd.focus_rects);
1921    state.layout_feedback.prev_focus_groups = std::mem::take(&mut fd.focus_groups);
1922    state.frame_data = fd;
1923    layout::render(&tree, buffer);
1924    // RAII guard ensuring the kitty clip frame is popped even if a raw-draw
1925    // callback panics — prevents stale scroll-clip state leaking into the
1926    // next region or subsequent frames.
1927    struct KittyClipGuard<'a>(&'a mut crate::buffer::Buffer);
1928    impl Drop for KittyClipGuard<'_> {
1929        fn drop(&mut self) {
1930            let _ = self.0.pop_kitty_clip();
1931        }
1932    }
1933    for rdr in raw_rects {
1934        if rdr.rect.width == 0 || rdr.rect.height == 0 {
1935            continue;
1936        }
1937        if let Some(cb) = ctx
1938            .deferred_draws
1939            .get_mut(rdr.draw_id)
1940            .and_then(|c| c.take())
1941        {
1942            buffer.push_clip(rdr.rect);
1943            buffer.push_kitty_clip(crate::buffer::KittyClipInfo {
1944                top_clip_rows: rdr.top_clip_rows,
1945                original_height: rdr.original_height,
1946            });
1947            {
1948                let guard = KittyClipGuard(buffer);
1949                // Explicit reborrow so the guard keeps ownership of the
1950                // outer `&mut Buffer` and pops on drop.
1951                cb(&mut *guard.0, rdr.rect);
1952                // Guard pops on drop at end of this scope.
1953            }
1954            buffer.pop_clip();
1955        }
1956    }
1957    debug_assert!(
1958        buffer.kitty_clip_info_stack.is_empty(),
1959        "kitty_clip_info_stack must be empty at end of frame"
1960    );
1961    state.hook_states = ctx.hook_states;
1962    state.named_states = ctx.named_states;
1963    // Issue #215: hand the keyed-state map back to FrameState so the next
1964    // frame can pick it up via `Context::new`. Mirrors the `named_states`
1965    // round-trip exactly.
1966    state.keyed_states = ctx.keyed_states;
1967    // Issue #262: hand the partial-chord buffer back so a chord spanning
1968    // multiple frames survives between them. Same round-trip as `keyed_states`.
1969    state.chord_states = ctx.chord;
1970    // Issue #248: hand the scheduler table back and GC any timer slot that was
1971    // not sampled this frame (mirrors the `named_states` round-trip lifecycle).
1972    let mut scheduler = ctx.scheduler;
1973    scheduler.gc_untouched();
1974    state.scheduler = scheduler;
1975    // Issue #234: hand the async task registry back so in-flight tasks and
1976    // pending results survive to the next frame (same round-trip lifecycle as
1977    // the scheduler table).
1978    #[cfg(feature = "async")]
1979    {
1980        // Pump the registry every frame (see the quit-path note): drains
1981        // completed results and honours handle-drop cancellations even on a
1982        // frame that called neither spawn nor poll.
1983        ctx.async_tasks.maintain();
1984        state.async_tasks = ctx.async_tasks;
1985    }
1986    state.screen_hook_map = ctx.screen_hook_map;
1987    state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1988    // Issue #201: persist any in-frame `set_debug_layer` change.
1989    state.diagnostics.debug_layer = ctx.debug_layer;
1990    // Issue #268: persist any in-frame `set_inspector` change.
1991    state.diagnostics.inspector_mode = ctx.inspector_mode;
1992    // Issue #208: remember the focus index that finished this frame so the
1993    // next frame can compute `Response::gained_focus` / `lost_focus`.
1994    state.focus.prev_focus_index = Some(ctx.focus_index);
1995    // Issue #217: swap the freshly-built focus name map into the previous
1996    // slot for next-frame resolution; carry forward any unresolved pending
1997    // name (deferred until the named widget exists).
1998    state.focus.focus_name_map_prev = ctx.focus_name_map;
1999    state.focus.pending_focus_name = ctx.pending_focus_name;
2000
2001    // Issue #204: reclaim the six per-frame `Vec`/`HashSet` allocations so the
2002    // next frame reuses the existing capacity instead of allocating fresh.
2003    // Frame-end invariants (asserted above at lines 1102–1121):
2004    //   - `rollback.group_stack` and `rollback.text_color_stack` are empty
2005    //   - `pending_tooltips` is empty
2006    // `context_stack` is asserted-empty by the consumers in `widgets_*`
2007    // modules (provider/use_context); on the rare panic-rollback path the
2008    // checkpoint truncates it back to the saved length, so we still
2009    // recover capacity.
2010    //
2011    // `deferred_draws`: most slots are emptied by the `take()` above, but
2012    // entries whose `RawDrawRect` had `width == 0 || height == 0` are
2013    // skipped at the loop guard and remain `Some(_)`. We explicitly
2014    // `clear()` to drop those callbacks here so they don't outlive the
2015    // frame; capacity is preserved. (Leaving them would not cause UB —
2016    // `Context::new` calls `.clear()` on the reclaimed Vec — but dropping
2017    // promptly matches user expectation that one-shot callbacks don't
2018    // survive past their frame.)
2019    //
2020    // `hovered_groups`: `clear()`-ed at the start of every frame inside
2021    // `build_hovered_groups`, so the existing entries are harmless to
2022    // reclaim with content; capacity is preserved.
2023    ctx.deferred_draws.clear();
2024    state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
2025    state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
2026    state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
2027    state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
2028    state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
2029    state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
2030    // Issue #273: this frame's recorded `cached` keys become next frame's
2031    // comparison baseline; the (now-stale) previous keys are reclaimed as the
2032    // recycled scratch buffer. Same alloc-reuse discipline as `commands_buf`.
2033    state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
2034    state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
2035    // Issue #150: reclaim the drained command Vec so the next `Context::new`
2036    // picks it up via `mem::take(&mut state.commands_buf)`. After
2037    // `build_tree(&mut ctx.commands)` the Vec is at `len == 0` with capacity
2038    // preserved; mirror the #204 reclamation pattern for the other six
2039    // per-frame buffers.
2040    state.commands_buf = std::mem::take(&mut ctx.commands);
2041
2042    let frame_time = frame_start.elapsed();
2043    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
2044    let frame_secs = frame_time.as_secs_f32();
2045    let inst_fps = if frame_secs > 0.0 {
2046        1.0 / frame_secs
2047    } else {
2048        0.0
2049    };
2050    state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
2051        inst_fps
2052    } else {
2053        (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
2054    };
2055    if state.diagnostics.debug_mode {
2056        layout::render_debug_overlay(
2057            &tree,
2058            buffer,
2059            frame_time_us,
2060            state.diagnostics.fps_ema,
2061            state.diagnostics.debug_layer,
2062        );
2063    }
2064    // Issue #268: render the devtools inspector panel (Ctrl+F12) on top of the
2065    // frame. Reuses the already-built tree and the focus snapshot threaded in
2066    // from `FrameState` (no new traversal beyond one focused-node DFS). The
2067    // name map was already swapped into `focus_name_map_prev` above, so it
2068    // reflects this frame's registrations.
2069    if state.diagnostics.inspector_mode {
2070        let focus = layout::InspectorFocus {
2071            focus_index: state.focus.focus_index,
2072            focus_count: state.focus.prev_focus_count,
2073            names: &state.focus.focus_name_map_prev,
2074            theme: &config.theme,
2075        };
2076        layout::render_inspector(&tree, buffer, &focus);
2077    }
2078
2079    FrameKernelResult {
2080        should_quit: false,
2081        #[cfg(feature = "crossterm")]
2082        clipboard_text,
2083        #[cfg(feature = "crossterm")]
2084        should_copy_selection,
2085    }
2086}
2087
2088fn run_frame(
2089    term: &mut impl Backend,
2090    state: &mut FrameState,
2091    config: &RunConfig,
2092    events: Vec<event::Event>,
2093    f: &mut impl FnMut(&mut context::Context),
2094) -> io::Result<bool> {
2095    let size = term.size();
2096    let kernel = run_frame_kernel(term.buffer_mut(), state, config, size, events, true, f);
2097    if kernel.should_quit {
2098        return Ok(false);
2099    }
2100
2101    #[cfg(feature = "crossterm")]
2102    if state.selection.active {
2103        terminal::apply_selection_overlay(
2104            term.buffer_mut(),
2105            &state.selection,
2106            &state.layout_feedback.prev_content_map,
2107        );
2108    }
2109    #[cfg(feature = "crossterm")]
2110    if kernel.should_copy_selection {
2111        let text = terminal::extract_selection_text(
2112            term.buffer_mut(),
2113            &state.selection,
2114            &state.layout_feedback.prev_content_map,
2115        );
2116        if !text.is_empty() {
2117            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
2118        }
2119        state.selection.clear();
2120    }
2121
2122    term.flush()?;
2123    #[cfg(feature = "crossterm")]
2124    if let Some(text) = kernel.clipboard_text {
2125        #[allow(clippy::print_stderr)]
2126        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
2127            eprintln!("[slt] failed to copy to clipboard: {e}");
2128        }
2129    }
2130    state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
2131
2132    Ok(true)
2133}
2134
2135#[cfg(feature = "crossterm")]
2136fn clear_frame_layout_cache(state: &mut FrameState) {
2137    state.layout_feedback.prev_hit_map.clear();
2138    state.layout_feedback.prev_group_rects.clear();
2139    state.layout_feedback.prev_content_map.clear();
2140    state.layout_feedback.prev_focus_rects.clear();
2141    state.layout_feedback.prev_focus_groups.clear();
2142    state.layout_feedback.prev_scroll_infos.clear();
2143    state.layout_feedback.prev_scroll_rects.clear();
2144    state.layout_feedback.last_mouse_pos = None;
2145    // Issue #273: a resize may change the geometry of every cached region, so
2146    // the previous frame's version keys are no longer a safe stability signal.
2147    // Dropping them forces a cache miss for all `cached` regions on the next
2148    // frame, matching the layout-feedback invalidation above.
2149    state.region_versions.clear();
2150}
2151
2152#[cfg(feature = "crossterm")]
2153fn is_ctrl_c(ev: &Event) -> bool {
2154    matches!(
2155        ev,
2156        Event::Key(event::KeyEvent {
2157            code: KeyCode::Char('c'),
2158            modifiers,
2159            kind: event::KeyEventKind::Press,
2160        }) if modifiers.contains(KeyModifiers::CONTROL)
2161    )
2162}
2163
2164#[cfg(feature = "crossterm")]
2165fn sleep_for_fps_cap(max_fps: Option<u32>, render_elapsed: Duration) {
2166    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
2167        let target = Duration::from_secs_f64(1.0 / fps as f64);
2168        if render_elapsed < target {
2169            std::thread::sleep(target - render_elapsed);
2170        }
2171    }
2172}
2173
2174#[cfg(all(test, feature = "crossterm"))]
2175mod run_loop_tests {
2176    //! Issue #201 regression tests for the run-loop F12 / Shift+F12
2177    //! keybinding handler. Exercises [`process_run_loop_event`] directly
2178    //! so we don't need a real crossterm event source.
2179    use super::*;
2180
2181    fn key(modifiers: event::KeyModifiers) -> Event {
2182        Event::Key(event::KeyEvent {
2183            code: KeyCode::F(12),
2184            kind: event::KeyEventKind::Press,
2185            modifiers,
2186        })
2187    }
2188
2189    #[test]
2190    fn plain_f12_toggles_debug_mode() {
2191        let mut state = FrameState::default();
2192        let mut has_resize = false;
2193        assert!(!state.diagnostics.debug_mode);
2194        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2195        assert!(state.diagnostics.debug_mode);
2196        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2197        assert!(!state.diagnostics.debug_mode);
2198    }
2199
2200    #[test]
2201    fn shift_f12_cycles_debug_layer_without_toggling_overlay() {
2202        let mut state = FrameState::default();
2203        let mut has_resize = false;
2204        // Default layer is `All`; debug overlay starts off.
2205        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2206        assert!(!state.diagnostics.debug_mode);
2207
2208        process_run_loop_event(
2209            &key(event::KeyModifiers::SHIFT),
2210            &mut state,
2211            &mut has_resize,
2212        );
2213        assert_eq!(state.diagnostics.debug_layer, DebugLayer::TopMost);
2214        // Cycling does not flip the on/off state.
2215        assert!(!state.diagnostics.debug_mode);
2216
2217        process_run_loop_event(
2218            &key(event::KeyModifiers::SHIFT),
2219            &mut state,
2220            &mut has_resize,
2221        );
2222        assert_eq!(state.diagnostics.debug_layer, DebugLayer::BaseOnly);
2223
2224        process_run_loop_event(
2225            &key(event::KeyModifiers::SHIFT),
2226            &mut state,
2227            &mut has_resize,
2228        );
2229        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2230    }
2231
2232    #[test]
2233    fn shift_f12_does_not_also_toggle_overlay() {
2234        // Regression for the modifier disambiguation: pre-fix, the F12
2235        // arm matched `..` modifiers so Shift+F12 would both cycle the
2236        // layer AND toggle the overlay on the same press.
2237        let mut state = FrameState::default();
2238        let mut has_resize = false;
2239        let before = state.diagnostics.debug_mode;
2240        process_run_loop_event(
2241            &key(event::KeyModifiers::SHIFT),
2242            &mut state,
2243            &mut has_resize,
2244        );
2245        assert_eq!(
2246            state.diagnostics.debug_mode, before,
2247            "Shift+F12 must not flip the on/off toggle"
2248        );
2249    }
2250
2251    #[test]
2252    fn plain_f12_does_not_cycle_layer() {
2253        // Symmetric guard: pressing plain F12 must not change the active
2254        // layer, only the on/off flag.
2255        let mut state = FrameState::default();
2256        let mut has_resize = false;
2257        let before = state.diagnostics.debug_layer;
2258        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2259        assert_eq!(state.diagnostics.debug_layer, before);
2260    }
2261
2262    // ── Issue #268: Ctrl+F12 devtools inspector toggle ───────────────────
2263
2264    #[test]
2265    fn ctrl_f12_toggles_inspector_independently() {
2266        let mut state = FrameState::default();
2267        let mut has_resize = false;
2268        assert!(!state.diagnostics.inspector_mode);
2269
2270        // Ctrl+F12 flips the inspector without touching debug overlay state.
2271        process_run_loop_event(
2272            &key(event::KeyModifiers::CONTROL),
2273            &mut state,
2274            &mut has_resize,
2275        );
2276        assert!(state.diagnostics.inspector_mode);
2277        assert!(
2278            !state.diagnostics.debug_mode,
2279            "Ctrl+F12 must not toggle the F12 outline overlay"
2280        );
2281        assert_eq!(
2282            state.diagnostics.debug_layer,
2283            DebugLayer::All,
2284            "Ctrl+F12 must not cycle the debug layer"
2285        );
2286
2287        // A second Ctrl+F12 toggles it back off.
2288        process_run_loop_event(
2289            &key(event::KeyModifiers::CONTROL),
2290            &mut state,
2291            &mut has_resize,
2292        );
2293        assert!(!state.diagnostics.inspector_mode);
2294    }
2295
2296    #[test]
2297    fn plain_and_shift_f12_do_not_touch_inspector() {
2298        let mut state = FrameState::default();
2299        let mut has_resize = false;
2300        // Plain F12 (overlay toggle) leaves the inspector alone.
2301        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2302        assert!(state.diagnostics.debug_mode);
2303        assert!(!state.diagnostics.inspector_mode);
2304        // Shift+F12 (layer cycle) also leaves the inspector alone.
2305        process_run_loop_event(
2306            &key(event::KeyModifiers::SHIFT),
2307            &mut state,
2308            &mut has_resize,
2309        );
2310        assert!(!state.diagnostics.inspector_mode);
2311    }
2312
2313    // ── Issue #263: RunConfig::handle_suspend ────────────────────────────
2314
2315    #[test]
2316    fn handle_suspend_defaults_to_true() {
2317        assert!(RunConfig::default().handle_suspend);
2318    }
2319
2320    #[test]
2321    fn handle_suspend_builder_opts_out() {
2322        let cfg = RunConfig::default().handle_suspend(false);
2323        assert!(!cfg.handle_suspend);
2324    }
2325
2326    #[test]
2327    fn handle_suspend_builder_is_independent_of_ctrl_c() {
2328        // Toggling suspend must not perturb the unrelated Ctrl+C toggle.
2329        let cfg = RunConfig::default()
2330            .handle_ctrl_c(false)
2331            .handle_suspend(false);
2332        assert!(!cfg.handle_ctrl_c);
2333        assert!(!cfg.handle_suspend);
2334
2335        let cfg = RunConfig::default().handle_suspend(true);
2336        assert!(cfg.handle_suspend);
2337        assert!(cfg.handle_ctrl_c, "Ctrl+C default preserved");
2338    }
2339
2340    /// End-to-end test of the real signal-delivery wiring: install the
2341    /// handler, deliver a real `SIGCONT` through signal-hook's registry +
2342    /// background thread, then drop the guard and confirm it closes the
2343    /// registration and joins the thread without hanging or panicking.
2344    ///
2345    /// `SIGCONT`'s default disposition is "continue", so it is safe to raise on
2346    /// the running test process — unlike `SIGTSTP`, which would stop the test
2347    /// runner. The suspend (`SIGTSTP`) sequence itself is covered hermetically
2348    /// by the `write_suspend_sequence` unit tests in `terminal`.
2349    #[cfg(unix)]
2350    #[test]
2351    fn suspend_handler_installs_delivers_and_tears_down() {
2352        // In constrained sandboxes signal registration can fail; if so the
2353        // wiring under test cannot be exercised, so skip rather than flake.
2354        let Ok(guard) = install_suspend_handler(terminal::test_session_snapshot()) else {
2355            return;
2356        };
2357
2358        // Deliver a real SIGCONT; the background thread must drain it. With no
2359        // prior SIGTSTP the handler's `has_terminal` guard makes this a no-op
2360        // re-enter (idempotency), which is exactly what we want to verify does
2361        // not corrupt state or crash the thread.
2362        let _ = signal_hook::low_level::raise(signal_hook::consts::SIGCONT);
2363        std::thread::sleep(Duration::from_millis(50));
2364
2365        // Dropping the guard closes the registration and joins the thread.
2366        // If `Handle::close` failed to wake `Signals::forever`, this hangs and
2367        // the test times out — a real regression signal.
2368        drop(guard);
2369    }
2370}