1pub 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, TerminalBackend};
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 AlertLevel, ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FormField,
76 FormState, ListState, MultiSelectState, PaletteCommand, RadioState, ScrollState, SelectState,
77 SpinnerState, StreamingTextState, TableState, TabsState, TextInputState, TextareaState,
78 ToastLevel, ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
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 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
101
102 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 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#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
149pub struct RunConfig {
150 pub tick_rate: Duration,
155 pub mouse: bool,
160 pub kitty_keyboard: bool,
167 pub theme: Theme,
171 pub color_depth: Option<ColorDepth>,
177 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
197pub(crate) struct FrameState {
198 pub hook_states: Vec<Box<dyn std::any::Any>>,
199 pub focus_index: usize,
200 pub prev_focus_count: usize,
201 pub tick: u64,
202 pub prev_scroll_infos: Vec<(u32, u32)>,
203 pub prev_scroll_rects: Vec<rect::Rect>,
204 pub prev_hit_map: Vec<rect::Rect>,
205 pub prev_group_rects: Vec<(String, rect::Rect)>,
206 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
207 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
208 pub prev_focus_groups: Vec<Option<String>>,
209 pub last_mouse_pos: Option<(u32, u32)>,
210 pub prev_modal_active: bool,
211 pub debug_mode: bool,
212 pub fps_ema: f32,
213 pub selection: terminal::SelectionState,
214}
215
216impl Default for FrameState {
217 fn default() -> Self {
218 Self {
219 hook_states: Vec::new(),
220 focus_index: 0,
221 prev_focus_count: 0,
222 tick: 0,
223 prev_scroll_infos: Vec::new(),
224 prev_scroll_rects: Vec::new(),
225 prev_hit_map: Vec::new(),
226 prev_group_rects: Vec::new(),
227 prev_content_map: Vec::new(),
228 prev_focus_rects: Vec::new(),
229 prev_focus_groups: Vec::new(),
230 last_mouse_pos: None,
231 prev_modal_active: false,
232 debug_mode: false,
233 fps_ema: 0.0,
234 selection: terminal::SelectionState::default(),
235 }
236 }
237}
238
239pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
254 run_with(RunConfig::default(), f)
255}
256
257pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
277 if !io::stdout().is_terminal() {
278 return Ok(());
279 }
280
281 install_panic_hook();
282 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
283 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
284 if config.theme.bg != Color::Reset {
285 term.theme_bg = Some(config.theme.bg);
286 }
287 let mut events: Vec<Event> = Vec::new();
288 let mut state = FrameState::default();
289
290 loop {
291 let frame_start = Instant::now();
292 let (w, h) = term.size();
293 if w == 0 || h == 0 {
294 sleep_for_fps_cap(config.max_fps, frame_start);
295 continue;
296 }
297
298 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
299 break;
300 }
301
302 events.clear();
303 if crossterm::event::poll(config.tick_rate)? {
304 let raw = crossterm::event::read()?;
305 if let Some(ev) = event::from_crossterm(raw) {
306 if is_ctrl_c(&ev) {
307 break;
308 }
309 if let Event::Resize(_, _) = &ev {
310 TerminalBackend::handle_resize(&mut term)?;
311 }
312 events.push(ev);
313 }
314
315 while crossterm::event::poll(Duration::ZERO)? {
316 let raw = crossterm::event::read()?;
317 if let Some(ev) = event::from_crossterm(raw) {
318 if is_ctrl_c(&ev) {
319 return Ok(());
320 }
321 if let Event::Resize(_, _) = &ev {
322 TerminalBackend::handle_resize(&mut term)?;
323 }
324 events.push(ev);
325 }
326 }
327
328 for ev in &events {
329 if matches!(
330 ev,
331 Event::Key(event::KeyEvent {
332 code: KeyCode::F(12),
333 kind: event::KeyEventKind::Press,
334 ..
335 })
336 ) {
337 state.debug_mode = !state.debug_mode;
338 }
339 }
340 }
341
342 update_last_mouse_pos(&mut state, &events);
343
344 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
345 clear_frame_layout_cache(&mut state);
346 }
347
348 sleep_for_fps_cap(config.max_fps, frame_start);
349 }
350
351 Ok(())
352}
353
354#[cfg(feature = "async")]
375pub fn run_async<M: Send + 'static>(
376 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
377) -> io::Result<tokio::sync::mpsc::Sender<M>> {
378 run_async_with(RunConfig::default(), f)
379}
380
381#[cfg(feature = "async")]
388pub fn run_async_with<M: Send + 'static>(
389 config: RunConfig,
390 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
391) -> io::Result<tokio::sync::mpsc::Sender<M>> {
392 let (tx, rx) = tokio::sync::mpsc::channel(100);
393 let handle =
394 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
395
396 handle.spawn_blocking(move || {
397 let _ = run_async_loop(config, f, rx);
398 });
399
400 Ok(tx)
401}
402
403#[cfg(feature = "async")]
404fn run_async_loop<M: Send + 'static>(
405 config: RunConfig,
406 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
407 mut rx: tokio::sync::mpsc::Receiver<M>,
408) -> io::Result<()> {
409 if !io::stdout().is_terminal() {
410 return Ok(());
411 }
412
413 install_panic_hook();
414 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
415 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
416 if config.theme.bg != Color::Reset {
417 term.theme_bg = Some(config.theme.bg);
418 }
419 let mut events: Vec<Event> = Vec::new();
420 let mut state = FrameState::default();
421
422 loop {
423 let frame_start = Instant::now();
424 let mut messages: Vec<M> = Vec::new();
425 while let Ok(message) = rx.try_recv() {
426 messages.push(message);
427 }
428
429 let (w, h) = term.size();
430 if w == 0 || h == 0 {
431 sleep_for_fps_cap(config.max_fps, frame_start);
432 continue;
433 }
434
435 let mut render = |ctx: &mut Context| {
436 f(ctx, &mut messages);
437 };
438 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
439 break;
440 }
441
442 events.clear();
443 if crossterm::event::poll(config.tick_rate)? {
444 let raw = crossterm::event::read()?;
445 if let Some(ev) = event::from_crossterm(raw) {
446 if is_ctrl_c(&ev) {
447 break;
448 }
449 if let Event::Resize(_, _) = &ev {
450 TerminalBackend::handle_resize(&mut term)?;
451 clear_frame_layout_cache(&mut state);
452 }
453 events.push(ev);
454 }
455
456 while crossterm::event::poll(Duration::ZERO)? {
457 let raw = crossterm::event::read()?;
458 if let Some(ev) = event::from_crossterm(raw) {
459 if is_ctrl_c(&ev) {
460 return Ok(());
461 }
462 if let Event::Resize(_, _) = &ev {
463 TerminalBackend::handle_resize(&mut term)?;
464 clear_frame_layout_cache(&mut state);
465 }
466 events.push(ev);
467 }
468 }
469 }
470
471 update_last_mouse_pos(&mut state, &events);
472
473 sleep_for_fps_cap(config.max_fps, frame_start);
474 }
475
476 Ok(())
477}
478
479pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
495 run_inline_with(height, RunConfig::default(), f)
496}
497
498pub fn run_inline_with(
503 height: u32,
504 config: RunConfig,
505 mut f: impl FnMut(&mut Context),
506) -> io::Result<()> {
507 if !io::stdout().is_terminal() {
508 return Ok(());
509 }
510
511 install_panic_hook();
512 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
513 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
514 let mut events: Vec<Event> = Vec::new();
515 let mut state = FrameState::default();
516
517 loop {
518 let frame_start = Instant::now();
519 let (w, h) = term.size();
520 if w == 0 || h == 0 {
521 sleep_for_fps_cap(config.max_fps, frame_start);
522 continue;
523 }
524
525 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
526 break;
527 }
528
529 events.clear();
530 if crossterm::event::poll(config.tick_rate)? {
531 let raw = crossterm::event::read()?;
532 if let Some(ev) = event::from_crossterm(raw) {
533 if is_ctrl_c(&ev) {
534 break;
535 }
536 if let Event::Resize(_, _) = &ev {
537 TerminalBackend::handle_resize(&mut term)?;
538 }
539 events.push(ev);
540 }
541
542 while crossterm::event::poll(Duration::ZERO)? {
543 let raw = crossterm::event::read()?;
544 if let Some(ev) = event::from_crossterm(raw) {
545 if is_ctrl_c(&ev) {
546 return Ok(());
547 }
548 if let Event::Resize(_, _) = &ev {
549 TerminalBackend::handle_resize(&mut term)?;
550 }
551 events.push(ev);
552 }
553 }
554
555 for ev in &events {
556 if matches!(
557 ev,
558 Event::Key(event::KeyEvent {
559 code: KeyCode::F(12),
560 kind: event::KeyEventKind::Press,
561 ..
562 })
563 ) {
564 state.debug_mode = !state.debug_mode;
565 }
566 }
567 }
568
569 update_last_mouse_pos(&mut state, &events);
570
571 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
572 clear_frame_layout_cache(&mut state);
573 }
574
575 sleep_for_fps_cap(config.max_fps, frame_start);
576 }
577
578 Ok(())
579}
580
581fn run_frame<T: TerminalBackend>(
582 term: &mut T,
583 state: &mut FrameState,
584 config: &RunConfig,
585 events: &[event::Event],
586 f: &mut dyn FnMut(&mut context::Context),
587) -> io::Result<bool> {
588 let frame_start = Instant::now();
589 let (w, h) = term.size();
590 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
591 ctx.is_real_terminal = true;
592 ctx.process_focus_keys();
593
594 f(&mut ctx);
595
596 if ctx.should_quit {
597 return Ok(false);
598 }
599 state.prev_modal_active = ctx.modal_active;
600 let clipboard_text = ctx.clipboard_text.take();
601
602 let mut should_copy_selection = false;
603 for ev in &ctx.events {
604 if let Event::Mouse(mouse) = ev {
605 match mouse.kind {
606 event::MouseKind::Down(event::MouseButton::Left) => {
607 state
608 .selection
609 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
610 }
611 event::MouseKind::Drag(event::MouseButton::Left) => {
612 state
613 .selection
614 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
615 }
616 event::MouseKind::Up(event::MouseButton::Left) => {
617 should_copy_selection = state.selection.active;
618 }
619 _ => {}
620 }
621 }
622 }
623
624 state.focus_index = ctx.focus_index;
625 state.prev_focus_count = ctx.focus_count;
626
627 let mut tree = layout::build_tree(&ctx.commands);
628 let area = crate::rect::Rect::new(0, 0, w, h);
629 layout::compute(&mut tree, area);
630 let fd = layout::collect_all(&tree);
631 state.prev_scroll_infos = fd.scroll_infos;
632 state.prev_scroll_rects = fd.scroll_rects;
633 state.prev_hit_map = fd.hit_areas;
634 state.prev_group_rects = fd.group_rects;
635 state.prev_content_map = fd.content_areas;
636 state.prev_focus_rects = fd.focus_rects;
637 state.prev_focus_groups = fd.focus_groups;
638 layout::render(&tree, term.buffer_mut());
639 let raw_rects = layout::collect_raw_draw_rects(&tree);
640 for (draw_id, rect) in raw_rects {
641 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
642 let buf = term.buffer_mut();
643 buf.push_clip(rect);
644 cb(buf, rect);
645 buf.pop_clip();
646 }
647 }
648 state.hook_states = ctx.hook_states;
649
650 let frame_time = frame_start.elapsed();
651 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
652 let frame_secs = frame_time.as_secs_f32();
653 let inst_fps = if frame_secs > 0.0 {
654 1.0 / frame_secs
655 } else {
656 0.0
657 };
658 state.fps_ema = if state.fps_ema == 0.0 {
659 inst_fps
660 } else {
661 (state.fps_ema * 0.9) + (inst_fps * 0.1)
662 };
663 if state.debug_mode {
664 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
665 }
666
667 if state.selection.active {
668 terminal::apply_selection_overlay(
669 term.buffer_mut(),
670 &state.selection,
671 &state.prev_content_map,
672 );
673 }
674 if should_copy_selection {
675 let text = terminal::extract_selection_text(
676 term.buffer_mut(),
677 &state.selection,
678 &state.prev_content_map,
679 );
680 if !text.is_empty() {
681 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
682 }
683 state.selection.clear();
684 }
685
686 term.flush()?;
687 if let Some(text) = clipboard_text {
688 let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
689 }
690 state.tick = state.tick.wrapping_add(1);
691
692 Ok(true)
693}
694
695fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
696 for ev in events {
697 match ev {
698 Event::Mouse(mouse) => {
699 state.last_mouse_pos = Some((mouse.x, mouse.y));
700 }
701 Event::FocusLost => {
702 state.last_mouse_pos = None;
703 }
704 _ => {}
705 }
706 }
707}
708
709fn clear_frame_layout_cache(state: &mut FrameState) {
710 state.prev_hit_map.clear();
711 state.prev_group_rects.clear();
712 state.prev_content_map.clear();
713 state.prev_focus_rects.clear();
714 state.prev_focus_groups.clear();
715 state.prev_scroll_infos.clear();
716 state.prev_scroll_rects.clear();
717 state.last_mouse_pos = None;
718}
719
720fn is_ctrl_c(ev: &Event) -> bool {
721 matches!(
722 ev,
723 Event::Key(event::KeyEvent {
724 code: KeyCode::Char('c'),
725 modifiers,
726 kind: event::KeyEventKind::Press,
727 }) if modifiers.contains(KeyModifiers::CONTROL)
728 )
729}
730
731fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
732 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
733 let target = Duration::from_secs_f64(1.0 / fps as f64);
734 let elapsed = frame_start.elapsed();
735 if elapsed < target {
736 std::thread::sleep(target - elapsed);
737 }
738 }
739}