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}