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    ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FormField, FormState,
76    ListState, MultiSelectState, PaletteCommand, RadioState, ScrollState, SelectState,
77    SpinnerState, StreamingTextState, TableState, TabsState, TextInputState, TextareaState,
78    ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState,
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 (100ms tick, 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 100ms.
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    let mut events: Vec<Event> = Vec::new();
285    let mut state = FrameState::default();
286
287    loop {
288        let frame_start = Instant::now();
289        let (w, h) = term.size();
290        if w == 0 || h == 0 {
291            sleep_for_fps_cap(config.max_fps, frame_start);
292            continue;
293        }
294
295        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
296            break;
297        }
298
299        events.clear();
300        if crossterm::event::poll(config.tick_rate)? {
301            let raw = crossterm::event::read()?;
302            if let Some(ev) = event::from_crossterm(raw) {
303                if is_ctrl_c(&ev) {
304                    break;
305                }
306                if let Event::Resize(_, _) = &ev {
307                    TerminalBackend::handle_resize(&mut term)?;
308                }
309                events.push(ev);
310            }
311
312            while crossterm::event::poll(Duration::ZERO)? {
313                let raw = crossterm::event::read()?;
314                if let Some(ev) = event::from_crossterm(raw) {
315                    if is_ctrl_c(&ev) {
316                        return Ok(());
317                    }
318                    if let Event::Resize(_, _) = &ev {
319                        TerminalBackend::handle_resize(&mut term)?;
320                    }
321                    events.push(ev);
322                }
323            }
324
325            for ev in &events {
326                if matches!(
327                    ev,
328                    Event::Key(event::KeyEvent {
329                        code: KeyCode::F(12),
330                        kind: event::KeyEventKind::Press,
331                        ..
332                    })
333                ) {
334                    state.debug_mode = !state.debug_mode;
335                }
336            }
337        }
338
339        update_last_mouse_pos(&mut state, &events);
340
341        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
342            clear_frame_layout_cache(&mut state);
343        }
344
345        sleep_for_fps_cap(config.max_fps, frame_start);
346    }
347
348    Ok(())
349}
350
351/// Run the TUI loop asynchronously with default configuration.
352///
353/// Requires the `async` feature. Spawns the render loop in a blocking thread
354/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
355/// from async tasks into the UI closure.
356///
357/// # Example
358///
359/// ```no_run
360/// # #[cfg(feature = "async")]
361/// # async fn example() -> std::io::Result<()> {
362/// let tx = slt::run_async::<String>(|ui, messages| {
363///     for msg in messages.drain(..) {
364///         ui.text(msg);
365///     }
366/// })?;
367/// tx.send("hello from async".to_string()).await.ok();
368/// # Ok(())
369/// # }
370/// ```
371#[cfg(feature = "async")]
372pub fn run_async<M: Send + 'static>(
373    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
374) -> io::Result<tokio::sync::mpsc::Sender<M>> {
375    run_async_with(RunConfig::default(), f)
376}
377
378/// Run the TUI loop asynchronously with custom configuration.
379///
380/// Requires the `async` feature. Like [`run_async`], but accepts a
381/// [`RunConfig`] to control tick rate, mouse support, and theming.
382///
383/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
384#[cfg(feature = "async")]
385pub fn run_async_with<M: Send + 'static>(
386    config: RunConfig,
387    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
388) -> io::Result<tokio::sync::mpsc::Sender<M>> {
389    let (tx, rx) = tokio::sync::mpsc::channel(100);
390    let handle =
391        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
392
393    handle.spawn_blocking(move || {
394        let _ = run_async_loop(config, f, rx);
395    });
396
397    Ok(tx)
398}
399
400#[cfg(feature = "async")]
401fn run_async_loop<M: Send + 'static>(
402    config: RunConfig,
403    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
404    mut rx: tokio::sync::mpsc::Receiver<M>,
405) -> io::Result<()> {
406    if !io::stdout().is_terminal() {
407        return Ok(());
408    }
409
410    install_panic_hook();
411    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
412    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
413    let mut events: Vec<Event> = Vec::new();
414    let mut state = FrameState::default();
415
416    loop {
417        let frame_start = Instant::now();
418        let mut messages: Vec<M> = Vec::new();
419        while let Ok(message) = rx.try_recv() {
420            messages.push(message);
421        }
422
423        let (w, h) = term.size();
424        if w == 0 || h == 0 {
425            sleep_for_fps_cap(config.max_fps, frame_start);
426            continue;
427        }
428
429        let mut render = |ctx: &mut Context| {
430            f(ctx, &mut messages);
431        };
432        if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
433            break;
434        }
435
436        events.clear();
437        if crossterm::event::poll(config.tick_rate)? {
438            let raw = crossterm::event::read()?;
439            if let Some(ev) = event::from_crossterm(raw) {
440                if is_ctrl_c(&ev) {
441                    break;
442                }
443                if let Event::Resize(_, _) = &ev {
444                    TerminalBackend::handle_resize(&mut term)?;
445                    clear_frame_layout_cache(&mut state);
446                }
447                events.push(ev);
448            }
449
450            while crossterm::event::poll(Duration::ZERO)? {
451                let raw = crossterm::event::read()?;
452                if let Some(ev) = event::from_crossterm(raw) {
453                    if is_ctrl_c(&ev) {
454                        return Ok(());
455                    }
456                    if let Event::Resize(_, _) = &ev {
457                        TerminalBackend::handle_resize(&mut term)?;
458                        clear_frame_layout_cache(&mut state);
459                    }
460                    events.push(ev);
461                }
462            }
463        }
464
465        update_last_mouse_pos(&mut state, &events);
466
467        sleep_for_fps_cap(config.max_fps, frame_start);
468    }
469
470    Ok(())
471}
472
473/// Run the TUI in inline mode with default configuration.
474///
475/// Renders `height` rows directly below the current cursor position without
476/// entering alternate screen mode. Useful for CLI tools that want a small
477/// interactive widget below the prompt.
478///
479/// # Example
480///
481/// ```no_run
482/// fn main() -> std::io::Result<()> {
483///     slt::run_inline(3, |ui| {
484///         ui.text("Inline TUI — no alternate screen");
485///     })
486/// }
487/// ```
488pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
489    run_inline_with(height, RunConfig::default(), f)
490}
491
492/// Run the TUI in inline mode with custom configuration.
493///
494/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
495/// mouse support, and theming.
496pub fn run_inline_with(
497    height: u32,
498    config: RunConfig,
499    mut f: impl FnMut(&mut Context),
500) -> io::Result<()> {
501    if !io::stdout().is_terminal() {
502        return Ok(());
503    }
504
505    install_panic_hook();
506    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
507    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
508    let mut events: Vec<Event> = Vec::new();
509    let mut state = FrameState::default();
510
511    loop {
512        let frame_start = Instant::now();
513        let (w, h) = term.size();
514        if w == 0 || h == 0 {
515            sleep_for_fps_cap(config.max_fps, frame_start);
516            continue;
517        }
518
519        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
520            break;
521        }
522
523        events.clear();
524        if crossterm::event::poll(config.tick_rate)? {
525            let raw = crossterm::event::read()?;
526            if let Some(ev) = event::from_crossterm(raw) {
527                if is_ctrl_c(&ev) {
528                    break;
529                }
530                if let Event::Resize(_, _) = &ev {
531                    TerminalBackend::handle_resize(&mut term)?;
532                }
533                events.push(ev);
534            }
535
536            while crossterm::event::poll(Duration::ZERO)? {
537                let raw = crossterm::event::read()?;
538                if let Some(ev) = event::from_crossterm(raw) {
539                    if is_ctrl_c(&ev) {
540                        return Ok(());
541                    }
542                    if let Event::Resize(_, _) = &ev {
543                        TerminalBackend::handle_resize(&mut term)?;
544                    }
545                    events.push(ev);
546                }
547            }
548
549            for ev in &events {
550                if matches!(
551                    ev,
552                    Event::Key(event::KeyEvent {
553                        code: KeyCode::F(12),
554                        kind: event::KeyEventKind::Press,
555                        ..
556                    })
557                ) {
558                    state.debug_mode = !state.debug_mode;
559                }
560            }
561        }
562
563        update_last_mouse_pos(&mut state, &events);
564
565        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
566            clear_frame_layout_cache(&mut state);
567        }
568
569        sleep_for_fps_cap(config.max_fps, frame_start);
570    }
571
572    Ok(())
573}
574
575fn run_frame<T: TerminalBackend>(
576    term: &mut T,
577    state: &mut FrameState,
578    config: &RunConfig,
579    events: &[event::Event],
580    f: &mut dyn FnMut(&mut context::Context),
581) -> io::Result<bool> {
582    let frame_start = Instant::now();
583    let (w, h) = term.size();
584    let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
585    ctx.process_focus_keys();
586
587    f(&mut ctx);
588
589    if ctx.should_quit {
590        return Ok(false);
591    }
592    state.prev_modal_active = ctx.modal_active;
593    let clipboard_text = ctx.clipboard_text.take();
594
595    let mut should_copy_selection = false;
596    for ev in &ctx.events {
597        if let Event::Mouse(mouse) = ev {
598            match mouse.kind {
599                event::MouseKind::Down(event::MouseButton::Left) => {
600                    state
601                        .selection
602                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
603                }
604                event::MouseKind::Drag(event::MouseButton::Left) => {
605                    state
606                        .selection
607                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
608                }
609                event::MouseKind::Up(event::MouseButton::Left) => {
610                    should_copy_selection = state.selection.active;
611                }
612                _ => {}
613            }
614        }
615    }
616
617    state.focus_index = ctx.focus_index;
618    state.prev_focus_count = ctx.focus_count;
619
620    let mut tree = layout::build_tree(&ctx.commands);
621    let area = crate::rect::Rect::new(0, 0, w, h);
622    layout::compute(&mut tree, area);
623    let fd = layout::collect_all(&tree);
624    state.prev_scroll_infos = fd.scroll_infos;
625    state.prev_scroll_rects = fd.scroll_rects;
626    state.prev_hit_map = fd.hit_areas;
627    state.prev_group_rects = fd.group_rects;
628    state.prev_content_map = fd.content_areas;
629    state.prev_focus_rects = fd.focus_rects;
630    state.prev_focus_groups = fd.focus_groups;
631    layout::render(&tree, term.buffer_mut());
632    let raw_rects = layout::collect_raw_draw_rects(&tree);
633    for (draw_id, rect) in raw_rects {
634        if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
635            let buf = term.buffer_mut();
636            buf.push_clip(rect);
637            cb(buf, rect);
638            buf.pop_clip();
639        }
640    }
641    state.hook_states = ctx.hook_states;
642
643    let frame_time = frame_start.elapsed();
644    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
645    let frame_secs = frame_time.as_secs_f32();
646    let inst_fps = if frame_secs > 0.0 {
647        1.0 / frame_secs
648    } else {
649        0.0
650    };
651    state.fps_ema = if state.fps_ema == 0.0 {
652        inst_fps
653    } else {
654        (state.fps_ema * 0.9) + (inst_fps * 0.1)
655    };
656    if state.debug_mode {
657        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
658    }
659
660    if state.selection.active {
661        terminal::apply_selection_overlay(
662            term.buffer_mut(),
663            &state.selection,
664            &state.prev_content_map,
665        );
666    }
667    if should_copy_selection {
668        let text = terminal::extract_selection_text(
669            term.buffer_mut(),
670            &state.selection,
671            &state.prev_content_map,
672        );
673        if !text.is_empty() {
674            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
675        }
676        state.selection.clear();
677    }
678
679    term.flush()?;
680    if let Some(text) = clipboard_text {
681        let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
682    }
683    state.tick = state.tick.wrapping_add(1);
684
685    Ok(true)
686}
687
688fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
689    for ev in events {
690        match ev {
691            Event::Mouse(mouse) => {
692                state.last_mouse_pos = Some((mouse.x, mouse.y));
693            }
694            Event::FocusLost => {
695                state.last_mouse_pos = None;
696            }
697            _ => {}
698        }
699    }
700}
701
702fn clear_frame_layout_cache(state: &mut FrameState) {
703    state.prev_hit_map.clear();
704    state.prev_group_rects.clear();
705    state.prev_content_map.clear();
706    state.prev_focus_rects.clear();
707    state.prev_focus_groups.clear();
708    state.prev_scroll_infos.clear();
709    state.prev_scroll_rects.clear();
710    state.last_mouse_pos = None;
711}
712
713fn is_ctrl_c(ev: &Event) -> bool {
714    matches!(
715        ev,
716        Event::Key(event::KeyEvent {
717            code: KeyCode::Char('c'),
718            modifiers,
719            kind: event::KeyEventKind::Press,
720        }) if modifiers.contains(KeyModifiers::CONTROL)
721    )
722}
723
724fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
725    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
726        let target = Duration::from_secs_f64(1.0 / fps as f64);
727        let elapsed = frame_start.elapsed();
728        if elapsed < target {
729            std::thread::sleep(target - elapsed);
730        }
731    }
732}