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;
63#[cfg(feature = "crossterm")]
64mod sixel;
65pub mod style;
66#[cfg(feature = "crossterm")]
67mod terminal;
68pub mod test_utils;
69pub mod widgets;
70
71use std::io;
72#[cfg(feature = "crossterm")]
73use std::io::IsTerminal;
74#[cfg(feature = "crossterm")]
75use std::io::Write;
76use std::sync::Once;
77use std::time::{Duration, Instant};
78
79#[cfg(feature = "crossterm")]
80pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
81#[cfg(feature = "crossterm")]
82use terminal::{InlineTerminal, Terminal};
83
84pub use crate::test_utils::{EventBuilder, TestBackend};
85pub use anim::{
86 ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
87 ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
88 Stagger, Tween,
89};
90pub use buffer::Buffer;
91pub use cell::Cell;
92pub use chart::{
93 Axis, Candle, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
94 HistogramBuilder, LegendPosition, Marker,
95};
96pub use context::{
97 Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
98 Response, State, Widget,
99};
100pub use event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind};
101pub use halfblock::HalfBlockImage;
102pub use keymap::{Binding, KeyMap};
103pub use layout::Direction;
104pub use palette::Palette;
105pub use rect::Rect;
106pub use style::{
107 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
108 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
109};
110pub use widgets::{
111 AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
112 DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, ListState,
113 MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
114 ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
115 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
116 ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
117};
118
119pub trait Backend {
169 fn size(&self) -> (u32, u32);
171
172 fn buffer_mut(&mut self) -> &mut Buffer;
177
178 fn flush(&mut self) -> io::Result<()>;
184}
185
186pub struct AppState {
198 pub(crate) inner: FrameState,
199}
200
201impl AppState {
202 pub fn new() -> Self {
204 Self {
205 inner: FrameState::default(),
206 }
207 }
208
209 pub fn tick(&self) -> u64 {
211 self.inner.tick
212 }
213
214 pub fn fps(&self) -> f32 {
216 self.inner.fps_ema
217 }
218
219 pub fn set_debug(&mut self, enabled: bool) {
221 self.inner.debug_mode = enabled;
222 }
223}
224
225impl Default for AppState {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231pub fn frame(
260 backend: &mut impl Backend,
261 state: &mut AppState,
262 config: &RunConfig,
263 events: &[Event],
264 f: &mut impl FnMut(&mut Context),
265) -> io::Result<bool> {
266 run_frame(backend, &mut state.inner, config, events, f)
267}
268
269static PANIC_HOOK_ONCE: Once = Once::new();
270
271#[allow(clippy::print_stderr)]
272#[cfg(feature = "crossterm")]
273fn install_panic_hook() {
274 PANIC_HOOK_ONCE.call_once(|| {
275 let original = std::panic::take_hook();
276 std::panic::set_hook(Box::new(move |panic_info| {
277 let _ = crossterm::terminal::disable_raw_mode();
278 let mut stdout = io::stdout();
279 let _ = crossterm::execute!(
280 stdout,
281 crossterm::terminal::LeaveAlternateScreen,
282 crossterm::cursor::Show,
283 crossterm::event::DisableMouseCapture,
284 crossterm::event::DisableBracketedPaste,
285 crossterm::style::ResetColor,
286 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
287 );
288
289 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
291
292 if let Some(location) = panic_info.location() {
294 eprintln!(
295 "\x1b[90m{}:{}:{}\x1b[0m",
296 location.file(),
297 location.line(),
298 location.column()
299 );
300 }
301
302 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
304 eprintln!("\x1b[1m{}\x1b[0m", msg);
305 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
306 eprintln!("\x1b[1m{}\x1b[0m", msg);
307 }
308
309 eprintln!(
310 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
311 );
312
313 original(panic_info);
314 }));
315 });
316}
317
318#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
339pub struct RunConfig {
340 pub tick_rate: Duration,
345 pub mouse: bool,
350 pub kitty_keyboard: bool,
357 pub theme: Theme,
361 pub color_depth: Option<ColorDepth>,
367 pub max_fps: Option<u32>,
372}
373
374impl Default for RunConfig {
375 fn default() -> Self {
376 Self {
377 tick_rate: Duration::from_millis(16),
378 mouse: false,
379 kitty_keyboard: false,
380 theme: Theme::dark(),
381 color_depth: None,
382 max_fps: Some(60),
383 }
384 }
385}
386
387pub(crate) struct FrameState {
388 pub hook_states: Vec<Box<dyn std::any::Any>>,
389 pub focus_index: usize,
390 pub prev_focus_count: usize,
391 pub prev_modal_focus_start: usize,
392 pub prev_modal_focus_count: usize,
393 pub tick: u64,
394 pub prev_scroll_infos: Vec<(u32, u32)>,
395 pub prev_scroll_rects: Vec<rect::Rect>,
396 pub prev_hit_map: Vec<rect::Rect>,
397 pub prev_group_rects: Vec<(String, rect::Rect)>,
398 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
399 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
400 pub prev_focus_groups: Vec<Option<String>>,
401 pub last_mouse_pos: Option<(u32, u32)>,
402 pub prev_modal_active: bool,
403 pub notification_queue: Vec<(String, ToastLevel, u64)>,
404 pub debug_mode: bool,
405 pub fps_ema: f32,
406 #[cfg(feature = "crossterm")]
407 pub selection: terminal::SelectionState,
408}
409
410impl Default for FrameState {
411 fn default() -> Self {
412 Self {
413 hook_states: Vec::new(),
414 focus_index: 0,
415 prev_focus_count: 0,
416 prev_modal_focus_start: 0,
417 prev_modal_focus_count: 0,
418 tick: 0,
419 prev_scroll_infos: Vec::new(),
420 prev_scroll_rects: Vec::new(),
421 prev_hit_map: Vec::new(),
422 prev_group_rects: Vec::new(),
423 prev_content_map: Vec::new(),
424 prev_focus_rects: Vec::new(),
425 prev_focus_groups: Vec::new(),
426 last_mouse_pos: None,
427 prev_modal_active: false,
428 notification_queue: Vec::new(),
429 debug_mode: false,
430 fps_ema: 0.0,
431 #[cfg(feature = "crossterm")]
432 selection: terminal::SelectionState::default(),
433 }
434 }
435}
436
437#[cfg(feature = "crossterm")]
452pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
453 run_with(RunConfig::default(), f)
454}
455
456#[cfg(feature = "crossterm")]
476pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
477 if !io::stdout().is_terminal() {
478 return Ok(());
479 }
480
481 install_panic_hook();
482 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
483 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
484 if config.theme.bg != Color::Reset {
485 term.theme_bg = Some(config.theme.bg);
486 }
487 let mut events: Vec<Event> = Vec::new();
488 let mut state = FrameState::default();
489
490 loop {
491 let frame_start = Instant::now();
492 let (w, h) = term.size();
493 if w == 0 || h == 0 {
494 sleep_for_fps_cap(config.max_fps, frame_start);
495 continue;
496 }
497
498 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
499 break;
500 }
501
502 events.clear();
503 if crossterm::event::poll(config.tick_rate)? {
504 let raw = crossterm::event::read()?;
505 if let Some(ev) = event::from_crossterm(raw) {
506 if is_ctrl_c(&ev) {
507 break;
508 }
509 if let Event::Resize(_, _) = &ev {
510 term.handle_resize()?;
511 }
512 events.push(ev);
513 }
514
515 while crossterm::event::poll(Duration::ZERO)? {
516 let raw = crossterm::event::read()?;
517 if let Some(ev) = event::from_crossterm(raw) {
518 if is_ctrl_c(&ev) {
519 return Ok(());
520 }
521 if let Event::Resize(_, _) = &ev {
522 term.handle_resize()?;
523 }
524 events.push(ev);
525 }
526 }
527
528 for ev in &events {
529 if matches!(
530 ev,
531 Event::Key(event::KeyEvent {
532 code: KeyCode::F(12),
533 kind: event::KeyEventKind::Press,
534 ..
535 })
536 ) {
537 state.debug_mode = !state.debug_mode;
538 }
539 }
540 }
541
542 update_last_mouse_pos(&mut state, &events);
543
544 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
545 clear_frame_layout_cache(&mut state);
546 }
547
548 sleep_for_fps_cap(config.max_fps, frame_start);
549 }
550
551 Ok(())
552}
553
554#[cfg(all(feature = "crossterm", feature = "async"))]
575pub fn run_async<M: Send + 'static>(
576 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
577) -> io::Result<tokio::sync::mpsc::Sender<M>> {
578 run_async_with(RunConfig::default(), f)
579}
580
581#[cfg(all(feature = "crossterm", feature = "async"))]
588pub fn run_async_with<M: Send + 'static>(
589 config: RunConfig,
590 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
591) -> io::Result<tokio::sync::mpsc::Sender<M>> {
592 let (tx, rx) = tokio::sync::mpsc::channel(100);
593 let handle =
594 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
595
596 handle.spawn_blocking(move || {
597 let _ = run_async_loop(config, f, rx);
598 });
599
600 Ok(tx)
601}
602
603#[cfg(all(feature = "crossterm", feature = "async"))]
604fn run_async_loop<M: Send + 'static>(
605 config: RunConfig,
606 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
607 mut rx: tokio::sync::mpsc::Receiver<M>,
608) -> io::Result<()> {
609 if !io::stdout().is_terminal() {
610 return Ok(());
611 }
612
613 install_panic_hook();
614 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
615 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
616 if config.theme.bg != Color::Reset {
617 term.theme_bg = Some(config.theme.bg);
618 }
619 let mut events: Vec<Event> = Vec::new();
620 let mut state = FrameState::default();
621
622 loop {
623 let frame_start = Instant::now();
624 let mut messages: Vec<M> = Vec::new();
625 while let Ok(message) = rx.try_recv() {
626 messages.push(message);
627 }
628
629 let (w, h) = term.size();
630 if w == 0 || h == 0 {
631 sleep_for_fps_cap(config.max_fps, frame_start);
632 continue;
633 }
634
635 let mut render = |ctx: &mut Context| {
636 f(ctx, &mut messages);
637 };
638 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
639 break;
640 }
641
642 events.clear();
643 if crossterm::event::poll(config.tick_rate)? {
644 let raw = crossterm::event::read()?;
645 if let Some(ev) = event::from_crossterm(raw) {
646 if is_ctrl_c(&ev) {
647 break;
648 }
649 if let Event::Resize(_, _) = &ev {
650 term.handle_resize()?;
651 clear_frame_layout_cache(&mut state);
652 }
653 events.push(ev);
654 }
655
656 while crossterm::event::poll(Duration::ZERO)? {
657 let raw = crossterm::event::read()?;
658 if let Some(ev) = event::from_crossterm(raw) {
659 if is_ctrl_c(&ev) {
660 return Ok(());
661 }
662 if let Event::Resize(_, _) = &ev {
663 term.handle_resize()?;
664 clear_frame_layout_cache(&mut state);
665 }
666 events.push(ev);
667 }
668 }
669 }
670
671 update_last_mouse_pos(&mut state, &events);
672
673 sleep_for_fps_cap(config.max_fps, frame_start);
674 }
675
676 Ok(())
677}
678
679#[cfg(feature = "crossterm")]
695pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
696 run_inline_with(height, RunConfig::default(), f)
697}
698
699#[cfg(feature = "crossterm")]
704pub fn run_inline_with(
705 height: u32,
706 config: RunConfig,
707 mut f: impl FnMut(&mut Context),
708) -> io::Result<()> {
709 if !io::stdout().is_terminal() {
710 return Ok(());
711 }
712
713 install_panic_hook();
714 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
715 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
716 if config.theme.bg != Color::Reset {
717 term.theme_bg = Some(config.theme.bg);
718 }
719 let mut events: Vec<Event> = Vec::new();
720 let mut state = FrameState::default();
721
722 loop {
723 let frame_start = Instant::now();
724 let (w, h) = term.size();
725 if w == 0 || h == 0 {
726 sleep_for_fps_cap(config.max_fps, frame_start);
727 continue;
728 }
729
730 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
731 break;
732 }
733
734 events.clear();
735 if crossterm::event::poll(config.tick_rate)? {
736 let raw = crossterm::event::read()?;
737 if let Some(ev) = event::from_crossterm(raw) {
738 if is_ctrl_c(&ev) {
739 break;
740 }
741 if let Event::Resize(_, _) = &ev {
742 term.handle_resize()?;
743 }
744 events.push(ev);
745 }
746
747 while crossterm::event::poll(Duration::ZERO)? {
748 let raw = crossterm::event::read()?;
749 if let Some(ev) = event::from_crossterm(raw) {
750 if is_ctrl_c(&ev) {
751 return Ok(());
752 }
753 if let Event::Resize(_, _) = &ev {
754 term.handle_resize()?;
755 }
756 events.push(ev);
757 }
758 }
759
760 for ev in &events {
761 if matches!(
762 ev,
763 Event::Key(event::KeyEvent {
764 code: KeyCode::F(12),
765 kind: event::KeyEventKind::Press,
766 ..
767 })
768 ) {
769 state.debug_mode = !state.debug_mode;
770 }
771 }
772 }
773
774 update_last_mouse_pos(&mut state, &events);
775
776 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
777 clear_frame_layout_cache(&mut state);
778 }
779
780 sleep_for_fps_cap(config.max_fps, frame_start);
781 }
782
783 Ok(())
784}
785
786#[cfg(feature = "crossterm")]
792pub fn run_static(
793 output: &mut StaticOutput,
794 dynamic_height: u32,
795 mut f: impl FnMut(&mut Context),
796) -> io::Result<()> {
797 let config = RunConfig::default();
798 if !io::stdout().is_terminal() {
799 return Ok(());
800 }
801
802 install_panic_hook();
803
804 let initial_lines = output.drain_new();
805 write_static_lines(&initial_lines)?;
806
807 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
808 let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
809 if config.theme.bg != Color::Reset {
810 term.theme_bg = Some(config.theme.bg);
811 }
812
813 let mut events: Vec<Event> = Vec::new();
814 let mut state = FrameState::default();
815
816 loop {
817 let frame_start = Instant::now();
818 let (w, h) = term.size();
819 if w == 0 || h == 0 {
820 sleep_for_fps_cap(config.max_fps, frame_start);
821 continue;
822 }
823
824 let new_lines = output.drain_new();
825 write_static_lines(&new_lines)?;
826
827 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
828 break;
829 }
830
831 events.clear();
832 if crossterm::event::poll(config.tick_rate)? {
833 let raw = crossterm::event::read()?;
834 if let Some(ev) = event::from_crossterm(raw) {
835 if is_ctrl_c(&ev) {
836 break;
837 }
838 if let Event::Resize(_, _) = &ev {
839 term.handle_resize()?;
840 }
841 events.push(ev);
842 }
843
844 while crossterm::event::poll(Duration::ZERO)? {
845 let raw = crossterm::event::read()?;
846 if let Some(ev) = event::from_crossterm(raw) {
847 if is_ctrl_c(&ev) {
848 return Ok(());
849 }
850 if let Event::Resize(_, _) = &ev {
851 term.handle_resize()?;
852 }
853 events.push(ev);
854 }
855 }
856
857 for ev in &events {
858 if matches!(
859 ev,
860 Event::Key(event::KeyEvent {
861 code: KeyCode::F(12),
862 kind: event::KeyEventKind::Press,
863 ..
864 })
865 ) {
866 state.debug_mode = !state.debug_mode;
867 }
868 }
869 }
870
871 update_last_mouse_pos(&mut state, &events);
872
873 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
874 clear_frame_layout_cache(&mut state);
875 }
876
877 sleep_for_fps_cap(config.max_fps, frame_start);
878 }
879
880 Ok(())
881}
882
883#[cfg(feature = "crossterm")]
884fn write_static_lines(lines: &[String]) -> io::Result<()> {
885 if lines.is_empty() {
886 return Ok(());
887 }
888
889 let mut stdout = io::stdout();
890 for line in lines {
891 stdout.write_all(line.as_bytes())?;
892 stdout.write_all(b"\r\n")?;
893 }
894 stdout.flush()
895}
896
897fn run_frame(
898 term: &mut impl Backend,
899 state: &mut FrameState,
900 config: &RunConfig,
901 events: &[event::Event],
902 f: &mut impl FnMut(&mut context::Context),
903) -> io::Result<bool> {
904 let frame_start = Instant::now();
905 let (w, h) = term.size();
906 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
907 ctx.is_real_terminal = true;
908 ctx.process_focus_keys();
909
910 f(&mut ctx);
911 ctx.render_notifications();
912 ctx.emit_pending_tooltips();
913
914 if ctx.should_quit {
915 return Ok(false);
916 }
917 state.prev_modal_active = ctx.modal_active;
918 state.prev_modal_focus_start = ctx.modal_focus_start;
919 state.prev_modal_focus_count = ctx.modal_focus_count;
920 #[cfg(feature = "crossterm")]
921 let clipboard_text = ctx.clipboard_text.take();
922 #[cfg(not(feature = "crossterm"))]
923 let _clipboard_text = ctx.clipboard_text.take();
924
925 #[cfg(feature = "crossterm")]
926 let mut should_copy_selection = false;
927 #[cfg(feature = "crossterm")]
928 for ev in &ctx.events {
929 if let Event::Mouse(mouse) = ev {
930 match mouse.kind {
931 event::MouseKind::Down(event::MouseButton::Left) => {
932 state
933 .selection
934 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
935 }
936 event::MouseKind::Drag(event::MouseButton::Left) => {
937 state
938 .selection
939 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
940 }
941 event::MouseKind::Up(event::MouseButton::Left) => {
942 should_copy_selection = state.selection.active;
943 }
944 _ => {}
945 }
946 }
947 }
948
949 state.focus_index = ctx.focus_index;
950 state.prev_focus_count = ctx.focus_count;
951
952 let mut tree = layout::build_tree(&ctx.commands);
953 let area = crate::rect::Rect::new(0, 0, w, h);
954 layout::compute(&mut tree, area);
955 let fd = layout::collect_all(&tree);
956 state.prev_scroll_infos = fd.scroll_infos;
957 state.prev_scroll_rects = fd.scroll_rects;
958 state.prev_hit_map = fd.hit_areas;
959 state.prev_group_rects = fd.group_rects;
960 state.prev_content_map = fd.content_areas;
961 state.prev_focus_rects = fd.focus_rects;
962 state.prev_focus_groups = fd.focus_groups;
963 layout::render(&tree, term.buffer_mut());
964 let raw_rects = layout::collect_raw_draw_rects(&tree);
965 for (draw_id, rect) in raw_rects {
966 if let Some(cb) = ctx.deferred_draws.get_mut(draw_id).and_then(|c| c.take()) {
967 let buf = term.buffer_mut();
968 buf.push_clip(rect);
969 cb(buf, rect);
970 buf.pop_clip();
971 }
972 }
973 state.hook_states = ctx.hook_states;
974 state.notification_queue = ctx.notification_queue;
975
976 let frame_time = frame_start.elapsed();
977 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
978 let frame_secs = frame_time.as_secs_f32();
979 let inst_fps = if frame_secs > 0.0 {
980 1.0 / frame_secs
981 } else {
982 0.0
983 };
984 state.fps_ema = if state.fps_ema == 0.0 {
985 inst_fps
986 } else {
987 (state.fps_ema * 0.9) + (inst_fps * 0.1)
988 };
989 if state.debug_mode {
990 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
991 }
992
993 #[cfg(feature = "crossterm")]
994 if state.selection.active {
995 terminal::apply_selection_overlay(
996 term.buffer_mut(),
997 &state.selection,
998 &state.prev_content_map,
999 );
1000 }
1001 #[cfg(feature = "crossterm")]
1002 if should_copy_selection {
1003 let text = terminal::extract_selection_text(
1004 term.buffer_mut(),
1005 &state.selection,
1006 &state.prev_content_map,
1007 );
1008 if !text.is_empty() {
1009 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1010 }
1011 state.selection.clear();
1012 }
1013
1014 term.flush()?;
1015 #[cfg(feature = "crossterm")]
1016 if let Some(text) = clipboard_text {
1017 #[allow(clippy::print_stderr)]
1018 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1019 eprintln!("[slt] failed to copy to clipboard: {e}");
1020 }
1021 }
1022 state.tick = state.tick.wrapping_add(1);
1023
1024 Ok(true)
1025}
1026
1027fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1028 for ev in events {
1029 match ev {
1030 Event::Mouse(mouse) => {
1031 state.last_mouse_pos = Some((mouse.x, mouse.y));
1032 }
1033 Event::FocusLost => {
1034 state.last_mouse_pos = None;
1035 }
1036 _ => {}
1037 }
1038 }
1039}
1040
1041fn clear_frame_layout_cache(state: &mut FrameState) {
1042 state.prev_hit_map.clear();
1043 state.prev_group_rects.clear();
1044 state.prev_content_map.clear();
1045 state.prev_focus_rects.clear();
1046 state.prev_focus_groups.clear();
1047 state.prev_scroll_infos.clear();
1048 state.prev_scroll_rects.clear();
1049 state.last_mouse_pos = None;
1050}
1051
1052#[cfg(feature = "crossterm")]
1053fn is_ctrl_c(ev: &Event) -> bool {
1054 matches!(
1055 ev,
1056 Event::Key(event::KeyEvent {
1057 code: KeyCode::Char('c'),
1058 modifiers,
1059 kind: event::KeyEventKind::Press,
1060 }) if modifiers.contains(KeyModifiers::CONTROL)
1061 )
1062}
1063
1064fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1065 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1066 let target = Duration::from_secs_f64(1.0 / fps as f64);
1067 let elapsed = frame_start.elapsed();
1068 if elapsed < target {
1069 std::thread::sleep(target - elapsed);
1070 }
1071 }
1072}