Skip to main content

slt/
lib.rs

1//! # SLT — Super Light TUI
2//!
3//! Immediate-mode terminal UI for Rust. Two dependencies. Zero `unsafe`.
4//!
5//! SLT gives you an egui-style API for terminals: your closure runs each frame,
6//! you describe your UI, and SLT handles layout, diffing, and rendering.
7//!
8//! ## Quick Start
9//!
10//! ```no_run
11//! fn main() -> std::io::Result<()> {
12//!     slt::run(|ui| {
13//!         ui.text("hello, world");
14//!     })
15//! }
16//! ```
17//!
18//! ## Features
19//!
20//! - **Flexbox layout** — `row()`, `col()`, `gap()`, `grow()`
21//! - **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
22//! - **Styling** — bold, italic, dim, underline, 256 colors, RGB
23//! - **Mouse** — click, hover, drag-to-scroll
24//! - **Focus** — automatic Tab/Shift+Tab cycling
25//! - **Theming** — dark/light presets or custom
26//! - **Animation** — tween and spring primitives with 9 easing functions
27//! - **Inline mode** — render below your prompt, no alternate screen
28//! - **Async** — optional tokio integration via `async` feature
29//! - **Layout debugger** — F12 to visualize container bounds
30//!
31//! ## Feature Flags
32//!
33//! | Flag | Description |
34//! |------|-------------|
35//! | `async` | Enable `run_async()` with tokio channel-based message passing |
36//! | `serde` | Enable Serialize/Deserialize for Style, Color, Theme, and layout types |
37
38pub mod anim;
39pub mod buffer;
40pub mod cell;
41pub mod chart;
42pub mod context;
43pub mod event;
44pub mod halfblock;
45pub mod layout;
46pub mod rect;
47pub mod style;
48mod terminal;
49pub mod test_utils;
50pub mod widgets;
51
52use std::io;
53use std::io::IsTerminal;
54use std::sync::Once;
55use std::time::{Duration, Instant};
56
57use terminal::{InlineTerminal, Terminal};
58
59pub use crate::test_utils::{EventBuilder, TestBackend};
60pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
61pub use chart::{
62    Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
63    HistogramBuilder, LegendPosition, Marker,
64};
65pub use context::{Bar, BarDirection, BarGroup, CanvasContext, Context, Response, State, Widget};
66pub use event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind};
67pub use halfblock::HalfBlockImage;
68pub use style::{
69    Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, Justify, Margin,
70    Modifiers, Padding, Style, Theme,
71};
72pub use widgets::{
73    ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FormField, FormState,
74    ListState, MultiSelectState, PaletteCommand, RadioState, ScrollState, SelectState,
75    SpinnerState, StreamingTextState, TableState, TabsState, TextInputState, TextareaState,
76    ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState,
77};
78
79static PANIC_HOOK_ONCE: Once = Once::new();
80
81fn install_panic_hook() {
82    PANIC_HOOK_ONCE.call_once(|| {
83        let original = std::panic::take_hook();
84        std::panic::set_hook(Box::new(move |panic_info| {
85            let _ = crossterm::terminal::disable_raw_mode();
86            let mut stdout = io::stdout();
87            let _ = crossterm::execute!(
88                stdout,
89                crossterm::terminal::LeaveAlternateScreen,
90                crossterm::cursor::Show,
91                crossterm::event::DisableMouseCapture,
92                crossterm::event::DisableBracketedPaste,
93                crossterm::style::ResetColor,
94                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
95            );
96
97            // Print friendly panic header
98            eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
99
100            // Print location if available
101            if let Some(location) = panic_info.location() {
102                eprintln!(
103                    "\x1b[90m{}:{}:{}\x1b[0m",
104                    location.file(),
105                    location.line(),
106                    location.column()
107                );
108            }
109
110            // Print message
111            if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
112                eprintln!("\x1b[1m{}\x1b[0m", msg);
113            } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
114                eprintln!("\x1b[1m{}\x1b[0m", msg);
115            }
116
117            eprintln!(
118                "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
119            );
120
121            original(panic_info);
122        }));
123    });
124}
125
126/// Configuration for a TUI run loop.
127///
128/// Pass to [`run_with`] or [`run_inline_with`] to customize behavior.
129/// Use [`Default::default()`] for sensible defaults (100ms tick, no mouse, dark theme).
130///
131/// # Example
132///
133/// ```no_run
134/// use slt::{RunConfig, Theme};
135/// use std::time::Duration;
136///
137/// let config = RunConfig {
138///     tick_rate: Duration::from_millis(50),
139///     mouse: true,
140///     kitty_keyboard: false,
141///     theme: Theme::light(),
142///     color_depth: None,
143///     max_fps: Some(60),
144/// };
145/// ```
146#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
147pub struct RunConfig {
148    /// How long to wait for input before triggering a tick with no events.
149    ///
150    /// Lower values give smoother animations at the cost of more CPU usage.
151    /// Defaults to 100ms.
152    pub tick_rate: Duration,
153    /// Whether to enable mouse event reporting.
154    ///
155    /// When `true`, the terminal captures mouse clicks, scrolls, and movement.
156    /// Defaults to `false`.
157    pub mouse: bool,
158    /// Whether to enable the Kitty keyboard protocol for enhanced input.
159    ///
160    /// When `true`, enables disambiguated key events, key release events,
161    /// and modifier-only key reporting on supporting terminals (kitty, Ghostty, WezTerm).
162    /// Terminals that don't support it silently ignore the request.
163    /// Defaults to `false`.
164    pub kitty_keyboard: bool,
165    /// The color theme applied to all widgets automatically.
166    ///
167    /// Defaults to [`Theme::dark()`].
168    pub theme: Theme,
169    /// Color depth override.
170    ///
171    /// `None` means auto-detect from `$COLORTERM` and `$TERM` environment
172    /// variables. Set explicitly to force a specific color depth regardless
173    /// of terminal capabilities.
174    pub color_depth: Option<ColorDepth>,
175    /// Optional maximum frame rate.
176    ///
177    /// `None` means unlimited frame rate. `Some(fps)` sleeps at the end of each
178    /// loop iteration to target that frame time.
179    pub max_fps: Option<u32>,
180}
181
182impl Default for RunConfig {
183    fn default() -> Self {
184        Self {
185            tick_rate: Duration::from_millis(16),
186            mouse: false,
187            kitty_keyboard: false,
188            theme: Theme::dark(),
189            color_depth: None,
190            max_fps: Some(60),
191        }
192    }
193}
194
195/// Run the TUI loop with default configuration.
196///
197/// Enters alternate screen mode, runs `f` each frame, and exits cleanly on
198/// Ctrl+C or when [`Context::quit`] is called.
199///
200/// # Example
201///
202/// ```no_run
203/// fn main() -> std::io::Result<()> {
204///     slt::run(|ui| {
205///         ui.text("Press Ctrl+C to exit");
206///     })
207/// }
208/// ```
209pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
210    run_with(RunConfig::default(), f)
211}
212
213/// Run the TUI loop with custom configuration.
214///
215/// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
216/// support, and theming.
217///
218/// # Example
219///
220/// ```no_run
221/// use slt::{RunConfig, Theme};
222///
223/// fn main() -> std::io::Result<()> {
224///     slt::run_with(
225///         RunConfig { theme: Theme::light(), ..Default::default() },
226///         |ui| {
227///             ui.text("Light theme!");
228///         },
229///     )
230/// }
231/// ```
232pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
233    if !io::stdout().is_terminal() {
234        return Ok(());
235    }
236
237    install_panic_hook();
238    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
239    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
240    let mut events: Vec<Event> = Vec::new();
241    let mut debug_mode: bool = false;
242    let mut tick: u64 = 0;
243    let mut focus_index: usize = 0;
244    let mut prev_focus_count: usize = 0;
245    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
246    let mut prev_scroll_rects: Vec<rect::Rect> = Vec::new();
247    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
248    let mut prev_group_rects: Vec<(String, rect::Rect)> = Vec::new();
249    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
250    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
251    let mut prev_focus_groups: Vec<Option<String>> = Vec::new();
252    let mut hook_states: Vec<Box<dyn std::any::Any>> = Vec::new();
253    let mut last_mouse_pos: Option<(u32, u32)> = None;
254    let mut prev_modal_active = false;
255    let mut selection = terminal::SelectionState::default();
256    let mut fps_ema: f32 = 0.0;
257
258    loop {
259        let frame_start = Instant::now();
260        let (w, h) = term.size();
261        if w == 0 || h == 0 {
262            sleep_for_fps_cap(config.max_fps, frame_start);
263            continue;
264        }
265        let mut ctx = Context::new(
266            std::mem::take(&mut events),
267            w,
268            h,
269            tick,
270            focus_index,
271            prev_focus_count,
272            std::mem::take(&mut prev_scroll_infos),
273            std::mem::take(&mut prev_scroll_rects),
274            std::mem::take(&mut prev_hit_map),
275            std::mem::take(&mut prev_group_rects),
276            std::mem::take(&mut prev_focus_rects),
277            std::mem::take(&mut prev_focus_groups),
278            std::mem::take(&mut hook_states),
279            debug_mode,
280            config.theme,
281            last_mouse_pos,
282            prev_modal_active,
283        );
284        ctx.process_focus_keys();
285
286        f(&mut ctx);
287
288        if ctx.should_quit {
289            break;
290        }
291        prev_modal_active = ctx.modal_active;
292        let clipboard_text = ctx.clipboard_text.take();
293
294        let mut should_copy_selection = false;
295        for ev in ctx.events.iter() {
296            if let Event::Mouse(mouse) = ev {
297                match mouse.kind {
298                    event::MouseKind::Down(event::MouseButton::Left) => {
299                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
300                    }
301                    event::MouseKind::Drag(event::MouseButton::Left) => {
302                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
303                    }
304                    event::MouseKind::Up(event::MouseButton::Left) => {
305                        should_copy_selection = selection.active;
306                    }
307                    _ => {}
308                }
309            }
310        }
311
312        focus_index = ctx.focus_index;
313        prev_focus_count = ctx.focus_count;
314
315        let mut tree = layout::build_tree(&ctx.commands);
316        let area = crate::rect::Rect::new(0, 0, w, h);
317        layout::compute(&mut tree, area);
318        prev_scroll_infos = layout::collect_scroll_infos(&tree);
319        prev_scroll_rects = layout::collect_scroll_rects(&tree);
320        prev_hit_map = layout::collect_hit_areas(&tree);
321        prev_group_rects = layout::collect_group_rects(&tree);
322        prev_content_map = layout::collect_content_areas(&tree);
323        prev_focus_rects = layout::collect_focus_rects(&tree);
324        prev_focus_groups = layout::collect_focus_groups(&tree);
325        layout::render(&tree, term.buffer_mut());
326        hook_states = ctx.hook_states;
327        let frame_time = frame_start.elapsed();
328        let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
329        let frame_secs = frame_time.as_secs_f32();
330        let inst_fps = if frame_secs > 0.0 {
331            1.0 / frame_secs
332        } else {
333            0.0
334        };
335        fps_ema = if fps_ema == 0.0 {
336            inst_fps
337        } else {
338            (fps_ema * 0.9) + (inst_fps * 0.1)
339        };
340        if debug_mode {
341            layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, fps_ema);
342        }
343
344        if selection.active {
345            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
346        }
347        if should_copy_selection {
348            let text =
349                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
350            if !text.is_empty() {
351                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
352            }
353            selection.clear();
354        }
355
356        term.flush()?;
357        if let Some(text) = clipboard_text {
358            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
359        }
360        tick = tick.wrapping_add(1);
361
362        events.clear();
363        if crossterm::event::poll(config.tick_rate)? {
364            let raw = crossterm::event::read()?;
365            if let Some(ev) = event::from_crossterm(raw) {
366                if is_ctrl_c(&ev) {
367                    break;
368                }
369                if let Event::Resize(_, _) = &ev {
370                    term.handle_resize()?;
371                }
372                events.push(ev);
373            }
374
375            while crossterm::event::poll(Duration::ZERO)? {
376                let raw = crossterm::event::read()?;
377                if let Some(ev) = event::from_crossterm(raw) {
378                    if is_ctrl_c(&ev) {
379                        return Ok(());
380                    }
381                    if let Event::Resize(_, _) = &ev {
382                        term.handle_resize()?;
383                    }
384                    events.push(ev);
385                }
386            }
387
388            for ev in &events {
389                if matches!(
390                    ev,
391                    Event::Key(event::KeyEvent {
392                        code: KeyCode::F(12),
393                        kind: event::KeyEventKind::Press,
394                        ..
395                    })
396                ) {
397                    debug_mode = !debug_mode;
398                }
399            }
400        }
401
402        for ev in &events {
403            match ev {
404                Event::Mouse(mouse) => {
405                    last_mouse_pos = Some((mouse.x, mouse.y));
406                }
407                Event::FocusLost => {
408                    last_mouse_pos = None;
409                }
410                _ => {}
411            }
412        }
413
414        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
415            prev_hit_map.clear();
416            prev_group_rects.clear();
417            prev_content_map.clear();
418            prev_focus_rects.clear();
419            prev_focus_groups.clear();
420            prev_scroll_infos.clear();
421            prev_scroll_rects.clear();
422            last_mouse_pos = None;
423        }
424
425        sleep_for_fps_cap(config.max_fps, frame_start);
426    }
427
428    Ok(())
429}
430
431/// Run the TUI loop asynchronously with default configuration.
432///
433/// Requires the `async` feature. Spawns the render loop in a blocking thread
434/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
435/// from async tasks into the UI closure.
436///
437/// # Example
438///
439/// ```no_run
440/// # #[cfg(feature = "async")]
441/// # async fn example() -> std::io::Result<()> {
442/// let tx = slt::run_async::<String>(|ui, messages| {
443///     for msg in messages.drain(..) {
444///         ui.text(msg);
445///     }
446/// })?;
447/// tx.send("hello from async".to_string()).await.ok();
448/// # Ok(())
449/// # }
450/// ```
451#[cfg(feature = "async")]
452pub fn run_async<M: Send + 'static>(
453    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
454) -> io::Result<tokio::sync::mpsc::Sender<M>> {
455    run_async_with(RunConfig::default(), f)
456}
457
458/// Run the TUI loop asynchronously with custom configuration.
459///
460/// Requires the `async` feature. Like [`run_async`], but accepts a
461/// [`RunConfig`] to control tick rate, mouse support, and theming.
462///
463/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
464#[cfg(feature = "async")]
465pub fn run_async_with<M: Send + 'static>(
466    config: RunConfig,
467    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
468) -> io::Result<tokio::sync::mpsc::Sender<M>> {
469    let (tx, rx) = tokio::sync::mpsc::channel(100);
470    let handle =
471        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
472
473    handle.spawn_blocking(move || {
474        let _ = run_async_loop(config, f, rx);
475    });
476
477    Ok(tx)
478}
479
480#[cfg(feature = "async")]
481fn run_async_loop<M: Send + 'static>(
482    config: RunConfig,
483    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
484    mut rx: tokio::sync::mpsc::Receiver<M>,
485) -> io::Result<()> {
486    if !io::stdout().is_terminal() {
487        return Ok(());
488    }
489
490    install_panic_hook();
491    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
492    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
493    let mut events: Vec<Event> = Vec::new();
494    let mut tick: u64 = 0;
495    let mut focus_index: usize = 0;
496    let mut prev_focus_count: usize = 0;
497    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
498    let mut prev_scroll_rects: Vec<rect::Rect> = Vec::new();
499    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
500    let mut prev_group_rects: Vec<(String, rect::Rect)> = Vec::new();
501    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
502    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
503    let mut prev_focus_groups: Vec<Option<String>> = Vec::new();
504    let mut hook_states: Vec<Box<dyn std::any::Any>> = Vec::new();
505    let mut last_mouse_pos: Option<(u32, u32)> = None;
506    let mut prev_modal_active = false;
507    let mut selection = terminal::SelectionState::default();
508
509    loop {
510        let frame_start = Instant::now();
511        let mut messages: Vec<M> = Vec::new();
512        while let Ok(message) = rx.try_recv() {
513            messages.push(message);
514        }
515
516        let (w, h) = term.size();
517        if w == 0 || h == 0 {
518            sleep_for_fps_cap(config.max_fps, frame_start);
519            continue;
520        }
521        let mut ctx = Context::new(
522            std::mem::take(&mut events),
523            w,
524            h,
525            tick,
526            focus_index,
527            prev_focus_count,
528            std::mem::take(&mut prev_scroll_infos),
529            std::mem::take(&mut prev_scroll_rects),
530            std::mem::take(&mut prev_hit_map),
531            std::mem::take(&mut prev_group_rects),
532            std::mem::take(&mut prev_focus_rects),
533            std::mem::take(&mut prev_focus_groups),
534            std::mem::take(&mut hook_states),
535            false,
536            config.theme,
537            last_mouse_pos,
538            prev_modal_active,
539        );
540        ctx.process_focus_keys();
541
542        f(&mut ctx, &mut messages);
543
544        if ctx.should_quit {
545            break;
546        }
547        prev_modal_active = ctx.modal_active;
548        let clipboard_text = ctx.clipboard_text.take();
549
550        let mut should_copy_selection = false;
551        for ev in ctx.events.iter() {
552            if let Event::Mouse(mouse) = ev {
553                match mouse.kind {
554                    event::MouseKind::Down(event::MouseButton::Left) => {
555                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
556                    }
557                    event::MouseKind::Drag(event::MouseButton::Left) => {
558                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
559                    }
560                    event::MouseKind::Up(event::MouseButton::Left) => {
561                        should_copy_selection = selection.active;
562                    }
563                    _ => {}
564                }
565            }
566        }
567
568        focus_index = ctx.focus_index;
569        prev_focus_count = ctx.focus_count;
570
571        let mut tree = layout::build_tree(&ctx.commands);
572        let area = crate::rect::Rect::new(0, 0, w, h);
573        layout::compute(&mut tree, area);
574        prev_scroll_infos = layout::collect_scroll_infos(&tree);
575        prev_scroll_rects = layout::collect_scroll_rects(&tree);
576        prev_hit_map = layout::collect_hit_areas(&tree);
577        prev_group_rects = layout::collect_group_rects(&tree);
578        prev_content_map = layout::collect_content_areas(&tree);
579        prev_focus_rects = layout::collect_focus_rects(&tree);
580        prev_focus_groups = layout::collect_focus_groups(&tree);
581        layout::render(&tree, term.buffer_mut());
582        hook_states = ctx.hook_states;
583
584        if selection.active {
585            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
586        }
587        if should_copy_selection {
588            let text =
589                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
590            if !text.is_empty() {
591                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
592            }
593            selection.clear();
594        }
595
596        term.flush()?;
597        if let Some(text) = clipboard_text {
598            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
599        }
600        tick = tick.wrapping_add(1);
601
602        events.clear();
603        if crossterm::event::poll(config.tick_rate)? {
604            let raw = crossterm::event::read()?;
605            if let Some(ev) = event::from_crossterm(raw) {
606                if is_ctrl_c(&ev) {
607                    break;
608                }
609                if let Event::Resize(_, _) = &ev {
610                    term.handle_resize()?;
611                    prev_hit_map.clear();
612                    prev_group_rects.clear();
613                    prev_content_map.clear();
614                    prev_focus_rects.clear();
615                    prev_focus_groups.clear();
616                    prev_scroll_infos.clear();
617                    prev_scroll_rects.clear();
618                    last_mouse_pos = None;
619                }
620                events.push(ev);
621            }
622
623            while crossterm::event::poll(Duration::ZERO)? {
624                let raw = crossterm::event::read()?;
625                if let Some(ev) = event::from_crossterm(raw) {
626                    if is_ctrl_c(&ev) {
627                        return Ok(());
628                    }
629                    if let Event::Resize(_, _) = &ev {
630                        term.handle_resize()?;
631                        prev_hit_map.clear();
632                        prev_group_rects.clear();
633                        prev_content_map.clear();
634                        prev_focus_rects.clear();
635                        prev_focus_groups.clear();
636                        prev_scroll_infos.clear();
637                        prev_scroll_rects.clear();
638                        last_mouse_pos = None;
639                    }
640                    events.push(ev);
641                }
642            }
643        }
644
645        for ev in &events {
646            match ev {
647                Event::Mouse(mouse) => {
648                    last_mouse_pos = Some((mouse.x, mouse.y));
649                }
650                Event::FocusLost => {
651                    last_mouse_pos = None;
652                }
653                _ => {}
654            }
655        }
656
657        sleep_for_fps_cap(config.max_fps, frame_start);
658    }
659
660    Ok(())
661}
662
663/// Run the TUI in inline mode with default configuration.
664///
665/// Renders `height` rows directly below the current cursor position without
666/// entering alternate screen mode. Useful for CLI tools that want a small
667/// interactive widget below the prompt.
668///
669/// # Example
670///
671/// ```no_run
672/// fn main() -> std::io::Result<()> {
673///     slt::run_inline(3, |ui| {
674///         ui.text("Inline TUI — no alternate screen");
675///     })
676/// }
677/// ```
678pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
679    run_inline_with(height, RunConfig::default(), f)
680}
681
682/// Run the TUI in inline mode with custom configuration.
683///
684/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
685/// mouse support, and theming.
686pub fn run_inline_with(
687    height: u32,
688    config: RunConfig,
689    mut f: impl FnMut(&mut Context),
690) -> io::Result<()> {
691    if !io::stdout().is_terminal() {
692        return Ok(());
693    }
694
695    install_panic_hook();
696    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
697    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
698    let mut events: Vec<Event> = Vec::new();
699    let mut debug_mode: bool = false;
700    let mut tick: u64 = 0;
701    let mut focus_index: usize = 0;
702    let mut prev_focus_count: usize = 0;
703    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
704    let mut prev_scroll_rects: Vec<rect::Rect> = Vec::new();
705    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
706    let mut prev_group_rects: Vec<(String, rect::Rect)> = Vec::new();
707    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
708    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
709    let mut prev_focus_groups: Vec<Option<String>> = Vec::new();
710    let mut hook_states: Vec<Box<dyn std::any::Any>> = Vec::new();
711    let mut last_mouse_pos: Option<(u32, u32)> = None;
712    let mut prev_modal_active = false;
713    let mut selection = terminal::SelectionState::default();
714    let mut fps_ema: f32 = 0.0;
715
716    loop {
717        let frame_start = Instant::now();
718        let (w, h) = term.size();
719        if w == 0 || h == 0 {
720            sleep_for_fps_cap(config.max_fps, frame_start);
721            continue;
722        }
723        let mut ctx = Context::new(
724            std::mem::take(&mut events),
725            w,
726            h,
727            tick,
728            focus_index,
729            prev_focus_count,
730            std::mem::take(&mut prev_scroll_infos),
731            std::mem::take(&mut prev_scroll_rects),
732            std::mem::take(&mut prev_hit_map),
733            std::mem::take(&mut prev_group_rects),
734            std::mem::take(&mut prev_focus_rects),
735            std::mem::take(&mut prev_focus_groups),
736            std::mem::take(&mut hook_states),
737            debug_mode,
738            config.theme,
739            last_mouse_pos,
740            prev_modal_active,
741        );
742        ctx.process_focus_keys();
743
744        f(&mut ctx);
745
746        if ctx.should_quit {
747            break;
748        }
749        prev_modal_active = ctx.modal_active;
750        let clipboard_text = ctx.clipboard_text.take();
751
752        let mut should_copy_selection = false;
753        for ev in ctx.events.iter() {
754            if let Event::Mouse(mouse) = ev {
755                match mouse.kind {
756                    event::MouseKind::Down(event::MouseButton::Left) => {
757                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
758                    }
759                    event::MouseKind::Drag(event::MouseButton::Left) => {
760                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
761                    }
762                    event::MouseKind::Up(event::MouseButton::Left) => {
763                        should_copy_selection = selection.active;
764                    }
765                    _ => {}
766                }
767            }
768        }
769
770        focus_index = ctx.focus_index;
771        prev_focus_count = ctx.focus_count;
772
773        let mut tree = layout::build_tree(&ctx.commands);
774        let area = crate::rect::Rect::new(0, 0, w, h);
775        layout::compute(&mut tree, area);
776        prev_scroll_infos = layout::collect_scroll_infos(&tree);
777        prev_scroll_rects = layout::collect_scroll_rects(&tree);
778        prev_hit_map = layout::collect_hit_areas(&tree);
779        prev_group_rects = layout::collect_group_rects(&tree);
780        prev_content_map = layout::collect_content_areas(&tree);
781        prev_focus_rects = layout::collect_focus_rects(&tree);
782        prev_focus_groups = layout::collect_focus_groups(&tree);
783        layout::render(&tree, term.buffer_mut());
784        hook_states = ctx.hook_states;
785        let frame_time = frame_start.elapsed();
786        let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
787        let frame_secs = frame_time.as_secs_f32();
788        let inst_fps = if frame_secs > 0.0 {
789            1.0 / frame_secs
790        } else {
791            0.0
792        };
793        fps_ema = if fps_ema == 0.0 {
794            inst_fps
795        } else {
796            (fps_ema * 0.9) + (inst_fps * 0.1)
797        };
798        if debug_mode {
799            layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, fps_ema);
800        }
801
802        if selection.active {
803            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
804        }
805        if should_copy_selection {
806            let text =
807                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
808            if !text.is_empty() {
809                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
810            }
811            selection.clear();
812        }
813
814        term.flush()?;
815        if let Some(text) = clipboard_text {
816            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
817        }
818        tick = tick.wrapping_add(1);
819
820        events.clear();
821        if crossterm::event::poll(config.tick_rate)? {
822            let raw = crossterm::event::read()?;
823            if let Some(ev) = event::from_crossterm(raw) {
824                if is_ctrl_c(&ev) {
825                    break;
826                }
827                if let Event::Resize(_, _) = &ev {
828                    term.handle_resize()?;
829                }
830                events.push(ev);
831            }
832
833            while crossterm::event::poll(Duration::ZERO)? {
834                let raw = crossterm::event::read()?;
835                if let Some(ev) = event::from_crossterm(raw) {
836                    if is_ctrl_c(&ev) {
837                        return Ok(());
838                    }
839                    if let Event::Resize(_, _) = &ev {
840                        term.handle_resize()?;
841                    }
842                    events.push(ev);
843                }
844            }
845
846            for ev in &events {
847                if matches!(
848                    ev,
849                    Event::Key(event::KeyEvent {
850                        code: KeyCode::F(12),
851                        kind: event::KeyEventKind::Press,
852                        ..
853                    })
854                ) {
855                    debug_mode = !debug_mode;
856                }
857            }
858        }
859
860        for ev in &events {
861            match ev {
862                Event::Mouse(mouse) => {
863                    last_mouse_pos = Some((mouse.x, mouse.y));
864                }
865                Event::FocusLost => {
866                    last_mouse_pos = None;
867                }
868                _ => {}
869            }
870        }
871
872        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
873            prev_hit_map.clear();
874            prev_group_rects.clear();
875            prev_content_map.clear();
876            prev_focus_rects.clear();
877            prev_focus_groups.clear();
878            prev_scroll_infos.clear();
879            prev_scroll_rects.clear();
880            last_mouse_pos = None;
881        }
882
883        sleep_for_fps_cap(config.max_fps, frame_start);
884    }
885
886    Ok(())
887}
888
889fn is_ctrl_c(ev: &Event) -> bool {
890    matches!(
891        ev,
892        Event::Key(event::KeyEvent {
893            code: KeyCode::Char('c'),
894            modifiers,
895            kind: event::KeyEventKind::Press,
896        }) if modifiers.contains(KeyModifiers::CONTROL)
897    )
898}
899
900fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
901    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
902        let target = Duration::from_secs_f64(1.0 / fps as f64);
903        let elapsed = frame_start.elapsed();
904        if elapsed < target {
905            std::thread::sleep(target - elapsed);
906        }
907    }
908}