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