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")]
103#[doc(hidden)]
104pub use terminal::__bench_flush_buffer_diff;
105#[cfg(feature = "crossterm")]
106pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
107#[cfg(feature = "crossterm")]
108use terminal::{InlineTerminal, Terminal};
109
110pub use crate::test_utils::{EventBuilder, TestBackend};
111pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
116pub use buffer::Buffer;
117pub use cell::Cell;
118pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
122pub use context::{
123 Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
124 Response, State, TreemapItem, Widget,
125};
126pub use event::{
127 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
128};
129pub use halfblock::HalfBlockImage;
130pub use keymap::{Binding, KeyMap};
131pub use layout::Direction;
132pub use palette::Palette;
133pub use rect::Rect;
134pub use style::{
135 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
136 Justify, Margin, Modifiers, Padding, Spacing, Style, Theme, ThemeBuilder, ThemeColor,
137 WidgetColors, WidgetTheme,
138};
139pub use widgets::{
140 AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
141 DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, GridColumn, ListState,
142 ModeState, MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState,
143 ScreenState, ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
144 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
145 ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
146};
147
148pub trait Backend {
198 fn size(&self) -> (u32, u32);
200
201 fn buffer_mut(&mut self) -> &mut Buffer;
206
207 fn flush(&mut self) -> io::Result<()>;
213}
214
215pub struct AppState {
227 pub(crate) inner: FrameState,
228}
229
230impl AppState {
231 pub fn new() -> Self {
233 Self {
234 inner: FrameState::default(),
235 }
236 }
237
238 pub fn tick(&self) -> u64 {
240 self.inner.diagnostics.tick
241 }
242
243 pub fn fps(&self) -> f32 {
245 self.inner.diagnostics.fps_ema
246 }
247
248 pub fn set_debug(&mut self, enabled: bool) {
250 self.inner.diagnostics.debug_mode = enabled;
251 }
252}
253
254impl Default for AppState {
255 fn default() -> Self {
256 Self::new()
257 }
258}
259
260pub fn frame(
293 backend: &mut impl Backend,
294 state: &mut AppState,
295 config: &RunConfig,
296 events: &[Event],
297 f: &mut impl FnMut(&mut Context),
298) -> io::Result<bool> {
299 run_frame(backend, &mut state.inner, config, events.to_vec(), f)
300}
301
302#[cfg(feature = "crossterm")]
303static PANIC_HOOK_ONCE: Once = Once::new();
304
305#[allow(clippy::print_stderr)]
306#[cfg(feature = "crossterm")]
307fn install_panic_hook() {
308 PANIC_HOOK_ONCE.call_once(|| {
309 let original = std::panic::take_hook();
310 std::panic::set_hook(Box::new(move |panic_info| {
311 let _ = crossterm::terminal::disable_raw_mode();
312 let mut stdout = io::stdout();
313 let _ = crossterm::execute!(
314 stdout,
315 crossterm::terminal::LeaveAlternateScreen,
316 crossterm::cursor::Show,
317 crossterm::event::DisableMouseCapture,
318 crossterm::event::DisableBracketedPaste,
319 crossterm::style::ResetColor,
320 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
321 );
322
323 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
325
326 if let Some(location) = panic_info.location() {
328 eprintln!(
329 "\x1b[90m{}:{}:{}\x1b[0m",
330 location.file(),
331 location.line(),
332 location.column()
333 );
334 }
335
336 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
338 eprintln!("\x1b[1m{}\x1b[0m", msg);
339 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
340 eprintln!("\x1b[1m{}\x1b[0m", msg);
341 }
342
343 eprintln!(
344 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
345 );
346
347 original(panic_info);
348 }));
349 });
350}
351
352#[non_exhaustive]
371#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
372pub struct RunConfig {
373 pub tick_rate: Duration,
378 pub mouse: bool,
383 pub kitty_keyboard: bool,
390 pub theme: Theme,
394 pub color_depth: Option<ColorDepth>,
400 pub max_fps: Option<u32>,
405 pub scroll_speed: u32,
407 pub title: Option<String>,
409 pub widget_theme: style::WidgetTheme,
414}
415
416impl Default for RunConfig {
417 fn default() -> Self {
418 Self {
419 tick_rate: Duration::from_millis(16),
420 mouse: false,
421 kitty_keyboard: false,
422 theme: Theme::dark(),
423 color_depth: None,
424 max_fps: Some(60),
425 scroll_speed: 1,
426 title: None,
427 widget_theme: style::WidgetTheme::new(),
428 }
429 }
430}
431
432impl RunConfig {
433 pub fn tick_rate(mut self, rate: Duration) -> Self {
435 self.tick_rate = rate;
436 self
437 }
438
439 pub fn mouse(mut self, enabled: bool) -> Self {
441 self.mouse = enabled;
442 self
443 }
444
445 pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
447 self.kitty_keyboard = enabled;
448 self
449 }
450
451 pub fn theme(mut self, theme: Theme) -> Self {
453 self.theme = theme;
454 self
455 }
456
457 pub fn color_depth(mut self, depth: ColorDepth) -> Self {
459 self.color_depth = Some(depth);
460 self
461 }
462
463 pub fn max_fps(mut self, fps: u32) -> Self {
465 self.max_fps = Some(fps);
466 self
467 }
468
469 pub fn scroll_speed(mut self, lines: u32) -> Self {
471 self.scroll_speed = lines.max(1);
472 self
473 }
474
475 pub fn title(mut self, title: impl Into<String>) -> Self {
477 self.title = Some(title.into());
478 self
479 }
480
481 pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
483 self.widget_theme = widget_theme;
484 self
485 }
486}
487
488#[derive(Default)]
489pub(crate) struct FocusState {
490 pub focus_index: usize,
491 pub prev_focus_count: usize,
492 pub prev_modal_active: bool,
493 pub prev_modal_focus_start: usize,
494 pub prev_modal_focus_count: usize,
495}
496
497#[derive(Default)]
498pub(crate) struct LayoutFeedbackState {
499 pub prev_scroll_infos: Vec<(u32, u32)>,
500 pub prev_scroll_rects: Vec<rect::Rect>,
501 pub prev_hit_map: Vec<rect::Rect>,
502 pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
503 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
504 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
505 pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
506 pub last_mouse_pos: Option<(u32, u32)>,
507}
508
509#[derive(Default)]
510pub(crate) struct DiagnosticsState {
511 pub tick: u64,
512 pub notification_queue: Vec<(String, ToastLevel, u64)>,
513 pub debug_mode: bool,
514 pub fps_ema: f32,
515}
516
517#[derive(Default)]
518pub(crate) struct FrameState {
519 pub hook_states: Vec<Box<dyn std::any::Any>>,
520 pub screen_hook_map: std::collections::HashMap<String, (usize, usize)>,
521 pub focus: FocusState,
522 pub layout_feedback: LayoutFeedbackState,
523 pub diagnostics: DiagnosticsState,
524 #[cfg(feature = "crossterm")]
525 pub selection: terminal::SelectionState,
526}
527
528#[cfg(feature = "crossterm")]
543pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
544 run_with(RunConfig::default(), f)
545}
546
547#[cfg(feature = "crossterm")]
548fn set_terminal_title(title: &Option<String>) {
549 if let Some(title) = title {
550 use std::io::Write;
551 let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
552 }
553}
554
555#[cfg(feature = "crossterm")]
575pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
576 if !io::stdout().is_terminal() {
577 return Ok(());
578 }
579
580 install_panic_hook();
581 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
582 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
583 set_terminal_title(&config.title);
584 if config.theme.bg != Color::Reset {
585 term.theme_bg = Some(config.theme.bg);
586 }
587 let mut events: Vec<Event> = Vec::new();
588 let mut state = FrameState::default();
589
590 loop {
591 let frame_start = Instant::now();
592 let (w, h) = term.size();
593 if w == 0 || h == 0 {
594 sleep_for_fps_cap(config.max_fps, frame_start);
595 continue;
596 }
597
598 if !run_frame(
599 &mut term,
600 &mut state,
601 &config,
602 std::mem::take(&mut events),
603 &mut f,
604 )? {
605 break;
606 }
607
608 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
609 term.handle_resize()
610 })? {
611 break;
612 }
613
614 sleep_for_fps_cap(config.max_fps, frame_start);
615 }
616
617 Ok(())
618}
619
620#[cfg(all(feature = "crossterm", feature = "async"))]
641pub fn run_async<M: Send + 'static>(
642 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
643) -> io::Result<tokio::sync::mpsc::Sender<M>> {
644 run_async_with(RunConfig::default(), f)
645}
646
647#[cfg(all(feature = "crossterm", feature = "async"))]
654pub fn run_async_with<M: Send + 'static>(
655 config: RunConfig,
656 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
657) -> io::Result<tokio::sync::mpsc::Sender<M>> {
658 let (tx, rx) = tokio::sync::mpsc::channel(100);
659 let handle =
660 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
661
662 handle.spawn_blocking(move || {
663 let _ = run_async_loop(config, f, rx);
664 });
665
666 Ok(tx)
667}
668
669#[cfg(all(feature = "crossterm", feature = "async"))]
670fn run_async_loop<M: Send + 'static>(
671 config: RunConfig,
672 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
673 mut rx: tokio::sync::mpsc::Receiver<M>,
674) -> io::Result<()> {
675 if !io::stdout().is_terminal() {
676 return Ok(());
677 }
678
679 install_panic_hook();
680 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
681 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
682 set_terminal_title(&config.title);
683 if config.theme.bg != Color::Reset {
684 term.theme_bg = Some(config.theme.bg);
685 }
686 let mut events: Vec<Event> = Vec::new();
687 let mut state = FrameState::default();
688
689 loop {
690 let frame_start = Instant::now();
691 let mut messages: Vec<M> = Vec::new();
692 while let Ok(message) = rx.try_recv() {
693 messages.push(message);
694 }
695
696 let (w, h) = term.size();
697 if w == 0 || h == 0 {
698 sleep_for_fps_cap(config.max_fps, frame_start);
699 continue;
700 }
701
702 let mut render = |ctx: &mut Context| {
703 f(ctx, &mut messages);
704 };
705 if !run_frame(
706 &mut term,
707 &mut state,
708 &config,
709 std::mem::take(&mut events),
710 &mut render,
711 )? {
712 break;
713 }
714
715 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
716 term.handle_resize()
717 })? {
718 break;
719 }
720
721 sleep_for_fps_cap(config.max_fps, frame_start);
722 }
723
724 Ok(())
725}
726
727#[cfg(feature = "crossterm")]
746pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
747 run_inline_with(height, RunConfig::default(), f)
748}
749
750#[cfg(feature = "crossterm")]
755pub fn run_inline_with(
756 height: u32,
757 config: RunConfig,
758 mut f: impl FnMut(&mut Context),
759) -> io::Result<()> {
760 if !io::stdout().is_terminal() {
761 return Ok(());
762 }
763
764 install_panic_hook();
765 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
766 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
767 set_terminal_title(&config.title);
768 if config.theme.bg != Color::Reset {
769 term.theme_bg = Some(config.theme.bg);
770 }
771 let mut events: Vec<Event> = Vec::new();
772 let mut state = FrameState::default();
773
774 loop {
775 let frame_start = Instant::now();
776 let (w, h) = term.size();
777 if w == 0 || h == 0 {
778 sleep_for_fps_cap(config.max_fps, frame_start);
779 continue;
780 }
781
782 if !run_frame(
783 &mut term,
784 &mut state,
785 &config,
786 std::mem::take(&mut events),
787 &mut f,
788 )? {
789 break;
790 }
791
792 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
793 term.handle_resize()
794 })? {
795 break;
796 }
797
798 sleep_for_fps_cap(config.max_fps, frame_start);
799 }
800
801 Ok(())
802}
803
804#[cfg(feature = "crossterm")]
812pub fn run_static(
813 output: &mut StaticOutput,
814 dynamic_height: u32,
815 f: impl FnMut(&mut Context),
816) -> io::Result<()> {
817 run_static_with(output, dynamic_height, RunConfig::default(), f)
818}
819
820#[cfg(feature = "crossterm")]
825pub fn run_static_with(
826 output: &mut StaticOutput,
827 dynamic_height: u32,
828 config: RunConfig,
829 mut f: impl FnMut(&mut Context),
830) -> io::Result<()> {
831 if !io::stdout().is_terminal() {
832 return Ok(());
833 }
834
835 install_panic_hook();
836
837 let initial_lines = output.drain_new();
838 write_static_lines(&initial_lines)?;
839
840 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
841 let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
842 set_terminal_title(&config.title);
843 if config.theme.bg != Color::Reset {
844 term.theme_bg = Some(config.theme.bg);
845 }
846
847 let mut events: Vec<Event> = Vec::new();
848 let mut state = FrameState::default();
849
850 loop {
851 let frame_start = Instant::now();
852 let (w, h) = term.size();
853 if w == 0 || h == 0 {
854 sleep_for_fps_cap(config.max_fps, frame_start);
855 continue;
856 }
857
858 let new_lines = output.drain_new();
859 write_static_lines(&new_lines)?;
860
861 if !run_frame(
862 &mut term,
863 &mut state,
864 &config,
865 std::mem::take(&mut events),
866 &mut f,
867 )? {
868 break;
869 }
870
871 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
872 term.handle_resize()
873 })? {
874 break;
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
897#[cfg(feature = "crossterm")]
900fn poll_events(
901 events: &mut Vec<Event>,
902 state: &mut FrameState,
903 tick_rate: Duration,
904 on_resize: &mut impl FnMut() -> io::Result<()>,
905) -> io::Result<bool> {
906 if crossterm::event::poll(tick_rate)? {
907 let raw = crossterm::event::read()?;
908 if let Some(ev) = event::from_crossterm(raw) {
909 if is_ctrl_c(&ev) {
910 return Ok(false);
911 }
912 if matches!(ev, Event::Resize(_, _)) {
913 on_resize()?;
914 }
915 events.push(ev);
916 }
917
918 while crossterm::event::poll(Duration::ZERO)? {
919 let raw = crossterm::event::read()?;
920 if let Some(ev) = event::from_crossterm(raw) {
921 if is_ctrl_c(&ev) {
922 return Ok(false);
923 }
924 if matches!(ev, Event::Resize(_, _)) {
925 on_resize()?;
926 }
927 events.push(ev);
928 }
929 }
930
931 for ev in events.iter() {
932 if matches!(
933 ev,
934 Event::Key(event::KeyEvent {
935 code: KeyCode::F(12),
936 kind: event::KeyEventKind::Press,
937 ..
938 })
939 ) {
940 state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
941 }
942 }
943 }
944
945 update_last_mouse_pos(state, events);
946
947 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
948 clear_frame_layout_cache(state);
949 }
950
951 Ok(true)
952}
953
954struct FrameKernelResult {
955 should_quit: bool,
956 #[cfg(feature = "crossterm")]
957 clipboard_text: Option<String>,
958 #[cfg(feature = "crossterm")]
959 should_copy_selection: bool,
960}
961
962pub(crate) fn run_frame_kernel(
963 buffer: &mut Buffer,
964 state: &mut FrameState,
965 config: &RunConfig,
966 size: (u32, u32),
967 events: Vec<event::Event>,
968 is_real_terminal: bool,
969 f: &mut impl FnMut(&mut context::Context),
970) -> FrameKernelResult {
971 let frame_start = Instant::now();
972 let (w, h) = size;
973 let mut ctx = Context::new(events, w, h, state, config.theme);
974 ctx.is_real_terminal = is_real_terminal;
975 ctx.set_scroll_speed(config.scroll_speed);
976 ctx.widget_theme = config.widget_theme;
977
978 f(&mut ctx);
979 ctx.process_focus_keys();
980 ctx.render_notifications();
981 ctx.emit_pending_tooltips();
982
983 debug_assert_eq!(
984 ctx.rollback.overlay_depth, 0,
985 "overlay depth must settle back to zero before layout"
986 );
987 debug_assert_eq!(
988 ctx.rollback.group_count, 0,
989 "group count must settle back to zero before layout"
990 );
991 debug_assert!(
992 ctx.rollback.group_stack.is_empty(),
993 "group stack must be empty before layout"
994 );
995 debug_assert!(
996 ctx.rollback.text_color_stack.is_empty(),
997 "text color stack must be empty before layout"
998 );
999 debug_assert!(
1000 ctx.rollback.pending_tooltips.is_empty(),
1001 "pending tooltips must be emitted before layout"
1002 );
1003
1004 if ctx.should_quit {
1005 state.hook_states = ctx.hook_states;
1006 state.screen_hook_map = ctx.screen_hook_map;
1007 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1008 #[cfg(feature = "crossterm")]
1009 let clipboard_text = ctx.clipboard_text.take();
1010 #[cfg(feature = "crossterm")]
1011 let should_copy_selection = false;
1012 return FrameKernelResult {
1013 should_quit: true,
1014 #[cfg(feature = "crossterm")]
1015 clipboard_text,
1016 #[cfg(feature = "crossterm")]
1017 should_copy_selection,
1018 };
1019 }
1020 state.focus.prev_modal_active = ctx.rollback.modal_active;
1021 state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
1022 state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
1023 #[cfg(feature = "crossterm")]
1024 let clipboard_text = ctx.clipboard_text.take();
1025 #[cfg(not(feature = "crossterm"))]
1026 let _clipboard_text = ctx.clipboard_text.take();
1027
1028 #[cfg(feature = "crossterm")]
1029 let mut should_copy_selection = false;
1030 #[cfg(feature = "crossterm")]
1031 for ev in &ctx.events {
1032 if let Event::Mouse(mouse) = ev {
1033 match mouse.kind {
1034 event::MouseKind::Down(event::MouseButton::Left) => {
1035 state.selection.mouse_down(
1036 mouse.x,
1037 mouse.y,
1038 &state.layout_feedback.prev_content_map,
1039 );
1040 }
1041 event::MouseKind::Drag(event::MouseButton::Left) => {
1042 state.selection.mouse_drag(
1043 mouse.x,
1044 mouse.y,
1045 &state.layout_feedback.prev_content_map,
1046 );
1047 }
1048 event::MouseKind::Up(event::MouseButton::Left) => {
1049 should_copy_selection = state.selection.active;
1050 }
1051 _ => {}
1052 }
1053 }
1054 }
1055
1056 state.focus.focus_index = ctx.focus_index;
1057 state.focus.prev_focus_count = ctx.rollback.focus_count;
1058
1059 let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
1060 let area = crate::rect::Rect::new(0, 0, w, h);
1061 layout::compute(&mut tree, area);
1062 let fd = layout::collect_all(&tree);
1063 assert_eq!(
1064 fd.scroll_infos.len(),
1065 fd.scroll_rects.len(),
1066 "scroll feedback vectors must stay aligned"
1067 );
1068 state.layout_feedback.prev_scroll_infos = fd.scroll_infos;
1069 state.layout_feedback.prev_scroll_rects = fd.scroll_rects;
1070 state.layout_feedback.prev_hit_map = fd.hit_areas;
1071 state.layout_feedback.prev_group_rects = fd.group_rects;
1072 state.layout_feedback.prev_content_map = fd.content_areas;
1073 state.layout_feedback.prev_focus_rects = fd.focus_rects;
1074 state.layout_feedback.prev_focus_groups = fd.focus_groups;
1075 layout::render(&tree, buffer);
1076 let raw_rects = fd.raw_draw_rects;
1077 struct KittyClipGuard<'a>(&'a mut crate::buffer::Buffer);
1081 impl Drop for KittyClipGuard<'_> {
1082 fn drop(&mut self) {
1083 let _ = self.0.pop_kitty_clip();
1084 }
1085 }
1086 for rdr in raw_rects {
1087 if rdr.rect.width == 0 || rdr.rect.height == 0 {
1088 continue;
1089 }
1090 if let Some(cb) = ctx
1091 .deferred_draws
1092 .get_mut(rdr.draw_id)
1093 .and_then(|c| c.take())
1094 {
1095 buffer.push_clip(rdr.rect);
1096 buffer.push_kitty_clip(crate::buffer::KittyClipInfo {
1097 top_clip_rows: rdr.top_clip_rows,
1098 original_height: rdr.original_height,
1099 });
1100 {
1101 let guard = KittyClipGuard(buffer);
1102 cb(&mut *guard.0, rdr.rect);
1105 }
1107 buffer.pop_clip();
1108 }
1109 }
1110 debug_assert!(
1111 buffer.kitty_clip_info_stack.is_empty(),
1112 "kitty_clip_info_stack must be empty at end of frame"
1113 );
1114 state.hook_states = ctx.hook_states;
1115 state.screen_hook_map = ctx.screen_hook_map;
1116 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1117
1118 let frame_time = frame_start.elapsed();
1119 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1120 let frame_secs = frame_time.as_secs_f32();
1121 let inst_fps = if frame_secs > 0.0 {
1122 1.0 / frame_secs
1123 } else {
1124 0.0
1125 };
1126 state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
1127 inst_fps
1128 } else {
1129 (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
1130 };
1131 if state.diagnostics.debug_mode {
1132 layout::render_debug_overlay(&tree, buffer, frame_time_us, state.diagnostics.fps_ema);
1133 }
1134
1135 FrameKernelResult {
1136 should_quit: false,
1137 #[cfg(feature = "crossterm")]
1138 clipboard_text,
1139 #[cfg(feature = "crossterm")]
1140 should_copy_selection,
1141 }
1142}
1143
1144fn run_frame(
1145 term: &mut impl Backend,
1146 state: &mut FrameState,
1147 config: &RunConfig,
1148 events: Vec<event::Event>,
1149 f: &mut impl FnMut(&mut context::Context),
1150) -> io::Result<bool> {
1151 let size = term.size();
1152 let kernel = run_frame_kernel(term.buffer_mut(), state, config, size, events, true, f);
1153 if kernel.should_quit {
1154 return Ok(false);
1155 }
1156
1157 #[cfg(feature = "crossterm")]
1158 if state.selection.active {
1159 terminal::apply_selection_overlay(
1160 term.buffer_mut(),
1161 &state.selection,
1162 &state.layout_feedback.prev_content_map,
1163 );
1164 }
1165 #[cfg(feature = "crossterm")]
1166 if kernel.should_copy_selection {
1167 let text = terminal::extract_selection_text(
1168 term.buffer_mut(),
1169 &state.selection,
1170 &state.layout_feedback.prev_content_map,
1171 );
1172 if !text.is_empty() {
1173 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1174 }
1175 state.selection.clear();
1176 }
1177
1178 term.flush()?;
1179 #[cfg(feature = "crossterm")]
1180 if let Some(text) = kernel.clipboard_text {
1181 #[allow(clippy::print_stderr)]
1182 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1183 eprintln!("[slt] failed to copy to clipboard: {e}");
1184 }
1185 }
1186 state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
1187
1188 Ok(true)
1189}
1190
1191#[cfg(feature = "crossterm")]
1192fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1193 for ev in events {
1194 match ev {
1195 Event::Mouse(mouse) => {
1196 state.layout_feedback.last_mouse_pos = Some((mouse.x, mouse.y));
1197 }
1198 Event::FocusLost => {
1199 state.layout_feedback.last_mouse_pos = None;
1200 }
1201 _ => {}
1202 }
1203 }
1204}
1205
1206#[cfg(feature = "crossterm")]
1207fn clear_frame_layout_cache(state: &mut FrameState) {
1208 state.layout_feedback.prev_hit_map.clear();
1209 state.layout_feedback.prev_group_rects.clear();
1210 state.layout_feedback.prev_content_map.clear();
1211 state.layout_feedback.prev_focus_rects.clear();
1212 state.layout_feedback.prev_focus_groups.clear();
1213 state.layout_feedback.prev_scroll_infos.clear();
1214 state.layout_feedback.prev_scroll_rects.clear();
1215 state.layout_feedback.last_mouse_pos = None;
1216}
1217
1218#[cfg(feature = "crossterm")]
1219fn is_ctrl_c(ev: &Event) -> bool {
1220 matches!(
1221 ev,
1222 Event::Key(event::KeyEvent {
1223 code: KeyCode::Char('c'),
1224 modifiers,
1225 kind: event::KeyEventKind::Press,
1226 }) if modifiers.contains(KeyModifiers::CONTROL)
1227 )
1228}
1229
1230#[cfg(feature = "crossterm")]
1231fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1232 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1233 let target = Duration::from_secs_f64(1.0 / fps as f64);
1234 let elapsed = frame_start.elapsed();
1235 if elapsed < target {
1236 std::thread::sleep(target - elapsed);
1237 }
1238 }
1239}