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};
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, StreamingMarkdownState, StreamingTextState, TableState,
90    TabsState, TextInputState, TextareaState, ToastLevel, ToastMessage, ToastState,
91    ToolApprovalState, TreeNode, TreeState, Trend,
92};
93
94/// Rendering backend for SLT.
95///
96/// Implement this trait to render SLT UIs to custom targets — alternative
97/// terminals, GUI embeds, test harnesses, WASM canvas, etc.
98///
99/// The built-in terminal backend ([`run()`], [`run_with()`]) handles setup,
100/// teardown, and event polling automatically. For custom backends, pair this
101/// trait with [`AppState`] and [`frame()`] to drive the render loop yourself.
102///
103/// # Example
104///
105/// ```ignore
106/// use slt::{Backend, AppState, Buffer, Rect, RunConfig, Context, Event};
107///
108/// struct MyBackend {
109///     buffer: Buffer,
110/// }
111///
112/// impl Backend for MyBackend {
113///     fn size(&self) -> (u32, u32) {
114///         (self.buffer.area.width, self.buffer.area.height)
115///     }
116///     fn buffer_mut(&mut self) -> &mut Buffer {
117///         &mut self.buffer
118///     }
119///     fn flush(&mut self) -> std::io::Result<()> {
120///         // Render self.buffer to your target
121///         Ok(())
122///     }
123/// }
124///
125/// fn main() -> std::io::Result<()> {
126///     let mut backend = MyBackend {
127///         buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
128///     };
129///     let mut state = AppState::new();
130///     let config = RunConfig::default();
131///
132///     loop {
133///         let events: Vec<Event> = vec![]; // Collect your own events
134///         if !slt::frame(&mut backend, &mut state, &config, &events, &mut |ui| {
135///             ui.text("Hello from custom backend!");
136///         })? {
137///             break;
138///         }
139///     }
140///     Ok(())
141/// }
142/// ```
143pub trait Backend {
144    /// Returns the current display size as `(width, height)` in cells.
145    fn size(&self) -> (u32, u32);
146
147    /// Returns a mutable reference to the display buffer.
148    ///
149    /// SLT writes the UI into this buffer each frame. After [`frame()`]
150    /// returns, call [`flush()`](Backend::flush) to present the result.
151    fn buffer_mut(&mut self) -> &mut Buffer;
152
153    /// Flush the buffer contents to the display.
154    ///
155    /// Called automatically at the end of each [`frame()`] call. Implementations
156    /// should present the current buffer to the user — by writing ANSI escapes,
157    /// drawing to a canvas, updating a texture, etc.
158    fn flush(&mut self) -> io::Result<()>;
159}
160
161/// Opaque per-session state that persists between frames.
162///
163/// Tracks focus, scroll positions, hook state, and other frame-to-frame data.
164/// Create with [`AppState::new()`] and pass to [`frame()`] each iteration.
165///
166/// # Example
167///
168/// ```ignore
169/// let mut state = slt::AppState::new();
170/// // state is passed to slt::frame() in your render loop
171/// ```
172pub struct AppState {
173    pub(crate) inner: FrameState,
174}
175
176impl AppState {
177    /// Create a new empty application state.
178    pub fn new() -> Self {
179        Self {
180            inner: FrameState::default(),
181        }
182    }
183
184    /// Returns the current frame tick count (increments each frame).
185    pub fn tick(&self) -> u64 {
186        self.inner.tick
187    }
188
189    /// Returns the smoothed FPS estimate (exponential moving average).
190    pub fn fps(&self) -> f32 {
191        self.inner.fps_ema
192    }
193
194    /// Toggle the debug overlay (same as pressing F12).
195    pub fn set_debug(&mut self, enabled: bool) {
196        self.inner.debug_mode = enabled;
197    }
198}
199
200impl Default for AppState {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206/// Process a single UI frame with a custom [`Backend`].
207///
208/// This is the low-level entry point for custom backends. For standard terminal
209/// usage, prefer [`run()`] or [`run_with()`] which handle the event loop,
210/// terminal setup, and teardown automatically.
211///
212/// Returns `Ok(true)` to continue, `Ok(false)` when [`Context::quit()`] was
213/// called.
214///
215/// # Arguments
216///
217/// * `backend` — Your [`Backend`] implementation
218/// * `state` — Persistent [`AppState`] (reuse across frames)
219/// * `config` — [`RunConfig`] (theme, tick rate, etc.)
220/// * `events` — Input events for this frame (keyboard, mouse, resize)
221/// * `f` — Your UI closure, called once per frame
222///
223/// # Example
224///
225/// ```ignore
226/// let keep_going = slt::frame(
227///     &mut my_backend,
228///     &mut state,
229///     &config,
230///     &events,
231///     &mut |ui| { ui.text("hello"); },
232/// )?;
233/// ```
234pub fn frame(
235    backend: &mut impl Backend,
236    state: &mut AppState,
237    config: &RunConfig,
238    events: &[Event],
239    f: &mut impl FnMut(&mut Context),
240) -> io::Result<bool> {
241    run_frame(backend, &mut state.inner, config, events, f)
242}
243
244static PANIC_HOOK_ONCE: Once = Once::new();
245
246fn install_panic_hook() {
247    PANIC_HOOK_ONCE.call_once(|| {
248        let original = std::panic::take_hook();
249        std::panic::set_hook(Box::new(move |panic_info| {
250            let _ = crossterm::terminal::disable_raw_mode();
251            let mut stdout = io::stdout();
252            let _ = crossterm::execute!(
253                stdout,
254                crossterm::terminal::LeaveAlternateScreen,
255                crossterm::cursor::Show,
256                crossterm::event::DisableMouseCapture,
257                crossterm::event::DisableBracketedPaste,
258                crossterm::style::ResetColor,
259                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
260            );
261
262            // Print friendly panic header
263            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
264
265            // Print location if available
266            if let Some(location) = panic_info.location() {
267                eprintln!(
268                    "\x1b[90m{}:{}:{}\x1b[0m",
269                    location.file(),
270                    location.line(),
271                    location.column()
272                );
273            }
274
275            // Print message
276            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
277                eprintln!("\x1b[1m{}\x1b[0m", msg);
278            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
279                eprintln!("\x1b[1m{}\x1b[0m", msg);
280            }
281
282            eprintln!(
283                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
284            );
285
286            original(panic_info);
287        }));
288    });
289}
290
291/// Configuration for a TUI run loop.
292///
293/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
294/// Use [`Default::default()`] for sensible defaults (16ms tick / 60fps, no mouse, dark theme).
295///
296/// # Example
297///
298/// ```no_run
299/// use slt::{RunConfig, Theme};
300/// use std::time::Duration;
301///
302/// let config = RunConfig {
303///     tick_rate: Duration::from_millis(50),
304///     mouse: true,
305///     kitty_keyboard: false,
306///     theme: Theme::light(),
307///     color_depth: None,
308///     max_fps: Some(60),
309/// };
310/// ```
311#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
312pub struct RunConfig {
313    /// How long to wait for input before triggering a tick with no events.
314    ///
315    /// Lower values give smoother animations at the cost of more CPU usage.
316    /// Defaults to 16ms (60fps).
317    pub tick_rate: Duration,
318    /// Whether to enable mouse event reporting.
319    ///
320    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
321    /// Defaults to `false`.
322    pub mouse: bool,
323    /// Whether to enable the Kitty keyboard protocol for enhanced input.
324    ///
325    /// When `true`, enables disambiguated key events, key release events,
326    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
327    /// Terminals that don't support it silently ignore the request.
328    /// Defaults to `false`.
329    pub kitty_keyboard: bool,
330    /// The color theme applied to all widgets automatically.
331    ///
332    /// Defaults to [`Theme::dark()`].
333    pub theme: Theme,
334    /// Color depth override.
335    ///
336    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
337    /// variables. Set explicitly to force a specific color depth regardless
338    /// of terminal capabilities.
339    pub color_depth: Option<ColorDepth>,
340    /// Optional maximum frame rate.
341    ///
342    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
343    /// loop iteration to target that frame time.
344    pub max_fps: Option<u32>,
345}
346
347impl Default for RunConfig {
348    fn default() -> Self {
349        Self {
350            tick_rate: Duration::from_millis(16),
351            mouse: false,
352            kitty_keyboard: false,
353            theme: Theme::dark(),
354            color_depth: None,
355            max_fps: Some(60),
356        }
357    }
358}
359
360pub(crate) struct FrameState {
361    pub hook_states: Vec<Box<dyn std::any::Any>>,
362    pub focus_index: usize,
363    pub prev_focus_count: usize,
364    pub tick: u64,
365    pub prev_scroll_infos: Vec<(u32, u32)>,
366    pub prev_scroll_rects: Vec<rect::Rect>,
367    pub prev_hit_map: Vec<rect::Rect>,
368    pub prev_group_rects: Vec<(String, rect::Rect)>,
369    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
370    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
371    pub prev_focus_groups: Vec<Option<String>>,
372    pub last_mouse_pos: Option<(u32, u32)>,
373    pub prev_modal_active: bool,
374    pub notification_queue: Vec<(String, ToastLevel, u64)>,
375    pub debug_mode: bool,
376    pub fps_ema: f32,
377    pub selection: terminal::SelectionState,
378}
379
380impl Default for FrameState {
381    fn default() -> Self {
382        Self {
383            hook_states: Vec::new(),
384            focus_index: 0,
385            prev_focus_count: 0,
386            tick: 0,
387            prev_scroll_infos: Vec::new(),
388            prev_scroll_rects: Vec::new(),
389            prev_hit_map: Vec::new(),
390            prev_group_rects: Vec::new(),
391            prev_content_map: Vec::new(),
392            prev_focus_rects: Vec::new(),
393            prev_focus_groups: Vec::new(),
394            last_mouse_pos: None,
395            prev_modal_active: false,
396            notification_queue: Vec::new(),
397            debug_mode: false,
398            fps_ema: 0.0,
399            selection: terminal::SelectionState::default(),
400        }
401    }
402}
403
404/// Run the TUI loop with default configuration.
405///
406/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
407/// Ctrl+C or when [`Context::quit`] is called.
408///
409/// # Example
410///
411/// ```no_run
412/// fn main() -> std::io::Result<()> {
413///     slt::run(|ui| {
414///         ui.text("Press Ctrl+C to exit");
415///     })
416/// }
417/// ```
418pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
419    run_with(RunConfig::default(), f)
420}
421
422/// Run the TUI loop with custom configuration.
423///
424/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
425/// support, and theming.
426///
427/// # Example
428///
429/// ```no_run
430/// use slt::{RunConfig, Theme};
431///
432/// fn main() -> std::io::Result<()> {
433///     slt::run_with(
434///         RunConfig { theme: Theme::light(), ..Default::default() },
435///         |ui| {
436///             ui.text("Light theme!");
437///         },
438///     )
439/// }
440/// ```
441pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
442    if !io::stdout().is_terminal() {
443        return Ok(());
444    }
445
446    install_panic_hook();
447    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
448    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
449    if config.theme.bg != Color::Reset {
450        term.theme_bg = Some(config.theme.bg);
451    }
452    let mut events: Vec<Event> = Vec::new();
453    let mut state = FrameState::default();
454
455    loop {
456        let frame_start = Instant::now();
457        let (w, h) = term.size();
458        if w == 0 || h == 0 {
459            sleep_for_fps_cap(config.max_fps, frame_start);
460            continue;
461        }
462
463        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
464            break;
465        }
466
467        events.clear();
468        if crossterm::event::poll(config.tick_rate)? {
469            let raw = crossterm::event::read()?;
470            if let Some(ev) = event::from_crossterm(raw) {
471                if is_ctrl_c(&ev) {
472                    break;
473                }
474                if let Event::Resize(_, _) = &ev {
475                    term.handle_resize()?;
476                }
477                events.push(ev);
478            }
479
480            while crossterm::event::poll(Duration::ZERO)? {
481                let raw = crossterm::event::read()?;
482                if let Some(ev) = event::from_crossterm(raw) {
483                    if is_ctrl_c(&ev) {
484                        return Ok(());
485                    }
486                    if let Event::Resize(_, _) = &ev {
487                        term.handle_resize()?;
488                    }
489                    events.push(ev);
490                }
491            }
492
493            for ev in &events {
494                if matches!(
495                    ev,
496                    Event::Key(event::KeyEvent {
497                        code: KeyCode::F(12),
498                        kind: event::KeyEventKind::Press,
499                        ..
500                    })
501                ) {
502                    state.debug_mode = !state.debug_mode;
503                }
504            }
505        }
506
507        update_last_mouse_pos(&mut state, &events);
508
509        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
510            clear_frame_layout_cache(&mut state);
511        }
512
513        sleep_for_fps_cap(config.max_fps, frame_start);
514    }
515
516    Ok(())
517}
518
519/// Run the TUI loop asynchronously with default configuration.
520///
521/// Requires the `async` feature. Spawns the render loop in a blocking thread
522/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
523/// from async tasks into the UI closure.
524///
525/// # Example
526///
527/// ```no_run
528/// # #[cfg(feature = "async")]
529/// # async fn example() -> std::io::Result<()> {
530/// let tx = slt::run_async::<String>(|ui, messages| {
531///     for msg in messages.drain(..) {
532///         ui.text(msg);
533///     }
534/// })?;
535/// tx.send("hello from async".to_string()).await.ok();
536/// # Ok(())
537/// # }
538/// ```
539#[cfg(feature = "async")]
540pub fn run_async<M: Send + 'static>(
541    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
542) -> io::Result<tokio::sync::mpsc::Sender<M>> {
543    run_async_with(RunConfig::default(), f)
544}
545
546/// Run the TUI loop asynchronously with custom configuration.
547///
548/// Requires the `async` feature. Like [`run_async`], but accepts a
549/// [`RunConfig`] to control tick rate, mouse support, and theming.
550///
551/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
552#[cfg(feature = "async")]
553pub fn run_async_with<M: Send + 'static>(
554    config: RunConfig,
555    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
556) -> io::Result<tokio::sync::mpsc::Sender<M>> {
557    let (tx, rx) = tokio::sync::mpsc::channel(100);
558    let handle =
559        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
560
561    handle.spawn_blocking(move || {
562        let _ = run_async_loop(config, f, rx);
563    });
564
565    Ok(tx)
566}
567
568#[cfg(feature = "async")]
569fn run_async_loop<M: Send + 'static>(
570    config: RunConfig,
571    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
572    mut rx: tokio::sync::mpsc::Receiver<M>,
573) -> io::Result<()> {
574    if !io::stdout().is_terminal() {
575        return Ok(());
576    }
577
578    install_panic_hook();
579    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
580    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
581    if config.theme.bg != Color::Reset {
582        term.theme_bg = Some(config.theme.bg);
583    }
584    let mut events: Vec<Event> = Vec::new();
585    let mut state = FrameState::default();
586
587    loop {
588        let frame_start = Instant::now();
589        let mut messages: Vec<M> = Vec::new();
590        while let Ok(message) = rx.try_recv() {
591            messages.push(message);
592        }
593
594        let (w, h) = term.size();
595        if w == 0 || h == 0 {
596            sleep_for_fps_cap(config.max_fps, frame_start);
597            continue;
598        }
599
600        let mut render = |ctx: &mut Context| {
601            f(ctx, &mut messages);
602        };
603        if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
604            break;
605        }
606
607        events.clear();
608        if crossterm::event::poll(config.tick_rate)? {
609            let raw = crossterm::event::read()?;
610            if let Some(ev) = event::from_crossterm(raw) {
611                if is_ctrl_c(&ev) {
612                    break;
613                }
614                if let Event::Resize(_, _) = &ev {
615                    term.handle_resize()?;
616                    clear_frame_layout_cache(&mut state);
617                }
618                events.push(ev);
619            }
620
621            while crossterm::event::poll(Duration::ZERO)? {
622                let raw = crossterm::event::read()?;
623                if let Some(ev) = event::from_crossterm(raw) {
624                    if is_ctrl_c(&ev) {
625                        return Ok(());
626                    }
627                    if let Event::Resize(_, _) = &ev {
628                        term.handle_resize()?;
629                        clear_frame_layout_cache(&mut state);
630                    }
631                    events.push(ev);
632                }
633            }
634        }
635
636        update_last_mouse_pos(&mut state, &events);
637
638        sleep_for_fps_cap(config.max_fps, frame_start);
639    }
640
641    Ok(())
642}
643
644/// Run the TUI in inline mode with default configuration.
645///
646/// Renders `height` rows directly below the current cursor position without
647/// entering alternate screen mode. Useful for CLI tools that want a small
648/// interactive widget below the prompt.
649///
650/// # Example
651///
652/// ```no_run
653/// fn main() -> std::io::Result<()> {
654///     slt::run_inline(3, |ui| {
655///         ui.text("Inline TUI — no alternate screen");
656///     })
657/// }
658/// ```
659pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
660    run_inline_with(height, RunConfig::default(), f)
661}
662
663/// Run the TUI in inline mode with custom configuration.
664///
665/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
666/// mouse support, and theming.
667pub fn run_inline_with(
668    height: u32,
669    config: RunConfig,
670    mut f: impl FnMut(&mut Context),
671) -> io::Result<()> {
672    if !io::stdout().is_terminal() {
673        return Ok(());
674    }
675
676    install_panic_hook();
677    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
678    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
679    if config.theme.bg != Color::Reset {
680        term.theme_bg = Some(config.theme.bg);
681    }
682    let mut events: Vec<Event> = Vec::new();
683    let mut state = FrameState::default();
684
685    loop {
686        let frame_start = Instant::now();
687        let (w, h) = term.size();
688        if w == 0 || h == 0 {
689            sleep_for_fps_cap(config.max_fps, frame_start);
690            continue;
691        }
692
693        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
694            break;
695        }
696
697        events.clear();
698        if crossterm::event::poll(config.tick_rate)? {
699            let raw = crossterm::event::read()?;
700            if let Some(ev) = event::from_crossterm(raw) {
701                if is_ctrl_c(&ev) {
702                    break;
703                }
704                if let Event::Resize(_, _) = &ev {
705                    term.handle_resize()?;
706                }
707                events.push(ev);
708            }
709
710            while crossterm::event::poll(Duration::ZERO)? {
711                let raw = crossterm::event::read()?;
712                if let Some(ev) = event::from_crossterm(raw) {
713                    if is_ctrl_c(&ev) {
714                        return Ok(());
715                    }
716                    if let Event::Resize(_, _) = &ev {
717                        term.handle_resize()?;
718                    }
719                    events.push(ev);
720                }
721            }
722
723            for ev in &events {
724                if matches!(
725                    ev,
726                    Event::Key(event::KeyEvent {
727                        code: KeyCode::F(12),
728                        kind: event::KeyEventKind::Press,
729                        ..
730                    })
731                ) {
732                    state.debug_mode = !state.debug_mode;
733                }
734            }
735        }
736
737        update_last_mouse_pos(&mut state, &events);
738
739        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
740            clear_frame_layout_cache(&mut state);
741        }
742
743        sleep_for_fps_cap(config.max_fps, frame_start);
744    }
745
746    Ok(())
747}
748
749fn run_frame(
750    term: &mut impl Backend,
751    state: &mut FrameState,
752    config: &RunConfig,
753    events: &[event::Event],
754    f: &mut impl FnMut(&mut context::Context),
755) -> io::Result<bool> {
756    let frame_start = Instant::now();
757    let (w, h) = term.size();
758    let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
759    ctx.is_real_terminal = true;
760    ctx.process_focus_keys();
761
762    f(&mut ctx);
763    ctx.render_notifications();
764
765    if ctx.should_quit {
766        return Ok(false);
767    }
768    state.prev_modal_active = ctx.modal_active;
769    let clipboard_text = ctx.clipboard_text.take();
770
771    let mut should_copy_selection = false;
772    for ev in &ctx.events {
773        if let Event::Mouse(mouse) = ev {
774            match mouse.kind {
775                event::MouseKind::Down(event::MouseButton::Left) => {
776                    state
777                        .selection
778                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
779                }
780                event::MouseKind::Drag(event::MouseButton::Left) => {
781                    state
782                        .selection
783                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
784                }
785                event::MouseKind::Up(event::MouseButton::Left) => {
786                    should_copy_selection = state.selection.active;
787                }
788                _ => {}
789            }
790        }
791    }
792
793    state.focus_index = ctx.focus_index;
794    state.prev_focus_count = ctx.focus_count;
795
796    let mut tree = layout::build_tree(&ctx.commands);
797    let area = crate::rect::Rect::new(0, 0, w, h);
798    layout::compute(&mut tree, area);
799    let fd = layout::collect_all(&tree);
800    state.prev_scroll_infos = fd.scroll_infos;
801    state.prev_scroll_rects = fd.scroll_rects;
802    state.prev_hit_map = fd.hit_areas;
803    state.prev_group_rects = fd.group_rects;
804    state.prev_content_map = fd.content_areas;
805    state.prev_focus_rects = fd.focus_rects;
806    state.prev_focus_groups = fd.focus_groups;
807    layout::render(&tree, term.buffer_mut());
808    let raw_rects = layout::collect_raw_draw_rects(&tree);
809    for (draw_id, rect) in raw_rects {
810        if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
811            let buf = term.buffer_mut();
812            buf.push_clip(rect);
813            cb(buf, rect);
814            buf.pop_clip();
815        }
816    }
817    state.hook_states = ctx.hook_states;
818    state.notification_queue = ctx.notification_queue;
819
820    let frame_time = frame_start.elapsed();
821    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
822    let frame_secs = frame_time.as_secs_f32();
823    let inst_fps = if frame_secs > 0.0 {
824        1.0 / frame_secs
825    } else {
826        0.0
827    };
828    state.fps_ema = if state.fps_ema == 0.0 {
829        inst_fps
830    } else {
831        (state.fps_ema * 0.9) + (inst_fps * 0.1)
832    };
833    if state.debug_mode {
834        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
835    }
836
837    if state.selection.active {
838        terminal::apply_selection_overlay(
839            term.buffer_mut(),
840            &state.selection,
841            &state.prev_content_map,
842        );
843    }
844    if should_copy_selection {
845        let text = terminal::extract_selection_text(
846            term.buffer_mut(),
847            &state.selection,
848            &state.prev_content_map,
849        );
850        if !text.is_empty() {
851            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
852        }
853        state.selection.clear();
854    }
855
856    term.flush()?;
857    if let Some(text) = clipboard_text {
858        let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
859    }
860    state.tick = state.tick.wrapping_add(1);
861
862    Ok(true)
863}
864
865fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
866    for ev in events {
867        match ev {
868            Event::Mouse(mouse) => {
869                state.last_mouse_pos = Some((mouse.x, mouse.y));
870            }
871            Event::FocusLost => {
872                state.last_mouse_pos = None;
873            }
874            _ => {}
875        }
876    }
877}
878
879fn clear_frame_layout_cache(state: &mut FrameState) {
880    state.prev_hit_map.clear();
881    state.prev_group_rects.clear();
882    state.prev_content_map.clear();
883    state.prev_focus_rects.clear();
884    state.prev_focus_groups.clear();
885    state.prev_scroll_infos.clear();
886    state.prev_scroll_rects.clear();
887    state.last_mouse_pos = None;
888}
889
890fn is_ctrl_c(ev: &Event) -> bool {
891    matches!(
892        ev,
893        Event::Key(event::KeyEvent {
894            code: KeyCode::Char('c'),
895            modifiers,
896            kind: event::KeyEventKind::Press,
897        }) if modifiers.contains(KeyModifiers::CONTROL)
898    )
899}
900
901fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
902    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
903        let target = Duration::from_secs_f64(1.0 / fps as f64);
904        let elapsed = frame_start.elapsed();
905        if elapsed < target {
906            std::thread::sleep(target - elapsed);
907        }
908    }
909}