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};
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, StreamingMarkdownState, StreamingTextState, TableState,
90 TabsState, TextInputState, TextareaState, ToastLevel, ToastMessage, ToastState,
91 ToolApprovalState, TreeNode, TreeState, Trend,
92};
93
94pub trait Backend {
144 fn size(&self) -> (u32, u32);
146
147 fn buffer_mut(&mut self) -> &mut Buffer;
152
153 fn flush(&mut self) -> io::Result<()>;
159}
160
161pub struct AppState {
173 pub(crate) inner: FrameState,
174}
175
176impl AppState {
177 pub fn new() -> Self {
179 Self {
180 inner: FrameState::default(),
181 }
182 }
183
184 pub fn tick(&self) -> u64 {
186 self.inner.tick
187 }
188
189 pub fn fps(&self) -> f32 {
191 self.inner.fps_ema
192 }
193
194 pub fn set_debug(&mut self, enabled: bool) {
196 self.inner.debug_mode = enabled;
197 }
198}
199
200impl Default for AppState {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206pub fn frame(
235 backend: &mut impl Backend,
236 state: &mut AppState,
237 config: &RunConfig,
238 events: &[Event],
239 f: &mut impl FnMut(&mut Context),
240) -> io::Result<bool> {
241 run_frame(backend, &mut state.inner, config, events, f)
242}
243
244static PANIC_HOOK_ONCE: Once = Once::new();
245
246fn install_panic_hook() {
247 PANIC_HOOK_ONCE.call_once(|| {
248 let original = std::panic::take_hook();
249 std::panic::set_hook(Box::new(move |panic_info| {
250 let _ = crossterm::terminal::disable_raw_mode();
251 let mut stdout = io::stdout();
252 let _ = crossterm::execute!(
253 stdout,
254 crossterm::terminal::LeaveAlternateScreen,
255 crossterm::cursor::Show,
256 crossterm::event::DisableMouseCapture,
257 crossterm::event::DisableBracketedPaste,
258 crossterm::style::ResetColor,
259 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
260 );
261
262 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
264
265 if let Some(location) = panic_info.location() {
267 eprintln!(
268 "\x1b[90m{}:{}:{}\x1b[0m",
269 location.file(),
270 location.line(),
271 location.column()
272 );
273 }
274
275 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
277 eprintln!("\x1b[1m{}\x1b[0m", msg);
278 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
279 eprintln!("\x1b[1m{}\x1b[0m", msg);
280 }
281
282 eprintln!(
283 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
284 );
285
286 original(panic_info);
287 }));
288 });
289}
290
291#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
312pub struct RunConfig {
313 pub tick_rate: Duration,
318 pub mouse: bool,
323 pub kitty_keyboard: bool,
330 pub theme: Theme,
334 pub color_depth: Option<ColorDepth>,
340 pub max_fps: Option<u32>,
345}
346
347impl Default for RunConfig {
348 fn default() -> Self {
349 Self {
350 tick_rate: Duration::from_millis(16),
351 mouse: false,
352 kitty_keyboard: false,
353 theme: Theme::dark(),
354 color_depth: None,
355 max_fps: Some(60),
356 }
357 }
358}
359
360pub(crate) struct FrameState {
361 pub hook_states: Vec<Box<dyn std::any::Any>>,
362 pub focus_index: usize,
363 pub prev_focus_count: usize,
364 pub tick: u64,
365 pub prev_scroll_infos: Vec<(u32, u32)>,
366 pub prev_scroll_rects: Vec<rect::Rect>,
367 pub prev_hit_map: Vec<rect::Rect>,
368 pub prev_group_rects: Vec<(String, rect::Rect)>,
369 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
370 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
371 pub prev_focus_groups: Vec<Option<String>>,
372 pub last_mouse_pos: Option<(u32, u32)>,
373 pub prev_modal_active: bool,
374 pub notification_queue: Vec<(String, ToastLevel, u64)>,
375 pub debug_mode: bool,
376 pub fps_ema: f32,
377 pub selection: terminal::SelectionState,
378}
379
380impl Default for FrameState {
381 fn default() -> Self {
382 Self {
383 hook_states: Vec::new(),
384 focus_index: 0,
385 prev_focus_count: 0,
386 tick: 0,
387 prev_scroll_infos: Vec::new(),
388 prev_scroll_rects: Vec::new(),
389 prev_hit_map: Vec::new(),
390 prev_group_rects: Vec::new(),
391 prev_content_map: Vec::new(),
392 prev_focus_rects: Vec::new(),
393 prev_focus_groups: Vec::new(),
394 last_mouse_pos: None,
395 prev_modal_active: false,
396 notification_queue: Vec::new(),
397 debug_mode: false,
398 fps_ema: 0.0,
399 selection: terminal::SelectionState::default(),
400 }
401 }
402}
403
404pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
419 run_with(RunConfig::default(), f)
420}
421
422pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
442 if !io::stdout().is_terminal() {
443 return Ok(());
444 }
445
446 install_panic_hook();
447 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
448 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
449 if config.theme.bg != Color::Reset {
450 term.theme_bg = Some(config.theme.bg);
451 }
452 let mut events: Vec<Event> = Vec::new();
453 let mut state = FrameState::default();
454
455 loop {
456 let frame_start = Instant::now();
457 let (w, h) = term.size();
458 if w == 0 || h == 0 {
459 sleep_for_fps_cap(config.max_fps, frame_start);
460 continue;
461 }
462
463 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
464 break;
465 }
466
467 events.clear();
468 if crossterm::event::poll(config.tick_rate)? {
469 let raw = crossterm::event::read()?;
470 if let Some(ev) = event::from_crossterm(raw) {
471 if is_ctrl_c(&ev) {
472 break;
473 }
474 if let Event::Resize(_, _) = &ev {
475 term.handle_resize()?;
476 }
477 events.push(ev);
478 }
479
480 while crossterm::event::poll(Duration::ZERO)? {
481 let raw = crossterm::event::read()?;
482 if let Some(ev) = event::from_crossterm(raw) {
483 if is_ctrl_c(&ev) {
484 return Ok(());
485 }
486 if let Event::Resize(_, _) = &ev {
487 term.handle_resize()?;
488 }
489 events.push(ev);
490 }
491 }
492
493 for ev in &events {
494 if matches!(
495 ev,
496 Event::Key(event::KeyEvent {
497 code: KeyCode::F(12),
498 kind: event::KeyEventKind::Press,
499 ..
500 })
501 ) {
502 state.debug_mode = !state.debug_mode;
503 }
504 }
505 }
506
507 update_last_mouse_pos(&mut state, &events);
508
509 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
510 clear_frame_layout_cache(&mut state);
511 }
512
513 sleep_for_fps_cap(config.max_fps, frame_start);
514 }
515
516 Ok(())
517}
518
519#[cfg(feature = "async")]
540pub fn run_async<M: Send + 'static>(
541 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
542) -> io::Result<tokio::sync::mpsc::Sender<M>> {
543 run_async_with(RunConfig::default(), f)
544}
545
546#[cfg(feature = "async")]
553pub fn run_async_with<M: Send + 'static>(
554 config: RunConfig,
555 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
556) -> io::Result<tokio::sync::mpsc::Sender<M>> {
557 let (tx, rx) = tokio::sync::mpsc::channel(100);
558 let handle =
559 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
560
561 handle.spawn_blocking(move || {
562 let _ = run_async_loop(config, f, rx);
563 });
564
565 Ok(tx)
566}
567
568#[cfg(feature = "async")]
569fn run_async_loop<M: Send + 'static>(
570 config: RunConfig,
571 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
572 mut rx: tokio::sync::mpsc::Receiver<M>,
573) -> io::Result<()> {
574 if !io::stdout().is_terminal() {
575 return Ok(());
576 }
577
578 install_panic_hook();
579 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
580 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
581 if config.theme.bg != Color::Reset {
582 term.theme_bg = Some(config.theme.bg);
583 }
584 let mut events: Vec<Event> = Vec::new();
585 let mut state = FrameState::default();
586
587 loop {
588 let frame_start = Instant::now();
589 let mut messages: Vec<M> = Vec::new();
590 while let Ok(message) = rx.try_recv() {
591 messages.push(message);
592 }
593
594 let (w, h) = term.size();
595 if w == 0 || h == 0 {
596 sleep_for_fps_cap(config.max_fps, frame_start);
597 continue;
598 }
599
600 let mut render = |ctx: &mut Context| {
601 f(ctx, &mut messages);
602 };
603 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
604 break;
605 }
606
607 events.clear();
608 if crossterm::event::poll(config.tick_rate)? {
609 let raw = crossterm::event::read()?;
610 if let Some(ev) = event::from_crossterm(raw) {
611 if is_ctrl_c(&ev) {
612 break;
613 }
614 if let Event::Resize(_, _) = &ev {
615 term.handle_resize()?;
616 clear_frame_layout_cache(&mut state);
617 }
618 events.push(ev);
619 }
620
621 while crossterm::event::poll(Duration::ZERO)? {
622 let raw = crossterm::event::read()?;
623 if let Some(ev) = event::from_crossterm(raw) {
624 if is_ctrl_c(&ev) {
625 return Ok(());
626 }
627 if let Event::Resize(_, _) = &ev {
628 term.handle_resize()?;
629 clear_frame_layout_cache(&mut state);
630 }
631 events.push(ev);
632 }
633 }
634 }
635
636 update_last_mouse_pos(&mut state, &events);
637
638 sleep_for_fps_cap(config.max_fps, frame_start);
639 }
640
641 Ok(())
642}
643
644pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
660 run_inline_with(height, RunConfig::default(), f)
661}
662
663pub fn run_inline_with(
668 height: u32,
669 config: RunConfig,
670 mut f: impl FnMut(&mut Context),
671) -> io::Result<()> {
672 if !io::stdout().is_terminal() {
673 return Ok(());
674 }
675
676 install_panic_hook();
677 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
678 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
679 if config.theme.bg != Color::Reset {
680 term.theme_bg = Some(config.theme.bg);
681 }
682 let mut events: Vec<Event> = Vec::new();
683 let mut state = FrameState::default();
684
685 loop {
686 let frame_start = Instant::now();
687 let (w, h) = term.size();
688 if w == 0 || h == 0 {
689 sleep_for_fps_cap(config.max_fps, frame_start);
690 continue;
691 }
692
693 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
694 break;
695 }
696
697 events.clear();
698 if crossterm::event::poll(config.tick_rate)? {
699 let raw = crossterm::event::read()?;
700 if let Some(ev) = event::from_crossterm(raw) {
701 if is_ctrl_c(&ev) {
702 break;
703 }
704 if let Event::Resize(_, _) = &ev {
705 term.handle_resize()?;
706 }
707 events.push(ev);
708 }
709
710 while crossterm::event::poll(Duration::ZERO)? {
711 let raw = crossterm::event::read()?;
712 if let Some(ev) = event::from_crossterm(raw) {
713 if is_ctrl_c(&ev) {
714 return Ok(());
715 }
716 if let Event::Resize(_, _) = &ev {
717 term.handle_resize()?;
718 }
719 events.push(ev);
720 }
721 }
722
723 for ev in &events {
724 if matches!(
725 ev,
726 Event::Key(event::KeyEvent {
727 code: KeyCode::F(12),
728 kind: event::KeyEventKind::Press,
729 ..
730 })
731 ) {
732 state.debug_mode = !state.debug_mode;
733 }
734 }
735 }
736
737 update_last_mouse_pos(&mut state, &events);
738
739 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
740 clear_frame_layout_cache(&mut state);
741 }
742
743 sleep_for_fps_cap(config.max_fps, frame_start);
744 }
745
746 Ok(())
747}
748
749fn run_frame(
750 term: &mut impl Backend,
751 state: &mut FrameState,
752 config: &RunConfig,
753 events: &[event::Event],
754 f: &mut impl FnMut(&mut context::Context),
755) -> io::Result<bool> {
756 let frame_start = Instant::now();
757 let (w, h) = term.size();
758 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
759 ctx.is_real_terminal = true;
760 ctx.process_focus_keys();
761
762 f(&mut ctx);
763 ctx.render_notifications();
764
765 if ctx.should_quit {
766 return Ok(false);
767 }
768 state.prev_modal_active = ctx.modal_active;
769 let clipboard_text = ctx.clipboard_text.take();
770
771 let mut should_copy_selection = false;
772 for ev in &ctx.events {
773 if let Event::Mouse(mouse) = ev {
774 match mouse.kind {
775 event::MouseKind::Down(event::MouseButton::Left) => {
776 state
777 .selection
778 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
779 }
780 event::MouseKind::Drag(event::MouseButton::Left) => {
781 state
782 .selection
783 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
784 }
785 event::MouseKind::Up(event::MouseButton::Left) => {
786 should_copy_selection = state.selection.active;
787 }
788 _ => {}
789 }
790 }
791 }
792
793 state.focus_index = ctx.focus_index;
794 state.prev_focus_count = ctx.focus_count;
795
796 let mut tree = layout::build_tree(&ctx.commands);
797 let area = crate::rect::Rect::new(0, 0, w, h);
798 layout::compute(&mut tree, area);
799 let fd = layout::collect_all(&tree);
800 state.prev_scroll_infos = fd.scroll_infos;
801 state.prev_scroll_rects = fd.scroll_rects;
802 state.prev_hit_map = fd.hit_areas;
803 state.prev_group_rects = fd.group_rects;
804 state.prev_content_map = fd.content_areas;
805 state.prev_focus_rects = fd.focus_rects;
806 state.prev_focus_groups = fd.focus_groups;
807 layout::render(&tree, term.buffer_mut());
808 let raw_rects = layout::collect_raw_draw_rects(&tree);
809 for (draw_id, rect) in raw_rects {
810 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
811 let buf = term.buffer_mut();
812 buf.push_clip(rect);
813 cb(buf, rect);
814 buf.pop_clip();
815 }
816 }
817 state.hook_states = ctx.hook_states;
818 state.notification_queue = ctx.notification_queue;
819
820 let frame_time = frame_start.elapsed();
821 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
822 let frame_secs = frame_time.as_secs_f32();
823 let inst_fps = if frame_secs > 0.0 {
824 1.0 / frame_secs
825 } else {
826 0.0
827 };
828 state.fps_ema = if state.fps_ema == 0.0 {
829 inst_fps
830 } else {
831 (state.fps_ema * 0.9) + (inst_fps * 0.1)
832 };
833 if state.debug_mode {
834 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
835 }
836
837 if state.selection.active {
838 terminal::apply_selection_overlay(
839 term.buffer_mut(),
840 &state.selection,
841 &state.prev_content_map,
842 );
843 }
844 if should_copy_selection {
845 let text = terminal::extract_selection_text(
846 term.buffer_mut(),
847 &state.selection,
848 &state.prev_content_map,
849 );
850 if !text.is_empty() {
851 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
852 }
853 state.selection.clear();
854 }
855
856 term.flush()?;
857 if let Some(text) = clipboard_text {
858 let _ = terminal::copy_to_clipboard(&mut io::stdout(), &text);
859 }
860 state.tick = state.tick.wrapping_add(1);
861
862 Ok(true)
863}
864
865fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
866 for ev in events {
867 match ev {
868 Event::Mouse(mouse) => {
869 state.last_mouse_pos = Some((mouse.x, mouse.y));
870 }
871 Event::FocusLost => {
872 state.last_mouse_pos = None;
873 }
874 _ => {}
875 }
876 }
877}
878
879fn clear_frame_layout_cache(state: &mut FrameState) {
880 state.prev_hit_map.clear();
881 state.prev_group_rects.clear();
882 state.prev_content_map.clear();
883 state.prev_focus_rects.clear();
884 state.prev_focus_groups.clear();
885 state.prev_scroll_infos.clear();
886 state.prev_scroll_rects.clear();
887 state.last_mouse_pos = None;
888}
889
890fn is_ctrl_c(ev: &Event) -> bool {
891 matches!(
892 ev,
893 Event::Key(event::KeyEvent {
894 code: KeyCode::Char('c'),
895 modifiers,
896 kind: event::KeyEventKind::Press,
897 }) if modifiers.contains(KeyModifiers::CONTROL)
898 )
899}
900
901fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
902 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
903 let target = Duration::from_secs_f64(1.0 / fps as f64);
904 let elapsed = frame_start.elapsed();
905 if elapsed < target {
906 std::thread::sleep(target - elapsed);
907 }
908 }
909}