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")]
103pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
104#[cfg(feature = "crossterm")]
105use terminal::{InlineTerminal, Terminal};
106
107pub use crate::test_utils::{EventBuilder, TestBackend};
108pub use anim::{
109 ease_in_cubic, ease_in_out_cubic, ease_in_out_quad, ease_in_quad, ease_linear, ease_out_bounce,
110 ease_out_cubic, ease_out_elastic, ease_out_quad, lerp, Keyframes, LoopMode, Sequence, Spring,
111 Stagger, Tween,
112};
113pub use buffer::Buffer;
114pub use cell::Cell;
115pub use chart::{
116 Axis, Candle, ChartBuilder, ChartConfig, ChartRenderer, Dataset, DatasetEntry, GraphType,
117 HistogramBuilder, LegendPosition, Marker,
118};
119pub use context::{
120 Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
121 Response, State, Widget,
122};
123pub use event::{
124 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
125};
126pub use halfblock::HalfBlockImage;
127pub use keymap::{Binding, KeyMap};
128pub use layout::Direction;
129pub use palette::Palette;
130pub use rect::Rect;
131pub use style::{
132 Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
133 Justify, Margin, Modifiers, Padding, Style, Theme, ThemeBuilder, WidgetColors,
134};
135pub use widgets::{
136 AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
137 DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, ListState,
138 MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
139 ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
140 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
141 ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
142};
143
144pub trait Backend {
194 fn size(&self) -> (u32, u32);
196
197 fn buffer_mut(&mut self) -> &mut Buffer;
202
203 fn flush(&mut self) -> io::Result<()>;
209}
210
211pub struct AppState {
223 pub(crate) inner: FrameState,
224}
225
226impl AppState {
227 pub fn new() -> Self {
229 Self {
230 inner: FrameState::default(),
231 }
232 }
233
234 pub fn tick(&self) -> u64 {
236 self.inner.diagnostics.tick
237 }
238
239 pub fn fps(&self) -> f32 {
241 self.inner.diagnostics.fps_ema
242 }
243
244 pub fn set_debug(&mut self, enabled: bool) {
246 self.inner.diagnostics.debug_mode = enabled;
247 }
248}
249
250impl Default for AppState {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256pub fn frame(
289 backend: &mut impl Backend,
290 state: &mut AppState,
291 config: &RunConfig,
292 events: &[Event],
293 f: &mut impl FnMut(&mut Context),
294) -> io::Result<bool> {
295 run_frame(backend, &mut state.inner, config, events.to_vec(), f)
296}
297
298#[cfg(feature = "crossterm")]
299static PANIC_HOOK_ONCE: Once = Once::new();
300
301#[allow(clippy::print_stderr)]
302#[cfg(feature = "crossterm")]
303fn install_panic_hook() {
304 PANIC_HOOK_ONCE.call_once(|| {
305 let original = std::panic::take_hook();
306 std::panic::set_hook(Box::new(move |panic_info| {
307 let _ = crossterm::terminal::disable_raw_mode();
308 let mut stdout = io::stdout();
309 let _ = crossterm::execute!(
310 stdout,
311 crossterm::terminal::LeaveAlternateScreen,
312 crossterm::cursor::Show,
313 crossterm::event::DisableMouseCapture,
314 crossterm::event::DisableBracketedPaste,
315 crossterm::style::ResetColor,
316 crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
317 );
318
319 eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
321
322 if let Some(location) = panic_info.location() {
324 eprintln!(
325 "\x1b[90m{}:{}:{}\x1b[0m",
326 location.file(),
327 location.line(),
328 location.column()
329 );
330 }
331
332 if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
334 eprintln!("\x1b[1m{}\x1b[0m", msg);
335 } else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
336 eprintln!("\x1b[1m{}\x1b[0m", msg);
337 }
338
339 eprintln!(
340 "\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
341 );
342
343 original(panic_info);
344 }));
345 });
346}
347
348#[non_exhaustive]
367#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
368pub struct RunConfig {
369 pub tick_rate: Duration,
374 pub mouse: bool,
379 pub kitty_keyboard: bool,
386 pub theme: Theme,
390 pub color_depth: Option<ColorDepth>,
396 pub max_fps: Option<u32>,
401 pub scroll_speed: u32,
403 pub title: Option<String>,
405}
406
407impl Default for RunConfig {
408 fn default() -> Self {
409 Self {
410 tick_rate: Duration::from_millis(16),
411 mouse: false,
412 kitty_keyboard: false,
413 theme: Theme::dark(),
414 color_depth: None,
415 max_fps: Some(60),
416 scroll_speed: 1,
417 title: None,
418 }
419 }
420}
421
422impl RunConfig {
423 pub fn tick_rate(mut self, rate: Duration) -> Self {
425 self.tick_rate = rate;
426 self
427 }
428
429 pub fn mouse(mut self, enabled: bool) -> Self {
431 self.mouse = enabled;
432 self
433 }
434
435 pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
437 self.kitty_keyboard = enabled;
438 self
439 }
440
441 pub fn theme(mut self, theme: Theme) -> Self {
443 self.theme = theme;
444 self
445 }
446
447 pub fn color_depth(mut self, depth: ColorDepth) -> Self {
449 self.color_depth = Some(depth);
450 self
451 }
452
453 pub fn max_fps(mut self, fps: u32) -> Self {
455 self.max_fps = Some(fps);
456 self
457 }
458
459 pub fn scroll_speed(mut self, lines: u32) -> Self {
461 self.scroll_speed = lines.max(1);
462 self
463 }
464
465 pub fn title(mut self, title: impl Into<String>) -> Self {
467 self.title = Some(title.into());
468 self
469 }
470}
471
472#[derive(Default)]
473pub(crate) struct FocusState {
474 pub focus_index: usize,
475 pub prev_focus_count: usize,
476 pub prev_modal_active: bool,
477 pub prev_modal_focus_start: usize,
478 pub prev_modal_focus_count: usize,
479}
480
481#[derive(Default)]
482pub(crate) struct LayoutFeedbackState {
483 pub prev_scroll_infos: Vec<(u32, u32)>,
484 pub prev_scroll_rects: Vec<rect::Rect>,
485 pub prev_hit_map: Vec<rect::Rect>,
486 pub prev_group_rects: Vec<(String, rect::Rect)>,
487 pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
488 pub prev_focus_rects: Vec<(usize, rect::Rect)>,
489 pub prev_focus_groups: Vec<Option<String>>,
490 pub last_mouse_pos: Option<(u32, u32)>,
491}
492
493#[derive(Default)]
494pub(crate) struct DiagnosticsState {
495 pub tick: u64,
496 pub notification_queue: Vec<(String, ToastLevel, u64)>,
497 pub debug_mode: bool,
498 pub fps_ema: f32,
499}
500
501#[derive(Default)]
502pub(crate) struct FrameState {
503 pub hook_states: Vec<Box<dyn std::any::Any>>,
504 pub focus: FocusState,
505 pub layout_feedback: LayoutFeedbackState,
506 pub diagnostics: DiagnosticsState,
507 #[cfg(feature = "crossterm")]
508 pub selection: terminal::SelectionState,
509}
510
511#[cfg(feature = "crossterm")]
526pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
527 run_with(RunConfig::default(), f)
528}
529
530#[cfg(feature = "crossterm")]
531fn set_terminal_title(title: &Option<String>) {
532 if let Some(title) = title {
533 use std::io::Write;
534 let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
535 }
536}
537
538#[cfg(feature = "crossterm")]
558pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
559 if !io::stdout().is_terminal() {
560 return Ok(());
561 }
562
563 install_panic_hook();
564 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
565 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
566 set_terminal_title(&config.title);
567 if config.theme.bg != Color::Reset {
568 term.theme_bg = Some(config.theme.bg);
569 }
570 let mut events: Vec<Event> = Vec::new();
571 let mut state = FrameState::default();
572
573 loop {
574 let frame_start = Instant::now();
575 let (w, h) = term.size();
576 if w == 0 || h == 0 {
577 sleep_for_fps_cap(config.max_fps, frame_start);
578 continue;
579 }
580
581 if !run_frame(
582 &mut term,
583 &mut state,
584 &config,
585 std::mem::take(&mut events),
586 &mut f,
587 )? {
588 break;
589 }
590
591 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
592 term.handle_resize()
593 })? {
594 break;
595 }
596
597 sleep_for_fps_cap(config.max_fps, frame_start);
598 }
599
600 Ok(())
601}
602
603#[cfg(all(feature = "crossterm", feature = "async"))]
624pub fn run_async<M: Send + 'static>(
625 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
626) -> io::Result<tokio::sync::mpsc::Sender<M>> {
627 run_async_with(RunConfig::default(), f)
628}
629
630#[cfg(all(feature = "crossterm", feature = "async"))]
637pub fn run_async_with<M: Send + 'static>(
638 config: RunConfig,
639 f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
640) -> io::Result<tokio::sync::mpsc::Sender<M>> {
641 let (tx, rx) = tokio::sync::mpsc::channel(100);
642 let handle =
643 tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
644
645 handle.spawn_blocking(move || {
646 let _ = run_async_loop(config, f, rx);
647 });
648
649 Ok(tx)
650}
651
652#[cfg(all(feature = "crossterm", feature = "async"))]
653fn run_async_loop<M: Send + 'static>(
654 config: RunConfig,
655 mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
656 mut rx: tokio::sync::mpsc::Receiver<M>,
657) -> io::Result<()> {
658 if !io::stdout().is_terminal() {
659 return Ok(());
660 }
661
662 install_panic_hook();
663 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
664 let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
665 set_terminal_title(&config.title);
666 if config.theme.bg != Color::Reset {
667 term.theme_bg = Some(config.theme.bg);
668 }
669 let mut events: Vec<Event> = Vec::new();
670 let mut state = FrameState::default();
671
672 loop {
673 let frame_start = Instant::now();
674 let mut messages: Vec<M> = Vec::new();
675 while let Ok(message) = rx.try_recv() {
676 messages.push(message);
677 }
678
679 let (w, h) = term.size();
680 if w == 0 || h == 0 {
681 sleep_for_fps_cap(config.max_fps, frame_start);
682 continue;
683 }
684
685 let mut render = |ctx: &mut Context| {
686 f(ctx, &mut messages);
687 };
688 if !run_frame(
689 &mut term,
690 &mut state,
691 &config,
692 std::mem::take(&mut events),
693 &mut render,
694 )? {
695 break;
696 }
697
698 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
699 term.handle_resize()
700 })? {
701 break;
702 }
703
704 sleep_for_fps_cap(config.max_fps, frame_start);
705 }
706
707 Ok(())
708}
709
710#[cfg(feature = "crossterm")]
729pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
730 run_inline_with(height, RunConfig::default(), f)
731}
732
733#[cfg(feature = "crossterm")]
738pub fn run_inline_with(
739 height: u32,
740 config: RunConfig,
741 mut f: impl FnMut(&mut Context),
742) -> io::Result<()> {
743 if !io::stdout().is_terminal() {
744 return Ok(());
745 }
746
747 install_panic_hook();
748 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
749 let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
750 set_terminal_title(&config.title);
751 if config.theme.bg != Color::Reset {
752 term.theme_bg = Some(config.theme.bg);
753 }
754 let mut events: Vec<Event> = Vec::new();
755 let mut state = FrameState::default();
756
757 loop {
758 let frame_start = Instant::now();
759 let (w, h) = term.size();
760 if w == 0 || h == 0 {
761 sleep_for_fps_cap(config.max_fps, frame_start);
762 continue;
763 }
764
765 if !run_frame(
766 &mut term,
767 &mut state,
768 &config,
769 std::mem::take(&mut events),
770 &mut f,
771 )? {
772 break;
773 }
774
775 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
776 term.handle_resize()
777 })? {
778 break;
779 }
780
781 sleep_for_fps_cap(config.max_fps, frame_start);
782 }
783
784 Ok(())
785}
786
787#[cfg(feature = "crossterm")]
795pub fn run_static(
796 output: &mut StaticOutput,
797 dynamic_height: u32,
798 mut f: impl FnMut(&mut Context),
799) -> io::Result<()> {
800 let config = RunConfig::default();
801 if !io::stdout().is_terminal() {
802 return Ok(());
803 }
804
805 install_panic_hook();
806
807 let initial_lines = output.drain_new();
808 write_static_lines(&initial_lines)?;
809
810 let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
811 let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
812 set_terminal_title(&config.title);
813 if config.theme.bg != Color::Reset {
814 term.theme_bg = Some(config.theme.bg);
815 }
816
817 let mut events: Vec<Event> = Vec::new();
818 let mut state = FrameState::default();
819
820 loop {
821 let frame_start = Instant::now();
822 let (w, h) = term.size();
823 if w == 0 || h == 0 {
824 sleep_for_fps_cap(config.max_fps, frame_start);
825 continue;
826 }
827
828 let new_lines = output.drain_new();
829 write_static_lines(&new_lines)?;
830
831 if !run_frame(
832 &mut term,
833 &mut state,
834 &config,
835 std::mem::take(&mut events),
836 &mut f,
837 )? {
838 break;
839 }
840
841 if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
842 term.handle_resize()
843 })? {
844 break;
845 }
846
847 sleep_for_fps_cap(config.max_fps, frame_start);
848 }
849
850 Ok(())
851}
852
853#[cfg(feature = "crossterm")]
854fn write_static_lines(lines: &[String]) -> io::Result<()> {
855 if lines.is_empty() {
856 return Ok(());
857 }
858
859 let mut stdout = io::stdout();
860 for line in lines {
861 stdout.write_all(line.as_bytes())?;
862 stdout.write_all(b"\r\n")?;
863 }
864 stdout.flush()
865}
866
867#[cfg(feature = "crossterm")]
870fn poll_events(
871 events: &mut Vec<Event>,
872 state: &mut FrameState,
873 tick_rate: Duration,
874 on_resize: &mut impl FnMut() -> io::Result<()>,
875) -> io::Result<bool> {
876 if crossterm::event::poll(tick_rate)? {
877 let raw = crossterm::event::read()?;
878 if let Some(ev) = event::from_crossterm(raw) {
879 if is_ctrl_c(&ev) {
880 return Ok(false);
881 }
882 if matches!(ev, Event::Resize(_, _)) {
883 on_resize()?;
884 }
885 events.push(ev);
886 }
887
888 while crossterm::event::poll(Duration::ZERO)? {
889 let raw = crossterm::event::read()?;
890 if let Some(ev) = event::from_crossterm(raw) {
891 if is_ctrl_c(&ev) {
892 return Ok(false);
893 }
894 if matches!(ev, Event::Resize(_, _)) {
895 on_resize()?;
896 }
897 events.push(ev);
898 }
899 }
900
901 for ev in events.iter() {
902 if matches!(
903 ev,
904 Event::Key(event::KeyEvent {
905 code: KeyCode::F(12),
906 kind: event::KeyEventKind::Press,
907 ..
908 })
909 ) {
910 state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
911 }
912 }
913 }
914
915 update_last_mouse_pos(state, events);
916
917 if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
918 clear_frame_layout_cache(state);
919 }
920
921 Ok(true)
922}
923
924struct FrameKernelResult {
925 should_quit: bool,
926 #[cfg(feature = "crossterm")]
927 clipboard_text: Option<String>,
928 #[cfg(feature = "crossterm")]
929 should_copy_selection: bool,
930}
931
932pub(crate) fn run_frame_kernel(
933 buffer: &mut Buffer,
934 state: &mut FrameState,
935 config: &RunConfig,
936 size: (u32, u32),
937 events: Vec<event::Event>,
938 is_real_terminal: bool,
939 f: &mut impl FnMut(&mut context::Context),
940) -> FrameKernelResult {
941 let frame_start = Instant::now();
942 let (w, h) = size;
943 let mut ctx = Context::new(events, w, h, state, config.theme);
944 ctx.is_real_terminal = is_real_terminal;
945 ctx.set_scroll_speed(config.scroll_speed);
946
947 f(&mut ctx);
948 ctx.process_focus_keys();
949 ctx.render_notifications();
950 ctx.emit_pending_tooltips();
951
952 debug_assert_eq!(
953 ctx.rollback.overlay_depth, 0,
954 "overlay depth must settle back to zero before layout"
955 );
956 debug_assert_eq!(
957 ctx.rollback.group_count, 0,
958 "group count must settle back to zero before layout"
959 );
960 debug_assert!(
961 ctx.rollback.group_stack.is_empty(),
962 "group stack must be empty before layout"
963 );
964 debug_assert!(
965 ctx.rollback.text_color_stack.is_empty(),
966 "text color stack must be empty before layout"
967 );
968 debug_assert!(
969 ctx.rollback.pending_tooltips.is_empty(),
970 "pending tooltips must be emitted before layout"
971 );
972
973 if ctx.should_quit {
974 state.hook_states = ctx.hook_states;
975 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
976 #[cfg(feature = "crossterm")]
977 let clipboard_text = ctx.clipboard_text.take();
978 #[cfg(feature = "crossterm")]
979 let should_copy_selection = false;
980 return FrameKernelResult {
981 should_quit: true,
982 #[cfg(feature = "crossterm")]
983 clipboard_text,
984 #[cfg(feature = "crossterm")]
985 should_copy_selection,
986 };
987 }
988 state.focus.prev_modal_active = ctx.rollback.modal_active;
989 state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
990 state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
991 #[cfg(feature = "crossterm")]
992 let clipboard_text = ctx.clipboard_text.take();
993 #[cfg(not(feature = "crossterm"))]
994 let _clipboard_text = ctx.clipboard_text.take();
995
996 #[cfg(feature = "crossterm")]
997 let mut should_copy_selection = false;
998 #[cfg(feature = "crossterm")]
999 for ev in &ctx.events {
1000 if let Event::Mouse(mouse) = ev {
1001 match mouse.kind {
1002 event::MouseKind::Down(event::MouseButton::Left) => {
1003 state.selection.mouse_down(
1004 mouse.x,
1005 mouse.y,
1006 &state.layout_feedback.prev_content_map,
1007 );
1008 }
1009 event::MouseKind::Drag(event::MouseButton::Left) => {
1010 state.selection.mouse_drag(
1011 mouse.x,
1012 mouse.y,
1013 &state.layout_feedback.prev_content_map,
1014 );
1015 }
1016 event::MouseKind::Up(event::MouseButton::Left) => {
1017 should_copy_selection = state.selection.active;
1018 }
1019 _ => {}
1020 }
1021 }
1022 }
1023
1024 state.focus.focus_index = ctx.focus_index;
1025 state.focus.prev_focus_count = ctx.rollback.focus_count;
1026
1027 let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
1028 let area = crate::rect::Rect::new(0, 0, w, h);
1029 layout::compute(&mut tree, area);
1030 let fd = layout::collect_all(&tree);
1031 debug_assert_eq!(
1032 fd.scroll_infos.len(),
1033 fd.scroll_rects.len(),
1034 "scroll feedback vectors must stay aligned"
1035 );
1036 state.layout_feedback.prev_scroll_infos = fd.scroll_infos;
1037 state.layout_feedback.prev_scroll_rects = fd.scroll_rects;
1038 state.layout_feedback.prev_hit_map = fd.hit_areas;
1039 state.layout_feedback.prev_group_rects = fd.group_rects;
1040 state.layout_feedback.prev_content_map = fd.content_areas;
1041 state.layout_feedback.prev_focus_rects = fd.focus_rects;
1042 state.layout_feedback.prev_focus_groups = fd.focus_groups;
1043 layout::render(&tree, buffer);
1044 let raw_rects = fd.raw_draw_rects;
1045 for rdr in raw_rects {
1046 if rdr.rect.width == 0 || rdr.rect.height == 0 {
1047 continue;
1048 }
1049 if let Some(cb) = ctx
1050 .deferred_draws
1051 .get_mut(rdr.draw_id)
1052 .and_then(|c| c.take())
1053 {
1054 buffer.push_clip(rdr.rect);
1055 buffer.kitty_clip_info = Some((rdr.top_clip_rows, rdr.original_height));
1056 cb(buffer, rdr.rect);
1057 buffer.kitty_clip_info = None;
1058 buffer.pop_clip();
1059 }
1060 }
1061 state.hook_states = ctx.hook_states;
1062 state.diagnostics.notification_queue = ctx.rollback.notification_queue;
1063
1064 let frame_time = frame_start.elapsed();
1065 let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
1066 let frame_secs = frame_time.as_secs_f32();
1067 let inst_fps = if frame_secs > 0.0 {
1068 1.0 / frame_secs
1069 } else {
1070 0.0
1071 };
1072 state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
1073 inst_fps
1074 } else {
1075 (state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
1076 };
1077 if state.diagnostics.debug_mode {
1078 layout::render_debug_overlay(&tree, buffer, frame_time_us, state.diagnostics.fps_ema);
1079 }
1080
1081 FrameKernelResult {
1082 should_quit: false,
1083 #[cfg(feature = "crossterm")]
1084 clipboard_text,
1085 #[cfg(feature = "crossterm")]
1086 should_copy_selection,
1087 }
1088}
1089
1090fn run_frame(
1091 term: &mut impl Backend,
1092 state: &mut FrameState,
1093 config: &RunConfig,
1094 events: Vec<event::Event>,
1095 f: &mut impl FnMut(&mut context::Context),
1096) -> io::Result<bool> {
1097 let size = term.size();
1098 let kernel = run_frame_kernel(term.buffer_mut(), state, config, size, events, true, f);
1099 if kernel.should_quit {
1100 return Ok(false);
1101 }
1102
1103 #[cfg(feature = "crossterm")]
1104 if state.selection.active {
1105 terminal::apply_selection_overlay(
1106 term.buffer_mut(),
1107 &state.selection,
1108 &state.layout_feedback.prev_content_map,
1109 );
1110 }
1111 #[cfg(feature = "crossterm")]
1112 if kernel.should_copy_selection {
1113 let text = terminal::extract_selection_text(
1114 term.buffer_mut(),
1115 &state.selection,
1116 &state.layout_feedback.prev_content_map,
1117 );
1118 if !text.is_empty() {
1119 terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
1120 }
1121 state.selection.clear();
1122 }
1123
1124 term.flush()?;
1125 #[cfg(feature = "crossterm")]
1126 if let Some(text) = kernel.clipboard_text {
1127 #[allow(clippy::print_stderr)]
1128 if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
1129 eprintln!("[slt] failed to copy to clipboard: {e}");
1130 }
1131 }
1132 state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
1133
1134 Ok(true)
1135}
1136
1137#[cfg(feature = "crossterm")]
1138fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
1139 for ev in events {
1140 match ev {
1141 Event::Mouse(mouse) => {
1142 state.layout_feedback.last_mouse_pos = Some((mouse.x, mouse.y));
1143 }
1144 Event::FocusLost => {
1145 state.layout_feedback.last_mouse_pos = None;
1146 }
1147 _ => {}
1148 }
1149 }
1150}
1151
1152#[cfg(feature = "crossterm")]
1153fn clear_frame_layout_cache(state: &mut FrameState) {
1154 state.layout_feedback.prev_hit_map.clear();
1155 state.layout_feedback.prev_group_rects.clear();
1156 state.layout_feedback.prev_content_map.clear();
1157 state.layout_feedback.prev_focus_rects.clear();
1158 state.layout_feedback.prev_focus_groups.clear();
1159 state.layout_feedback.prev_scroll_infos.clear();
1160 state.layout_feedback.prev_scroll_rects.clear();
1161 state.layout_feedback.last_mouse_pos = None;
1162}
1163
1164#[cfg(feature = "crossterm")]
1165fn is_ctrl_c(ev: &Event) -> bool {
1166 matches!(
1167 ev,
1168 Event::Key(event::KeyEvent {
1169 code: KeyCode::Char('c'),
1170 modifiers,
1171 kind: event::KeyEventKind::Press,
1172 }) if modifiers.contains(KeyModifiers::CONTROL)
1173 )
1174}
1175
1176#[cfg(feature = "crossterm")]
1177fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
1178 if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
1179 let target = Duration::from_secs_f64(1.0 / fps as f64);
1180 let elapsed = frame_start.elapsed();
1181 if elapsed < target {
1182 std::thread::sleep(target - elapsed);
1183 }
1184 }
1185}