Skip to main content

slt/
lib.rs

1//! SuperLightTUI — an immediate-mode flexbox-layout terminal UI library.
2//!
3//! Build a TUI as easily as a web page: write a closure, SLT calls it
4//! every frame. State lives in your code; layout is described every
5//! frame; styling uses Tailwind-inspired shorthand; focus and events are
6//! threaded through a single [`Context`] parameter.
7//!
8//! See `docs/QUICK_START.md` for a 5-minute introduction and
9//! `docs/DESIGN_PRINCIPLES.md` for the principles every public API
10//! follows.
11//!
12//! # Example
13//!
14//! ```no_run
15//! fn main() -> std::io::Result<()> {
16//!     slt::run(|ui| {
17//!         ui.text("hello, world");
18//!     })
19//! }
20//! ```
21
22// Safety
23#![forbid(unsafe_code)]
24// Documentation
25#![cfg_attr(docsrs, feature(doc_cfg))]
26#![warn(rustdoc::broken_intra_doc_links)]
27#![warn(missing_docs)]
28#![warn(rustdoc::private_intra_doc_links)]
29// Correctness
30#![deny(clippy::unwrap_in_result)]
31#![warn(clippy::unwrap_used)]
32// Library hygiene — a library must not write to stdout/stderr
33#![warn(clippy::dbg_macro)]
34#![warn(clippy::print_stdout)]
35#![warn(clippy::print_stderr)]
36
37//! # SLT — Super Light TUI
38//!
39//! Immediate-mode terminal UI for Rust. Small core. Zero `unsafe`.
40//!
41//! SLT gives you an egui-style API for terminals: your closure runs each frame,
42//! you describe your UI, and SLT handles layout, diffing, and rendering.
43//!
44//! ## Quick Start
45//!
46//! ```no_run
47//! fn main() -> std::io::Result<()> {
48//!     slt::run(|ui| {
49//!         ui.text("hello, world");
50//!     })
51//! }
52//! ```
53//!
54//! ## Features
55//!
56//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
57//! - **50+ built-in widgets** — input, textarea, table, list, tabs, button, checkbox, toggle, spinner, progress, toast, slider, separator, help bar, scrollable, chart, bar chart, stacked bar chart, sparkline, histogram, heatmap, treemap, candlestick, canvas, grid, select, radio, multi-select, tree, virtual list, command palette, markdown, alert, badge, stat, breadcrumb, accordion, code block, big text, image, modal, tooltip, form, calendar, file picker, qr code
58//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
59//! - **Mouse** — click, hover, drag-to-scroll
60//! - **Focus** — automatic Tab/Shift+Tab cycling
61//! - **Theming** — 10 presets, semantic tokens (`ThemeColor`), spacing scale, contrast helpers
62//! - **Animation** — tween and spring primitives with 9 easing functions
63//! - **Inline mode** — render below your prompt, no alternate screen
64//! - **Async** — optional tokio integration via `async` feature
65//! - **Layout debugger** — F12 to visualize container bounds
66//!
67//! ## Feature Flags
68//!
69//! | Flag | Description |
70//! |------|-------------|
71//! | `crossterm` | Built-in terminal runtime (`run`, `run_inline`, clipboard query helpers). Enabled by default. |
72//! | `async` | Enable `run_async()` with tokio channel-based message passing |
73//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
74//! | `image` | Enable image-loading helpers for terminal image widgets |
75//! | `qrcode` | Enable `ui.qr_code(...)` |
76//! | `syntax` / `syntax-*` | Enable tree-sitter syntax highlighting |
77//!
78//! ## Learn More
79//!
80//! - Guides index: <https://github.com/subinium/SuperLightTUI/blob/main/docs/README.md>
81//! - Quick start: <https://github.com/subinium/SuperLightTUI/blob/main/docs/QUICK_START.md>
82//! - Backends and run loops: <https://github.com/subinium/SuperLightTUI/blob/main/docs/BACKENDS.md>
83//! - Testing: <https://github.com/subinium/SuperLightTUI/blob/main/docs/TESTING.md>
84//! - Debugging: <https://github.com/subinium/SuperLightTUI/blob/main/docs/DEBUGGING.md>
85
86/// Animation primitives: tween, spring, keyframes, sequence, stagger.
87pub mod anim;
88/// Double-buffered cell grid with clip stack and diff tracking.
89pub mod buffer;
90/// Terminal cell representation.
91pub mod cell;
92/// Chart and data visualization widgets.
93pub mod chart;
94/// UI context, container builder, and widget rendering.
95pub mod context;
96/// Input events (keyboard, mouse, resize, paste).
97pub mod event;
98/// Half-block image rendering.
99pub mod halfblock;
100/// Keyboard shortcut mapping.
101pub mod keymap;
102/// Flexbox layout engine and command tree.
103pub mod layout;
104/// Color palettes (Tailwind-style).
105pub mod palette;
106/// Rectangular region type used throughout SLT layout.
107pub mod rect;
108#[cfg(feature = "crossterm")]
109mod sixel;
110/// Styling: colors, borders, padding, margins, themes, constraints.
111pub mod style;
112/// Tree-sitter syntax highlighting integration.
113pub mod syntax;
114#[cfg(feature = "crossterm")]
115mod terminal;
116/// Headless test utilities for unit-testing TUI closures.
117pub mod test_utils;
118/// Widget state types (list, table, input, select, etc.).
119pub mod widgets;
120
121use std::io;
122#[cfg(feature = "crossterm")]
123use std::io::IsTerminal;
124#[cfg(feature = "crossterm")]
125use std::io::Write;
126#[cfg(feature = "crossterm")]
127use std::sync::Once;
128use std::time::{Duration, Instant};
129
130#[doc(hidden)]
131pub use layout::__bench_dim_buffer_around;
132#[doc(hidden)]
133pub use layout::__bench_wrap_segments;
134#[cfg(feature = "crossterm")]
135#[doc(hidden)]
136pub use terminal::__bench_flush_buffer_diff;
137#[cfg(feature = "crossterm")]
138#[doc(hidden)]
139pub use terminal::__bench_flush_buffer_diff_mut;
140#[cfg(feature = "crossterm")]
141#[doc(hidden)]
142pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture};
143#[cfg(feature = "crossterm")]
144pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
145#[cfg(feature = "crossterm")]
146use terminal::{InlineTerminal, Terminal};
147
148pub use crate::test_utils::{EventBuilder, FrameRecord, TestBackend, TestSequence};
149// Animation primitives (builder types) are re-exported at crate root for
150// ergonomic `use slt::{Tween, Spring, ...}`. The easing functions and `lerp`
151// live under `slt::anim::*` — they are rarely imported in isolation and
152// keeping them out of the root shrinks the top-level surface.
153pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
154pub use buffer::Buffer;
155pub use cell::Cell;
156// Chart user-facing types at crate root; internals (`ChartRenderer`,
157// `RenderedLine`, `ColorSpan`, `DatasetEntry`, `HistogramBuilder`,
158// `GraphType`, `Axis`) live under `slt::chart::*`.
159pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
160pub use context::{
161    Anchor, Bar, BarChartConfig, BarDirection, BarGroup, Breadcrumb, CanvasContext,
162    ContainerBuilder, Context, Gauge, GutterOpts, LineGauge, Response, State, TreemapItem, Widget,
163};
164pub use event::{
165    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
166};
167pub use halfblock::HalfBlockImage;
168pub use keymap::{Binding, KeyMap, PublishedKeymap, WidgetKeyHelp};
169pub use layout::Direction;
170pub use palette::Palette;
171pub use rect::Rect;
172pub use style::{
173    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
174    HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, Theme, ThemeBuilder,
175    ThemeColor, WidgetColors, WidgetTheme, WidthSpec,
176};
177pub use widgets::{
178    AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalendarState,
179    CommandPaletteState, ContextItem, DirectoryTreeState, FileEntry, FilePickerState, FormField,
180    FormState, GaugeResponse, GridColumn, GutterResponse, HighlightRange, ListState, ModeState,
181    MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
182    ScrollState, SelectState, SpinnerState, SplitPaneResponse, SplitPaneState, StaticOutput,
183    StreamingMarkdownState, StreamingTextState, TableState, TabsState, TextInputState,
184    TextareaState, ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState,
185    Trend,
186};
187
188/// Rendering backend for SLT.
189///
190/// Implement this trait to render SLT UIs to custom targets — alternative
191/// terminals, GUI embeds, test harnesses, WASM canvas, etc.
192///
193/// The built-in terminal backend ([`run()`], [`run_with()`]) handles setup,
194/// teardown, and event polling automatically. For custom backends, pair this
195/// trait with [`AppState`] and [`frame()`] to drive the render loop yourself.
196///
197/// # Example
198///
199/// ```ignore
200/// use slt::{Backend, AppState, Buffer, Rect, RunConfig, Context, Event};
201///
202/// struct MyBackend {
203///     buffer: Buffer,
204/// }
205///
206/// impl Backend for MyBackend {
207///     fn size(&self) -> (u32, u32) {
208///         (self.buffer.area.width, self.buffer.area.height)
209///     }
210///     fn buffer_mut(&mut self) -> &mut Buffer {
211///         &mut self.buffer
212///     }
213///     fn flush(&mut self) -> std::io::Result<()> {
214///         // Render self.buffer to your target
215///         Ok(())
216///     }
217/// }
218///
219/// fn main() -> std::io::Result<()> {
220///     let mut backend = MyBackend {
221///         buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
222///     };
223///     let mut state = AppState::new();
224///     let config = RunConfig::default();
225///
226///     loop {
227///         let events: Vec<Event> = vec![]; // Collect your own events
228///         if !slt::frame(&mut backend, &mut state, &config, &events, &mut |ui| {
229///             ui.text("Hello from custom backend!");
230///         })? {
231///             break;
232///         }
233///     }
234///     Ok(())
235/// }
236/// ```
237pub trait Backend {
238    /// Returns the current display size as `(width, height)` in cells.
239    fn size(&self) -> (u32, u32);
240
241    /// Returns a mutable reference to the display buffer.
242    ///
243    /// SLT writes the UI into this buffer each frame. After [`frame()`]
244    /// returns, call [`flush()`](Backend::flush) to present the result.
245    fn buffer_mut(&mut self) -> &mut Buffer;
246
247    /// Flush the buffer contents to the display.
248    ///
249    /// Called automatically at the end of each [`frame()`] call. Implementations
250    /// should present the current buffer to the user — by writing ANSI escapes,
251    /// drawing to a canvas, updating a texture, etc.
252    fn flush(&mut self) -> io::Result<()>;
253}
254
255/// Opaque per-session state that persists between frames.
256///
257/// Tracks focus, scroll positions, hook state, and other frame-to-frame data.
258/// Create with [`AppState::new()`] and pass to [`frame()`] each iteration.
259///
260/// # Example
261///
262/// ```ignore
263/// let mut state = slt::AppState::new();
264/// // state is passed to slt::frame() in your render loop
265/// ```
266pub struct AppState {
267    pub(crate) inner: FrameState,
268}
269
270impl AppState {
271    /// Create a new empty application state.
272    pub fn new() -> Self {
273        Self {
274            inner: FrameState::default(),
275        }
276    }
277
278    /// Returns the current frame tick count (increments each frame).
279    pub fn tick(&self) -> u64 {
280        self.inner.diagnostics.tick
281    }
282
283    /// Returns the smoothed FPS estimate (exponential moving average).
284    pub fn fps(&self) -> f32 {
285        self.inner.diagnostics.fps_ema
286    }
287
288    /// Toggle the debug overlay (same as pressing F12).
289    pub fn set_debug(&mut self, enabled: bool) {
290        self.inner.diagnostics.debug_mode = enabled;
291    }
292}
293
294impl Default for AppState {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300/// Process a single UI frame with a custom [`Backend`].
301///
302/// This is the low-level entry point for custom backends. For standard terminal
303/// usage, prefer [`run()`] or [`run_with()`] which handle the event loop,
304/// terminal setup, and teardown automatically.
305///
306/// Returns `Ok(true)` to continue, `Ok(false)` when [`Context::quit()`] was
307/// called.
308///
309/// # Arguments
310///
311/// * `backend` — Your [`Backend`] implementation
312/// * `state` — Persistent [`AppState`] (reuse across frames)
313/// * `config` — [`RunConfig`] (theme, tick rate, etc.)
314/// * `events` — Input events for this frame (keyboard, mouse, resize)
315/// * `f` — Your UI closure, called once per frame
316///
317/// Build a fresh event slice each frame in your outer loop, then pass it here.
318/// `frame()` reads from that slice but does not own your event source.
319/// Reuse the same [`AppState`] for the lifetime of the session.
320///
321/// # Example
322///
323/// ```ignore
324/// let keep_going = slt::frame(
325///     &mut my_backend,
326///     &mut state,
327///     &config,
328///     &events,
329///     &mut |ui| { ui.text("hello"); },
330/// )?;
331/// ```
332pub fn frame(
333    backend: &mut impl Backend,
334    state: &mut AppState,
335    config: &RunConfig,
336    events: &[Event],
337    f: &mut impl FnMut(&mut Context),
338) -> io::Result<bool> {
339    frame_owned(backend, state, config, events.to_vec(), f)
340}
341
342/// Process a single UI frame, taking ownership of the events `Vec` (zero-copy).
343///
344/// Like [`frame`], but accepts an owned `Vec<Event>` to avoid the `to_vec()`
345/// copy `frame` performs internally. Prefer this in high-frequency custom
346/// render loops where you already own the event buffer.
347///
348/// # Example
349///
350/// ```ignore
351/// let events: Vec<slt::Event> = collect_events();
352/// let keep_going = slt::frame_owned(
353///     &mut my_backend,
354///     &mut state,
355///     &config,
356///     events,
357///     &mut |ui| { ui.text("hello"); },
358/// )?;
359/// ```
360pub fn frame_owned(
361    backend: &mut impl Backend,
362    state: &mut AppState,
363    config: &RunConfig,
364    events: Vec<Event>,
365    f: &mut impl FnMut(&mut Context),
366) -> io::Result<bool> {
367    run_frame(backend, &mut state.inner, config, events, f)
368}
369
370#[cfg(feature = "crossterm")]
371static PANIC_HOOK_ONCE: Once = Once::new();
372
373#[allow(clippy::print_stderr)]
374#[cfg(feature = "crossterm")]
375fn install_panic_hook() {
376    PANIC_HOOK_ONCE.call_once(|| {
377        let original = std::panic::take_hook();
378        std::panic::set_hook(Box::new(move |panic_info| {
379            let _ = crossterm::terminal::disable_raw_mode();
380            let mut stdout = io::stdout();
381            let _ = crossterm::execute!(
382                stdout,
383                crossterm::terminal::LeaveAlternateScreen,
384                crossterm::cursor::Show,
385                crossterm::event::DisableMouseCapture,
386                crossterm::event::DisableBracketedPaste,
387                crossterm::style::ResetColor,
388                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
389            );
390
391            // Print friendly panic header
392            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
393
394            // Print location if available
395            if let Some(location) = panic_info.location() {
396                eprintln!(
397                    "\x1b[90m{}:{}:{}\x1b[0m",
398                    location.file(),
399                    location.line(),
400                    location.column()
401                );
402            }
403
404            // Print message
405            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
406                eprintln!("\x1b[1m{}\x1b[0m", msg);
407            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
408                eprintln!("\x1b[1m{}\x1b[0m", msg);
409            }
410
411            eprintln!(
412                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
413            );
414
415            original(panic_info);
416        }));
417    });
418}
419
420/// Configuration for a TUI run loop.
421///
422/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
423/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
424/// This type is `#[non_exhaustive]`, so prefer builder methods instead of struct literals.
425///
426/// # Example
427///
428/// ```no_run
429/// use slt::{RunConfig, Theme};
430/// use std::time::Duration;
431///
432/// let config = RunConfig::default()
433///     .tick_rate(Duration::from_millis(50))
434///     .mouse(true)
435///     .theme(Theme::light())
436///     .max_fps(60);
437/// ```
438#[non_exhaustive]
439#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
440pub struct RunConfig {
441    /// How long to wait for input before triggering a tick with no events.
442    ///
443    /// Lower values give smoother animations at the cost of more CPU usage.
444    /// Defaults to 16ms (60fps).
445    pub tick_rate: Duration,
446    /// Whether to enable mouse event reporting.
447    ///
448    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
449    /// Defaults to `false`.
450    pub mouse: bool,
451    /// Whether to enable the Kitty keyboard protocol for enhanced input.
452    ///
453    /// When `true`, enables disambiguated key events, key release events,
454    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
455    /// Terminals that don't support it silently ignore the request.
456    /// Defaults to `false`.
457    pub kitty_keyboard: bool,
458    /// The color theme applied to all widgets automatically.
459    ///
460    /// Defaults to [`Theme::dark()`].
461    pub theme: Theme,
462    /// Color depth override.
463    ///
464    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
465    /// variables. Set explicitly to force a specific color depth regardless
466    /// of terminal capabilities.
467    pub color_depth: Option<ColorDepth>,
468    /// Optional maximum frame rate.
469    ///
470    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
471    /// loop iteration to target that frame time.
472    pub max_fps: Option<u32>,
473    /// Lines scrolled per mouse scroll event. Defaults to 1.
474    pub scroll_speed: u32,
475    /// Optional terminal window title (set via OSC 2).
476    pub title: Option<String>,
477    /// Default colors applied to all instances of each widget type.
478    ///
479    /// Per-callsite `_colored()` overrides still take precedence.
480    /// Defaults to all-`None` (use theme colors).
481    pub widget_theme: style::WidgetTheme,
482    /// Whether the runtime intercepts Ctrl+C and exits the loop cleanly.
483    ///
484    /// When `true` (the default), Ctrl+C is treated as a quit signal —
485    /// matching the v0.19 behavior. When `false`, the Ctrl+C key event flows
486    /// through to the frame closure as a regular [`Event::Key`], matching
487    /// RataTUI's raw-mode semantics. The user is then responsible for
488    /// deciding whether to call [`Context::quit`] or treat it as any other
489    /// shortcut (e.g. clear input, cancel current operation).
490    ///
491    /// Set this to `false` when migrating code from RataTUI that already
492    /// handles Ctrl+C explicitly, or when implementing a graceful-shutdown
493    /// prompt (e.g. "save unsaved changes?").
494    ///
495    /// # Example
496    ///
497    /// ```no_run
498    /// # use slt::{KeyCode, KeyModifiers, RunConfig};
499    /// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
500    ///     // Ctrl+C now reaches your closure as a normal key event.
501    ///     if ui.key_mod('c', KeyModifiers::CONTROL) {
502    ///         // Decide what to do — clear input, prompt to save, quit, etc.
503    ///         ui.quit();
504    ///     }
505    /// }).unwrap();
506    /// ```
507    pub handle_ctrl_c: bool,
508}
509
510impl Default for RunConfig {
511    fn default() -> Self {
512        Self {
513            tick_rate: Duration::from_millis(16),
514            mouse: false,
515            kitty_keyboard: false,
516            theme: Theme::dark(),
517            color_depth: None,
518            max_fps: Some(60),
519            scroll_speed: 1,
520            title: None,
521            widget_theme: style::WidgetTheme::new(),
522            handle_ctrl_c: true,
523        }
524    }
525}
526
527impl RunConfig {
528    /// Set the tick rate (input polling interval).
529    pub fn tick_rate(mut self, rate: Duration) -> Self {
530        self.tick_rate = rate;
531        self
532    }
533
534    /// Enable or disable mouse event reporting.
535    pub fn mouse(mut self, enabled: bool) -> Self {
536        self.mouse = enabled;
537        self
538    }
539
540    /// Enable or disable Kitty keyboard protocol.
541    pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
542        self.kitty_keyboard = enabled;
543        self
544    }
545
546    /// Set the color theme.
547    pub fn theme(mut self, theme: Theme) -> Self {
548        self.theme = theme;
549        self
550    }
551
552    /// Override the color depth.
553    pub fn color_depth(mut self, depth: ColorDepth) -> Self {
554        self.color_depth = Some(depth);
555        self
556    }
557
558    /// Set the maximum frame rate.
559    pub fn max_fps(mut self, fps: u32) -> Self {
560        self.max_fps = Some(fps);
561        self
562    }
563
564    /// Disable the frame rate cap (unlimited FPS).
565    ///
566    /// By default, [`RunConfig`] caps rendering at 60 fps. Call this to remove
567    /// the cap entirely — useful when controlling external sleep/vsync.
568    ///
569    /// # Example
570    ///
571    /// ```no_run
572    /// slt::run_with(
573    ///     slt::RunConfig::default().no_fps_cap(),
574    ///     |ui| { ui.text("uncapped"); },
575    /// ).unwrap();
576    /// ```
577    pub fn no_fps_cap(mut self) -> Self {
578        self.max_fps = None;
579        self
580    }
581
582    /// Set the scroll speed (lines per scroll event).
583    pub fn scroll_speed(mut self, lines: u32) -> Self {
584        self.scroll_speed = lines.max(1);
585        self
586    }
587
588    /// Set the terminal window title.
589    pub fn title(mut self, title: impl Into<String>) -> Self {
590        self.title = Some(title.into());
591        self
592    }
593
594    /// Set default widget colors for all widget types.
595    pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
596        self.widget_theme = widget_theme;
597        self
598    }
599
600    /// Configure whether the runtime auto-exits on Ctrl+C.
601    ///
602    /// Defaults to `true` (current v0.19 behavior). Set to `false` to
603    /// receive Ctrl+C as a regular [`Event::Key`] inside the frame closure
604    /// — see [`RunConfig::handle_ctrl_c`] for the full migration story.
605    ///
606    /// # Example
607    ///
608    /// ```no_run
609    /// use slt::RunConfig;
610    /// let cfg = RunConfig::default().handle_ctrl_c(false);
611    /// assert!(!cfg.handle_ctrl_c);
612    /// ```
613    pub fn handle_ctrl_c(mut self, enabled: bool) -> Self {
614        self.handle_ctrl_c = enabled;
615        self
616    }
617}
618
619#[derive(Default)]
620pub(crate) struct FocusState {
621    pub focus_index: usize,
622    pub prev_focus_count: usize,
623    pub prev_modal_active: bool,
624    pub prev_modal_focus_start: usize,
625    pub prev_modal_focus_count: usize,
626    /// Issue #208: focus index at the end of the previous frame. `None` on
627    /// the first frame so widgets do not falsely report `gained_focus`.
628    pub prev_focus_index: Option<usize>,
629    /// Issue #217: persisted `name → focus_index` map from the most recent
630    /// completed frame. Used at frame start to resolve a pending
631    /// `focus_by_name(...)` against the previous render's registrations.
632    pub focus_name_map_prev: std::collections::HashMap<String, usize>,
633    /// Issue #217: a name passed to `focus_by_name(...)` that has not yet
634    /// been resolved. Consumed once the matching registration is found in
635    /// `focus_name_map_prev`.
636    pub pending_focus_name: Option<String>,
637}
638
639#[derive(Default)]
640pub(crate) struct LayoutFeedbackState {
641    pub prev_scroll_infos: Vec<(u32, u32)>,
642    pub prev_scroll_rects: Vec<rect::Rect>,
643    pub prev_hit_map: Vec<rect::Rect>,
644    pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
645    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
646    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
647    pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
648    pub last_mouse_pos: Option<(u32, u32)>,
649}
650
651#[derive(Default)]
652pub(crate) struct DiagnosticsState {
653    pub tick: u64,
654    pub notification_queue: Vec<(String, ToastLevel, u64)>,
655    pub debug_mode: bool,
656    pub debug_layer: DebugLayer,
657    pub fps_ema: f32,
658}
659
660/// Which layers the F12 debug overlay should outline (issue #201).
661///
662/// `All` (the default) outlines both the base layer and any active
663/// overlays/modals — matching the user's expectation for "show everything
664/// the renderer is producing this frame." `TopMost` only outlines the
665/// topmost overlay (or the base if no overlay is active), and `BaseOnly`
666/// keeps the legacy pre-fix behavior of skipping overlays entirely.
667///
668/// At runtime, **Shift+F12** cycles `All → TopMost → BaseOnly → All` so a
669/// developer debugging a stacked modal can shrink the visible outlines to
670/// just the layer they care about without leaving the keyboard. Plain
671/// **F12** independently toggles the overlay on/off.
672///
673/// # Example
674///
675/// ```no_run
676/// use slt::{Context, DebugLayer};
677///
678/// slt::run(|ui: &mut Context| {
679///     // Match on the current layer to drive bespoke debug UI.
680///     let label = match ui.debug_layer() {
681///         DebugLayer::All => "showing base + overlays",
682///         DebugLayer::TopMost => "showing topmost overlay only",
683///         DebugLayer::BaseOnly => "showing base layer only",
684///     };
685///     ui.text(label);
686/// })
687/// .unwrap();
688/// ```
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
690pub enum DebugLayer {
691    /// Outline both the base tree and every active overlay/modal.
692    ///
693    /// Default. Matches the reporter expectation that F12 reflects
694    /// everything the renderer is producing this frame. Each layer family
695    /// gets its own hue so a glance distinguishes base, overlay, and modal
696    /// containers.
697    #[default]
698    All,
699    /// Outline only the topmost overlay (or the base if no overlay is
700    /// active).
701    ///
702    /// Useful when modals or popovers stack and you only care about the
703    /// active dialog — base-tree outlines become noise underneath an open
704    /// modal.
705    TopMost,
706    /// Outline only the base layer (legacy v0.19.x behavior).
707    ///
708    /// Skips overlays and modals entirely. Use when an overlay is
709    /// confirmed correct and you want to inspect the base layout
710    /// underneath it.
711    BaseOnly,
712}
713
714/// Type alias matching `context::core::RawDrawCallback` (private over there);
715/// used inside `FrameState` for the recycled-Vec field for issue #204. Kept
716/// in lib.rs to avoid leaking a public type alias.
717pub(crate) type FrameDeferredDrawSlot =
718    Option<Box<dyn FnOnce(&mut crate::buffer::Buffer, crate::rect::Rect)>>;
719
720#[derive(Default)]
721pub(crate) struct FrameState {
722    pub hook_states: Vec<Box<dyn std::any::Any>>,
723    pub named_states: std::collections::HashMap<&'static str, Box<dyn std::any::Any>>,
724    /// Issue #215: runtime-string-keyed parallel of `named_states`. Persisted
725    /// across frames; survives panics inside `error_boundary` (matching the
726    /// `named_states` policy).
727    pub keyed_states: std::collections::HashMap<String, Box<dyn std::any::Any>>,
728    pub screen_hook_map: std::collections::HashMap<String, (usize, usize)>,
729    pub focus: FocusState,
730    pub layout_feedback: LayoutFeedbackState,
731    pub diagnostics: DiagnosticsState,
732    /// Recycled command Vec (issue #150). `Context::new` swaps this into the
733    /// new context (capacity preserved, len reset to 0). After `build_tree`
734    /// drains the commands, the now-empty Vec is reclaimed back here.
735    pub commands_buf: Vec<crate::layout::Command>,
736    /// Recycled per-frame layout collection scratch (issue #155). Same
737    /// pattern as `commands_buf`: clear before use, restore after.
738    pub frame_data: crate::layout::FrameData,
739    /// Recycled `Context::context_stack` Vec (issue #204). Empty/cleared at
740    /// frame end (same pattern as `commands_buf`).
741    pub context_stack_buf: Vec<Box<dyn std::any::Any>>,
742    /// Recycled `Context::deferred_draws` Vec (issue #204). Slots are emptied
743    /// (set to `None`) when callbacks fire; we clear before reuse.
744    pub deferred_draws_buf: Vec<FrameDeferredDrawSlot>,
745    /// Recycled `rollback.group_stack` Vec (issue #204). Asserted empty at
746    /// frame end before reclamation.
747    pub group_stack_buf: Vec<std::sync::Arc<str>>,
748    /// Recycled `rollback.text_color_stack` Vec (issue #204). Asserted empty
749    /// at frame end before reclamation.
750    pub text_color_stack_buf: Vec<Option<crate::style::Color>>,
751    /// Recycled `Context::pending_tooltips` Vec (issue #204). Asserted empty
752    /// at frame end before reclamation.
753    pub pending_tooltips_buf: Vec<context::PendingTooltip>,
754    /// Recycled `Context::hovered_groups` set (issue #204). Cleared at the
755    /// start of each frame by `build_hovered_groups`.
756    pub hovered_groups_buf: std::collections::HashSet<std::sync::Arc<str>>,
757    #[cfg(feature = "crossterm")]
758    pub selection: terminal::SelectionState,
759}
760
761/// Run the TUI loop with default configuration.
762///
763/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
764/// Ctrl+C or when [`Context::quit`] is called.
765///
766/// # Raw mode is handled for you
767///
768/// SLT enters raw mode automatically inside [`run`] / [`run_with`] /
769/// [`run_inline`] / [`run_async`]. Wrapping these with manual
770/// `crossterm::terminal::enable_raw_mode()` and `disable_raw_mode()` is
771/// **redundant** — the calls are idempotent so no harm comes of it, but it
772/// suggests a misunderstood lifecycle. Drop the wrapper calls:
773///
774/// ```no_run
775/// // Don't do this — it's already handled internally:
776/// // crossterm::terminal::enable_raw_mode()?;
777/// slt::run(|ui| { ui.text("hi"); })?;
778/// // crossterm::terminal::disable_raw_mode()?;
779/// # Ok::<_, std::io::Error>(())
780/// ```
781///
782/// # Ctrl+C opt-out (issue #238)
783///
784/// By default, Ctrl+C exits the loop cleanly — matching the v0.19 contract
785/// and the convention most TUIs follow. To match RataTUI's raw-mode
786/// semantics (Ctrl+C delivered as a regular `Event::Key`), set
787/// [`RunConfig::handle_ctrl_c(false)`](RunConfig::handle_ctrl_c) and decide
788/// inside the frame closure whether to call [`Context::quit`]:
789///
790/// ```no_run
791/// use slt::{KeyModifiers, RunConfig};
792///
793/// slt::run_with(RunConfig::default().handle_ctrl_c(false), |ui| {
794///     if ui.key_mod('c', KeyModifiers::CONTROL) {
795///         // e.g. clear input, prompt to save, then quit:
796///         ui.quit();
797///     }
798/// })?;
799/// # Ok::<_, std::io::Error>(())
800/// ```
801///
802/// # Example
803///
804/// ```no_run
805/// fn main() -> std::io::Result<()> {
806///     slt::run(|ui| {
807///         ui.text("Press Ctrl+C to exit");
808///     })
809/// }
810/// ```
811#[cfg(feature = "crossterm")]
812pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
813    run_with(RunConfig::default(), f)
814}
815
816#[cfg(feature = "crossterm")]
817fn set_terminal_title(title: &Option<String>) {
818    if let Some(title) = title {
819        use std::io::Write;
820        let mut stdout = io::stdout();
821        let _ = write!(stdout, "\x1b]2;{title}\x07");
822        let _ = stdout.flush();
823    }
824}
825
826/// Run the TUI loop with custom configuration.
827///
828/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
829/// support, and theming.
830///
831/// # Example
832///
833/// ```no_run
834/// use slt::{RunConfig, Theme};
835///
836/// fn main() -> std::io::Result<()> {
837///     slt::run_with(
838///         RunConfig::default().theme(Theme::light()),
839///         |ui| {
840///             ui.text("Light theme!");
841///         },
842///     )
843/// }
844/// ```
845#[cfg(feature = "crossterm")]
846pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
847    if !io::stdout().is_terminal() {
848        return Ok(());
849    }
850
851    install_panic_hook();
852    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
853    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
854    set_terminal_title(&config.title);
855    if config.theme.bg != Color::Reset {
856        term.theme_bg = Some(config.theme.bg);
857    }
858    let mut events: Vec<Event> = Vec::new();
859    let mut state = FrameState::default();
860
861    loop {
862        let frame_start = Instant::now();
863        let (w, h) = term.size();
864        if w == 0 || h == 0 {
865            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
866            continue;
867        }
868
869        if !run_frame(
870            &mut term,
871            &mut state,
872            &config,
873            std::mem::take(&mut events),
874            &mut f,
875        )? {
876            break;
877        }
878        // Issue #233: full-screen mode has no scrollback channel — warn and
879        // drop any `ui.static_log(...)` lines so they do not leak into the
880        // next frame's named_states.
881        discard_static_log(&mut state, "full-screen run()");
882        let render_elapsed = frame_start.elapsed();
883
884        if !poll_events(
885            &mut events,
886            &mut state,
887            config.tick_rate,
888            &mut || term.handle_resize(),
889            config.handle_ctrl_c,
890        )? {
891            break;
892        }
893
894        sleep_for_fps_cap(config.max_fps, render_elapsed);
895    }
896
897    Ok(())
898}
899
900/// Run the TUI loop asynchronously with default configuration.
901///
902/// Requires the `async` feature. Spawns the render loop in a blocking thread
903/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
904/// from async tasks into the UI closure.
905///
906/// # Example
907///
908/// ```no_run
909/// # #[cfg(feature = "async")]
910/// # async fn example() -> std::io::Result<()> {
911/// let tx = slt::run_async::<String>(|ui, messages| {
912///     for msg in messages.drain(..) {
913///         ui.text(msg);
914///     }
915/// })?;
916/// tx.send("hello from async".to_string()).await.ok();
917/// # Ok(())
918/// # }
919/// ```
920#[cfg(all(feature = "crossterm", feature = "async"))]
921pub fn run_async<M: Send + 'static>(
922    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
923) -> io::Result<tokio::sync::mpsc::Sender<M>> {
924    run_async_with(RunConfig::default(), f)
925}
926
927/// Run the TUI loop asynchronously with custom configuration.
928///
929/// Requires the `async` feature. Like [`run_async`], but accepts a
930/// [`RunConfig`] to control tick rate, mouse support, and theming.
931///
932/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
933#[cfg(all(feature = "crossterm", feature = "async"))]
934pub fn run_async_with<M: Send + 'static>(
935    config: RunConfig,
936    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
937) -> io::Result<tokio::sync::mpsc::Sender<M>> {
938    let (tx, rx) = tokio::sync::mpsc::channel(100);
939    let handle =
940        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
941
942    handle.spawn_blocking(move || {
943        let _ = run_async_loop(config, f, rx);
944    });
945
946    Ok(tx)
947}
948
949#[cfg(all(feature = "crossterm", feature = "async"))]
950fn run_async_loop<M: Send + 'static>(
951    config: RunConfig,
952    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
953    mut rx: tokio::sync::mpsc::Receiver<M>,
954) -> io::Result<()> {
955    if !io::stdout().is_terminal() {
956        return Ok(());
957    }
958
959    install_panic_hook();
960    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
961    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
962    set_terminal_title(&config.title);
963    if config.theme.bg != Color::Reset {
964        term.theme_bg = Some(config.theme.bg);
965    }
966    let mut events: Vec<Event> = Vec::new();
967    let mut messages: Vec<M> = Vec::new();
968    let mut state = FrameState::default();
969
970    loop {
971        let frame_start = Instant::now();
972        messages.clear();
973        while let Ok(message) = rx.try_recv() {
974            messages.push(message);
975        }
976
977        let (w, h) = term.size();
978        if w == 0 || h == 0 {
979            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
980            continue;
981        }
982
983        let mut render = |ctx: &mut Context| {
984            f(ctx, &mut messages);
985        };
986        if !run_frame(
987            &mut term,
988            &mut state,
989            &config,
990            std::mem::take(&mut events),
991            &mut render,
992        )? {
993            break;
994        }
995        // Issue #233: full-screen async mode has no scrollback channel — warn
996        // and drop any pending static_log lines.
997        discard_static_log(&mut state, "run_async()");
998        let render_elapsed = frame_start.elapsed();
999
1000        if !poll_events(
1001            &mut events,
1002            &mut state,
1003            config.tick_rate,
1004            &mut || term.handle_resize(),
1005            config.handle_ctrl_c,
1006        )? {
1007            break;
1008        }
1009
1010        sleep_for_fps_cap(config.max_fps, render_elapsed);
1011    }
1012
1013    Ok(())
1014}
1015
1016/// Run the TUI in inline mode with default configuration.
1017///
1018/// Renders `height` rows directly below the current cursor position without
1019/// entering alternate screen mode. Useful for CLI tools that want a small
1020/// interactive widget below the prompt.
1021///
1022/// `height` is the reserved inline render area in terminal rows.
1023/// The rest of the terminal stays in normal scrollback mode.
1024///
1025/// # Example
1026///
1027/// ```no_run
1028/// fn main() -> std::io::Result<()> {
1029///     slt::run_inline(3, |ui| {
1030///         ui.text("Inline TUI — no alternate screen");
1031///     })
1032/// }
1033/// ```
1034#[cfg(feature = "crossterm")]
1035pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
1036    run_inline_with(height, RunConfig::default(), f)
1037}
1038
1039/// Run the TUI in inline mode with custom configuration.
1040///
1041/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
1042/// mouse support, and theming.
1043#[cfg(feature = "crossterm")]
1044pub fn run_inline_with(
1045    height: u32,
1046    config: RunConfig,
1047    mut f: impl FnMut(&mut Context),
1048) -> io::Result<()> {
1049    if !io::stdout().is_terminal() {
1050        return Ok(());
1051    }
1052
1053    install_panic_hook();
1054    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1055    let mut term = InlineTerminal::new(height, config.mouse, config.kitty_keyboard, color_depth)?;
1056    set_terminal_title(&config.title);
1057    if config.theme.bg != Color::Reset {
1058        term.theme_bg = Some(config.theme.bg);
1059    }
1060    let mut events: Vec<Event> = Vec::new();
1061    let mut state = FrameState::default();
1062
1063    loop {
1064        let frame_start = Instant::now();
1065        let (w, h) = term.size();
1066        if w == 0 || h == 0 {
1067            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1068            continue;
1069        }
1070
1071        if !run_frame(
1072            &mut term,
1073            &mut state,
1074            &config,
1075            std::mem::take(&mut events),
1076            &mut f,
1077        )? {
1078            break;
1079        }
1080        // Issue #233: inline mode without `StaticOutput` has no scrollback
1081        // channel either — warn and drop any pending lines.
1082        discard_static_log(&mut state, "run_inline()");
1083        let render_elapsed = frame_start.elapsed();
1084
1085        if !poll_events(
1086            &mut events,
1087            &mut state,
1088            config.tick_rate,
1089            &mut || term.handle_resize(),
1090            config.handle_ctrl_c,
1091        )? {
1092            break;
1093        }
1094
1095        sleep_for_fps_cap(config.max_fps, render_elapsed);
1096    }
1097
1098    Ok(())
1099}
1100
1101/// Run the TUI in static-output mode.
1102///
1103/// Static lines written through [`StaticOutput`] are printed into terminal
1104/// scrollback, while the interactive UI stays rendered in a fixed-height inline
1105/// area at the bottom.
1106///
1107/// Use this when you want a log-style output stream above a live inline UI.
1108#[cfg(feature = "crossterm")]
1109pub fn run_static(
1110    output: &mut StaticOutput,
1111    dynamic_height: u32,
1112    f: impl FnMut(&mut Context),
1113) -> io::Result<()> {
1114    run_static_with(output, dynamic_height, RunConfig::default(), f)
1115}
1116
1117/// Run the TUI in static-output mode with custom configuration.
1118///
1119/// Like [`run_static`] but accepts a [`RunConfig`] for theme, mouse, tick rate,
1120/// and other settings.
1121#[cfg(feature = "crossterm")]
1122pub fn run_static_with(
1123    output: &mut StaticOutput,
1124    dynamic_height: u32,
1125    config: RunConfig,
1126    mut f: impl FnMut(&mut Context),
1127) -> io::Result<()> {
1128    if !io::stdout().is_terminal() {
1129        return Ok(());
1130    }
1131
1132    install_panic_hook();
1133
1134    let initial_lines = output.drain_new();
1135    write_static_lines(&initial_lines)?;
1136
1137    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
1138    let mut term = InlineTerminal::new(
1139        dynamic_height,
1140        config.mouse,
1141        config.kitty_keyboard,
1142        color_depth,
1143    )?;
1144    set_terminal_title(&config.title);
1145    if config.theme.bg != Color::Reset {
1146        term.theme_bg = Some(config.theme.bg);
1147    }
1148
1149    let mut events: Vec<Event> = Vec::new();
1150    let mut state = FrameState::default();
1151
1152    loop {
1153        let frame_start = Instant::now();
1154        let (w, h) = term.size();
1155        if w == 0 || h == 0 {
1156            sleep_for_fps_cap(config.max_fps, frame_start.elapsed());
1157            continue;
1158        }
1159
1160        let new_lines = output.drain_new();
1161        write_static_lines(&new_lines)?;
1162
1163        if !run_frame(
1164            &mut term,
1165            &mut state,
1166            &config,
1167            std::mem::take(&mut events),
1168            &mut f,
1169        )? {
1170            break;
1171        }
1172        // Issue #233: drain any `ui.static_log(...)` lines queued during the
1173        // frame closure into `output`; the next loop iteration flushes them
1174        // above the inline area via `write_static_lines`.
1175        for line in drain_static_log(&mut state) {
1176            output.println(line);
1177        }
1178        let render_elapsed = frame_start.elapsed();
1179
1180        if !poll_events(
1181            &mut events,
1182            &mut state,
1183            config.tick_rate,
1184            &mut || term.handle_resize(),
1185            config.handle_ctrl_c,
1186        )? {
1187            break;
1188        }
1189
1190        sleep_for_fps_cap(config.max_fps, render_elapsed);
1191    }
1192
1193    Ok(())
1194}
1195
1196#[cfg(feature = "crossterm")]
1197fn write_static_lines(lines: &[String]) -> io::Result<()> {
1198    if lines.is_empty() {
1199        return Ok(());
1200    }
1201
1202    let mut stdout = io::stdout();
1203    for line in lines {
1204        stdout.write_all(line.as_bytes())?;
1205        stdout.write_all(b"\r\n")?;
1206    }
1207    stdout.flush()
1208}
1209
1210/// Reserved sentinel key used by [`Context::static_log`] (issue #233).
1211/// Re-exported into `context::runtime` so reads/writes never drift.
1212pub(crate) const STATIC_LOG_NAMED_STATE_KEY: &str = "__slt_static_log_pending";
1213
1214/// Reserved sentinel key used by [`Context::publish_keymap`] (issue #236).
1215/// Re-exported into `context::runtime` so reads/writes never drift.
1216pub(crate) const KEYMAP_REGISTRY_NAMED_STATE_KEY: &str = "__slt_keymap_registry";
1217
1218/// Clear the per-frame keymap registry stored in [`FrameState::named_states`]
1219/// (issue #236). Called at the start of every kernel iteration so that
1220/// `Context::publish_keymap` always sees a fresh empty buffer. Capacity is
1221/// preserved by clearing the inner `Vec` rather than removing the entry.
1222pub(crate) fn clear_keymap_registry(state: &mut FrameState) {
1223    if let Some(boxed) = state.named_states.get_mut(KEYMAP_REGISTRY_NAMED_STATE_KEY) {
1224        if let Some(vec) = boxed.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>() {
1225            vec.clear();
1226        }
1227    }
1228}
1229
1230/// Drain any [`Context::static_log`] lines accumulated during the most recent
1231/// frame from the persisted [`FrameState`] (issue #233).
1232///
1233/// After [`run_frame_kernel`] returns, `state.named_states` owns the buffer.
1234/// This helper drains it back to a `Vec<String>` so the runtime can flush
1235/// the lines through whichever scrollback mechanism is appropriate
1236/// (`run_static_with` writes them above the inline region; other run modes
1237/// drop them with a debug warning).
1238#[cfg(feature = "crossterm")]
1239pub(crate) fn drain_static_log(state: &mut FrameState) -> Vec<String> {
1240    if let Some(boxed) = state.named_states.get_mut(STATIC_LOG_NAMED_STATE_KEY) {
1241        if let Some(buf) = boxed.downcast_mut::<Vec<String>>() {
1242            return std::mem::take(buf);
1243        }
1244    }
1245    Vec::new()
1246}
1247
1248/// Discard any [`Context::static_log`] lines that accumulated during the
1249/// most recent frame and emit a debug warning (issue #233).
1250///
1251/// Used by run modes that have no scrollback channel (full-screen,
1252/// inline-without-static, async). Release builds silently drop the buffer.
1253#[cfg(feature = "crossterm")]
1254fn discard_static_log(state: &mut FrameState, mode: &str) {
1255    let drained = drain_static_log(state);
1256    #[cfg(debug_assertions)]
1257    if !drained.is_empty() {
1258        #[allow(clippy::print_stderr)]
1259        {
1260            eprintln!(
1261                "[slt] {} static_log lines were dropped: {} runtime has no scrollback channel; use slt::run_static for streaming output",
1262                drained.len(),
1263                mode
1264            );
1265        }
1266    }
1267    #[cfg(not(debug_assertions))]
1268    {
1269        let _ = (drained, mode);
1270    }
1271}
1272
1273/// Apply a single terminal event to `FrameState`, mutating tracked
1274/// diagnostics fields (debug overlay toggle, mouse position cache,
1275/// resize flag) accordingly.
1276///
1277/// Issue #201: handles **F12** (toggle overlay on/off) and **Shift+F12**
1278/// (cycle [`DebugLayer`] across `All → TopMost → BaseOnly`). The two
1279/// keybindings are independent — toggling the overlay does not change
1280/// the active layer.
1281///
1282/// Extracted from `poll_events` so the keybinding behavior can be
1283/// exercised by unit tests without standing up a real crossterm event
1284/// stream.
1285#[cfg(feature = "crossterm")]
1286pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
1287    match ev {
1288        Event::Mouse(m) => {
1289            state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
1290        }
1291        Event::FocusLost => {
1292            state.layout_feedback.last_mouse_pos = None;
1293        }
1294        // Issue #201: Shift+F12 cycles the active `DebugLayer`. Match
1295        // before the plain-F12 arm so the modifier branch wins. Plain
1296        // F12 keeps its legacy on/off toggle when no modifiers are
1297        // held; we explicitly require `KeyModifiers::NONE` so the two
1298        // arms do not double-fire on the same press.
1299        Event::Key(event::KeyEvent {
1300            code: KeyCode::F(12),
1301            kind: event::KeyEventKind::Press,
1302            modifiers,
1303        }) if modifiers.contains(event::KeyModifiers::SHIFT) => {
1304            state.diagnostics.debug_layer = match state.diagnostics.debug_layer {
1305                DebugLayer::All => DebugLayer::TopMost,
1306                DebugLayer::TopMost => DebugLayer::BaseOnly,
1307                DebugLayer::BaseOnly => DebugLayer::All,
1308            };
1309        }
1310        Event::Key(event::KeyEvent {
1311            code: KeyCode::F(12),
1312            kind: event::KeyEventKind::Press,
1313            modifiers,
1314        }) if *modifiers == event::KeyModifiers::NONE => {
1315            state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
1316        }
1317        Event::Resize(_, _) => {
1318            *has_resize = true;
1319        }
1320        _ => {}
1321    }
1322}
1323
1324/// Poll for terminal events, handling resize, Ctrl-C, F12 debug toggle,
1325/// and layout cache invalidation. Returns `Ok(false)` when the loop should exit.
1326///
1327/// `handle_ctrl_c` controls whether Ctrl+C exits the loop (`true`, default
1328/// v0.19 behavior) or is delivered to the frame closure as a regular key
1329/// event (`false`, RataTUI parity, issue #238).
1330#[cfg(feature = "crossterm")]
1331fn poll_events(
1332    events: &mut Vec<Event>,
1333    state: &mut FrameState,
1334    tick_rate: Duration,
1335    on_resize: &mut impl FnMut() -> io::Result<()>,
1336    handle_ctrl_c: bool,
1337) -> io::Result<bool> {
1338    let mut has_resize = false;
1339
1340    fn process_ev(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
1341        process_run_loop_event(ev, state, has_resize);
1342    }
1343
1344    if crossterm::event::poll(tick_rate)? {
1345        let raw = crossterm::event::read()?;
1346        if let Some(ev) = event::from_crossterm(raw) {
1347            if handle_ctrl_c && is_ctrl_c(&ev) {
1348                return Ok(false);
1349            }
1350            if matches!(ev, Event::Resize(_, _)) {
1351                on_resize()?;
1352            }
1353            process_ev(&ev, state, &mut has_resize);
1354            events.push(ev);
1355        }
1356
1357        while crossterm::event::poll(Duration::ZERO)? {
1358            let raw = crossterm::event::read()?;
1359            if let Some(ev) = event::from_crossterm(raw) {
1360                if handle_ctrl_c && is_ctrl_c(&ev) {
1361                    return Ok(false);
1362                }
1363                if matches!(ev, Event::Resize(_, _)) {
1364                    on_resize()?;
1365                }
1366                process_ev(&ev, state, &mut has_resize);
1367                events.push(ev);
1368            }
1369        }
1370    }
1371
1372    // #90: clear cache first (which also resets last_mouse_pos to None),
1373    // then re-apply latest mouse pos so Resize+Mouse frames keep coords.
1374    if has_resize {
1375        clear_frame_layout_cache(state);
1376        // After clearing, re-walk events to restore the latest mouse pos
1377        // (process_ev already set it during collection, but
1378        // clear_frame_layout_cache wiped it).
1379        for ev in events.iter() {
1380            match ev {
1381                Event::Mouse(m) => {
1382                    state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
1383                }
1384                Event::FocusLost => {
1385                    state.layout_feedback.last_mouse_pos = None;
1386                }
1387                _ => {}
1388            }
1389        }
1390    }
1391
1392    Ok(true)
1393}
1394
1395struct FrameKernelResult {
1396    should_quit: bool,
1397    #[cfg(feature = "crossterm")]
1398    clipboard_text: Option<String>,
1399    #[cfg(feature = "crossterm")]
1400    should_copy_selection: bool,
1401}
1402
1403pub(crate) fn run_frame_kernel(
1404    buffer: &mut Buffer,
1405    state: &mut FrameState,
1406    config: &RunConfig,
1407    size: (u32, u32),
1408    events: Vec<event::Event>,
1409    is_real_terminal: bool,
1410    f: &mut impl FnMut(&mut context::Context),
1411) -> FrameKernelResult {
1412    let frame_start = Instant::now();
1413    let (w, h) = size;
1414    // Issue #236: reset the per-frame keymap registry before constructing
1415    // `Context`. Widgets that call `publish_keymap` accumulate fresh
1416    // entries; entries from the previous frame must not leak through
1417    // `named_states` persistence.
1418    clear_keymap_registry(state);
1419    let mut ctx = Context::new(events, w, h, state, config.theme);
1420    ctx.is_real_terminal = is_real_terminal;
1421    ctx.set_scroll_speed(config.scroll_speed);
1422    ctx.widget_theme = config.widget_theme;
1423
1424    f(&mut ctx);
1425    ctx.process_focus_keys();
1426    ctx.render_notifications();
1427    ctx.emit_pending_tooltips();
1428
1429    debug_assert_eq!(
1430        ctx.rollback.overlay_depth, 0,
1431        "overlay depth must settle back to zero before layout"
1432    );
1433    debug_assert_eq!(
1434        ctx.rollback.group_count, 0,
1435        "group count must settle back to zero before layout"
1436    );
1437    debug_assert!(
1438        ctx.rollback.group_stack.is_empty(),
1439        "group stack must be empty before layout"
1440    );
1441    debug_assert!(
1442        ctx.rollback.text_color_stack.is_empty(),
1443        "text color stack must be empty before layout"
1444    );
1445    debug_assert!(
1446        ctx.pending_tooltips.is_empty(),
1447        "pending tooltips must be emitted before layout"
1448    );
1449
1450    if ctx.should_quit {
1451        state.hook_states = ctx.hook_states;
1452        state.named_states = ctx.named_states;
1453        state.keyed_states = ctx.keyed_states;
1454        state.screen_hook_map = ctx.screen_hook_map;
1455        state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1456        state.diagnostics.debug_layer = ctx.debug_layer;
1457        // Issue #208 / #217: persist focus tracking state on quit so a later
1458        // resumed run starts in a sensible place. (Real TUI exits before
1459        // resuming, but tests reuse `FrameState` across calls.)
1460        state.focus.prev_focus_index = Some(ctx.focus_index);
1461        state.focus.focus_name_map_prev = ctx.focus_name_map;
1462        state.focus.pending_focus_name = ctx.pending_focus_name;
1463        // Issue #204: reclaim the 6 alloc-reuse buffers on the quit path
1464        // too. Real TUI exits ignore this, but TestBackend reuses the same
1465        // FrameState across `render()` calls — without the reclaim the next
1466        // frame's `Context::new` `mem::take`s an empty Vec and silently
1467        // reverts to v0.19 per-frame allocation.
1468        ctx.deferred_draws.clear();
1469        state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
1470        state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
1471        state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
1472        state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
1473        state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
1474        state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
1475        // Issue #150: reclaim `commands` on quit too (TestBackend reuses
1476        // `FrameState` across `render()` calls — same rationale as #204).
1477        // The Vec was never `build_tree`'d on the quit path so it may still
1478        // hold the recorded commands; clearing here drops them and keeps
1479        // capacity for the next frame.
1480        ctx.commands.clear();
1481        state.commands_buf = std::mem::take(&mut ctx.commands);
1482        #[cfg(feature = "crossterm")]
1483        let clipboard_text = ctx.clipboard_text.take();
1484        #[cfg(feature = "crossterm")]
1485        let should_copy_selection = false;
1486        return FrameKernelResult {
1487            should_quit: true,
1488            #[cfg(feature = "crossterm")]
1489            clipboard_text,
1490            #[cfg(feature = "crossterm")]
1491            should_copy_selection,
1492        };
1493    }
1494    state.focus.prev_modal_active = ctx.rollback.modal_active;
1495    state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
1496    state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
1497    #[cfg(feature = "crossterm")]
1498    let clipboard_text = ctx.clipboard_text.take();
1499    #[cfg(not(feature = "crossterm"))]
1500    let _clipboard_text = ctx.clipboard_text.take();
1501
1502    #[cfg(feature = "crossterm")]
1503    let mut should_copy_selection = false;
1504    #[cfg(feature = "crossterm")]
1505    for ev in &ctx.events {
1506        if let Event::Mouse(mouse) = ev {
1507            match mouse.kind {
1508                event::MouseKind::Down(event::MouseButton::Left) => {
1509                    state.selection.mouse_down(
1510                        mouse.x,
1511                        mouse.y,
1512                        &state.layout_feedback.prev_content_map,
1513                    );
1514                }
1515                event::MouseKind::Drag(event::MouseButton::Left) => {
1516                    state.selection.mouse_drag(
1517                        mouse.x,
1518                        mouse.y,
1519                        &state.layout_feedback.prev_content_map,
1520                    );
1521                }
1522                event::MouseKind::Up(event::MouseButton::Left) => {
1523                    should_copy_selection = state.selection.active;
1524                }
1525                _ => {}
1526            }
1527        }
1528    }
1529
1530    state.focus.focus_index = ctx.focus_index;
1531    state.focus.prev_focus_count = ctx.rollback.focus_count;
1532
1533    // Issue #150: `state.commands_buf` is swapped into `ctx.commands` on
1534    // entry (see `Context::new`), so the per-frame `Vec::new()` allocation
1535    // for the command list is amortized to one allocation across the
1536    // session. `build_tree` now takes `&mut Vec<Command>` and `drain`s it,
1537    // leaving the Vec at `len == 0` with capacity preserved. We reclaim
1538    // that Vec into `state.commands_buf` after the frame so the next call
1539    // to `Context::new` can pick it up via `mem::take` (matches the #204
1540    // pattern for the other six recycled buffers).
1541    let mut tree = layout::build_tree(&mut ctx.commands);
1542    let area = crate::rect::Rect::new(0, 0, w, h);
1543    layout::compute(&mut tree, area);
1544
1545    // Issue #155: reuse `state.frame_data` across frames. `collect_all` calls
1546    // `fd.clear()` first so the Vecs reset to len=0 with capacity preserved
1547    // from the prior frame, then refills them.
1548    let mut fd = std::mem::take(&mut state.frame_data);
1549    layout::collect_all(&tree, &mut fd);
1550    debug_assert_eq!(
1551        fd.scroll_infos.len(),
1552        fd.scroll_rects.len(),
1553        "scroll feedback vectors must stay aligned"
1554    );
1555    let raw_rects = std::mem::take(&mut fd.raw_draw_rects);
1556    state.layout_feedback.prev_scroll_infos = std::mem::take(&mut fd.scroll_infos);
1557    state.layout_feedback.prev_scroll_rects = std::mem::take(&mut fd.scroll_rects);
1558    state.layout_feedback.prev_hit_map = std::mem::take(&mut fd.hit_areas);
1559    state.layout_feedback.prev_group_rects = std::mem::take(&mut fd.group_rects);
1560    state.layout_feedback.prev_content_map = std::mem::take(&mut fd.content_areas);
1561    state.layout_feedback.prev_focus_rects = std::mem::take(&mut fd.focus_rects);
1562    state.layout_feedback.prev_focus_groups = std::mem::take(&mut fd.focus_groups);
1563    state.frame_data = fd;
1564    layout::render(&tree, buffer);
1565    // RAII guard ensuring the kitty clip frame is popped even if a raw-draw
1566    // callback panics — prevents stale scroll-clip state leaking into the
1567    // next region or subsequent frames.
1568    struct KittyClipGuard<'a>(&'a mut crate::buffer::Buffer);
1569    impl Drop for KittyClipGuard<'_> {
1570        fn drop(&mut self) {
1571            let _ = self.0.pop_kitty_clip();
1572        }
1573    }
1574    for rdr in raw_rects {
1575        if rdr.rect.width == 0 || rdr.rect.height == 0 {
1576            continue;
1577        }
1578        if let Some(cb) = ctx
1579            .deferred_draws
1580            .get_mut(rdr.draw_id)
1581            .and_then(|c| c.take())
1582        {
1583            buffer.push_clip(rdr.rect);
1584            buffer.push_kitty_clip(crate::buffer::KittyClipInfo {
1585                top_clip_rows: rdr.top_clip_rows,
1586                original_height: rdr.original_height,
1587            });
1588            {
1589                let guard = KittyClipGuard(buffer);
1590                // Explicit reborrow so the guard keeps ownership of the
1591                // outer `&mut Buffer` and pops on drop.
1592                cb(&mut *guard.0, rdr.rect);
1593                // Guard pops on drop at end of this scope.
1594            }
1595            buffer.pop_clip();
1596        }
1597    }
1598    debug_assert!(
1599        buffer.kitty_clip_info_stack.is_empty(),
1600        "kitty_clip_info_stack must be empty at end of frame"
1601    );
1602    state.hook_states = ctx.hook_states;
1603    state.named_states = ctx.named_states;
1604    // Issue #215: hand the keyed-state map back to FrameState so the next
1605    // frame can pick it up via `Context::new`. Mirrors the `named_states`
1606    // round-trip exactly.
1607    state.keyed_states = ctx.keyed_states;
1608    state.screen_hook_map = ctx.screen_hook_map;
1609    state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1610    // Issue #201: persist any in-frame `set_debug_layer` change.
1611    state.diagnostics.debug_layer = ctx.debug_layer;
1612    // Issue #208: remember the focus index that finished this frame so the
1613    // next frame can compute `Response::gained_focus` / `lost_focus`.
1614    state.focus.prev_focus_index = Some(ctx.focus_index);
1615    // Issue #217: swap the freshly-built focus name map into the previous
1616    // slot for next-frame resolution; carry forward any unresolved pending
1617    // name (deferred until the named widget exists).
1618    state.focus.focus_name_map_prev = ctx.focus_name_map;
1619    state.focus.pending_focus_name = ctx.pending_focus_name;
1620
1621    // Issue #204: reclaim the six per-frame `Vec`/`HashSet` allocations so the
1622    // next frame reuses the existing capacity instead of allocating fresh.
1623    // Frame-end invariants (asserted above at lines 1102–1121):
1624    //   - `rollback.group_stack` and `rollback.text_color_stack` are empty
1625    //   - `pending_tooltips` is empty
1626    // `context_stack` is asserted-empty by the consumers in `widgets_*`
1627    // modules (provider/use_context); on the rare panic-rollback path the
1628    // checkpoint truncates it back to the saved length, so we still
1629    // recover capacity.
1630    //
1631    // `deferred_draws`: most slots are emptied by the `take()` above, but
1632    // entries whose `RawDrawRect` had `width == 0 || height == 0` are
1633    // skipped at the loop guard and remain `Some(_)`. We explicitly
1634    // `clear()` to drop those callbacks here so they don't outlive the
1635    // frame; capacity is preserved. (Leaving them would not cause UB —
1636    // `Context::new` calls `.clear()` on the reclaimed Vec — but dropping
1637    // promptly matches user expectation that one-shot callbacks don't
1638    // survive past their frame.)
1639    //
1640    // `hovered_groups`: `clear()`-ed at the start of every frame inside
1641    // `build_hovered_groups`, so the existing entries are harmless to
1642    // reclaim with content; capacity is preserved.
1643    ctx.deferred_draws.clear();
1644    state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
1645    state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
1646    state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
1647    state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
1648    state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
1649    state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
1650    // Issue #150: reclaim the drained command Vec so the next `Context::new`
1651    // picks it up via `mem::take(&mut state.commands_buf)`. After
1652    // `build_tree(&mut ctx.commands)` the Vec is at `len == 0` with capacity
1653    // preserved; mirror the #204 reclamation pattern for the other six
1654    // per-frame buffers.
1655    state.commands_buf = std::mem::take(&mut ctx.commands);
1656
1657    let frame_time = frame_start.elapsed();
1658    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1659    let frame_secs = frame_time.as_secs_f32();
1660    let inst_fps = if frame_secs > 0.0 {
1661        1.0 / frame_secs
1662    } else {
1663        0.0
1664    };
1665    state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
1666        inst_fps
1667    } else {
1668        (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
1669    };
1670    if state.diagnostics.debug_mode {
1671        layout::render_debug_overlay(
1672            &tree,
1673            buffer,
1674            frame_time_us,
1675            state.diagnostics.fps_ema,
1676            state.diagnostics.debug_layer,
1677        );
1678    }
1679
1680    FrameKernelResult {
1681        should_quit: false,
1682        #[cfg(feature = "crossterm")]
1683        clipboard_text,
1684        #[cfg(feature = "crossterm")]
1685        should_copy_selection,
1686    }
1687}
1688
1689fn run_frame(
1690    term: &mut impl Backend,
1691    state: &mut FrameState,
1692    config: &RunConfig,
1693    events: Vec<event::Event>,
1694    f: &mut impl FnMut(&mut context::Context),
1695) -> io::Result<bool> {
1696    let size = term.size();
1697    let kernel = run_frame_kernel(term.buffer_mut(), state, config, size, events, true, f);
1698    if kernel.should_quit {
1699        return Ok(false);
1700    }
1701
1702    #[cfg(feature = "crossterm")]
1703    if state.selection.active {
1704        terminal::apply_selection_overlay(
1705            term.buffer_mut(),
1706            &state.selection,
1707            &state.layout_feedback.prev_content_map,
1708        );
1709    }
1710    #[cfg(feature = "crossterm")]
1711    if kernel.should_copy_selection {
1712        let text = terminal::extract_selection_text(
1713            term.buffer_mut(),
1714            &state.selection,
1715            &state.layout_feedback.prev_content_map,
1716        );
1717        if !text.is_empty() {
1718            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1719        }
1720        state.selection.clear();
1721    }
1722
1723    term.flush()?;
1724    #[cfg(feature = "crossterm")]
1725    if let Some(text) = kernel.clipboard_text {
1726        #[allow(clippy::print_stderr)]
1727        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1728            eprintln!("[slt] failed to copy to clipboard: {e}");
1729        }
1730    }
1731    state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
1732
1733    Ok(true)
1734}
1735
1736#[cfg(feature = "crossterm")]
1737fn clear_frame_layout_cache(state: &mut FrameState) {
1738    state.layout_feedback.prev_hit_map.clear();
1739    state.layout_feedback.prev_group_rects.clear();
1740    state.layout_feedback.prev_content_map.clear();
1741    state.layout_feedback.prev_focus_rects.clear();
1742    state.layout_feedback.prev_focus_groups.clear();
1743    state.layout_feedback.prev_scroll_infos.clear();
1744    state.layout_feedback.prev_scroll_rects.clear();
1745    state.layout_feedback.last_mouse_pos = None;
1746}
1747
1748#[cfg(feature = "crossterm")]
1749fn is_ctrl_c(ev: &Event) -> bool {
1750    matches!(
1751        ev,
1752        Event::Key(event::KeyEvent {
1753            code: KeyCode::Char('c'),
1754            modifiers,
1755            kind: event::KeyEventKind::Press,
1756        }) if modifiers.contains(KeyModifiers::CONTROL)
1757    )
1758}
1759
1760#[cfg(feature = "crossterm")]
1761fn sleep_for_fps_cap(max_fps: Option<u32>, render_elapsed: Duration) {
1762    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1763        let target = Duration::from_secs_f64(1.0 / fps as f64);
1764        if render_elapsed < target {
1765            std::thread::sleep(target - render_elapsed);
1766        }
1767    }
1768}
1769
1770#[cfg(all(test, feature = "crossterm"))]
1771mod run_loop_tests {
1772    //! Issue #201 regression tests for the run-loop F12 / Shift+F12
1773    //! keybinding handler. Exercises [`process_run_loop_event`] directly
1774    //! so we don't need a real crossterm event source.
1775    use super::*;
1776
1777    fn key(modifiers: event::KeyModifiers) -> Event {
1778        Event::Key(event::KeyEvent {
1779            code: KeyCode::F(12),
1780            kind: event::KeyEventKind::Press,
1781            modifiers,
1782        })
1783    }
1784
1785    #[test]
1786    fn plain_f12_toggles_debug_mode() {
1787        let mut state = FrameState::default();
1788        let mut has_resize = false;
1789        assert!(!state.diagnostics.debug_mode);
1790        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
1791        assert!(state.diagnostics.debug_mode);
1792        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
1793        assert!(!state.diagnostics.debug_mode);
1794    }
1795
1796    #[test]
1797    fn shift_f12_cycles_debug_layer_without_toggling_overlay() {
1798        let mut state = FrameState::default();
1799        let mut has_resize = false;
1800        // Default layer is `All`; debug overlay starts off.
1801        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
1802        assert!(!state.diagnostics.debug_mode);
1803
1804        process_run_loop_event(
1805            &key(event::KeyModifiers::SHIFT),
1806            &mut state,
1807            &mut has_resize,
1808        );
1809        assert_eq!(state.diagnostics.debug_layer, DebugLayer::TopMost);
1810        // Cycling does not flip the on/off state.
1811        assert!(!state.diagnostics.debug_mode);
1812
1813        process_run_loop_event(
1814            &key(event::KeyModifiers::SHIFT),
1815            &mut state,
1816            &mut has_resize,
1817        );
1818        assert_eq!(state.diagnostics.debug_layer, DebugLayer::BaseOnly);
1819
1820        process_run_loop_event(
1821            &key(event::KeyModifiers::SHIFT),
1822            &mut state,
1823            &mut has_resize,
1824        );
1825        assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
1826    }
1827
1828    #[test]
1829    fn shift_f12_does_not_also_toggle_overlay() {
1830        // Regression for the modifier disambiguation: pre-fix, the F12
1831        // arm matched `..` modifiers so Shift+F12 would both cycle the
1832        // layer AND toggle the overlay on the same press.
1833        let mut state = FrameState::default();
1834        let mut has_resize = false;
1835        let before = state.diagnostics.debug_mode;
1836        process_run_loop_event(
1837            &key(event::KeyModifiers::SHIFT),
1838            &mut state,
1839            &mut has_resize,
1840        );
1841        assert_eq!(
1842            state.diagnostics.debug_mode, before,
1843            "Shift+F12 must not flip the on/off toggle"
1844        );
1845    }
1846
1847    #[test]
1848    fn plain_f12_does_not_cycle_layer() {
1849        // Symmetric guard: pressing plain F12 must not change the active
1850        // layer, only the on/off flag.
1851        let mut state = FrameState::default();
1852        let mut has_resize = false;
1853        let before = state.diagnostics.debug_layer;
1854        process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
1855        assert_eq!(state.diagnostics.debug_layer, before);
1856    }
1857}