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