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 prev_modal_focus_start: usize,
381    pub prev_modal_focus_count: usize,
382    pub tick: u64,
383    pub prev_scroll_infos: Vec<(u32, u32)>,
384    pub prev_scroll_rects: Vec<rect::Rect>,
385    pub prev_hit_map: Vec<rect::Rect>,
386    pub prev_group_rects: Vec<(String, rect::Rect)>,
387    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
388    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
389    pub prev_focus_groups: Vec<Option<String>>,
390    pub last_mouse_pos: Option<(u32, u32)>,
391    pub prev_modal_active: bool,
392    pub notification_queue: Vec<(String, ToastLevel, u64)>,
393    pub debug_mode: bool,
394    pub fps_ema: f32,
395    pub selection: terminal::SelectionState,
396}
397
398impl Default for FrameState {
399    fn default() -> Self {
400        Self {
401            hook_states: Vec::new(),
402            focus_index: 0,
403            prev_focus_count: 0,
404            prev_modal_focus_start: 0,
405            prev_modal_focus_count: 0,
406            tick: 0,
407            prev_scroll_infos: Vec::new(),
408            prev_scroll_rects: Vec::new(),
409            prev_hit_map: Vec::new(),
410            prev_group_rects: Vec::new(),
411            prev_content_map: Vec::new(),
412            prev_focus_rects: Vec::new(),
413            prev_focus_groups: Vec::new(),
414            last_mouse_pos: None,
415            prev_modal_active: false,
416            notification_queue: Vec::new(),
417            debug_mode: false,
418            fps_ema: 0.0,
419            selection: terminal::SelectionState::default(),
420        }
421    }
422}
423
424/// Run the TUI loop with default configuration.
425///
426/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
427/// Ctrl+C or when [`Context::quit`] is called.
428///
429/// # Example
430///
431/// ```no_run
432/// fn main() -> std::io::Result<()> {
433///     slt::run(|ui| {
434///         ui.text("Press Ctrl+C to exit");
435///     })
436/// }
437/// ```
438pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
439    run_with(RunConfig::default(), f)
440}
441
442/// Run the TUI loop with custom configuration.
443///
444/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
445/// support, and theming.
446///
447/// # Example
448///
449/// ```no_run
450/// use slt::{RunConfig, Theme};
451///
452/// fn main() -> std::io::Result<()> {
453///     slt::run_with(
454///         RunConfig { theme: Theme::light(), ..Default::default() },
455///         |ui| {
456///             ui.text("Light theme!");
457///         },
458///     )
459/// }
460/// ```
461pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
462    if !io::stdout().is_terminal() {
463        return Ok(());
464    }
465
466    install_panic_hook();
467    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
468    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
469    if config.theme.bg != Color::Reset {
470        term.theme_bg = Some(config.theme.bg);
471    }
472    let mut events: Vec<Event> = Vec::new();
473    let mut state = FrameState::default();
474
475    loop {
476        let frame_start = Instant::now();
477        let (w, h) = term.size();
478        if w == 0 || h == 0 {
479            sleep_for_fps_cap(config.max_fps, frame_start);
480            continue;
481        }
482
483        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
484            break;
485        }
486
487        events.clear();
488        if crossterm::event::poll(config.tick_rate)? {
489            let raw = crossterm::event::read()?;
490            if let Some(ev) = event::from_crossterm(raw) {
491                if is_ctrl_c(&ev) {
492                    break;
493                }
494                if let Event::Resize(_, _) = &ev {
495                    term.handle_resize()?;
496                }
497                events.push(ev);
498            }
499
500            while crossterm::event::poll(Duration::ZERO)? {
501                let raw = crossterm::event::read()?;
502                if let Some(ev) = event::from_crossterm(raw) {
503                    if is_ctrl_c(&ev) {
504                        return Ok(());
505                    }
506                    if let Event::Resize(_, _) = &ev {
507                        term.handle_resize()?;
508                    }
509                    events.push(ev);
510                }
511            }
512
513            for ev in &events {
514                if matches!(
515                    ev,
516                    Event::Key(event::KeyEvent {
517                        code: KeyCode::F(12),
518                        kind: event::KeyEventKind::Press,
519                        ..
520                    })
521                ) {
522                    state.debug_mode = !state.debug_mode;
523                }
524            }
525        }
526
527        update_last_mouse_pos(&mut state, &events);
528
529        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
530            clear_frame_layout_cache(&mut state);
531        }
532
533        sleep_for_fps_cap(config.max_fps, frame_start);
534    }
535
536    Ok(())
537}
538
539/// Run the TUI loop asynchronously with default configuration.
540///
541/// Requires the `async` feature. Spawns the render loop in a blocking thread
542/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
543/// from async tasks into the UI closure.
544///
545/// # Example
546///
547/// ```no_run
548/// # #[cfg(feature = "async")]
549/// # async fn example() -> std::io::Result<()> {
550/// let tx = slt::run_async::<String>(|ui, messages| {
551///     for msg in messages.drain(..) {
552///         ui.text(msg);
553///     }
554/// })?;
555/// tx.send("hello from async".to_string()).await.ok();
556/// # Ok(())
557/// # }
558/// ```
559#[cfg(feature = "async")]
560pub fn run_async<M: Send + 'static>(
561    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
562) -> io::Result<tokio::sync::mpsc::Sender<M>> {
563    run_async_with(RunConfig::default(), f)
564}
565
566/// Run the TUI loop asynchronously with custom configuration.
567///
568/// Requires the `async` feature. Like [`run_async`], but accepts a
569/// [`RunConfig`] to control tick rate, mouse support, and theming.
570///
571/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
572#[cfg(feature = "async")]
573pub fn run_async_with<M: Send + 'static>(
574    config: RunConfig,
575    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
576) -> io::Result<tokio::sync::mpsc::Sender<M>> {
577    let (tx, rx) = tokio::sync::mpsc::channel(100);
578    let handle =
579        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
580
581    handle.spawn_blocking(move || {
582        let _ = run_async_loop(config, f, rx);
583    });
584
585    Ok(tx)
586}
587
588#[cfg(feature = "async")]
589fn run_async_loop<M: Send + 'static>(
590    config: RunConfig,
591    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
592    mut rx: tokio::sync::mpsc::Receiver<M>,
593) -> io::Result<()> {
594    if !io::stdout().is_terminal() {
595        return Ok(());
596    }
597
598    install_panic_hook();
599    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
600    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
601    if config.theme.bg != Color::Reset {
602        term.theme_bg = Some(config.theme.bg);
603    }
604    let mut events: Vec<Event> = Vec::new();
605    let mut state = FrameState::default();
606
607    loop {
608        let frame_start = Instant::now();
609        let mut messages: Vec<M> = Vec::new();
610        while let Ok(message) = rx.try_recv() {
611            messages.push(message);
612        }
613
614        let (w, h) = term.size();
615        if w == 0 || h == 0 {
616            sleep_for_fps_cap(config.max_fps, frame_start);
617            continue;
618        }
619
620        let mut render = |ctx: &mut Context| {
621            f(ctx, &mut messages);
622        };
623        if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
624            break;
625        }
626
627        events.clear();
628        if crossterm::event::poll(config.tick_rate)? {
629            let raw = crossterm::event::read()?;
630            if let Some(ev) = event::from_crossterm(raw) {
631                if is_ctrl_c(&ev) {
632                    break;
633                }
634                if let Event::Resize(_, _) = &ev {
635                    term.handle_resize()?;
636                    clear_frame_layout_cache(&mut state);
637                }
638                events.push(ev);
639            }
640
641            while crossterm::event::poll(Duration::ZERO)? {
642                let raw = crossterm::event::read()?;
643                if let Some(ev) = event::from_crossterm(raw) {
644                    if is_ctrl_c(&ev) {
645                        return Ok(());
646                    }
647                    if let Event::Resize(_, _) = &ev {
648                        term.handle_resize()?;
649                        clear_frame_layout_cache(&mut state);
650                    }
651                    events.push(ev);
652                }
653            }
654        }
655
656        update_last_mouse_pos(&mut state, &events);
657
658        sleep_for_fps_cap(config.max_fps, frame_start);
659    }
660
661    Ok(())
662}
663
664/// Run the TUI in inline mode with default configuration.
665///
666/// Renders `height` rows directly below the current cursor position without
667/// entering alternate screen mode. Useful for CLI tools that want a small
668/// interactive widget below the prompt.
669///
670/// # Example
671///
672/// ```no_run
673/// fn main() -> std::io::Result<()> {
674///     slt::run_inline(3, |ui| {
675///         ui.text("Inline TUI — no alternate screen");
676///     })
677/// }
678/// ```
679pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
680    run_inline_with(height, RunConfig::default(), f)
681}
682
683/// Run the TUI in inline mode with custom configuration.
684///
685/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
686/// mouse support, and theming.
687pub fn run_inline_with(
688    height: u32,
689    config: RunConfig,
690    mut f: impl FnMut(&mut Context),
691) -> io::Result<()> {
692    if !io::stdout().is_terminal() {
693        return Ok(());
694    }
695
696    install_panic_hook();
697    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
698    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
699    if config.theme.bg != Color::Reset {
700        term.theme_bg = Some(config.theme.bg);
701    }
702    let mut events: Vec<Event> = Vec::new();
703    let mut state = FrameState::default();
704
705    loop {
706        let frame_start = Instant::now();
707        let (w, h) = term.size();
708        if w == 0 || h == 0 {
709            sleep_for_fps_cap(config.max_fps, frame_start);
710            continue;
711        }
712
713        if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
714            break;
715        }
716
717        events.clear();
718        if crossterm::event::poll(config.tick_rate)? {
719            let raw = crossterm::event::read()?;
720            if let Some(ev) = event::from_crossterm(raw) {
721                if is_ctrl_c(&ev) {
722                    break;
723                }
724                if let Event::Resize(_, _) = &ev {
725                    term.handle_resize()?;
726                }
727                events.push(ev);
728            }
729
730            while crossterm::event::poll(Duration::ZERO)? {
731                let raw = crossterm::event::read()?;
732                if let Some(ev) = event::from_crossterm(raw) {
733                    if is_ctrl_c(&ev) {
734                        return Ok(());
735                    }
736                    if let Event::Resize(_, _) = &ev {
737                        term.handle_resize()?;
738                    }
739                    events.push(ev);
740                }
741            }
742
743            for ev in &events {
744                if matches!(
745                    ev,
746                    Event::Key(event::KeyEvent {
747                        code: KeyCode::F(12),
748                        kind: event::KeyEventKind::Press,
749                        ..
750                    })
751                ) {
752                    state.debug_mode = !state.debug_mode;
753                }
754            }
755        }
756
757        update_last_mouse_pos(&mut state, &events);
758
759        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
760            clear_frame_layout_cache(&mut state);
761        }
762
763        sleep_for_fps_cap(config.max_fps, frame_start);
764    }
765
766    Ok(())
767}
768
769fn run_frame(
770    term: &mut impl Backend,
771    state: &mut FrameState,
772    config: &RunConfig,
773    events: &[event::Event],
774    f: &mut impl FnMut(&mut context::Context),
775) -> io::Result<bool> {
776    let frame_start = Instant::now();
777    let (w, h) = term.size();
778    let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
779    ctx.is_real_terminal = true;
780    ctx.process_focus_keys();
781
782    f(&mut ctx);
783    ctx.render_notifications();
784
785    if ctx.should_quit {
786        return Ok(false);
787    }
788    state.prev_modal_active = ctx.modal_active;
789    state.prev_modal_focus_start = ctx.modal_focus_start;
790    state.prev_modal_focus_count = ctx.modal_focus_count;
791    let clipboard_text = ctx.clipboard_text.take();
792
793    let mut should_copy_selection = false;
794    for ev in &ctx.events {
795        if let Event::Mouse(mouse) = ev {
796            match mouse.kind {
797                event::MouseKind::Down(event::MouseButton::Left) => {
798                    state
799                        .selection
800                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
801                }
802                event::MouseKind::Drag(event::MouseButton::Left) => {
803                    state
804                        .selection
805                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
806                }
807                event::MouseKind::Up(event::MouseButton::Left) => {
808                    should_copy_selection = state.selection.active;
809                }
810                _ => {}
811            }
812        }
813    }
814
815    state.focus_index = ctx.focus_index;
816    state.prev_focus_count = ctx.focus_count;
817
818    let mut tree = layout::build_tree(&ctx.commands);
819    let area = crate::rect::Rect::new(0, 0, w, h);
820    layout::compute(&mut tree, area);
821    let fd = layout::collect_all(&tree);
822    state.prev_scroll_infos = fd.scroll_infos;
823    state.prev_scroll_rects = fd.scroll_rects;
824    state.prev_hit_map = fd.hit_areas;
825    state.prev_group_rects = fd.group_rects;
826    state.prev_content_map = fd.content_areas;
827    state.prev_focus_rects = fd.focus_rects;
828    state.prev_focus_groups = fd.focus_groups;
829    layout::render(&tree, term.buffer_mut());
830    let raw_rects = layout::collect_raw_draw_rects(&tree);
831    for (draw_id, rect) in raw_rects {
832        if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
833            let buf = term.buffer_mut();
834            buf.push_clip(rect);
835            cb(buf, rect);
836            buf.pop_clip();
837        }
838    }
839    state.hook_states = ctx.hook_states;
840    state.notification_queue = ctx.notification_queue;
841
842    let frame_time = frame_start.elapsed();
843    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
844    let frame_secs = frame_time.as_secs_f32();
845    let inst_fps = if frame_secs > 0.0 {
846        1.0 / frame_secs
847    } else {
848        0.0
849    };
850    state.fps_ema = if state.fps_ema == 0.0 {
851        inst_fps
852    } else {
853        (state.fps_ema * 0.9) + (inst_fps * 0.1)
854    };
855    if state.debug_mode {
856        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
857    }
858
859    if state.selection.active {
860        terminal::apply_selection_overlay(
861            term.buffer_mut(),
862            &state.selection,
863            &state.prev_content_map,
864        );
865    }
866    if should_copy_selection {
867        let text = terminal::extract_selection_text(
868            term.buffer_mut(),
869            &state.selection,
870            &state.prev_content_map,
871        );
872        if !text.is_empty() {
873            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
874        }
875        state.selection.clear();
876    }
877
878    term.flush()?;
879    if let Some(text) = clipboard_text {
880        #[allow(clippy::print_stderr)]
881        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
882            eprintln!("[slt] failed to copy to clipboard: {e}");
883        }
884    }
885    state.tick = state.tick.wrapping_add(1);
886
887    Ok(true)
888}
889
890fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
891    for ev in events {
892        match ev {
893            Event::Mouse(mouse) => {
894                state.last_mouse_pos = Some((mouse.x, mouse.y));
895            }
896            Event::FocusLost => {
897                state.last_mouse_pos = None;
898            }
899            _ => {}
900        }
901    }
902}
903
904fn clear_frame_layout_cache(state: &mut FrameState) {
905    state.prev_hit_map.clear();
906    state.prev_group_rects.clear();
907    state.prev_content_map.clear();
908    state.prev_focus_rects.clear();
909    state.prev_focus_groups.clear();
910    state.prev_scroll_infos.clear();
911    state.prev_scroll_rects.clear();
912    state.last_mouse_pos = None;
913}
914
915fn is_ctrl_c(ev: &Event) -> bool {
916    matches!(
917        ev,
918        Event::Key(event::KeyEvent {
919            code: KeyCode::Char('c'),
920            modifiers,
921            kind: event::KeyEventKind::Press,
922        }) if modifiers.contains(KeyModifiers::CONTROL)
923    )
924}
925
926fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
927    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
928        let target = Duration::from_secs_f64(1.0 / fps as f64);
929        let elapsed = frame_start.elapsed();
930        if elapsed < target {
931            std::thread::sleep(target - elapsed);
932        }
933    }
934}