1#![forbid(unsafe_code)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
5#![warn(rustdoc::broken_intra_doc_links)]
6#![warn(rustdoc::private_intra_doc_links)]
7#![deny(clippy::unwrap_in_result)]
9#![warn(clippy::unwrap_used)]
10#![warn(clippy::dbg_macro)]
12#![warn(clippy::print_stdout)]
13#![warn(clippy::print_stderr)]
14
15pub mod anim;
53pub mod buffer;
54pub mod cell;
55pub mod chart;
56pub mod context;
57pub mod event;
58pub mod halfblock;
59pub mod keymap;
60pub mod layout;
61pub mod palette;
62pub mod rect;
63pub mod style;
64mod terminal;
65pub mod test_utils;
66pub mod widgets;
67
68use std::io;
69use std::io::IsTerminal;
70use std::sync::Once;
71use std::time::{Duration, Instant};
72
73use terminal::{InlineTerminal, Terminal};
74
75pub use crate::test_utils::{EventBuilder, TestBackend};
76pub use anim::{
77 ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
78 ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
79 Stagger, Tween,
80};
81pub use buffer::Buffer;
82pub use cell::Cell;
83pub use chart::{
84 Axis, Candle, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
85 HistogramBuilder, LegendPosition, Marker,
86};
87pub use context::{
88 Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
89 Response, State, Widget,
90};
91pub use event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind};
92pub use halfblock::HalfBlockImage;
93pub use keymap::{Binding, KeyMap};
94pub use layout::Direction;
95pub use palette::Palette;
96pub use rect::Rect;
97pub use style::{
98 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
99 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
100};
101pub use widgets::{
102 AlertLevel, ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FileEntry,
103 FilePickerState, FormField, FormState, ListState, MultiSelectState, PaletteCommand, RadioState,
104 ScrollState, SelectState, SpinnerState, StreamingMarkdownState, StreamingTextState, TableState,
105 TabsState, TextInputState, TextareaState, ToastLevel, ToastMessage, ToastState,
106 ToolApprovalState, TreeNode, TreeState, Trend,
107};
108
109pub trait Backend {
159 fn size(&self) -> (u32, u32);
161
162 fn buffer_mut(&mut self) -> &mut Buffer;
167
168 fn flush(&mut self) -> io::Result<()>;
174}
175
176pub struct AppState {
188 pub(crate) inner: FrameState,
189}
190
191impl AppState {
192 pub fn new() -> Self {
194 Self {
195 inner: FrameState::default(),
196 }
197 }
198
199 pub fn tick(&self) -> u64 {
201 self.inner.tick
202 }
203
204 pub fn fps(&self) -> f32 {
206 self.inner.fps_ema
207 }
208
209 pub fn set_debug(&mut self, enabled: bool) {
211 self.inner.debug_mode = enabled;
212 }
213}
214
215impl Default for AppState {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221pub fn frame(
250 backend: &mut impl Backend,
251 state: &mut AppState,
252 config: &RunConfig,
253 events: &[Event],
254 f: &mut impl FnMut(&mut Context),
255) -> io::Result<bool> {
256 run_frame(backend, &mut state.inner, config, events, f)
257}
258
259static PANIC_HOOK_ONCE: Once = Once::new();
260
261#[allow(clippy::print_stderr)]
262fn install_panic_hook() {
263 PANIC_HOOK_ONCE.call_once(|| {
264 let original = std::panic::take_hook();
265 std::panic::set_hook(Box::new(move |panic_info| {
266 let _ = crossterm::terminal::disable_raw_mode();
267 let mut stdout = io::stdout();
268 let _ = crossterm::execute!(
269 stdout,
270 crossterm::terminal::LeaveAlternateScreen,
271 crossterm::cursor::Show,
272 crossterm::event::DisableMouseCapture,
273 crossterm::event::DisableBracketedPaste,
274 crossterm::style::ResetColor,
275 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
276 );
277
278 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
280
281 if let Some(location) = panic_info.location() {
283 eprintln!(
284 "\x1b[90m{}:{}:{}\x1b[0m",
285 location.file(),
286 location.line(),
287 location.column()
288 );
289 }
290
291 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
293 eprintln!("\x1b[1m{}\x1b[0m", msg);
294 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
295 eprintln!("\x1b[1m{}\x1b[0m", msg);
296 }
297
298 eprintln!(
299 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
300 );
301
302 original(panic_info);
303 }));
304 });
305}
306
307#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
328pub struct RunConfig {
329 pub tick_rate: Duration,
334 pub mouse: bool,
339 pub kitty_keyboard: bool,
346 pub theme: Theme,
350 pub color_depth: Option<ColorDepth>,
356 pub max_fps: Option<u32>,
361}
362
363impl Default for RunConfig {
364 fn default() -> Self {
365 Self {
366 tick_rate: Duration::from_millis(16),
367 mouse: false,
368 kitty_keyboard: false,
369 theme: Theme::dark(),
370 color_depth: None,
371 max_fps: Some(60),
372 }
373 }
374}
375
376pub(crate) struct FrameState {
377 pub hook_states: Vec<Box<dyn std::any::Any>>,
378 pub focus_index: usize,
379 pub prev_focus_count: usize,
380 pub prev_modal_focus_start: usize,
381 pub prev_modal_focus_count: usize,
382 pub tick: u64,
383 pub prev_scroll_infos: Vec<(u32, u32)>,
384 pub prev_scroll_rects: Vec<rect::Rect>,
385 pub prev_hit_map: Vec<rect::Rect>,
386 pub prev_group_rects: Vec<(String, rect::Rect)>,
387 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
388 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
389 pub prev_focus_groups: Vec<Option<String>>,
390 pub last_mouse_pos: Option<(u32, u32)>,
391 pub prev_modal_active: bool,
392 pub notification_queue: Vec<(String, ToastLevel, u64)>,
393 pub debug_mode: bool,
394 pub fps_ema: f32,
395 pub selection: terminal::SelectionState,
396}
397
398impl Default for FrameState {
399 fn default() -> Self {
400 Self {
401 hook_states: Vec::new(),
402 focus_index: 0,
403 prev_focus_count: 0,
404 prev_modal_focus_start: 0,
405 prev_modal_focus_count: 0,
406 tick: 0,
407 prev_scroll_infos: Vec::new(),
408 prev_scroll_rects: Vec::new(),
409 prev_hit_map: Vec::new(),
410 prev_group_rects: Vec::new(),
411 prev_content_map: Vec::new(),
412 prev_focus_rects: Vec::new(),
413 prev_focus_groups: Vec::new(),
414 last_mouse_pos: None,
415 prev_modal_active: false,
416 notification_queue: Vec::new(),
417 debug_mode: false,
418 fps_ema: 0.0,
419 selection: terminal::SelectionState::default(),
420 }
421 }
422}
423
424pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
439 run_with(RunConfig::default(), f)
440}
441
442pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
462 if !io::stdout().is_terminal() {
463 return Ok(());
464 }
465
466 install_panic_hook();
467 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
468 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
469 if config.theme.bg != Color::Reset {
470 term.theme_bg = Some(config.theme.bg);
471 }
472 let mut events: Vec<Event> = Vec::new();
473 let mut state = FrameState::default();
474
475 loop {
476 let frame_start = Instant::now();
477 let (w, h) = term.size();
478 if w == 0 || h == 0 {
479 sleep_for_fps_cap(config.max_fps, frame_start);
480 continue;
481 }
482
483 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
484 break;
485 }
486
487 events.clear();
488 if crossterm::event::poll(config.tick_rate)? {
489 let raw = crossterm::event::read()?;
490 if let Some(ev) = event::from_crossterm(raw) {
491 if is_ctrl_c(&ev) {
492 break;
493 }
494 if let Event::Resize(_, _) = &ev {
495 term.handle_resize()?;
496 }
497 events.push(ev);
498 }
499
500 while crossterm::event::poll(Duration::ZERO)? {
501 let raw = crossterm::event::read()?;
502 if let Some(ev) = event::from_crossterm(raw) {
503 if is_ctrl_c(&ev) {
504 return Ok(());
505 }
506 if let Event::Resize(_, _) = &ev {
507 term.handle_resize()?;
508 }
509 events.push(ev);
510 }
511 }
512
513 for ev in &events {
514 if matches!(
515 ev,
516 Event::Key(event::KeyEvent {
517 code: KeyCode::F(12),
518 kind: event::KeyEventKind::Press,
519 ..
520 })
521 ) {
522 state.debug_mode = !state.debug_mode;
523 }
524 }
525 }
526
527 update_last_mouse_pos(&mut state, &events);
528
529 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
530 clear_frame_layout_cache(&mut state);
531 }
532
533 sleep_for_fps_cap(config.max_fps, frame_start);
534 }
535
536 Ok(())
537}
538
539#[cfg(feature = "async")]
560pub fn run_async<M: Send + 'static>(
561 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
562) -> io::Result<tokio::sync::mpsc::Sender<M>> {
563 run_async_with(RunConfig::default(), f)
564}
565
566#[cfg(feature = "async")]
573pub fn run_async_with<M: Send + 'static>(
574 config: RunConfig,
575 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
576) -> io::Result<tokio::sync::mpsc::Sender<M>> {
577 let (tx, rx) = tokio::sync::mpsc::channel(100);
578 let handle =
579 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
580
581 handle.spawn_blocking(move || {
582 let _ = run_async_loop(config, f, rx);
583 });
584
585 Ok(tx)
586}
587
588#[cfg(feature = "async")]
589fn run_async_loop<M: Send + 'static>(
590 config: RunConfig,
591 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
592 mut rx: tokio::sync::mpsc::Receiver<M>,
593) -> io::Result<()> {
594 if !io::stdout().is_terminal() {
595 return Ok(());
596 }
597
598 install_panic_hook();
599 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
600 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
601 if config.theme.bg != Color::Reset {
602 term.theme_bg = Some(config.theme.bg);
603 }
604 let mut events: Vec<Event> = Vec::new();
605 let mut state = FrameState::default();
606
607 loop {
608 let frame_start = Instant::now();
609 let mut messages: Vec<M> = Vec::new();
610 while let Ok(message) = rx.try_recv() {
611 messages.push(message);
612 }
613
614 let (w, h) = term.size();
615 if w == 0 || h == 0 {
616 sleep_for_fps_cap(config.max_fps, frame_start);
617 continue;
618 }
619
620 let mut render = |ctx: &mut Context| {
621 f(ctx, &mut messages);
622 };
623 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
624 break;
625 }
626
627 events.clear();
628 if crossterm::event::poll(config.tick_rate)? {
629 let raw = crossterm::event::read()?;
630 if let Some(ev) = event::from_crossterm(raw) {
631 if is_ctrl_c(&ev) {
632 break;
633 }
634 if let Event::Resize(_, _) = &ev {
635 term.handle_resize()?;
636 clear_frame_layout_cache(&mut state);
637 }
638 events.push(ev);
639 }
640
641 while crossterm::event::poll(Duration::ZERO)? {
642 let raw = crossterm::event::read()?;
643 if let Some(ev) = event::from_crossterm(raw) {
644 if is_ctrl_c(&ev) {
645 return Ok(());
646 }
647 if let Event::Resize(_, _) = &ev {
648 term.handle_resize()?;
649 clear_frame_layout_cache(&mut state);
650 }
651 events.push(ev);
652 }
653 }
654 }
655
656 update_last_mouse_pos(&mut state, &events);
657
658 sleep_for_fps_cap(config.max_fps, frame_start);
659 }
660
661 Ok(())
662}
663
664pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
680 run_inline_with(height, RunConfig::default(), f)
681}
682
683pub fn run_inline_with(
688 height: u32,
689 config: RunConfig,
690 mut f: impl FnMut(&mut Context),
691) -> io::Result<()> {
692 if !io::stdout().is_terminal() {
693 return Ok(());
694 }
695
696 install_panic_hook();
697 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
698 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
699 if config.theme.bg != Color::Reset {
700 term.theme_bg = Some(config.theme.bg);
701 }
702 let mut events: Vec<Event> = Vec::new();
703 let mut state = FrameState::default();
704
705 loop {
706 let frame_start = Instant::now();
707 let (w, h) = term.size();
708 if w == 0 || h == 0 {
709 sleep_for_fps_cap(config.max_fps, frame_start);
710 continue;
711 }
712
713 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
714 break;
715 }
716
717 events.clear();
718 if crossterm::event::poll(config.tick_rate)? {
719 let raw = crossterm::event::read()?;
720 if let Some(ev) = event::from_crossterm(raw) {
721 if is_ctrl_c(&ev) {
722 break;
723 }
724 if let Event::Resize(_, _) = &ev {
725 term.handle_resize()?;
726 }
727 events.push(ev);
728 }
729
730 while crossterm::event::poll(Duration::ZERO)? {
731 let raw = crossterm::event::read()?;
732 if let Some(ev) = event::from_crossterm(raw) {
733 if is_ctrl_c(&ev) {
734 return Ok(());
735 }
736 if let Event::Resize(_, _) = &ev {
737 term.handle_resize()?;
738 }
739 events.push(ev);
740 }
741 }
742
743 for ev in &events {
744 if matches!(
745 ev,
746 Event::Key(event::KeyEvent {
747 code: KeyCode::F(12),
748 kind: event::KeyEventKind::Press,
749 ..
750 })
751 ) {
752 state.debug_mode = !state.debug_mode;
753 }
754 }
755 }
756
757 update_last_mouse_pos(&mut state, &events);
758
759 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
760 clear_frame_layout_cache(&mut state);
761 }
762
763 sleep_for_fps_cap(config.max_fps, frame_start);
764 }
765
766 Ok(())
767}
768
769fn run_frame(
770 term: &mut impl Backend,
771 state: &mut FrameState,
772 config: &RunConfig,
773 events: &[event::Event],
774 f: &mut impl FnMut(&mut context::Context),
775) -> io::Result<bool> {
776 let frame_start = Instant::now();
777 let (w, h) = term.size();
778 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
779 ctx.is_real_terminal = true;
780 ctx.process_focus_keys();
781
782 f(&mut ctx);
783 ctx.render_notifications();
784
785 if ctx.should_quit {
786 return Ok(false);
787 }
788 state.prev_modal_active = ctx.modal_active;
789 state.prev_modal_focus_start = ctx.modal_focus_start;
790 state.prev_modal_focus_count = ctx.modal_focus_count;
791 let clipboard_text = ctx.clipboard_text.take();
792
793 let mut should_copy_selection = false;
794 for ev in &ctx.events {
795 if let Event::Mouse(mouse) = ev {
796 match mouse.kind {
797 event::MouseKind::Down(event::MouseButton::Left) => {
798 state
799 .selection
800 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
801 }
802 event::MouseKind::Drag(event::MouseButton::Left) => {
803 state
804 .selection
805 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
806 }
807 event::MouseKind::Up(event::MouseButton::Left) => {
808 should_copy_selection = state.selection.active;
809 }
810 _ => {}
811 }
812 }
813 }
814
815 state.focus_index = ctx.focus_index;
816 state.prev_focus_count = ctx.focus_count;
817
818 let mut tree = layout::build_tree(&ctx.commands);
819 let area = crate::rect::Rect::new(0, 0, w, h);
820 layout::compute(&mut tree, area);
821 let fd = layout::collect_all(&tree);
822 state.prev_scroll_infos = fd.scroll_infos;
823 state.prev_scroll_rects = fd.scroll_rects;
824 state.prev_hit_map = fd.hit_areas;
825 state.prev_group_rects = fd.group_rects;
826 state.prev_content_map = fd.content_areas;
827 state.prev_focus_rects = fd.focus_rects;
828 state.prev_focus_groups = fd.focus_groups;
829 layout::render(&tree, term.buffer_mut());
830 let raw_rects = layout::collect_raw_draw_rects(&tree);
831 for (draw_id, rect) in raw_rects {
832 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
833 let buf = term.buffer_mut();
834 buf.push_clip(rect);
835 cb(buf, rect);
836 buf.pop_clip();
837 }
838 }
839 state.hook_states = ctx.hook_states;
840 state.notification_queue = ctx.notification_queue;
841
842 let frame_time = frame_start.elapsed();
843 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
844 let frame_secs = frame_time.as_secs_f32();
845 let inst_fps = if frame_secs > 0.0 {
846 1.0 / frame_secs
847 } else {
848 0.0
849 };
850 state.fps_ema = if state.fps_ema == 0.0 {
851 inst_fps
852 } else {
853 (state.fps_ema * 0.9) + (inst_fps * 0.1)
854 };
855 if state.debug_mode {
856 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
857 }
858
859 if state.selection.active {
860 terminal::apply_selection_overlay(
861 term.buffer_mut(),
862 &state.selection,
863 &state.prev_content_map,
864 );
865 }
866 if should_copy_selection {
867 let text = terminal::extract_selection_text(
868 term.buffer_mut(),
869 &state.selection,
870 &state.prev_content_map,
871 );
872 if !text.is_empty() {
873 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
874 }
875 state.selection.clear();
876 }
877
878 term.flush()?;
879 if let Some(text) = clipboard_text {
880 #[allow(clippy::print_stderr)]
881 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
882 eprintln!("[slt] failed to copy to clipboard: {e}");
883 }
884 }
885 state.tick = state.tick.wrapping_add(1);
886
887 Ok(true)
888}
889
890fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
891 for ev in events {
892 match ev {
893 Event::Mouse(mouse) => {
894 state.last_mouse_pos = Some((mouse.x, mouse.y));
895 }
896 Event::FocusLost => {
897 state.last_mouse_pos = None;
898 }
899 _ => {}
900 }
901 }
902}
903
904fn clear_frame_layout_cache(state: &mut FrameState) {
905 state.prev_hit_map.clear();
906 state.prev_group_rects.clear();
907 state.prev_content_map.clear();
908 state.prev_focus_rects.clear();
909 state.prev_focus_groups.clear();
910 state.prev_scroll_infos.clear();
911 state.prev_scroll_rects.clear();
912 state.last_mouse_pos = None;
913}
914
915fn is_ctrl_c(ev: &Event) -> bool {
916 matches!(
917 ev,
918 Event::Key(event::KeyEvent {
919 code: KeyCode::Char('c'),
920 modifiers,
921 kind: event::KeyEventKind::Press,
922 }) if modifiers.contains(KeyModifiers::CONTROL)
923 )
924}
925
926fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
927 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
928 let target = Duration::from_secs_f64(1.0 / fps as f64);
929 let elapsed = frame_start.elapsed();
930 if elapsed < target {
931 std::thread::sleep(target - elapsed);
932 }
933 }
934}