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