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.tick
237    }
238
239    /// Returns the smoothed FPS estimate (exponential moving average).
240    pub fn fps(&self) -> f32 {
241        self.inner.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.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
472pub(crate) struct FrameState {
473    pub hook_states: Vec<Box<dyn std::any::Any>>,
474    pub focus_index: usize,
475    pub prev_focus_count: usize,
476    pub prev_modal_focus_start: usize,
477    pub prev_modal_focus_count: usize,
478    pub tick: u64,
479    pub prev_scroll_infos: Vec<(u32, u32)>,
480    pub prev_scroll_rects: Vec<rect::Rect>,
481    pub prev_hit_map: Vec<rect::Rect>,
482    pub prev_group_rects: Vec<(String, rect::Rect)>,
483    pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
484    pub prev_focus_rects: Vec<(usize, rect::Rect)>,
485    pub prev_focus_groups: Vec<Option<String>>,
486    pub last_mouse_pos: Option<(u32, u32)>,
487    pub prev_modal_active: bool,
488    pub notification_queue: Vec<(String, ToastLevel, u64)>,
489    pub debug_mode: bool,
490    pub fps_ema: f32,
491    #[cfg(feature = "crossterm")]
492    pub selection: terminal::SelectionState,
493}
494
495impl Default for FrameState {
496    fn default() -> Self {
497        Self {
498            hook_states: Vec::new(),
499            focus_index: 0,
500            prev_focus_count: 0,
501            prev_modal_focus_start: 0,
502            prev_modal_focus_count: 0,
503            tick: 0,
504            prev_scroll_infos: Vec::new(),
505            prev_scroll_rects: Vec::new(),
506            prev_hit_map: Vec::new(),
507            prev_group_rects: Vec::new(),
508            prev_content_map: Vec::new(),
509            prev_focus_rects: Vec::new(),
510            prev_focus_groups: Vec::new(),
511            last_mouse_pos: None,
512            prev_modal_active: false,
513            notification_queue: Vec::new(),
514            debug_mode: false,
515            fps_ema: 0.0,
516            #[cfg(feature = "crossterm")]
517            selection: terminal::SelectionState::default(),
518        }
519    }
520}
521
522/// Run the TUI loop with default configuration.
523///
524/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
525/// Ctrl+C or when [`Context::quit`] is called.
526///
527/// # Example
528///
529/// ```no_run
530/// fn main() -> std::io::Result<()> {
531///     slt::run(|ui| {
532///         ui.text("Press Ctrl+C to exit");
533///     })
534/// }
535/// ```
536#[cfg(feature = "crossterm")]
537pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
538    run_with(RunConfig::default(), f)
539}
540
541#[cfg(feature = "crossterm")]
542fn set_terminal_title(title: &Option<String>) {
543    if let Some(title) = title {
544        use std::io::Write;
545        let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
546    }
547}
548
549/// Run the TUI loop with custom configuration.
550///
551/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
552/// support, and theming.
553///
554/// # Example
555///
556/// ```no_run
557/// use slt::{RunConfig, Theme};
558///
559/// fn main() -> std::io::Result<()> {
560///     slt::run_with(
561///         RunConfig::default().theme(Theme::light()),
562///         |ui| {
563///             ui.text("Light theme!");
564///         },
565///     )
566/// }
567/// ```
568#[cfg(feature = "crossterm")]
569pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
570    if !io::stdout().is_terminal() {
571        return Ok(());
572    }
573
574    install_panic_hook();
575    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
576    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
577    set_terminal_title(&config.title);
578    if config.theme.bg != Color::Reset {
579        term.theme_bg = Some(config.theme.bg);
580    }
581    let mut events: Vec<Event> = Vec::new();
582    let mut state = FrameState::default();
583
584    loop {
585        let frame_start = Instant::now();
586        let (w, h) = term.size();
587        if w == 0 || h == 0 {
588            sleep_for_fps_cap(config.max_fps, frame_start);
589            continue;
590        }
591
592        if !run_frame(
593            &mut term,
594            &mut state,
595            &config,
596            std::mem::take(&mut events),
597            &mut f,
598        )? {
599            break;
600        }
601
602        if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
603            term.handle_resize()
604        })? {
605            break;
606        }
607
608        sleep_for_fps_cap(config.max_fps, frame_start);
609    }
610
611    Ok(())
612}
613
614/// Run the TUI loop asynchronously with default configuration.
615///
616/// Requires the `async` feature. Spawns the render loop in a blocking thread
617/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
618/// from async tasks into the UI closure.
619///
620/// # Example
621///
622/// ```no_run
623/// # #[cfg(feature = "async")]
624/// # async fn example() -> std::io::Result<()> {
625/// let tx = slt::run_async::<String>(|ui, messages| {
626///     for msg in messages.drain(..) {
627///         ui.text(msg);
628///     }
629/// })?;
630/// tx.send("hello from async".to_string()).await.ok();
631/// # Ok(())
632/// # }
633/// ```
634#[cfg(all(feature = "crossterm", feature = "async"))]
635pub fn run_async<M: Send + 'static>(
636    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
637) -> io::Result<tokio::sync::mpsc::Sender<M>> {
638    run_async_with(RunConfig::default(), f)
639}
640
641/// Run the TUI loop asynchronously with custom configuration.
642///
643/// Requires the `async` feature. Like [`run_async`], but accepts a
644/// [`RunConfig`] to control tick rate, mouse support, and theming.
645///
646/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
647#[cfg(all(feature = "crossterm", feature = "async"))]
648pub fn run_async_with<M: Send + 'static>(
649    config: RunConfig,
650    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
651) -> io::Result<tokio::sync::mpsc::Sender<M>> {
652    let (tx, rx) = tokio::sync::mpsc::channel(100);
653    let handle =
654        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
655
656    handle.spawn_blocking(move || {
657        let _ = run_async_loop(config, f, rx);
658    });
659
660    Ok(tx)
661}
662
663#[cfg(all(feature = "crossterm", feature = "async"))]
664fn run_async_loop<M: Send + 'static>(
665    config: RunConfig,
666    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
667    mut rx: tokio::sync::mpsc::Receiver<M>,
668) -> io::Result<()> {
669    if !io::stdout().is_terminal() {
670        return Ok(());
671    }
672
673    install_panic_hook();
674    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
675    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
676    set_terminal_title(&config.title);
677    if config.theme.bg != Color::Reset {
678        term.theme_bg = Some(config.theme.bg);
679    }
680    let mut events: Vec<Event> = Vec::new();
681    let mut state = FrameState::default();
682
683    loop {
684        let frame_start = Instant::now();
685        let mut messages: Vec<M> = Vec::new();
686        while let Ok(message) = rx.try_recv() {
687            messages.push(message);
688        }
689
690        let (w, h) = term.size();
691        if w == 0 || h == 0 {
692            sleep_for_fps_cap(config.max_fps, frame_start);
693            continue;
694        }
695
696        let mut render = |ctx: &mut Context| {
697            f(ctx, &mut messages);
698        };
699        if !run_frame(
700            &mut term,
701            &mut state,
702            &config,
703            std::mem::take(&mut events),
704            &mut render,
705        )? {
706            break;
707        }
708
709        if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
710            term.handle_resize()
711        })? {
712            break;
713        }
714
715        sleep_for_fps_cap(config.max_fps, frame_start);
716    }
717
718    Ok(())
719}
720
721/// Run the TUI in inline mode with default configuration.
722///
723/// Renders `height` rows directly below the current cursor position without
724/// entering alternate screen mode. Useful for CLI tools that want a small
725/// interactive widget below the prompt.
726///
727/// `height` is the reserved inline render area in terminal rows.
728/// The rest of the terminal stays in normal scrollback mode.
729///
730/// # Example
731///
732/// ```no_run
733/// fn main() -> std::io::Result<()> {
734///     slt::run_inline(3, |ui| {
735///         ui.text("Inline TUI — no alternate screen");
736///     })
737/// }
738/// ```
739#[cfg(feature = "crossterm")]
740pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
741    run_inline_with(height, RunConfig::default(), f)
742}
743
744/// Run the TUI in inline mode with custom configuration.
745///
746/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
747/// mouse support, and theming.
748#[cfg(feature = "crossterm")]
749pub fn run_inline_with(
750    height: u32,
751    config: RunConfig,
752    mut f: impl FnMut(&mut Context),
753) -> io::Result<()> {
754    if !io::stdout().is_terminal() {
755        return Ok(());
756    }
757
758    install_panic_hook();
759    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
760    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
761    set_terminal_title(&config.title);
762    if config.theme.bg != Color::Reset {
763        term.theme_bg = Some(config.theme.bg);
764    }
765    let mut events: Vec<Event> = Vec::new();
766    let mut state = FrameState::default();
767
768    loop {
769        let frame_start = Instant::now();
770        let (w, h) = term.size();
771        if w == 0 || h == 0 {
772            sleep_for_fps_cap(config.max_fps, frame_start);
773            continue;
774        }
775
776        if !run_frame(
777            &mut term,
778            &mut state,
779            &config,
780            std::mem::take(&mut events),
781            &mut f,
782        )? {
783            break;
784        }
785
786        if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
787            term.handle_resize()
788        })? {
789            break;
790        }
791
792        sleep_for_fps_cap(config.max_fps, frame_start);
793    }
794
795    Ok(())
796}
797
798/// Run the TUI in static-output mode.
799///
800/// Static lines written through [`StaticOutput`] are printed into terminal
801/// scrollback, while the interactive UI stays rendered in a fixed-height inline
802/// area at the bottom.
803///
804/// Use this when you want a log-style output stream above a live inline UI.
805#[cfg(feature = "crossterm")]
806pub fn run_static(
807    output: &mut StaticOutput,
808    dynamic_height: u32,
809    mut f: impl FnMut(&mut Context),
810) -> io::Result<()> {
811    let config = RunConfig::default();
812    if !io::stdout().is_terminal() {
813        return Ok(());
814    }
815
816    install_panic_hook();
817
818    let initial_lines = output.drain_new();
819    write_static_lines(&initial_lines)?;
820
821    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
822    let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
823    set_terminal_title(&config.title);
824    if config.theme.bg != Color::Reset {
825        term.theme_bg = Some(config.theme.bg);
826    }
827
828    let mut events: Vec<Event> = Vec::new();
829    let mut state = FrameState::default();
830
831    loop {
832        let frame_start = Instant::now();
833        let (w, h) = term.size();
834        if w == 0 || h == 0 {
835            sleep_for_fps_cap(config.max_fps, frame_start);
836            continue;
837        }
838
839        let new_lines = output.drain_new();
840        write_static_lines(&new_lines)?;
841
842        if !run_frame(
843            &mut term,
844            &mut state,
845            &config,
846            std::mem::take(&mut events),
847            &mut f,
848        )? {
849            break;
850        }
851
852        if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
853            term.handle_resize()
854        })? {
855            break;
856        }
857
858        sleep_for_fps_cap(config.max_fps, frame_start);
859    }
860
861    Ok(())
862}
863
864#[cfg(feature = "crossterm")]
865fn write_static_lines(lines: &[String]) -> io::Result<()> {
866    if lines.is_empty() {
867        return Ok(());
868    }
869
870    let mut stdout = io::stdout();
871    for line in lines {
872        stdout.write_all(line.as_bytes())?;
873        stdout.write_all(b"\r\n")?;
874    }
875    stdout.flush()
876}
877
878/// Poll for terminal events, handling resize, Ctrl-C, F12 debug toggle,
879/// and layout cache invalidation. Returns `Ok(false)` when the loop should exit.
880#[cfg(feature = "crossterm")]
881fn poll_events(
882    events: &mut Vec<Event>,
883    state: &mut FrameState,
884    tick_rate: Duration,
885    on_resize: &mut impl FnMut() -> io::Result<()>,
886) -> io::Result<bool> {
887    if crossterm::event::poll(tick_rate)? {
888        let raw = crossterm::event::read()?;
889        if let Some(ev) = event::from_crossterm(raw) {
890            if is_ctrl_c(&ev) {
891                return Ok(false);
892            }
893            if matches!(ev, Event::Resize(_, _)) {
894                on_resize()?;
895            }
896            events.push(ev);
897        }
898
899        while crossterm::event::poll(Duration::ZERO)? {
900            let raw = crossterm::event::read()?;
901            if let Some(ev) = event::from_crossterm(raw) {
902                if is_ctrl_c(&ev) {
903                    return Ok(false);
904                }
905                if matches!(ev, Event::Resize(_, _)) {
906                    on_resize()?;
907                }
908                events.push(ev);
909            }
910        }
911
912        for ev in events.iter() {
913            if matches!(
914                ev,
915                Event::Key(event::KeyEvent {
916                    code: KeyCode::F(12),
917                    kind: event::KeyEventKind::Press,
918                    ..
919                })
920            ) {
921                state.debug_mode = !state.debug_mode;
922            }
923        }
924    }
925
926    update_last_mouse_pos(state, events);
927
928    if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
929        clear_frame_layout_cache(state);
930    }
931
932    Ok(true)
933}
934
935fn run_frame(
936    term: &mut impl Backend,
937    state: &mut FrameState,
938    config: &RunConfig,
939    events: Vec<event::Event>,
940    f: &mut impl FnMut(&mut context::Context),
941) -> io::Result<bool> {
942    let frame_start = Instant::now();
943    let (w, h) = term.size();
944    let mut ctx = Context::new(events, w, h, state, config.theme);
945    ctx.is_real_terminal = true;
946    ctx.set_scroll_speed(config.scroll_speed);
947
948    f(&mut ctx);
949    ctx.process_focus_keys();
950    ctx.render_notifications();
951    ctx.emit_pending_tooltips();
952
953    if ctx.should_quit {
954        return Ok(false);
955    }
956    state.prev_modal_active = ctx.modal_active;
957    state.prev_modal_focus_start = ctx.modal_focus_start;
958    state.prev_modal_focus_count = ctx.modal_focus_count;
959    #[cfg(feature = "crossterm")]
960    let clipboard_text = ctx.clipboard_text.take();
961    #[cfg(not(feature = "crossterm"))]
962    let _clipboard_text = ctx.clipboard_text.take();
963
964    #[cfg(feature = "crossterm")]
965    let mut should_copy_selection = false;
966    #[cfg(feature = "crossterm")]
967    for ev in &ctx.events {
968        if let Event::Mouse(mouse) = ev {
969            match mouse.kind {
970                event::MouseKind::Down(event::MouseButton::Left) => {
971                    state
972                        .selection
973                        .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
974                }
975                event::MouseKind::Drag(event::MouseButton::Left) => {
976                    state
977                        .selection
978                        .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
979                }
980                event::MouseKind::Up(event::MouseButton::Left) => {
981                    should_copy_selection = state.selection.active;
982                }
983                _ => {}
984            }
985        }
986    }
987
988    state.focus_index = ctx.focus_index;
989    state.prev_focus_count = ctx.focus_count;
990
991    let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
992    let area = crate::rect::Rect::new(0, 0, w, h);
993    layout::compute(&mut tree, area);
994    let fd = layout::collect_all(&tree);
995    state.prev_scroll_infos = fd.scroll_infos;
996    state.prev_scroll_rects = fd.scroll_rects;
997    state.prev_hit_map = fd.hit_areas;
998    state.prev_group_rects = fd.group_rects;
999    state.prev_content_map = fd.content_areas;
1000    state.prev_focus_rects = fd.focus_rects;
1001    state.prev_focus_groups = fd.focus_groups;
1002    layout::render(&tree, term.buffer_mut());
1003    let raw_rects = fd.raw_draw_rects;
1004    for rdr in raw_rects {
1005        if rdr.rect.width == 0 || rdr.rect.height == 0 {
1006            continue;
1007        }
1008        if let Some(cb) = ctx
1009            .deferred_draws
1010            .get_mut(rdr.draw_id)
1011            .and_then(|c| c.take())
1012        {
1013            let buf = term.buffer_mut();
1014            buf.push_clip(rdr.rect);
1015            buf.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
1016            cb(buf, rdr.rect);
1017            buf.kitty_clip_info = None;
1018            buf.pop_clip();
1019        }
1020    }
1021    state.hook_states = ctx.hook_states;
1022    state.notification_queue = ctx.notification_queue;
1023
1024    let frame_time = frame_start.elapsed();
1025    let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1026    let frame_secs = frame_time.as_secs_f32();
1027    let inst_fps = if frame_secs > 0.0 {
1028        1.0 / frame_secs
1029    } else {
1030        0.0
1031    };
1032    state.fps_ema = if state.fps_ema == 0.0 {
1033        inst_fps
1034    } else {
1035        (state.fps_ema * 0.9) + (inst_fps * 0.1)
1036    };
1037    if state.debug_mode {
1038        layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
1039    }
1040
1041    #[cfg(feature = "crossterm")]
1042    if state.selection.active {
1043        terminal::apply_selection_overlay(
1044            term.buffer_mut(),
1045            &state.selection,
1046            &state.prev_content_map,
1047        );
1048    }
1049    #[cfg(feature = "crossterm")]
1050    if should_copy_selection {
1051        let text = terminal::extract_selection_text(
1052            term.buffer_mut(),
1053            &state.selection,
1054            &state.prev_content_map,
1055        );
1056        if !text.is_empty() {
1057            terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1058        }
1059        state.selection.clear();
1060    }
1061
1062    term.flush()?;
1063    #[cfg(feature = "crossterm")]
1064    if let Some(text) = clipboard_text {
1065        #[allow(clippy::print_stderr)]
1066        if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1067            eprintln!("[slt] failed to copy to clipboard: {e}");
1068        }
1069    }
1070    state.tick = state.tick.wrapping_add(1);
1071
1072    Ok(true)
1073}
1074
1075#[cfg(feature = "crossterm")]
1076fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1077    for ev in events {
1078        match ev {
1079            Event::Mouse(mouse) => {
1080                state.last_mouse_pos = Some((mouse.x, mouse.y));
1081            }
1082            Event::FocusLost => {
1083                state.last_mouse_pos = None;
1084            }
1085            _ => {}
1086        }
1087    }
1088}
1089
1090#[cfg(feature = "crossterm")]
1091fn clear_frame_layout_cache(state: &mut FrameState) {
1092    state.prev_hit_map.clear();
1093    state.prev_group_rects.clear();
1094    state.prev_content_map.clear();
1095    state.prev_focus_rects.clear();
1096    state.prev_focus_groups.clear();
1097    state.prev_scroll_infos.clear();
1098    state.prev_scroll_rects.clear();
1099    state.last_mouse_pos = None;
1100}
1101
1102#[cfg(feature = "crossterm")]
1103fn is_ctrl_c(ev: &Event) -> bool {
1104    matches!(
1105        ev,
1106        Event::Key(event::KeyEvent {
1107            code: KeyCode::Char('c'),
1108            modifiers,
1109            kind: event::KeyEventKind::Press,
1110        }) if modifiers.contains(KeyModifiers::CONTROL)
1111    )
1112}
1113
1114#[cfg(feature = "crossterm")]
1115fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1116    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1117        let target = Duration::from_secs_f64(1.0 / fps as f64);
1118        let elapsed = frame_start.elapsed();
1119        if elapsed < target {
1120            std::thread::sleep(target - elapsed);
1121        }
1122    }
1123}