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, 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_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
249    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
250    let mut last_mouse_pos: Option<(u32, u32)> = None;
251    let mut prev_modal_active = false;
252    let mut selection = terminal::SelectionState::default();
253    let mut fps_ema: f32 = 0.0;
254
255    loop {
256        let frame_start = Instant::now();
257        let (w, h) = term.size();
258        if w == 0 || h == 0 {
259            sleep_for_fps_cap(config.max_fps, frame_start);
260            continue;
261        }
262        let mut ctx = Context::new(
263            std::mem::take(&mut events),
264            w,
265            h,
266            tick,
267            focus_index,
268            prev_focus_count,
269            std::mem::take(&mut prev_scroll_infos),
270            std::mem::take(&mut prev_scroll_rects),
271            std::mem::take(&mut prev_hit_map),
272            std::mem::take(&mut prev_focus_rects),
273            debug_mode,
274            config.theme,
275            last_mouse_pos,
276            prev_modal_active,
277        );
278        ctx.process_focus_keys();
279
280        f(&mut ctx);
281
282        if ctx.should_quit {
283            break;
284        }
285        prev_modal_active = ctx.modal_active;
286        let clipboard_text = ctx.clipboard_text.take();
287
288        let mut should_copy_selection = false;
289        for ev in ctx.events.iter() {
290            if let Event::Mouse(mouse) = ev {
291                match mouse.kind {
292                    event::MouseKind::Down(event::MouseButton::Left) => {
293                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
294                    }
295                    event::MouseKind::Drag(event::MouseButton::Left) => {
296                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
297                    }
298                    event::MouseKind::Up(event::MouseButton::Left) => {
299                        should_copy_selection = selection.active;
300                    }
301                    _ => {}
302                }
303            }
304        }
305
306        focus_index = ctx.focus_index;
307        prev_focus_count = ctx.focus_count;
308
309        let mut tree = layout::build_tree(&ctx.commands);
310        let area = crate::rect::Rect::new(0, 0, w, h);
311        layout::compute(&mut tree, area);
312        prev_scroll_infos = layout::collect_scroll_infos(&tree);
313        prev_scroll_rects = layout::collect_scroll_rects(&tree);
314        prev_hit_map = layout::collect_hit_areas(&tree);
315        prev_content_map = layout::collect_content_areas(&tree);
316        prev_focus_rects = layout::collect_focus_rects(&tree);
317        layout::render(&tree, term.buffer_mut());
318        let frame_time = frame_start.elapsed();
319        let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
320        let frame_secs = frame_time.as_secs_f32();
321        let inst_fps = if frame_secs > 0.0 {
322            1.0 / frame_secs
323        } else {
324            0.0
325        };
326        fps_ema = if fps_ema == 0.0 {
327            inst_fps
328        } else {
329            (fps_ema * 0.9) + (inst_fps * 0.1)
330        };
331        if debug_mode {
332            layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, fps_ema);
333        }
334
335        if selection.active {
336            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
337        }
338        if should_copy_selection {
339            let text =
340                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
341            if !text.is_empty() {
342                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
343            }
344            selection.clear();
345        }
346
347        term.flush()?;
348        if let Some(text) = clipboard_text {
349            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
350        }
351        tick = tick.wrapping_add(1);
352
353        events.clear();
354        if crossterm::event::poll(config.tick_rate)? {
355            let raw = crossterm::event::read()?;
356            if let Some(ev) = event::from_crossterm(raw) {
357                if is_ctrl_c(&ev) {
358                    break;
359                }
360                if let Event::Resize(_, _) = &ev {
361                    term.handle_resize()?;
362                }
363                events.push(ev);
364            }
365
366            while crossterm::event::poll(Duration::ZERO)? {
367                let raw = crossterm::event::read()?;
368                if let Some(ev) = event::from_crossterm(raw) {
369                    if is_ctrl_c(&ev) {
370                        return Ok(());
371                    }
372                    if let Event::Resize(_, _) = &ev {
373                        term.handle_resize()?;
374                    }
375                    events.push(ev);
376                }
377            }
378
379            for ev in &events {
380                if matches!(
381                    ev,
382                    Event::Key(event::KeyEvent {
383                        code: KeyCode::F(12),
384                        kind: event::KeyEventKind::Press,
385                        ..
386                    })
387                ) {
388                    debug_mode = !debug_mode;
389                }
390            }
391        }
392
393        for ev in &events {
394            match ev {
395                Event::Mouse(mouse) => {
396                    last_mouse_pos = Some((mouse.x, mouse.y));
397                }
398                Event::FocusLost => {
399                    last_mouse_pos = None;
400                }
401                _ => {}
402            }
403        }
404
405        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
406            prev_hit_map.clear();
407            prev_content_map.clear();
408            prev_focus_rects.clear();
409            prev_scroll_infos.clear();
410            prev_scroll_rects.clear();
411            last_mouse_pos = None;
412        }
413
414        sleep_for_fps_cap(config.max_fps, frame_start);
415    }
416
417    Ok(())
418}
419
420/// Run the TUI loop asynchronously with default configuration.
421///
422/// Requires the `async` feature. Spawns the render loop in a blocking thread
423/// and returns a [`tokio::sync::mpsc::Sender`] you can use to push messages
424/// from async tasks into the UI closure.
425///
426/// # Example
427///
428/// ```no_run
429/// # #[cfg(feature = "async")]
430/// # async fn example() -> std::io::Result<()> {
431/// let tx = slt::run_async::<String>(|ui, messages| {
432///     for msg in messages.drain(..) {
433///         ui.text(msg);
434///     }
435/// })?;
436/// tx.send("hello from async".to_string()).await.ok();
437/// # Ok(())
438/// # }
439/// ```
440#[cfg(feature = "async")]
441pub fn run_async<M: Send + 'static>(
442    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
443) -> io::Result<tokio::sync::mpsc::Sender<M>> {
444    run_async_with(RunConfig::default(), f)
445}
446
447/// Run the TUI loop asynchronously with custom configuration.
448///
449/// Requires the `async` feature. Like [`run_async`], but accepts a
450/// [`RunConfig`] to control tick rate, mouse support, and theming.
451///
452/// Returns a [`tokio::sync::mpsc::Sender`] for pushing messages into the UI.
453#[cfg(feature = "async")]
454pub fn run_async_with<M: Send + 'static>(
455    config: RunConfig,
456    f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
457) -> io::Result<tokio::sync::mpsc::Sender<M>> {
458    let (tx, rx) = tokio::sync::mpsc::channel(100);
459    let handle =
460        tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
461
462    handle.spawn_blocking(move || {
463        let _ = run_async_loop(config, f, rx);
464    });
465
466    Ok(tx)
467}
468
469#[cfg(feature = "async")]
470fn run_async_loop<M: Send + 'static>(
471    config: RunConfig,
472    mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
473    mut rx: tokio::sync::mpsc::Receiver<M>,
474) -> io::Result<()> {
475    if !io::stdout().is_terminal() {
476        return Ok(());
477    }
478
479    install_panic_hook();
480    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
481    let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
482    let mut events: Vec<Event> = Vec::new();
483    let mut tick: u64 = 0;
484    let mut focus_index: usize = 0;
485    let mut prev_focus_count: usize = 0;
486    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
487    let mut prev_scroll_rects: Vec<rect::Rect> = Vec::new();
488    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
489    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
490    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
491    let mut last_mouse_pos: Option<(u32, u32)> = None;
492    let mut prev_modal_active = false;
493    let mut selection = terminal::SelectionState::default();
494
495    loop {
496        let frame_start = Instant::now();
497        let mut messages: Vec<M> = Vec::new();
498        while let Ok(message) = rx.try_recv() {
499            messages.push(message);
500        }
501
502        let (w, h) = term.size();
503        if w == 0 || h == 0 {
504            sleep_for_fps_cap(config.max_fps, frame_start);
505            continue;
506        }
507        let mut ctx = Context::new(
508            std::mem::take(&mut events),
509            w,
510            h,
511            tick,
512            focus_index,
513            prev_focus_count,
514            std::mem::take(&mut prev_scroll_infos),
515            std::mem::take(&mut prev_scroll_rects),
516            std::mem::take(&mut prev_hit_map),
517            std::mem::take(&mut prev_focus_rects),
518            false,
519            config.theme,
520            last_mouse_pos,
521            prev_modal_active,
522        );
523        ctx.process_focus_keys();
524
525        f(&mut ctx, &mut messages);
526
527        if ctx.should_quit {
528            break;
529        }
530        prev_modal_active = ctx.modal_active;
531        let clipboard_text = ctx.clipboard_text.take();
532
533        let mut should_copy_selection = false;
534        for ev in ctx.events.iter() {
535            if let Event::Mouse(mouse) = ev {
536                match mouse.kind {
537                    event::MouseKind::Down(event::MouseButton::Left) => {
538                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
539                    }
540                    event::MouseKind::Drag(event::MouseButton::Left) => {
541                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
542                    }
543                    event::MouseKind::Up(event::MouseButton::Left) => {
544                        should_copy_selection = selection.active;
545                    }
546                    _ => {}
547                }
548            }
549        }
550
551        focus_index = ctx.focus_index;
552        prev_focus_count = ctx.focus_count;
553
554        let mut tree = layout::build_tree(&ctx.commands);
555        let area = crate::rect::Rect::new(0, 0, w, h);
556        layout::compute(&mut tree, area);
557        prev_scroll_infos = layout::collect_scroll_infos(&tree);
558        prev_scroll_rects = layout::collect_scroll_rects(&tree);
559        prev_hit_map = layout::collect_hit_areas(&tree);
560        prev_content_map = layout::collect_content_areas(&tree);
561        prev_focus_rects = layout::collect_focus_rects(&tree);
562        layout::render(&tree, term.buffer_mut());
563
564        if selection.active {
565            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
566        }
567        if should_copy_selection {
568            let text =
569                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
570            if !text.is_empty() {
571                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
572            }
573            selection.clear();
574        }
575
576        term.flush()?;
577        if let Some(text) = clipboard_text {
578            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
579        }
580        tick = tick.wrapping_add(1);
581
582        events.clear();
583        if crossterm::event::poll(config.tick_rate)? {
584            let raw = crossterm::event::read()?;
585            if let Some(ev) = event::from_crossterm(raw) {
586                if is_ctrl_c(&ev) {
587                    break;
588                }
589                if let Event::Resize(_, _) = &ev {
590                    term.handle_resize()?;
591                    prev_hit_map.clear();
592                    prev_content_map.clear();
593                    prev_focus_rects.clear();
594                    prev_scroll_infos.clear();
595                    prev_scroll_rects.clear();
596                    last_mouse_pos = None;
597                }
598                events.push(ev);
599            }
600
601            while crossterm::event::poll(Duration::ZERO)? {
602                let raw = crossterm::event::read()?;
603                if let Some(ev) = event::from_crossterm(raw) {
604                    if is_ctrl_c(&ev) {
605                        return Ok(());
606                    }
607                    if let Event::Resize(_, _) = &ev {
608                        term.handle_resize()?;
609                        prev_hit_map.clear();
610                        prev_content_map.clear();
611                        prev_focus_rects.clear();
612                        prev_scroll_infos.clear();
613                        prev_scroll_rects.clear();
614                        last_mouse_pos = None;
615                    }
616                    events.push(ev);
617                }
618            }
619        }
620
621        for ev in &events {
622            match ev {
623                Event::Mouse(mouse) => {
624                    last_mouse_pos = Some((mouse.x, mouse.y));
625                }
626                Event::FocusLost => {
627                    last_mouse_pos = None;
628                }
629                _ => {}
630            }
631        }
632
633        sleep_for_fps_cap(config.max_fps, frame_start);
634    }
635
636    Ok(())
637}
638
639/// Run the TUI in inline mode with default configuration.
640///
641/// Renders `height` rows directly below the current cursor position without
642/// entering alternate screen mode. Useful for CLI tools that want a small
643/// interactive widget below the prompt.
644///
645/// # Example
646///
647/// ```no_run
648/// fn main() -> std::io::Result<()> {
649///     slt::run_inline(3, |ui| {
650///         ui.text("Inline TUI — no alternate screen");
651///     })
652/// }
653/// ```
654pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
655    run_inline_with(height, RunConfig::default(), f)
656}
657
658/// Run the TUI in inline mode with custom configuration.
659///
660/// Like [`run_inline`], but accepts a [`RunConfig`] to control tick rate,
661/// mouse support, and theming.
662pub fn run_inline_with(
663    height: u32,
664    config: RunConfig,
665    mut f: impl FnMut(&mut Context),
666) -> io::Result<()> {
667    if !io::stdout().is_terminal() {
668        return Ok(());
669    }
670
671    install_panic_hook();
672    let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
673    let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
674    let mut events: Vec<Event> = Vec::new();
675    let mut debug_mode: bool = false;
676    let mut tick: u64 = 0;
677    let mut focus_index: usize = 0;
678    let mut prev_focus_count: usize = 0;
679    let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
680    let mut prev_scroll_rects: Vec<rect::Rect> = Vec::new();
681    let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
682    let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
683    let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
684    let mut last_mouse_pos: Option<(u32, u32)> = None;
685    let mut prev_modal_active = false;
686    let mut selection = terminal::SelectionState::default();
687    let mut fps_ema: f32 = 0.0;
688
689    loop {
690        let frame_start = Instant::now();
691        let (w, h) = term.size();
692        if w == 0 || h == 0 {
693            sleep_for_fps_cap(config.max_fps, frame_start);
694            continue;
695        }
696        let mut ctx = Context::new(
697            std::mem::take(&mut events),
698            w,
699            h,
700            tick,
701            focus_index,
702            prev_focus_count,
703            std::mem::take(&mut prev_scroll_infos),
704            std::mem::take(&mut prev_scroll_rects),
705            std::mem::take(&mut prev_hit_map),
706            std::mem::take(&mut prev_focus_rects),
707            debug_mode,
708            config.theme,
709            last_mouse_pos,
710            prev_modal_active,
711        );
712        ctx.process_focus_keys();
713
714        f(&mut ctx);
715
716        if ctx.should_quit {
717            break;
718        }
719        prev_modal_active = ctx.modal_active;
720        let clipboard_text = ctx.clipboard_text.take();
721
722        let mut should_copy_selection = false;
723        for ev in ctx.events.iter() {
724            if let Event::Mouse(mouse) = ev {
725                match mouse.kind {
726                    event::MouseKind::Down(event::MouseButton::Left) => {
727                        selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
728                    }
729                    event::MouseKind::Drag(event::MouseButton::Left) => {
730                        selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
731                    }
732                    event::MouseKind::Up(event::MouseButton::Left) => {
733                        should_copy_selection = selection.active;
734                    }
735                    _ => {}
736                }
737            }
738        }
739
740        focus_index = ctx.focus_index;
741        prev_focus_count = ctx.focus_count;
742
743        let mut tree = layout::build_tree(&ctx.commands);
744        let area = crate::rect::Rect::new(0, 0, w, h);
745        layout::compute(&mut tree, area);
746        prev_scroll_infos = layout::collect_scroll_infos(&tree);
747        prev_scroll_rects = layout::collect_scroll_rects(&tree);
748        prev_hit_map = layout::collect_hit_areas(&tree);
749        prev_content_map = layout::collect_content_areas(&tree);
750        prev_focus_rects = layout::collect_focus_rects(&tree);
751        layout::render(&tree, term.buffer_mut());
752        let frame_time = frame_start.elapsed();
753        let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
754        let frame_secs = frame_time.as_secs_f32();
755        let inst_fps = if frame_secs > 0.0 {
756            1.0 / frame_secs
757        } else {
758            0.0
759        };
760        fps_ema = if fps_ema == 0.0 {
761            inst_fps
762        } else {
763            (fps_ema * 0.9) + (inst_fps * 0.1)
764        };
765        if debug_mode {
766            layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, fps_ema);
767        }
768
769        if selection.active {
770            terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
771        }
772        if should_copy_selection {
773            let text =
774                terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
775            if !text.is_empty() {
776                terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
777            }
778            selection.clear();
779        }
780
781        term.flush()?;
782        if let Some(text) = clipboard_text {
783            let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
784        }
785        tick = tick.wrapping_add(1);
786
787        events.clear();
788        if crossterm::event::poll(config.tick_rate)? {
789            let raw = crossterm::event::read()?;
790            if let Some(ev) = event::from_crossterm(raw) {
791                if is_ctrl_c(&ev) {
792                    break;
793                }
794                if let Event::Resize(_, _) = &ev {
795                    term.handle_resize()?;
796                }
797                events.push(ev);
798            }
799
800            while crossterm::event::poll(Duration::ZERO)? {
801                let raw = crossterm::event::read()?;
802                if let Some(ev) = event::from_crossterm(raw) {
803                    if is_ctrl_c(&ev) {
804                        return Ok(());
805                    }
806                    if let Event::Resize(_, _) = &ev {
807                        term.handle_resize()?;
808                    }
809                    events.push(ev);
810                }
811            }
812
813            for ev in &events {
814                if matches!(
815                    ev,
816                    Event::Key(event::KeyEvent {
817                        code: KeyCode::F(12),
818                        kind: event::KeyEventKind::Press,
819                        ..
820                    })
821                ) {
822                    debug_mode = !debug_mode;
823                }
824            }
825        }
826
827        for ev in &events {
828            match ev {
829                Event::Mouse(mouse) => {
830                    last_mouse_pos = Some((mouse.x, mouse.y));
831                }
832                Event::FocusLost => {
833                    last_mouse_pos = None;
834                }
835                _ => {}
836            }
837        }
838
839        if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
840            prev_hit_map.clear();
841            prev_content_map.clear();
842            prev_focus_rects.clear();
843            prev_scroll_infos.clear();
844            prev_scroll_rects.clear();
845            last_mouse_pos = None;
846        }
847
848        sleep_for_fps_cap(config.max_fps, frame_start);
849    }
850
851    Ok(())
852}
853
854fn is_ctrl_c(ev: &Event) -> bool {
855    matches!(
856        ev,
857        Event::Key(event::KeyEvent {
858            code: KeyCode::Char('c'),
859            modifiers,
860            kind: event::KeyEventKind::Press,
861        }) if modifiers.contains(KeyModifiers::CONTROL)
862    )
863}
864
865fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
866    if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
867        let target = Duration::from_secs_f64(1.0 / fps as f64);
868        let elapsed = frame_start.elapsed();
869        if elapsed < target {
870            std::thread::sleep(target - elapsed);
871        }
872    }
873}