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