#![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};
#[doc(hidden)]
pub use layout::__bench_dim_buffer_around;
#[doc(hidden)]
pub use layout::__bench_wrap_segments;
#[cfg(feature = "crossterm")]
#[doc(hidden)]
pub use terminal::__bench_flush_buffer_diff;
#[cfg(feature = "crossterm")]
#[doc(hidden)]
pub use terminal::__bench_flush_buffer_diff_mut;
#[cfg(feature = "crossterm")]
#[doc(hidden)]
pub use terminal::{__BenchKittyFixture, __bench_new_kitty_fixture};
#[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, FrameRecord, TestBackend, TestSequence};
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::{
Anchor, Bar, BarChartConfig, BarDirection, BarGroup, Breadcrumb, CanvasContext,
ContainerBuilder, Context, Gauge, GutterOpts, LineGauge, Response, State, TreemapItem, Widget,
};
pub use event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseKind,
};
pub use halfblock::HalfBlockImage;
pub use keymap::{Binding, KeyMap, PublishedKeymap, WidgetKeyHelp};
pub use layout::Direction;
pub use palette::Palette;
pub use rect::Rect;
pub use style::{
Align, Border, BorderSides, Breakpoint, Color, ColorDepth, Constraints, ContainerStyle,
HeightSpec, Justify, Margin, Modifiers, Padding, Spacing, Style, Theme, ThemeBuilder,
ThemeColor, WidgetColors, WidgetTheme, WidthSpec,
};
pub use widgets::{
AlertLevel, ApprovalAction, BreadcrumbResponse, ButtonVariant, CalendarState,
CommandPaletteState, ContextItem, DirectoryTreeState, FileEntry, FilePickerState, FormField,
FormState, GaugeResponse, GridColumn, GutterResponse, HighlightRange, ListState, ModeState,
MultiSelectState, PaletteCommand, RadioState, RichLogEntry, RichLogState, ScreenState,
ScrollState, SelectState, SpinnerState, SplitPaneResponse, SplitPaneState, 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> {
frame_owned(backend, state, config, events.to_vec(), f)
}
pub fn frame_owned(
backend: &mut impl Backend,
state: &mut AppState,
config: &RunConfig,
events: Vec<Event>,
f: &mut impl FnMut(&mut Context),
) -> io::Result<bool> {
run_frame(backend, &mut state.inner, config, events, 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,
pub handle_ctrl_c: bool,
}
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(),
handle_ctrl_c: true,
}
}
}
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 no_fps_cap(mut self) -> Self {
self.max_fps = None;
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
}
pub fn handle_ctrl_c(mut self, enabled: bool) -> Self {
self.handle_ctrl_c = enabled;
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,
pub prev_focus_index: Option<usize>,
pub focus_name_map_prev: std::collections::HashMap<String, usize>,
pub pending_focus_name: Option<String>,
}
#[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 debug_layer: DebugLayer,
pub fps_ema: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DebugLayer {
#[default]
All,
TopMost,
BaseOnly,
}
pub(crate) type FrameDeferredDrawSlot =
Option<Box<dyn FnOnce(&mut crate::buffer::Buffer, crate::rect::Rect)>>;
#[derive(Default)]
pub(crate) struct FrameState {
pub hook_states: Vec<Box<dyn std::any::Any>>,
pub named_states: std::collections::HashMap<&'static str, Box<dyn std::any::Any>>,
pub keyed_states: std::collections::HashMap<String, 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,
pub commands_buf: Vec<crate::layout::Command>,
pub frame_data: crate::layout::FrameData,
pub context_stack_buf: Vec<Box<dyn std::any::Any>>,
pub deferred_draws_buf: Vec<FrameDeferredDrawSlot>,
pub group_stack_buf: Vec<std::sync::Arc<str>>,
pub text_color_stack_buf: Vec<Option<crate::style::Color>>,
pub pending_tooltips_buf: Vec<context::PendingTooltip>,
pub hovered_groups_buf: std::collections::HashSet<std::sync::Arc<str>>,
#[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 mut stdout = io::stdout();
let _ = write!(stdout, "\x1b]2;{title}\x07");
let _ = stdout.flush();
}
}
#[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.elapsed());
continue;
}
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut f,
)? {
break;
}
discard_static_log(&mut state, "full-screen run()");
let render_elapsed = frame_start.elapsed();
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
)? {
break;
}
sleep_for_fps_cap(config.max_fps, render_elapsed);
}
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 messages: Vec<M> = Vec::new();
let mut state = FrameState::default();
loop {
let frame_start = Instant::now();
messages.clear();
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.elapsed());
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;
}
discard_static_log(&mut state, "run_async()");
let render_elapsed = frame_start.elapsed();
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
)? {
break;
}
sleep_for_fps_cap(config.max_fps, render_elapsed);
}
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, 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.elapsed());
continue;
}
if !run_frame(
&mut term,
&mut state,
&config,
std::mem::take(&mut events),
&mut f,
)? {
break;
}
discard_static_log(&mut state, "run_inline()");
let render_elapsed = frame_start.elapsed();
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
)? {
break;
}
sleep_for_fps_cap(config.max_fps, render_elapsed);
}
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,
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.elapsed());
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;
}
for line in drain_static_log(&mut state) {
output.println(line);
}
let render_elapsed = frame_start.elapsed();
if !poll_events(
&mut events,
&mut state,
config.tick_rate,
&mut || term.handle_resize(),
config.handle_ctrl_c,
)? {
break;
}
sleep_for_fps_cap(config.max_fps, render_elapsed);
}
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()
}
pub(crate) const STATIC_LOG_NAMED_STATE_KEY: &str = "__slt_static_log_pending";
pub(crate) const KEYMAP_REGISTRY_NAMED_STATE_KEY: &str = "__slt_keymap_registry";
pub(crate) fn clear_keymap_registry(state: &mut FrameState) {
if let Some(boxed) = state.named_states.get_mut(KEYMAP_REGISTRY_NAMED_STATE_KEY) {
if let Some(vec) = boxed.downcast_mut::<Vec<crate::keymap::PublishedKeymap>>() {
vec.clear();
}
}
}
#[cfg(feature = "crossterm")]
pub(crate) fn drain_static_log(state: &mut FrameState) -> Vec<String> {
if let Some(boxed) = state.named_states.get_mut(STATIC_LOG_NAMED_STATE_KEY) {
if let Some(buf) = boxed.downcast_mut::<Vec<String>>() {
return std::mem::take(buf);
}
}
Vec::new()
}
#[cfg(feature = "crossterm")]
fn discard_static_log(state: &mut FrameState, mode: &str) {
let drained = drain_static_log(state);
#[cfg(debug_assertions)]
if !drained.is_empty() {
#[allow(clippy::print_stderr)]
{
eprintln!(
"[slt] {} static_log lines were dropped: {} runtime has no scrollback channel; use slt::run_static for streaming output",
drained.len(),
mode
);
}
}
#[cfg(not(debug_assertions))]
{
let _ = (drained, mode);
}
}
#[cfg(feature = "crossterm")]
pub(crate) fn process_run_loop_event(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
match ev {
Event::Mouse(m) => {
state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
}
Event::FocusLost => {
state.layout_feedback.last_mouse_pos = None;
}
Event::Key(event::KeyEvent {
code: KeyCode::F(12),
kind: event::KeyEventKind::Press,
modifiers,
}) if modifiers.contains(event::KeyModifiers::SHIFT) => {
state.diagnostics.debug_layer = match state.diagnostics.debug_layer {
DebugLayer::All => DebugLayer::TopMost,
DebugLayer::TopMost => DebugLayer::BaseOnly,
DebugLayer::BaseOnly => DebugLayer::All,
};
}
Event::Key(event::KeyEvent {
code: KeyCode::F(12),
kind: event::KeyEventKind::Press,
modifiers,
}) if *modifiers == event::KeyModifiers::NONE => {
state.diagnostics.debug_mode = !state.diagnostics.debug_mode;
}
Event::Resize(_, _) => {
*has_resize = true;
}
_ => {}
}
}
#[cfg(feature = "crossterm")]
fn poll_events(
events: &mut Vec<Event>,
state: &mut FrameState,
tick_rate: Duration,
on_resize: &mut impl FnMut() -> io::Result<()>,
handle_ctrl_c: bool,
) -> io::Result<bool> {
let mut has_resize = false;
fn process_ev(ev: &Event, state: &mut FrameState, has_resize: &mut bool) {
process_run_loop_event(ev, state, has_resize);
}
if crossterm::event::poll(tick_rate)? {
let raw = crossterm::event::read()?;
if let Some(ev) = event::from_crossterm(raw) {
if handle_ctrl_c && is_ctrl_c(&ev) {
return Ok(false);
}
if matches!(ev, Event::Resize(_, _)) {
on_resize()?;
}
process_ev(&ev, state, &mut has_resize);
events.push(ev);
}
while crossterm::event::poll(Duration::ZERO)? {
let raw = crossterm::event::read()?;
if let Some(ev) = event::from_crossterm(raw) {
if handle_ctrl_c && is_ctrl_c(&ev) {
return Ok(false);
}
if matches!(ev, Event::Resize(_, _)) {
on_resize()?;
}
process_ev(&ev, state, &mut has_resize);
events.push(ev);
}
}
}
if has_resize {
clear_frame_layout_cache(state);
for ev in events.iter() {
match ev {
Event::Mouse(m) => {
state.layout_feedback.last_mouse_pos = Some((m.x, m.y));
}
Event::FocusLost => {
state.layout_feedback.last_mouse_pos = None;
}
_ => {}
}
}
}
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;
clear_keymap_registry(state);
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.pending_tooltips.is_empty(),
"pending tooltips must be emitted before layout"
);
if ctx.should_quit {
state.hook_states = ctx.hook_states;
state.named_states = ctx.named_states;
state.keyed_states = ctx.keyed_states;
state.screen_hook_map = ctx.screen_hook_map;
state.diagnostics.notification_queue = ctx.rollback.notification_queue;
state.diagnostics.debug_layer = ctx.debug_layer;
state.focus.prev_focus_index = Some(ctx.focus_index);
state.focus.focus_name_map_prev = ctx.focus_name_map;
state.focus.pending_focus_name = ctx.pending_focus_name;
ctx.deferred_draws.clear();
state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
ctx.commands.clear();
state.commands_buf = std::mem::take(&mut ctx.commands);
#[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(&mut ctx.commands);
let area = crate::rect::Rect::new(0, 0, w, h);
layout::compute(&mut tree, area);
let mut fd = std::mem::take(&mut state.frame_data);
layout::collect_all(&tree, &mut fd);
debug_assert_eq!(
fd.scroll_infos.len(),
fd.scroll_rects.len(),
"scroll feedback vectors must stay aligned"
);
let raw_rects = std::mem::take(&mut fd.raw_draw_rects);
state.layout_feedback.prev_scroll_infos = std::mem::take(&mut fd.scroll_infos);
state.layout_feedback.prev_scroll_rects = std::mem::take(&mut fd.scroll_rects);
state.layout_feedback.prev_hit_map = std::mem::take(&mut fd.hit_areas);
state.layout_feedback.prev_group_rects = std::mem::take(&mut fd.group_rects);
state.layout_feedback.prev_content_map = std::mem::take(&mut fd.content_areas);
state.layout_feedback.prev_focus_rects = std::mem::take(&mut fd.focus_rects);
state.layout_feedback.prev_focus_groups = std::mem::take(&mut fd.focus_groups);
state.frame_data = fd;
layout::render(&tree, buffer);
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.named_states = ctx.named_states;
state.keyed_states = ctx.keyed_states;
state.screen_hook_map = ctx.screen_hook_map;
state.diagnostics.notification_queue = ctx.rollback.notification_queue;
state.diagnostics.debug_layer = ctx.debug_layer;
state.focus.prev_focus_index = Some(ctx.focus_index);
state.focus.focus_name_map_prev = ctx.focus_name_map;
state.focus.pending_focus_name = ctx.pending_focus_name;
ctx.deferred_draws.clear();
state.context_stack_buf = std::mem::take(&mut ctx.context_stack);
state.deferred_draws_buf = std::mem::take(&mut ctx.deferred_draws);
state.group_stack_buf = std::mem::take(&mut ctx.rollback.group_stack);
state.text_color_stack_buf = std::mem::take(&mut ctx.rollback.text_color_stack);
state.pending_tooltips_buf = std::mem::take(&mut ctx.pending_tooltips);
state.hovered_groups_buf = std::mem::take(&mut ctx.hovered_groups);
state.commands_buf = std::mem::take(&mut ctx.commands);
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,
state.diagnostics.debug_layer,
);
}
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 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>, render_elapsed: Duration) {
if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
let target = Duration::from_secs_f64(1.0 / fps as f64);
if render_elapsed < target {
std::thread::sleep(target - render_elapsed);
}
}
}
#[cfg(all(test, feature = "crossterm"))]
mod run_loop_tests {
use super::*;
fn key(modifiers: event::KeyModifiers) -> Event {
Event::Key(event::KeyEvent {
code: KeyCode::F(12),
kind: event::KeyEventKind::Press,
modifiers,
})
}
#[test]
fn plain_f12_toggles_debug_mode() {
let mut state = FrameState::default();
let mut has_resize = false;
assert!(!state.diagnostics.debug_mode);
process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
assert!(state.diagnostics.debug_mode);
process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
assert!(!state.diagnostics.debug_mode);
}
#[test]
fn shift_f12_cycles_debug_layer_without_toggling_overlay() {
let mut state = FrameState::default();
let mut has_resize = false;
assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
assert!(!state.diagnostics.debug_mode);
process_run_loop_event(
&key(event::KeyModifiers::SHIFT),
&mut state,
&mut has_resize,
);
assert_eq!(state.diagnostics.debug_layer, DebugLayer::TopMost);
assert!(!state.diagnostics.debug_mode);
process_run_loop_event(
&key(event::KeyModifiers::SHIFT),
&mut state,
&mut has_resize,
);
assert_eq!(state.diagnostics.debug_layer, DebugLayer::BaseOnly);
process_run_loop_event(
&key(event::KeyModifiers::SHIFT),
&mut state,
&mut has_resize,
);
assert_eq!(state.diagnostics.debug_layer, DebugLayer::All);
}
#[test]
fn shift_f12_does_not_also_toggle_overlay() {
let mut state = FrameState::default();
let mut has_resize = false;
let before = state.diagnostics.debug_mode;
process_run_loop_event(
&key(event::KeyModifiers::SHIFT),
&mut state,
&mut has_resize,
);
assert_eq!(
state.diagnostics.debug_mode, before,
"Shift+F12 must not flip the on/off toggle"
);
}
#[test]
fn plain_f12_does_not_cycle_layer() {
let mut state = FrameState::default();
let mut has_resize = false;
let before = state.diagnostics.debug_layer;
process_run_loop_event(&key(event::KeyModifiers::NONE), &mut state, &mut has_resize);
assert_eq!(state.diagnostics.debug_layer, before);
}
}