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}