Skip to main content

slt/
lib.rs

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