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