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