1pub mod anim;
39pub mod buffer;
40pub mod cell;
41pub mod chart;
42pub mod context;
43pub mod event;
44pub mod layout;
45pub mod rect;
46pub mod style;
47mod terminal;
48pub mod test_utils;
49pub mod widgets;
50
51use std::io;
52use std::io::IsTerminal;
53use std::sync::Once;
54use std::time::{Duration, Instant};
55
56use terminal::{InlineTerminal, Terminal};
57
58pub use crate::test_utils::{EventBuilder, TestBackend};
59pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
60pub use chart::{
61 Axis, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
62 HistogramBuilder, LegendPosition, Marker,
63};
64pub use context::{Bar, BarDirection, BarGroup, CanvasContext, Context, Response, Widget};
65pub use event::{Event, KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseKind};
66pub use style::{
67 Align, Border, BorderSides, Color, Constraints, Justify, Margin, Modifiers, Padding, Style,
68 Theme,
69};
70pub use widgets::{
71 ButtonVariant, CommandPaletteState, FormField, FormState, ListState, MultiSelectState,
72 PaletteCommand, RadioState, ScrollState, SelectState, SpinnerState, TableState, TabsState,
73 TextInputState, TextareaState, ToastLevel, ToastMessage, ToastState, TreeNode, TreeState,
74};
75
76static PANIC_HOOK_ONCE: Once = Once::new();
77
78fn install_panic_hook() {
79 PANIC_HOOK_ONCE.call_once(|| {
80 let original = std::panic::take_hook();
81 std::panic::set_hook(Box::new(move |panic_info| {
82 let _ = crossterm::terminal::disable_raw_mode();
83 let mut stdout = io::stdout();
84 let _ = crossterm::execute!(
85 stdout,
86 crossterm::terminal::LeaveAlternateScreen,
87 crossterm::cursor::Show,
88 crossterm::event::DisableMouseCapture,
89 crossterm::event::DisableBracketedPaste,
90 crossterm::style::ResetColor,
91 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
92 );
93
94 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
96
97 if let Some(location) = panic_info.location() {
99 eprintln!(
100 "\x1b[90m{}:{}:{}\x1b[0m",
101 location.file(),
102 location.line(),
103 location.column()
104 );
105 }
106
107 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
109 eprintln!("\x1b[1m{}\x1b[0m", msg);
110 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
111 eprintln!("\x1b[1m{}\x1b[0m", msg);
112 }
113
114 eprintln!(
115 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
116 );
117
118 original(panic_info);
119 }));
120 });
121}
122
123#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
142pub struct RunConfig {
143 pub tick_rate: Duration,
148 pub mouse: bool,
153 pub theme: Theme,
157 pub max_fps: Option<u32>,
162}
163
164impl Default for RunConfig {
165 fn default() -> Self {
166 Self {
167 tick_rate: Duration::from_millis(16),
168 mouse: false,
169 theme: Theme::dark(),
170 max_fps: Some(60),
171 }
172 }
173}
174
175pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
190 run_with(RunConfig::default(), f)
191}
192
193pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
213 if !io::stdout().is_terminal() {
214 return Ok(());
215 }
216
217 install_panic_hook();
218 let mut term = Terminal::new(config.mouse)?;
219 let mut events: Vec<Event> = Vec::new();
220 let mut debug_mode: bool = false;
221 let mut tick: u64 = 0;
222 let mut focus_index: usize = 0;
223 let mut prev_focus_count: usize = 0;
224 let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
225 let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
226 let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
227 let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
228 let mut last_mouse_pos: Option<(u32, u32)> = None;
229 let mut prev_modal_active = false;
230 let mut selection = terminal::SelectionState::default();
231
232 loop {
233 let frame_start = Instant::now();
234 let (w, h) = term.size();
235 if w == 0 || h == 0 {
236 sleep_for_fps_cap(config.max_fps, frame_start);
237 continue;
238 }
239 let mut ctx = Context::new(
240 std::mem::take(&mut events),
241 w,
242 h,
243 tick,
244 focus_index,
245 prev_focus_count,
246 std::mem::take(&mut prev_scroll_infos),
247 std::mem::take(&mut prev_hit_map),
248 std::mem::take(&mut prev_focus_rects),
249 debug_mode,
250 config.theme,
251 last_mouse_pos,
252 prev_modal_active,
253 );
254 ctx.process_focus_keys();
255
256 f(&mut ctx);
257
258 if ctx.should_quit {
259 break;
260 }
261 prev_modal_active = ctx.modal_active;
262
263 let mut should_copy_selection = false;
264 for ev in ctx.events.iter() {
265 if let Event::Mouse(mouse) = ev {
266 match mouse.kind {
267 event::MouseKind::Down(event::MouseButton::Left) => {
268 selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
269 }
270 event::MouseKind::Drag(event::MouseButton::Left) => {
271 selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
272 }
273 event::MouseKind::Up(event::MouseButton::Left) => {
274 should_copy_selection = selection.active;
275 }
276 _ => {}
277 }
278 }
279 }
280
281 focus_index = ctx.focus_index;
282 prev_focus_count = ctx.focus_count;
283
284 let mut tree = layout::build_tree(&ctx.commands);
285 let area = crate::rect::Rect::new(0, 0, w, h);
286 layout::compute(&mut tree, area);
287 prev_scroll_infos = layout::collect_scroll_infos(&tree);
288 prev_hit_map = layout::collect_hit_areas(&tree);
289 prev_content_map = layout::collect_content_areas(&tree);
290 prev_focus_rects = layout::collect_focus_rects(&tree);
291 layout::render(&tree, term.buffer_mut());
292 if debug_mode {
293 layout::render_debug_overlay(&tree, term.buffer_mut());
294 }
295
296 if selection.active {
297 terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
298 }
299 if should_copy_selection {
300 let text =
301 terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
302 if !text.is_empty() {
303 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
304 }
305 selection.clear();
306 }
307
308 term.flush()?;
309 tick = tick.wrapping_add(1);
310
311 events.clear();
312 if crossterm::event::poll(config.tick_rate)? {
313 let raw = crossterm::event::read()?;
314 if let Some(ev) = event::from_crossterm(raw) {
315 if is_ctrl_c(&ev) {
316 break;
317 }
318 if let Event::Resize(_, _) = &ev {
319 term.handle_resize()?;
320 }
321 events.push(ev);
322 }
323
324 while crossterm::event::poll(Duration::ZERO)? {
325 let raw = crossterm::event::read()?;
326 if let Some(ev) = event::from_crossterm(raw) {
327 if is_ctrl_c(&ev) {
328 return Ok(());
329 }
330 if let Event::Resize(_, _) = &ev {
331 term.handle_resize()?;
332 }
333 events.push(ev);
334 }
335 }
336
337 for ev in &events {
338 if matches!(
339 ev,
340 Event::Key(event::KeyEvent {
341 code: KeyCode::F(12),
342 ..
343 })
344 ) {
345 debug_mode = !debug_mode;
346 }
347 }
348 }
349
350 for ev in &events {
351 match ev {
352 Event::Mouse(mouse) => {
353 last_mouse_pos = Some((mouse.x, mouse.y));
354 }
355 Event::FocusLost => {
356 last_mouse_pos = None;
357 }
358 _ => {}
359 }
360 }
361
362 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
363 prev_hit_map.clear();
364 prev_content_map.clear();
365 prev_focus_rects.clear();
366 prev_scroll_infos.clear();
367 last_mouse_pos = None;
368 }
369
370 sleep_for_fps_cap(config.max_fps, frame_start);
371 }
372
373 Ok(())
374}
375
376#[cfg(feature = "async")]
397pub fn run_async<M: Send + 'static>(
398 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
399) -> io::Result<tokio::sync::mpsc::Sender<M>> {
400 run_async_with(RunConfig::default(), f)
401}
402
403#[cfg(feature = "async")]
410pub fn run_async_with<M: Send + 'static>(
411 config: RunConfig,
412 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
413) -> io::Result<tokio::sync::mpsc::Sender<M>> {
414 let (tx, rx) = tokio::sync::mpsc::channel(100);
415 let handle =
416 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
417
418 handle.spawn_blocking(move || {
419 let _ = run_async_loop(config, f, rx);
420 });
421
422 Ok(tx)
423}
424
425#[cfg(feature = "async")]
426fn run_async_loop<M: Send + 'static>(
427 config: RunConfig,
428 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
429 mut rx: tokio::sync::mpsc::Receiver<M>,
430) -> io::Result<()> {
431 if !io::stdout().is_terminal() {
432 return Ok(());
433 }
434
435 install_panic_hook();
436 let mut term = Terminal::new(config.mouse)?;
437 let mut events: Vec<Event> = Vec::new();
438 let mut tick: u64 = 0;
439 let mut focus_index: usize = 0;
440 let mut prev_focus_count: usize = 0;
441 let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
442 let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
443 let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
444 let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
445 let mut last_mouse_pos: Option<(u32, u32)> = None;
446 let mut prev_modal_active = false;
447 let mut selection = terminal::SelectionState::default();
448
449 loop {
450 let frame_start = Instant::now();
451 let mut messages: Vec<M> = Vec::new();
452 while let Ok(message) = rx.try_recv() {
453 messages.push(message);
454 }
455
456 let (w, h) = term.size();
457 if w == 0 || h == 0 {
458 sleep_for_fps_cap(config.max_fps, frame_start);
459 continue;
460 }
461 let mut ctx = Context::new(
462 std::mem::take(&mut events),
463 w,
464 h,
465 tick,
466 focus_index,
467 prev_focus_count,
468 std::mem::take(&mut prev_scroll_infos),
469 std::mem::take(&mut prev_hit_map),
470 std::mem::take(&mut prev_focus_rects),
471 false,
472 config.theme,
473 last_mouse_pos,
474 prev_modal_active,
475 );
476 ctx.process_focus_keys();
477
478 f(&mut ctx, &mut messages);
479
480 if ctx.should_quit {
481 break;
482 }
483 prev_modal_active = ctx.modal_active;
484
485 let mut should_copy_selection = false;
486 for ev in ctx.events.iter() {
487 if let Event::Mouse(mouse) = ev {
488 match mouse.kind {
489 event::MouseKind::Down(event::MouseButton::Left) => {
490 selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
491 }
492 event::MouseKind::Drag(event::MouseButton::Left) => {
493 selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
494 }
495 event::MouseKind::Up(event::MouseButton::Left) => {
496 should_copy_selection = selection.active;
497 }
498 _ => {}
499 }
500 }
501 }
502
503 focus_index = ctx.focus_index;
504 prev_focus_count = ctx.focus_count;
505
506 let mut tree = layout::build_tree(&ctx.commands);
507 let area = crate::rect::Rect::new(0, 0, w, h);
508 layout::compute(&mut tree, area);
509 prev_scroll_infos = layout::collect_scroll_infos(&tree);
510 prev_hit_map = layout::collect_hit_areas(&tree);
511 prev_content_map = layout::collect_content_areas(&tree);
512 prev_focus_rects = layout::collect_focus_rects(&tree);
513 layout::render(&tree, term.buffer_mut());
514
515 if selection.active {
516 terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
517 }
518 if should_copy_selection {
519 let text =
520 terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
521 if !text.is_empty() {
522 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
523 }
524 selection.clear();
525 }
526
527 term.flush()?;
528 tick = tick.wrapping_add(1);
529
530 events.clear();
531 if crossterm::event::poll(config.tick_rate)? {
532 let raw = crossterm::event::read()?;
533 if let Some(ev) = event::from_crossterm(raw) {
534 if is_ctrl_c(&ev) {
535 break;
536 }
537 if let Event::Resize(_, _) = &ev {
538 term.handle_resize()?;
539 prev_hit_map.clear();
540 prev_content_map.clear();
541 prev_focus_rects.clear();
542 prev_scroll_infos.clear();
543 last_mouse_pos = None;
544 }
545 events.push(ev);
546 }
547
548 while crossterm::event::poll(Duration::ZERO)? {
549 let raw = crossterm::event::read()?;
550 if let Some(ev) = event::from_crossterm(raw) {
551 if is_ctrl_c(&ev) {
552 return Ok(());
553 }
554 if let Event::Resize(_, _) = &ev {
555 term.handle_resize()?;
556 prev_hit_map.clear();
557 prev_content_map.clear();
558 prev_focus_rects.clear();
559 prev_scroll_infos.clear();
560 last_mouse_pos = None;
561 }
562 events.push(ev);
563 }
564 }
565 }
566
567 for ev in &events {
568 match ev {
569 Event::Mouse(mouse) => {
570 last_mouse_pos = Some((mouse.x, mouse.y));
571 }
572 Event::FocusLost => {
573 last_mouse_pos = None;
574 }
575 _ => {}
576 }
577 }
578
579 sleep_for_fps_cap(config.max_fps, frame_start);
580 }
581
582 Ok(())
583}
584
585pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
601 run_inline_with(height, RunConfig::default(), f)
602}
603
604pub fn run_inline_with(
609 height: u32,
610 config: RunConfig,
611 mut f: impl FnMut(&mut Context),
612) -> io::Result<()> {
613 if !io::stdout().is_terminal() {
614 return Ok(());
615 }
616
617 install_panic_hook();
618 let mut term = InlineTerminal::new(height, config.mouse)?;
619 let mut events: Vec<Event> = Vec::new();
620 let mut debug_mode: bool = false;
621 let mut tick: u64 = 0;
622 let mut focus_index: usize = 0;
623 let mut prev_focus_count: usize = 0;
624 let mut prev_scroll_infos: Vec<(u32, u32)> = Vec::new();
625 let mut prev_hit_map: Vec<rect::Rect> = Vec::new();
626 let mut prev_content_map: Vec<(rect::Rect, rect::Rect)> = Vec::new();
627 let mut prev_focus_rects: Vec<(usize, rect::Rect)> = Vec::new();
628 let mut last_mouse_pos: Option<(u32, u32)> = None;
629 let mut prev_modal_active = false;
630 let mut selection = terminal::SelectionState::default();
631
632 loop {
633 let frame_start = Instant::now();
634 let (w, h) = term.size();
635 if w == 0 || h == 0 {
636 sleep_for_fps_cap(config.max_fps, frame_start);
637 continue;
638 }
639 let mut ctx = Context::new(
640 std::mem::take(&mut events),
641 w,
642 h,
643 tick,
644 focus_index,
645 prev_focus_count,
646 std::mem::take(&mut prev_scroll_infos),
647 std::mem::take(&mut prev_hit_map),
648 std::mem::take(&mut prev_focus_rects),
649 debug_mode,
650 config.theme,
651 last_mouse_pos,
652 prev_modal_active,
653 );
654 ctx.process_focus_keys();
655
656 f(&mut ctx);
657
658 if ctx.should_quit {
659 break;
660 }
661 prev_modal_active = ctx.modal_active;
662
663 let mut should_copy_selection = false;
664 for ev in ctx.events.iter() {
665 if let Event::Mouse(mouse) = ev {
666 match mouse.kind {
667 event::MouseKind::Down(event::MouseButton::Left) => {
668 selection.mouse_down(mouse.x, mouse.y, &prev_content_map);
669 }
670 event::MouseKind::Drag(event::MouseButton::Left) => {
671 selection.mouse_drag(mouse.x, mouse.y, &prev_content_map);
672 }
673 event::MouseKind::Up(event::MouseButton::Left) => {
674 should_copy_selection = selection.active;
675 }
676 _ => {}
677 }
678 }
679 }
680
681 focus_index = ctx.focus_index;
682 prev_focus_count = ctx.focus_count;
683
684 let mut tree = layout::build_tree(&ctx.commands);
685 let area = crate::rect::Rect::new(0, 0, w, h);
686 layout::compute(&mut tree, area);
687 prev_scroll_infos = layout::collect_scroll_infos(&tree);
688 prev_hit_map = layout::collect_hit_areas(&tree);
689 prev_content_map = layout::collect_content_areas(&tree);
690 prev_focus_rects = layout::collect_focus_rects(&tree);
691 layout::render(&tree, term.buffer_mut());
692 if debug_mode {
693 layout::render_debug_overlay(&tree, term.buffer_mut());
694 }
695
696 if selection.active {
697 terminal::apply_selection_overlay(term.buffer_mut(), &selection, &prev_content_map);
698 }
699 if should_copy_selection {
700 let text =
701 terminal::extract_selection_text(term.buffer_mut(), &selection, &prev_content_map);
702 if !text.is_empty() {
703 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
704 }
705 selection.clear();
706 }
707
708 term.flush()?;
709 tick = tick.wrapping_add(1);
710
711 events.clear();
712 if crossterm::event::poll(config.tick_rate)? {
713 let raw = crossterm::event::read()?;
714 if let Some(ev) = event::from_crossterm(raw) {
715 if is_ctrl_c(&ev) {
716 break;
717 }
718 if let Event::Resize(_, _) = &ev {
719 term.handle_resize()?;
720 }
721 events.push(ev);
722 }
723
724 while crossterm::event::poll(Duration::ZERO)? {
725 let raw = crossterm::event::read()?;
726 if let Some(ev) = event::from_crossterm(raw) {
727 if is_ctrl_c(&ev) {
728 return Ok(());
729 }
730 if let Event::Resize(_, _) = &ev {
731 term.handle_resize()?;
732 }
733 events.push(ev);
734 }
735 }
736
737 for ev in &events {
738 if matches!(
739 ev,
740 Event::Key(event::KeyEvent {
741 code: KeyCode::F(12),
742 ..
743 })
744 ) {
745 debug_mode = !debug_mode;
746 }
747 }
748 }
749
750 for ev in &events {
751 match ev {
752 Event::Mouse(mouse) => {
753 last_mouse_pos = Some((mouse.x, mouse.y));
754 }
755 Event::FocusLost => {
756 last_mouse_pos = None;
757 }
758 _ => {}
759 }
760 }
761
762 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
763 prev_hit_map.clear();
764 prev_content_map.clear();
765 prev_focus_rects.clear();
766 prev_scroll_infos.clear();
767 last_mouse_pos = None;
768 }
769
770 sleep_for_fps_cap(config.max_fps, frame_start);
771 }
772
773 Ok(())
774}
775
776fn is_ctrl_c(ev: &Event) -> bool {
777 matches!(
778 ev,
779 Event::Key(event::KeyEvent {
780 code: KeyCode::Char('c'),
781 modifiers,
782 }) if modifiers.contains(KeyModifiers::CONTROL)
783 )
784}
785
786fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
787 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
788 let target = Duration::from_secs_f64(1.0 / fps as f64);
789 let elapsed = frame_start.elapsed();
790 if elapsed < target {
791 std::thread::sleep(target - elapsed);
792 }
793 }
794}