Skip to main content

slt/
lib.rs

1//! # SLT — Super Light TUI
2//!
3//! Immediate-mode terminal UI for Rust. Two dependencies. Zero `unsafe`.
4//!
5//! SLT gives you an egui-style API for terminals: your closure runs each frame,
6//! you describe your UI, and SLT handles layout, diffing, and rendering.
7//!
8//! ## Quick Start
9//!
10//! ```no_run
11//! fn main() -> std::io::Result<()> {
12//!     slt::run(|ui| {
13//!         ui.text("hello, world");
14//!     })
15//! }
16//! ```
17//!
18//! ## Features
19//!
20//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
21//! - **30+ built-in widgets** — input, textarea, table, list, tabs, button, checkbox, toggle, spinner, progress, toast, separator, help bar, scrollable, chart, bar chart, sparkline, histogram, canvas, grid, select, radio, multi-select, tree, virtual list, command palette, markdown
22//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
23//! - **Mouse** — click, hover, drag-to-scroll
24//! - **Focus** — automatic Tab/Shift+Tab cycling
25//! - **Theming** — dark/light presets or custom
26//! - **Animation** — tween and spring primitives with 9 easing functions
27//! - **Inline mode** — render below your prompt, no alternate screen
28//! - **Async** — optional tokio integration via `async` feature
29//! - **Layout debugger** — F12 to visualize container bounds
30//!
31//! ## Feature Flags
32//!
33//! | Flag | Description |
34//! |------|-------------|
35//! | `async` | Enable `run_async()` with tokio channel-based message passing |
36//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
37
38pub mod anim;
39pub mod buffer;
40pub mod cell;
41pub mod chart;
42pub mod context;
43pub mod event;
44pub mod halfblock;
45pub mod layout;
46pub mod rect;
47pub mod style;
48mod terminal;
49pub mod test_utils;
50pub mod widgets;
51
52use std::io;
53use std::io::IsTerminal;
54use std::sync::Once;
55use std::time::{Duration, Instant};
56
57use terminal::{InlineTerminal, Terminal, TerminalBackend};
58
59pub use crate::test_utils::{EventBuilder, TestBackend};
60pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
61pub use buffer::Buffer;
62pub use chart::{
63    Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
64    HistogramBuilder, LegendPosition, Marker,
65};
66pub use context::{Bar, BarDirection, BarGroup, CanvasContext, Context, Response, State, Widget};
67pub use event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind};
68pub use halfblock::HalfBlockImage;
69pub use rect::Rect;
70pub use style::{
71    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
72    Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder,
73};
74pub use widgets::{
75    AlertLevel, ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FormField,
76    FormState, ListState, MultiSelectState, PaletteCommand, RadioState, ScrollState, SelectState,
77    SpinnerState, StreamingTextState, TableState, TabsState, TextInputState, TextareaState,
78    ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
79};
80
81static PANIC_HOOK_ONCE: Once = Once::new();
82
83fn install_panic_hook() {
84    PANIC_HOOK_ONCE.call_once(|| {
85        let original = std::panic::take_hook();
86        std::panic::set_hook(Box::new(move |panic_info| {
87            let _ = crossterm::terminal::disable_raw_mode();
88            let mut stdout = io::stdout();
89            let _ = crossterm::execute!(
90                stdout,
91                crossterm::terminal::LeaveAlternateScreen,
92                crossterm::cursor::Show,
93                crossterm::event::DisableMouseCapture,
94                crossterm::event::DisableBracketedPaste,
95                crossterm::style::ResetColor,
96                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
97            );
98
99            // Print friendly panic header
100            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
101
102            // Print location if available
103            if let Some(location) = panic_info.location() {
104                eprintln!(
105                    "\x1b[90m{}:{}:{}\x1b[0m",
106                    location.file(),
107                    location.line(),
108                    location.column()
109                );
110            }
111
112            // Print message
113            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
114                eprintln!("\x1b[1m{}\x1b[0m", msg);
115            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
116                eprintln!("\x1b[1m{}\x1b[0m", msg);
117            }
118
119            eprintln!(
120                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
121            );
122
123            original(panic_info);
124        }));
125    });
126}
127
128/// Configuration for a TUI run loop.
129///
130/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
131/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
132///
133/// # Example
134///
135/// ```no_run
136/// use slt::{RunConfig, Theme};
137/// use std::time::Duration;
138///
139/// let config = RunConfig {
140///     tick_rate: Duration::from_millis(50),
141///     mouse: true,
142///     kitty_keyboard: false,
143///     theme: Theme::light(),
144///     color_depth: None,
145///     max_fps: Some(60),
146/// };
147/// ```
148#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
149pub struct RunConfig {
150    /// How long to wait for input before triggering a tick with no events.
151    ///
152    /// Lower values give smoother animations at the cost of more CPU usage.
153    /// Defaults to 16ms (60fps).
154    pub tick_rate: Duration,
155    /// Whether to enable mouse event reporting.
156    ///
157    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
158    /// Defaults to `false`.
159    pub mouse: bool,
160    /// Whether to enable the Kitty keyboard protocol for enhanced input.
161    ///
162    /// When `true`, enables disambiguated key events, key release events,
163    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
164    /// Terminals that don't support it silently ignore the request.
165    /// Defaults to `false`.
166    pub kitty_keyboard: bool,
167    /// The color theme applied to all widgets automatically.
168    ///
169    /// Defaults to [`Theme::dark()`].
170    pub theme: Theme,
171    /// Color depth override.
172    ///
173    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
174    /// variables. Set explicitly to force a specific color depth regardless
175    /// of terminal capabilities.
176    pub color_depth: Option<ColorDepth>,
177    /// Optional maximum frame rate.
178    ///
179    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
180    /// loop iteration to target that frame time.
181    pub max_fps: Option<u32>,
182}
183
184impl Default for RunConfig {
185    fn default() -> Self {
186        Self {
187            tick_rate: Duration::from_millis(16),
188            mouse: false,
189            kitty_keyboard: false,
190            theme: Theme::dark(),
191            color_depth: None,
192            max_fps: Some(60),
193        }
194    }
195}
196
197pub(crate) struct FrameState {
198    pub hook_states: Vec<Box<dyn std::any::Any>>,
199    pub focus_index: usize,
200    pub prev_focus_count: usize,
201    pub tick: u64,
202    pub prev_scroll_infos: Vec<(u32, u32)>,
203    pub prev_scroll_rects: Vec<rect::Rect>,
204    pub prev_hit_map: Vec<rect::Rect>,
205    pub prev_group_rects: Vec<(String, rect::Rect)>,
206    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
207    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
208    pub prev_focus_groups: Vec<Option<String>>,
209    pub last_mouse_pos: Option<(u32, u32)>,
210    pub prev_modal_active: bool,
211    pub debug_mode: bool,
212    pub fps_ema: f32,
213    pub selection: terminal::SelectionState,
214}
215
216impl Default for FrameState {
217    fn default() -> Self {
218        Self {
219            hook_states: Vec::new(),
220            focus_index: 0,
221            prev_focus_count: 0,
222            tick: 0,
223            prev_scroll_infos: Vec::new(),
224            prev_scroll_rects: Vec::new(),
225            prev_hit_map: Vec::new(),
226            prev_group_rects: Vec::new(),
227            prev_content_map: Vec::new(),
228            prev_focus_rects: Vec::new(),
229            prev_focus_groups: Vec::new(),
230            last_mouse_pos: None,
231            prev_modal_active: false,
232            debug_mode: false,
233            fps_ema: 0.0,
234            selection: terminal::SelectionState::default(),
235        }
236    }
237}
238
239/// Run the TUI loop with default configuration.
240///
241/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
242/// Ctrl+C or when [`Context::quit`] is called.
243///
244/// # Example
245///
246/// ```no_run
247/// fn main() -> std::io::Result<()> {
248///     slt::run(|ui| {
249///         ui.text("Press Ctrl+C to exit");
250///     })
251/// }
252/// ```
253pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
254    run_with(RunConfig::default(), f)
255}
256
257/// Run the TUI loop with custom configuration.
258///
259/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
260/// support, and theming.
261///
262/// # Example
263///
264/// ```no_run
265/// use slt::{RunConfig, Theme};
266///
267/// fn main() -> std::io::Result<()> {
268///     slt::run_with(
269///         RunConfig { theme: Theme::light(), ..Default::default() },
270///         |ui| {
271///             ui.text("Light theme!");
272///         },
273///     )
274/// }
275/// ```
276pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
277    if !io::stdout().is_terminal() {
278        return Ok(());
279    }
280
281    install_panic_hook();
282    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
283    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
284    if config.theme.bg != Color::Reset {
285        term.theme_bg = Some(config.theme.bg);
286    }
287    let mut events: Vec<Event> = Vec::new();
288    let mut state = FrameState::default();
289
290    loop {
291        let frame_start = Instant::now();
292        let (w, h) = term.size();
293        if w == 0 || h == 0 {
294            sleep_for_fps_cap(config.max_fps, frame_start);
295            continue;
296        }
297
298        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
299            break;
300        }
301
302        events.clear();
303        if crossterm::event::poll(config.tick_rate)? {
304            let raw = crossterm::event::read()?;
305            if let Some(ev) = event::from_crossterm(raw) {
306                if is_ctrl_c(&ev) {
307                    break;
308                }
309                if let Event::Resize(_, _) = &ev {
310                    TerminalBackend::handle_resize(&mut term)?;
311                }
312                events.push(ev);
313            }
314
315            while crossterm::event::poll(Duration::ZERO)? {
316                let raw = crossterm::event::read()?;
317                if let Some(ev) = event::from_crossterm(raw) {
318                    if is_ctrl_c(&ev) {
319                        return Ok(());
320                    }
321                    if let Event::Resize(_, _) = &ev {
322                        TerminalBackend::handle_resize(&mut term)?;
323                    }
324                    events.push(ev);
325                }
326            }
327
328            for ev in &events {
329                if matches!(
330                    ev,
331                    Event::Key(event::KeyEvent {
332                        code: KeyCode::F(12),
333                        kind: event::KeyEventKind::Press,
334                        ..
335                    })
336                ) {
337                    state.debug_mode = !state.debug_mode;
338                }
339            }
340        }
341
342        update_last_mouse_pos(&mut state, &events);
343
344        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
345            clear_frame_layout_cache(&mut state);
346        }
347
348        sleep_for_fps_cap(config.max_fps, frame_start);
349    }
350
351    Ok(())
352}
353
354/// Run the TUI loop asynchronously with default configuration.
355///
356/// Requires the `async` feature. Spawns the render loop in a blocking thread
357/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
358/// from async tasks into the UI closure.
359///
360/// # Example
361///
362/// ```no_run
363/// # #[cfg(feature = "async")]
364/// # async fn example() -> std::io::Result<()> {
365/// let tx = slt::run_async::<String>(|ui, messages| {
366///     for msg in messages.drain(..) {
367///         ui.text(msg);
368///     }
369/// })?;
370/// tx.send("hello from async".to_string()).await.ok();
371/// # Ok(())
372/// # }
373/// ```
374#[cfg(feature = "async")]
375pub fn run_async<M: Send + 'static>(
376    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
377) -> io::Result<tokio::sync::mpsc::Sender<M>> {
378    run_async_with(RunConfig::default(), f)
379}
380
381/// Run the TUI loop asynchronously with custom configuration.
382///
383/// Requires the `async` feature. Like [`run_async`], but accepts a
384/// [`RunConfig`] to control tick rate, mouse support, and theming.
385///
386/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
387#[cfg(feature = "async")]
388pub fn run_async_with<M: Send + 'static>(
389    config: RunConfig,
390    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
391) -> io::Result<tokio::sync::mpsc::Sender<M>> {
392    let (tx, rx) = tokio::sync::mpsc::channel(100);
393    let handle =
394        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
395
396    handle.spawn_blocking(move || {
397        let _ = run_async_loop(config, f, rx);
398    });
399
400    Ok(tx)
401}
402
403#[cfg(feature = "async")]
404fn run_async_loop<M: Send + 'static>(
405    config: RunConfig,
406    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
407    mut rx: tokio::sync::mpsc::Receiver<M>,
408) -> io::Result<()> {
409    if !io::stdout().is_terminal() {
410        return Ok(());
411    }
412
413    install_panic_hook();
414    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
415    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
416    if config.theme.bg != Color::Reset {
417        term.theme_bg = Some(config.theme.bg);
418    }
419    let mut events: Vec<Event> = Vec::new();
420    let mut state = FrameState::default();
421
422    loop {
423        let frame_start = Instant::now();
424        let mut messages: Vec<M> = Vec::new();
425        while let Ok(message) = rx.try_recv() {
426            messages.push(message);
427        }
428
429        let (w, h) = term.size();
430        if w == 0 || h == 0 {
431            sleep_for_fps_cap(config.max_fps, frame_start);
432            continue;
433        }
434
435        let mut render = |ctx: &mut Context| {
436            f(ctx, &mut messages);
437        };
438        if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
439            break;
440        }
441
442        events.clear();
443        if crossterm::event::poll(config.tick_rate)? {
444            let raw = crossterm::event::read()?;
445            if let Some(ev) = event::from_crossterm(raw) {
446                if is_ctrl_c(&ev) {
447                    break;
448                }
449                if let Event::Resize(_, _) = &ev {
450                    TerminalBackend::handle_resize(&mut term)?;
451                    clear_frame_layout_cache(&mut state);
452                }
453                events.push(ev);
454            }
455
456            while crossterm::event::poll(Duration::ZERO)? {
457                let raw = crossterm::event::read()?;
458                if let Some(ev) = event::from_crossterm(raw) {
459                    if is_ctrl_c(&ev) {
460                        return Ok(());
461                    }
462                    if let Event::Resize(_, _) = &ev {
463                        TerminalBackend::handle_resize(&mut term)?;
464                        clear_frame_layout_cache(&mut state);
465                    }
466                    events.push(ev);
467                }
468            }
469        }
470
471        update_last_mouse_pos(&mut state, &events);
472
473        sleep_for_fps_cap(config.max_fps, frame_start);
474    }
475
476    Ok(())
477}
478
479/// Run the TUI in inline mode with default configuration.
480///
481/// Renders `height` rows directly below the current cursor position without
482/// entering alternate screen mode. Useful for CLI tools that want a small
483/// interactive widget below the prompt.
484///
485/// # Example
486///
487/// ```no_run
488/// fn main() -> std::io::Result<()> {
489///     slt::run_inline(3, |ui| {
490///         ui.text("Inline TUI — no alternate screen");
491///     })
492/// }
493/// ```
494pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
495    run_inline_with(height, RunConfig::default(), f)
496}
497
498/// Run the TUI in inline mode with custom configuration.
499///
500/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
501/// mouse support, and theming.
502pub fn run_inline_with(
503    height: u32,
504    config: RunConfig,
505    mut f: impl FnMut(&mut Context),
506) -> io::Result<()> {
507    if !io::stdout().is_terminal() {
508        return Ok(());
509    }
510
511    install_panic_hook();
512    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
513    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
514    let mut events: Vec<Event> = Vec::new();
515    let mut state = FrameState::default();
516
517    loop {
518        let frame_start = Instant::now();
519        let (w, h) = term.size();
520        if w == 0 || h == 0 {
521            sleep_for_fps_cap(config.max_fps, frame_start);
522            continue;
523        }
524
525        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
526            break;
527        }
528
529        events.clear();
530        if crossterm::event::poll(config.tick_rate)? {
531            let raw = crossterm::event::read()?;
532            if let Some(ev) = event::from_crossterm(raw) {
533                if is_ctrl_c(&ev) {
534                    break;
535                }
536                if let Event::Resize(_, _) = &ev {
537                    TerminalBackend::handle_resize(&mut term)?;
538                }
539                events.push(ev);
540            }
541
542            while crossterm::event::poll(Duration::ZERO)? {
543                let raw = crossterm::event::read()?;
544                if let Some(ev) = event::from_crossterm(raw) {
545                    if is_ctrl_c(&ev) {
546                        return Ok(());
547                    }
548                    if let Event::Resize(_, _) = &ev {
549                        TerminalBackend::handle_resize(&mut term)?;
550                    }
551                    events.push(ev);
552                }
553            }
554
555            for ev in &events {
556                if matches!(
557                    ev,
558                    Event::Key(event::KeyEvent {
559                        code: KeyCode::F(12),
560                        kind: event::KeyEventKind::Press,
561                        ..
562                    })
563                ) {
564                    state.debug_mode = !state.debug_mode;
565                }
566            }
567        }
568
569        update_last_mouse_pos(&mut state, &events);
570
571        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
572            clear_frame_layout_cache(&mut state);
573        }
574
575        sleep_for_fps_cap(config.max_fps, frame_start);
576    }
577
578    Ok(())
579}
580
581fn run_frame<T: TerminalBackend>(
582    term: &mut T,
583    state: &mut FrameState,
584    config: &RunConfig,
585    events: &[event::Event],
586    f: &mut dyn FnMut(&mut context::Context),
587) -> io::Result<bool> {
588    let frame_start = Instant::now();
589    let (w, h) = term.size();
590    let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
591    ctx.is_real_terminal = true;
592    ctx.process_focus_keys();
593
594    f(&mut ctx);
595
596    if ctx.should_quit {
597        return Ok(false);
598    }
599    state.prev_modal_active = ctx.modal_active;
600    let clipboard_text = ctx.clipboard_text.take();
601
602    let mut should_copy_selection = false;
603    for ev in &ctx.events {
604        if let Event::Mouse(mouse) = ev {
605            match mouse.kind {
606                event::MouseKind::Down(event::MouseButton::Left) => {
607                    state
608                        .selection
609                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
610                }
611                event::MouseKind::Drag(event::MouseButton::Left) => {
612                    state
613                        .selection
614                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
615                }
616                event::MouseKind::Up(event::MouseButton::Left) => {
617                    should_copy_selection = state.selection.active;
618                }
619                _ => {}
620            }
621        }
622    }
623
624    state.focus_index = ctx.focus_index;
625    state.prev_focus_count = ctx.focus_count;
626
627    let mut tree = layout::build_tree(&ctx.commands);
628    let area = crate::rect::Rect::new(0, 0, w, h);
629    layout::compute(&mut tree, area);
630    let fd = layout::collect_all(&tree);
631    state.prev_scroll_infos = fd.scroll_infos;
632    state.prev_scroll_rects = fd.scroll_rects;
633    state.prev_hit_map = fd.hit_areas;
634    state.prev_group_rects = fd.group_rects;
635    state.prev_content_map = fd.content_areas;
636    state.prev_focus_rects = fd.focus_rects;
637    state.prev_focus_groups = fd.focus_groups;
638    layout::render(&tree, term.buffer_mut());
639    let raw_rects = layout::collect_raw_draw_rects(&tree);
640    for (draw_id, rect) in raw_rects {
641        if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
642            let buf = term.buffer_mut();
643            buf.push_clip(rect);
644            cb(buf, rect);
645            buf.pop_clip();
646        }
647    }
648    state.hook_states = ctx.hook_states;
649
650    let frame_time = frame_start.elapsed();
651    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
652    let frame_secs = frame_time.as_secs_f32();
653    let inst_fps = if frame_secs > 0.0 {
654        1.0 / frame_secs
655    } else {
656        0.0
657    };
658    state.fps_ema = if state.fps_ema == 0.0 {
659        inst_fps
660    } else {
661        (state.fps_ema * 0.9) + (inst_fps * 0.1)
662    };
663    if state.debug_mode {
664        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
665    }
666
667    if state.selection.active {
668        terminal::apply_selection_overlay(
669            term.buffer_mut(),
670            &state.selection,
671            &state.prev_content_map,
672        );
673    }
674    if should_copy_selection {
675        let text = terminal::extract_selection_text(
676            term.buffer_mut(),
677            &state.selection,
678            &state.prev_content_map,
679        );
680        if !text.is_empty() {
681            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
682        }
683        state.selection.clear();
684    }
685
686    term.flush()?;
687    if let Some(text) = clipboard_text {
688        let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
689    }
690    state.tick = state.tick.wrapping_add(1);
691
692    Ok(true)
693}
694
695fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
696    for ev in events {
697        match ev {
698            Event::Mouse(mouse) => {
699                state.last_mouse_pos = Some((mouse.x, mouse.y));
700            }
701            Event::FocusLost => {
702                state.last_mouse_pos = None;
703            }
704            _ => {}
705        }
706    }
707}
708
709fn clear_frame_layout_cache(state: &mut FrameState) {
710    state.prev_hit_map.clear();
711    state.prev_group_rects.clear();
712    state.prev_content_map.clear();
713    state.prev_focus_rects.clear();
714    state.prev_focus_groups.clear();
715    state.prev_scroll_infos.clear();
716    state.prev_scroll_rects.clear();
717    state.last_mouse_pos = None;
718}
719
720fn is_ctrl_c(ev: &Event) -> bool {
721    matches!(
722        ev,
723        Event::Key(event::KeyEvent {
724            code: KeyCode::Char('c'),
725            modifiers,
726            kind: event::KeyEventKind::Press,
727        }) if modifiers.contains(KeyModifiers::CONTROL)
728    )
729}
730
731fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
732    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
733        let target = Duration::from_secs_f64(1.0 / fps as f64);
734        let elapsed = frame_start.elapsed();
735        if elapsed < target {
736            std::thread::sleep(target - elapsed);
737        }
738    }
739}