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