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::{
112 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
113};
114pub use halfblock::HalfBlockImage;
115pub use keymap::{Binding, KeyMap};
116pub use layout::Direction;
117pub use palette::Palette;
118pub use rect::Rect;
119pub use style::{
120 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
121 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
122};
123pub use widgets::{
124 AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
125 DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, ListState,
126 MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
127 ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
128 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
129 ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
130};
131
132pub trait Backend {
182 fn size(&self) -> (u32, u32);
184
185 fn buffer_mut(&mut self) -> &mut Buffer;
190
191 fn flush(&mut self) -> io::Result<()>;
197}
198
199pub struct AppState {
211 pub(crate) inner: FrameState,
212}
213
214impl AppState {
215 pub fn new() -> Self {
217 Self {
218 inner: FrameState::default(),
219 }
220 }
221
222 pub fn tick(&self) -> u64 {
224 self.inner.tick
225 }
226
227 pub fn fps(&self) -> f32 {
229 self.inner.fps_ema
230 }
231
232 pub fn set_debug(&mut self, enabled: bool) {
234 self.inner.debug_mode = enabled;
235 }
236}
237
238impl Default for AppState {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244pub fn frame(
273 backend: &mut impl Backend,
274 state: &mut AppState,
275 config: &RunConfig,
276 events: &[Event],
277 f: &mut impl FnMut(&mut Context),
278) -> io::Result<bool> {
279 run_frame(backend, &mut state.inner, config, events, f)
280}
281
282#[cfg(feature = "crossterm")]
283static PANIC_HOOK_ONCE: Once = Once::new();
284
285#[allow(clippy::print_stderr)]
286#[cfg(feature = "crossterm")]
287fn install_panic_hook() {
288 PANIC_HOOK_ONCE.call_once(|| {
289 let original = std::panic::take_hook();
290 std::panic::set_hook(Box::new(move |panic_info| {
291 let _ = crossterm::terminal::disable_raw_mode();
292 let mut stdout = io::stdout();
293 let _ = crossterm::execute!(
294 stdout,
295 crossterm::terminal::LeaveAlternateScreen,
296 crossterm::cursor::Show,
297 crossterm::event::DisableMouseCapture,
298 crossterm::event::DisableBracketedPaste,
299 crossterm::style::ResetColor,
300 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
301 );
302
303 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
305
306 if let Some(location) = panic_info.location() {
308 eprintln!(
309 "\x1b[90m{}:{}:{}\x1b[0m",
310 location.file(),
311 location.line(),
312 location.column()
313 );
314 }
315
316 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
318 eprintln!("\x1b[1m{}\x1b[0m", msg);
319 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
320 eprintln!("\x1b[1m{}\x1b[0m", msg);
321 }
322
323 eprintln!(
324 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
325 );
326
327 original(panic_info);
328 }));
329 });
330}
331
332#[non_exhaustive]
350#[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 pub scroll_speed: u32,
386 pub title: Option<String>,
388}
389
390impl Default for RunConfig {
391 fn default() -> Self {
392 Self {
393 tick_rate: Duration::from_millis(16),
394 mouse: false,
395 kitty_keyboard: false,
396 theme: Theme::dark(),
397 color_depth: None,
398 max_fps: Some(60),
399 scroll_speed: 1,
400 title: None,
401 }
402 }
403}
404
405impl RunConfig {
406 pub fn tick_rate(mut self, rate: Duration) -> Self {
408 self.tick_rate = rate;
409 self
410 }
411
412 pub fn mouse(mut self, enabled: bool) -> Self {
414 self.mouse = enabled;
415 self
416 }
417
418 pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
420 self.kitty_keyboard = enabled;
421 self
422 }
423
424 pub fn theme(mut self, theme: Theme) -> Self {
426 self.theme = theme;
427 self
428 }
429
430 pub fn color_depth(mut self, depth: ColorDepth) -> Self {
432 self.color_depth = Some(depth);
433 self
434 }
435
436 pub fn max_fps(mut self, fps: u32) -> Self {
438 self.max_fps = Some(fps);
439 self
440 }
441
442 pub fn scroll_speed(mut self, lines: u32) -> Self {
444 self.scroll_speed = lines.max(1);
445 self
446 }
447
448 pub fn title(mut self, title: impl Into<String>) -> Self {
450 self.title = Some(title.into());
451 self
452 }
453}
454
455pub(crate) struct FrameState {
456 pub hook_states: Vec<Box<dyn std::any::Any>>,
457 pub focus_index: usize,
458 pub prev_focus_count: usize,
459 pub prev_modal_focus_start: usize,
460 pub prev_modal_focus_count: usize,
461 pub tick: u64,
462 pub prev_scroll_infos: Vec<(u32, u32)>,
463 pub prev_scroll_rects: Vec<rect::Rect>,
464 pub prev_hit_map: Vec<rect::Rect>,
465 pub prev_group_rects: Vec<(String, rect::Rect)>,
466 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
467 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
468 pub prev_focus_groups: Vec<Option<String>>,
469 pub last_mouse_pos: Option<(u32, u32)>,
470 pub prev_modal_active: bool,
471 pub notification_queue: Vec<(String, ToastLevel, u64)>,
472 pub debug_mode: bool,
473 pub fps_ema: f32,
474 #[cfg(feature = "crossterm")]
475 pub selection: terminal::SelectionState,
476}
477
478impl Default for FrameState {
479 fn default() -> Self {
480 Self {
481 hook_states: Vec::new(),
482 focus_index: 0,
483 prev_focus_count: 0,
484 prev_modal_focus_start: 0,
485 prev_modal_focus_count: 0,
486 tick: 0,
487 prev_scroll_infos: Vec::new(),
488 prev_scroll_rects: Vec::new(),
489 prev_hit_map: Vec::new(),
490 prev_group_rects: Vec::new(),
491 prev_content_map: Vec::new(),
492 prev_focus_rects: Vec::new(),
493 prev_focus_groups: Vec::new(),
494 last_mouse_pos: None,
495 prev_modal_active: false,
496 notification_queue: Vec::new(),
497 debug_mode: false,
498 fps_ema: 0.0,
499 #[cfg(feature = "crossterm")]
500 selection: terminal::SelectionState::default(),
501 }
502 }
503}
504
505#[cfg(feature = "crossterm")]
520pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
521 run_with(RunConfig::default(), f)
522}
523
524#[cfg(feature = "crossterm")]
525fn set_terminal_title(title: &Option<String>) {
526 if let Some(title) = title {
527 use std::io::Write;
528 let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
529 }
530}
531
532#[cfg(feature = "crossterm")]
552pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
553 if !io::stdout().is_terminal() {
554 return Ok(());
555 }
556
557 install_panic_hook();
558 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
559 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
560 set_terminal_title(&config.title);
561 if config.theme.bg != Color::Reset {
562 term.theme_bg = Some(config.theme.bg);
563 }
564 let mut events: Vec<Event> = Vec::new();
565 let mut state = FrameState::default();
566
567 loop {
568 let frame_start = Instant::now();
569 let (w, h) = term.size();
570 if w == 0 || h == 0 {
571 sleep_for_fps_cap(config.max_fps, frame_start);
572 continue;
573 }
574
575 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
576 break;
577 }
578
579 events.clear();
580 if crossterm::event::poll(config.tick_rate)? {
581 let raw = crossterm::event::read()?;
582 if let Some(ev) = event::from_crossterm(raw) {
583 if is_ctrl_c(&ev) {
584 break;
585 }
586 if let Event::Resize(_, _) = &ev {
587 term.handle_resize()?;
588 }
589 events.push(ev);
590 }
591
592 while crossterm::event::poll(Duration::ZERO)? {
593 let raw = crossterm::event::read()?;
594 if let Some(ev) = event::from_crossterm(raw) {
595 if is_ctrl_c(&ev) {
596 return Ok(());
597 }
598 if let Event::Resize(_, _) = &ev {
599 term.handle_resize()?;
600 }
601 events.push(ev);
602 }
603 }
604
605 for ev in &events {
606 if matches!(
607 ev,
608 Event::Key(event::KeyEvent {
609 code: KeyCode::F(12),
610 kind: event::KeyEventKind::Press,
611 ..
612 })
613 ) {
614 state.debug_mode = !state.debug_mode;
615 }
616 }
617 }
618
619 update_last_mouse_pos(&mut state, &events);
620
621 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
622 clear_frame_layout_cache(&mut state);
623 }
624
625 sleep_for_fps_cap(config.max_fps, frame_start);
626 }
627
628 Ok(())
629}
630
631#[cfg(all(feature = "crossterm", feature = "async"))]
652pub fn run_async<M: Send + 'static>(
653 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
654) -> io::Result<tokio::sync::mpsc::Sender<M>> {
655 run_async_with(RunConfig::default(), f)
656}
657
658#[cfg(all(feature = "crossterm", feature = "async"))]
665pub fn run_async_with<M: Send + 'static>(
666 config: RunConfig,
667 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
668) -> io::Result<tokio::sync::mpsc::Sender<M>> {
669 let (tx, rx) = tokio::sync::mpsc::channel(100);
670 let handle =
671 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
672
673 handle.spawn_blocking(move || {
674 let _ = run_async_loop(config, f, rx);
675 });
676
677 Ok(tx)
678}
679
680#[cfg(all(feature = "crossterm", feature = "async"))]
681fn run_async_loop<M: Send + 'static>(
682 config: RunConfig,
683 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
684 mut rx: tokio::sync::mpsc::Receiver<M>,
685) -> io::Result<()> {
686 if !io::stdout().is_terminal() {
687 return Ok(());
688 }
689
690 install_panic_hook();
691 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
692 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
693 set_terminal_title(&config.title);
694 if config.theme.bg != Color::Reset {
695 term.theme_bg = Some(config.theme.bg);
696 }
697 let mut events: Vec<Event> = Vec::new();
698 let mut state = FrameState::default();
699
700 loop {
701 let frame_start = Instant::now();
702 let mut messages: Vec<M> = Vec::new();
703 while let Ok(message) = rx.try_recv() {
704 messages.push(message);
705 }
706
707 let (w, h) = term.size();
708 if w == 0 || h == 0 {
709 sleep_for_fps_cap(config.max_fps, frame_start);
710 continue;
711 }
712
713 let mut render = |ctx: &mut Context| {
714 f(ctx, &mut messages);
715 };
716 if !run_frame(&mut term, &mut state, &config, &events, &mut render)? {
717 break;
718 }
719
720 events.clear();
721 if crossterm::event::poll(config.tick_rate)? {
722 let raw = crossterm::event::read()?;
723 if let Some(ev) = event::from_crossterm(raw) {
724 if is_ctrl_c(&ev) {
725 break;
726 }
727 if let Event::Resize(_, _) = &ev {
728 term.handle_resize()?;
729 clear_frame_layout_cache(&mut state);
730 }
731 events.push(ev);
732 }
733
734 while crossterm::event::poll(Duration::ZERO)? {
735 let raw = crossterm::event::read()?;
736 if let Some(ev) = event::from_crossterm(raw) {
737 if is_ctrl_c(&ev) {
738 return Ok(());
739 }
740 if let Event::Resize(_, _) = &ev {
741 term.handle_resize()?;
742 clear_frame_layout_cache(&mut state);
743 }
744 events.push(ev);
745 }
746 }
747 }
748
749 update_last_mouse_pos(&mut state, &events);
750
751 sleep_for_fps_cap(config.max_fps, frame_start);
752 }
753
754 Ok(())
755}
756
757#[cfg(feature = "crossterm")]
773pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
774 run_inline_with(height, RunConfig::default(), f)
775}
776
777#[cfg(feature = "crossterm")]
782pub fn run_inline_with(
783 height: u32,
784 config: RunConfig,
785 mut f: impl FnMut(&mut Context),
786) -> io::Result<()> {
787 if !io::stdout().is_terminal() {
788 return Ok(());
789 }
790
791 install_panic_hook();
792 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
793 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
794 set_terminal_title(&config.title);
795 if config.theme.bg != Color::Reset {
796 term.theme_bg = Some(config.theme.bg);
797 }
798 let mut events: Vec<Event> = Vec::new();
799 let mut state = FrameState::default();
800
801 loop {
802 let frame_start = Instant::now();
803 let (w, h) = term.size();
804 if w == 0 || h == 0 {
805 sleep_for_fps_cap(config.max_fps, frame_start);
806 continue;
807 }
808
809 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
810 break;
811 }
812
813 events.clear();
814 if crossterm::event::poll(config.tick_rate)? {
815 let raw = crossterm::event::read()?;
816 if let Some(ev) = event::from_crossterm(raw) {
817 if is_ctrl_c(&ev) {
818 break;
819 }
820 if let Event::Resize(_, _) = &ev {
821 term.handle_resize()?;
822 }
823 events.push(ev);
824 }
825
826 while crossterm::event::poll(Duration::ZERO)? {
827 let raw = crossterm::event::read()?;
828 if let Some(ev) = event::from_crossterm(raw) {
829 if is_ctrl_c(&ev) {
830 return Ok(());
831 }
832 if let Event::Resize(_, _) = &ev {
833 term.handle_resize()?;
834 }
835 events.push(ev);
836 }
837 }
838
839 for ev in &events {
840 if matches!(
841 ev,
842 Event::Key(event::KeyEvent {
843 code: KeyCode::F(12),
844 kind: event::KeyEventKind::Press,
845 ..
846 })
847 ) {
848 state.debug_mode = !state.debug_mode;
849 }
850 }
851 }
852
853 update_last_mouse_pos(&mut state, &events);
854
855 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
856 clear_frame_layout_cache(&mut state);
857 }
858
859 sleep_for_fps_cap(config.max_fps, frame_start);
860 }
861
862 Ok(())
863}
864
865#[cfg(feature = "crossterm")]
871pub fn run_static(
872 output: &mut StaticOutput,
873 dynamic_height: u32,
874 mut f: impl FnMut(&mut Context),
875) -> io::Result<()> {
876 let config = RunConfig::default();
877 if !io::stdout().is_terminal() {
878 return Ok(());
879 }
880
881 install_panic_hook();
882
883 let initial_lines = output.drain_new();
884 write_static_lines(&initial_lines)?;
885
886 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
887 let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
888 set_terminal_title(&config.title);
889 if config.theme.bg != Color::Reset {
890 term.theme_bg = Some(config.theme.bg);
891 }
892
893 let mut events: Vec<Event> = Vec::new();
894 let mut state = FrameState::default();
895
896 loop {
897 let frame_start = Instant::now();
898 let (w, h) = term.size();
899 if w == 0 || h == 0 {
900 sleep_for_fps_cap(config.max_fps, frame_start);
901 continue;
902 }
903
904 let new_lines = output.drain_new();
905 write_static_lines(&new_lines)?;
906
907 if !run_frame(&mut term, &mut state, &config, &events, &mut f)? {
908 break;
909 }
910
911 events.clear();
912 if crossterm::event::poll(config.tick_rate)? {
913 let raw = crossterm::event::read()?;
914 if let Some(ev) = event::from_crossterm(raw) {
915 if is_ctrl_c(&ev) {
916 break;
917 }
918 if let Event::Resize(_, _) = &ev {
919 term.handle_resize()?;
920 }
921 events.push(ev);
922 }
923
924 while crossterm::event::poll(Duration::ZERO)? {
925 let raw = crossterm::event::read()?;
926 if let Some(ev) = event::from_crossterm(raw) {
927 if is_ctrl_c(&ev) {
928 return Ok(());
929 }
930 if let Event::Resize(_, _) = &ev {
931 term.handle_resize()?;
932 }
933 events.push(ev);
934 }
935 }
936
937 for ev in &events {
938 if matches!(
939 ev,
940 Event::Key(event::KeyEvent {
941 code: KeyCode::F(12),
942 kind: event::KeyEventKind::Press,
943 ..
944 })
945 ) {
946 state.debug_mode = !state.debug_mode;
947 }
948 }
949 }
950
951 update_last_mouse_pos(&mut state, &events);
952
953 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
954 clear_frame_layout_cache(&mut state);
955 }
956
957 sleep_for_fps_cap(config.max_fps, frame_start);
958 }
959
960 Ok(())
961}
962
963#[cfg(feature = "crossterm")]
964fn write_static_lines(lines: &[String]) -> io::Result<()> {
965 if lines.is_empty() {
966 return Ok(());
967 }
968
969 let mut stdout = io::stdout();
970 for line in lines {
971 stdout.write_all(line.as_bytes())?;
972 stdout.write_all(b"\r\n")?;
973 }
974 stdout.flush()
975}
976
977fn run_frame(
978 term: &mut impl Backend,
979 state: &mut FrameState,
980 config: &RunConfig,
981 events: &[event::Event],
982 f: &mut impl FnMut(&mut context::Context),
983) -> io::Result<bool> {
984 let frame_start = Instant::now();
985 let (w, h) = term.size();
986 let mut ctx = Context::new(events.to_vec(), w, h, state, config.theme);
987 ctx.is_real_terminal = true;
988 ctx.set_scroll_speed(config.scroll_speed);
989
990 f(&mut ctx);
991 ctx.process_focus_keys();
992 ctx.render_notifications();
993 ctx.emit_pending_tooltips();
994
995 if ctx.should_quit {
996 return Ok(false);
997 }
998 state.prev_modal_active = ctx.modal_active;
999 state.prev_modal_focus_start = ctx.modal_focus_start;
1000 state.prev_modal_focus_count = ctx.modal_focus_count;
1001 #[cfg(feature = "crossterm")]
1002 let clipboard_text = ctx.clipboard_text.take();
1003 #[cfg(not(feature = "crossterm"))]
1004 let _clipboard_text = ctx.clipboard_text.take();
1005
1006 #[cfg(feature = "crossterm")]
1007 let mut should_copy_selection = false;
1008 #[cfg(feature = "crossterm")]
1009 for ev in &ctx.events {
1010 if let Event::Mouse(mouse) = ev {
1011 match mouse.kind {
1012 event::MouseKind::Down(event::MouseButton::Left) => {
1013 state
1014 .selection
1015 .mouse_down(mouse.x, mouse.y, &state.prev_content_map);
1016 }
1017 event::MouseKind::Drag(event::MouseButton::Left) => {
1018 state
1019 .selection
1020 .mouse_drag(mouse.x, mouse.y, &state.prev_content_map);
1021 }
1022 event::MouseKind::Up(event::MouseButton::Left) => {
1023 should_copy_selection = state.selection.active;
1024 }
1025 _ => {}
1026 }
1027 }
1028 }
1029
1030 state.focus_index = ctx.focus_index;
1031 state.prev_focus_count = ctx.focus_count;
1032
1033 let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
1034 let area = crate::rect::Rect::new(0, 0, w, h);
1035 layout::compute(&mut tree, area);
1036 let fd = layout::collect_all(&tree);
1037 state.prev_scroll_infos = fd.scroll_infos;
1038 state.prev_scroll_rects = fd.scroll_rects;
1039 state.prev_hit_map = fd.hit_areas;
1040 state.prev_group_rects = fd.group_rects;
1041 state.prev_content_map = fd.content_areas;
1042 state.prev_focus_rects = fd.focus_rects;
1043 state.prev_focus_groups = fd.focus_groups;
1044 layout::render(&tree, term.buffer_mut());
1045 let raw_rects = fd.raw_draw_rects;
1046 for rdr in raw_rects {
1047 if rdr.rect.width == 0 || rdr.rect.height == 0 {
1048 continue;
1049 }
1050 if let Some(cb) = ctx
1051 .deferred_draws
1052 .get_mut(rdr.draw_id)
1053 .and_then(|c| c.take())
1054 {
1055 let buf = term.buffer_mut();
1056 buf.push_clip(rdr.rect);
1057 buf.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
1058 cb(buf, rdr.rect);
1059 buf.kitty_clip_info = None;
1060 buf.pop_clip();
1061 }
1062 }
1063 state.hook_states = ctx.hook_states;
1064 state.notification_queue = ctx.notification_queue;
1065
1066 let frame_time = frame_start.elapsed();
1067 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1068 let frame_secs = frame_time.as_secs_f32();
1069 let inst_fps = if frame_secs > 0.0 {
1070 1.0 / frame_secs
1071 } else {
1072 0.0
1073 };
1074 state.fps_ema = if state.fps_ema == 0.0 {
1075 inst_fps
1076 } else {
1077 (state.fps_ema * 0.9) + (inst_fps * 0.1)
1078 };
1079 if state.debug_mode {
1080 layout::render_debug_overlay(&tree, term.buffer_mut(), frame_time_us, state.fps_ema);
1081 }
1082
1083 #[cfg(feature = "crossterm")]
1084 if state.selection.active {
1085 terminal::apply_selection_overlay(
1086 term.buffer_mut(),
1087 &state.selection,
1088 &state.prev_content_map,
1089 );
1090 }
1091 #[cfg(feature = "crossterm")]
1092 if should_copy_selection {
1093 let text = terminal::extract_selection_text(
1094 term.buffer_mut(),
1095 &state.selection,
1096 &state.prev_content_map,
1097 );
1098 if !text.is_empty() {
1099 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1100 }
1101 state.selection.clear();
1102 }
1103
1104 term.flush()?;
1105 #[cfg(feature = "crossterm")]
1106 if let Some(text) = clipboard_text {
1107 #[allow(clippy::print_stderr)]
1108 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1109 eprintln!("[slt] failed to copy to clipboard: {e}");
1110 }
1111 }
1112 state.tick = state.tick.wrapping_add(1);
1113
1114 Ok(true)
1115}
1116
1117#[cfg(feature = "crossterm")]
1118fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1119 for ev in events {
1120 match ev {
1121 Event::Mouse(mouse) => {
1122 state.last_mouse_pos = Some((mouse.x, mouse.y));
1123 }
1124 Event::FocusLost => {
1125 state.last_mouse_pos = None;
1126 }
1127 _ => {}
1128 }
1129 }
1130}
1131
1132#[cfg(feature = "crossterm")]
1133fn clear_frame_layout_cache(state: &mut FrameState) {
1134 state.prev_hit_map.clear();
1135 state.prev_group_rects.clear();
1136 state.prev_content_map.clear();
1137 state.prev_focus_rects.clear();
1138 state.prev_focus_groups.clear();
1139 state.prev_scroll_infos.clear();
1140 state.prev_scroll_rects.clear();
1141 state.last_mouse_pos = None;
1142}
1143
1144#[cfg(feature = "crossterm")]
1145fn is_ctrl_c(ev: &Event) -> bool {
1146 matches!(
1147 ev,
1148 Event::Key(event::KeyEvent {
1149 code: KeyCode::Char('c'),
1150 modifiers,
1151 kind: event::KeyEventKind::Press,
1152 }) if modifiers.contains(KeyModifiers::CONTROL)
1153 )
1154}
1155
1156#[cfg(feature = "crossterm")]
1157fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1158 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1159 let target = Duration::from_secs_f64(1.0 / fps as f64);
1160 let elapsed = frame_start.elapsed();
1161 if elapsed < target {
1162 std::thread::sleep(target - elapsed);
1163 }
1164 }
1165}