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//! - **20+ 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
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 layout;
45pub mod rect;
46pub mod style;
47mod terminal;
48pub mod test_utils;
49pub mod widgets;
50
51use std::io;
52use std::io::IsTerminal;
53use std::sync::Once;
54use std::time::{Duration, Instant};
55
56use terminal::{InlineTerminal, Terminal};
57
58pub use crate::test_utils::{EventBuilder, TestBackend};
59pub use anim::{Spring, Tween};
60pub use chart::{
61    Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
62    HistogramBuilder, LegendPosition, Marker,
63};
64pub use context::{Bar, BarDirection, BarGroup, CanvasContext, Context, Response, Widget};
65pub use event::{Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseKind};
66pub use style::{Align, Border, Color, Constraints, Margin, Modifiers, Padding, Style, Theme};
67pub use widgets::{
68    ListState, ScrollState, SpinnerState, TableState, TabsState, TextInputState, TextareaState,
69    ToastLevel, ToastMessage, ToastState,
70};
71
72static PANIC_HOOK_ONCE: Once = Once::new();
73
74fn install_panic_hook() {
75    PANIC_HOOK_ONCE.call_once(|| {
76        let original = std::panic::take_hook();
77        std::panic::set_hook(Box::new(move |panic_info| {
78            let _ = crossterm::terminal::disable_raw_mode();
79            let mut stdout = io::stdout();
80            let _ = crossterm::execute!(
81                stdout,
82                crossterm::terminal::LeaveAlternateScreen,
83                crossterm::cursor::Show,
84                crossterm::event::DisableMouseCapture,
85                crossterm::event::DisableBracketedPaste,
86                crossterm::style::ResetColor,
87                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
88            );
89            original(panic_info);
90        }));
91    });
92}
93
94/// Configuration for a TUI run loop.
95///
96/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
97/// Use [`Default::default()`] for sensible defaults (100ms tick, no mouse, dark theme).
98///
99/// # Example
100///
101/// ```no_run
102/// use slt::{RunConfig, Theme};
103/// use std::time::Duration;
104///
105/// let config = RunConfig {
106///     tick_rate: Duration::from_millis(50),
107///     mouse: true,
108///     theme: Theme::light(),
109///     max_fps: Some(60),
110/// };
111/// ```
112#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
113pub struct RunConfig {
114    /// How long to wait for input before triggering a tick with no events.
115    ///
116    /// Lower values give smoother animations at the cost of more CPU usage.
117    /// Defaults to 100ms.
118    pub tick_rate: Duration,
119    /// Whether to enable mouse event reporting.
120    ///
121    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
122    /// Defaults to `false`.
123    pub mouse: bool,
124    /// The color theme applied to all widgets automatically.
125    ///
126    /// Defaults to [`Theme::dark()`].
127    pub theme: Theme,
128    /// Optional maximum frame rate.
129    ///
130    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
131    /// loop iteration to target that frame time.
132    pub max_fps: Option<u32>,
133}
134
135impl Default for RunConfig {
136    fn default() -> Self {
137        Self {
138            tick_rate: Duration::from_millis(100),
139            mouse: false,
140            theme: Theme::dark(),
141            max_fps: None,
142        }
143    }
144}
145
146/// Run the TUI loop with default configuration.
147///
148/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
149/// Ctrl+C or when [`Context::quit`] is called.
150///
151/// # Example
152///
153/// ```no_run
154/// fn main() -> std::io::Result<()> {
155///     slt::run(|ui| {
156///         ui.text("Press Ctrl+C to exit");
157///     })
158/// }
159/// ```
160pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
161    run_with(RunConfig::default(), f)
162}
163
164/// Run the TUI loop with custom configuration.
165///
166/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
167/// support, and theming.
168///
169/// # Example
170///
171/// ```no_run
172/// use slt::{RunConfig, Theme};
173///
174/// fn main() -> std::io::Result<()> {
175///     slt::run_with(
176///         RunConfig { theme: Theme::light(), ..Default::default() },
177///         |ui| {
178///             ui.text("Light theme!");
179///         },
180///     )
181/// }
182/// ```
183pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
184    if !io::stdout().is_terminal() {
185        return Ok(());
186    }
187
188    install_panic_hook();
189    let mut term = Terminal::new(config.mouse)?;
190    let mut events: Vec<Event> = Vec::new();
191    let mut debug_mode: bool = false;
192    let mut tick: u64 = 0;
193    let mut focus_index: usize = 0;
194    let mut prev_focus_count: usize = 0;
195    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
196    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
197    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
198    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
199    let mut last_mouse_pos: Option<(u32, u32)> = None;
200    let mut selection = terminal::SelectionState::default();
201
202    loop {
203        let frame_start = Instant::now();
204        let (w, h) = term.size();
205        if w == 0 || h == 0 {
206            sleep_for_fps_cap(config.max_fps, frame_start);
207            continue;
208        }
209        let mut ctx = Context::new(
210            std::mem::take(&mut events),
211            w,
212            h,
213            tick,
214            focus_index,
215            prev_focus_count,
216            std::mem::take(&mut prev_scroll_infos),
217            std::mem::take(&mut prev_hit_map),
218            std::mem::take(&mut prev_focus_rects),
219            debug_mode,
220            config.theme,
221            last_mouse_pos,
222        );
223        ctx.process_focus_keys();
224
225        f(&mut ctx);
226
227        if ctx.should_quit {
228            break;
229        }
230
231        let mut should_copy_selection = false;
232        for ev in ctx.events.iter() {
233            if let Event::Mouse(mouse) = ev {
234                match mouse.kind {
235                    event::MouseKind::Down(event::MouseButton::Left) => {
236                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
237                    }
238                    event::MouseKind::Drag(event::MouseButton::Left) => {
239                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
240                    }
241                    event::MouseKind::Up(event::MouseButton::Left) => {
242                        should_copy_selection = selection.active;
243                    }
244                    _ => {}
245                }
246            }
247        }
248
249        focus_index = ctx.focus_index;
250        prev_focus_count = ctx.focus_count;
251
252        let mut tree = layout::build_tree(&ctx.commands);
253        let area = crate::rect::Rect::new(0, 0, w, h);
254        layout::compute(&mut tree, area);
255        prev_scroll_infos = layout::collect_scroll_infos(&tree);
256        prev_hit_map = layout::collect_hit_areas(&tree);
257        prev_content_map = layout::collect_content_areas(&tree);
258        prev_focus_rects = layout::collect_focus_rects(&tree);
259        layout::render(&tree, term.buffer_mut());
260        if debug_mode {
261            layout::render_debug_overlay(&tree, term.buffer_mut());
262        }
263
264        if selection.active {
265            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
266        }
267        if should_copy_selection {
268            let text =
269                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
270            if !text.is_empty() {
271                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
272            }
273            selection.clear();
274        }
275
276        term.flush()?;
277        tick = tick.wrapping_add(1);
278
279        events.clear();
280        if crossterm::event::poll(config.tick_rate)? {
281            let raw = crossterm::event::read()?;
282            if let Some(ev) = event::from_crossterm(raw) {
283                if is_ctrl_c(&ev) {
284                    break;
285                }
286                if let Event::Resize(_, _) = &ev {
287                    term.handle_resize()?;
288                }
289                events.push(ev);
290            }
291
292            while crossterm::event::poll(Duration::ZERO)? {
293                let raw = crossterm::event::read()?;
294                if let Some(ev) = event::from_crossterm(raw) {
295                    if is_ctrl_c(&ev) {
296                        return Ok(());
297                    }
298                    if let Event::Resize(_, _) = &ev {
299                        term.handle_resize()?;
300                    }
301                    events.push(ev);
302                }
303            }
304
305            for ev in &events {
306                if matches!(
307                    ev,
308                    Event::Key(event::KeyEvent {
309                        code: KeyCode::F(12),
310                        ..
311                    })
312                ) {
313                    debug_mode = !debug_mode;
314                }
315            }
316        }
317
318        for ev in &events {
319            if let Event::Mouse(mouse) = ev {
320                last_mouse_pos = Some((mouse.x, mouse.y));
321            }
322        }
323
324        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
325            prev_hit_map.clear();
326            prev_content_map.clear();
327            prev_focus_rects.clear();
328            prev_scroll_infos.clear();
329            last_mouse_pos = None;
330        }
331
332        sleep_for_fps_cap(config.max_fps, frame_start);
333    }
334
335    Ok(())
336}
337
338/// Run the TUI loop asynchronously with default configuration.
339///
340/// Requires the `async` feature. Spawns the render loop in a blocking thread
341/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
342/// from async tasks into the UI closure.
343///
344/// # Example
345///
346/// ```no_run
347/// # #[cfg(feature = "async")]
348/// # async fn example() -> std::io::Result<()> {
349/// let tx = slt::run_async::<String>(|ui, messages| {
350///     for msg in messages.drain(..) {
351///         ui.text(msg);
352///     }
353/// })?;
354/// tx.send("hello from async".to_string()).await.ok();
355/// # Ok(())
356/// # }
357/// ```
358#[cfg(feature = "async")]
359pub fn run_async<M: Send + 'static>(
360    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
361) -> io::Result<tokio::sync::mpsc::Sender<M>> {
362    run_async_with(RunConfig::default(), f)
363}
364
365/// Run the TUI loop asynchronously with custom configuration.
366///
367/// Requires the `async` feature. Like [`run_async`], but accepts a
368/// [`RunConfig`] to control tick rate, mouse support, and theming.
369///
370/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
371#[cfg(feature = "async")]
372pub fn run_async_with<M: Send + 'static>(
373    config: RunConfig,
374    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
375) -> io::Result<tokio::sync::mpsc::Sender<M>> {
376    let (tx, rx) = tokio::sync::mpsc::channel(100);
377    let handle =
378        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
379
380    handle.spawn_blocking(move || {
381        let _ = run_async_loop(config, f, rx);
382    });
383
384    Ok(tx)
385}
386
387#[cfg(feature = "async")]
388fn run_async_loop<M: Send + 'static>(
389    config: RunConfig,
390    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
391    mut rx: tokio::sync::mpsc::Receiver<M>,
392) -> io::Result<()> {
393    if !io::stdout().is_terminal() {
394        return Ok(());
395    }
396
397    install_panic_hook();
398    let mut term = Terminal::new(config.mouse)?;
399    let mut events: Vec<Event> = Vec::new();
400    let mut tick: u64 = 0;
401    let mut focus_index: usize = 0;
402    let mut prev_focus_count: usize = 0;
403    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
404    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
405    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
406    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
407    let mut last_mouse_pos: Option<(u32, u32)> = None;
408    let mut selection = terminal::SelectionState::default();
409
410    loop {
411        let frame_start = Instant::now();
412        let mut messages: Vec<M> = Vec::new();
413        while let Ok(message) = rx.try_recv() {
414            messages.push(message);
415        }
416
417        let (w, h) = term.size();
418        if w == 0 || h == 0 {
419            sleep_for_fps_cap(config.max_fps, frame_start);
420            continue;
421        }
422        let mut ctx = Context::new(
423            std::mem::take(&mut events),
424            w,
425            h,
426            tick,
427            focus_index,
428            prev_focus_count,
429            std::mem::take(&mut prev_scroll_infos),
430            std::mem::take(&mut prev_hit_map),
431            std::mem::take(&mut prev_focus_rects),
432            false,
433            config.theme,
434            last_mouse_pos,
435        );
436        ctx.process_focus_keys();
437
438        f(&mut ctx, &mut messages);
439
440        if ctx.should_quit {
441            break;
442        }
443
444        let mut should_copy_selection = false;
445        for ev in ctx.events.iter() {
446            if let Event::Mouse(mouse) = ev {
447                match mouse.kind {
448                    event::MouseKind::Down(event::MouseButton::Left) => {
449                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
450                    }
451                    event::MouseKind::Drag(event::MouseButton::Left) => {
452                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
453                    }
454                    event::MouseKind::Up(event::MouseButton::Left) => {
455                        should_copy_selection = selection.active;
456                    }
457                    _ => {}
458                }
459            }
460        }
461
462        focus_index = ctx.focus_index;
463        prev_focus_count = ctx.focus_count;
464
465        let mut tree = layout::build_tree(&ctx.commands);
466        let area = crate::rect::Rect::new(0, 0, w, h);
467        layout::compute(&mut tree, area);
468        prev_scroll_infos = layout::collect_scroll_infos(&tree);
469        prev_hit_map = layout::collect_hit_areas(&tree);
470        prev_content_map = layout::collect_content_areas(&tree);
471        prev_focus_rects = layout::collect_focus_rects(&tree);
472        layout::render(&tree, term.buffer_mut());
473
474        if selection.active {
475            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
476        }
477        if should_copy_selection {
478            let text =
479                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
480            if !text.is_empty() {
481                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
482            }
483            selection.clear();
484        }
485
486        term.flush()?;
487        tick = tick.wrapping_add(1);
488
489        events.clear();
490        if crossterm::event::poll(config.tick_rate)? {
491            let raw = crossterm::event::read()?;
492            if let Some(ev) = event::from_crossterm(raw) {
493                if is_ctrl_c(&ev) {
494                    break;
495                }
496                if let Event::Resize(_, _) = &ev {
497                    term.handle_resize()?;
498                    prev_hit_map.clear();
499                    prev_content_map.clear();
500                    prev_focus_rects.clear();
501                    prev_scroll_infos.clear();
502                    last_mouse_pos = None;
503                }
504                events.push(ev);
505            }
506
507            while crossterm::event::poll(Duration::ZERO)? {
508                let raw = crossterm::event::read()?;
509                if let Some(ev) = event::from_crossterm(raw) {
510                    if is_ctrl_c(&ev) {
511                        return Ok(());
512                    }
513                    if let Event::Resize(_, _) = &ev {
514                        term.handle_resize()?;
515                        prev_hit_map.clear();
516                        prev_content_map.clear();
517                        prev_focus_rects.clear();
518                        prev_scroll_infos.clear();
519                        last_mouse_pos = None;
520                    }
521                    events.push(ev);
522                }
523            }
524        }
525
526        for ev in &events {
527            if let Event::Mouse(mouse) = ev {
528                last_mouse_pos = Some((mouse.x, mouse.y));
529            }
530        }
531
532        sleep_for_fps_cap(config.max_fps, frame_start);
533    }
534
535    Ok(())
536}
537
538/// Run the TUI in inline mode with default configuration.
539///
540/// Renders `height` rows directly below the current cursor position without
541/// entering alternate screen mode. Useful for CLI tools that want a small
542/// interactive widget below the prompt.
543///
544/// # Example
545///
546/// ```no_run
547/// fn main() -> std::io::Result<()> {
548///     slt::run_inline(3, |ui| {
549///         ui.text("Inline TUI — no alternate screen");
550///     })
551/// }
552/// ```
553pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
554    run_inline_with(height, RunConfig::default(), f)
555}
556
557/// Run the TUI in inline mode with custom configuration.
558///
559/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
560/// mouse support, and theming.
561pub fn run_inline_with(
562    height: u32,
563    config: RunConfig,
564    mut f: impl FnMut(&mut Context),
565) -> io::Result<()> {
566    if !io::stdout().is_terminal() {
567        return Ok(());
568    }
569
570    install_panic_hook();
571    let mut term = InlineTerminal::new(height, config.mouse)?;
572    let mut events: Vec<Event> = Vec::new();
573    let mut debug_mode: bool = false;
574    let mut tick: u64 = 0;
575    let mut focus_index: usize = 0;
576    let mut prev_focus_count: usize = 0;
577    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
578    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
579    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
580    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
581    let mut last_mouse_pos: Option<(u32, u32)> = None;
582    let mut selection = terminal::SelectionState::default();
583
584    loop {
585        let frame_start = Instant::now();
586        let (w, h) = term.size();
587        if w == 0 || h == 0 {
588            sleep_for_fps_cap(config.max_fps, frame_start);
589            continue;
590        }
591        let mut ctx = Context::new(
592            std::mem::take(&mut events),
593            w,
594            h,
595            tick,
596            focus_index,
597            prev_focus_count,
598            std::mem::take(&mut prev_scroll_infos),
599            std::mem::take(&mut prev_hit_map),
600            std::mem::take(&mut prev_focus_rects),
601            debug_mode,
602            config.theme,
603            last_mouse_pos,
604        );
605        ctx.process_focus_keys();
606
607        f(&mut ctx);
608
609        if ctx.should_quit {
610            break;
611        }
612
613        let mut should_copy_selection = false;
614        for ev in ctx.events.iter() {
615            if let Event::Mouse(mouse) = ev {
616                match mouse.kind {
617                    event::MouseKind::Down(event::MouseButton::Left) => {
618                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
619                    }
620                    event::MouseKind::Drag(event::MouseButton::Left) => {
621                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
622                    }
623                    event::MouseKind::Up(event::MouseButton::Left) => {
624                        should_copy_selection = selection.active;
625                    }
626                    _ => {}
627                }
628            }
629        }
630
631        focus_index = ctx.focus_index;
632        prev_focus_count = ctx.focus_count;
633
634        let mut tree = layout::build_tree(&ctx.commands);
635        let area = crate::rect::Rect::new(0, 0, w, h);
636        layout::compute(&mut tree, area);
637        prev_scroll_infos = layout::collect_scroll_infos(&tree);
638        prev_hit_map = layout::collect_hit_areas(&tree);
639        prev_content_map = layout::collect_content_areas(&tree);
640        prev_focus_rects = layout::collect_focus_rects(&tree);
641        layout::render(&tree, term.buffer_mut());
642        if debug_mode {
643            layout::render_debug_overlay(&tree, term.buffer_mut());
644        }
645
646        if selection.active {
647            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
648        }
649        if should_copy_selection {
650            let text =
651                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
652            if !text.is_empty() {
653                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
654            }
655            selection.clear();
656        }
657
658        term.flush()?;
659        tick = tick.wrapping_add(1);
660
661        events.clear();
662        if crossterm::event::poll(config.tick_rate)? {
663            let raw = crossterm::event::read()?;
664            if let Some(ev) = event::from_crossterm(raw) {
665                if is_ctrl_c(&ev) {
666                    break;
667                }
668                if let Event::Resize(_, _) = &ev {
669                    term.handle_resize()?;
670                }
671                events.push(ev);
672            }
673
674            while crossterm::event::poll(Duration::ZERO)? {
675                let raw = crossterm::event::read()?;
676                if let Some(ev) = event::from_crossterm(raw) {
677                    if is_ctrl_c(&ev) {
678                        return Ok(());
679                    }
680                    if let Event::Resize(_, _) = &ev {
681                        term.handle_resize()?;
682                    }
683                    events.push(ev);
684                }
685            }
686
687            for ev in &events {
688                if matches!(
689                    ev,
690                    Event::Key(event::KeyEvent {
691                        code: KeyCode::F(12),
692                        ..
693                    })
694                ) {
695                    debug_mode = !debug_mode;
696                }
697            }
698        }
699
700        for ev in &events {
701            if let Event::Mouse(mouse) = ev {
702                last_mouse_pos = Some((mouse.x, mouse.y));
703            }
704        }
705
706        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
707            prev_hit_map.clear();
708            prev_content_map.clear();
709            prev_focus_rects.clear();
710            prev_scroll_infos.clear();
711            last_mouse_pos = None;
712        }
713
714        sleep_for_fps_cap(config.max_fps, frame_start);
715    }
716
717    Ok(())
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        }) if modifiers.contains(KeyModifiers::CONTROL)
727    )
728}
729
730fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
731    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
732        let target = Duration::from_secs_f64(1.0 / fps as f64);
733        let elapsed = frame_start.elapsed();
734        if elapsed < target {
735            std::thread::sleep(target - elapsed);
736        }
737    }
738}