Skip to main content

slt/
lib.rs

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