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 tick: u64,
381 pub prev_scroll_infos: Vec<(u32, u32)>,
382 pub prev_scroll_rects: Vec<rect::Rect>,
383 pub prev_hit_map: Vec<rect::Rect>,
384 pub prev_group_rects: Vec<(String, rect::Rect)>,
385 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
386 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
387 pub prev_focus_groups: Vec<Option<String>>,
388 pub last_mouse_pos: Option<(u32, u32)>,
389 pub prev_modal_active: bool,
390 pub notification_queue: Vec<(String, ToastLevel, u64)>,
391 pub debug_mode: bool,
392 pub fps_ema: f32,
393 pub selection: terminal::SelectionState,
394}
395
396impl Default for FrameState {
397 fn default() -> Self {
398 Self {
399 hook_states: Vec::new(),
400 focus_index: 0,
401 prev_focus_count: 0,
402 tick: 0,
403 prev_scroll_infos: Vec::new(),
404 prev_scroll_rects: Vec::new(),
405 prev_hit_map: Vec::new(),
406 prev_group_rects: Vec::new(),
407 prev_content_map: Vec::new(),
408 prev_focus_rects: Vec::new(),
409 prev_focus_groups: Vec::new(),
410 last_mouse_pos: None,
411 prev_modal_active: false,
412 notification_queue: Vec::new(),
413 debug_mode: false,
414 fps_ema: 0.0,
415 selection: terminal::SelectionState::default(),
416 }
417 }
418}
419
420pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
435 run_with(RunConfig::default(), f)
436}
437
438pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
458 if !io::stdout().is_terminal() {
459 return Ok(());
460 }
461
462 install_panic_hook();
463 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
464 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
465 if config.theme.bg != Color::Reset {
466 term.theme_bg = Some(config.theme.bg);
467 }
468 let mut events: Vec<Event> = Vec::new();
469 let mut state = FrameState::default();
470
471 loop {
472 let frame_start = Instant::now();
473 let (w, h) = term.size();
474 if w == 0 || h == 0 {
475 sleep_for_fps_cap(config.max_fps, frame_start);
476 continue;
477 }
478
479 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
480 break;
481 }
482
483 events.clear();
484 if crossterm::event::poll(config.tick_rate)? {
485 let raw = crossterm::event::read()?;
486 if let Some(ev) = event::from_crossterm(raw) {
487 if is_ctrl_c(&ev) {
488 break;
489 }
490 if let Event::Resize(_, _) = &ev {
491 term.handle_resize()?;
492 }
493 events.push(ev);
494 }
495
496 while crossterm::event::poll(Duration::ZERO)? {
497 let raw = crossterm::event::read()?;
498 if let Some(ev) = event::from_crossterm(raw) {
499 if is_ctrl_c(&ev) {
500 return Ok(());
501 }
502 if let Event::Resize(_, _) = &ev {
503 term.handle_resize()?;
504 }
505 events.push(ev);
506 }
507 }
508
509 for ev in &events {
510 if matches!(
511 ev,
512 Event::Key(event::KeyEvent {
513 code: KeyCode::F(12),
514 kind: event::KeyEventKind::Press,
515 ..
516 })
517 ) {
518 state.debug_mode = !state.debug_mode;
519 }
520 }
521 }
522
523 update_last_mouse_pos(&mut state, &events);
524
525 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
526 clear_frame_layout_cache(&mut state);
527 }
528
529 sleep_for_fps_cap(config.max_fps, frame_start);
530 }
531
532 Ok(())
533}
534
535#[cfg(feature = "async")]
556pub fn run_async<M: Send + 'static>(
557 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
558) -> io::Result<tokio::sync::mpsc::Sender<M>> {
559 run_async_with(RunConfig::default(), f)
560}
561
562#[cfg(feature = "async")]
569pub fn run_async_with<M: Send + 'static>(
570 config: RunConfig,
571 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
572) -> io::Result<tokio::sync::mpsc::Sender<M>> {
573 let (tx, rx) = tokio::sync::mpsc::channel(100);
574 let handle =
575 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
576
577 handle.spawn_blocking(move || {
578 let _ = run_async_loop(config, f, rx);
579 });
580
581 Ok(tx)
582}
583
584#[cfg(feature = "async")]
585fn run_async_loop<M: Send + 'static>(
586 config: RunConfig,
587 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
588 mut rx: tokio::sync::mpsc::Receiver<M>,
589) -> io::Result<()> {
590 if !io::stdout().is_terminal() {
591 return Ok(());
592 }
593
594 install_panic_hook();
595 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
596 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
597 if config.theme.bg != Color::Reset {
598 term.theme_bg = Some(config.theme.bg);
599 }
600 let mut events: Vec<Event> = Vec::new();
601 let mut state = FrameState::default();
602
603 loop {
604 let frame_start = Instant::now();
605 let mut messages: Vec<M> = Vec::new();
606 while let Ok(message) = rx.try_recv() {
607 messages.push(message);
608 }
609
610 let (w, h) = term.size();
611 if w == 0 || h == 0 {
612 sleep_for_fps_cap(config.max_fps, frame_start);
613 continue;
614 }
615
616 let mut render = |ctx: &mut Context| {
617 f(ctx, &mut messages);
618 };
619 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
620 break;
621 }
622
623 events.clear();
624 if crossterm::event::poll(config.tick_rate)? {
625 let raw = crossterm::event::read()?;
626 if let Some(ev) = event::from_crossterm(raw) {
627 if is_ctrl_c(&ev) {
628 break;
629 }
630 if let Event::Resize(_, _) = &ev {
631 term.handle_resize()?;
632 clear_frame_layout_cache(&mut state);
633 }
634 events.push(ev);
635 }
636
637 while crossterm::event::poll(Duration::ZERO)? {
638 let raw = crossterm::event::read()?;
639 if let Some(ev) = event::from_crossterm(raw) {
640 if is_ctrl_c(&ev) {
641 return Ok(());
642 }
643 if let Event::Resize(_, _) = &ev {
644 term.handle_resize()?;
645 clear_frame_layout_cache(&mut state);
646 }
647 events.push(ev);
648 }
649 }
650 }
651
652 update_last_mouse_pos(&mut state, &events);
653
654 sleep_for_fps_cap(config.max_fps, frame_start);
655 }
656
657 Ok(())
658}
659
660pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
676 run_inline_with(height, RunConfig::default(), f)
677}
678
679pub fn run_inline_with(
684 height: u32,
685 config: RunConfig,
686 mut f: impl FnMut(&mut Context),
687) -> io::Result<()> {
688 if !io::stdout().is_terminal() {
689 return Ok(());
690 }
691
692 install_panic_hook();
693 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
694 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
695 if config.theme.bg != Color::Reset {
696 term.theme_bg = Some(config.theme.bg);
697 }
698 let mut events: Vec<Event> = Vec::new();
699 let mut state = FrameState::default();
700
701 loop {
702 let frame_start = Instant::now();
703 let (w, h) = term.size();
704 if w == 0 || h == 0 {
705 sleep_for_fps_cap(config.max_fps, frame_start);
706 continue;
707 }
708
709 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
710 break;
711 }
712
713 events.clear();
714 if crossterm::event::poll(config.tick_rate)? {
715 let raw = crossterm::event::read()?;
716 if let Some(ev) = event::from_crossterm(raw) {
717 if is_ctrl_c(&ev) {
718 break;
719 }
720 if let Event::Resize(_, _) = &ev {
721 term.handle_resize()?;
722 }
723 events.push(ev);
724 }
725
726 while crossterm::event::poll(Duration::ZERO)? {
727 let raw = crossterm::event::read()?;
728 if let Some(ev) = event::from_crossterm(raw) {
729 if is_ctrl_c(&ev) {
730 return Ok(());
731 }
732 if let Event::Resize(_, _) = &ev {
733 term.handle_resize()?;
734 }
735 events.push(ev);
736 }
737 }
738
739 for ev in &events {
740 if matches!(
741 ev,
742 Event::Key(event::KeyEvent {
743 code: KeyCode::F(12),
744 kind: event::KeyEventKind::Press,
745 ..
746 })
747 ) {
748 state.debug_mode = !state.debug_mode;
749 }
750 }
751 }
752
753 update_last_mouse_pos(&mut state, &events);
754
755 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
756 clear_frame_layout_cache(&mut state);
757 }
758
759 sleep_for_fps_cap(config.max_fps, frame_start);
760 }
761
762 Ok(())
763}
764
765fn run_frame(
766 term: &mut impl Backend,
767 state: &mut FrameState,
768 config: &RunConfig,
769 events: &[event::Event],
770 f: &mut impl FnMut(&mut context::Context),
771) -> io::Result<bool> {
772 let frame_start = Instant::now();
773 let (w, h) = term.size();
774 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
775 ctx.is_real_terminal = true;
776 ctx.process_focus_keys();
777
778 f(&mut ctx);
779 ctx.render_notifications();
780
781 if ctx.should_quit {
782 return Ok(false);
783 }
784 state.prev_modal_active = ctx.modal_active;
785 let clipboard_text = ctx.clipboard_text.take();
786
787 let mut should_copy_selection = false;
788 for ev in &ctx.events {
789 if let Event::Mouse(mouse) = ev {
790 match mouse.kind {
791 event::MouseKind::Down(event::MouseButton::Left) => {
792 state
793 .selection
794 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
795 }
796 event::MouseKind::Drag(event::MouseButton::Left) => {
797 state
798 .selection
799 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
800 }
801 event::MouseKind::Up(event::MouseButton::Left) => {
802 should_copy_selection = state.selection.active;
803 }
804 _ => {}
805 }
806 }
807 }
808
809 state.focus_index = ctx.focus_index;
810 state.prev_focus_count = ctx.focus_count;
811
812 let mut tree = layout::build_tree(&ctx.commands);
813 let area = crate::rect::Rect::new(0, 0, w, h);
814 layout::compute(&mut tree, area);
815 let fd = layout::collect_all(&tree);
816 state.prev_scroll_infos = fd.scroll_infos;
817 state.prev_scroll_rects = fd.scroll_rects;
818 state.prev_hit_map = fd.hit_areas;
819 state.prev_group_rects = fd.group_rects;
820 state.prev_content_map = fd.content_areas;
821 state.prev_focus_rects = fd.focus_rects;
822 state.prev_focus_groups = fd.focus_groups;
823 layout::render(&tree, term.buffer_mut());
824 let raw_rects = layout::collect_raw_draw_rects(&tree);
825 for (draw_id, rect) in raw_rects {
826 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
827 let buf = term.buffer_mut();
828 buf.push_clip(rect);
829 cb(buf, rect);
830 buf.pop_clip();
831 }
832 }
833 state.hook_states = ctx.hook_states;
834 state.notification_queue = ctx.notification_queue;
835
836 let frame_time = frame_start.elapsed();
837 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
838 let frame_secs = frame_time.as_secs_f32();
839 let inst_fps = if frame_secs > 0.0 {
840 1.0 / frame_secs
841 } else {
842 0.0
843 };
844 state.fps_ema = if state.fps_ema == 0.0 {
845 inst_fps
846 } else {
847 (state.fps_ema * 0.9) + (inst_fps * 0.1)
848 };
849 if state.debug_mode {
850 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
851 }
852
853 if state.selection.active {
854 terminal::apply_selection_overlay(
855 term.buffer_mut(),
856 &state.selection,
857 &state.prev_content_map,
858 );
859 }
860 if should_copy_selection {
861 let text = terminal::extract_selection_text(
862 term.buffer_mut(),
863 &state.selection,
864 &state.prev_content_map,
865 );
866 if !text.is_empty() {
867 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
868 }
869 state.selection.clear();
870 }
871
872 term.flush()?;
873 if let Some(text) = clipboard_text {
874 #[allow(clippy::print_stderr)]
875 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
876 eprintln!("[slt] failed to copy to clipboard: {e}");
877 }
878 }
879 state.tick = state.tick.wrapping_add(1);
880
881 Ok(true)
882}
883
884fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
885 for ev in events {
886 match ev {
887 Event::Mouse(mouse) => {
888 state.last_mouse_pos = Some((mouse.x, mouse.y));
889 }
890 Event::FocusLost => {
891 state.last_mouse_pos = None;
892 }
893 _ => {}
894 }
895 }
896}
897
898fn clear_frame_layout_cache(state: &mut FrameState) {
899 state.prev_hit_map.clear();
900 state.prev_group_rects.clear();
901 state.prev_content_map.clear();
902 state.prev_focus_rects.clear();
903 state.prev_focus_groups.clear();
904 state.prev_scroll_infos.clear();
905 state.prev_scroll_rects.clear();
906 state.last_mouse_pos = None;
907}
908
909fn is_ctrl_c(ev: &Event) -> bool {
910 matches!(
911 ev,
912 Event::Key(event::KeyEvent {
913 code: KeyCode::Char('c'),
914 modifiers,
915 kind: event::KeyEventKind::Press,
916 }) if modifiers.contains(KeyModifiers::CONTROL)
917 )
918}
919
920fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
921 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
922 let target = Duration::from_secs_f64(1.0 / fps as f64);
923 let elapsed = frame_start.elapsed();
924 if elapsed < target {
925 std::thread::sleep(target - elapsed);
926 }
927 }
928}