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