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