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