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