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: the shipping library is 100% safe. Unit tests are excused only
23// because edition 2024 made `std::env::set_var`/`remove_var` `unsafe`, and a
24// few `#[cfg(test)]` terminal-detection helpers must mutate process env (they
25// serialize via a mutex). `forbid` stays on for every non-test build.
26#![cfg_attr(not(test), forbid(unsafe_code))]
27#![cfg_attr(test, deny(unsafe_code))]
28// Cross-target lints (rustdoc links, rust-2018-idioms) are configured
29// centrally in [workspace.lints] and applied via `[lints] workspace = true` in
30// Cargo.toml. The lints below stay here as lib-only inner attributes on
31// purpose: `[lints]` is package-scoped and would otherwise fire on the
32// package's example binaries and integration tests, which legitimately expose
33// undocumented `pub` helpers, print to stdout, and unwrap. The cfg-conditional
34// unsafe_code policy above likewise can't live in workspace.lints.
35#![warn(missing_docs)]
36#![warn(unreachable_pub)]
37#![deny(clippy::unwrap_in_result)]
38#![warn(clippy::unwrap_used)]
39#![warn(clippy::dbg_macro)]
40#![warn(clippy::print_stdout)]
41#![warn(clippy::print_stderr)]
42#![cfg_attr(docsrs, feature(doc_cfg))]
43
44//! # SLT — Super Light TUI
45//!
46//! Immediate-mode terminal UI for Rust. Small core. Zero `unsafe`.
47//!
48//! SLT gives you an egui-style API for terminals: your closure runs each frame,
49//! you describe your UI, and SLT handles layout, diffing, and rendering.
50//!
51//! ## Quick Start
52//!
53//! ```no_run
54//! fn main() -> std::io::Result<()> {
55//!     slt::run(|ui| {
56//!         ui.text("hello, world");
57//!     })
58//! }
59//! ```
60//!
61//! ## Features
62//!
63//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
64//! - **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
65//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
66//! - **Mouse** — click, hover, drag-to-scroll
67//! - **Focus** — automatic Tab/Shift+Tab cycling
68//! - **Theming** — 10 presets, semantic tokens (`ThemeColor`), spacing scale, contrast helpers
69//! - **Animation** — tween and spring primitives with 9 easing functions
70//! - **Inline mode** — render below your prompt, no alternate screen
71//! - **Async** — optional tokio integration via `async` feature
72//! - **Layout debugger** — F12 to visualize container bounds
73//!
74//! ## Feature Flags
75//!
76//! | Flag | Description |
77//! |------|-------------|
78//! | `crossterm` | Built-in terminal runtime (`run`, `run_inline`, clipboard query helpers). Enabled by default. |
79//! | `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. |
80//! | `async` | Enable `run_async()` with tokio channel-based message passing |
81//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
82//! | `image` | Enable image-loading helpers for terminal image widgets |
83//! | `qrcode` | Enable `ui.qr_code(...)` |
84//! | `syntax` / `syntax-*` | Enable tree-sitter syntax highlighting |
85//!
86//! ## Learn More
87//!
88//! - Guides index: <https://github.com/subinium/SuperLightTUI/blob/main/docs/README.md>
89//! - Quick start: <https://github.com/subinium/SuperLightTUI/blob/main/docs/QUICK_START.md>
90//! - Backends and run loops: <https://github.com/subinium/SuperLightTUI/blob/main/docs/BACKENDS.md>
91//! - Testing: <https://github.com/subinium/SuperLightTUI/blob/main/docs/TESTING.md>
92//! - Debugging: <https://github.com/subinium/SuperLightTUI/blob/main/docs/DEBUGGING.md>
93
94/// Animation primitives: tween, spring, keyframes, sequence, stagger.
95pub mod anim;
96/// Double-buffered cell grid with clip stack and diff tracking.
97pub mod buffer;
98/// Terminal cell representation.
99pub mod cell;
100/// Chart and data visualization widgets.
101pub mod chart;
102/// UI context, container builder, and widget rendering.
103pub mod context;
104/// Input events (keyboard, mouse, resize, paste).
105pub mod event;
106/// Half-block image rendering.
107pub mod halfblock;
108#[cfg(feature = "crossterm")]
109mod iterm;
110/// Keyboard shortcut mapping.
111pub mod keymap;
112/// Flexbox layout engine and command tree.
113pub mod layout;
114/// Color palettes (Tailwind-style).
115pub mod palette;
116/// Rectangular region type used throughout SLT layout.
117pub mod rect;
118#[cfg(feature = "crossterm")]
119mod sixel;
120/// Styling: colors, borders, padding, margins, themes, constraints.
121pub mod style;
122/// Tree-sitter syntax highlighting integration.
123pub mod syntax;
124#[cfg(feature = "crossterm")]
125mod terminal;
126/// Headless test utilities for unit-testing TUI closures.
127pub mod test_utils;
128/// Widget state types (list, table, input, select, etc.).
129pub mod widgets;
130
131use std::io;
132#[cfg(feature = "crossterm")]
133use std::io::IsTerminal;
134use std::time::{Duration, Instant};
135
136/// Re-export of the [`crossterm`] crate (issue #278) so callers can name the
137/// input type accepted by [`event::from_crossterm`] without depending on — and
138/// risking a version mismatch against — crossterm directly.
139#[cfg(feature = "crossterm")]
140#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
141pub use crossterm;
142#[doc(hidden)]
143pub use layout::__bench_dim_buffer_around;
144#[doc(hidden)]
145pub use layout::__bench_wrap_segments;
146#[cfg(feature = "crossterm")]
147#[doc(hidden)]
148pub use terminal::__bench_flush_buffer_diff;
149#[cfg(feature = "crossterm")]
150#[doc(hidden)]
151pub use terminal::__bench_flush_buffer_diff_mut;
152#[cfg(feature = "crossterm")]
153#[doc(hidden)]
154pub use terminal::__bench_flush_buffer_diff_mut_with_buf;
155#[cfg(feature = "crossterm")]
156#[doc(hidden)]
157pub use terminal::__bench_flush_kitty;
158#[cfg(feature = "crossterm")]
159#[doc(hidden)]
160pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture};
161#[cfg(feature = "crossterm")]
162#[doc(hidden)]
163pub use terminal::{__BenchSprixelFixture, __bench_flush_sprixels, __bench_new_sprixel_fixture};
164/// Runtime terminal capability probe (issue #264): read-only [`Capabilities`]
165/// snapshot plus the [`Blitter`] ladder it drives. Diagnostics-only — image
166/// rendering routes through the ladder automatically.
167#[cfg(feature = "crossterm")]
168#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
169pub use terminal::{Blitter, BlitterSupport, Capabilities, capabilities};
170#[cfg(feature = "crossterm")]
171#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
172pub use terminal::{ColorScheme, detect_color_scheme, read_clipboard};
173/// Concrete crossterm terminal backends, exposed (issue #278) so external
174/// integrations can drive SLT's render pipeline with their own event loop —
175/// pair with [`event::from_crossterm`]. Most apps should use [`run`] /
176/// [`run_inline`], which build and drive these internally.
177#[cfg(feature = "crossterm")]
178#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
179pub use terminal::{InlineTerminal, Terminal};
180
181pub use crate::test_utils::{EventBuilder, FrameRecord, TestBackend, TestSequence};
182/// PTY/sink test harness for end-to-end escape-byte assertions (issue #274).
183/// Gated behind the dev-only `pty-test` feature; absent from default builds.
184#[cfg(feature = "pty-test")]
185#[cfg_attr(docsrs, doc(cfg(feature = "pty-test")))]
186pub use crate::test_utils::{PtyBackend, PtyFrame};
187// Animation primitives (builder types) are re-exported at crate root for
188// ergonomic `use slt::{Tween, Spring, ...}`. The easing functions and `lerp`
189// live under `slt::anim::*` — they are rarely imported in isolation and
190// keeping them out of the root shrinks the top-level surface.
191pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
192pub use buffer::Buffer;
193pub use cell::Cell;
194// Chart user-facing types at crate root; internals (`ChartRenderer`,
195// `RenderedLine`, `ColorSpan`, `DatasetEntry`, `HistogramBuilder`,
196// `GraphType`, `Axis`) live under `slt::chart::*`.
197pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
198pub use context::{
199    Anchor, Bar, BarChartConfig, BarDirection, BarGroup, Breadcrumb, CanvasContext, CodeBlock,
200    ContainerBuilder, Context, Gauge, GutterOpts, LineGauge, Memo, Response, State, TreemapItem,
201    Widget,
202};
203// Issue #234: opaque handle from `Context::spawn`, gated behind `async`.
204#[cfg(feature = "async")]
205#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
206pub use context::{TaskHandle, TaskOutcome};
207pub use event::{
208    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, ModifierKey, MouseButton, MouseEvent,
209    MouseKind,
210};
211pub use halfblock::HalfBlockImage;
212pub use keymap::{Binding, KeyMap, PublishedKeymap, WidgetKeyHelp};
213pub use layout::Direction;
214pub use palette::Palette;
215pub use rect::Rect;
216#[cfg(feature = "theme-watch")]
217#[cfg_attr(docsrs, doc(cfg(feature = "theme-watch")))]
218pub use style::ThemeWatcher;
219pub use style::{
220    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, ColorParseError, Constraints,
221    ContainerStyle, HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, SyntaxPalette,
222    Theme, ThemeBuilder, ThemeColor, UnderlineStyle, WidgetColors, WidgetTheme, WidthSpec,
223};
224#[cfg(feature = "serde")]
225#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
226pub use style::{ThemeFile, ThemeLoadError};
227#[cfg(feature = "async")]
228#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
229pub use widgets::AsyncValidation;
230pub use widgets::validators;
231pub use widgets::{
232    AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalDate, CalendarSelect,
233    CalendarState, ChordState, ColorPickerState, CommandPaletteState, ContextItem,
234    DEFAULT_CHORD_TIMEOUT_TICKS, DirectoryTreeState, FileEntry, FilePickerScanError,
235    FilePickerScanOperation, FilePickerScanStatus, FilePickerState, FormField, FormState,
236    GaugeResponse, GridColumn, GutterResponse, HighlightRange, ListResponse, ListState, ModeState,
237    MultiSelectState, NumberInputState, PaginatorState, PaginatorStyle, PaletteCommand, PickerMode,
238    RadioState, RichLogEntry, RichLogState, SchedulerState, ScreenState, ScrollState, SelectState,
239    SliderOpts, SpinnerPreset, SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput,
240    StreamingMarkdownState, StreamingTextState, TableColumn, TableState, TabsState, TextInputState,
241    TextareaState, ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState,
242    Trend, ValidateTrigger, Validator,
243};
244
245/// Rendering backend for SLT.
246///
247/// Implement this trait to render SLT UIs to custom targets — alternative
248/// terminals, GUI embeds, test harnesses, WASM canvas, etc.
249///
250/// The built-in terminal backend ([`run()`], [`run_with()`]) handles setup,
251/// teardown, and event polling automatically. For custom backends, pair this
252/// trait with [`AppState`] and [`frame()`] to drive the render loop yourself.
253///
254/// # Example
255///
256/// ```ignore
257/// use slt::{Backend, AppState, Buffer, Rect, RunConfig, Context, Event};
258///
259/// struct MyBackend {
260///     buffer: Buffer,
261/// }
262///
263/// impl Backend for MyBackend {
264///     fn size(&self) -> (u32, u32) {
265///         (self.buffer.area.width, self.buffer.area.height)
266///     }
267///     fn buffer_mut(&mut self) -> &mut Buffer {
268///         &mut self.buffer
269///     }
270///     fn flush(&mut self) -> std::io::Result<()> {
271///         // Render self.buffer to your target
272///         Ok(())
273///     }
274/// }
275///
276/// fn main() -> std::io::Result<()> {
277///     let mut backend = MyBackend {
278///         buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
279///     };
280///     let mut state = AppState::new();
281///     let config = RunConfig::default();
282///
283///     loop {
284///         let events: Vec<Event> = vec![]; // Collect your own events
285///         if !slt::frame(&mut backend, &mut state, &config, &events, &mut |ui| {
286///             ui.text("Hello from custom backend!");
287///         })? {
288///             break;
289///         }
290///     }
291///     Ok(())
292/// }
293/// ```
294pub trait Backend {
295    /// Returns the current display size as `(width, height)` in cells.
296    fn size(&self) -> (u32, u32);
297
298    /// Returns a mutable reference to the display buffer.
299    ///
300    /// SLT writes the UI into this buffer each frame. After [`frame()`]
301    /// returns, call [`flush()`](Backend::flush) to present the result.
302    fn buffer_mut(&mut self) -> &mut Buffer;
303
304    /// Flush the buffer contents to the display.
305    ///
306    /// Called automatically at the end of each [`frame()`] call. Implementations
307    /// should present the current buffer to the user — by writing ANSI escapes,
308    /// drawing to a canvas, updating a texture, etc.
309    fn flush(&mut self) -> io::Result<()>;
310
311    /// Returns whether this backend owns a real terminal-style session.
312    ///
313    /// Custom backends should keep the default `false`, which prevents process
314    /// stdout clipboard writes, capability probes, and terminal panic recovery.
315    /// The built-in [`Terminal`] and [`InlineTerminal`] backends opt in.
316    #[doc(hidden)]
317    fn owns_terminal_session(&self) -> bool {
318        false
319    }
320}
321
322/// Opaque per-session state that persists between frames.
323///
324/// Tracks focus, scroll positions, hook state, and other frame-to-frame data.
325/// Create with [`AppState::new()`] and pass to [`frame()`] each iteration.
326///
327/// # Example
328///
329/// ```ignore
330/// let mut state = slt::AppState::new();
331/// // state is passed to slt::frame() in your render loop
332/// ```
333pub struct AppState {
334    pub(crate) inner: FrameState,
335}
336
337impl AppState {
338    /// Create a new empty application state.
339    pub fn new() -> Self {
340        Self {
341            inner: FrameState::default(),
342        }
343    }
344
345    /// Returns the current frame tick count (increments each frame).
346    pub fn tick(&self) -> u64 {
347        self.inner.diagnostics.tick
348    }
349
350    /// Returns the smoothed FPS estimate (exponential moving average).
351    pub fn fps_f64(&self) -> f64 {
352        f64::from(self.inner.diagnostics.fps_ema)
353    }
354
355    /// Deprecated `f32` alias for [`fps_f64`](Self::fps_f64).
356    #[deprecated(
357        since = "0.22.2",
358        note = "use AppState::fps_f64() to keep public float APIs on f64"
359    )]
360    pub fn fps(&self) -> f32 {
361        self.inner.diagnostics.fps_ema
362    }
363
364    /// Toggle the debug overlay (same as pressing F12).
365    pub fn set_debug(&mut self, enabled: bool) {
366        self.inner.diagnostics.debug_mode = enabled;
367    }
368}
369
370impl Default for AppState {
371    fn default() -> Self {
372        Self::new()
373    }
374}
375
376/// Process a single UI frame with a custom [`Backend`].
377///
378/// This is the low-level entry point for custom backends. For standard terminal
379/// usage, prefer [`run()`] or [`run_with()`] which handle the event loop,
380/// terminal setup, and teardown automatically.
381///
382/// Returns `Ok(true)` to continue, `Ok(false)` when [`Context::quit()`] was
383/// called.
384///
385/// # Arguments
386///
387/// * `backend` — Your [`Backend`] implementation
388/// * `state` — Persistent [`AppState`] (reuse across frames)
389/// * `config` — [`RunConfig`] (theme, tick rate, etc.)
390/// * `events` — Input events for this frame (keyboard, mouse, resize)
391/// * `f` — Your UI closure, called once per frame
392///
393/// Build a fresh event slice each frame in your outer loop, then pass it here.
394/// `frame()` reads from that slice but does not own your event source.
395/// Reuse the same [`AppState`] for the lifetime of the session.
396///
397/// # Example
398///
399/// ```ignore
400/// let keep_going = slt::frame(
401///     &mut my_backend,
402///     &mut state,
403///     &config,
404///     &events,
405///     &mut |ui| { ui.text("hello"); },
406/// )?;
407/// ```
408pub fn frame(
409    backend: &mut impl Backend,
410    state: &mut AppState,
411    config: &RunConfig,
412    events: &[Event],
413    f: &mut impl FnMut(&mut Context),
414) -> io::Result<bool> {
415    frame_owned(backend, state, config, events.to_vec(), f)
416}
417
418/// Process a single UI frame, taking ownership of the events `Vec` (zero-copy).
419///
420/// Like [`frame`], but accepts an owned `Vec<Event>` to avoid the `to_vec()`
421/// copy `frame` performs internally. Prefer this in high-frequency custom
422/// render loops where you already own the event buffer.
423///
424/// # Example
425///
426/// ```ignore
427/// let events: Vec<slt::Event> = collect_events();
428/// let keep_going = slt::frame_owned(
429///     &mut my_backend,
430///     &mut state,
431///     &config,
432///     events,
433///     &mut |ui| { ui.text("hello"); },
434/// )?;
435/// ```
436pub fn frame_owned(
437    backend: &mut impl Backend,
438    state: &mut AppState,
439    config: &RunConfig,
440    events: Vec<Event>,
441    f: &mut impl FnMut(&mut Context),
442) -> io::Result<bool> {
443    let terminal_side_effects = backend.owns_terminal_session();
444    run_frame(
445        backend,
446        &mut state.inner,
447        config,
448        events,
449        terminal_side_effects,
450        f,
451    )
452}
453
454#[cfg(feature = "crossterm")]
455type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;
456
457#[cfg(feature = "crossterm")]
458struct PanicHookState {
459    active_sessions: usize,
460    installed: bool,
461    previous: Option<PanicHook>,
462}
463
464#[cfg(feature = "crossterm")]
465static PANIC_HOOK_STATE: std::sync::Mutex<PanicHookState> = std::sync::Mutex::new(PanicHookState {
466    active_sessions: 0,
467    installed: false,
468    previous: None,
469});
470
471#[cfg(feature = "crossterm")]
472struct PanicHookGuard;
473
474#[cfg(feature = "crossterm")]
475impl Drop for PanicHookGuard {
476    fn drop(&mut self) {
477        let mut state = PANIC_HOOK_STATE
478            .lock()
479            .unwrap_or_else(std::sync::PoisonError::into_inner);
480        state.active_sessions = state.active_sessions.saturating_sub(1);
481
482        // `take_hook` panics while the current thread is unwinding. In that
483        // case the dispatcher remains installed but inactive and forwards
484        // directly to the previous hook. A later normal session restores it.
485        if state.active_sessions == 0 && state.installed && !std::thread::panicking() {
486            let dispatcher = std::panic::take_hook();
487            drop(dispatcher);
488            if let Some(previous) = state.previous.take() {
489                std::panic::set_hook(previous);
490            }
491            state.installed = false;
492        }
493    }
494}
495
496#[allow(clippy::print_stderr)]
497#[cfg(feature = "crossterm")]
498fn slt_panic_hook(panic_info: &std::panic::PanicHookInfo<'_>) {
499    let active = PANIC_HOOK_STATE
500        .lock()
501        .unwrap_or_else(std::sync::PoisonError::into_inner)
502        .active_sessions
503        > 0;
504    let restored = active && terminal::cleanup_after_panic();
505
506    if restored {
507        eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
508        if let Some(location) = panic_info.location() {
509            eprintln!(
510                "\x1b[90m{}:{}:{}\x1b[0m",
511                location.file(),
512                location.line(),
513                location.column()
514            );
515        }
516        if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
517            eprintln!("\x1b[1m{msg}\x1b[0m");
518        } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
519            eprintln!("\x1b[1m{msg}\x1b[0m");
520        }
521        eprintln!(
522            "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
523        );
524    }
525
526    let state = PANIC_HOOK_STATE
527        .lock()
528        .unwrap_or_else(std::sync::PoisonError::into_inner);
529    if let Some(previous) = state.previous.as_ref() {
530        previous(panic_info);
531    }
532}
533
534#[cfg(feature = "crossterm")]
535fn install_panic_hook() -> PanicHookGuard {
536    let mut state = PANIC_HOOK_STATE
537        .lock()
538        .unwrap_or_else(std::sync::PoisonError::into_inner);
539    if !state.installed {
540        state.previous = Some(std::panic::take_hook());
541        std::panic::set_hook(Box::new(slt_panic_hook));
542        state.installed = true;
543    }
544    state.active_sessions = state.active_sessions.saturating_add(1);
545    PanicHookGuard
546}
547
548#[cfg(feature = "crossterm")]
549fn with_session_panic_hook<T>(f: impl FnOnce() -> T) -> T {
550    let guard = install_panic_hook();
551    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
552    drop(guard);
553    match result {
554        Ok(value) => value,
555        Err(payload) => std::panic::resume_unwind(payload),
556    }
557}
558
559/// RAII guard owning the unix suspend/resume (`SIGTSTP`/`SIGCONT`) handler
560/// thread for the duration of a run loop (issue #263).
561///
562/// Dropping the guard closes the `signal-hook` registration so the background
563/// thread breaks out of `Signals::forever()` and is joined, leaving no signal
564/// handlers installed after the loop exits.
565#[cfg(all(feature = "crossterm", unix))]
566struct SuspendGuard {
567    handle: signal_hook::iterator::Handle,
568    thread: Option<std::thread::JoinHandle<()>>,
569}
570
571#[cfg(all(feature = "crossterm", unix))]
572impl Drop for SuspendGuard {
573    fn drop(&mut self) {
574        // Closing the handle wakes `Signals::forever()` so the thread returns.
575        self.handle.close();
576        if let Some(thread) = self.thread.take() {
577            let _ = thread.join();
578        }
579    }
580}
581
582/// Install the unix job-control suspend/resume handler for one run loop.
583///
584/// Spawns a `signal-hook` background thread that, on `SIGTSTP`, restores the
585/// terminal and re-raises the default-disposition stop, and on `SIGCONT`
586/// re-enters the session and flags a full redraw. Uses only signal-hook's safe
587/// API, preserving `#![forbid(unsafe_code)]`. Returns the guard that owns the
588/// thread; dropping it uninstalls the handler.
589#[cfg(all(feature = "crossterm", unix))]
590fn install_suspend_handler(snapshot: terminal::SessionSnapshot) -> io::Result<SuspendGuard> {
591    use signal_hook::consts::{SIGCONT, SIGTSTP};
592    use signal_hook::iterator::Signals;
593
594    let mut signals = Signals::new([SIGTSTP, SIGCONT])?;
595    let handle = signals.handle();
596    let thread = std::thread::Builder::new()
597        .name("slt-suspend".to_string())
598        .spawn(move || {
599            // `has_terminal` tracks whether the TUI session is currently
600            // entered, so a stray SIGCONT (no prior SIGTSTP) or a repeated
601            // SIGTSTP cannot double-leave / double-enter (idempotency).
602            let mut has_terminal = true;
603            for signal in &mut signals {
604                match signal {
605                    SIGTSTP if has_terminal => {
606                        terminal::suspend_to_shell(&snapshot);
607                        has_terminal = false;
608                        // Genuinely stop the process now that the terminal is
609                        // restored; control returns to the shell.
610                        let _ = signal_hook::low_level::emulate_default_handler(SIGTSTP);
611                    }
612                    SIGCONT if !has_terminal => {
613                        terminal::resume_from_shell(&snapshot);
614                        has_terminal = true;
615                    }
616                    // Repeated SIGTSTP/SIGCONT or out-of-order delivery is a
617                    // no-op — the `has_terminal` guard keeps enter/leave
618                    // balanced (idempotency, issue #263).
619                    _ => {}
620                }
621            }
622        })?;
623
624    Ok(SuspendGuard {
625        handle,
626        thread: Some(thread),
627    })
628}
629
630#[cfg(all(feature = "crossterm", unix))]
631fn suspend_current_session(snapshot: terminal::SessionSnapshot) -> io::Result<()> {
632    terminal::suspend_to_shell(&snapshot);
633    let result = signal_hook::low_level::emulate_default_handler(signal_hook::consts::SIGTSTP);
634    terminal::resume_from_shell(&snapshot);
635    result?;
636    Ok(())
637}
638
639/// Consume the pending full-redraw request raised by a `SIGCONT` resume and, if
640/// set, clear + repaint the whole frame (issue #263).
641///
642/// Called at the top of each run-loop iteration. No-op on non-unix builds.
643#[cfg(all(feature = "crossterm", unix))]
644fn drain_resume_redraw(handle_resize: &mut impl FnMut() -> io::Result<()>) -> io::Result<()> {
645    use std::sync::atomic::Ordering;
646    if terminal::NEEDS_FULL_REDRAW.swap(false, Ordering::SeqCst) {
647        handle_resize()?;
648    }
649    Ok(())
650}
651
652/// Configuration for a TUI run loop.
653///
654/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
655/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
656/// This type is `#[non_exhaustive]`, so prefer builder methods instead of struct literals.
657///
658/// # Example
659///
660/// ```no_run
661/// use slt::{RunConfig, Theme};
662/// use std::time::Duration;
663///
664/// let config = RunConfig::default()
665///     .tick_rate(Duration::from_millis(50))
666///     .mouse(true)
667///     .theme(Theme::light())
668///     .max_fps(60);
669/// ```
670#[non_exhaustive]
671#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
672pub struct RunConfig {
673    /// How long to wait for input before triggering a tick with no events.
674    ///
675    /// Lower values give smoother animations at the cost of more CPU usage.
676    /// Defaults to 16ms (60fps).
677    pub tick_rate: Duration,
678    /// Whether to enable mouse event reporting.
679    ///
680    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
681    /// Defaults to `false`.
682    pub mouse: bool,
683    /// Whether to enable the Kitty keyboard protocol for enhanced input.
684    ///
685    /// When `true`, enables disambiguated key events, key release events,
686    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
687    /// Terminals that don't support it silently ignore the request.
688    /// Defaults to `false`.
689    pub kitty_keyboard: bool,
690    /// Whether to request modifier-only key events (bare Ctrl/Shift/Alt/Super
691    /// presses and releases, with no accompanying character).
692    ///
693    /// Has **no effect** unless [`kitty_keyboard`](Self::kitty_keyboard) is also
694    /// `true`: it OR-es the Kitty `REPORT_ALL_KEYS_AS_ESCAPE_CODES`
695    /// progressive-enhancement flag into the pushed flag set. On supporting
696    /// terminals (kitty, Ghostty, WezTerm) this makes bare modifier presses
697    /// arrive as [`KeyCode::Modifier`] events; other terminals never emit them.
698    ///
699    /// Kept opt-in to avoid flooding apps with modifier events they don't want.
700    /// Defaults to `false`.
701    ///
702    /// Since 0.21.0.
703    pub report_all_keys: bool,
704    /// The color theme applied to all widgets automatically.
705    ///
706    /// Defaults to [`Theme::dark()`].
707    pub theme: Theme,
708    /// Color depth override.
709    ///
710    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
711    /// variables. Set explicitly to force a specific color depth regardless
712    /// of terminal capabilities.
713    pub color_depth: Option<ColorDepth>,
714    /// Optional maximum frame rate.
715    ///
716    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
717    /// loop iteration to target that frame time.
718    pub max_fps: Option<u32>,
719    /// Lines scrolled per mouse scroll event. Defaults to 1.
720    pub scroll_speed: u32,
721    /// Optional terminal window title (set via OSC 2).
722    pub title: Option<String>,
723    /// Default colors applied to all instances of each widget type.
724    ///
725    /// Per-callsite `_colored()` overrides still take precedence.
726    /// Defaults to all-`None` (use theme colors).
727    pub widget_theme: style::WidgetTheme,
728    /// Whether the runtime intercepts Ctrl+C and exits the loop cleanly.
729    ///
730    /// When `true` (the default), Ctrl+C is treated as a quit signal —
731    /// matching the v0.19 behavior. When `false`, the Ctrl+C key event flows
732    /// through to the frame closure as a regular [`Event::Key`], matching
733    /// RataTUI's raw-mode semantics. The user is then responsible for
734    /// deciding whether to call [`Context::quit`] or treat it as any other
735    /// shortcut (e.g. clear input, cancel current operation).
736    ///
737    /// Set this to `false` when migrating code from RataTUI that already
738    /// handles Ctrl+C explicitly, or when implementing a graceful-shutdown
739    /// prompt (e.g. "save unsaved changes?").
740    ///
741    /// # Example
742    ///
743    /// ```no_run
744    /// # use slt::{KeyCode, KeyModifiers, RunConfig};
745    /// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
746    ///     // Ctrl+C now reaches your closure as a normal key event.
747    ///     if ui.key_mod('c', KeyModifiers::CONTROL) {
748    ///         // Decide what to do — clear input, prompt to save, quit, etc.
749    ///         ui.quit();
750    ///     }
751    /// }).unwrap();
752    /// ```
753    pub handle_ctrl_c: bool,
754    /// Whether the runtime restores the terminal on Ctrl+Z (`SIGTSTP`) and
755    /// re-enters it on resume (`SIGCONT`).
756    ///
757    /// When `true` (the default) on Unix, pressing Ctrl+Z runs the full
758    /// session teardown — leave the alternate screen (fullscreen only), show
759    /// the cursor, disable raw mode / bracketed paste / focus / mouse / kitty
760    /// — *before* the process is suspended, so the shell prompt returns to a
761    /// clean terminal. Resuming with `fg` re-enters the same session and forces
762    /// a full redraw. This matches helix/zellij/bubbletea job-control behavior.
763    ///
764    /// When `false`, no signal handler is installed and Ctrl+Z falls through to
765    /// crossterm as a regular key event in raw mode (the pre-0.21 behavior).
766    ///
767    /// Unix only; ignored on Windows, WASM, and non-`crossterm` builds where
768    /// there is no `SIGTSTP`. Defaults to `true`.
769    ///
770    /// # Example
771    ///
772    /// ```no_run
773    /// use slt::RunConfig;
774    /// // Opt out: let Ctrl+Z reach the frame closure as a key event.
775    /// let cfg = RunConfig::default().handle_suspend(false);
776    /// assert!(!cfg.handle_suspend);
777    /// ```
778    pub handle_suspend: bool,
779    /// Maximum number of external async messages delivered to one frame.
780    ///
781    /// Messages beyond this count remain queued in FIFO order for later
782    /// frames. Defaults to the async channel capacity (100).
783    pub async_message_budget: usize,
784}
785
786impl Default for RunConfig {
787    fn default() -> Self {
788        Self {
789            tick_rate: Duration::from_millis(16),
790            mouse: false,
791            kitty_keyboard: false,
792            report_all_keys: false,
793            theme: Theme::dark(),
794            color_depth: None,
795            max_fps: Some(60),
796            scroll_speed: 1,
797            title: None,
798            widget_theme: style::WidgetTheme::new(),
799            handle_ctrl_c: true,
800            handle_suspend: true,
801            async_message_budget: 100,
802        }
803    }
804}
805
806impl RunConfig {
807    /// Set the tick rate (input polling interval).
808    pub fn tick_rate(mut self, rate: Duration) -> Self {
809        self.tick_rate = rate;
810        self
811    }
812
813    /// Enable or disable mouse event reporting.
814    pub fn mouse(mut self, enabled: bool) -> Self {
815        self.mouse = enabled;
816        self
817    }
818
819    /// Enable or disable Kitty keyboard protocol.
820    pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
821        self.kitty_keyboard = enabled;
822        self
823    }
824
825    /// Enable or disable modifier-only key reporting (Kitty
826    /// `REPORT_ALL_KEYS_AS_ESCAPE_CODES`).
827    ///
828    /// Requires [`kitty_keyboard(true)`](Self::kitty_keyboard) to have any
829    /// effect. When enabled on a supporting terminal, bare modifier presses
830    /// and releases arrive as [`KeyCode::Modifier`] events. Defaults to
831    /// `false`.
832    ///
833    /// Since 0.21.0.
834    ///
835    /// # Example
836    ///
837    /// ```no_run
838    /// use slt::RunConfig;
839    /// let cfg = RunConfig::default().kitty_keyboard(true).report_all_keys(true);
840    /// assert!(cfg.report_all_keys);
841    /// ```
842    pub fn report_all_keys(mut self, enabled: bool) -> Self {
843        self.report_all_keys = enabled;
844        self
845    }
846
847    /// Set the color theme.
848    pub fn theme(mut self, theme: Theme) -> Self {
849        self.theme = theme;
850        self
851    }
852
853    /// Override the color depth.
854    pub fn color_depth(mut self, depth: ColorDepth) -> Self {
855        self.color_depth = Some(depth);
856        self
857    }
858
859    /// Set the maximum frame rate.
860    pub fn max_fps(mut self, fps: u32) -> Self {
861        self.max_fps = Some(fps);
862        self
863    }
864
865    /// Disable the frame rate cap (unlimited FPS).
866    ///
867    /// By default, [`RunConfig`] caps rendering at 60 fps. Call this to remove
868    /// the cap entirely — useful when controlling external sleep/vsync.
869    ///
870    /// # Example
871    ///
872    /// ```no_run
873    /// slt::run_with(
874    ///     slt::RunConfig::default().no_fps_cap(),
875    ///     |ui| { ui.text("uncapped"); },
876    /// ).unwrap();
877    /// ```
878    pub fn no_fps_cap(mut self) -> Self {
879        self.max_fps = None;
880        self
881    }
882
883    /// Set the maximum number of external async messages delivered per frame.
884    ///
885    /// Values below one are normalized to one so queued messages always make
886    /// progress.
887    pub fn async_message_budget(mut self, messages: usize) -> Self {
888        self.async_message_budget = messages.max(1);
889        self
890    }
891
892    /// Set the scroll speed (lines per scroll event).
893    pub fn scroll_speed(mut self, lines: u32) -> Self {
894        self.scroll_speed = lines.max(1);
895        self
896    }
897
898    /// Set the terminal window title.
899    pub fn title(mut self, title: impl Into<String>) -> Self {
900        self.title = Some(title.into());
901        self
902    }
903
904    /// Set default widget colors for all widget types.
905    pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
906        self.widget_theme = widget_theme;
907        self
908    }
909
910    /// Configure whether the runtime auto-exits on Ctrl+C.
911    ///
912    /// Defaults to `true` (current v0.19 behavior). Set to `false` to
913    /// receive Ctrl+C as a regular [`Event::Key`] inside the frame closure
914    /// — see [`RunConfig::handle_ctrl_c`] for the full migration story.
915    ///
916    /// # Example
917    ///
918    /// ```no_run
919    /// use slt::RunConfig;
920    /// let cfg = RunConfig::default().handle_ctrl_c(false);
921    /// assert!(!cfg.handle_ctrl_c);
922    /// ```
923    pub fn handle_ctrl_c(mut self, enabled: bool) -> Self {
924        self.handle_ctrl_c = enabled;
925        self
926    }
927
928    /// Configure whether the runtime restores the terminal on Ctrl+Z
929    /// (`SIGTSTP`) and re-enters it on resume (`SIGCONT`).
930    ///
931    /// Defaults to `true`. Set to `false` to disable the suspend handler so
932    /// Ctrl+Z falls through to crossterm as a regular key event — see
933    /// [`RunConfig::handle_suspend`] for the full behavior. Unix only; ignored
934    /// elsewhere.
935    ///
936    /// # Example
937    ///
938    /// ```no_run
939    /// use slt::RunConfig;
940    /// let cfg = RunConfig::default().handle_suspend(false);
941    /// assert!(!cfg.handle_suspend);
942    /// ```
943    pub fn handle_suspend(mut self, enabled: bool) -> Self {
944        self.handle_suspend = enabled;
945        self
946    }
947}
948
949#[derive(Default)]
950pub(crate) struct FocusState {
951    pub focus_index: usize,
952    pub prev_focus_count: usize,
953    pub prev_modal_active: bool,
954    pub prev_modal_focus_start: usize,
955    pub prev_modal_focus_count: usize,
956    /// Issue #208: focus index at the end of the previous frame. `None` on
957    /// the first frame so widgets do not falsely report `gained_focus`.
958    pub prev_focus_index: Option<usize>,
959    /// Issue #217: persisted `name → focus_index` map from the most recent
960    /// completed frame. Used at frame start to resolve a pending
961    /// `focus_by_name(...)` against the previous render's registrations.
962    pub focus_name_map_prev: std::collections::HashMap<String, usize>,
963    /// Issue #217: a name passed to `focus_by_name(...)` that has not yet
964    /// been resolved. Consumed once the matching registration is found in
965    /// `focus_name_map_prev`.
966    pub pending_focus_name: Option<String>,
967}
968
969/// v0.21.1: maximum gap between two same-cell left clicks for them to count as
970/// a double-click. Tuned to the common desktop default (~400ms).
971pub(crate) const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
972
973#[derive(Default)]
974pub(crate) struct LayoutFeedbackState {
975    /// `(content_extent, viewport_extent, is_horizontal)` per scrollable last
976    /// frame (#247). `is_horizontal` selects which `ScrollState` axis the
977    /// `scrollable` binding updates.
978    pub prev_scroll_infos: Vec<(u32, u32, bool)>,
979    pub prev_scroll_rects: Vec<rect::Rect>,
980    pub prev_hit_map: Vec<rect::Rect>,
981    pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
982    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
983    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
984    pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
985    pub last_mouse_pos: Option<(u32, u32)>,
986    /// v0.21.1: wall-clock time of the previous left-click `Down`, used to
987    /// detect a double-click (a second click on the same cell within
988    /// `DOUBLE_CLICK_WINDOW`, ~400ms). `None` after a double-click fires (so a
989    /// triple click is not double-counted) or when no click has occurred.
990    pub last_click_at: Option<std::time::Instant>,
991    /// v0.21.1: cell position of the previous left-click `Down`, paired with
992    /// `last_click_at` for same-cell double-click detection.
993    pub last_click_pos: Option<(u32, u32)>,
994}
995
996#[derive(Default)]
997pub(crate) struct DiagnosticsState {
998    pub tick: u64,
999    pub notification_queue: Vec<(String, ToastLevel, u64)>,
1000    pub debug_mode: bool,
1001    pub debug_layer: DebugLayer,
1002    /// Issue #268: whether the devtools inspector panel (Ctrl+F12) is active.
1003    /// Independent of `debug_mode`/`debug_layer`. Round-trips through
1004    /// `Context::inspector_mode` like `debug_layer` so `set_inspector` persists.
1005    pub inspector_mode: bool,
1006    pub fps_ema: f32,
1007}
1008
1009/// Which layers the F12 debug overlay should outline (issue #201).
1010///
1011/// `All` (the default) outlines both the base layer and any active
1012/// overlays/modals — matching the user's expectation for "show everything
1013/// the renderer is producing this frame." `TopMost` only outlines the
1014/// topmost overlay (or the base if no overlay is active), and `BaseOnly`
1015/// keeps the legacy pre-fix behavior of skipping overlays entirely.
1016///
1017/// At runtime, **Shift+F12** cycles `All → TopMost → BaseOnly → All` so a
1018/// developer debugging a stacked modal can shrink the visible outlines to
1019/// just the layer they care about without leaving the keyboard. Plain
1020/// **F12** independently toggles the overlay on/off.
1021///
1022/// # Example
1023///
1024/// ```no_run
1025/// use slt::{Context, DebugLayer};
1026///
1027/// slt::run(|ui: &mut Context| {
1028///     // Match on the current layer to drive bespoke debug UI.
1029///     let label = match ui.debug_layer() {
1030///         DebugLayer::All => "showing base + overlays",
1031///         DebugLayer::TopMost => "showing topmost overlay only",
1032///         DebugLayer::BaseOnly => "showing base layer only",
1033///     };
1034///     ui.text(label);
1035/// })
1036/// .unwrap();
1037/// ```
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1039pub enum DebugLayer {
1040    /// Outline both the base tree and every active overlay/modal.
1041    ///
1042    /// Default. Matches the reporter expectation that F12 reflects
1043    /// everything the renderer is producing this frame. Each layer family
1044    /// gets its own hue so a glance distinguishes base, overlay, and modal
1045    /// containers.
1046    #[default]
1047    All,
1048    /// Outline only the topmost overlay (or the base if no overlay is
1049    /// active).
1050    ///
1051    /// Useful when modals or popovers stack and you only care about the
1052    /// active dialog — base-tree outlines become noise underneath an open
1053    /// modal.
1054    TopMost,
1055    /// Outline only the base layer (legacy v0.19.x behavior).
1056    ///
1057    /// Skips overlays and modals entirely. Use when an overlay is
1058    /// confirmed correct and you want to inspect the base layout
1059    /// underneath it.
1060    BaseOnly,
1061}
1062
1063/// Type alias matching `context::core::RawDrawCallback` (private over there);
1064/// used inside `FrameState` for the recycled-Vec field for issue #204. Kept
1065/// in lib.rs to avoid leaking a public type alias.
1066pub(crate) type FrameDeferredDrawSlot =
1067    Option<Box<dyn FnOnce(&mut crate::buffer::Buffer, crate::rect::Rect)>>;
1068
1069#[derive(Default)]
1070pub(crate) struct FrameState {
1071    pub hook_states: Vec<Box<dyn std::any::Any>>,
1072    pub named_states: std::collections::HashMap<&'static str, Box<dyn std::any::Any>>,
1073    /// Issue #215: runtime-string-keyed parallel of `named_states`. Persisted
1074    /// across frames; survives panics inside `error_boundary` (matching the
1075    /// `named_states` policy).
1076    pub keyed_states: std::collections::HashMap<String, Box<dyn std::any::Any>>,
1077    /// Issue #262: cross-frame partial-chord buffer for [`Context::key_chord`].
1078    /// Round-trips across frames using the same `std::mem::take` out/in policy
1079    /// as `keyed_states` (moved out in `Context::new`, restored at frame end in
1080    /// `run_frame_kernel`).
1081    pub chord_states: widgets::ChordState,
1082    /// Issue #248: persistent frame-clock timer table. Round-tripped through
1083    /// `Context` exactly like `named_states` — moved out at frame start, moved
1084    /// back at frame end where untouched slots are garbage-collected.
1085    pub scheduler: widgets::SchedulerState,
1086    /// Issue #234: persistent async task registry backing `Context::spawn` /
1087    /// `Context::poll`. Round-tripped through `Context` exactly like
1088    /// `scheduler` — moved out at frame start, moved back at frame end. Gated
1089    /// behind `async`; absent (zero overhead) when the feature is off.
1090    #[cfg(feature = "async")]
1091    pub async_tasks: context::AsyncTasks,
1092    pub screen_hook_map:
1093        std::collections::HashMap<u64, std::collections::HashMap<String, (usize, usize)>>,
1094    pub focus: FocusState,
1095    pub layout_feedback: LayoutFeedbackState,
1096    pub diagnostics: DiagnosticsState,
1097    /// Recycled command Vec (issue #150). `Context::new` swaps this into the
1098    /// new context (capacity preserved, len reset to 0). After `build_tree`
1099    /// drains the commands, the now-empty Vec is reclaimed back here.
1100    pub commands_buf: Vec<crate::layout::Command>,
1101    /// Recycled per-frame layout collection scratch (issue #155). Same
1102    /// pattern as `commands_buf`: clear before use, restore after.
1103    pub frame_data: crate::layout::FrameData,
1104    /// Recycled `Context::context_stack` Vec (issue #204). Empty/cleared at
1105    /// frame end (same pattern as `commands_buf`).
1106    pub context_stack_buf: Vec<Box<dyn std::any::Any>>,
1107    /// Recycled `Context::deferred_draws` Vec (issue #204). Slots are emptied
1108    /// (set to `None`) when callbacks fire; we clear before reuse.
1109    pub deferred_draws_buf: Vec<FrameDeferredDrawSlot>,
1110    /// Recycled `rollback.group_stack` Vec (issue #204). Asserted empty at
1111    /// frame end before reclamation.
1112    pub group_stack_buf: Vec<std::sync::Arc<str>>,
1113    /// Recycled `rollback.text_color_stack` Vec (issue #204). Asserted empty
1114    /// at frame end before reclamation.
1115    pub text_color_stack_buf: Vec<Option<crate::style::Color>>,
1116    /// Recycled `Context::pending_tooltips` Vec (issue #204). Asserted empty
1117    /// at frame end before reclamation.
1118    pub pending_tooltips_buf: Vec<context::PendingTooltip>,
1119    /// Recycled `Context::hovered_groups` set (issue #204). Cleared at the
1120    /// start of each frame by `build_hovered_groups`.
1121    pub hovered_groups_buf: std::collections::HashSet<std::sync::Arc<str>>,
1122    /// Issue #273: per-call-site version keys recorded by
1123    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) on the
1124    /// previous frame, indexed by the order `cached` regions were declared.
1125    /// Compared against this frame's keys to classify each cached region as a
1126    /// hit (key unchanged) or miss (key changed / new slot / first frame).
1127    /// Cleared on resize by [`clear_frame_layout_cache`] so every cached
1128    /// region misses after a geometry change. Round-trips through `Context`
1129    /// exactly like `commands_buf` (moved out at frame start, moved back at
1130    /// frame end). Empty (zero overhead) for apps that never call `cached`.
1131    pub region_versions: Vec<u64>,
1132    /// Issue #273: recycled scratch Vec for the CURRENT frame's `cached`
1133    /// region keys (same alloc-reuse discipline as `commands_buf`). Cleared
1134    /// before reuse; swapped into `region_versions` at frame end so the keys
1135    /// recorded this frame become next frame's comparison baseline.
1136    pub region_versions_buf: Vec<u64>,
1137    #[cfg(feature = "crossterm")]
1138    pub selection: terminal::SelectionState,
1139}
1140
1141/// Run the TUI loop with default configuration.
1142///
1143/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
1144/// Ctrl+C or when [`Context::quit`] is called.
1145///
1146/// # Raw mode is handled for you
1147///
1148/// SLT enters raw mode automatically inside [`run`] / [`run_with`] /
1149/// [`run_inline`] / [`run_async`]. Wrapping these with manual
1150/// `crossterm::terminal::enable_raw_mode()` and `disable_raw_mode()` is
1151/// **redundant** — the calls are idempotent so no harm comes of it, but it
1152/// suggests a misunderstood lifecycle. Drop the wrapper calls:
1153///
1154/// ```no_run
1155/// // Don't do this — it's already handled internally:
1156/// // crossterm::terminal::enable_raw_mode()?;
1157/// slt::run(|ui| { ui.text("hi"); })?;
1158/// // crossterm::terminal::disable_raw_mode()?;
1159/// # Ok::<_, std::io::Error>(())
1160/// ```
1161///
1162/// # Ctrl+C opt-out (issue #238)
1163///
1164/// By default, Ctrl+C exits the loop cleanly — matching the v0.19 contract
1165/// and the convention most TUIs follow. To match RataTUI's raw-mode
1166/// semantics (Ctrl+C delivered as a regular `Event::Key`), set
1167/// [`RunConfig::handle_ctrl_c(false)`](RunConfig::handle_ctrl_c) and decide
1168/// inside the frame closure whether to call [`Context::quit`]:
1169///
1170/// ```no_run
1171/// use slt::{KeyModifiers, RunConfig};
1172///
1173/// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
1174///     if ui.key_mod('c', KeyModifiers::CONTROL) {
1175///         // e.g. clear input, prompt to save, then quit:
1176///         ui.quit();
1177///     }
1178/// })?;
1179/// # Ok::<_, std::io::Error>(())
1180/// ```
1181///
1182/// # Example
1183///
1184/// ```no_run
1185/// fn main() -> std::io::Result<()> {
1186///     slt::run(|ui| {
1187///         ui.text("Press Ctrl+C to exit");
1188///     })
1189/// }
1190/// ```
1191#[cfg(feature = "crossterm")]
1192#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1193pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
1194    run_with(RunConfig::default(), f)
1195}
1196
1197#[cfg(feature = "crossterm")]
1198fn validate_terminal_endpoints(
1199    stdin_is_terminal: bool,
1200    stdout_is_terminal: bool,
1201) -> io::Result<()> {
1202    if stdin_is_terminal && stdout_is_terminal {
1203        return Ok(());
1204    }
1205
1206    let unavailable = match (stdin_is_terminal, stdout_is_terminal) {
1207        (false, false) => "stdin and stdout are not terminals",
1208        (false, true) => "stdin is not a terminal",
1209        (true, false) => "stdout is not a terminal",
1210        (true, true) => unreachable!("validated above"),
1211    };
1212    Err(io::Error::new(
1213        io::ErrorKind::NotConnected,
1214        format!("interactive SLT runtime unavailable: {unavailable}"),
1215    ))
1216}
1217
1218#[cfg(feature = "crossterm")]
1219fn ensure_interactive_terminal() -> io::Result<()> {
1220    validate_terminal_endpoints(io::stdin().is_terminal(), io::stdout().is_terminal())
1221}
1222
1223#[cfg(feature = "crossterm")]
1224fn validate_inline_height(height: u32) -> io::Result<()> {
1225    if height == 0 {
1226        return Err(io::Error::new(
1227            io::ErrorKind::InvalidInput,
1228            "inline terminal height must be greater than zero",
1229        ));
1230    }
1231    Ok(())
1232}
1233
1234#[cfg(feature = "crossterm")]
1235fn set_terminal_title(title: &Option<String>) {
1236    if let Some(title) = title {
1237        use std::io::Write;
1238        let title = sanitize_terminal_text(title);
1239        let mut stdout = io::stdout();
1240        let _ = write!(stdout, "\x1b]2;{title}\x07");
1241        let _ = stdout.flush();
1242    }
1243}
1244
1245#[cfg(feature = "crossterm")]
1246fn sanitize_terminal_text(input: &str) -> String {
1247    input
1248        .chars()
1249        .map(|ch| {
1250            if ch.is_control() || ('\u{80}'..='\u{9f}').contains(&ch) {
1251                '?'
1252            } else {
1253                ch
1254            }
1255        })
1256        .collect()
1257}
1258
1259/// Run the TUI loop with custom configuration.
1260///
1261/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
1262/// support, and theming.
1263///
1264/// Returns [`io::ErrorKind::NotConnected`] when stdin or stdout is not a
1265/// terminal. Headless and remote renderers should drive [`frame`] with a custom
1266/// [`Backend`] instead.
1267///
1268/// # Example
1269///
1270/// ```no_run
1271/// use slt::{RunConfig, Theme};
1272///
1273/// fn main() -> std::io::Result<()> {
1274///     slt::run_with(
1275///         RunConfig::default().theme(Theme::light()),
1276///         |ui| {
1277///             ui.text("Light theme!");
1278///         },
1279///     )
1280/// }
1281/// ```
1282#[cfg(feature = "crossterm")]
1283#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1284pub fn run_with(config: RunConfig, f: impl FnMut(&mut Context)) -> io::Result<()> {
1285    ensure_interactive_terminal()?;
1286    with_session_panic_hook(|| run_with_inner(config, f))
1287}
1288
1289#[cfg(feature = "crossterm")]
1290fn run_with_inner(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
1291    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1292    let mut term = Terminal::new(
1293        config.mouse,
1294        config.kitty_keyboard,
1295        config.report_all_keys,
1296        color_depth,
1297    )?;
1298    set_terminal_title(&config.title);
1299    if config.theme.bg != Color::Reset {
1300        term.theme_bg = Some(config.theme.bg);
1301    }
1302    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1303    #[cfg(unix)]
1304    let _suspend_guard = if config.handle_suspend {
1305        Some(install_suspend_handler(term.session_snapshot())?)
1306    } else {
1307        None
1308    };
1309    let mut events: Vec<Event> = Vec::new();
1310    let mut state = FrameState::default();
1311
1312    loop {
1313        let frame_start = Instant::now();
1314        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1315        #[cfg(unix)]
1316        drain_resume_redraw(&mut || term.handle_resize())?;
1317        let (w, h) = term.size();
1318        if w > 0 && h > 0 {
1319            if !run_frame(
1320                &mut term,
1321                &mut state,
1322                &config,
1323                std::mem::take(&mut events),
1324                true,
1325                &mut f,
1326            )? {
1327                break;
1328            }
1329            // Issue #233: full-screen mode has no scrollback channel — warn and
1330            // drop any `ui.static_log(...)` lines so they do not leak into the
1331            // next frame's named_states.
1332            discard_static_log(&mut state, "full-screen run()");
1333        }
1334
1335        #[cfg(unix)]
1336        let suspend_snapshot = term.session_snapshot();
1337        #[cfg(unix)]
1338        let mut on_suspend = || suspend_current_session(suspend_snapshot);
1339        #[cfg(not(unix))]
1340        let mut on_suspend = || Ok(());
1341
1342        if !poll_events(
1343            &mut events,
1344            &mut state,
1345            config.tick_rate,
1346            &mut || term.handle_resize(),
1347            config.handle_ctrl_c,
1348            config.handle_suspend,
1349            &mut on_suspend,
1350        )? {
1351            break;
1352        }
1353
1354        sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1355    }
1356
1357    Ok(())
1358}
1359
1360/// Error returned when an asynchronous run loop is joined.
1361#[cfg(all(feature = "crossterm", feature = "async"))]
1362#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1363#[derive(Debug)]
1364pub enum AsyncRunError {
1365    /// The render loop returned an I/O error.
1366    Io(io::Error),
1367    /// Tokio reported task cancellation or a panic from the render loop.
1368    Join(tokio::task::JoinError),
1369}
1370
1371#[cfg(all(feature = "crossterm", feature = "async"))]
1372impl std::fmt::Display for AsyncRunError {
1373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1374        match self {
1375            Self::Io(err) => write!(f, "async render loop failed: {err}"),
1376            Self::Join(err) => write!(f, "async render task failed: {err}"),
1377        }
1378    }
1379}
1380
1381#[cfg(all(feature = "crossterm", feature = "async"))]
1382impl std::error::Error for AsyncRunError {
1383    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1384        match self {
1385            Self::Io(err) => Some(err),
1386            Self::Join(err) => Some(err),
1387        }
1388    }
1389}
1390
1391#[cfg(all(feature = "crossterm", feature = "async"))]
1392#[derive(Default)]
1393struct AsyncWake {
1394    generation: std::sync::atomic::AtomicU64,
1395    notify: std::sync::Arc<tokio::sync::Notify>,
1396}
1397
1398#[cfg(all(feature = "crossterm", feature = "async"))]
1399impl AsyncWake {
1400    fn notify(&self) {
1401        self.generation
1402            .fetch_add(1, std::sync::atomic::Ordering::Release);
1403        self.notify.notify_one();
1404    }
1405
1406    fn generation(&self) -> u64 {
1407        self.generation.load(std::sync::atomic::Ordering::Acquire)
1408    }
1409}
1410
1411/// Bounded async message sender that wakes its associated render loop.
1412#[cfg(all(feature = "crossterm", feature = "async"))]
1413#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1414pub struct AsyncSender<M> {
1415    inner: tokio::sync::mpsc::Sender<M>,
1416    wake: std::sync::Arc<AsyncWake>,
1417}
1418
1419#[cfg(all(feature = "crossterm", feature = "async"))]
1420impl<M> Clone for AsyncSender<M> {
1421    fn clone(&self) -> Self {
1422        Self {
1423            inner: self.inner.clone(),
1424            wake: std::sync::Arc::clone(&self.wake),
1425        }
1426    }
1427}
1428
1429#[cfg(all(feature = "crossterm", feature = "async"))]
1430impl<M> Drop for AsyncSender<M> {
1431    fn drop(&mut self) {
1432        self.wake.notify();
1433    }
1434}
1435
1436#[cfg(all(feature = "crossterm", feature = "async"))]
1437impl<M> AsyncSender<M> {
1438    /// Send one message, waiting for bounded-channel capacity.
1439    pub async fn send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::SendError<M>> {
1440        self.inner.send(message).await?;
1441        self.wake.notify();
1442        Ok(())
1443    }
1444
1445    /// Try to send one message without waiting for capacity.
1446    pub fn try_send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::TrySendError<M>> {
1447        self.inner.try_send(message)?;
1448        self.wake.notify();
1449        Ok(())
1450    }
1451
1452    /// Send one message from synchronous code, blocking for capacity.
1453    pub fn blocking_send(&self, message: M) -> Result<(), tokio::sync::mpsc::error::SendError<M>> {
1454        self.inner.blocking_send(message)?;
1455        self.wake.notify();
1456        Ok(())
1457    }
1458
1459    /// Returns `true` when the render-loop receiver has closed.
1460    pub fn is_closed(&self) -> bool {
1461        self.inner.is_closed()
1462    }
1463
1464    /// Wait until the render-loop receiver closes.
1465    pub async fn closed(&self) {
1466        self.inner.closed().await;
1467    }
1468
1469    /// Return the channel's remaining capacity.
1470    pub fn capacity(&self) -> usize {
1471        self.inner.capacity()
1472    }
1473
1474    /// Return the channel's configured maximum capacity.
1475    pub fn max_capacity(&self) -> usize {
1476        self.inner.max_capacity()
1477    }
1478}
1479
1480#[cfg(all(feature = "crossterm", feature = "async"))]
1481impl<M> std::ops::Deref for AsyncSender<M> {
1482    type Target = tokio::sync::mpsc::Sender<M>;
1483
1484    fn deref(&self) -> &Self::Target {
1485        &self.inner
1486    }
1487}
1488
1489/// Owned lifetime handle for an asynchronous SLT render loop.
1490///
1491/// The handle dereferences to [`AsyncSender`], preserving the common
1492/// `handle.send(message).await` call shape. Dropping it requests cancellation;
1493/// call [`join`](Self::join) to observe normal completion, I/O failures, or a
1494/// render-task panic.
1495#[cfg(all(feature = "crossterm", feature = "async"))]
1496#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1497#[must_use = "dropping the handle cancels the render loop; join it to observe completion"]
1498pub struct AsyncRunHandle<M> {
1499    sender: Option<AsyncSender<M>>,
1500    join: Option<tokio::task::JoinHandle<io::Result<()>>>,
1501    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1502    wake: std::sync::Arc<AsyncWake>,
1503    cancel_on_drop: bool,
1504}
1505
1506#[cfg(all(feature = "crossterm", feature = "async"))]
1507impl<M> AsyncRunHandle<M> {
1508    /// Clone the wake-aware sender while retaining ownership of the run loop.
1509    pub fn sender(&self) -> AsyncSender<M> {
1510        self.sender
1511            .as_ref()
1512            .expect("sender is available until join starts")
1513            .clone()
1514    }
1515
1516    /// Request cooperative cancellation of the render loop.
1517    pub fn cancel(&self) {
1518        self.cancel
1519            .store(true, std::sync::atomic::Ordering::Release);
1520        self.wake.notify();
1521    }
1522
1523    /// Returns `true` when the Tokio render task has completed.
1524    pub fn is_finished(&self) -> bool {
1525        self.join
1526            .as_ref()
1527            .is_none_or(tokio::task::JoinHandle::is_finished)
1528    }
1529
1530    /// Wait for render-loop completion and preserve both I/O and join errors.
1531    ///
1532    /// Joining drops this handle's sender first. Sender clones must also be
1533    /// dropped, or the UI must quit, before a disconnect-driven loop can end.
1534    pub async fn join(mut self) -> Result<(), AsyncRunError> {
1535        self.sender.take();
1536        let join = self
1537            .join
1538            .take()
1539            .expect("join handle is consumed exactly once");
1540        join.await
1541            .map_err(AsyncRunError::Join)?
1542            .map_err(AsyncRunError::Io)
1543    }
1544
1545    /// Request cancellation and wait for deterministic teardown.
1546    pub async fn cancel_and_join(mut self) -> Result<(), AsyncRunError> {
1547        self.cancel();
1548        self.sender.take();
1549        let join = self
1550            .join
1551            .take()
1552            .expect("join handle is consumed exactly once");
1553        join.await
1554            .map_err(AsyncRunError::Join)?
1555            .map_err(AsyncRunError::Io)
1556    }
1557
1558    /// Detach the render task and return only a wake-aware sender.
1559    ///
1560    /// This compatibility path intentionally makes completion errors
1561    /// unobservable. Prefer retaining and joining the owned handle.
1562    pub fn detach(mut self) -> AsyncSender<M> {
1563        self.cancel_on_drop = false;
1564        self.join.take();
1565        self.sender
1566            .take()
1567            .expect("sender is available until detach")
1568    }
1569}
1570
1571#[cfg(all(feature = "crossterm", feature = "async"))]
1572impl<M> std::ops::Deref for AsyncRunHandle<M> {
1573    type Target = AsyncSender<M>;
1574
1575    fn deref(&self) -> &Self::Target {
1576        self.sender
1577            .as_ref()
1578            .expect("sender is available until join starts")
1579    }
1580}
1581
1582#[cfg(all(feature = "crossterm", feature = "async"))]
1583impl<M> Drop for AsyncRunHandle<M> {
1584    fn drop(&mut self) {
1585        if self.cancel_on_drop {
1586            self.cancel
1587                .store(true, std::sync::atomic::Ordering::Release);
1588            self.wake.notify();
1589        }
1590    }
1591}
1592
1593#[cfg(all(feature = "crossterm", feature = "async"))]
1594impl<M: Send + 'static> std::future::IntoFuture for AsyncRunHandle<M> {
1595    type Output = Result<(), AsyncRunError>;
1596    type IntoFuture =
1597        std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'static>>;
1598
1599    fn into_future(self) -> Self::IntoFuture {
1600        Box::pin(self.join())
1601    }
1602}
1603
1604/// Run the TUI loop asynchronously with default configuration.
1605///
1606/// Requires the `async` feature. Spawns the render loop in a blocking thread
1607/// and returns an owned [`AsyncRunHandle`]. The handle sends messages directly,
1608/// can be cloned into an [`AsyncSender`], supports cooperative cancellation,
1609/// and exposes render-loop I/O or panic failures when joined.
1610///
1611/// Returns [`io::ErrorKind::NotConnected`] when stdin or stdout is not a
1612/// terminal. Use [`frame`] with a custom backend for headless rendering.
1613///
1614/// # Example
1615///
1616/// ```no_run
1617/// # #[cfg(feature = "async")]
1618/// # async fn example() -> std::io::Result<()> {
1619/// let run = slt::run_async::<String>(|ui, messages| {
1620///     for msg in messages.drain(..) {
1621///         ui.text(msg);
1622///     }
1623/// })?;
1624/// run.send("hello from async".to_string()).await.ok();
1625/// run.cancel_and_join().await.map_err(std::io::Error::other)?;
1626/// # Ok(())
1627/// # }
1628/// ```
1629#[cfg(all(feature = "crossterm", feature = "async"))]
1630#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1631pub fn run_async<M: Send + 'static>(
1632    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1633) -> io::Result<AsyncRunHandle<M>> {
1634    run_async_with(RunConfig::default(), f)
1635}
1636
1637/// Run the TUI loop asynchronously with custom configuration.
1638///
1639/// Requires the `async` feature. Like [`run_async`], but accepts a
1640/// [`RunConfig`] to control tick rate, mouse support, and theming.
1641///
1642/// Returns an owned [`AsyncRunHandle`] for sending, cancellation, and joining.
1643#[cfg(all(feature = "crossterm", feature = "async"))]
1644#[cfg_attr(docsrs, doc(cfg(all(feature = "crossterm", feature = "async"))))]
1645pub fn run_async_with<M: Send + 'static>(
1646    config: RunConfig,
1647    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
1648) -> io::Result<AsyncRunHandle<M>> {
1649    ensure_interactive_terminal()?;
1650    let (tx, rx) = tokio::sync::mpsc::channel(100);
1651    let handle =
1652        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
1653    let wake = std::sync::Arc::new(AsyncWake::default());
1654    let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1655
1656    // Issue #234: clone the runtime handle into the render loop so
1657    // `Context::spawn` has a runtime to launch tasks onto. The render loop runs
1658    // on `spawn_blocking` (no ambient runtime), so the handle must be passed
1659    // explicitly rather than recovered via `Handle::try_current()` inside.
1660    let loop_handle = handle.clone();
1661    let loop_wake = std::sync::Arc::clone(&wake);
1662    let loop_cancel = std::sync::Arc::clone(&cancel);
1663    let join = handle
1664        .spawn_blocking(move || run_async_loop(config, f, rx, loop_handle, loop_wake, loop_cancel));
1665
1666    Ok(AsyncRunHandle {
1667        sender: Some(AsyncSender {
1668            inner: tx,
1669            wake: std::sync::Arc::clone(&wake),
1670        }),
1671        join: Some(join),
1672        cancel,
1673        wake,
1674        cancel_on_drop: true,
1675    })
1676}
1677
1678#[cfg(all(feature = "crossterm", feature = "async"))]
1679fn drain_async_messages<M>(
1680    rx: &mut tokio::sync::mpsc::Receiver<M>,
1681    messages: &mut Vec<M>,
1682    budget: usize,
1683) -> bool {
1684    let mut disconnected = false;
1685    for _ in 0..budget.max(1) {
1686        match rx.try_recv() {
1687            Ok(message) => messages.push(message),
1688            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
1689            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1690                disconnected = true;
1691                break;
1692            }
1693        }
1694    }
1695    disconnected || (rx.is_closed() && rx.is_empty())
1696}
1697
1698#[cfg(all(feature = "crossterm", feature = "async"))]
1699fn run_async_loop<M: Send + 'static>(
1700    config: RunConfig,
1701    f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
1702    rx: tokio::sync::mpsc::Receiver<M>,
1703    runtime: tokio::runtime::Handle,
1704    wake: std::sync::Arc<AsyncWake>,
1705    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1706) -> io::Result<()> {
1707    with_session_panic_hook(move || run_async_loop_inner(config, f, rx, runtime, wake, cancel))
1708}
1709
1710#[cfg(all(feature = "crossterm", feature = "async"))]
1711fn run_async_loop_inner<M: Send + 'static>(
1712    config: RunConfig,
1713    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
1714    mut rx: tokio::sync::mpsc::Receiver<M>,
1715    runtime: tokio::runtime::Handle,
1716    wake: std::sync::Arc<AsyncWake>,
1717    cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1718) -> io::Result<()> {
1719    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1720    let mut term = Terminal::new(
1721        config.mouse,
1722        config.kitty_keyboard,
1723        config.report_all_keys,
1724        color_depth,
1725    )?;
1726    set_terminal_title(&config.title);
1727    if config.theme.bg != Color::Reset {
1728        term.theme_bg = Some(config.theme.bg);
1729    }
1730    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1731    #[cfg(unix)]
1732    let _suspend_guard = if config.handle_suspend {
1733        Some(install_suspend_handler(term.session_snapshot())?)
1734    } else {
1735        None
1736    };
1737    let mut events: Vec<Event> = Vec::new();
1738    let mut messages: Vec<M> = Vec::new();
1739    let mut state = FrameState::default();
1740    // Issue #234: inject the ambient runtime so `Context::spawn` works inside
1741    // the frame closure. Set once before the loop; round-tripped through
1742    // `Context` from here on (see `run_frame_kernel`).
1743    state.async_tasks.set_runtime(runtime.clone());
1744    state
1745        .async_tasks
1746        .set_waker(std::sync::Arc::clone(&wake.notify));
1747
1748    loop {
1749        if cancel.load(std::sync::atomic::Ordering::Acquire) {
1750            break;
1751        }
1752        let frame_start = Instant::now();
1753        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1754        #[cfg(unix)]
1755        drain_resume_redraw(&mut || term.handle_resize())?;
1756        let observed_wake = wake.generation();
1757        messages.clear();
1758        let input_disconnected =
1759            drain_async_messages(&mut rx, &mut messages, config.async_message_budget);
1760        if input_disconnected && messages.is_empty() {
1761            break;
1762        }
1763
1764        let (w, h) = term.size();
1765        if w > 0 && h > 0 {
1766            let mut render = |ctx: &mut Context| {
1767                f(ctx, &mut messages);
1768            };
1769            if !run_frame(
1770                &mut term,
1771                &mut state,
1772                &config,
1773                std::mem::take(&mut events),
1774                true,
1775                &mut render,
1776            )? {
1777                break;
1778            }
1779            // Issue #233: full-screen async mode has no scrollback channel — warn
1780            // and drop any pending static_log lines.
1781            discard_static_log(&mut state, "run_async()");
1782            if input_disconnected {
1783                break;
1784            }
1785        } else if input_disconnected {
1786            break;
1787        }
1788
1789        #[cfg(unix)]
1790        let suspend_snapshot = term.session_snapshot();
1791        #[cfg(unix)]
1792        let mut on_suspend = || suspend_current_session(suspend_snapshot);
1793        #[cfg(not(unix))]
1794        let mut on_suspend = || Ok(());
1795
1796        if !poll_events(
1797            &mut events,
1798            &mut state,
1799            if wake.generation() != observed_wake
1800                || runtime.block_on(async {
1801                    tokio::time::timeout(Duration::ZERO, wake.notify.notified())
1802                        .await
1803                        .is_ok()
1804                })
1805                || cancel.load(std::sync::atomic::Ordering::Acquire)
1806            {
1807                Duration::ZERO
1808            } else {
1809                config.tick_rate.min(Duration::from_millis(4))
1810            },
1811            &mut || term.handle_resize(),
1812            config.handle_ctrl_c,
1813            config.handle_suspend,
1814            &mut on_suspend,
1815        )? {
1816            break;
1817        }
1818
1819        sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1820    }
1821
1822    Ok(())
1823}
1824
1825/// Run the TUI in inline mode with default configuration.
1826///
1827/// Renders `height` rows directly below the current cursor position without
1828/// entering alternate screen mode. Useful for CLI tools that want a small
1829/// interactive widget below the prompt.
1830///
1831/// `height` is the reserved inline render area in terminal rows.
1832/// The rest of the terminal stays in normal scrollback mode.
1833/// A zero height returns [`io::ErrorKind::InvalidInput`]; non-terminal stdin or
1834/// stdout returns [`io::ErrorKind::NotConnected`].
1835///
1836/// # Example
1837///
1838/// ```no_run
1839/// fn main() -> std::io::Result<()> {
1840///     slt::run_inline(3, |ui| {
1841///         ui.text("Inline TUI — no alternate screen");
1842///     })
1843/// }
1844/// ```
1845#[cfg(feature = "crossterm")]
1846#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1847pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
1848    run_inline_with(height, RunConfig::default(), f)
1849}
1850
1851/// Run the TUI in inline mode with custom configuration.
1852///
1853/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
1854/// mouse support, and theming.
1855#[cfg(feature = "crossterm")]
1856#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1857pub fn run_inline_with(
1858    height: u32,
1859    config: RunConfig,
1860    f: impl FnMut(&mut Context),
1861) -> io::Result<()> {
1862    validate_inline_height(height)?;
1863    ensure_interactive_terminal()?;
1864    with_session_panic_hook(|| run_inline_with_inner(height, config, f))
1865}
1866
1867#[cfg(feature = "crossterm")]
1868fn run_inline_with_inner(
1869    height: u32,
1870    config: RunConfig,
1871    mut f: impl FnMut(&mut Context),
1872) -> io::Result<()> {
1873    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1874    let mut term = InlineTerminal::new(
1875        height,
1876        config.mouse,
1877        config.kitty_keyboard,
1878        config.report_all_keys,
1879        color_depth,
1880    )?;
1881    set_terminal_title(&config.title);
1882    if config.theme.bg != Color::Reset {
1883        term.theme_bg = Some(config.theme.bg);
1884    }
1885    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1886    #[cfg(unix)]
1887    let _suspend_guard = if config.handle_suspend {
1888        Some(install_suspend_handler(term.session_snapshot())?)
1889    } else {
1890        None
1891    };
1892    let mut events: Vec<Event> = Vec::new();
1893    let mut state = FrameState::default();
1894
1895    loop {
1896        let frame_start = Instant::now();
1897        // Issue #263: after a SIGCONT resume, repaint the whole frame.
1898        #[cfg(unix)]
1899        drain_resume_redraw(&mut || term.handle_resize())?;
1900        let (w, h) = term.size();
1901        if w > 0 && h > 0 {
1902            if !run_frame(
1903                &mut term,
1904                &mut state,
1905                &config,
1906                std::mem::take(&mut events),
1907                true,
1908                &mut f,
1909            )? {
1910                break;
1911            }
1912            // Issue #233: inline mode without `StaticOutput` has no scrollback
1913            // channel either — warn and drop any pending lines.
1914            discard_static_log(&mut state, "run_inline()");
1915        }
1916
1917        #[cfg(unix)]
1918        let suspend_snapshot = term.session_snapshot();
1919        #[cfg(unix)]
1920        let mut on_suspend = || suspend_current_session(suspend_snapshot);
1921        #[cfg(not(unix))]
1922        let mut on_suspend = || Ok(());
1923
1924        if !poll_events(
1925            &mut events,
1926            &mut state,
1927            config.tick_rate,
1928            &mut || term.handle_resize(),
1929            config.handle_ctrl_c,
1930            config.handle_suspend,
1931            &mut on_suspend,
1932        )? {
1933            break;
1934        }
1935
1936        sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1937    }
1938
1939    Ok(())
1940}
1941
1942/// Run the TUI in static-output mode.
1943///
1944/// Static lines written through [`StaticOutput`] are printed into terminal
1945/// scrollback, while the interactive UI stays rendered in a fixed-height inline
1946/// area at the bottom.
1947///
1948/// Use this when you want a log-style output stream above a live inline UI.
1949/// A zero dynamic height returns [`io::ErrorKind::InvalidInput`]; non-terminal
1950/// stdin or stdout returns [`io::ErrorKind::NotConnected`].
1951#[cfg(feature = "crossterm")]
1952#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1953pub fn run_static(
1954    output: &mut StaticOutput,
1955    dynamic_height: u32,
1956    f: impl FnMut(&mut Context),
1957) -> io::Result<()> {
1958    run_static_with(output, dynamic_height, RunConfig::default(), f)
1959}
1960
1961/// Run the TUI in static-output mode with custom configuration.
1962///
1963/// Like [`run_static`] but accepts a [`RunConfig`] for theme, mouse, tick rate,
1964/// and other settings.
1965#[cfg(feature = "crossterm")]
1966#[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
1967pub fn run_static_with(
1968    output: &mut StaticOutput,
1969    dynamic_height: u32,
1970    config: RunConfig,
1971    f: impl FnMut(&mut Context),
1972) -> io::Result<()> {
1973    validate_inline_height(dynamic_height)?;
1974    ensure_interactive_terminal()?;
1975    with_session_panic_hook(|| run_static_with_inner(output, dynamic_height, config, f))
1976}
1977
1978#[cfg(feature = "crossterm")]
1979fn run_static_with_inner(
1980    output: &mut StaticOutput,
1981    dynamic_height: u32,
1982    config: RunConfig,
1983    mut f: impl FnMut(&mut Context),
1984) -> io::Result<()> {
1985    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1986    let mut term = InlineTerminal::new(
1987        dynamic_height,
1988        config.mouse,
1989        config.kitty_keyboard,
1990        config.report_all_keys,
1991        color_depth,
1992    )?;
1993    term.write_scrollback(&output.drain_new())?;
1994    set_terminal_title(&config.title);
1995    if config.theme.bg != Color::Reset {
1996        term.theme_bg = Some(config.theme.bg);
1997    }
1998    // Issue #263: install the unix Ctrl+Z / `fg` suspend handler for the loop.
1999    #[cfg(unix)]
2000    let _suspend_guard = if config.handle_suspend {
2001        Some(install_suspend_handler(term.session_snapshot())?)
2002    } else {
2003        None
2004    };
2005
2006    let mut events: Vec<Event> = Vec::new();
2007    let mut state = FrameState::default();
2008
2009    loop {
2010        let frame_start = Instant::now();
2011        // Issue #263: after a SIGCONT resume, repaint the whole frame.
2012        #[cfg(unix)]
2013        drain_resume_redraw(&mut || term.handle_resize())?;
2014        let (w, h) = term.size();
2015        term.write_scrollback(&output.drain_new())?;
2016        if w > 0 && h > 0 {
2017            let keep_running = run_frame(
2018                &mut term,
2019                &mut state,
2020                &config,
2021                std::mem::take(&mut events),
2022                true,
2023                &mut f,
2024            )?;
2025            // Issue #233: drain any `ui.static_log(...)` lines queued during the
2026            // frame closure into `output`, then flush them before any exit path.
2027            for line in drain_static_log(&mut state) {
2028                output.println(line);
2029            }
2030            term.write_scrollback(&output.drain_new())?;
2031            if !keep_running {
2032                break;
2033            }
2034        }
2035
2036        #[cfg(unix)]
2037        let suspend_snapshot = term.session_snapshot();
2038        #[cfg(unix)]
2039        let mut on_suspend = || suspend_current_session(suspend_snapshot);
2040        #[cfg(not(unix))]
2041        let mut on_suspend = || Ok(());
2042
2043        if !poll_events(
2044            &mut events,
2045            &mut state,
2046            config.tick_rate,
2047            &mut || term.handle_resize(),
2048            config.handle_ctrl_c,
2049            config.handle_suspend,
2050            &mut on_suspend,
2051        )? {
2052            break;
2053        }
2054
2055        sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
2056    }
2057
2058    Ok(())
2059}
2060
2061#[cfg(all(feature = "crossterm", test))]
2062fn write_static_lines_to(stdout: &mut impl io::Write, lines: &[String]) -> io::Result<()> {
2063    for line in lines {
2064        let safe = sanitize_terminal_text(line);
2065        stdout.write_all(safe.as_bytes())?;
2066        stdout.write_all(b"\r\n")?;
2067    }
2068    stdout.flush()
2069}
2070
2071/// Reserved sentinel key used by [`Context::static_log`] (issue #233).
2072/// Re-exported into `context::runtime` so reads/writes never drift.
2073pub(crate) const STATIC_LOG_NAMED_STATE_KEY: &str = "__slt_static_log_pending";
2074
2075/// Reserved sentinel key used by [`Context::publish_keymap`] (issue #236).
2076/// Re-exported into `context::runtime` so reads/writes never drift.
2077pub(crate) const KEYMAP_REGISTRY_NAMED_STATE_KEY: &str = "__slt_keymap_registry";
2078
2079/// Clear the per-frame keymap registry stored in [`FrameState::named_states`]
2080/// (issue #236). Called at the start of every kernel iteration so that
2081/// `Context::publish_keymap` always sees a fresh empty buffer. Capacity is
2082/// preserved by clearing the inner `Vec` rather than removing the entry.
2083pub(crate) fn clear_keymap_registry(state: &mut FrameState) {
2084    if let Some(boxed) = state.named_states.get_mut(KEYMAP_REGISTRY_NAMED_STATE_KEY)
2085        && let Some(vec) = boxed.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>()
2086    {
2087        vec.clear();
2088    }
2089}
2090
2091/// Drain any [`Context::static_log`] lines accumulated during the most recent
2092/// frame from the persisted [`FrameState`] (issue #233).
2093///
2094/// After [`run_frame_kernel`] returns, `state.named_states` owns the buffer.
2095/// This helper drains it back to a `Vec<String>` so the runtime can flush
2096/// the lines through whichever scrollback mechanism is appropriate
2097/// (`run_static_with` writes them above the inline region; other run modes
2098/// drop them with a debug warning).
2099#[cfg(feature = "crossterm")]
2100pub(crate) fn drain_static_log(state: &mut FrameState) -> Vec<String> {
2101    if let Some(boxed) = state.named_states.get_mut(STATIC_LOG_NAMED_STATE_KEY)
2102        && let Some(buf) = boxed.downcast_mut::<Vec<String>>()
2103    {
2104        return std::mem::take(buf);
2105    }
2106    Vec::new()
2107}
2108
2109/// Discard any [`Context::static_log`] lines that accumulated during the
2110/// most recent frame and emit a debug warning (issue #233).
2111///
2112/// Used by run modes that have no scrollback channel (full-screen,
2113/// inline-without-static, async). Release builds silently drop the buffer.
2114#[cfg(feature = "crossterm")]
2115fn discard_static_log(state: &mut FrameState, mode: &str) {
2116    let drained = drain_static_log(state);
2117    #[cfg(debug_assertions)]
2118    if !drained.is_empty() {
2119        #[allow(clippy::print_stderr)]
2120        {
2121            eprintln!(
2122                "[slt] {} static_log lines were dropped: {} runtime has no scrollback channel; use slt::run_static for streaming output",
2123                drained.len(),
2124                mode
2125            );
2126        }
2127    }
2128    #[cfg(not(debug_assertions))]
2129    {
2130        let _ = (drained, mode);
2131    }
2132}
2133
2134/// Apply a single terminal event to `FrameState`, mutating tracked
2135/// diagnostics fields (debug overlay toggle, mouse position cache,
2136/// resize flag) accordingly.
2137///
2138/// Issue #201: handles **F12** (toggle overlay on/off) and **Shift+F12**
2139/// (cycle [`DebugLayer`] across `All → TopMost → BaseOnly`). The two
2140/// keybindings are independent — toggling the overlay does not change
2141/// the active layer.
2142///
2143/// Extracted from `poll_events` so the keybinding behavior can be
2144/// exercised by unit tests without standing up a real crossterm event
2145/// stream.
2146#[cfg(feature = "crossterm")]
2147pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
2148    match ev {
2149        Event::Mouse(m) => {
2150            state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
2151        }
2152        Event::FocusLost => {
2153            state.layout_feedback.last_mouse_pos = None;
2154        }
2155        // Issue #268: Ctrl+F12 toggles the devtools inspector panel
2156        // independently of the F12 outline overlay and the Shift+F12 layer
2157        // cycle. Match before the Shift/NONE arms so the Control branch wins.
2158        Event::Key(event::KeyEvent {
2159            code: KeyCode::F(12),
2160            kind: event::KeyEventKind::Press,
2161            modifiers,
2162        }) if modifiers.contains(event::KeyModifiers::CONTROL) => {
2163            state.diagnostics.inspector_mode = !state.diagnostics.inspector_mode;
2164        }
2165        // Issue #201: Shift+F12 cycles the active `DebugLayer`. Match
2166        // before the plain-F12 arm so the modifier branch wins. Plain
2167        // F12 keeps its legacy on/off toggle when no modifiers are
2168        // held; we explicitly require `KeyModifiers::NONE` so the two
2169        // arms do not double-fire on the same press.
2170        Event::Key(event::KeyEvent {
2171            code: KeyCode::F(12),
2172            kind: event::KeyEventKind::Press,
2173            modifiers,
2174        }) if modifiers.contains(event::KeyModifiers::SHIFT) => {
2175            state.diagnostics.debug_layer = match state.diagnostics.debug_layer {
2176                DebugLayer::All => DebugLayer::TopMost,
2177                DebugLayer::TopMost => DebugLayer::BaseOnly,
2178                DebugLayer::BaseOnly => DebugLayer::All,
2179            };
2180        }
2181        Event::Key(event::KeyEvent {
2182            code: KeyCode::F(12),
2183            kind: event::KeyEventKind::Press,
2184            modifiers,
2185        }) if *modifiers == event::KeyModifiers::NONE => {
2186            state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
2187        }
2188        Event::Resize(_, _) => {
2189            *has_resize = true;
2190        }
2191        _ => {}
2192    }
2193}
2194
2195/// Number of `on_resize` invocations a batch of events should trigger.
2196///
2197/// v0.21.1 resize coalescing: a single poll batch may deliver a burst of
2198/// `Event::Resize` events while a user drags the window edge. Each
2199/// [`Terminal::handle_resize`](crate::terminal::Terminal::handle_resize) does a
2200/// `terminal::size()` syscall, two buffer reallocations, and a `Clear(All)`, so
2201/// firing it per-event is pure waste — only the *final* geometry matters and
2202/// `handle_resize` always reads the live terminal size, not the per-event
2203/// payload. This helper returns `1` if the batch contains any resize and `0`
2204/// otherwise, so the caller can collapse the burst into one end-of-batch call.
2205///
2206/// Kept as a pure function (no I/O) so the coalescing rule is unit-testable
2207/// without a real crossterm event source.
2208#[cfg(feature = "crossterm")]
2209#[inline]
2210fn resize_invocations_for_batch(events: &[Event]) -> usize {
2211    usize::from(events.iter().any(|e| matches!(e, Event::Resize(_, _))))
2212}
2213
2214/// Poll for terminal events, handling resize, Ctrl-C, F12 debug toggle,
2215/// and layout cache invalidation. Returns `Ok(false)` when the loop should exit.
2216///
2217/// `handle_ctrl_c` controls whether Ctrl+C exits the loop (`true`, default
2218/// v0.19 behavior) or is delivered to the frame closure as a regular key
2219/// event (`false`, RataTUI parity, issue #238).
2220///
2221/// v0.21.1: resize events within one poll batch are *coalesced* — `on_resize`
2222/// is invoked at most once, after the whole batch is drained, using the final
2223/// terminal size (`handle_resize` re-reads `terminal::size()`). Dragging a
2224/// window edge can emit dozens of `Event::Resize` per poll; firing the
2225/// `Clear(All)` + double realloc + `size()` syscall for each is wasted work
2226/// when only the last geometry survives. The SIGCONT/resume redraw path in
2227/// [`run_with`] is unaffected — it calls `handle_resize` directly, outside this
2228/// function.
2229#[cfg(feature = "crossterm")]
2230fn poll_events(
2231    events: &mut Vec<Event>,
2232    state: &mut FrameState,
2233    tick_rate: Duration,
2234    on_resize: &mut impl FnMut() -> io::Result<()>,
2235    handle_ctrl_c: bool,
2236    handle_suspend: bool,
2237    on_suspend: &mut impl FnMut() -> io::Result<()>,
2238) -> io::Result<bool> {
2239    let mut has_resize = false;
2240    let batch_start = events.len();
2241
2242    fn process_ev(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
2243        process_run_loop_event(ev, state, has_resize);
2244    }
2245
2246    if crossterm::event::poll(tick_rate)? {
2247        let raw = crossterm::event::read()?;
2248        if let Some(ev) = event::from_crossterm(raw) {
2249            if handle_ctrl_c && is_ctrl_c(&ev) {
2250                return Ok(false);
2251            }
2252            if handle_suspend && is_ctrl_z(&ev) {
2253                on_suspend()?;
2254                return Ok(true);
2255            }
2256            // Resize is recorded (via `has_resize`) but not yet acted on — the
2257            // single `on_resize` call is deferred to end-of-batch so a burst
2258            // collapses into one geometry sync.
2259            process_ev(&ev, state, &mut has_resize);
2260            events.push(ev);
2261        }
2262
2263        while crossterm::event::poll(Duration::ZERO)? {
2264            let raw = crossterm::event::read()?;
2265            if let Some(ev) = event::from_crossterm(raw) {
2266                if handle_ctrl_c && is_ctrl_c(&ev) {
2267                    return Ok(false);
2268                }
2269                if handle_suspend && is_ctrl_z(&ev) {
2270                    on_suspend()?;
2271                    return Ok(true);
2272                }
2273                process_ev(&ev, state, &mut has_resize);
2274                events.push(ev);
2275            }
2276        }
2277    }
2278
2279    // Coalesced resize: fire `on_resize` exactly once for the whole batch,
2280    // after every event has been read, so it picks up the final terminal size.
2281    // `has_resize` is the per-batch "saw a resize" flag set by `process_ev`.
2282    debug_assert_eq!(
2283        usize::from(has_resize),
2284        resize_invocations_for_batch(&events[batch_start..]),
2285        "has_resize must agree with the coalescing helper"
2286    );
2287    if has_resize {
2288        on_resize()?;
2289    }
2290
2291    // #90: clear cache first (which also resets last_mouse_pos to None),
2292    // then re-apply latest mouse pos so Resize+Mouse frames keep coords.
2293    if has_resize {
2294        clear_frame_layout_cache(state);
2295        // After clearing, re-walk events to restore the latest mouse pos
2296        // (process_ev already set it during collection, but
2297        // clear_frame_layout_cache wiped it).
2298        for ev in &events[batch_start..] {
2299            match ev {
2300                Event::Mouse(m) => {
2301                    state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
2302                }
2303                Event::FocusLost => {
2304                    state.layout_feedback.last_mouse_pos = None;
2305                }
2306                _ => {}
2307            }
2308        }
2309    }
2310
2311    Ok(true)
2312}
2313
2314struct FrameKernelResult {
2315    should_quit: bool,
2316    #[cfg(feature = "crossterm")]
2317    clipboard_text: Option<String>,
2318    #[cfg(feature = "crossterm")]
2319    should_copy_selection: bool,
2320}
2321
2322pub(crate) fn run_frame_kernel(
2323    buffer: &mut Buffer,
2324    state: &mut FrameState,
2325    config: &RunConfig,
2326    size: (u32, u32),
2327    events: Vec<event::Event>,
2328    is_real_terminal: bool,
2329    f: &mut impl FnMut(&mut context::Context),
2330) -> FrameKernelResult {
2331    let frame_start = Instant::now();
2332    let (w, h) = size;
2333    // Issue #236: reset the per-frame keymap registry before constructing
2334    // `Context`. Widgets that call `publish_keymap` accumulate fresh
2335    // entries; entries from the previous frame must not leak through
2336    // `named_states` persistence.
2337    clear_keymap_registry(state);
2338    // Issue #273: invalidate every `cached` region's persisted version key on a
2339    // resize. The real run loop also clears region keys via
2340    // `clear_frame_layout_cache` (driven by its `has_resize` flag), but the
2341    // headless `TestBackend` / `frame_owned` paths feed the kernel directly
2342    // and never run that flag, so we detect the resize event here too. This
2343    // keeps the "resize forces a cache miss for all cached regions" invariant
2344    // path-independent: a geometry change cannot be silently treated as a hit.
2345    // Cheap when unused — `region_versions` is empty for apps without `cached`.
2346    if !state.region_versions.is_empty() && events.iter().any(|e| matches!(e, Event::Resize(_, _)))
2347    {
2348        state.region_versions.clear();
2349    }
2350    let mut ctx = Context::new(events, w, h, state, config.theme);
2351    ctx.is_real_terminal = is_real_terminal;
2352    // Issue #264: surface the negotiated capability snapshot read-only. The
2353    // probe ran once at session enter (cached in a `OnceLock`); on a headless
2354    // backend it never ran, so we keep the conservative default rather than
2355    // forcing a probe that would block on stdin.
2356    #[cfg(feature = "crossterm")]
2357    if is_real_terminal {
2358        ctx.capabilities = terminal::capabilities();
2359    }
2360    ctx.set_scroll_speed(config.scroll_speed);
2361    ctx.widget_theme = config.widget_theme;
2362
2363    f(&mut ctx);
2364    ctx.process_focus_keys();
2365    ctx.render_notifications();
2366    ctx.emit_pending_tooltips();
2367
2368    debug_assert_eq!(
2369        ctx.rollback.overlay_depth, 0,
2370        "overlay depth must settle back to zero before layout"
2371    );
2372    debug_assert_eq!(
2373        ctx.rollback.group_count, 0,
2374        "group count must settle back to zero before layout"
2375    );
2376    debug_assert!(
2377        ctx.rollback.group_stack.is_empty(),
2378        "group stack must be empty before layout"
2379    );
2380    debug_assert!(
2381        ctx.rollback.text_color_stack.is_empty(),
2382        "text color stack must be empty before layout"
2383    );
2384    debug_assert!(
2385        ctx.pending_tooltips.is_empty(),
2386        "pending tooltips must be emitted before layout"
2387    );
2388
2389    if ctx.should_quit {
2390        state.hook_states = ctx.hook_states;
2391        state.named_states = ctx.named_states;
2392        state.keyed_states = ctx.keyed_states;
2393        // Issue #262: persist the partial-chord buffer on quit too (TestBackend
2394        // reuses `FrameState` across `render()` calls — same rationale as the
2395        // keyed-state reclaim).
2396        state.chord_states = ctx.chord;
2397        // Issue #248: hand the scheduler table back and GC abandoned timers.
2398        let mut scheduler = ctx.scheduler;
2399        scheduler.gc_untouched();
2400        state.scheduler = scheduler;
2401        // Issue #234: hand the async task registry back so in-flight tasks and
2402        // pending results survive to the next frame (TestBackend reuses
2403        // `FrameState` across `render()` calls — same rationale as the
2404        // scheduler reclaim).
2405        #[cfg(feature = "async")]
2406        {
2407            // Pump the registry every frame so a handle dropped on a frame that
2408            // calls neither spawn nor poll still has its cancellation processed
2409            // (and completed results moved in) before the round-trip.
2410            ctx.async_tasks.maintain();
2411            state.async_tasks = ctx.async_tasks;
2412        }
2413        state.screen_hook_map = ctx.screen_hook_map;
2414        state.diagnostics.notification_queue = ctx.rollback.notification_queue;
2415        state.diagnostics.debug_layer = ctx.debug_layer;
2416        // Issue #268: persist any in-frame `set_inspector` change on quit too.
2417        state.diagnostics.inspector_mode = ctx.inspector_mode;
2418        // Issue #208 / #217: persist focus tracking state on quit so a later
2419        // resumed run starts in a sensible place. (Real TUI exits before
2420        // resuming, but tests reuse `FrameState` across calls.)
2421        state.focus.prev_focus_index = Some(ctx.focus_index);
2422        state.focus.focus_name_map_prev = ctx.focus_name_map;
2423        state.focus.pending_focus_name = ctx.pending_focus_name;
2424        // Issue #204: reclaim the 6 alloc-reuse buffers on the quit path
2425        // too. Real TUI exits ignore this, but TestBackend reuses the same
2426        // FrameState across `render()` calls — without the reclaim the next
2427        // frame's `Context::new` `mem::take`s an empty Vec and silently
2428        // reverts to v0.19 per-frame allocation.
2429        ctx.deferred_draws.clear();
2430        state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
2431        state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
2432        state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
2433        state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
2434        state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
2435        state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
2436        // Issue #273: reclaim the region-cache key buffers on quit too
2437        // (TestBackend reuses `FrameState` across `render()` calls — same
2438        // rationale as #204). The quit path skips `build_tree`, but the keys
2439        // recorded by any `cached` regions before `quit()` are still valid as
2440        // next frame's baseline.
2441        state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
2442        state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
2443        // Issue #150: reclaim `commands` on quit too (TestBackend reuses
2444        // `FrameState` across `render()` calls — same rationale as #204).
2445        // The Vec was never `build_tree`'d on the quit path so it may still
2446        // hold the recorded commands; clearing here drops them and keeps
2447        // capacity for the next frame.
2448        ctx.commands.clear();
2449        state.commands_buf = std::mem::take(&mut ctx.commands);
2450        #[cfg(feature = "crossterm")]
2451        let clipboard_text = ctx.clipboard_text.take();
2452        #[cfg(feature = "crossterm")]
2453        let should_copy_selection = false;
2454        return FrameKernelResult {
2455            should_quit: true,
2456            #[cfg(feature = "crossterm")]
2457            clipboard_text,
2458            #[cfg(feature = "crossterm")]
2459            should_copy_selection,
2460        };
2461    }
2462    state.focus.prev_modal_active = ctx.rollback.modal_active;
2463    state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
2464    state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
2465    #[cfg(feature = "crossterm")]
2466    let clipboard_text = ctx.clipboard_text.take();
2467    #[cfg(not(feature = "crossterm"))]
2468    let _clipboard_text = ctx.clipboard_text.take();
2469
2470    #[cfg(feature = "crossterm")]
2471    let mut should_copy_selection = false;
2472    #[cfg(feature = "crossterm")]
2473    for ev in &ctx.events {
2474        if let Event::Mouse(mouse) = ev {
2475            match mouse.kind {
2476                event::MouseKind::Down(event::MouseButton::Left) => {
2477                    state.selection.mouse_down(
2478                        mouse.x,
2479                        mouse.y,
2480                        &state.layout_feedback.prev_content_map,
2481                    );
2482                }
2483                event::MouseKind::Drag(event::MouseButton::Left) => {
2484                    state.selection.mouse_drag(
2485                        mouse.x,
2486                        mouse.y,
2487                        &state.layout_feedback.prev_content_map,
2488                    );
2489                }
2490                event::MouseKind::Up(event::MouseButton::Left) => {
2491                    should_copy_selection = state.selection.active;
2492                }
2493                _ => {}
2494            }
2495        }
2496    }
2497
2498    state.focus.focus_index = ctx.focus_index;
2499    state.focus.prev_focus_count = ctx.rollback.focus_count;
2500
2501    // Issue #150: `state.commands_buf` is swapped into `ctx.commands` on
2502    // entry (see `Context::new`), so the per-frame `Vec::new()` allocation
2503    // for the command list is amortized to one allocation across the
2504    // session. `build_tree` now takes `&mut Vec<Command>` and `drain`s it,
2505    // leaving the Vec at `len == 0` with capacity preserved. We reclaim
2506    // that Vec into `state.commands_buf` after the frame so the next call
2507    // to `Context::new` can pick it up via `mem::take` (matches the #204
2508    // pattern for the other six recycled buffers).
2509    let mut tree = layout::build_tree(&mut ctx.commands);
2510    let area = crate::rect::Rect::new(0, 0, w, h);
2511    layout::compute(&mut tree, area);
2512
2513    // Recover the previous feedback vectors as this frame's collection
2514    // scratch, then publish the newly collected vectors with a second swap.
2515    // This keeps both sides' capacities warm instead of `mem::take`-ing every
2516    // filled vector and leaving an empty FrameData behind.
2517    let mut fd = std::mem::take(&mut state.frame_data);
2518    fd.swap_feedback(&mut state.layout_feedback);
2519    layout::collect_all(&tree, &mut fd);
2520    debug_assert_eq!(
2521        fd.scroll_infos.len(),
2522        fd.scroll_rects.len(),
2523        "scroll feedback vectors must stay aligned"
2524    );
2525    fd.swap_feedback(&mut state.layout_feedback);
2526    let mut raw_rects = std::mem::take(&mut fd.raw_draw_rects);
2527    layout::render(&tree, buffer);
2528    let mut deferred_draw_panic = None;
2529    for rdr in raw_rects.drain(..) {
2530        if rdr.rect.width == 0 || rdr.rect.height == 0 {
2531            continue;
2532        }
2533        let Some(cb) = ctx
2534            .deferred_draws
2535            .get_mut(rdr.draw_id)
2536            .and_then(|c| c.take())
2537        else {
2538            continue;
2539        };
2540        if let Err(panic) = context::invoke_deferred_draw(
2541            buffer,
2542            rdr.rect,
2543            rdr.left_clip_cols,
2544            rdr.top_clip_rows,
2545            rdr.original_width,
2546            rdr.original_height,
2547            cb,
2548        ) {
2549            deferred_draw_panic = Some(panic);
2550            break;
2551        }
2552    }
2553    raw_rects.clear();
2554    fd.raw_draw_rects = raw_rects;
2555    state.frame_data = fd;
2556    debug_assert!(
2557        buffer.kitty_clip_info_stack.is_empty(),
2558        "kitty_clip_info_stack must be empty at end of frame"
2559    );
2560    debug_assert!(
2561        buffer.kitty_horizontal_clip_stack.is_empty(),
2562        "kitty_horizontal_clip_stack must be empty at end of frame"
2563    );
2564    state.hook_states = ctx.hook_states;
2565    state.named_states = ctx.named_states;
2566    // Issue #215: hand the keyed-state map back to FrameState so the next
2567    // frame can pick it up via `Context::new`. Mirrors the `named_states`
2568    // round-trip exactly.
2569    state.keyed_states = ctx.keyed_states;
2570    // Issue #262: hand the partial-chord buffer back so a chord spanning
2571    // multiple frames survives between them. Same round-trip as `keyed_states`.
2572    state.chord_states = ctx.chord;
2573    // Issue #248: hand the scheduler table back and GC any timer slot that was
2574    // not sampled this frame (mirrors the `named_states` round-trip lifecycle).
2575    let mut scheduler = ctx.scheduler;
2576    scheduler.gc_untouched();
2577    state.scheduler = scheduler;
2578    // Issue #234: hand the async task registry back so in-flight tasks and
2579    // pending results survive to the next frame (same round-trip lifecycle as
2580    // the scheduler table).
2581    #[cfg(feature = "async")]
2582    {
2583        // Pump the registry every frame (see the quit-path note): drains
2584        // completed results and honours handle-drop cancellations even on a
2585        // frame that called neither spawn nor poll.
2586        ctx.async_tasks.maintain();
2587        state.async_tasks = ctx.async_tasks;
2588    }
2589    state.screen_hook_map = ctx.screen_hook_map;
2590    state.diagnostics.notification_queue = ctx.rollback.notification_queue;
2591    // Issue #201: persist any in-frame `set_debug_layer` change.
2592    state.diagnostics.debug_layer = ctx.debug_layer;
2593    // Issue #268: persist any in-frame `set_inspector` change.
2594    state.diagnostics.inspector_mode = ctx.inspector_mode;
2595    // Issue #208: remember the focus index that finished this frame so the
2596    // next frame can compute `Response::gained_focus` / `lost_focus`.
2597    state.focus.prev_focus_index = Some(ctx.focus_index);
2598    // Issue #217: swap the freshly-built focus name map into the previous
2599    // slot for next-frame resolution; carry forward any unresolved pending
2600    // name (deferred until the named widget exists).
2601    state.focus.focus_name_map_prev = ctx.focus_name_map;
2602    state.focus.pending_focus_name = ctx.pending_focus_name;
2603
2604    // Issue #204: reclaim the six per-frame `Vec`/`HashSet` allocations so the
2605    // next frame reuses the existing capacity instead of allocating fresh.
2606    // Frame-end invariants (asserted above at lines 1102–1121):
2607    //   - `rollback.group_stack` and `rollback.text_color_stack` are empty
2608    //   - `pending_tooltips` is empty
2609    // `context_stack` is asserted-empty by the consumers in `widgets_*`
2610    // modules (provider/use_context); on the rare panic-rollback path the
2611    // checkpoint truncates it back to the saved length, so we still
2612    // recover capacity.
2613    //
2614    // `deferred_draws`: most slots are emptied by the `take()` above, but
2615    // entries whose `RawDrawRect` had `width == 0 || height == 0` are
2616    // skipped at the loop guard and remain `Some(_)`. We explicitly
2617    // `clear()` to drop those callbacks here so they don't outlive the
2618    // frame; capacity is preserved. (Leaving them would not cause UB —
2619    // `Context::new` calls `.clear()` on the reclaimed Vec — but dropping
2620    // promptly matches user expectation that one-shot callbacks don't
2621    // survive past their frame.)
2622    //
2623    // `hovered_groups`: `clear()`-ed at the start of every frame inside
2624    // `build_hovered_groups`, so the existing entries are harmless to
2625    // reclaim with content; capacity is preserved.
2626    ctx.deferred_draws.clear();
2627    state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
2628    state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
2629    state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
2630    state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
2631    state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
2632    state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
2633    // Issue #273: this frame's recorded `cached` keys become next frame's
2634    // comparison baseline; the (now-stale) previous keys are reclaimed as the
2635    // recycled scratch buffer. Same alloc-reuse discipline as `commands_buf`.
2636    state.region_versions = std::mem::take(&mut ctx.region_versions_cur);
2637    state.region_versions_buf = std::mem::take(&mut ctx.region_versions_prev);
2638    // Issue #150: reclaim the drained command Vec so the next `Context::new`
2639    // picks it up via `mem::take(&mut state.commands_buf)`. After
2640    // `build_tree(&mut ctx.commands)` the Vec is at `len == 0` with capacity
2641    // preserved; mirror the #204 reclamation pattern for the other six
2642    // per-frame buffers.
2643    state.commands_buf = std::mem::take(&mut ctx.commands);
2644
2645    let frame_time = frame_start.elapsed();
2646    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
2647    let frame_secs = frame_time.as_secs_f32();
2648    let inst_fps = if frame_secs > 0.0 {
2649        1.0 / frame_secs
2650    } else {
2651        0.0
2652    };
2653    state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
2654        inst_fps
2655    } else {
2656        (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
2657    };
2658    if state.diagnostics.debug_mode {
2659        layout::render_debug_overlay(
2660            &tree,
2661            buffer,
2662            frame_time_us,
2663            state.diagnostics.fps_ema,
2664            state.diagnostics.debug_layer,
2665        );
2666    }
2667    // Issue #268: render the devtools inspector panel (Ctrl+F12) on top of the
2668    // frame. Reuses the already-built tree and the focus snapshot threaded in
2669    // from `FrameState` (no new traversal beyond one focused-node DFS). The
2670    // name map was already swapped into `focus_name_map_prev` above, so it
2671    // reflects this frame's registrations.
2672    if state.diagnostics.inspector_mode {
2673        let focus = layout::InspectorFocus {
2674            focus_index: state.focus.focus_index,
2675            focus_count: state.focus.prev_focus_count,
2676            names: &state.focus.focus_name_map_prev,
2677            theme: &config.theme,
2678        };
2679        layout::render_inspector(&tree, buffer, &focus);
2680    }
2681
2682    // The callback executed after layout, so no Context fallback can safely
2683    // run here. Restore every persistent frame field first, then preserve the
2684    // original panic for the owning runtime or outer catch_unwind boundary.
2685    if let Some(panic) = deferred_draw_panic {
2686        std::panic::resume_unwind(panic);
2687    }
2688
2689    FrameKernelResult {
2690        should_quit: false,
2691        #[cfg(feature = "crossterm")]
2692        clipboard_text,
2693        #[cfg(feature = "crossterm")]
2694        should_copy_selection,
2695    }
2696}
2697
2698fn run_frame(
2699    term: &mut impl Backend,
2700    state: &mut FrameState,
2701    config: &RunConfig,
2702    events: Vec<event::Event>,
2703    terminal_side_effects: bool,
2704    f: &mut impl FnMut(&mut context::Context),
2705) -> io::Result<bool> {
2706    let size = term.size();
2707    let kernel = run_frame_kernel(
2708        term.buffer_mut(),
2709        state,
2710        config,
2711        size,
2712        events,
2713        terminal_side_effects,
2714        f,
2715    );
2716    if kernel.should_quit {
2717        return Ok(false);
2718    }
2719
2720    #[cfg(feature = "crossterm")]
2721    if state.selection.active {
2722        terminal::apply_selection_overlay(
2723            term.buffer_mut(),
2724            &state.selection,
2725            &state.layout_feedback.prev_content_map,
2726        );
2727    }
2728    #[cfg(feature = "crossterm")]
2729    if terminal_side_effects && kernel.should_copy_selection {
2730        let text = terminal::extract_selection_text(
2731            term.buffer_mut(),
2732            &state.selection,
2733            &state.layout_feedback.prev_content_map,
2734        );
2735        if !text.is_empty() {
2736            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
2737        }
2738        state.selection.clear();
2739    }
2740
2741    term.flush()?;
2742    #[cfg(feature = "crossterm")]
2743    if terminal_side_effects && let Some(text) = kernel.clipboard_text {
2744        #[allow(clippy::print_stderr)]
2745        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
2746            eprintln!("[slt] failed to copy to clipboard: {e}");
2747        }
2748    }
2749    state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
2750
2751    Ok(true)
2752}
2753
2754#[cfg(feature = "crossterm")]
2755fn clear_frame_layout_cache(state: &mut FrameState) {
2756    state.layout_feedback.prev_hit_map.clear();
2757    state.layout_feedback.prev_group_rects.clear();
2758    state.layout_feedback.prev_content_map.clear();
2759    state.layout_feedback.prev_focus_rects.clear();
2760    state.layout_feedback.prev_focus_groups.clear();
2761    state.layout_feedback.prev_scroll_infos.clear();
2762    state.layout_feedback.prev_scroll_rects.clear();
2763    state.layout_feedback.last_mouse_pos = None;
2764    // Issue #273: a resize may change the geometry of every cached region, so
2765    // the previous frame's version keys are no longer a safe stability signal.
2766    // Dropping them forces a cache miss for all `cached` regions on the next
2767    // frame, matching the layout-feedback invalidation above.
2768    state.region_versions.clear();
2769}
2770
2771#[cfg(feature = "crossterm")]
2772fn is_ctrl_c(ev: &Event) -> bool {
2773    matches!(
2774        ev,
2775        Event::Key(event::KeyEvent {
2776            code: KeyCode::Char('c'),
2777            modifiers,
2778            kind: event::KeyEventKind::Press,
2779        }) if modifiers.contains(KeyModifiers::CONTROL)
2780    )
2781}
2782
2783#[cfg(feature = "crossterm")]
2784fn is_ctrl_z(ev: &Event) -> bool {
2785    matches!(
2786        ev,
2787        Event::Key(event::KeyEvent {
2788            code: KeyCode::Char('z'),
2789            modifiers,
2790            kind: event::KeyEventKind::Press,
2791        }) if modifiers.contains(KeyModifiers::CONTROL)
2792    )
2793}
2794
2795#[cfg(feature = "crossterm")]
2796fn sleep_for_fps_cap(max_fps: Option<u32>, loop_elapsed: Duration) {
2797    if let Some(remaining) = fps_sleep_duration(max_fps, loop_elapsed) {
2798        std::thread::sleep(remaining);
2799    }
2800}
2801
2802#[cfg(feature = "crossterm")]
2803fn fps_sleep_duration(max_fps: Option<u32>, loop_elapsed: Duration) -> Option<Duration> {
2804    let fps = max_fps.filter(|fps| *fps > 0)?;
2805    let target = Duration::from_secs_f64(1.0 / fps as f64);
2806    (loop_elapsed < target).then(|| target - loop_elapsed)
2807}
2808
2809#[cfg(all(test, feature = "crossterm"))]
2810mod run_loop_tests {
2811    //! Issue #201 regression tests for the run-loop F12 / Shift+F12
2812    //! keybinding handler. Exercises [`process_run_loop_event`] directly
2813    //! so we don't need a real crossterm event source.
2814    use super::*;
2815
2816    static PANIC_HOOK_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
2817
2818    fn key(modifiers: event::KeyModifiers) -> Event {
2819        Event::Key(event::KeyEvent {
2820            code: KeyCode::F(12),
2821            kind: event::KeyEventKind::Press,
2822            modifiers,
2823        })
2824    }
2825
2826    fn char_key(ch: char, modifiers: event::KeyModifiers) -> Event {
2827        Event::Key(event::KeyEvent {
2828            code: KeyCode::Char(ch),
2829            kind: event::KeyEventKind::Press,
2830            modifiers,
2831        })
2832    }
2833
2834    #[test]
2835    fn terminal_text_sanitizer_replaces_control_bytes() {
2836        assert_eq!(
2837            sanitize_terminal_text("safe\x1b]52;c;AAAA\x07text\u{9b}tail"),
2838            "safe?]52;c;AAAA?text?tail"
2839        );
2840    }
2841
2842    #[test]
2843    fn fps_pacing_accounts_for_polling_and_rendering_together() {
2844        let remaining = fps_sleep_duration(Some(60), Duration::from_millis(16))
2845            .expect("a sub-frame remainder remains");
2846        assert!(remaining < Duration::from_millis(1));
2847        assert_eq!(
2848            fps_sleep_duration(Some(60), Duration::from_millis(20)),
2849            None,
2850            "a slow whole-loop iteration must not sleep again"
2851        );
2852        assert_eq!(fps_sleep_duration(None, Duration::ZERO), None);
2853    }
2854
2855    #[test]
2856    fn terminal_endpoint_validation_reports_each_non_tty_case() {
2857        assert!(validate_terminal_endpoints(true, true).is_ok());
2858        for endpoints in [(false, true), (true, false), (false, false)] {
2859            let err = validate_terminal_endpoints(endpoints.0, endpoints.1).unwrap_err();
2860            assert_eq!(err.kind(), io::ErrorKind::NotConnected);
2861        }
2862    }
2863
2864    #[test]
2865    fn panic_hook_tracks_nested_session_ownership() {
2866        let _serial = PANIC_HOOK_TEST_GUARD
2867            .lock()
2868            .unwrap_or_else(std::sync::PoisonError::into_inner);
2869        let outer = install_panic_hook();
2870        let inner = install_panic_hook();
2871        {
2872            let state = PANIC_HOOK_STATE
2873                .lock()
2874                .unwrap_or_else(std::sync::PoisonError::into_inner);
2875            assert_eq!(state.active_sessions, 2);
2876            assert!(state.installed);
2877        }
2878        drop(inner);
2879        assert_eq!(
2880            PANIC_HOOK_STATE
2881                .lock()
2882                .unwrap_or_else(std::sync::PoisonError::into_inner)
2883                .active_sessions,
2884            1
2885        );
2886        drop(outer);
2887        let state = PANIC_HOOK_STATE
2888            .lock()
2889            .unwrap_or_else(std::sync::PoisonError::into_inner);
2890        assert_eq!(state.active_sessions, 0);
2891        assert!(!state.installed);
2892        assert!(state.previous.is_none());
2893        drop(state);
2894
2895        let caught = std::panic::catch_unwind(|| {
2896            with_session_panic_hook(|| panic!("session panic"));
2897        });
2898        assert!(caught.is_err());
2899        let state = PANIC_HOOK_STATE
2900            .lock()
2901            .unwrap_or_else(std::sync::PoisonError::into_inner);
2902        assert_eq!(state.active_sessions, 0);
2903        assert!(!state.installed);
2904        assert!(state.previous.is_none());
2905    }
2906
2907    #[test]
2908    fn static_lines_are_sanitized_before_scrollback_write() {
2909        let lines = vec!["ok\x1b[31mred\x07".to_string()];
2910        let mut out = Vec::new();
2911        write_static_lines_to(&mut out, &lines).unwrap();
2912        assert_eq!(out, b"ok?[31mred?\r\n");
2913    }
2914
2915    #[test]
2916    fn quit_frame_keeps_final_static_log_available_for_drain() {
2917        let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 2));
2918        let mut state = FrameState::default();
2919        let result = run_frame_kernel(
2920            &mut buffer,
2921            &mut state,
2922            &RunConfig::default(),
2923            (20, 2),
2924            Vec::new(),
2925            false,
2926            &mut |ui| {
2927                ui.static_log("final line");
2928                ui.quit();
2929            },
2930        );
2931
2932        assert!(result.should_quit);
2933        assert_eq!(drain_static_log(&mut state), vec!["final line"]);
2934    }
2935
2936    #[test]
2937    fn ctrl_z_suspend_key_is_detected_separately_from_ctrl_c() {
2938        assert!(is_ctrl_z(&char_key('z', event::KeyModifiers::CONTROL)));
2939        assert!(!is_ctrl_z(&char_key('z', event::KeyModifiers::NONE)));
2940        assert!(is_ctrl_c(&char_key('c', event::KeyModifiers::CONTROL)));
2941        assert!(!is_ctrl_c(&char_key('z', event::KeyModifiers::CONTROL)));
2942    }
2943
2944    #[test]
2945    fn plain_f12_toggles_debug_mode() {
2946        let mut state = FrameState::default();
2947        let mut has_resize = false;
2948        assert!(!state.diagnostics.debug_mode);
2949        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2950        assert!(state.diagnostics.debug_mode);
2951        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
2952        assert!(!state.diagnostics.debug_mode);
2953    }
2954
2955    #[test]
2956    fn shift_f12_cycles_debug_layer_without_toggling_overlay() {
2957        let mut state = FrameState::default();
2958        let mut has_resize = false;
2959        // Default layer is `All`; debug overlay starts off.
2960        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2961        assert!(!state.diagnostics.debug_mode);
2962
2963        process_run_loop_event(
2964            &key(event::KeyModifiers::SHIFT),
2965            &mut state,
2966            &mut has_resize,
2967        );
2968        assert_eq!(state.diagnostics.debug_layer, DebugLayer::TopMost);
2969        // Cycling does not flip the on/off state.
2970        assert!(!state.diagnostics.debug_mode);
2971
2972        process_run_loop_event(
2973            &key(event::KeyModifiers::SHIFT),
2974            &mut state,
2975            &mut has_resize,
2976        );
2977        assert_eq!(state.diagnostics.debug_layer, DebugLayer::BaseOnly);
2978
2979        process_run_loop_event(
2980            &key(event::KeyModifiers::SHIFT),
2981            &mut state,
2982            &mut has_resize,
2983        );
2984        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
2985    }
2986
2987    #[test]
2988    fn shift_f12_does_not_also_toggle_overlay() {
2989        // Regression for the modifier disambiguation: pre-fix, the F12
2990        // arm matched `..` modifiers so Shift+F12 would both cycle the
2991        // layer AND toggle the overlay on the same press.
2992        let mut state = FrameState::default();
2993        let mut has_resize = false;
2994        let before = state.diagnostics.debug_mode;
2995        process_run_loop_event(
2996            &key(event::KeyModifiers::SHIFT),
2997            &mut state,
2998            &mut has_resize,
2999        );
3000        assert_eq!(
3001            state.diagnostics.debug_mode, before,
3002            "Shift+F12 must not flip the on/off toggle"
3003        );
3004    }
3005
3006    #[test]
3007    fn plain_f12_does_not_cycle_layer() {
3008        // Symmetric guard: pressing plain F12 must not change the active
3009        // layer, only the on/off flag.
3010        let mut state = FrameState::default();
3011        let mut has_resize = false;
3012        let before = state.diagnostics.debug_layer;
3013        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
3014        assert_eq!(state.diagnostics.debug_layer, before);
3015    }
3016
3017    #[cfg(feature = "async")]
3018    #[test]
3019    fn async_message_drain_reports_disconnect_after_sender_drop() {
3020        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
3021        tx.try_send(1u8).expect("channel has capacity");
3022        tx.try_send(2u8).expect("channel has capacity");
3023        drop(tx);
3024
3025        let mut messages = Vec::new();
3026        let disconnected = drain_async_messages(&mut rx, &mut messages, 4);
3027
3028        assert!(disconnected);
3029        assert_eq!(messages, vec![1, 2]);
3030    }
3031
3032    #[cfg(feature = "async")]
3033    #[test]
3034    fn async_message_drain_respects_per_frame_budget_and_fifo_order() {
3035        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
3036        for message in 0u8..6 {
3037            tx.try_send(message).expect("channel has capacity");
3038        }
3039
3040        let mut first = Vec::new();
3041        assert!(!drain_async_messages(&mut rx, &mut first, 2));
3042        assert_eq!(first, vec![0, 1]);
3043
3044        let mut second = Vec::new();
3045        assert!(!drain_async_messages(&mut rx, &mut second, 3));
3046        assert_eq!(second, vec![2, 3, 4]);
3047
3048        drop(tx);
3049        let mut final_batch = Vec::new();
3050        assert!(drain_async_messages(&mut rx, &mut final_batch, 3));
3051        assert_eq!(final_batch, vec![5]);
3052    }
3053
3054    #[test]
3055    fn async_message_budget_normalizes_zero_to_one() {
3056        assert_eq!(
3057            RunConfig::default()
3058                .async_message_budget(0)
3059                .async_message_budget,
3060            1
3061        );
3062    }
3063
3064    #[cfg(feature = "async")]
3065    fn test_async_handle(
3066        task: impl FnOnce(std::sync::Arc<std::sync::atomic::AtomicBool>) -> io::Result<()>
3067        + Send
3068        + 'static,
3069    ) -> AsyncRunHandle<()> {
3070        let (tx, _rx) = tokio::sync::mpsc::channel(1);
3071        let wake = std::sync::Arc::new(AsyncWake::default());
3072        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
3073        let task_cancel = std::sync::Arc::clone(&cancel);
3074        let join = tokio::task::spawn_blocking(move || task(task_cancel));
3075        AsyncRunHandle {
3076            sender: Some(AsyncSender {
3077                inner: tx,
3078                wake: std::sync::Arc::clone(&wake),
3079            }),
3080            join: Some(join),
3081            cancel,
3082            wake,
3083            cancel_on_drop: true,
3084        }
3085    }
3086
3087    #[cfg(feature = "async")]
3088    #[tokio::test]
3089    async fn async_run_handle_observes_normal_and_io_completion() {
3090        test_async_handle(|_| Ok(())).await.unwrap();
3091
3092        let error = test_async_handle(|_| {
3093            Err(io::Error::new(
3094                io::ErrorKind::BrokenPipe,
3095                "deterministic failure",
3096            ))
3097        })
3098        .await
3099        .unwrap_err();
3100        assert!(matches!(
3101            error,
3102            AsyncRunError::Io(ref err) if err.kind() == io::ErrorKind::BrokenPipe
3103        ));
3104    }
3105
3106    #[cfg(feature = "async")]
3107    #[tokio::test]
3108    async fn async_run_handle_observes_panics_and_cancels_cooperatively() {
3109        let panic_error = test_async_handle(|_| panic!("render panic"))
3110            .await
3111            .unwrap_err();
3112        assert!(matches!(
3113            panic_error,
3114            AsyncRunError::Join(ref err) if err.is_panic()
3115        ));
3116
3117        let handle = test_async_handle(|cancel| {
3118            while !cancel.load(std::sync::atomic::Ordering::Acquire) {
3119                std::thread::sleep(Duration::from_millis(1));
3120            }
3121            Ok(())
3122        });
3123        handle.cancel_and_join().await.unwrap();
3124    }
3125
3126    #[cfg(feature = "async")]
3127    #[tokio::test]
3128    async fn async_sender_notifies_on_send_and_last_clone_drop() {
3129        let (tx, mut rx) = tokio::sync::mpsc::channel(2);
3130        let wake = std::sync::Arc::new(AsyncWake::default());
3131        let sender = AsyncSender {
3132            inner: tx,
3133            wake: std::sync::Arc::clone(&wake),
3134        };
3135
3136        let before_send = wake.generation();
3137        sender.send(7u8).await.unwrap();
3138        assert_ne!(wake.generation(), before_send);
3139        assert_eq!(rx.recv().await, Some(7));
3140
3141        let clone = sender.clone();
3142        let before_drop = wake.generation();
3143        drop(clone);
3144        assert_ne!(wake.generation(), before_drop);
3145    }
3146
3147    // ── Issue #268: Ctrl+F12 devtools inspector toggle ───────────────────
3148
3149    #[test]
3150    fn ctrl_f12_toggles_inspector_independently() {
3151        let mut state = FrameState::default();
3152        let mut has_resize = false;
3153        assert!(!state.diagnostics.inspector_mode);
3154
3155        // Ctrl+F12 flips the inspector without touching debug overlay state.
3156        process_run_loop_event(
3157            &key(event::KeyModifiers::CONTROL),
3158            &mut state,
3159            &mut has_resize,
3160        );
3161        assert!(state.diagnostics.inspector_mode);
3162        assert!(
3163            !state.diagnostics.debug_mode,
3164            "Ctrl+F12 must not toggle the F12 outline overlay"
3165        );
3166        assert_eq!(
3167            state.diagnostics.debug_layer,
3168            DebugLayer::All,
3169            "Ctrl+F12 must not cycle the debug layer"
3170        );
3171
3172        // A second Ctrl+F12 toggles it back off.
3173        process_run_loop_event(
3174            &key(event::KeyModifiers::CONTROL),
3175            &mut state,
3176            &mut has_resize,
3177        );
3178        assert!(!state.diagnostics.inspector_mode);
3179    }
3180
3181    #[test]
3182    fn plain_and_shift_f12_do_not_touch_inspector() {
3183        let mut state = FrameState::default();
3184        let mut has_resize = false;
3185        // Plain F12 (overlay toggle) leaves the inspector alone.
3186        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
3187        assert!(state.diagnostics.debug_mode);
3188        assert!(!state.diagnostics.inspector_mode);
3189        // Shift+F12 (layer cycle) also leaves the inspector alone.
3190        process_run_loop_event(
3191            &key(event::KeyModifiers::SHIFT),
3192            &mut state,
3193            &mut has_resize,
3194        );
3195        assert!(!state.diagnostics.inspector_mode);
3196    }
3197
3198    // ── Issue #263: RunConfig::handle_suspend ────────────────────────────
3199
3200    #[test]
3201    fn handle_suspend_defaults_to_true() {
3202        assert!(RunConfig::default().handle_suspend);
3203    }
3204
3205    #[test]
3206    fn handle_suspend_builder_opts_out() {
3207        let cfg = RunConfig::default().handle_suspend(false);
3208        assert!(!cfg.handle_suspend);
3209    }
3210
3211    #[test]
3212    fn handle_suspend_builder_is_independent_of_ctrl_c() {
3213        // Toggling suspend must not perturb the unrelated Ctrl+C toggle.
3214        let cfg = RunConfig::default()
3215            .handle_ctrl_c(false)
3216            .handle_suspend(false);
3217        assert!(!cfg.handle_ctrl_c);
3218        assert!(!cfg.handle_suspend);
3219
3220        let cfg = RunConfig::default().handle_suspend(true);
3221        assert!(cfg.handle_suspend);
3222        assert!(cfg.handle_ctrl_c, "Ctrl+C default preserved");
3223    }
3224
3225    // ── v0.21.1: resize debounce / coalesce ─────────────────────────────
3226
3227    fn resize(w: u32, h: u32) -> Event {
3228        Event::Resize(w, h)
3229    }
3230
3231    #[test]
3232    fn resize_batch_coalesces_to_single_invocation() {
3233        // Three resize events in one poll batch must collapse to exactly one
3234        // `on_resize` call (the helper that drives the single end-of-batch
3235        // call in `poll_events`). The final size is irrelevant to the count —
3236        // `handle_resize` re-reads `terminal::size()` — but we feed distinct
3237        // sizes to mirror a real drag burst.
3238        let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)];
3239        assert_eq!(
3240            resize_invocations_for_batch(&batch),
3241            1,
3242            "a burst of resizes must coalesce to one on_resize"
3243        );
3244    }
3245
3246    #[test]
3247    fn resize_batch_without_resize_invokes_zero_times() {
3248        // A batch with no resize event must not trigger `on_resize` at all.
3249        let batch = vec![key(event::KeyModifiers::NONE)];
3250        assert_eq!(resize_invocations_for_batch(&batch), 0);
3251        // Empty batch is likewise a no-op.
3252        assert_eq!(resize_invocations_for_batch(&[]), 0);
3253    }
3254
3255    #[test]
3256    fn resize_coalesce_uses_final_size_via_has_resize_flag() {
3257        // The single deferred `on_resize` is gated on `has_resize`, which
3258        // `process_run_loop_event` sets to `true` for any resize in the batch.
3259        // Feeding three resizes leaves the flag set once (idempotent), and the
3260        // coalescing helper agrees — this is exactly the `debug_assert_eq!`
3261        // invariant `poll_events` checks before its single `on_resize` call.
3262        let mut state = FrameState::default();
3263        let mut has_resize = false;
3264        let batch = vec![resize(80, 24), resize(100, 30), resize(120, 40)];
3265        for ev in &batch {
3266            process_run_loop_event(ev, &mut state, &mut has_resize);
3267        }
3268        assert!(has_resize, "any resize in the batch must set has_resize");
3269        assert_eq!(
3270            usize::from(has_resize),
3271            resize_invocations_for_batch(&batch)
3272        );
3273    }
3274
3275    /// End-to-end test of the real signal-delivery wiring: install the
3276    /// handler, deliver a real `SIGCONT` through signal-hook's registry +
3277    /// background thread, then drop the guard and confirm it closes the
3278    /// registration and joins the thread without hanging or panicking.
3279    ///
3280    /// `SIGCONT`'s default disposition is "continue", so it is safe to raise on
3281    /// the running test process — unlike `SIGTSTP`, which would stop the test
3282    /// runner. The suspend (`SIGTSTP`) sequence itself is covered hermetically
3283    /// by the `write_suspend_sequence` unit tests in `terminal`.
3284    #[cfg(unix)]
3285    #[test]
3286    fn suspend_handler_installs_delivers_and_tears_down() {
3287        // In constrained sandboxes signal registration can fail; if so the
3288        // wiring under test cannot be exercised, so skip rather than flake.
3289        let Ok(guard) = install_suspend_handler(terminal::test_session_snapshot()) else {
3290            return;
3291        };
3292
3293        // Deliver a real SIGCONT; the background thread must drain it. With no
3294        // prior SIGTSTP the handler's `has_terminal` guard makes this a no-op
3295        // re-enter (idempotency), which is exactly what we want to verify does
3296        // not corrupt state or crash the thread.
3297        let _ = signal_hook::low_level::raise(signal_hook::consts::SIGCONT);
3298        std::thread::sleep(Duration::from_millis(50));
3299
3300        // Dropping the guard closes the registration and joins the thread.
3301        // If `Handle::close` failed to wake `Signals::forever`, this hangs and
3302        // the test times out — a real regression signal.
3303        drop(guard);
3304    }
3305}