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