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