1pub mod anim;
39pub mod buffer;
40pub mod cell;
41pub mod chart;
42pub mod context;
43pub mod event;
44pub mod halfblock;
45pub mod keymap;
46pub mod layout;
47pub mod palette;
48pub mod rect;
49pub mod style;
50mod terminal;
51pub mod test_utils;
52pub mod widgets;
53
54use std::io;
55use std::io::IsTerminal;
56use std::sync::Once;
57use std::time::{Duration, Instant};
58
59use terminal::{InlineTerminal, Terminal, TerminalBackend};
60
61pub use crate::test_utils::{EventBuilder, TestBackend};
62pub use anim::{
63 ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
64 ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
65 Stagger, Tween,
66};
67pub use buffer::Buffer;
68pub use cell::Cell;
69pub use chart::{
70 Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
71 HistogramBuilder, LegendPosition, Marker,
72};
73pub use context::{
74 Bar, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context, Response, State, Widget,
75};
76pub use event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind};
77pub use halfblock::HalfBlockImage;
78pub use keymap::{Binding, KeyMap};
79pub use layout::Direction;
80pub use palette::Palette;
81pub use rect::Rect;
82pub use style::{
83 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
84 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder,
85};
86pub use widgets::{
87 AlertLevel, ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FileEntry,
88 FilePickerState, FormField, FormState, ListState, MultiSelectState, PaletteCommand, RadioState,
89 ScrollState, SelectState, SpinnerState, StreamingTextState, TableState, TabsState,
90 TextInputState, TextareaState, ToastLevel, ToastMessage, ToastState, ToolApprovalState,
91 TreeNode, TreeState, Trend,
92};
93
94static PANIC_HOOK_ONCE: Once = Once::new();
95
96fn install_panic_hook() {
97 PANIC_HOOK_ONCE.call_once(|| {
98 let original = std::panic::take_hook();
99 std::panic::set_hook(Box::new(move |panic_info| {
100 let _ = crossterm::terminal::disable_raw_mode();
101 let mut stdout = io::stdout();
102 let _ = crossterm::execute!(
103 stdout,
104 crossterm::terminal::LeaveAlternateScreen,
105 crossterm::cursor::Show,
106 crossterm::event::DisableMouseCapture,
107 crossterm::event::DisableBracketedPaste,
108 crossterm::style::ResetColor,
109 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
110 );
111
112 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
114
115 if let Some(location) = panic_info.location() {
117 eprintln!(
118 "\x1b[90m{}:{}:{}\x1b[0m",
119 location.file(),
120 location.line(),
121 location.column()
122 );
123 }
124
125 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
127 eprintln!("\x1b[1m{}\x1b[0m", msg);
128 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
129 eprintln!("\x1b[1m{}\x1b[0m", msg);
130 }
131
132 eprintln!(
133 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
134 );
135
136 original(panic_info);
137 }));
138 });
139}
140
141#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
162pub struct RunConfig {
163 pub tick_rate: Duration,
168 pub mouse: bool,
173 pub kitty_keyboard: bool,
180 pub theme: Theme,
184 pub color_depth: Option<ColorDepth>,
190 pub max_fps: Option<u32>,
195}
196
197impl Default for RunConfig {
198 fn default() -> Self {
199 Self {
200 tick_rate: Duration::from_millis(16),
201 mouse: false,
202 kitty_keyboard: false,
203 theme: Theme::dark(),
204 color_depth: None,
205 max_fps: Some(60),
206 }
207 }
208}
209
210pub(crate) struct FrameState {
211 pub hook_states: Vec<Box<dyn std::any::Any>>,
212 pub focus_index: usize,
213 pub prev_focus_count: usize,
214 pub tick: u64,
215 pub prev_scroll_infos: Vec<(u32, u32)>,
216 pub prev_scroll_rects: Vec<rect::Rect>,
217 pub prev_hit_map: Vec<rect::Rect>,
218 pub prev_group_rects: Vec<(String, rect::Rect)>,
219 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
220 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
221 pub prev_focus_groups: Vec<Option<String>>,
222 pub last_mouse_pos: Option<(u32, u32)>,
223 pub prev_modal_active: bool,
224 pub notification_queue: Vec<(String, ToastLevel, u64)>,
225 pub debug_mode: bool,
226 pub fps_ema: f32,
227 pub selection: terminal::SelectionState,
228}
229
230impl Default for FrameState {
231 fn default() -> Self {
232 Self {
233 hook_states: Vec::new(),
234 focus_index: 0,
235 prev_focus_count: 0,
236 tick: 0,
237 prev_scroll_infos: Vec::new(),
238 prev_scroll_rects: Vec::new(),
239 prev_hit_map: Vec::new(),
240 prev_group_rects: Vec::new(),
241 prev_content_map: Vec::new(),
242 prev_focus_rects: Vec::new(),
243 prev_focus_groups: Vec::new(),
244 last_mouse_pos: None,
245 prev_modal_active: false,
246 notification_queue: Vec::new(),
247 debug_mode: false,
248 fps_ema: 0.0,
249 selection: terminal::SelectionState::default(),
250 }
251 }
252}
253
254pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
269 run_with(RunConfig::default(), f)
270}
271
272pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
292 if !io::stdout().is_terminal() {
293 return Ok(());
294 }
295
296 install_panic_hook();
297 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
298 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
299 if config.theme.bg != Color::Reset {
300 term.theme_bg = Some(config.theme.bg);
301 }
302 let mut events: Vec<Event> = Vec::new();
303 let mut state = FrameState::default();
304
305 loop {
306 let frame_start = Instant::now();
307 let (w, h) = term.size();
308 if w == 0 || h == 0 {
309 sleep_for_fps_cap(config.max_fps, frame_start);
310 continue;
311 }
312
313 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
314 break;
315 }
316
317 events.clear();
318 if crossterm::event::poll(config.tick_rate)? {
319 let raw = crossterm::event::read()?;
320 if let Some(ev) = event::from_crossterm(raw) {
321 if is_ctrl_c(&ev) {
322 break;
323 }
324 if let Event::Resize(_, _) = &ev {
325 TerminalBackend::handle_resize(&mut term)?;
326 }
327 events.push(ev);
328 }
329
330 while crossterm::event::poll(Duration::ZERO)? {
331 let raw = crossterm::event::read()?;
332 if let Some(ev) = event::from_crossterm(raw) {
333 if is_ctrl_c(&ev) {
334 return Ok(());
335 }
336 if let Event::Resize(_, _) = &ev {
337 TerminalBackend::handle_resize(&mut term)?;
338 }
339 events.push(ev);
340 }
341 }
342
343 for ev in &events {
344 if matches!(
345 ev,
346 Event::Key(event::KeyEvent {
347 code: KeyCode::F(12),
348 kind: event::KeyEventKind::Press,
349 ..
350 })
351 ) {
352 state.debug_mode = !state.debug_mode;
353 }
354 }
355 }
356
357 update_last_mouse_pos(&mut state, &events);
358
359 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
360 clear_frame_layout_cache(&mut state);
361 }
362
363 sleep_for_fps_cap(config.max_fps, frame_start);
364 }
365
366 Ok(())
367}
368
369#[cfg(feature = "async")]
390pub fn run_async<M: Send + 'static>(
391 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
392) -> io::Result<tokio::sync::mpsc::Sender<M>> {
393 run_async_with(RunConfig::default(), f)
394}
395
396#[cfg(feature = "async")]
403pub fn run_async_with<M: Send + 'static>(
404 config: RunConfig,
405 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
406) -> io::Result<tokio::sync::mpsc::Sender<M>> {
407 let (tx, rx) = tokio::sync::mpsc::channel(100);
408 let handle =
409 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
410
411 handle.spawn_blocking(move || {
412 let _ = run_async_loop(config, f, rx);
413 });
414
415 Ok(tx)
416}
417
418#[cfg(feature = "async")]
419fn run_async_loop<M: Send + 'static>(
420 config: RunConfig,
421 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
422 mut rx: tokio::sync::mpsc::Receiver<M>,
423) -> io::Result<()> {
424 if !io::stdout().is_terminal() {
425 return Ok(());
426 }
427
428 install_panic_hook();
429 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
430 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
431 if config.theme.bg != Color::Reset {
432 term.theme_bg = Some(config.theme.bg);
433 }
434 let mut events: Vec<Event> = Vec::new();
435 let mut state = FrameState::default();
436
437 loop {
438 let frame_start = Instant::now();
439 let mut messages: Vec<M> = Vec::new();
440 while let Ok(message) = rx.try_recv() {
441 messages.push(message);
442 }
443
444 let (w, h) = term.size();
445 if w == 0 || h == 0 {
446 sleep_for_fps_cap(config.max_fps, frame_start);
447 continue;
448 }
449
450 let mut render = |ctx: &mut Context| {
451 f(ctx, &mut messages);
452 };
453 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
454 break;
455 }
456
457 events.clear();
458 if crossterm::event::poll(config.tick_rate)? {
459 let raw = crossterm::event::read()?;
460 if let Some(ev) = event::from_crossterm(raw) {
461 if is_ctrl_c(&ev) {
462 break;
463 }
464 if let Event::Resize(_, _) = &ev {
465 TerminalBackend::handle_resize(&mut term)?;
466 clear_frame_layout_cache(&mut state);
467 }
468 events.push(ev);
469 }
470
471 while crossterm::event::poll(Duration::ZERO)? {
472 let raw = crossterm::event::read()?;
473 if let Some(ev) = event::from_crossterm(raw) {
474 if is_ctrl_c(&ev) {
475 return Ok(());
476 }
477 if let Event::Resize(_, _) = &ev {
478 TerminalBackend::handle_resize(&mut term)?;
479 clear_frame_layout_cache(&mut state);
480 }
481 events.push(ev);
482 }
483 }
484 }
485
486 update_last_mouse_pos(&mut state, &events);
487
488 sleep_for_fps_cap(config.max_fps, frame_start);
489 }
490
491 Ok(())
492}
493
494pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
510 run_inline_with(height, RunConfig::default(), f)
511}
512
513pub fn run_inline_with(
518 height: u32,
519 config: RunConfig,
520 mut f: impl FnMut(&mut Context),
521) -> io::Result<()> {
522 if !io::stdout().is_terminal() {
523 return Ok(());
524 }
525
526 install_panic_hook();
527 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
528 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
529 if config.theme.bg != Color::Reset {
530 term.theme_bg = Some(config.theme.bg);
531 }
532 let mut events: Vec<Event> = Vec::new();
533 let mut state = FrameState::default();
534
535 loop {
536 let frame_start = Instant::now();
537 let (w, h) = term.size();
538 if w == 0 || h == 0 {
539 sleep_for_fps_cap(config.max_fps, frame_start);
540 continue;
541 }
542
543 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
544 break;
545 }
546
547 events.clear();
548 if crossterm::event::poll(config.tick_rate)? {
549 let raw = crossterm::event::read()?;
550 if let Some(ev) = event::from_crossterm(raw) {
551 if is_ctrl_c(&ev) {
552 break;
553 }
554 if let Event::Resize(_, _) = &ev {
555 TerminalBackend::handle_resize(&mut term)?;
556 }
557 events.push(ev);
558 }
559
560 while crossterm::event::poll(Duration::ZERO)? {
561 let raw = crossterm::event::read()?;
562 if let Some(ev) = event::from_crossterm(raw) {
563 if is_ctrl_c(&ev) {
564 return Ok(());
565 }
566 if let Event::Resize(_, _) = &ev {
567 TerminalBackend::handle_resize(&mut term)?;
568 }
569 events.push(ev);
570 }
571 }
572
573 for ev in &events {
574 if matches!(
575 ev,
576 Event::Key(event::KeyEvent {
577 code: KeyCode::F(12),
578 kind: event::KeyEventKind::Press,
579 ..
580 })
581 ) {
582 state.debug_mode = !state.debug_mode;
583 }
584 }
585 }
586
587 update_last_mouse_pos(&mut state, &events);
588
589 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
590 clear_frame_layout_cache(&mut state);
591 }
592
593 sleep_for_fps_cap(config.max_fps, frame_start);
594 }
595
596 Ok(())
597}
598
599fn run_frame<T: TerminalBackend>(
600 term: &mut T,
601 state: &mut FrameState,
602 config: &RunConfig,
603 events: &[event::Event],
604 f: &mut dyn FnMut(&mut context::Context),
605) -> io::Result<bool> {
606 let frame_start = Instant::now();
607 let (w, h) = term.size();
608 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
609 ctx.is_real_terminal = true;
610 ctx.process_focus_keys();
611
612 f(&mut ctx);
613 ctx.render_notifications();
614
615 if ctx.should_quit {
616 return Ok(false);
617 }
618 state.prev_modal_active = ctx.modal_active;
619 let clipboard_text = ctx.clipboard_text.take();
620
621 let mut should_copy_selection = false;
622 for ev in &ctx.events {
623 if let Event::Mouse(mouse) = ev {
624 match mouse.kind {
625 event::MouseKind::Down(event::MouseButton::Left) => {
626 state
627 .selection
628 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
629 }
630 event::MouseKind::Drag(event::MouseButton::Left) => {
631 state
632 .selection
633 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
634 }
635 event::MouseKind::Up(event::MouseButton::Left) => {
636 should_copy_selection = state.selection.active;
637 }
638 _ => {}
639 }
640 }
641 }
642
643 state.focus_index = ctx.focus_index;
644 state.prev_focus_count = ctx.focus_count;
645
646 let mut tree = layout::build_tree(&ctx.commands);
647 let area = crate::rect::Rect::new(0, 0, w, h);
648 layout::compute(&mut tree, area);
649 let fd = layout::collect_all(&tree);
650 state.prev_scroll_infos = fd.scroll_infos;
651 state.prev_scroll_rects = fd.scroll_rects;
652 state.prev_hit_map = fd.hit_areas;
653 state.prev_group_rects = fd.group_rects;
654 state.prev_content_map = fd.content_areas;
655 state.prev_focus_rects = fd.focus_rects;
656 state.prev_focus_groups = fd.focus_groups;
657 layout::render(&tree, term.buffer_mut());
658 let raw_rects = layout::collect_raw_draw_rects(&tree);
659 for (draw_id, rect) in raw_rects {
660 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
661 let buf = term.buffer_mut();
662 buf.push_clip(rect);
663 cb(buf, rect);
664 buf.pop_clip();
665 }
666 }
667 state.hook_states = ctx.hook_states;
668 state.notification_queue = ctx.notification_queue;
669
670 let frame_time = frame_start.elapsed();
671 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
672 let frame_secs = frame_time.as_secs_f32();
673 let inst_fps = if frame_secs > 0.0 {
674 1.0 / frame_secs
675 } else {
676 0.0
677 };
678 state.fps_ema = if state.fps_ema == 0.0 {
679 inst_fps
680 } else {
681 (state.fps_ema * 0.9) + (inst_fps * 0.1)
682 };
683 if state.debug_mode {
684 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
685 }
686
687 if state.selection.active {
688 terminal::apply_selection_overlay(
689 term.buffer_mut(),
690 &state.selection,
691 &state.prev_content_map,
692 );
693 }
694 if should_copy_selection {
695 let text = terminal::extract_selection_text(
696 term.buffer_mut(),
697 &state.selection,
698 &state.prev_content_map,
699 );
700 if !text.is_empty() {
701 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
702 }
703 state.selection.clear();
704 }
705
706 term.flush()?;
707 if let Some(text) = clipboard_text {
708 let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
709 }
710 state.tick = state.tick.wrapping_add(1);
711
712 Ok(true)
713}
714
715fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
716 for ev in events {
717 match ev {
718 Event::Mouse(mouse) => {
719 state.last_mouse_pos = Some((mouse.x, mouse.y));
720 }
721 Event::FocusLost => {
722 state.last_mouse_pos = None;
723 }
724 _ => {}
725 }
726 }
727}
728
729fn clear_frame_layout_cache(state: &mut FrameState) {
730 state.prev_hit_map.clear();
731 state.prev_group_rects.clear();
732 state.prev_content_map.clear();
733 state.prev_focus_rects.clear();
734 state.prev_focus_groups.clear();
735 state.prev_scroll_infos.clear();
736 state.prev_scroll_rects.clear();
737 state.last_mouse_pos = None;
738}
739
740fn is_ctrl_c(ev: &Event) -> bool {
741 matches!(
742 ev,
743 Event::Key(event::KeyEvent {
744 code: KeyCode::Char('c'),
745 modifiers,
746 kind: event::KeyEventKind::Press,
747 }) if modifiers.contains(KeyModifiers::CONTROL)
748 )
749}
750
751fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
752 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
753 let target = Duration::from_secs_f64(1.0 / fps as f64);
754 let elapsed = frame_start.elapsed();
755 if elapsed < target {
756 std::thread::sleep(target - elapsed);
757 }
758 }
759}