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