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