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