Skip to main content

slt/
lib.rs

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