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