1#![forbid(unsafe_code)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
5#![warn(rustdoc::broken_intra_doc_links)]
6#![warn(missing_docs)]
7#![warn(rustdoc::private_intra_doc_links)]
8#![deny(clippy::unwrap_in_result)]
10#![warn(clippy::unwrap_used)]
11#![warn(clippy::dbg_macro)]
13#![warn(clippy::print_stdout)]
14#![warn(clippy::print_stderr)]
15
16pub mod anim;
66pub mod buffer;
67pub mod cell;
69pub mod chart;
71pub mod context;
73pub mod event;
75pub mod halfblock;
77pub mod keymap;
79pub mod layout;
81pub mod palette;
83pub mod rect;
84#[cfg(feature = "crossterm")]
85mod sixel;
86pub mod style;
87pub mod syntax;
88#[cfg(feature = "crossterm")]
89mod terminal;
90pub mod test_utils;
91pub mod widgets;
92
93use std::io;
94#[cfg(feature = "crossterm")]
95use std::io::IsTerminal;
96#[cfg(feature = "crossterm")]
97use std::io::Write;
98#[cfg(feature = "crossterm")]
99use std::sync::Once;
100use std::time::{Duration, Instant};
101
102#[cfg(feature = "crossterm")]
103pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
104#[cfg(feature = "crossterm")]
105use terminal::{InlineTerminal, Terminal};
106
107pub use crate::test_utils::{EventBuilder, TestBackend};
108pub use anim::{
109 ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
110 ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
111 Stagger, Tween,
112};
113pub use buffer::Buffer;
114pub use cell::Cell;
115pub use chart::{
116 Axis, Candle, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
117 HistogramBuilder, LegendPosition, Marker,
118};
119pub use context::{
120 Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
121 Response, State, Widget,
122};
123pub use event::{
124 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
125};
126pub use halfblock::HalfBlockImage;
127pub use keymap::{Binding, KeyMap};
128pub use layout::Direction;
129pub use palette::Palette;
130pub use rect::Rect;
131pub use style::{
132 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
133 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
134};
135pub use widgets::{
136 AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
137 DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, ListState,
138 MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
139 ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
140 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
141 ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
142};
143
144pub trait Backend {
194 fn size(&self) -> (u32, u32);
196
197 fn buffer_mut(&mut self) -> &mut Buffer;
202
203 fn flush(&mut self) -> io::Result<()>;
209}
210
211pub struct AppState {
223 pub(crate) inner: FrameState,
224}
225
226impl AppState {
227 pub fn new() -> Self {
229 Self {
230 inner: FrameState::default(),
231 }
232 }
233
234 pub fn tick(&self) -> u64 {
236 self.inner.tick
237 }
238
239 pub fn fps(&self) -> f32 {
241 self.inner.fps_ema
242 }
243
244 pub fn set_debug(&mut self, enabled: bool) {
246 self.inner.debug_mode = enabled;
247 }
248}
249
250impl Default for AppState {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256pub fn frame(
289 backend: &mut impl Backend,
290 state: &mut AppState,
291 config: &RunConfig,
292 events: &[Event],
293 f: &mut impl FnMut(&mut Context),
294) -> io::Result<bool> {
295 run_frame(backend, &mut state.inner, config, events.to_vec(), f)
296}
297
298#[cfg(feature = "crossterm")]
299static PANIC_HOOK_ONCE: Once = Once::new();
300
301#[allow(clippy::print_stderr)]
302#[cfg(feature = "crossterm")]
303fn install_panic_hook() {
304 PANIC_HOOK_ONCE.call_once(|| {
305 let original = std::panic::take_hook();
306 std::panic::set_hook(Box::new(move |panic_info| {
307 let _ = crossterm::terminal::disable_raw_mode();
308 let mut stdout = io::stdout();
309 let _ = crossterm::execute!(
310 stdout,
311 crossterm::terminal::LeaveAlternateScreen,
312 crossterm::cursor::Show,
313 crossterm::event::DisableMouseCapture,
314 crossterm::event::DisableBracketedPaste,
315 crossterm::style::ResetColor,
316 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
317 );
318
319 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
321
322 if let Some(location) = panic_info.location() {
324 eprintln!(
325 "\x1b[90m{}:{}:{}\x1b[0m",
326 location.file(),
327 location.line(),
328 location.column()
329 );
330 }
331
332 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
334 eprintln!("\x1b[1m{}\x1b[0m", msg);
335 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
336 eprintln!("\x1b[1m{}\x1b[0m", msg);
337 }
338
339 eprintln!(
340 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
341 );
342
343 original(panic_info);
344 }));
345 });
346}
347
348#[non_exhaustive]
367#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
368pub struct RunConfig {
369 pub tick_rate: Duration,
374 pub mouse: bool,
379 pub kitty_keyboard: bool,
386 pub theme: Theme,
390 pub color_depth: Option<ColorDepth>,
396 pub max_fps: Option<u32>,
401 pub scroll_speed: u32,
403 pub title: Option<String>,
405}
406
407impl Default for RunConfig {
408 fn default() -> Self {
409 Self {
410 tick_rate: Duration::from_millis(16),
411 mouse: false,
412 kitty_keyboard: false,
413 theme: Theme::dark(),
414 color_depth: None,
415 max_fps: Some(60),
416 scroll_speed: 1,
417 title: None,
418 }
419 }
420}
421
422impl RunConfig {
423 pub fn tick_rate(mut self, rate: Duration) -> Self {
425 self.tick_rate = rate;
426 self
427 }
428
429 pub fn mouse(mut self, enabled: bool) -> Self {
431 self.mouse = enabled;
432 self
433 }
434
435 pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
437 self.kitty_keyboard = enabled;
438 self
439 }
440
441 pub fn theme(mut self, theme: Theme) -> Self {
443 self.theme = theme;
444 self
445 }
446
447 pub fn color_depth(mut self, depth: ColorDepth) -> Self {
449 self.color_depth = Some(depth);
450 self
451 }
452
453 pub fn max_fps(mut self, fps: u32) -> Self {
455 self.max_fps = Some(fps);
456 self
457 }
458
459 pub fn scroll_speed(mut self, lines: u32) -> Self {
461 self.scroll_speed = lines.max(1);
462 self
463 }
464
465 pub fn title(mut self, title: impl Into<String>) -> Self {
467 self.title = Some(title.into());
468 self
469 }
470}
471
472pub(crate) struct FrameState {
473 pub hook_states: Vec<Box<dyn std::any::Any>>,
474 pub focus_index: usize,
475 pub prev_focus_count: usize,
476 pub prev_modal_focus_start: usize,
477 pub prev_modal_focus_count: usize,
478 pub tick: u64,
479 pub prev_scroll_infos: Vec<(u32, u32)>,
480 pub prev_scroll_rects: Vec<rect::Rect>,
481 pub prev_hit_map: Vec<rect::Rect>,
482 pub prev_group_rects: Vec<(String, rect::Rect)>,
483 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
484 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
485 pub prev_focus_groups: Vec<Option<String>>,
486 pub last_mouse_pos: Option<(u32, u32)>,
487 pub prev_modal_active: bool,
488 pub notification_queue: Vec<(String, ToastLevel, u64)>,
489 pub debug_mode: bool,
490 pub fps_ema: f32,
491 #[cfg(feature = "crossterm")]
492 pub selection: terminal::SelectionState,
493}
494
495impl Default for FrameState {
496 fn default() -> Self {
497 Self {
498 hook_states: Vec::new(),
499 focus_index: 0,
500 prev_focus_count: 0,
501 prev_modal_focus_start: 0,
502 prev_modal_focus_count: 0,
503 tick: 0,
504 prev_scroll_infos: Vec::new(),
505 prev_scroll_rects: Vec::new(),
506 prev_hit_map: Vec::new(),
507 prev_group_rects: Vec::new(),
508 prev_content_map: Vec::new(),
509 prev_focus_rects: Vec::new(),
510 prev_focus_groups: Vec::new(),
511 last_mouse_pos: None,
512 prev_modal_active: false,
513 notification_queue: Vec::new(),
514 debug_mode: false,
515 fps_ema: 0.0,
516 #[cfg(feature = "crossterm")]
517 selection: terminal::SelectionState::default(),
518 }
519 }
520}
521
522#[cfg(feature = "crossterm")]
537pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
538 run_with(RunConfig::default(), f)
539}
540
541#[cfg(feature = "crossterm")]
542fn set_terminal_title(title: &Option<String>) {
543 if let Some(title) = title {
544 use std::io::Write;
545 let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
546 }
547}
548
549#[cfg(feature = "crossterm")]
569pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
570 if !io::stdout().is_terminal() {
571 return Ok(());
572 }
573
574 install_panic_hook();
575 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
576 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
577 set_terminal_title(&config.title);
578 if config.theme.bg != Color::Reset {
579 term.theme_bg = Some(config.theme.bg);
580 }
581 let mut events: Vec<Event> = Vec::new();
582 let mut state = FrameState::default();
583
584 loop {
585 let frame_start = Instant::now();
586 let (w, h) = term.size();
587 if w == 0 || h == 0 {
588 sleep_for_fps_cap(config.max_fps, frame_start);
589 continue;
590 }
591
592 if !run_frame(
593 &mut term,
594 &mut state,
595 &config,
596 std::mem::take(&mut events),
597 &mut f,
598 )? {
599 break;
600 }
601
602 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
603 term.handle_resize()
604 })? {
605 break;
606 }
607
608 sleep_for_fps_cap(config.max_fps, frame_start);
609 }
610
611 Ok(())
612}
613
614#[cfg(all(feature = "crossterm", feature = "async"))]
635pub fn run_async<M: Send + 'static>(
636 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
637) -> io::Result<tokio::sync::mpsc::Sender<M>> {
638 run_async_with(RunConfig::default(), f)
639}
640
641#[cfg(all(feature = "crossterm", feature = "async"))]
648pub fn run_async_with<M: Send + 'static>(
649 config: RunConfig,
650 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
651) -> io::Result<tokio::sync::mpsc::Sender<M>> {
652 let (tx, rx) = tokio::sync::mpsc::channel(100);
653 let handle =
654 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
655
656 handle.spawn_blocking(move || {
657 let _ = run_async_loop(config, f, rx);
658 });
659
660 Ok(tx)
661}
662
663#[cfg(all(feature = "crossterm", feature = "async"))]
664fn run_async_loop<M: Send + 'static>(
665 config: RunConfig,
666 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
667 mut rx: tokio::sync::mpsc::Receiver<M>,
668) -> io::Result<()> {
669 if !io::stdout().is_terminal() {
670 return Ok(());
671 }
672
673 install_panic_hook();
674 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
675 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
676 set_terminal_title(&config.title);
677 if config.theme.bg != Color::Reset {
678 term.theme_bg = Some(config.theme.bg);
679 }
680 let mut events: Vec<Event> = Vec::new();
681 let mut state = FrameState::default();
682
683 loop {
684 let frame_start = Instant::now();
685 let mut messages: Vec<M> = Vec::new();
686 while let Ok(message) = rx.try_recv() {
687 messages.push(message);
688 }
689
690 let (w, h) = term.size();
691 if w == 0 || h == 0 {
692 sleep_for_fps_cap(config.max_fps, frame_start);
693 continue;
694 }
695
696 let mut render = |ctx: &mut Context| {
697 f(ctx, &mut messages);
698 };
699 if !run_frame(
700 &mut term,
701 &mut state,
702 &config,
703 std::mem::take(&mut events),
704 &mut render,
705 )? {
706 break;
707 }
708
709 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
710 term.handle_resize()
711 })? {
712 break;
713 }
714
715 sleep_for_fps_cap(config.max_fps, frame_start);
716 }
717
718 Ok(())
719}
720
721#[cfg(feature = "crossterm")]
740pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
741 run_inline_with(height, RunConfig::default(), f)
742}
743
744#[cfg(feature = "crossterm")]
749pub fn run_inline_with(
750 height: u32,
751 config: RunConfig,
752 mut f: impl FnMut(&mut Context),
753) -> io::Result<()> {
754 if !io::stdout().is_terminal() {
755 return Ok(());
756 }
757
758 install_panic_hook();
759 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
760 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
761 set_terminal_title(&config.title);
762 if config.theme.bg != Color::Reset {
763 term.theme_bg = Some(config.theme.bg);
764 }
765 let mut events: Vec<Event> = Vec::new();
766 let mut state = FrameState::default();
767
768 loop {
769 let frame_start = Instant::now();
770 let (w, h) = term.size();
771 if w == 0 || h == 0 {
772 sleep_for_fps_cap(config.max_fps, frame_start);
773 continue;
774 }
775
776 if !run_frame(
777 &mut term,
778 &mut state,
779 &config,
780 std::mem::take(&mut events),
781 &mut f,
782 )? {
783 break;
784 }
785
786 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
787 term.handle_resize()
788 })? {
789 break;
790 }
791
792 sleep_for_fps_cap(config.max_fps, frame_start);
793 }
794
795 Ok(())
796}
797
798#[cfg(feature = "crossterm")]
806pub fn run_static(
807 output: &mut StaticOutput,
808 dynamic_height: u32,
809 mut f: impl FnMut(&mut Context),
810) -> io::Result<()> {
811 let config = RunConfig::default();
812 if !io::stdout().is_terminal() {
813 return Ok(());
814 }
815
816 install_panic_hook();
817
818 let initial_lines = output.drain_new();
819 write_static_lines(&initial_lines)?;
820
821 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
822 let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
823 set_terminal_title(&config.title);
824 if config.theme.bg != Color::Reset {
825 term.theme_bg = Some(config.theme.bg);
826 }
827
828 let mut events: Vec<Event> = Vec::new();
829 let mut state = FrameState::default();
830
831 loop {
832 let frame_start = Instant::now();
833 let (w, h) = term.size();
834 if w == 0 || h == 0 {
835 sleep_for_fps_cap(config.max_fps, frame_start);
836 continue;
837 }
838
839 let new_lines = output.drain_new();
840 write_static_lines(&new_lines)?;
841
842 if !run_frame(
843 &mut term,
844 &mut state,
845 &config,
846 std::mem::take(&mut events),
847 &mut f,
848 )? {
849 break;
850 }
851
852 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
853 term.handle_resize()
854 })? {
855 break;
856 }
857
858 sleep_for_fps_cap(config.max_fps, frame_start);
859 }
860
861 Ok(())
862}
863
864#[cfg(feature = "crossterm")]
865fn write_static_lines(lines: &[String]) -> io::Result<()> {
866 if lines.is_empty() {
867 return Ok(());
868 }
869
870 let mut stdout = io::stdout();
871 for line in lines {
872 stdout.write_all(line.as_bytes())?;
873 stdout.write_all(b"\r\n")?;
874 }
875 stdout.flush()
876}
877
878#[cfg(feature = "crossterm")]
881fn poll_events(
882 events: &mut Vec<Event>,
883 state: &mut FrameState,
884 tick_rate: Duration,
885 on_resize: &mut impl FnMut() -> io::Result<()>,
886) -> io::Result<bool> {
887 if crossterm::event::poll(tick_rate)? {
888 let raw = crossterm::event::read()?;
889 if let Some(ev) = event::from_crossterm(raw) {
890 if is_ctrl_c(&ev) {
891 return Ok(false);
892 }
893 if matches!(ev, Event::Resize(_, _)) {
894 on_resize()?;
895 }
896 events.push(ev);
897 }
898
899 while crossterm::event::poll(Duration::ZERO)? {
900 let raw = crossterm::event::read()?;
901 if let Some(ev) = event::from_crossterm(raw) {
902 if is_ctrl_c(&ev) {
903 return Ok(false);
904 }
905 if matches!(ev, Event::Resize(_, _)) {
906 on_resize()?;
907 }
908 events.push(ev);
909 }
910 }
911
912 for ev in events.iter() {
913 if matches!(
914 ev,
915 Event::Key(event::KeyEvent {
916 code: KeyCode::F(12),
917 kind: event::KeyEventKind::Press,
918 ..
919 })
920 ) {
921 state.debug_mode = !state.debug_mode;
922 }
923 }
924 }
925
926 update_last_mouse_pos(state, events);
927
928 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
929 clear_frame_layout_cache(state);
930 }
931
932 Ok(true)
933}
934
935fn run_frame(
936 term: &mut impl Backend,
937 state: &mut FrameState,
938 config: &RunConfig,
939 events: Vec<event::Event>,
940 f: &mut impl FnMut(&mut context::Context),
941) -> io::Result<bool> {
942 let frame_start = Instant::now();
943 let (w, h) = term.size();
944 let mut ctx = Context::new(events, w, h, state, config.theme);
945 ctx.is_real_terminal = true;
946 ctx.set_scroll_speed(config.scroll_speed);
947
948 f(&mut ctx);
949 ctx.process_focus_keys();
950 ctx.render_notifications();
951 ctx.emit_pending_tooltips();
952
953 if ctx.should_quit {
954 return Ok(false);
955 }
956 state.prev_modal_active = ctx.modal_active;
957 state.prev_modal_focus_start = ctx.modal_focus_start;
958 state.prev_modal_focus_count = ctx.modal_focus_count;
959 #[cfg(feature = "crossterm")]
960 let clipboard_text = ctx.clipboard_text.take();
961 #[cfg(not(feature = "crossterm"))]
962 let _clipboard_text = ctx.clipboard_text.take();
963
964 #[cfg(feature = "crossterm")]
965 let mut should_copy_selection = false;
966 #[cfg(feature = "crossterm")]
967 for ev in &ctx.events {
968 if let Event::Mouse(mouse) = ev {
969 match mouse.kind {
970 event::MouseKind::Down(event::MouseButton::Left) => {
971 state
972 .selection
973 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
974 }
975 event::MouseKind::Drag(event::MouseButton::Left) => {
976 state
977 .selection
978 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
979 }
980 event::MouseKind::Up(event::MouseButton::Left) => {
981 should_copy_selection = state.selection.active;
982 }
983 _ => {}
984 }
985 }
986 }
987
988 state.focus_index = ctx.focus_index;
989 state.prev_focus_count = ctx.focus_count;
990
991 let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
992 let area = crate::rect::Rect::new(0, 0, w, h);
993 layout::compute(&mut tree, area);
994 let fd = layout::collect_all(&tree);
995 state.prev_scroll_infos = fd.scroll_infos;
996 state.prev_scroll_rects = fd.scroll_rects;
997 state.prev_hit_map = fd.hit_areas;
998 state.prev_group_rects = fd.group_rects;
999 state.prev_content_map = fd.content_areas;
1000 state.prev_focus_rects = fd.focus_rects;
1001 state.prev_focus_groups = fd.focus_groups;
1002 layout::render(&tree, term.buffer_mut());
1003 let raw_rects = fd.raw_draw_rects;
1004 for rdr in raw_rects {
1005 if rdr.rect.width == 0 || rdr.rect.height == 0 {
1006 continue;
1007 }
1008 if let Some(cb) = ctx
1009 .deferred_draws
1010 .get_mut(rdr.draw_id)
1011 .and_then(|c| c.take())
1012 {
1013 let buf = term.buffer_mut();
1014 buf.push_clip(rdr.rect);
1015 buf.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
1016 cb(buf, rdr.rect);
1017 buf.kitty_clip_info = None;
1018 buf.pop_clip();
1019 }
1020 }
1021 state.hook_states = ctx.hook_states;
1022 state.notification_queue = ctx.notification_queue;
1023
1024 let frame_time = frame_start.elapsed();
1025 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1026 let frame_secs = frame_time.as_secs_f32();
1027 let inst_fps = if frame_secs > 0.0 {
1028 1.0 / frame_secs
1029 } else {
1030 0.0
1031 };
1032 state.fps_ema = if state.fps_ema == 0.0 {
1033 inst_fps
1034 } else {
1035 (state.fps_ema * 0.9) + (inst_fps * 0.1)
1036 };
1037 if state.debug_mode {
1038 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
1039 }
1040
1041 #[cfg(feature = "crossterm")]
1042 if state.selection.active {
1043 terminal::apply_selection_overlay(
1044 term.buffer_mut(),
1045 &state.selection,
1046 &state.prev_content_map,
1047 );
1048 }
1049 #[cfg(feature = "crossterm")]
1050 if should_copy_selection {
1051 let text = terminal::extract_selection_text(
1052 term.buffer_mut(),
1053 &state.selection,
1054 &state.prev_content_map,
1055 );
1056 if !text.is_empty() {
1057 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1058 }
1059 state.selection.clear();
1060 }
1061
1062 term.flush()?;
1063 #[cfg(feature = "crossterm")]
1064 if let Some(text) = clipboard_text {
1065 #[allow(clippy::print_stderr)]
1066 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1067 eprintln!("[slt] failed to copy to clipboard: {e}");
1068 }
1069 }
1070 state.tick = state.tick.wrapping_add(1);
1071
1072 Ok(true)
1073}
1074
1075#[cfg(feature = "crossterm")]
1076fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1077 for ev in events {
1078 match ev {
1079 Event::Mouse(mouse) => {
1080 state.last_mouse_pos = Some((mouse.x, mouse.y));
1081 }
1082 Event::FocusLost => {
1083 state.last_mouse_pos = None;
1084 }
1085 _ => {}
1086 }
1087 }
1088}
1089
1090#[cfg(feature = "crossterm")]
1091fn clear_frame_layout_cache(state: &mut FrameState) {
1092 state.prev_hit_map.clear();
1093 state.prev_group_rects.clear();
1094 state.prev_content_map.clear();
1095 state.prev_focus_rects.clear();
1096 state.prev_focus_groups.clear();
1097 state.prev_scroll_infos.clear();
1098 state.prev_scroll_rects.clear();
1099 state.last_mouse_pos = None;
1100}
1101
1102#[cfg(feature = "crossterm")]
1103fn is_ctrl_c(ev: &Event) -> bool {
1104 matches!(
1105 ev,
1106 Event::Key(event::KeyEvent {
1107 code: KeyCode::Char('c'),
1108 modifiers,
1109 kind: event::KeyEventKind::Press,
1110 }) if modifiers.contains(KeyModifiers::CONTROL)
1111 )
1112}
1113
1114#[cfg(feature = "crossterm")]
1115fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1116 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1117 let target = Duration::from_secs_f64(1.0 / fps as f64);
1118 let elapsed = frame_start.elapsed();
1119 if elapsed < target {
1120 std::thread::sleep(target - elapsed);
1121 }
1122 }
1123}