Skip to main content

slt/
lib.rs

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