#![forbid(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(rustdoc::broken_intra_doc_links)]
#![warn(missing_docs)]
#![warn(rustdoc::private_intra_doc_links)]
#![deny(clippy::unwrap_in_result)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::dbg_macro)]
#![warn(clippy::print_stdout)]
#![warn(clippy::print_stderr)]
pub mod anim;
pub mod buffer;
pub mod cell;
pub mod chart;
pub mod context;
pub mod event;
pub mod halfblock;
pub mod keymap;
pub mod layout;
pub mod palette;
pub mod rect;
#[cfg(feature = "crossterm")]
mod sixel;
pub mod style;
pub mod syntax;
#[cfg(feature = "crossterm")]
mod terminal;
pub mod test_utils;
pub mod widgets;
use std::io;
#[cfg(feature = "crossterm")]
use std::io::IsTerminal;
#[cfg(feature = "crossterm")]
use std::io::Write;
#[cfg(feature = "crossterm")]
use std::sync::Once;
use std::time::{Duration, Instant};
#[cfg(feature = "crossterm")]
#[doc(hidden)]
pub use terminal::__bench_flush_buffer_diff;
#[cfg(feature = "crossterm")]
pub use terminal::{detect_color_scheme, read_clipboard, ColorScheme};
#[cfg(feature = "crossterm")]
use terminal::{InlineTerminal, Terminal};
pub use crate::test_utils::{EventBuilder, TestBackend};
pub use anim::{Keyframes, LoopMode, Sequence, Spring, Stagger, Tween};
pub use buffer::Buffer;
pub use cell::Cell;
pub use chart::{Candle, ChartBuilder, ChartConfig, Dataset, LegendPosition, Marker};
pub use context::{
Bar, BarChartConfig, BarDirection, BarGroup, CanvasContext, ContainerBuilder, Context,
Response, State, TreemapItem, Widget,
};
pub use event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
};
pub use halfblock::HalfBlockImage;
pub use keymap::{Binding, KeyMap};
pub use layout::Direction;
pub use palette::Palette;
pub use rect::Rect;
pub use style::{
Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
Justify, Margin, Modifiers, Padding, Spacing, Style, Theme, ThemeBuilder, ThemeColor,
WidgetColors, WidgetTheme,
};
pub use widgets::{
AlertLevel, ApprovalAction, ButtonVariant, CalendarState, CommandPaletteState, ContextItem,
DirectoryTreeState, FileEntry, FilePickerState, FormField, FormState, GridColumn, ListState,
ModeState, MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState,
ScreenState, ScrollState, SelectState, SpinnerState, StaticOutput, StreamingMarkdownState,
StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
ToastMessage, ToastState, ToolApprovalState, TreeNode, TreeState, Trend,
};
pub trait Backend {
fn size(&self) -> (u32, u32);
fn buffer_mut(&mut self) -> &mut Buffer;
fn flush(&mut self) -> io::Result<()>;
}
pub struct AppState {
pub(crate) inner: FrameState,
}
impl AppState {
pub fn new() -> Self {
Self {
inner: FrameState::default(),
}
}
pub fn tick(&self) -> u64 {
self.inner.diagnostics.tick
}
pub fn fps(&self) -> f32 {
self.inner.diagnostics.fps_ema
}
pub fn set_debug(&mut self, enabled: bool) {
self.inner.diagnostics.debug_mode = enabled;
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
pub fn frame(
backend: &mut impl Backend,
state: &mut AppState,
config: &RunConfig,
events: &[Event],
f: &mut impl FnMut(&mut Context),
) -> io::Result<bool> {
run_frame(backend, &mut state.inner, config, events.to_vec(), f)
}
#[cfg(feature = "crossterm")]
static PANIC_HOOK_ONCE: Once = Once::new();
#[allow(clippy::print_stderr)]
#[cfg(feature = "crossterm")]
fn install_panic_hook() {
PANIC_HOOK_ONCE.call_once(|| {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode();
let mut stdout = io::stdout();
let _ = crossterm::execute!(
stdout,
crossterm::terminal::LeaveAlternateScreen,
crossterm::cursor::Show,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste,
crossterm::style::ResetColor,
crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
);
eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
if let Some(location) = panic_info.location() {
eprintln!(
"\x1b[90m{}:{}:{}\x1b[0m",
location.file(),
location.line(),
location.column()
);
}
if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
eprintln!("\x1b[1m{}\x1b[0m", msg);
} else if let Some(msg) = panic_info.payload().downcast_ref::<String>() {
eprintln!("\x1b[1m{}\x1b[0m", msg);
}
eprintln!(
"\n\x1b[90mTerminal state restored. Report bugs at https://github.com/subinium/SuperLightTUI/issues\x1b[0m\n"
);
original(panic_info);
}));
});
}
#[non_exhaustive]
#[must_use = "configure loop behavior before passing to run_with or run_inline_with"]
pub struct RunConfig {
pub tick_rate: Duration,
pub mouse: bool,
pub kitty_keyboard: bool,
pub theme: Theme,
pub color_depth: Option<ColorDepth>,
pub max_fps: Option<u32>,
pub scroll_speed: u32,
pub title: Option<String>,
pub widget_theme: style::WidgetTheme,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
tick_rate: Duration::from_millis(16),
mouse: false,
kitty_keyboard: false,
theme: Theme::dark(),
color_depth: None,
max_fps: Some(60),
scroll_speed: 1,
title: None,
widget_theme: style::WidgetTheme::new(),
}
}
}
impl RunConfig {
pub fn tick_rate(mut self, rate: Duration) -> Self {
self.tick_rate = rate;
self
}
pub fn mouse(mut self, enabled: bool) -> Self {
self.mouse = enabled;
self
}
pub fn kitty_keyboard(mut self, enabled: bool) -> Self {
self.kitty_keyboard = enabled;
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn color_depth(mut self, depth: ColorDepth) -> Self {
self.color_depth = Some(depth);
self
}
pub fn max_fps(mut self, fps: u32) -> Self {
self.max_fps = Some(fps);
self
}
pub fn scroll_speed(mut self, lines: u32) -> Self {
self.scroll_speed = lines.max(1);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn widget_theme(mut self, widget_theme: style::WidgetTheme) -> Self {
self.widget_theme = widget_theme;
self
}
}
#[derive(Default)]
pub(crate) struct FocusState {
pub focus_index: usize,
pub prev_focus_count: usize,
pub prev_modal_active: bool,
pub prev_modal_focus_start: usize,
pub prev_modal_focus_count: usize,
}
#[derive(Default)]
pub(crate) struct LayoutFeedbackState {
pub prev_scroll_infos: Vec<(u32, u32)>,
pub prev_scroll_rects: Vec<rect::Rect>,
pub prev_hit_map: Vec<rect::Rect>,
pub prev_group_rects: Vec<(std::sync::Arc<str>, rect::Rect)>,
pub prev_content_map: Vec<(rect::Rect, rect::Rect)>,
pub prev_focus_rects: Vec<(usize, rect::Rect)>,
pub prev_focus_groups: Vec<Option<std::sync::Arc<str>>>,
pub last_mouse_pos: Option<(u32, u32)>,
}
#[derive(Default)]
pub(crate) struct DiagnosticsState {
pub tick: u64,
pub notification_queue: Vec<(String, ToastLevel, u64)>,
pub debug_mode: bool,
pub fps_ema: f32,
}
#[derive(Default)]
pub(crate) struct FrameState {
pub hook_states: Vec<Box<dyn std::any::Any>>,
pub screen_hook_map: std::collections::HashMap<String, (usize, usize)>,
pub focus: FocusState,
pub layout_feedback: LayoutFeedbackState,
pub diagnostics: DiagnosticsState,
#[cfg(feature = "crossterm")]
pub selection: terminal::SelectionState,
}
#[cfg(feature = "crossterm")]
pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
run_with(RunConfig::default(), f)
}
#[cfg(feature = "crossterm")]
fn set_terminal_title(title: &Option<String>) {
if let Some(title) = title {
use std::io::Write;
let _ = write!(io::stdout(), "\x1b]2;{title}\x07");
}
}
#[cfg(feature = "crossterm")]
pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Result<()> {
if !io::stdout().is_terminal() {
return Ok(());
}
install_panic_hook();
let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
set_terminal_title(&config.title);
if config.theme.bg != Color::Reset {
term.theme_bg = Some(config.theme.bg);
}
let mut events: Vec<Event> = Vec::new();
let mut state = FrameState::default();
loop {
let frame_start = Instant::now();
let (w, h) = term.size();
if w == 0 || h == 0 {
sleep_for_fps_cap(config.max_fps, frame_start);
continue;
}
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut f,
)? {
break;
}
if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
term.handle_resize()
})? {
break;
}
sleep_for_fps_cap(config.max_fps, frame_start);
}
Ok(())
}
#[cfg(all(feature = "crossterm", feature = "async"))]
pub fn run_async<M: Send + 'static>(
f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
) -> io::Result<tokio::sync::mpsc::Sender<M>> {
run_async_with(RunConfig::default(), f)
}
#[cfg(all(feature = "crossterm", feature = "async"))]
pub fn run_async_with<M: Send + 'static>(
config: RunConfig,
f: impl FnMut(&mut Context, &mut Vec<M>) + Send + 'static,
) -> io::Result<tokio::sync::mpsc::Sender<M>> {
let (tx, rx) = tokio::sync::mpsc::channel(100);
let handle =
tokio::runtime::Handle::try_current().map_err(|err| io::Error::other(err.to_string()))?;
handle.spawn_blocking(move || {
let _ = run_async_loop(config, f, rx);
});
Ok(tx)
}
#[cfg(all(feature = "crossterm", feature = "async"))]
fn run_async_loop<M: Send + 'static>(
config: RunConfig,
mut f: impl FnMut(&mut Context, &mut Vec<M>) + Send,
mut rx: tokio::sync::mpsc::Receiver<M>,
) -> io::Result<()> {
if !io::stdout().is_terminal() {
return Ok(());
}
install_panic_hook();
let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
let mut term = Terminal::new(config.mouse, config.kitty_keyboard, color_depth)?;
set_terminal_title(&config.title);
if config.theme.bg != Color::Reset {
term.theme_bg = Some(config.theme.bg);
}
let mut events: Vec<Event> = Vec::new();
let mut state = FrameState::default();
loop {
let frame_start = Instant::now();
let mut messages: Vec<M> = Vec::new();
while let Ok(message) = rx.try_recv() {
messages.push(message);
}
let (w, h) = term.size();
if w == 0 || h == 0 {
sleep_for_fps_cap(config.max_fps, frame_start);
continue;
}
let mut render = |ctx: &mut Context| {
f(ctx, &mut messages);
};
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut render,
)? {
break;
}
if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
term.handle_resize()
})? {
break;
}
sleep_for_fps_cap(config.max_fps, frame_start);
}
Ok(())
}
#[cfg(feature = "crossterm")]
pub fn run_inline(height: u32, f: impl FnMut(&mut Context)) -> io::Result<()> {
run_inline_with(height, RunConfig::default(), f)
}
#[cfg(feature = "crossterm")]
pub fn run_inline_with(
height: u32,
config: RunConfig,
mut f: impl FnMut(&mut Context),
) -> io::Result<()> {
if !io::stdout().is_terminal() {
return Ok(());
}
install_panic_hook();
let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
let mut term = InlineTerminal::new(height, config.mouse, color_depth)?;
set_terminal_title(&config.title);
if config.theme.bg != Color::Reset {
term.theme_bg = Some(config.theme.bg);
}
let mut events: Vec<Event> = Vec::new();
let mut state = FrameState::default();
loop {
let frame_start = Instant::now();
let (w, h) = term.size();
if w == 0 || h == 0 {
sleep_for_fps_cap(config.max_fps, frame_start);
continue;
}
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut f,
)? {
break;
}
if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
term.handle_resize()
})? {
break;
}
sleep_for_fps_cap(config.max_fps, frame_start);
}
Ok(())
}
#[cfg(feature = "crossterm")]
pub fn run_static(
output: &mut StaticOutput,
dynamic_height: u32,
f: impl FnMut(&mut Context),
) -> io::Result<()> {
run_static_with(output, dynamic_height, RunConfig::default(), f)
}
#[cfg(feature = "crossterm")]
pub fn run_static_with(
output: &mut StaticOutput,
dynamic_height: u32,
config: RunConfig,
mut f: impl FnMut(&mut Context),
) -> io::Result<()> {
if !io::stdout().is_terminal() {
return Ok(());
}
install_panic_hook();
let initial_lines = output.drain_new();
write_static_lines(&initial_lines)?;
let color_depth = config.color_depth.unwrap_or_else(ColorDepth::detect);
let mut term = InlineTerminal::new(dynamic_height, config.mouse, color_depth)?;
set_terminal_title(&config.title);
if config.theme.bg != Color::Reset {
term.theme_bg = Some(config.theme.bg);
}
let mut events: Vec<Event> = Vec::new();
let mut state = FrameState::default();
loop {
let frame_start = Instant::now();
let (w, h) = term.size();
if w == 0 || h == 0 {
sleep_for_fps_cap(config.max_fps, frame_start);
continue;
}
let new_lines = output.drain_new();
write_static_lines(&new_lines)?;
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut f,
)? {
break;
}
if !poll_events(&mut events, &mut state, config.tick_rate, &mut || {
term.handle_resize()
})? {
break;
}
sleep_for_fps_cap(config.max_fps, frame_start);
}
Ok(())
}
#[cfg(feature = "crossterm")]
fn write_static_lines(lines: &[String]) -> io::Result<()> {
if lines.is_empty() {
return Ok(());
}
let mut stdout = io::stdout();
for line in lines {
stdout.write_all(line.as_bytes())?;
stdout.write_all(b"\r\n")?;
}
stdout.flush()
}
#[cfg(feature = "crossterm")]
fn poll_events(
events: &mut Vec<Event>,
state: &mut FrameState,
tick_rate: Duration,
on_resize: &mut impl FnMut() -> io::Result<()>,
) -> io::Result<bool> {
if crossterm::event::poll(tick_rate)? {
let raw = crossterm::event::read()?;
if let Some(ev) = event::from_crossterm(raw) {
if is_ctrl_c(&ev) {
return Ok(false);
}
if matches!(ev, Event::Resize(_, _)) {
on_resize()?;
}
events.push(ev);
}
while crossterm::event::poll(Duration::ZERO)? {
let raw = crossterm::event::read()?;
if let Some(ev) = event::from_crossterm(raw) {
if is_ctrl_c(&ev) {
return Ok(false);
}
if matches!(ev, Event::Resize(_, _)) {
on_resize()?;
}
events.push(ev);
}
}
for ev in events.iter() {
if matches!(
ev,
Event::Key(event::KeyEvent {
code: KeyCode::F(12),
kind: event::KeyEventKind::Press,
..
})
) {
state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
}
}
}
update_last_mouse_pos(state, events);
if events.iter().any(|e| matches!(e, Event::Resize(_, _))) {
clear_frame_layout_cache(state);
}
Ok(true)
}
struct FrameKernelResult {
should_quit: bool,
#[cfg(feature = "crossterm")]
clipboard_text: Option<String>,
#[cfg(feature = "crossterm")]
should_copy_selection: bool,
}
pub(crate) fn run_frame_kernel(
buffer: &mut Buffer,
state: &mut FrameState,
config: &RunConfig,
size: (u32, u32),
events: Vec<event::Event>,
is_real_terminal: bool,
f: &mut impl FnMut(&mut context::Context),
) -> FrameKernelResult {
let frame_start = Instant::now();
let (w, h) = size;
let mut ctx = Context::new(events, w, h, state, config.theme);
ctx.is_real_terminal = is_real_terminal;
ctx.set_scroll_speed(config.scroll_speed);
ctx.widget_theme = config.widget_theme;
f(&mut ctx);
ctx.process_focus_keys();
ctx.render_notifications();
ctx.emit_pending_tooltips();
debug_assert_eq!(
ctx.rollback.overlay_depth, 0,
"overlay depth must settle back to zero before layout"
);
debug_assert_eq!(
ctx.rollback.group_count, 0,
"group count must settle back to zero before layout"
);
debug_assert!(
ctx.rollback.group_stack.is_empty(),
"group stack must be empty before layout"
);
debug_assert!(
ctx.rollback.text_color_stack.is_empty(),
"text color stack must be empty before layout"
);
debug_assert!(
ctx.rollback.pending_tooltips.is_empty(),
"pending tooltips must be emitted before layout"
);
if ctx.should_quit {
state.hook_states = ctx.hook_states;
state.screen_hook_map = ctx.screen_hook_map;
state.diagnostics.notification_queue = ctx.rollback.notification_queue;
#[cfg(feature = "crossterm")]
let clipboard_text = ctx.clipboard_text.take();
#[cfg(feature = "crossterm")]
let should_copy_selection = false;
return FrameKernelResult {
should_quit: true,
#[cfg(feature = "crossterm")]
clipboard_text,
#[cfg(feature = "crossterm")]
should_copy_selection,
};
}
state.focus.prev_modal_active = ctx.rollback.modal_active;
state.focus.prev_modal_focus_start = ctx.rollback.modal_focus_start;
state.focus.prev_modal_focus_count = ctx.rollback.modal_focus_count;
#[cfg(feature = "crossterm")]
let clipboard_text = ctx.clipboard_text.take();
#[cfg(not(feature = "crossterm"))]
let _clipboard_text = ctx.clipboard_text.take();
#[cfg(feature = "crossterm")]
let mut should_copy_selection = false;
#[cfg(feature = "crossterm")]
for ev in &ctx.events {
if let Event::Mouse(mouse) = ev {
match mouse.kind {
event::MouseKind::Down(event::MouseButton::Left) => {
state.selection.mouse_down(
mouse.x,
mouse.y,
&state.layout_feedback.prev_content_map,
);
}
event::MouseKind::Drag(event::MouseButton::Left) => {
state.selection.mouse_drag(
mouse.x,
mouse.y,
&state.layout_feedback.prev_content_map,
);
}
event::MouseKind::Up(event::MouseButton::Left) => {
should_copy_selection = state.selection.active;
}
_ => {}
}
}
}
state.focus.focus_index = ctx.focus_index;
state.focus.prev_focus_count = ctx.rollback.focus_count;
let mut tree = layout::build_tree(std::mem::take(&mut ctx.commands));
let area = crate::rect::Rect::new(0, 0, w, h);
layout::compute(&mut tree, area);
let fd = layout::collect_all(&tree);
assert_eq!(
fd.scroll_infos.len(),
fd.scroll_rects.len(),
"scroll feedback vectors must stay aligned"
);
state.layout_feedback.prev_scroll_infos = fd.scroll_infos;
state.layout_feedback.prev_scroll_rects = fd.scroll_rects;
state.layout_feedback.prev_hit_map = fd.hit_areas;
state.layout_feedback.prev_group_rects = fd.group_rects;
state.layout_feedback.prev_content_map = fd.content_areas;
state.layout_feedback.prev_focus_rects = fd.focus_rects;
state.layout_feedback.prev_focus_groups = fd.focus_groups;
layout::render(&tree, buffer);
let raw_rects = fd.raw_draw_rects;
struct KittyClipGuard<'a>(&'a mut crate::buffer::Buffer);
impl Drop for KittyClipGuard<'_> {
fn drop(&mut self) {
let _ = self.0.pop_kitty_clip();
}
}
for rdr in raw_rects {
if rdr.rect.width == 0 || rdr.rect.height == 0 {
continue;
}
if let Some(cb) = ctx
.deferred_draws
.get_mut(rdr.draw_id)
.and_then(|c| c.take())
{
buffer.push_clip(rdr.rect);
buffer.push_kitty_clip(crate::buffer::KittyClipInfo {
top_clip_rows: rdr.top_clip_rows,
original_height: rdr.original_height,
});
{
let guard = KittyClipGuard(buffer);
cb(&mut *guard.0, rdr.rect);
}
buffer.pop_clip();
}
}
debug_assert!(
buffer.kitty_clip_info_stack.is_empty(),
"kitty_clip_info_stack must be empty at end of frame"
);
state.hook_states = ctx.hook_states;
state.screen_hook_map = ctx.screen_hook_map;
state.diagnostics.notification_queue = ctx.rollback.notification_queue;
let frame_time = frame_start.elapsed();
let frame_time_us = frame_time.as_micros().min(u128::from(u64::MAX)) as u64;
let frame_secs = frame_time.as_secs_f32();
let inst_fps = if frame_secs > 0.0 {
1.0 / frame_secs
} else {
0.0
};
state.diagnostics.fps_ema = if state.diagnostics.fps_ema == 0.0 {
inst_fps
} else {
(state.diagnostics.fps_ema * 0.9) + (inst_fps * 0.1)
};
if state.diagnostics.debug_mode {
layout::render_debug_overlay(&tree, buffer, frame_time_us, state.diagnostics.fps_ema);
}
FrameKernelResult {
should_quit: false,
#[cfg(feature = "crossterm")]
clipboard_text,
#[cfg(feature = "crossterm")]
should_copy_selection,
}
}
fn run_frame(
term: &mut impl Backend,
state: &mut FrameState,
config: &RunConfig,
events: Vec<event::Event>,
f: &mut impl FnMut(&mut context::Context),
) -> io::Result<bool> {
let size = term.size();
let kernel = run_frame_kernel(term.buffer_mut(), state, config, size, events, true, f);
if kernel.should_quit {
return Ok(false);
}
#[cfg(feature = "crossterm")]
if state.selection.active {
terminal::apply_selection_overlay(
term.buffer_mut(),
&state.selection,
&state.layout_feedback.prev_content_map,
);
}
#[cfg(feature = "crossterm")]
if kernel.should_copy_selection {
let text = terminal::extract_selection_text(
term.buffer_mut(),
&state.selection,
&state.layout_feedback.prev_content_map,
);
if !text.is_empty() {
terminal::copy_to_clipboard(&mut io::stdout(), &text)?;
}
state.selection.clear();
}
term.flush()?;
#[cfg(feature = "crossterm")]
if let Some(text) = kernel.clipboard_text {
#[allow(clippy::print_stderr)]
if let Err(e) = terminal::copy_to_clipboard(&mut io::stdout(), &text) {
eprintln!("[slt] failed to copy to clipboard: {e}");
}
}
state.diagnostics.tick = state.diagnostics.tick.wrapping_add(1);
Ok(true)
}
#[cfg(feature = "crossterm")]
fn update_last_mouse_pos(state: &mut FrameState, events: &[Event]) {
for ev in events {
match ev {
Event::Mouse(mouse) => {
state.layout_feedback.last_mouse_pos = Some((mouse.x, mouse.y));
}
Event::FocusLost => {
state.layout_feedback.last_mouse_pos = None;
}
_ => {}
}
}
}
#[cfg(feature = "crossterm")]
fn clear_frame_layout_cache(state: &mut FrameState) {
state.layout_feedback.prev_hit_map.clear();
state.layout_feedback.prev_group_rects.clear();
state.layout_feedback.prev_content_map.clear();
state.layout_feedback.prev_focus_rects.clear();
state.layout_feedback.prev_focus_groups.clear();
state.layout_feedback.prev_scroll_infos.clear();
state.layout_feedback.prev_scroll_rects.clear();
state.layout_feedback.last_mouse_pos = None;
}
#[cfg(feature = "crossterm")]
fn is_ctrl_c(ev: &Event) -> bool {
matches!(
ev,
Event::Key(event::KeyEvent {
code: KeyCode::Char('c'),
modifiers,
kind: event::KeyEventKind::Press,
}) if modifiers.contains(KeyModifiers::CONTROL)
)
}
#[cfg(feature = "crossterm")]
fn sleep_for_fps_cap(max_fps: Option<u32>, frame_start: Instant) {
if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
let target = Duration::from_secs_f64(1.0 / fps as f64);
let elapsed = frame_start.elapsed();
if elapsed < target {
std::thread::sleep(target - elapsed);
}
}
}