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