#![forbid(unsafe_code)]
use crate::StorageResult;
use crate::evidence_sink::{EvidenceSink, EvidenceSinkConfig};
use crate::evidence_telemetry::{
BudgetDecisionSnapshot, ConformalSnapshot, ResizeDecisionSnapshot, set_budget_snapshot,
set_resize_snapshot,
};
use crate::input_fairness::{FairnessDecision, FairnessEventType, InputFairnessGuard};
use crate::input_macro::{EventRecorder, InputMacro};
use crate::locale::LocaleContext;
use crate::queueing_scheduler::{EstimateSource, QueueingScheduler, SchedulerConfig, WeightSource};
use crate::render_trace::RenderTraceConfig;
use crate::resize_coalescer::{CoalesceAction, CoalescerConfig, ResizeCoalescer};
use crate::state_persistence::StateRegistry;
use crate::subscription::SubscriptionManager;
use crate::terminal_writer::{RuntimeDiffConfig, ScreenMode, TerminalWriter, UiAnchor};
use crate::voi_sampling::{VoiConfig, VoiSampler};
use crate::{BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor};
#[cfg(feature = "asupersync-executor")]
use asupersync::runtime::{BlockingTaskHandle, Runtime as AsupersyncRuntime, RuntimeBuilder};
use ftui_backend::{BackendEventSource, BackendFeatures};
use ftui_core::event::{
Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
};
#[cfg(feature = "crossterm-compat")]
use ftui_core::terminal_capabilities::TerminalCapabilities;
#[cfg(feature = "crossterm-compat")]
use ftui_core::terminal_session::{SessionOptions, TerminalSession};
use ftui_layout::{
PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
PaneDragResizeTransition, PaneInertialThrow, PaneLayout, PaneModifierSnapshot,
PaneMotionVector, PaneNodeKind, PanePointerButton, PanePointerPosition,
PanePressureSnapProfile, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
};
use ftui_render::arena::FrameArena;
use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
use ftui_render::buffer::Buffer;
use ftui_render::diff_strategy::DiffStrategy;
use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
use ftui_render::sanitize::sanitize;
use std::any::Any;
use std::collections::HashMap;
use std::io::{self, Stdout, Write};
use std::panic::{self, AssertUnwindSafe};
use std::sync::Arc;
#[inline]
fn check_termination_signal() -> Option<i32> {
ftui_core::shutdown_signal::pending_termination_signal()
}
#[inline]
fn clear_termination_signal() {
ftui_core::shutdown_signal::clear_pending_termination_signal();
}
use std::sync::mpsc;
use std::thread::{self, JoinHandle};
use tracing::{debug, debug_span, info, info_span, trace};
use web_time::{Duration, Instant};
pub trait Model: Sized {
type Message: From<Event> + Send + 'static;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
fn view(&self, frame: &mut Frame);
fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
vec![]
}
fn as_screen_tick_dispatch(
&mut self,
) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
None
}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
Cmd::none()
}
}
const DEFAULT_TASK_WEIGHT: f64 = 1.0;
const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
#[derive(Debug, Clone)]
pub struct TaskSpec {
pub weight: f64,
pub estimate_ms: f64,
pub name: Option<String>,
}
impl Default for TaskSpec {
fn default() -> Self {
Self {
weight: DEFAULT_TASK_WEIGHT,
estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
name: None,
}
}
}
impl TaskSpec {
#[must_use]
pub fn new(weight: f64, estimate_ms: f64) -> Self {
Self {
weight,
estimate_ms,
name: None,
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct FrameTiming {
pub frame_idx: u64,
pub update_us: u64,
pub render_us: u64,
pub diff_us: u64,
pub present_us: u64,
pub total_us: u64,
}
#[derive(Debug)]
struct SignalTerminationError {
signal: i32,
}
impl std::fmt::Display for SignalTerminationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "terminated by signal {}", self.signal)
}
}
impl std::error::Error for SignalTerminationError {}
fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
err.get_ref()
.and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
.map(|inner| inner.signal)
}
pub trait FrameTimingSink: Send + Sync {
fn record_frame(&self, timing: &FrameTiming);
}
#[derive(Clone)]
pub struct FrameTimingConfig {
pub sink: Arc<dyn FrameTimingSink>,
}
impl FrameTimingConfig {
#[must_use]
pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
Self { sink }
}
}
impl std::fmt::Debug for FrameTimingConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FrameTimingConfig")
.field("sink", &"<dyn FrameTimingSink>")
.finish()
}
}
#[derive(Default)]
pub enum Cmd<M> {
#[default]
None,
Quit,
Batch(Vec<Cmd<M>>),
Sequence(Vec<Cmd<M>>),
Msg(M),
Tick(Duration),
Log(String),
Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
SaveState,
RestoreState,
SetMouseCapture(bool),
SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
}
impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::Quit => write!(f, "Quit"),
Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
Self::SaveState => write!(f, "SaveState"),
Self::RestoreState => write!(f, "RestoreState"),
Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
}
}
}
impl<M> Cmd<M> {
#[inline]
pub fn none() -> Self {
Self::None
}
#[inline]
pub fn quit() -> Self {
Self::Quit
}
#[inline]
pub fn msg(m: M) -> Self {
Self::Msg(m)
}
#[inline]
pub fn log(msg: impl Into<String>) -> Self {
Self::Log(msg.into())
}
pub fn batch(cmds: Vec<Self>) -> Self {
if cmds.is_empty() {
Self::None
} else if cmds.len() == 1 {
cmds.into_iter().next().unwrap_or(Self::None)
} else {
Self::Batch(cmds)
}
}
pub fn sequence(cmds: Vec<Self>) -> Self {
if cmds.is_empty() {
Self::None
} else if cmds.len() == 1 {
cmds.into_iter().next().unwrap_or(Self::None)
} else {
Self::Sequence(cmds)
}
}
#[inline]
pub fn type_name(&self) -> &'static str {
match self {
Self::None => "None",
Self::Quit => "Quit",
Self::Batch(_) => "Batch",
Self::Sequence(_) => "Sequence",
Self::Msg(_) => "Msg",
Self::Tick(_) => "Tick",
Self::Log(_) => "Log",
Self::Task(..) => "Task",
Self::SaveState => "SaveState",
Self::RestoreState => "RestoreState",
Self::SetMouseCapture(_) => "SetMouseCapture",
Self::SetTickStrategy(_) => "SetTickStrategy",
}
}
#[inline]
pub fn tick(duration: Duration) -> Self {
Self::Tick(duration)
}
pub fn task<F>(f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(TaskSpec::default(), Box::new(f))
}
pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(spec, Box::new(f))
}
pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
}
pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(TaskSpec::default().with_name(name), Box::new(f))
}
pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
Self::SetTickStrategy(Box::new(strategy))
}
#[inline]
pub fn save_state() -> Self {
Self::SaveState
}
#[inline]
pub fn restore_state() -> Self {
Self::RestoreState
}
#[inline]
pub fn set_mouse_capture(enabled: bool) -> Self {
Self::SetMouseCapture(enabled)
}
pub fn count(&self) -> usize {
match self {
Self::None => 0,
Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
_ => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeBehavior {
Immediate,
Throttled,
}
impl ResizeBehavior {
const fn uses_coalescer(self) -> bool {
matches!(self, ResizeBehavior::Throttled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MouseCapturePolicy {
#[default]
Auto,
On,
Off,
}
impl MouseCapturePolicy {
#[must_use]
pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
match self {
Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
Self::On => true,
Self::Off => false,
}
}
}
const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTerminalSplitterHandle {
pub target: PaneResizeTarget,
pub rect: Rect,
pub boundary: i32,
}
#[must_use]
pub fn pane_terminal_splitter_handles(
tree: &PaneTree,
layout: &PaneLayout,
hit_thickness: u16,
) -> Vec<PaneTerminalSplitterHandle> {
let thickness = if hit_thickness == 0 {
PANE_TERMINAL_DEFAULT_HIT_THICKNESS
} else {
hit_thickness
};
let mut handles = Vec::new();
for node in tree.nodes() {
let PaneNodeKind::Split(split) = &node.kind else {
continue;
};
let Some(split_rect) = layout.rect(node.id) else {
continue;
};
if split_rect.is_empty() {
continue;
}
let Some(first_rect) = layout.rect(split.first) else {
continue;
};
let Some(second_rect) = layout.rect(split.second) else {
continue;
};
let boundary_u16 = match split.axis {
SplitAxis::Horizontal => {
if second_rect.x == split_rect.x {
first_rect.right()
} else {
second_rect.x
}
}
SplitAxis::Vertical => {
if second_rect.y == split_rect.y {
first_rect.bottom()
} else {
second_rect.y
}
}
};
let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
continue;
};
handles.push(PaneTerminalSplitterHandle {
target: PaneResizeTarget {
split_id: node.id,
axis: split.axis,
},
rect,
boundary: i32::from(boundary_u16),
});
}
handles
}
#[must_use]
pub fn pane_terminal_resolve_splitter_target(
handles: &[PaneTerminalSplitterHandle],
x: u16,
y: u16,
) -> Option<PaneResizeTarget> {
let px = i32::from(x);
let py = i32::from(y);
let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
for handle in handles {
if !rect_contains_cell(handle.rect, x, y) {
continue;
}
let distance = match handle.target.axis {
SplitAxis::Horizontal => px.abs_diff(handle.boundary),
SplitAxis::Vertical => py.abs_diff(handle.boundary),
};
let axis_rank = match handle.target.axis {
SplitAxis::Horizontal => 0,
SplitAxis::Vertical => 1,
};
let key = (distance, handle.target.split_id.get(), axis_rank);
if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
best = Some((key, handle.target));
}
}
best.map(|(_, target)| target)
}
pub fn register_pane_terminal_splitter_hits(
frame: &mut Frame,
handles: &[PaneTerminalSplitterHandle],
hit_id_base: u32,
) -> usize {
let mut registered = 0usize;
for (idx, handle) in handles.iter().enumerate() {
let Ok(offset) = u32::try_from(idx) else {
break;
};
let hit_id = HitId::new(hit_id_base.saturating_add(offset));
if frame.register_hit(
handle.rect,
hit_id,
HitRegion::Handle,
encode_pane_resize_target(handle.target),
) {
registered = registered.saturating_add(1);
}
}
registered
}
#[must_use]
pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
let (_, region, data) = hit;
if region != HitRegion::Handle {
return None;
}
decode_pane_resize_target(data)
}
fn splitter_hit_rect(
axis: SplitAxis,
split_rect: Rect,
boundary: u16,
thickness: u16,
) -> Option<Rect> {
let half = thickness.saturating_sub(1) / 2;
match axis {
SplitAxis::Horizontal => {
let start = boundary.saturating_sub(half).max(split_rect.x);
let end = boundary
.saturating_add(thickness.saturating_sub(half))
.min(split_rect.right());
let width = end.saturating_sub(start);
(width > 0 && split_rect.height > 0).then_some(Rect::new(
start,
split_rect.y,
width,
split_rect.height,
))
}
SplitAxis::Vertical => {
let start = boundary.saturating_sub(half).max(split_rect.y);
let end = boundary
.saturating_add(thickness.saturating_sub(half))
.min(split_rect.bottom());
let height = end.saturating_sub(start);
(height > 0 && split_rect.width > 0).then_some(Rect::new(
split_rect.x,
start,
split_rect.width,
height,
))
}
}
}
fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
}
fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
let axis = match target.axis {
SplitAxis::Horizontal => 0_u64,
SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
};
(target.split_id.get() << 1) | axis
}
fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
SplitAxis::Horizontal
} else {
SplitAxis::Vertical
};
let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
Some(PaneResizeTarget { split_id, axis })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PaneMuxEnvironment {
None,
Tmux,
Screen,
Zellij,
WeztermMux,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneCapabilityMatrix {
pub mux: PaneMuxEnvironment,
pub mouse_sgr: bool,
pub mouse_drag_reliable: bool,
pub mouse_button_discrimination: bool,
pub focus_events: bool,
pub bracketed_paste: bool,
pub unicode_box_drawing: bool,
pub true_color: bool,
pub degraded: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCapabilityLimitation {
pub id: &'static str,
pub description: &'static str,
pub fallback: &'static str,
}
impl PaneCapabilityMatrix {
#[must_use]
pub fn from_capabilities(
caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
) -> Self {
let mux = if caps.in_tmux {
PaneMuxEnvironment::Tmux
} else if caps.in_screen {
PaneMuxEnvironment::Screen
} else if caps.in_zellij {
PaneMuxEnvironment::Zellij
} else if caps.in_wezterm_mux {
PaneMuxEnvironment::WeztermMux
} else {
PaneMuxEnvironment::None
};
let mouse_sgr = caps.mouse_sgr;
let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
let mouse_button_discrimination = mouse_sgr;
let focus_events = caps.focus_events && !caps.in_any_mux();
let bracketed_paste = caps.bracketed_paste;
let unicode_box_drawing = caps.unicode_box_drawing;
let true_color = caps.true_color;
let degraded =
!mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
Self {
mux,
mouse_sgr,
mouse_drag_reliable,
mouse_button_discrimination,
focus_events,
bracketed_paste,
unicode_box_drawing,
true_color,
degraded,
}
}
#[must_use]
pub const fn drag_enabled(&self) -> bool {
self.mouse_drag_reliable
}
#[must_use]
pub const fn focus_cancel_effective(&self) -> bool {
self.focus_events
}
#[must_use]
pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
let mut out = Vec::new();
if !self.mouse_sgr {
out.push(PaneCapabilityLimitation {
id: "no_sgr_mouse",
description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
});
}
if !self.mouse_drag_reliable {
out.push(PaneCapabilityLimitation {
id: "mouse_drag_unreliable",
description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
});
}
if !self.mouse_button_discrimination {
out.push(PaneCapabilityLimitation {
id: "no_button_discrimination",
description: "Mouse release events do not identify which button was released",
fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
});
}
if !self.focus_events {
out.push(PaneCapabilityLimitation {
id: "no_focus_events",
description: "Terminal does not deliver focus-in/focus-out events",
fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
});
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTerminalAdapterConfig {
pub drag_threshold: u16,
pub update_hysteresis: u16,
pub activation_button: PanePointerButton,
pub drag_update_coalesce_distance: u16,
pub cancel_on_focus_lost: bool,
pub cancel_on_resize: bool,
}
impl Default for PaneTerminalAdapterConfig {
fn default() -> Self {
Self {
drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
activation_button: PanePointerButton::Primary,
drag_update_coalesce_distance: 2,
cancel_on_focus_lost: true,
cancel_on_resize: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PaneTerminalActivePointer {
pointer_id: u32,
target: PaneResizeTarget,
button: PanePointerButton,
last_position: PanePointerPosition,
cumulative_delta_x: i32,
cumulative_delta_y: i32,
direction_changes: u16,
sample_count: u32,
previous_step_delta_x: i32,
previous_step_delta_y: i32,
start_time: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalLifecyclePhase {
MouseDown,
MouseDrag,
MouseMove,
MouseUp,
MouseScroll,
KeyResize,
KeyCancel,
FocusLoss,
ResizeInterrupt,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalIgnoredReason {
MissingTarget,
NoActivePointer,
PointerButtonMismatch,
ActivationButtonRequired,
WindowNotFocused,
UnsupportedKey,
FocusGainNoop,
ResizeNoop,
DragCoalesced,
NonSemanticEvent,
MachineRejectedEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalLogOutcome {
SemanticForwarded,
SemanticForwardedAfterRecovery,
Ignored(PaneTerminalIgnoredReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTerminalLogEntry {
pub phase: PaneTerminalLifecyclePhase,
pub sequence: Option<u64>,
pub pointer_id: Option<u32>,
pub target: Option<PaneResizeTarget>,
pub recovery_cancel_sequence: Option<u64>,
pub outcome: PaneTerminalLogOutcome,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PaneTerminalDispatch {
pub primary_event: Option<PaneSemanticInputEvent>,
pub primary_transition: Option<PaneDragResizeTransition>,
pub motion: Option<PaneMotionVector>,
pub inertial_throw: Option<PaneInertialThrow>,
pub projected_position: Option<PanePointerPosition>,
pub recovery_event: Option<PaneSemanticInputEvent>,
pub recovery_transition: Option<PaneDragResizeTransition>,
pub log: PaneTerminalLogEntry,
}
impl PaneTerminalDispatch {
fn ignored(
phase: PaneTerminalLifecyclePhase,
reason: PaneTerminalIgnoredReason,
pointer_id: Option<u32>,
target: Option<PaneResizeTarget>,
) -> Self {
Self {
primary_event: None,
primary_transition: None,
motion: None,
inertial_throw: None,
projected_position: None,
recovery_event: None,
recovery_transition: None,
log: PaneTerminalLogEntry {
phase,
sequence: None,
pointer_id,
target,
recovery_cancel_sequence: None,
outcome: PaneTerminalLogOutcome::Ignored(reason),
},
}
}
fn forwarded(
phase: PaneTerminalLifecyclePhase,
pointer_id: Option<u32>,
target: Option<PaneResizeTarget>,
event: PaneSemanticInputEvent,
transition: PaneDragResizeTransition,
) -> Self {
let sequence = Some(event.sequence);
Self {
primary_event: Some(event),
primary_transition: Some(transition),
motion: None,
inertial_throw: None,
projected_position: None,
recovery_event: None,
recovery_transition: None,
log: PaneTerminalLogEntry {
phase,
sequence,
pointer_id,
target,
recovery_cancel_sequence: None,
outcome: PaneTerminalLogOutcome::SemanticForwarded,
},
}
}
#[must_use]
pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
self.motion.map(PanePressureSnapProfile::from_motion)
}
}
#[derive(Debug, Clone)]
pub struct PaneTerminalAdapter {
machine: PaneDragResizeMachine,
config: PaneTerminalAdapterConfig,
active: Option<PaneTerminalActivePointer>,
window_focused: bool,
next_sequence: u64,
}
impl PaneTerminalAdapter {
pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
let config = PaneTerminalAdapterConfig {
drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
..config
};
let machine = PaneDragResizeMachine::new_with_hysteresis(
config.drag_threshold,
config.update_hysteresis,
)?;
Ok(Self {
machine,
config,
active: None,
window_focused: true,
next_sequence: 1,
})
}
#[must_use]
pub const fn config(&self) -> PaneTerminalAdapterConfig {
self.config
}
#[must_use]
pub fn active_pointer_id(&self) -> Option<u32> {
self.active.map(|active| active.pointer_id)
}
#[must_use]
pub const fn window_focused(&self) -> bool {
self.window_focused
}
#[must_use]
pub const fn machine_state(&self) -> PaneDragResizeState {
self.machine.state()
}
pub fn translate(
&mut self,
event: &Event,
target_hint: Option<PaneResizeTarget>,
) -> PaneTerminalDispatch {
match event {
Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
Event::Key(key) => self.translate_key(*key, target_hint),
Event::Focus(focused) => self.translate_focus(*focused),
Event::Resize { .. } => self.translate_resize(),
_ => PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::Other,
PaneTerminalIgnoredReason::NonSemanticEvent,
None,
target_hint,
),
}
}
pub fn translate_with_handles(
&mut self,
event: &Event,
handles: &[PaneTerminalSplitterHandle],
) -> PaneTerminalDispatch {
let active_target = self.active.map(|active| active.target);
let target_hint = match event {
Event::Mouse(mouse) => {
let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
match mouse.kind {
MouseEventKind::Down(_)
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => resolved,
MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
resolved.or(active_target)
}
}
}
Event::Key(_) => active_target,
_ => None,
};
self.translate(event, target_hint)
}
fn translate_mouse(
&mut self,
mouse: MouseEvent,
target_hint: Option<PaneResizeTarget>,
) -> PaneTerminalDispatch {
let position = mouse_position(mouse);
let modifiers = pane_modifiers(mouse.modifiers);
match mouse.kind {
MouseEventKind::Down(button) => {
let pane_button = pane_button(button);
if pane_button != self.config.activation_button {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseDown,
PaneTerminalIgnoredReason::ActivationButtonRequired,
Some(pointer_id_for_button(pane_button)),
target_hint,
);
}
let Some(target) = target_hint else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseDown,
PaneTerminalIgnoredReason::MissingTarget,
Some(pointer_id_for_button(pane_button)),
None,
);
};
let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
let pointer_id = pointer_id_for_button(pane_button);
let kind = PaneSemanticInputEventKind::PointerDown {
target,
pointer_id,
button: pane_button,
position,
};
let mut dispatch = self.forward_semantic(
PaneTerminalLifecyclePhase::MouseDown,
Some(pointer_id),
Some(target),
kind,
modifiers,
);
if dispatch.primary_transition.is_some() {
self.active = Some(PaneTerminalActivePointer {
pointer_id,
target,
button: pane_button,
last_position: position,
cumulative_delta_x: 0,
cumulative_delta_y: 0,
direction_changes: 0,
sample_count: 0,
previous_step_delta_x: 0,
previous_step_delta_y: 0,
start_time: Instant::now(),
});
}
if let Some((cancel_event, cancel_transition)) = recovery {
dispatch.recovery_event = Some(cancel_event);
dispatch.recovery_transition = Some(cancel_transition);
dispatch.log.recovery_cancel_sequence =
dispatch.recovery_event.as_ref().map(|event| event.sequence);
if matches!(
dispatch.log.outcome,
PaneTerminalLogOutcome::SemanticForwarded
) {
dispatch.log.outcome =
PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
}
}
dispatch
}
MouseEventKind::Drag(button) => {
let pane_button = pane_button(button);
let Some(mut active) = self.active else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseDrag,
PaneTerminalIgnoredReason::NoActivePointer,
Some(pointer_id_for_button(pane_button)),
target_hint,
);
};
if active.button != pane_button {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseDrag,
PaneTerminalIgnoredReason::PointerButtonMismatch,
Some(pointer_id_for_button(pane_button)),
Some(active.target),
);
}
let delta_x = position.x.saturating_sub(active.last_position.x);
let delta_y = position.y.saturating_sub(active.last_position.y);
if self.should_coalesce_drag(delta_x, delta_y) {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseDrag,
PaneTerminalIgnoredReason::DragCoalesced,
Some(active.pointer_id),
Some(active.target),
);
}
if active.sample_count > 0 {
let flipped_x = delta_x.signum() != 0
&& active.previous_step_delta_x.signum() != 0
&& delta_x.signum() != active.previous_step_delta_x.signum();
let flipped_y = delta_y.signum() != 0
&& active.previous_step_delta_y.signum() != 0
&& delta_y.signum() != active.previous_step_delta_y.signum();
if flipped_x || flipped_y {
active.direction_changes = active.direction_changes.saturating_add(1);
}
}
active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
active.sample_count = active.sample_count.saturating_add(1);
active.previous_step_delta_x = delta_x;
active.previous_step_delta_y = delta_y;
let kind = PaneSemanticInputEventKind::PointerMove {
target: active.target,
pointer_id: active.pointer_id,
position,
delta_x,
delta_y,
};
let mut dispatch = self.forward_semantic(
PaneTerminalLifecyclePhase::MouseDrag,
Some(active.pointer_id),
Some(active.target),
kind,
modifiers,
);
if dispatch.primary_transition.is_some() {
active.last_position = position;
self.active = Some(active);
let duration = active.start_time.elapsed().as_millis() as u32;
dispatch.motion = Some(PaneMotionVector::from_delta(
active.cumulative_delta_x,
active.cumulative_delta_y,
duration,
active.direction_changes,
));
}
dispatch
}
MouseEventKind::Moved => {
let Some(mut active) = self.active else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseMove,
PaneTerminalIgnoredReason::NoActivePointer,
None,
target_hint,
);
};
let delta_x = position.x.saturating_sub(active.last_position.x);
let delta_y = position.y.saturating_sub(active.last_position.y);
if self.should_coalesce_drag(delta_x, delta_y) {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseMove,
PaneTerminalIgnoredReason::DragCoalesced,
Some(active.pointer_id),
Some(active.target),
);
}
if active.sample_count > 0 {
let flipped_x = delta_x.signum() != 0
&& active.previous_step_delta_x.signum() != 0
&& delta_x.signum() != active.previous_step_delta_x.signum();
let flipped_y = delta_y.signum() != 0
&& active.previous_step_delta_y.signum() != 0
&& delta_y.signum() != active.previous_step_delta_y.signum();
if flipped_x || flipped_y {
active.direction_changes = active.direction_changes.saturating_add(1);
}
}
active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
active.sample_count = active.sample_count.saturating_add(1);
active.previous_step_delta_x = delta_x;
active.previous_step_delta_y = delta_y;
let kind = PaneSemanticInputEventKind::PointerMove {
target: active.target,
pointer_id: active.pointer_id,
position,
delta_x,
delta_y,
};
let mut dispatch = self.forward_semantic(
PaneTerminalLifecyclePhase::MouseMove,
Some(active.pointer_id),
Some(active.target),
kind,
modifiers,
);
if dispatch.primary_transition.is_some() {
active.last_position = position;
self.active = Some(active);
let duration = active.start_time.elapsed().as_millis() as u32;
dispatch.motion = Some(PaneMotionVector::from_delta(
active.cumulative_delta_x,
active.cumulative_delta_y,
duration,
active.direction_changes,
));
}
dispatch
}
MouseEventKind::Up(button) => {
let pane_button = pane_button(button);
let Some(active) = self.active else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseUp,
PaneTerminalIgnoredReason::NoActivePointer,
Some(pointer_id_for_button(pane_button)),
target_hint,
);
};
if active.button != pane_button {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseUp,
PaneTerminalIgnoredReason::PointerButtonMismatch,
Some(pointer_id_for_button(pane_button)),
Some(active.target),
);
}
let kind = PaneSemanticInputEventKind::PointerUp {
target: active.target,
pointer_id: active.pointer_id,
button: active.button,
position,
};
let mut dispatch = self.forward_semantic(
PaneTerminalLifecyclePhase::MouseUp,
Some(active.pointer_id),
Some(active.target),
kind,
modifiers,
);
if dispatch.primary_transition.is_some() {
let duration = active.start_time.elapsed().as_millis() as u32;
let motion = PaneMotionVector::from_delta(
active.cumulative_delta_x,
active.cumulative_delta_y,
duration,
active.direction_changes,
);
let inertial_throw = PaneInertialThrow::from_motion(motion);
dispatch.motion = Some(motion);
dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
dispatch.inertial_throw = Some(inertial_throw);
self.active = None;
}
dispatch
}
MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => {
let target = target_hint.or(self.active.map(|active| active.target));
let Some(target) = target else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::MouseScroll,
PaneTerminalIgnoredReason::MissingTarget,
None,
None,
);
};
let lines = match mouse.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
_ => unreachable!("handled by outer match"),
};
let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
self.forward_semantic(
PaneTerminalLifecyclePhase::MouseScroll,
None,
Some(target),
kind,
modifiers,
)
}
}
}
fn translate_key(
&mut self,
key: KeyEvent,
target_hint: Option<PaneResizeTarget>,
) -> PaneTerminalDispatch {
if !self.window_focused {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::KeyResize,
PaneTerminalIgnoredReason::WindowNotFocused,
self.active_pointer_id(),
target_hint.or(self.active.map(|active| active.target)),
);
}
if key.kind == KeyEventKind::Release {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::Other,
PaneTerminalIgnoredReason::UnsupportedKey,
None,
target_hint,
);
}
if matches!(key.code, KeyCode::Escape) {
return self.cancel_active_dispatch(
PaneTerminalLifecyclePhase::KeyCancel,
PaneCancelReason::EscapeKey,
PaneTerminalIgnoredReason::NoActivePointer,
);
}
let target = target_hint.or(self.active.map(|active| active.target));
let Some(target) = target else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::KeyResize,
PaneTerminalIgnoredReason::MissingTarget,
None,
None,
);
};
let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::KeyResize,
PaneTerminalIgnoredReason::UnsupportedKey,
None,
Some(target),
);
};
let units = keyboard_resize_units(key.modifiers);
let kind = PaneSemanticInputEventKind::KeyboardResize {
target,
direction,
units,
};
self.forward_semantic(
PaneTerminalLifecyclePhase::KeyResize,
self.active_pointer_id(),
Some(target),
kind,
pane_modifiers(key.modifiers),
)
}
fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
if focused {
self.window_focused = true;
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::Other,
PaneTerminalIgnoredReason::FocusGainNoop,
self.active_pointer_id(),
self.active.map(|active| active.target),
);
}
self.window_focused = false;
if !self.config.cancel_on_focus_lost {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::FocusLoss,
PaneTerminalIgnoredReason::ResizeNoop,
self.active_pointer_id(),
self.active.map(|active| active.target),
);
}
self.cancel_active_dispatch(
PaneTerminalLifecyclePhase::FocusLoss,
PaneCancelReason::FocusLost,
PaneTerminalIgnoredReason::NoActivePointer,
)
}
fn translate_resize(&mut self) -> PaneTerminalDispatch {
if !self.config.cancel_on_resize {
return PaneTerminalDispatch::ignored(
PaneTerminalLifecyclePhase::ResizeInterrupt,
PaneTerminalIgnoredReason::ResizeNoop,
self.active_pointer_id(),
self.active.map(|active| active.target),
);
}
self.cancel_active_dispatch(
PaneTerminalLifecyclePhase::ResizeInterrupt,
PaneCancelReason::Programmatic,
PaneTerminalIgnoredReason::ResizeNoop,
)
}
fn cancel_active_dispatch(
&mut self,
phase: PaneTerminalLifecyclePhase,
reason: PaneCancelReason,
no_active_reason: PaneTerminalIgnoredReason,
) -> PaneTerminalDispatch {
let Some(active) = self.active else {
return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
};
let kind = PaneSemanticInputEventKind::Cancel {
target: Some(active.target),
reason,
};
let dispatch = self.forward_semantic(
phase,
Some(active.pointer_id),
Some(active.target),
kind,
PaneModifierSnapshot::default(),
);
if dispatch.primary_transition.is_some() {
self.active = None;
}
dispatch
}
fn cancel_active_internal(
&mut self,
reason: PaneCancelReason,
) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
let active = self.active?;
let kind = PaneSemanticInputEventKind::Cancel {
target: Some(active.target),
reason,
};
let result = self
.apply_semantic(kind, PaneModifierSnapshot::default())
.ok();
if result.is_some() {
self.active = None;
}
result
}
fn forward_semantic(
&mut self,
phase: PaneTerminalLifecyclePhase,
pointer_id: Option<u32>,
target: Option<PaneResizeTarget>,
kind: PaneSemanticInputEventKind,
modifiers: PaneModifierSnapshot,
) -> PaneTerminalDispatch {
match self.apply_semantic(kind, modifiers) {
Ok((event, transition)) => {
PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
}
Err(_) => PaneTerminalDispatch::ignored(
phase,
PaneTerminalIgnoredReason::MachineRejectedEvent,
pointer_id,
target,
),
}
}
fn apply_semantic(
&mut self,
kind: PaneSemanticInputEventKind,
modifiers: PaneModifierSnapshot,
) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
{
let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
event.modifiers = modifiers;
let transition = self.machine.apply_event(&event)?;
Ok((event, transition))
}
fn next_sequence(&mut self) -> u64 {
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.saturating_add(1);
sequence
}
fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
return false;
}
let movement = delta_x
.unsigned_abs()
.saturating_add(delta_y.unsigned_abs());
movement < u32::from(self.config.drag_update_coalesce_distance)
}
pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
let was_active = self.active.is_some();
let machine_state_before = self.machine.state();
let machine_transition = self.machine.force_cancel();
let active_pointer = self.active.take();
if !was_active && machine_transition.is_none() {
return None;
}
Some(PaneCleanupDiagnostics {
had_active_pointer: was_active,
active_pointer_id: active_pointer.map(|a| a.pointer_id),
machine_state_before,
machine_transition,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCleanupDiagnostics {
pub had_active_pointer: bool,
pub active_pointer_id: Option<u32>,
pub machine_state_before: PaneDragResizeState,
pub machine_transition: Option<PaneDragResizeTransition>,
}
pub struct PaneInteractionGuard<'a> {
adapter: &'a mut PaneTerminalAdapter,
finished: bool,
diagnostics: Option<PaneCleanupDiagnostics>,
}
impl<'a> PaneInteractionGuard<'a> {
pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
Self {
adapter,
finished: false,
diagnostics: None,
}
}
pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
self.adapter
}
pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
self.finished = true;
let diagnostics = self.adapter.force_cancel_all();
self.diagnostics = diagnostics.clone();
diagnostics
}
}
impl Drop for PaneInteractionGuard<'_> {
fn drop(&mut self) {
if !self.finished {
self.diagnostics = self.adapter.force_cancel_all();
}
}
}
fn pane_button(button: MouseButton) -> PanePointerButton {
match button {
MouseButton::Left => PanePointerButton::Primary,
MouseButton::Right => PanePointerButton::Secondary,
MouseButton::Middle => PanePointerButton::Middle,
}
}
fn pointer_id_for_button(button: PanePointerButton) -> u32 {
match button {
PanePointerButton::Primary => 1,
PanePointerButton::Secondary => 2,
PanePointerButton::Middle => 3,
}
}
fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
}
fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
PaneModifierSnapshot {
shift: modifiers.contains(Modifiers::SHIFT),
alt: modifiers.contains(Modifiers::ALT),
ctrl: modifiers.contains(Modifiers::CTRL),
meta: modifiers.contains(Modifiers::SUPER),
}
}
fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
match (axis, code) {
(SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
(SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
(SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
(SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
(_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
(_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
_ => None,
}
}
fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
if modifiers.contains(Modifiers::SHIFT) {
5
} else {
1
}
}
#[derive(Clone)]
pub struct PersistenceConfig {
pub registry: Option<std::sync::Arc<StateRegistry>>,
pub checkpoint_interval: Option<Duration>,
pub auto_load: bool,
pub auto_save: bool,
}
impl Default for PersistenceConfig {
fn default() -> Self {
Self {
registry: None,
checkpoint_interval: None,
auto_load: true,
auto_save: true,
}
}
}
impl std::fmt::Debug for PersistenceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PersistenceConfig")
.field(
"registry",
&self.registry.as_ref().map(|r| r.backend_name()),
)
.field("checkpoint_interval", &self.checkpoint_interval)
.field("auto_load", &self.auto_load)
.field("auto_save", &self.auto_save)
.finish()
}
}
impl PersistenceConfig {
#[must_use]
pub fn disabled() -> Self {
Self::default()
}
#[must_use]
pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
Self {
registry: Some(registry),
..Default::default()
}
}
#[must_use]
pub fn checkpoint_every(mut self, interval: Duration) -> Self {
self.checkpoint_interval = Some(interval);
self
}
#[must_use]
pub fn auto_load(mut self, enabled: bool) -> Self {
self.auto_load = enabled;
self
}
#[must_use]
pub fn auto_save(mut self, enabled: bool) -> Self {
self.auto_save = enabled;
self
}
}
#[derive(Debug, Clone)]
pub struct WidgetRefreshConfig {
pub enabled: bool,
pub staleness_window_ms: u64,
pub starve_ms: u64,
pub max_starved_per_frame: usize,
pub max_drop_fraction: f32,
pub weight_priority: f32,
pub weight_staleness: f32,
pub weight_focus: f32,
pub weight_interaction: f32,
pub starve_boost: f32,
pub min_cost_us: f32,
}
impl Default for WidgetRefreshConfig {
fn default() -> Self {
Self {
enabled: true,
staleness_window_ms: 1_000,
starve_ms: 3_000,
max_starved_per_frame: 2,
max_drop_fraction: 1.0,
weight_priority: 1.0,
weight_staleness: 0.5,
weight_focus: 0.75,
weight_interaction: 0.5,
starve_boost: 1.5,
min_cost_us: 1.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TaskExecutorBackend {
#[default]
Spawned,
EffectQueue,
#[cfg(feature = "asupersync-executor")]
Asupersync,
}
#[derive(Debug, Clone)]
pub struct EffectQueueConfig {
pub enabled: bool,
pub backend: TaskExecutorBackend,
pub scheduler: SchedulerConfig,
pub max_queue_depth: usize,
explicit_backend: bool,
}
impl Default for EffectQueueConfig {
fn default() -> Self {
let scheduler = SchedulerConfig {
smith_enabled: true,
force_fifo: false,
preemptive: false,
aging_factor: 0.0,
wait_starve_ms: 0.0,
enable_logging: false,
..Default::default()
};
Self {
enabled: false,
backend: TaskExecutorBackend::Spawned,
scheduler,
max_queue_depth: 0,
explicit_backend: false,
}
}
}
impl EffectQueueConfig {
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self.backend = if enabled {
TaskExecutorBackend::EffectQueue
} else {
TaskExecutorBackend::Spawned
};
self.explicit_backend = true;
self
}
#[must_use]
pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
self.backend = backend;
self.explicit_backend = true;
self
}
#[must_use]
pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
self.scheduler = scheduler;
self
}
#[must_use]
pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
self.max_queue_depth = depth;
self
}
#[must_use]
fn uses_legacy_default_backend(&self) -> bool {
!self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
}
}
#[derive(Debug, Clone)]
pub struct ImmediateDrainConfig {
pub max_zero_timeout_polls_per_burst: usize,
pub max_burst_duration: Duration,
pub backoff_timeout: Duration,
}
impl Default for ImmediateDrainConfig {
fn default() -> Self {
Self {
max_zero_timeout_polls_per_burst: 64,
max_burst_duration: Duration::from_millis(2),
backoff_timeout: Duration::from_millis(1),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImmediateDrainStats {
pub bursts: u64,
pub zero_timeout_polls: u64,
pub backoff_polls: u64,
pub capped_bursts: u64,
pub max_zero_timeout_polls_in_burst: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuntimeLane {
Legacy,
#[default]
Structured,
Asupersync,
}
impl RuntimeLane {
#[must_use]
pub fn resolve(self) -> Self {
match self {
Self::Asupersync => {
tracing::info!(
target: "ftui.runtime",
requested = "asupersync",
resolved = "structured",
"Asupersync lane not yet available; falling back to structured cancellation"
);
Self::Structured
}
other => other,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Structured => "structured",
Self::Asupersync => "asupersync",
}
}
#[must_use]
pub fn uses_structured_cancellation(self) -> bool {
matches!(self, Self::Structured | Self::Asupersync)
}
#[must_use]
fn task_executor_backend(self) -> TaskExecutorBackend {
match self {
Self::Legacy => TaskExecutorBackend::Spawned,
Self::Structured => TaskExecutorBackend::EffectQueue,
Self::Asupersync => {
#[cfg(feature = "asupersync-executor")]
{
TaskExecutorBackend::Asupersync
}
#[cfg(not(feature = "asupersync-executor"))]
{
TaskExecutorBackend::EffectQueue
}
}
}
}
#[must_use]
pub fn from_env() -> Option<Self> {
let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
Self::parse(&val)
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"legacy" => Some(Self::Legacy),
"structured" => Some(Self::Structured),
"asupersync" => Some(Self::Asupersync),
_ => {
tracing::warn!(
target: "ftui.runtime",
value = s,
"RuntimeLane::parse: unrecognized value"
);
None
}
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RolloutPolicy {
#[default]
Off,
Shadow,
Enabled,
}
impl RolloutPolicy {
#[must_use]
pub fn from_env() -> Option<Self> {
let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
Self::parse(&val)
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"off" => Some(Self::Off),
"shadow" => Some(Self::Shadow),
"enabled" => Some(Self::Enabled),
_ => {
tracing::warn!(
target: "ftui.runtime",
value = s,
"RolloutPolicy::parse: unrecognized value"
);
None
}
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Shadow => "shadow",
Self::Enabled => "enabled",
}
}
#[must_use]
pub fn is_shadow(self) -> bool {
matches!(self, Self::Shadow)
}
}
impl std::fmt::Display for RolloutPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
impl std::fmt::Display for RuntimeLane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone)]
pub struct ProgramConfig {
pub screen_mode: ScreenMode,
pub ui_anchor: UiAnchor,
pub budget: FrameBudgetConfig,
pub diff_config: RuntimeDiffConfig,
pub evidence_sink: EvidenceSinkConfig,
pub render_trace: RenderTraceConfig,
pub frame_timing: Option<FrameTimingConfig>,
pub conformal_config: Option<ConformalConfig>,
pub locale_context: LocaleContext,
pub poll_timeout: Duration,
pub immediate_drain: ImmediateDrainConfig,
pub resize_coalescer: CoalescerConfig,
pub resize_behavior: ResizeBehavior,
pub forced_size: Option<(u16, u16)>,
pub mouse_capture_policy: MouseCapturePolicy,
pub bracketed_paste: bool,
pub focus_reporting: bool,
pub kitty_keyboard: bool,
pub persistence: PersistenceConfig,
pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
pub widget_refresh: WidgetRefreshConfig,
pub effect_queue: EffectQueueConfig,
pub guardrails: GuardrailsConfig,
pub intercept_signals: bool,
pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
pub runtime_lane: RuntimeLane,
pub rollout_policy: RolloutPolicy,
}
impl Default for ProgramConfig {
fn default() -> Self {
Self {
screen_mode: ScreenMode::Inline { ui_height: 4 },
ui_anchor: UiAnchor::Bottom,
budget: FrameBudgetConfig::default(),
diff_config: RuntimeDiffConfig::default(),
evidence_sink: EvidenceSinkConfig::default(),
render_trace: RenderTraceConfig::default(),
frame_timing: None,
conformal_config: None,
locale_context: LocaleContext::global(),
poll_timeout: Duration::from_millis(100),
immediate_drain: ImmediateDrainConfig::default(),
resize_coalescer: CoalescerConfig::default(),
resize_behavior: ResizeBehavior::Throttled,
forced_size: None,
mouse_capture_policy: MouseCapturePolicy::Auto,
bracketed_paste: true,
focus_reporting: false,
kitty_keyboard: false,
persistence: PersistenceConfig::default(),
inline_auto_remeasure: None,
widget_refresh: WidgetRefreshConfig::default(),
effect_queue: EffectQueueConfig::default(),
guardrails: GuardrailsConfig::default(),
intercept_signals: true,
tick_strategy: None,
runtime_lane: RuntimeLane::default(),
rollout_policy: RolloutPolicy::default(),
}
}
}
impl ProgramConfig {
pub fn fullscreen() -> Self {
Self {
screen_mode: ScreenMode::AltScreen,
..Default::default()
}
}
pub fn inline(height: u16) -> Self {
Self {
screen_mode: ScreenMode::Inline { ui_height: height },
..Default::default()
}
}
pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
Self {
screen_mode: ScreenMode::InlineAuto {
min_height,
max_height,
},
inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
..Default::default()
}
}
#[must_use]
pub fn with_mouse(mut self) -> Self {
self.mouse_capture_policy = MouseCapturePolicy::On;
self
}
#[must_use]
pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
self.mouse_capture_policy = policy;
self
}
#[must_use]
pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
self.mouse_capture_policy = if enabled {
MouseCapturePolicy::On
} else {
MouseCapturePolicy::Off
};
self
}
#[must_use]
pub const fn resolved_mouse_capture(&self) -> bool {
self.mouse_capture_policy.resolve(self.screen_mode)
}
#[must_use]
pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
self.budget = budget;
self
}
#[must_use]
pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
self.diff_config = diff_config;
self
}
#[must_use]
pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
self.evidence_sink = config;
self
}
#[must_use]
pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
self.render_trace = config;
self
}
#[must_use]
pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
self.frame_timing = Some(config);
self
}
#[must_use]
pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
self.conformal_config = Some(config);
self
}
#[must_use]
pub fn without_conformal(mut self) -> Self {
self.conformal_config = None;
self
}
#[must_use]
pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
self.locale_context = locale_context;
self
}
#[must_use]
pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
self.locale_context = LocaleContext::new(locale);
self
}
#[must_use]
pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
self.widget_refresh = config;
self
}
#[must_use]
pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
self.effect_queue = config;
self
}
#[must_use]
pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
self.resize_coalescer = config;
self
}
#[must_use]
pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
self.resize_behavior = behavior;
self
}
#[must_use]
pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
let width = width.max(1);
let height = height.max(1);
self.forced_size = Some((width, height));
self
}
#[must_use]
pub fn without_forced_size(mut self) -> Self {
self.forced_size = None;
self
}
#[must_use]
pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
if enabled {
self.resize_behavior = ResizeBehavior::Immediate;
}
self
}
#[must_use]
pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
self.persistence = persistence;
self
}
#[must_use]
pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
self.persistence = PersistenceConfig::with_registry(registry);
self
}
#[must_use]
pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
self.inline_auto_remeasure = Some(config);
self
}
#[must_use]
pub fn without_inline_auto_remeasure(mut self) -> Self {
self.inline_auto_remeasure = None;
self
}
#[must_use]
pub fn with_signal_interception(mut self, enabled: bool) -> Self {
self.intercept_signals = enabled;
self
}
#[must_use]
pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
self.guardrails = config;
self
}
#[must_use]
pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
self.immediate_drain = config;
self
}
#[must_use]
pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
self.tick_strategy = Some(strategy);
self
}
#[must_use]
pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
self.runtime_lane = lane;
self
}
#[must_use]
pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
self.rollout_policy = policy;
self
}
#[must_use]
pub fn with_env_overrides(mut self) -> Self {
if let Some(lane) = RuntimeLane::from_env() {
self.runtime_lane = lane;
}
if let Some(policy) = RolloutPolicy::from_env() {
self.rollout_policy = policy;
}
self
}
#[must_use]
fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
if !self.effect_queue.uses_legacy_default_backend() {
return self.effect_queue.clone();
}
self.effect_queue
.clone()
.with_backend(self.runtime_lane.resolve().task_executor_backend())
}
}
enum EffectCommand<M> {
Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EffectLoopControl {
Continue,
ShutdownRequested,
}
struct EffectQueue<M: Send + 'static> {
sender: mpsc::Sender<EffectCommand<M>>,
handle: Option<JoinHandle<()>>,
closed: bool,
}
impl<M: Send + 'static> EffectQueue<M> {
fn start(
config: EffectQueueConfig,
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
) -> io::Result<Self> {
let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
let handle = thread::Builder::new()
.name("ftui-effects".into())
.spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
Ok(Self {
sender: tx,
handle: Some(handle),
closed: false,
})
}
fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
if self.closed {
crate::effect_system::record_queue_drop("post_shutdown");
tracing::debug!("rejecting task enqueue after effect queue shutdown");
return;
}
if self
.sender
.send(EffectCommand::Enqueue(spec, task))
.is_err()
{
crate::effect_system::record_queue_drop("channel_closed");
}
}
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
fn shutdown(&mut self) {
self.closed = true;
let _ = self.sender.send(EffectCommand::Shutdown);
if let Some(handle) = self.handle.take() {
let start = Instant::now();
if handle.is_finished() {
let _ = handle.join();
let elapsed_us = start.elapsed().as_micros() as u64;
tracing::debug!(
target: "ftui.runtime",
elapsed_us,
"effect-queue shutdown (fast path)"
);
return;
}
while !handle.is_finished() {
if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
tracing::warn!(
target: "ftui.runtime",
timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
"effect-queue thread did not stop within timeout; detaching"
);
return;
}
thread::sleep(Self::SHUTDOWN_POLL);
}
let _ = handle.join();
let elapsed_us = start.elapsed().as_micros() as u64;
tracing::debug!(
target: "ftui.runtime",
elapsed_us,
"effect-queue shutdown (slow path)"
);
}
}
}
impl<M: Send + 'static> Drop for EffectQueue<M> {
fn drop(&mut self) {
self.shutdown();
}
}
struct SpawnTaskExecutor<M: Send + 'static> {
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
handles: Vec<JoinHandle<()>>,
closed: bool,
}
impl<M: Send + 'static> SpawnTaskExecutor<M> {
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
Self {
result_sender,
evidence_sink,
handles: Vec::new(),
closed: false,
}
}
fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
if self.closed {
tracing::debug!("rejecting spawned task submit after shutdown");
return;
}
let sender = self.result_sender.clone();
let evidence_sink = self.evidence_sink.clone();
let handle = thread::spawn(move || {
let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
});
self.handles.push(handle);
}
fn reap_finished(&mut self) {
if self.handles.is_empty() {
return;
}
let mut i = 0;
while i < self.handles.len() {
if self.handles[i].is_finished() {
let handle = self.handles.swap_remove(i);
let _ = handle.join();
} else {
i += 1;
}
}
}
fn shutdown(&mut self) {
self.closed = true;
let start = Instant::now();
self.reap_finished();
if self.handles.is_empty() {
let elapsed_us = start.elapsed().as_micros() as u64;
tracing::debug!(
target: "ftui.runtime",
elapsed_us,
"spawn-executor shutdown (fast path, all tasks already finished)"
);
return;
}
let pending_at_start = self.handles.len();
while self.handles.iter().any(|handle| !handle.is_finished()) {
if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
let still_pending = self
.handles
.iter()
.filter(|handle| !handle.is_finished())
.count();
tracing::warn!(
target: "ftui.runtime",
timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
pending_handles = still_pending,
"background task threads did not stop within timeout; detaching"
);
self.handles.clear();
return;
}
thread::sleep(Self::SHUTDOWN_POLL);
}
self.reap_finished();
let elapsed_us = start.elapsed().as_micros() as u64;
tracing::debug!(
target: "ftui.runtime",
elapsed_us,
pending_at_start,
"spawn-executor shutdown (slow path)"
);
}
}
#[cfg(feature = "asupersync-executor")]
struct AsupersyncTaskExecutor<M: Send + 'static> {
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
runtime: AsupersyncRuntime,
handles: Vec<BlockingTaskHandle>,
closed: bool,
}
#[cfg(feature = "asupersync-executor")]
impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
fn new(
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
) -> io::Result<Self> {
let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
let runtime = RuntimeBuilder::new()
.blocking_threads(1, max_threads)
.thread_name_prefix("ftui-asupersync-task")
.build()
.map_err(|error| {
io::Error::other(format!("asupersync runtime init failed: {error}"))
})?;
Ok(Self {
result_sender,
evidence_sink,
runtime,
handles: Vec::new(),
closed: false,
})
}
fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
if self.closed {
tracing::debug!("rejecting asupersync task submit after shutdown");
return;
}
let sender = self.result_sender.clone();
let evidence_sink = self.evidence_sink.clone();
let handle = self
.runtime
.spawn_blocking(move || {
let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
})
.expect("asupersync blocking pool must be configured");
self.handles.push(handle);
}
fn reap_finished(&mut self) {
self.handles.retain(|handle| !handle.is_done());
}
fn shutdown(&mut self) {
self.closed = true;
let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
for handle in &self.handles {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() || !handle.wait_timeout(remaining) {
tracing::warn!(
timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
pending_handles = self
.handles
.iter()
.filter(|pending| !pending.is_done())
.count(),
"Asupersync blocking tasks did not stop within timeout; detaching"
);
self.handles.clear();
return;
}
}
self.handles.clear();
}
}
enum TaskExecutor<M: Send + 'static> {
Spawned(SpawnTaskExecutor<M>),
Queued(EffectQueue<M>),
#[cfg(feature = "asupersync-executor")]
Asupersync(AsupersyncTaskExecutor<M>),
}
impl<M: Send + 'static> TaskExecutor<M> {
fn new(
config: &EffectQueueConfig,
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
) -> io::Result<Self> {
let executor = match config.backend {
TaskExecutorBackend::Spawned => {
Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
}
TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
config.clone(),
result_sender,
evidence_sink.clone(),
)?),
#[cfg(feature = "asupersync-executor")]
TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
result_sender,
evidence_sink.clone(),
)?),
};
emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
Ok(executor)
}
fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
match self {
Self::Spawned(executor) => executor.submit(task),
Self::Queued(queue) => queue.enqueue(spec, task),
#[cfg(feature = "asupersync-executor")]
Self::Asupersync(executor) => executor.submit(task),
}
}
fn reap_finished(&mut self) {
match self {
Self::Spawned(executor) => executor.reap_finished(),
#[cfg(feature = "asupersync-executor")]
Self::Asupersync(executor) => executor.reap_finished(),
Self::Queued(_) => {}
}
}
fn shutdown(&mut self) {
match self {
Self::Spawned(executor) => executor.shutdown(),
Self::Queued(queue) => queue.shutdown(),
#[cfg(feature = "asupersync-executor")]
Self::Asupersync(executor) => executor.shutdown(),
}
}
#[cfg(test)]
fn kind_name(&self) -> &'static str {
self.kind_name_for_logs()
}
fn kind_name_for_logs(&self) -> &'static str {
match self {
Self::Spawned(_) => "spawned",
Self::Queued(_) => "queued",
#[cfg(feature = "asupersync-executor")]
Self::Asupersync(_) => "asupersync",
}
}
}
fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
let Some(sink) = sink else {
return;
};
let _ = sink.write_jsonl(&format!(
r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
));
}
fn emit_task_executor_completion_evidence(
sink: Option<&EvidenceSink>,
backend: &str,
duration_us: u64,
) {
let Some(sink) = sink else {
return;
};
let _ = sink.write_jsonl(&format!(
r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
));
}
fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
let Some(sink) = sink else {
return;
};
let escaped = panic_msg
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
let _ = sink.write_jsonl(&format!(
r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
));
}
fn emit_task_executor_backpressure_evidence(
sink: Option<&EvidenceSink>,
backend: &str,
action: &str,
queue_length: usize,
max_queue_size: usize,
total_rejected: u64,
) {
let Some(sink) = sink else {
return;
};
let _ = sink.write_jsonl(&format!(
r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
));
}
fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_owned()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic payload".to_owned()
}
}
fn log_task_executor_panic(backend: &str, panic_msg: &str) {
#[cfg(feature = "tracing")]
tracing::error!(
executor_backend = backend,
panic_msg,
"task executor task panicked"
);
#[cfg(not(feature = "tracing"))]
eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
}
fn run_task_closure<M: Send + 'static>(
task: Box<dyn FnOnce() -> M + Send>,
backend: &str,
evidence_sink: Option<&EvidenceSink>,
result_sender: &mpsc::Sender<M>,
) -> bool {
let start = Instant::now();
match panic::catch_unwind(AssertUnwindSafe(task)) {
Ok(msg) => {
let duration_us = start.elapsed().as_micros() as u64;
tracing::debug!(
target: "ftui.effect",
command_type = "task",
executor_backend = backend,
duration_us = duration_us,
effect_duration_us = duration_us,
"task effect completed"
);
emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
let _ = result_sender.send(msg);
true
}
Err(payload) => {
let panic_msg = panic_payload_message(payload);
log_task_executor_panic(backend, &panic_msg);
emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
false
}
}
}
fn effect_queue_loop<M: Send + 'static>(
config: EffectQueueConfig,
rx: mpsc::Receiver<EffectCommand<M>>,
result_sender: mpsc::Sender<M>,
evidence_sink: Option<EvidenceSink>,
) {
let mut scheduler = QueueingScheduler::new(config.scheduler);
let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
let mut shutdown_requested = false;
let max_depth = config.max_queue_depth;
loop {
if tasks.is_empty() {
if shutdown_requested {
return;
}
match rx.recv() {
Ok(cmd) => {
if matches!(
handle_effect_command(
cmd,
&mut scheduler,
&mut tasks,
&result_sender,
evidence_sink.as_ref(),
max_depth,
),
EffectLoopControl::ShutdownRequested
) {
shutdown_requested = true;
}
}
Err(_) => return,
}
}
while let Ok(cmd) = rx.try_recv() {
if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
crate::effect_system::record_queue_drop("post_shutdown");
continue;
}
if matches!(
handle_effect_command(
cmd,
&mut scheduler,
&mut tasks,
&result_sender,
evidence_sink.as_ref(),
max_depth,
),
EffectLoopControl::ShutdownRequested
) {
shutdown_requested = true;
}
}
if tasks.is_empty() {
if shutdown_requested {
return;
}
continue;
}
let Some(job) = scheduler.peek_next().cloned() else {
continue;
};
if let Some(ref sink) = evidence_sink {
let evidence = scheduler.evidence();
let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
}
let completed = scheduler.tick(job.remaining_time);
for job_id in completed {
if let Some(task) = tasks.remove(&job_id) {
let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
crate::effect_system::record_queue_processed();
}
}
}
}
fn handle_effect_command<M: Send + 'static>(
cmd: EffectCommand<M>,
scheduler: &mut QueueingScheduler,
tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
result_sender: &mpsc::Sender<M>,
evidence_sink: Option<&EvidenceSink>,
max_depth: usize,
) -> EffectLoopControl {
match cmd {
EffectCommand::Enqueue(spec, task) => {
if max_depth > 0 && tasks.len() >= max_depth {
crate::effect_system::record_queue_drop("backpressure");
return EffectLoopControl::Continue;
}
let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
WeightSource::Default
} else {
WeightSource::Explicit
};
let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
EstimateSource::Default
} else {
EstimateSource::Explicit
};
let id = scheduler.submit_with_sources(
spec.weight,
spec.estimate_ms,
weight_source,
estimate_source,
spec.name,
);
if let Some(id) = id {
tasks.insert(id, task);
crate::effect_system::record_queue_enqueue(tasks.len() as u64);
} else {
let stats = scheduler.stats();
emit_task_executor_backpressure_evidence(
evidence_sink,
"queued",
"inline_fallback",
stats.queue_length,
scheduler.max_queue_size(),
stats.total_rejected,
);
let _ =
run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
}
EffectLoopControl::Continue
}
EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
}
}
#[derive(Debug, Clone)]
pub struct InlineAutoRemeasureConfig {
pub voi: VoiConfig,
pub change_threshold_rows: u16,
}
impl Default for InlineAutoRemeasureConfig {
fn default() -> Self {
Self {
voi: VoiConfig {
prior_alpha: 1.0,
prior_beta: 9.0,
max_interval_ms: 1000,
min_interval_ms: 100,
max_interval_events: 0,
min_interval_events: 0,
sample_cost: 0.08,
..VoiConfig::default()
},
change_threshold_rows: 1,
}
}
}
#[derive(Debug)]
struct InlineAutoRemeasureState {
config: InlineAutoRemeasureConfig,
sampler: VoiSampler,
}
impl InlineAutoRemeasureState {
fn new(config: InlineAutoRemeasureConfig) -> Self {
let sampler = VoiSampler::new(config.voi.clone());
Self { config, sampler }
}
fn reset(&mut self) {
self.sampler = VoiSampler::new(self.config.voi.clone());
}
}
#[derive(Debug, Clone)]
struct ConformalEvidence {
bucket_key: String,
n_b: usize,
alpha: f64,
q_b: f64,
y_hat: f64,
upper_us: f64,
risk: bool,
fallback_level: u8,
window_size: usize,
reset_count: u64,
}
impl ConformalEvidence {
fn from_prediction(prediction: &ConformalPrediction) -> Self {
let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
Self {
bucket_key: prediction.bucket.to_string(),
n_b: prediction.sample_count,
alpha,
q_b: prediction.quantile,
y_hat: prediction.y_hat,
upper_us: prediction.upper_us,
risk: prediction.risk,
fallback_level: prediction.fallback_level,
window_size: prediction.window_size,
reset_count: prediction.reset_count,
}
}
}
#[derive(Debug, Clone)]
struct BudgetDecisionEvidence {
frame_idx: u64,
decision: BudgetDecision,
controller_decision: BudgetDecision,
degradation_before: DegradationLevel,
degradation_after: DegradationLevel,
frame_time_us: f64,
budget_us: f64,
pid_output: f64,
pid_p: f64,
pid_i: f64,
pid_d: f64,
e_value: f64,
frames_observed: u32,
frames_since_change: u32,
in_warmup: bool,
conformal: Option<ConformalEvidence>,
}
impl BudgetDecisionEvidence {
fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
if after > before {
BudgetDecision::Degrade
} else if after < before {
BudgetDecision::Upgrade
} else {
BudgetDecision::Hold
}
}
#[must_use]
fn to_jsonl(&self) -> String {
let conformal = self.conformal.as_ref();
let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
let risk = Self::opt_bool(conformal.map(|c| c.risk));
let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
format!(
r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
self.frame_idx,
self.decision.as_str(),
self.controller_decision.as_str(),
self.degradation_before.as_str(),
self.degradation_after.as_str(),
self.frame_time_us,
self.budget_us,
self.pid_output,
self.pid_p,
self.pid_i,
self.pid_d,
self.e_value,
self.frames_observed,
self.frames_since_change,
self.in_warmup,
bucket_key,
n_b,
alpha,
q_b,
y_hat,
upper_us,
risk,
fallback_level,
window_size,
reset_count
)
}
fn opt_f64(value: Option<f64>) -> String {
value
.map(|v| format!("{v:.6}"))
.unwrap_or_else(|| "null".to_string())
}
fn opt_u64(value: Option<u64>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn opt_u8(value: Option<u8>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn opt_usize(value: Option<usize>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn opt_bool(value: Option<bool>) -> String {
value
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn opt_str(value: Option<&str>) -> String {
value
.map(|v| {
format!(
"\"{}\"",
v.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
)
})
.unwrap_or_else(|| "null".to_string())
}
}
#[derive(Debug, Clone)]
struct FairnessConfigEvidence {
enabled: bool,
input_priority_threshold_ms: u64,
dominance_threshold: u32,
fairness_threshold: f64,
}
impl FairnessConfigEvidence {
#[must_use]
fn to_jsonl(&self) -> String {
format!(
r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
self.enabled,
self.input_priority_threshold_ms,
self.dominance_threshold,
self.fairness_threshold
)
}
}
#[derive(Debug, Clone)]
struct FairnessDecisionEvidence {
frame_idx: u64,
decision: &'static str,
reason: &'static str,
pending_input_latency_ms: Option<u64>,
jain_index: f64,
resize_dominance_count: u32,
dominance_threshold: u32,
fairness_threshold: f64,
input_priority_threshold_ms: u64,
}
impl FairnessDecisionEvidence {
#[must_use]
fn to_jsonl(&self) -> String {
let pending_latency = self
.pending_input_latency_ms
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string());
format!(
r#"{{"event":"fairness_decision","frame_idx":{},"decision":"{}","reason":"{}","pending_input_latency_ms":{},"jain_index":{:.6},"resize_dominance_count":{},"dominance_threshold":{},"fairness_threshold":{:.6},"input_priority_threshold_ms":{}}}"#,
self.frame_idx,
self.decision,
self.reason,
pending_latency,
self.jain_index,
self.resize_dominance_count,
self.dominance_threshold,
self.fairness_threshold,
self.input_priority_threshold_ms
)
}
}
#[derive(Debug, Clone)]
struct WidgetRefreshEntry {
widget_id: u64,
essential: bool,
starved: bool,
value: f32,
cost_us: f32,
score: f32,
staleness_ms: u64,
}
impl WidgetRefreshEntry {
fn to_json(&self) -> String {
format!(
r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
self.widget_id,
self.cost_us,
self.value,
self.score,
self.essential,
self.starved,
self.staleness_ms
)
}
}
#[derive(Debug, Clone)]
struct WidgetRefreshPlan {
frame_idx: u64,
budget_us: f64,
degradation: DegradationLevel,
essentials_cost_us: f64,
selected_cost_us: f64,
selected_value: f64,
signal_count: usize,
selected: Vec<WidgetRefreshEntry>,
skipped_count: usize,
skipped_starved: usize,
starved_selected: usize,
over_budget: bool,
}
impl WidgetRefreshPlan {
fn new() -> Self {
Self {
frame_idx: 0,
budget_us: 0.0,
degradation: DegradationLevel::Full,
essentials_cost_us: 0.0,
selected_cost_us: 0.0,
selected_value: 0.0,
signal_count: 0,
selected: Vec::new(),
skipped_count: 0,
skipped_starved: 0,
starved_selected: 0,
over_budget: false,
}
}
fn clear(&mut self) {
self.frame_idx = 0;
self.budget_us = 0.0;
self.degradation = DegradationLevel::Full;
self.essentials_cost_us = 0.0;
self.selected_cost_us = 0.0;
self.selected_value = 0.0;
self.signal_count = 0;
self.selected.clear();
self.skipped_count = 0;
self.skipped_starved = 0;
self.starved_selected = 0;
self.over_budget = false;
}
fn as_budget(&self) -> WidgetBudget {
if self.signal_count == 0 {
return WidgetBudget::allow_all();
}
let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
WidgetBudget::allow_only(ids)
}
fn recompute(
&mut self,
frame_idx: u64,
budget_us: f64,
degradation: DegradationLevel,
signals: &[WidgetSignal],
config: &WidgetRefreshConfig,
) {
self.clear();
self.frame_idx = frame_idx;
self.budget_us = budget_us;
self.degradation = degradation;
if !config.enabled || signals.is_empty() {
return;
}
self.signal_count = signals.len();
let mut essentials_cost = 0.0f64;
let mut selected_cost = 0.0f64;
let mut selected_value = 0.0f64;
let staleness_window = config.staleness_window_ms.max(1) as f32;
let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
for signal in signals {
let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
let mut value = config.weight_priority * signal.priority
+ config.weight_staleness * staleness_score
+ config.weight_focus * signal.focus_boost
+ config.weight_interaction * signal.interaction_boost;
if starved {
value += config.starve_boost;
}
let raw_cost = if signal.recent_cost_us > 0.0 {
signal.recent_cost_us
} else {
signal.cost_estimate_us
};
let cost_us = raw_cost.max(config.min_cost_us);
let score = if cost_us > 0.0 {
value / cost_us
} else {
value
};
let entry = WidgetRefreshEntry {
widget_id: signal.widget_id,
essential: signal.essential,
starved,
value,
cost_us,
score,
staleness_ms: signal.staleness_ms,
};
if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
self.skipped_count += 1;
if starved {
self.skipped_starved = self.skipped_starved.saturating_add(1);
}
continue;
}
if signal.essential {
essentials_cost += cost_us as f64;
selected_cost += cost_us as f64;
selected_value += value as f64;
if starved {
self.starved_selected = self.starved_selected.saturating_add(1);
}
self.selected.push(entry);
} else {
candidates.push(entry);
}
}
let mut remaining = budget_us - selected_cost;
if degradation < DegradationLevel::EssentialOnly {
let nonessential_total = candidates.len();
let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
let min_nonessential_selected = if enforce_drop_rate {
let min_fraction = (1.0 - max_drop_fraction).max(0.0);
((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
} else {
0
};
candidates.sort_by(|a, b| {
b.starved
.cmp(&a.starved)
.then_with(|| b.score.total_cmp(&a.score))
.then_with(|| b.value.total_cmp(&a.value))
.then_with(|| a.cost_us.total_cmp(&b.cost_us))
.then_with(|| a.widget_id.cmp(&b.widget_id))
});
let mut forced_starved = 0usize;
let mut nonessential_selected = 0usize;
let mut skipped_candidates = if enforce_drop_rate {
Vec::with_capacity(candidates.len())
} else {
Vec::new()
};
for entry in candidates.into_iter() {
if entry.starved && forced_starved >= config.max_starved_per_frame {
self.skipped_count += 1;
self.skipped_starved = self.skipped_starved.saturating_add(1);
if enforce_drop_rate {
skipped_candidates.push(entry);
}
continue;
}
if remaining >= entry.cost_us as f64 {
remaining -= entry.cost_us as f64;
selected_cost += entry.cost_us as f64;
selected_value += entry.value as f64;
if entry.starved {
self.starved_selected = self.starved_selected.saturating_add(1);
forced_starved += 1;
}
nonessential_selected += 1;
self.selected.push(entry);
} else if entry.starved
&& forced_starved < config.max_starved_per_frame
&& nonessential_selected == 0
{
selected_cost += entry.cost_us as f64;
selected_value += entry.value as f64;
self.starved_selected = self.starved_selected.saturating_add(1);
forced_starved += 1;
nonessential_selected += 1;
self.selected.push(entry);
} else {
self.skipped_count += 1;
if entry.starved {
self.skipped_starved = self.skipped_starved.saturating_add(1);
}
if enforce_drop_rate {
skipped_candidates.push(entry);
}
}
}
if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
for entry in skipped_candidates.into_iter() {
if nonessential_selected >= min_nonessential_selected {
break;
}
if entry.starved && forced_starved >= config.max_starved_per_frame {
continue;
}
selected_cost += entry.cost_us as f64;
selected_value += entry.value as f64;
if entry.starved {
self.starved_selected = self.starved_selected.saturating_add(1);
forced_starved += 1;
self.skipped_starved = self.skipped_starved.saturating_sub(1);
}
self.skipped_count = self.skipped_count.saturating_sub(1);
nonessential_selected += 1;
self.selected.push(entry);
}
}
}
self.essentials_cost_us = essentials_cost;
self.selected_cost_us = selected_cost;
self.selected_value = selected_value;
self.over_budget = selected_cost > budget_us;
}
#[must_use]
fn to_jsonl(&self) -> String {
let mut out = String::with_capacity(256 + self.selected.len() * 96);
out.push_str(r#"{"event":"widget_refresh""#);
out.push_str(&format!(
r#","frame_idx":{},"budget_us":{:.3},"degradation":"{}","essentials_cost_us":{:.3},"selected_cost_us":{:.3},"selected_value":{:.3},"selected_count":{},"skipped_count":{},"starved_selected":{},"starved_skipped":{},"over_budget":{}"#,
self.frame_idx,
self.budget_us,
self.degradation.as_str(),
self.essentials_cost_us,
self.selected_cost_us,
self.selected_value,
self.selected.len(),
self.skipped_count,
self.starved_selected,
self.skipped_starved,
self.over_budget
));
out.push_str(r#","selected":["#);
for (i, entry) in self.selected.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&entry.to_json());
}
out.push_str("]}");
out
}
}
#[cfg(feature = "crossterm-compat")]
pub struct CrosstermEventSource {
session: TerminalSession,
features: BackendFeatures,
}
#[cfg(feature = "crossterm-compat")]
impl CrosstermEventSource {
pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
Self {
session,
features: initial_features,
}
}
}
#[cfg(feature = "crossterm-compat")]
impl BackendEventSource for CrosstermEventSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), io::Error> {
self.session.size()
}
fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
if features.mouse_capture != self.features.mouse_capture {
self.session.set_mouse_capture(features.mouse_capture)?;
}
self.features = features;
Ok(())
}
fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
self.session.poll_event(timeout)
}
fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
self.session.read_event()
}
}
pub struct HeadlessEventSource {
width: u16,
height: u16,
features: BackendFeatures,
}
impl HeadlessEventSource {
pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
Self {
width,
height,
features,
}
}
}
impl BackendEventSource for HeadlessEventSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), io::Error> {
Ok((self.width, self.height))
}
fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
self.features = features;
Ok(())
}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
Ok(false)
}
fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
Ok(None)
}
}
pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
model: M,
writer: TerminalWriter<W>,
events: E,
backend_features: BackendFeatures,
running: bool,
tick_rate: Option<Duration>,
executed_cmd_count: usize,
last_tick: Instant,
dirty: bool,
frame_idx: u64,
tick_count: u64,
widget_signals: Vec<WidgetSignal>,
widget_refresh_config: WidgetRefreshConfig,
widget_refresh_plan: WidgetRefreshPlan,
width: u16,
height: u16,
forced_size: Option<(u16, u16)>,
poll_timeout: Duration,
intercept_signals: bool,
immediate_drain_config: ImmediateDrainConfig,
immediate_drain_stats: ImmediateDrainStats,
budget: RenderBudget,
conformal_predictor: Option<ConformalPredictor>,
last_frame_time_us: Option<f64>,
last_update_us: Option<u64>,
frame_timing: Option<FrameTimingConfig>,
locale_context: LocaleContext,
locale_version: u64,
resize_coalescer: ResizeCoalescer,
evidence_sink: Option<EvidenceSink>,
fairness_config_logged: bool,
resize_behavior: ResizeBehavior,
fairness_guard: InputFairnessGuard,
event_recorder: Option<EventRecorder>,
subscriptions: SubscriptionManager<M::Message>,
#[cfg(test)]
task_sender: std::sync::mpsc::Sender<M::Message>,
task_receiver: std::sync::mpsc::Receiver<M::Message>,
task_executor: TaskExecutor<M::Message>,
state_registry: Option<std::sync::Arc<StateRegistry>>,
persistence_config: PersistenceConfig,
last_checkpoint: Instant,
inline_auto_remeasure: Option<InlineAutoRemeasureState>,
frame_arena: FrameArena,
guardrails: FrameGuardrails,
tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
last_active_screen_for_strategy: Option<String>,
}
#[cfg(feature = "crossterm-compat")]
impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
pub fn new(model: M) -> io::Result<Self>
where
M::Message: Send + 'static,
{
Self::with_config(model, ProgramConfig::default())
}
pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
where
M::Message: Send + 'static,
{
let resolved_lane = config.runtime_lane.resolve();
let effect_queue_config = config.resolved_effect_queue_config();
let capabilities = TerminalCapabilities::with_overrides();
let mouse_capture = config.resolved_mouse_capture();
let requested_features = BackendFeatures {
mouse_capture,
bracketed_paste: config.bracketed_paste,
focus_events: config.focus_reporting,
kitty_keyboard: config.kitty_keyboard,
};
let initial_features =
sanitize_backend_features_for_capabilities(requested_features, &capabilities);
let session = TerminalSession::new(SessionOptions {
alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
mouse_capture: initial_features.mouse_capture,
bracketed_paste: initial_features.bracketed_paste,
focus_events: initial_features.focus_events,
kitty_keyboard: initial_features.kitty_keyboard,
intercept_signals: config.intercept_signals,
})?;
let events = CrosstermEventSource::new(session, initial_features);
let mut writer = TerminalWriter::with_diff_config(
io::stdout(),
config.screen_mode,
config.ui_anchor,
capabilities,
config.diff_config.clone(),
);
let frame_timing = config.frame_timing.clone();
writer.set_timing_enabled(frame_timing.is_some());
let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
if let Some(ref sink) = evidence_sink {
writer = writer.with_evidence_sink(sink.clone());
}
let render_trace = crate::RenderTraceRecorder::from_config(
&config.render_trace,
crate::RenderTraceContext {
capabilities: writer.capabilities(),
diff_config: config.diff_config.clone(),
resize_config: config.resize_coalescer.clone(),
conformal_config: config.conformal_config.clone(),
},
)?;
if let Some(recorder) = render_trace {
writer = writer.with_render_trace(recorder);
}
let (w, h) = config
.forced_size
.unwrap_or_else(|| events.size().unwrap_or((80, 24)));
let width = w.max(1);
let height = h.max(1);
writer.set_size(width, height);
let budget = RenderBudget::from_config(&config.budget);
let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
let locale_context = config.locale_context.clone();
let locale_version = locale_context.version();
let mut resize_coalescer =
ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
.with_screen_mode(config.screen_mode);
if let Some(ref sink) = evidence_sink {
resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
}
let subscriptions = SubscriptionManager::new();
let (task_sender, task_receiver) = std::sync::mpsc::channel();
let inline_auto_remeasure = config
.inline_auto_remeasure
.clone()
.map(InlineAutoRemeasureState::new);
let task_executor = TaskExecutor::new(
&effect_queue_config,
task_sender.clone(),
evidence_sink.clone(),
)?;
let guardrails = FrameGuardrails::new(config.guardrails);
tracing::info!(
target: "ftui.runtime",
requested_lane = config.runtime_lane.label(),
resolved_lane = resolved_lane.label(),
rollout_policy = config.rollout_policy.label(),
"runtime startup: lane={}, rollout={}",
resolved_lane.label(),
config.rollout_policy.label(),
);
Ok(Self {
model,
writer,
events,
backend_features: initial_features,
running: true,
tick_rate: None,
executed_cmd_count: 0,
last_tick: Instant::now(),
dirty: true,
frame_idx: 0,
tick_count: 0,
widget_signals: Vec::new(),
widget_refresh_config: config.widget_refresh,
widget_refresh_plan: WidgetRefreshPlan::new(),
width,
height,
forced_size: config.forced_size,
poll_timeout: config.poll_timeout,
intercept_signals: config.intercept_signals,
immediate_drain_config: config.immediate_drain,
immediate_drain_stats: ImmediateDrainStats::default(),
budget,
conformal_predictor,
last_frame_time_us: None,
last_update_us: None,
frame_timing,
locale_context,
locale_version,
resize_coalescer,
evidence_sink,
fairness_config_logged: false,
resize_behavior: config.resize_behavior,
fairness_guard: InputFairnessGuard::new(),
event_recorder: None,
subscriptions,
#[cfg(test)]
task_sender,
task_receiver,
task_executor,
state_registry: config.persistence.registry.clone(),
persistence_config: config.persistence,
last_checkpoint: Instant::now(),
inline_auto_remeasure,
frame_arena: FrameArena::default(),
guardrails,
tick_strategy: config
.tick_strategy
.map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
last_active_screen_for_strategy: None,
})
}
}
impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
pub fn with_event_source(
model: M,
events: E,
backend_features: BackendFeatures,
writer: TerminalWriter<W>,
config: ProgramConfig,
) -> io::Result<Self>
where
M::Message: Send + 'static,
{
let effect_queue_config = config.resolved_effect_queue_config();
let (width, height) = config
.forced_size
.unwrap_or_else(|| events.size().unwrap_or((80, 24)));
let width = width.max(1);
let height = height.max(1);
let mut writer = writer;
writer.set_size(width, height);
let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
if let Some(ref sink) = evidence_sink {
writer = writer.with_evidence_sink(sink.clone());
}
let render_trace = crate::RenderTraceRecorder::from_config(
&config.render_trace,
crate::RenderTraceContext {
capabilities: writer.capabilities(),
diff_config: config.diff_config.clone(),
resize_config: config.resize_coalescer.clone(),
conformal_config: config.conformal_config.clone(),
},
)?;
if let Some(recorder) = render_trace {
writer = writer.with_render_trace(recorder);
}
let frame_timing = config.frame_timing.clone();
writer.set_timing_enabled(frame_timing.is_some());
let budget = RenderBudget::from_config(&config.budget);
let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
let locale_context = config.locale_context.clone();
let locale_version = locale_context.version();
let mut resize_coalescer =
ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
.with_screen_mode(config.screen_mode);
if let Some(ref sink) = evidence_sink {
resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
}
let subscriptions = SubscriptionManager::new();
let (task_sender, task_receiver) = std::sync::mpsc::channel();
let inline_auto_remeasure = config
.inline_auto_remeasure
.clone()
.map(InlineAutoRemeasureState::new);
let task_executor = TaskExecutor::new(
&effect_queue_config,
task_sender.clone(),
evidence_sink.clone(),
)?;
let guardrails = FrameGuardrails::new(config.guardrails);
Ok(Self {
model,
writer,
events,
backend_features,
running: true,
tick_rate: None,
executed_cmd_count: 0,
last_tick: Instant::now(),
dirty: true,
frame_idx: 0,
tick_count: 0,
widget_signals: Vec::new(),
widget_refresh_config: config.widget_refresh,
widget_refresh_plan: WidgetRefreshPlan::new(),
width,
height,
forced_size: config.forced_size,
poll_timeout: config.poll_timeout,
intercept_signals: config.intercept_signals,
immediate_drain_config: config.immediate_drain,
immediate_drain_stats: ImmediateDrainStats::default(),
budget,
conformal_predictor,
last_frame_time_us: None,
last_update_us: None,
frame_timing,
locale_context,
locale_version,
resize_coalescer,
evidence_sink,
fairness_config_logged: false,
resize_behavior: config.resize_behavior,
fairness_guard: InputFairnessGuard::new(),
event_recorder: None,
subscriptions,
#[cfg(test)]
task_sender,
task_receiver,
task_executor,
state_registry: config.persistence.registry.clone(),
persistence_config: config.persistence,
last_checkpoint: Instant::now(),
inline_auto_remeasure,
frame_arena: FrameArena::default(),
guardrails,
tick_strategy: config
.tick_strategy
.map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
last_active_screen_for_strategy: None,
})
}
}
#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
#[inline]
const fn sanitize_backend_features_for_capabilities(
requested: BackendFeatures,
capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
) -> BackendFeatures {
let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
BackendFeatures {
mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
focus_events: requested.focus_events && focus_events_supported,
kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
}
}
#[cfg(feature = "native-backend")]
impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
where
M::Message: Send + 'static,
{
let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
let mouse_capture = config.resolved_mouse_capture();
let requested_features = BackendFeatures {
mouse_capture,
bracketed_paste: config.bracketed_paste,
focus_events: config.focus_reporting,
kitty_keyboard: config.kitty_keyboard,
};
let features =
sanitize_backend_features_for_capabilities(requested_features, &capabilities);
let options = ftui_tty::TtySessionOptions {
alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
features,
intercept_signals: config.intercept_signals,
};
#[cfg(unix)]
let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
#[cfg(not(unix))]
let backend = ftui_tty::TtyBackend::new(0, 0);
let writer = TerminalWriter::with_diff_config(
io::stdout(),
config.screen_mode,
config.ui_anchor,
capabilities,
config.diff_config.clone(),
);
Self::with_event_source(model, backend, features, writer, config)
}
}
impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
pub fn run(&mut self) -> io::Result<()> {
self.run_event_loop()
}
#[inline]
fn observed_termination_signal(&self) -> Option<i32> {
if self.intercept_signals {
check_termination_signal()
} else {
None
}
}
#[inline]
pub fn last_widget_signals(&self) -> &[WidgetSignal] {
&self.widget_signals
}
#[inline]
pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
self.immediate_drain_stats
}
fn run_event_loop(&mut self) -> io::Result<()> {
if self.persistence_config.auto_load {
self.load_state();
}
let cmd = {
let _span = info_span!("ftui.program.init").entered();
self.model.init()
};
self.execute_cmd(cmd)?;
let mut termination_signal = self.observed_termination_signal();
if self.running && termination_signal.is_none() {
self.reconcile_subscriptions();
self.render_frame()?;
}
let mut loop_count: u64 = 0;
while self.running {
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
loop_count += 1;
if loop_count.is_multiple_of(100) {
crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
}
let timeout = self.effective_timeout();
let poll_result = self.events.poll_event(timeout)?;
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
if poll_result {
self.drain_ready_events()?;
}
if !self.running {
break;
}
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
self.process_subscription_messages()?;
if !self.running {
break;
}
self.process_task_results()?;
self.reap_finished_tasks();
if !self.running {
break;
}
self.process_resize_coalescer()?;
if !self.running {
break;
}
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
self.check_screen_transition();
if self.should_tick() {
self.tick_count = self.tick_count.wrapping_add(1);
let tick_count = self.tick_count;
let mut used_screen_dispatch = false;
if let Some(strategy) = self.tick_strategy.as_mut() {
let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
let active = dispatch.active_screen_id();
let all_screens = dispatch.screen_ids();
(active, all_screens)
});
if let Some((active, all_screens)) = dispatch_snapshot {
used_screen_dispatch = true;
if let Some(previous_active) =
self.last_active_screen_for_strategy.as_deref()
&& previous_active != active
{
strategy.on_screen_transition(previous_active, &active);
}
self.last_active_screen_for_strategy = Some(active.clone());
let all_screens_count = all_screens.len();
let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
tick_targets.push(active.clone());
for screen_id in all_screens {
if screen_id != active
&& strategy.should_tick(&screen_id, tick_count, &active)
== crate::tick_strategy::TickDecision::Tick
{
tick_targets.push(screen_id);
}
}
let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
for screen_id in &tick_targets {
dispatch.tick_screen(screen_id, tick_count);
}
}
trace!(
tick = tick_count,
active = %active,
ticked = tick_targets.len(),
skipped = skipped_count,
"tick_strategy.frame"
);
strategy.maintenance_tick(tick_count);
self.mark_dirty();
}
}
if used_screen_dispatch && self.running {
self.reconcile_subscriptions();
}
if !used_screen_dispatch {
self.last_active_screen_for_strategy = None;
let msg = M::Message::from(Event::Tick);
let cmd = {
let _span = debug_span!(
"ftui.program.update",
msg_type = "Tick",
duration_us = tracing::field::Empty,
cmd_type = tracing::field::Empty
)
.entered();
let start = Instant::now();
let cmd = self.model.update(msg);
tracing::Span::current()
.record("duration_us", start.elapsed().as_micros() as u64);
tracing::Span::current()
.record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
cmd
};
self.mark_dirty();
self.execute_cmd(cmd)?;
if self.running {
self.reconcile_subscriptions();
}
}
}
self.check_checkpoint_save();
self.check_locale_change();
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
if self.dirty {
self.render_frame()?;
}
if loop_count.is_multiple_of(1000) {
self.writer.gc(None);
}
}
let shutdown_cmd = {
let _span = info_span!("ftui.program.shutdown").entered();
self.model.on_shutdown()
};
self.execute_cmd(shutdown_cmd)?;
if self.persistence_config.auto_save {
self.save_state();
}
if let Some(ref mut strategy) = self.tick_strategy {
strategy.shutdown();
}
self.subscriptions.stop_all();
self.task_executor.shutdown();
self.reap_finished_tasks();
self.drain_shutdown_task_results()?;
if let Some(signal) = termination_signal {
clear_termination_signal();
let err = io::Error::new(
io::ErrorKind::Interrupted,
SignalTerminationError { signal },
);
debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
return Err(err);
}
Ok(())
}
fn drain_ready_events(&mut self) -> io::Result<()> {
self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
let zero_poll_limit = self
.immediate_drain_config
.max_zero_timeout_polls_per_burst
.max(1);
let max_burst_duration = self.immediate_drain_config.max_burst_duration;
let backoff_timeout = self.immediate_drain_config.backoff_timeout;
let mut burst_start = Instant::now();
let mut zero_polls_in_burst_window: u64 = 0;
let mut capped_this_burst = false;
loop {
if let Some(event) = self.events.read_event()? {
self.handle_event(event)?;
if !self.running {
break;
}
}
let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
|| burst_start.elapsed() >= max_burst_duration;
if budget_exhausted {
if !capped_this_burst {
capped_this_burst = true;
self.immediate_drain_stats.capped_bursts =
self.immediate_drain_stats.capped_bursts.saturating_add(1);
}
self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
.immediate_drain_stats
.max_zero_timeout_polls_in_burst
.max(zero_polls_in_burst_window);
std::thread::yield_now();
self.immediate_drain_stats.backoff_polls =
self.immediate_drain_stats.backoff_polls.saturating_add(1);
if !self.events.poll_event(backoff_timeout)? {
break;
}
zero_polls_in_burst_window = 0;
burst_start = Instant::now();
continue;
}
self.immediate_drain_stats.zero_timeout_polls = self
.immediate_drain_stats
.zero_timeout_polls
.saturating_add(1);
zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
if !self.events.poll_event(Duration::ZERO)? {
break;
}
}
self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
.immediate_drain_stats
.max_zero_timeout_polls_in_burst
.max(zero_polls_in_burst_window);
Ok(())
}
fn load_state(&mut self) {
if let Some(registry) = &self.state_registry {
match registry.load() {
Ok(count) => {
info!(count, "loaded widget state from persistence");
}
Err(e) => {
tracing::warn!(error = %e, "failed to load widget state");
}
}
}
}
fn save_state(&mut self) {
if let Some(registry) = &self.state_registry {
match registry.flush() {
Ok(true) => {
debug!("saved widget state to persistence");
}
Ok(false) => {
}
Err(e) => {
tracing::warn!(error = %e, "failed to save widget state");
}
}
}
}
fn check_checkpoint_save(&mut self) {
if let Some(interval) = self.persistence_config.checkpoint_interval
&& self.last_checkpoint.elapsed() >= interval
{
self.save_state();
self.last_checkpoint = Instant::now();
}
}
fn handle_event(&mut self, event: Event) -> io::Result<()> {
let event_start = Instant::now();
let fairness_event_type = Self::classify_event_for_fairness(&event);
if fairness_event_type == FairnessEventType::Input {
self.fairness_guard.input_arrived(event_start);
}
if let Some(recorder) = &mut self.event_recorder {
recorder.record(&event);
}
let event = match event {
Event::Resize { width, height } => {
debug!(
width,
height,
behavior = ?self.resize_behavior,
"Resize event received"
);
if let Some((forced_width, forced_height)) = self.forced_size {
debug!(
forced_width,
forced_height, "Resize ignored due to forced size override"
);
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
return Ok(());
}
let width = width.max(1);
let height = height.max(1);
match self.resize_behavior {
ResizeBehavior::Immediate => {
self.resize_coalescer
.record_external_apply(width, height, Instant::now());
let result = self.apply_resize(width, height, Duration::ZERO, false);
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
return result;
}
ResizeBehavior::Throttled => {
let action = self.resize_coalescer.handle_resize(width, height);
if let CoalesceAction::ApplyResize {
width,
height,
coalesce_time,
forced_by_deadline,
} = action
{
let result =
self.apply_resize(width, height, coalesce_time, forced_by_deadline);
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
return result;
}
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
return Ok(());
}
}
}
other => other,
};
let msg = M::Message::from(event);
let cmd = {
let _span = debug_span!(
"ftui.program.update",
msg_type = "event",
duration_us = tracing::field::Empty,
cmd_type = tracing::field::Empty
)
.entered();
let start = Instant::now();
let cmd = self.model.update(msg);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
tracing::Span::current().record("duration_us", elapsed_us);
tracing::Span::current()
.record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
cmd
};
self.mark_dirty();
self.execute_cmd(cmd)?;
if self.running {
self.reconcile_subscriptions();
}
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
Ok(())
}
fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
match event {
Event::Key(_)
| Event::Mouse(_)
| Event::Paste(_)
| Event::Ime(_)
| Event::Focus(_)
| Event::Clipboard(_) => FairnessEventType::Input,
Event::Resize { .. } => FairnessEventType::Resize,
Event::Tick => FairnessEventType::Tick,
}
}
fn reconcile_subscriptions(&mut self) {
let _span = debug_span!(
"ftui.program.subscriptions",
active_count = tracing::field::Empty,
started = tracing::field::Empty,
stopped = tracing::field::Empty
)
.entered();
let subs = self.model.subscriptions();
let before_count = self.subscriptions.active_count();
self.subscriptions.reconcile(subs);
let after_count = self.subscriptions.active_count();
let started = after_count.saturating_sub(before_count);
let stopped = before_count.saturating_sub(after_count);
crate::debug_trace!(
"subscriptions reconcile: before={}, after={}, started={}, stopped={}",
before_count,
after_count,
started,
stopped
);
if after_count == 0 {
crate::debug_trace!("subscriptions reconcile: no active subscriptions");
}
let current = tracing::Span::current();
current.record("active_count", after_count);
current.record("started", started);
current.record("stopped", stopped);
}
fn process_subscription_messages(&mut self) -> io::Result<()> {
let messages = self.subscriptions.drain_messages();
let msg_count = messages.len();
if msg_count > 0 {
crate::debug_trace!("processing {} subscription message(s)", msg_count);
}
for msg in messages {
let cmd = {
let _span = debug_span!(
"ftui.program.update",
msg_type = "subscription",
duration_us = tracing::field::Empty,
cmd_type = tracing::field::Empty
)
.entered();
let start = Instant::now();
let cmd = self.model.update(msg);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
tracing::Span::current().record("duration_us", elapsed_us);
tracing::Span::current()
.record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
cmd
};
self.mark_dirty();
self.execute_cmd(cmd)?;
if !self.running {
break;
}
}
if self.running && self.dirty {
self.reconcile_subscriptions();
}
Ok(())
}
fn process_task_results(&mut self) -> io::Result<()> {
while let Ok(msg) = self.task_receiver.try_recv() {
let cmd = {
let _span = debug_span!(
"ftui.program.update",
msg_type = "task",
duration_us = tracing::field::Empty,
cmd_type = tracing::field::Empty
)
.entered();
let start = Instant::now();
let cmd = self.model.update(msg);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
tracing::Span::current().record("duration_us", elapsed_us);
tracing::Span::current()
.record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
cmd
};
self.mark_dirty();
self.execute_cmd(cmd)?;
if !self.running {
break;
}
}
if self.running && self.dirty {
self.reconcile_subscriptions();
}
Ok(())
}
fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
match cmd {
Cmd::None => {}
Cmd::Quit => self.running = false,
Cmd::Msg(m) => {
let start = Instant::now();
let cmd = self.model.update(m);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
self.mark_dirty();
self.execute_cmd(cmd)?;
}
Cmd::Batch(cmds) => {
for c in cmds {
self.execute_cmd(c)?;
if !self.running {
break;
}
}
}
Cmd::Sequence(cmds) => {
for c in cmds {
self.execute_cmd(c)?;
if !self.running {
break;
}
}
}
Cmd::Tick(duration) => {
self.tick_rate = Some(duration);
self.last_tick = Instant::now();
}
Cmd::Log(text) => {
let sanitized = sanitize(&text);
let mut text_crlf = if sanitized.contains('\n') {
sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
} else {
sanitized.into_owned()
};
if !text_crlf.ends_with("\r\n") {
if text_crlf.ends_with('\n') {
text_crlf.pop();
}
text_crlf.push_str("\r\n");
}
self.writer.write_log(&text_crlf)?;
}
Cmd::Task(spec, f) => {
crate::effect_system::record_command_effect("task", 0);
self.task_executor.submit(spec, f);
}
Cmd::SaveState => {
self.save_state();
}
Cmd::RestoreState => {
self.load_state();
}
Cmd::SetMouseCapture(enabled) => {
self.backend_features.mouse_capture = enabled;
self.events.set_features(self.backend_features)?;
}
Cmd::SetTickStrategy(strategy) => {
let new_name = strategy.name().to_owned();
if let Some(mut previous) = self.tick_strategy.replace(strategy) {
let old_name = previous.name().to_owned();
previous.shutdown();
info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
} else {
info!(new = %new_name, "tick strategy changed at runtime");
}
self.last_active_screen_for_strategy = None;
}
}
Ok(())
}
fn check_screen_transition(&mut self) {
if self.tick_strategy.is_none() {
return;
}
let current_active = match self.model.as_screen_tick_dispatch() {
Some(dispatch) => dispatch.active_screen_id(),
None => return,
};
let previous = match self.last_active_screen_for_strategy.take() {
Some(prev) => prev,
None => {
self.last_active_screen_for_strategy = Some(current_active);
return;
}
};
if previous == current_active {
self.last_active_screen_for_strategy = Some(current_active);
return;
}
if let Some(strategy) = self.tick_strategy.as_mut() {
strategy.on_screen_transition(&previous, ¤t_active);
}
let mut force_ticked = false;
if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
dispatch.tick_screen(¤t_active, self.tick_count);
force_ticked = true;
}
if force_ticked && self.running {
self.reconcile_subscriptions();
}
self.last_active_screen_for_strategy = Some(current_active);
self.mark_dirty();
}
fn reap_finished_tasks(&mut self) {
self.task_executor.reap_finished();
}
fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
while let Ok(msg) = self.task_receiver.try_recv() {
let cmd = {
let _span = debug_span!(
"ftui.program.update",
msg_type = "shutdown_task",
duration_us = tracing::field::Empty,
cmd_type = tracing::field::Empty
)
.entered();
let start = Instant::now();
let cmd = self.model.update(msg);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
tracing::Span::current().record("duration_us", elapsed_us);
tracing::Span::current()
.record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
cmd
};
self.mark_dirty();
self.execute_cmd(cmd)?;
}
Ok(())
}
fn render_frame(&mut self) -> io::Result<()> {
crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
self.frame_idx = self.frame_idx.wrapping_add(1);
let frame_idx = self.frame_idx;
let degradation_start = self.budget.degradation();
self.budget.next_frame();
let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
let verdict = self.guardrails.check_frame(memory_bytes, 0);
if verdict.should_drop_frame() {
return Ok(());
}
if verdict.should_degrade() {
let current = self.budget.degradation();
if verdict.recommended_level > current {
self.budget.set_degradation(verdict.recommended_level);
}
}
let mut conformal_prediction = None;
if let Some(predictor) = self.conformal_predictor.as_ref() {
let baseline_us = self
.last_frame_time_us
.unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
let diff_strategy = self
.writer
.last_diff_strategy()
.unwrap_or(DiffStrategy::Full);
let frame_height_hint = self.writer.render_height_hint().max(1);
let key = BucketKey::from_context(
self.writer.screen_mode(),
diff_strategy,
self.width,
frame_height_hint,
);
let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
let prediction = predictor.predict(key, baseline_us, budget_us);
if prediction.risk {
self.budget.degrade();
info!(
bucket = %prediction.bucket,
upper_us = prediction.upper_us,
budget_us = prediction.budget_us,
fallback_level = prediction.fallback_level,
degradation = self.budget.degradation().as_str(),
"conformal gate triggered strategy downgrade"
);
debug!(
monotonic.counter.conformal_gate_triggers_total = 1_u64,
bucket = %prediction.bucket,
"conformal gate trigger"
);
}
debug!(
bucket = %prediction.bucket,
upper_us = prediction.upper_us,
budget_us = prediction.budget_us,
fallback = prediction.fallback_level,
risk = prediction.risk,
"conformal risk gate"
);
debug!(
monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
bucket = %prediction.bucket,
"conformal prediction interval width"
);
conformal_prediction = Some(prediction);
}
if self.budget.exhausted() {
self.budget.record_frame_time(Duration::ZERO);
self.emit_budget_evidence(
frame_idx,
degradation_start,
0.0,
conformal_prediction.as_ref(),
);
crate::debug_trace!(
"frame skipped: budget exhausted (degradation={})",
self.budget.degradation().as_str()
);
debug!(
degradation = self.budget.degradation().as_str(),
"frame skipped: budget exhausted before render"
);
return Ok(());
}
let auto_bounds = self.writer.inline_auto_bounds();
let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
let mut should_measure = needs_measure;
if auto_bounds.is_some()
&& let Some(state) = self.inline_auto_remeasure.as_mut()
{
let decision = state.sampler.decide(Instant::now());
if decision.should_sample {
should_measure = true;
}
} else {
crate::voi_telemetry::clear_inline_auto_voi_snapshot();
}
let render_start = Instant::now();
if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
let measure_height = if needs_measure {
self.writer.render_height_hint().max(1)
} else {
max_height.max(1)
};
let (measure_buffer, _) = self.render_measure_buffer(measure_height);
let measured_height = measure_buffer.content_height();
let clamped = measured_height.clamp(min_height, max_height);
let previous_height = self.writer.auto_ui_height();
self.writer.set_auto_ui_height(clamped);
if let Some(state) = self.inline_auto_remeasure.as_mut() {
let threshold = state.config.change_threshold_rows;
let violated = previous_height
.map(|prev| prev.abs_diff(clamped) >= threshold)
.unwrap_or(false);
state.sampler.observe(violated);
}
}
if auto_bounds.is_some()
&& let Some(state) = self.inline_auto_remeasure.as_ref()
{
let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
}
let frame_height = self.writer.render_height_hint().max(1);
let _frame_span = info_span!(
"ftui.render.frame",
width = self.width,
height = frame_height,
duration_us = tracing::field::Empty
)
.entered();
let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
self.update_widget_refresh_plan(frame_idx);
let render_elapsed = render_start.elapsed();
let mut present_elapsed = Duration::ZERO;
let mut presented = false;
let render_budget = self.budget.phase_budgets().render;
if render_elapsed > render_budget {
debug!(
render_ms = render_elapsed.as_millis() as u32,
budget_ms = render_budget.as_millis() as u32,
"render phase exceeded budget"
);
if self.budget.should_degrade(render_budget) {
self.budget.degrade();
}
}
if !self.budget.exhausted() {
let present_start = Instant::now();
{
let _present_span = debug_span!("ftui.render.present").entered();
self.writer
.present_ui_owned(buffer, cursor, cursor_visible)?;
}
presented = true;
present_elapsed = present_start.elapsed();
let present_budget = self.budget.phase_budgets().present;
if present_elapsed > present_budget {
debug!(
present_ms = present_elapsed.as_millis() as u32,
budget_ms = present_budget.as_millis() as u32,
"present phase exceeded budget"
);
}
} else {
debug!(
degradation = self.budget.degradation().as_str(),
elapsed_ms = self.budget.elapsed().as_millis() as u32,
"frame present skipped: budget exhausted after render"
);
}
if let Some(ref frame_timing) = self.frame_timing {
let update_us = self.last_update_us.unwrap_or(0);
let render_us = render_elapsed.as_micros() as u64;
let present_us = present_elapsed.as_micros() as u64;
let diff_us = if presented {
self.writer
.take_last_present_timings()
.map(|timings| timings.diff_us)
.unwrap_or(0)
} else {
let _ = self.writer.take_last_present_timings();
0
};
let total_us = update_us
.saturating_add(render_us)
.saturating_add(present_us);
let timing = FrameTiming {
frame_idx,
update_us,
render_us,
diff_us,
present_us,
total_us,
};
frame_timing.sink.record_frame(&timing);
}
let frame_time = render_elapsed.saturating_add(present_elapsed);
self.budget.record_frame_time(frame_time);
let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
if let (Some(predictor), Some(prediction)) = (
self.conformal_predictor.as_mut(),
conformal_prediction.as_ref(),
) {
let diff_strategy = self
.writer
.last_diff_strategy()
.unwrap_or(DiffStrategy::Full);
let key = BucketKey::from_context(
self.writer.screen_mode(),
diff_strategy,
self.width,
frame_height,
);
predictor.observe(key, prediction.y_hat, frame_time_us);
}
self.last_frame_time_us = Some(frame_time_us);
self.emit_budget_evidence(
frame_idx,
degradation_start,
frame_time_us,
conformal_prediction.as_ref(),
);
if presented {
self.dirty = false;
}
Ok(())
}
fn emit_budget_evidence(
&self,
frame_idx: u64,
degradation_start: DegradationLevel,
frame_time_us: f64,
conformal_prediction: Option<&ConformalPrediction>,
) {
let Some(telemetry) = self.budget.telemetry() else {
set_budget_snapshot(None);
return;
};
let budget_us = conformal_prediction
.map(|prediction| prediction.budget_us)
.unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
let degradation_after = self.budget.degradation();
let evidence = BudgetDecisionEvidence {
frame_idx,
decision: BudgetDecisionEvidence::decision_from_levels(
degradation_start,
degradation_after,
),
controller_decision: telemetry.last_decision,
degradation_before: degradation_start,
degradation_after,
frame_time_us,
budget_us,
pid_output: telemetry.pid_output,
pid_p: telemetry.pid_p,
pid_i: telemetry.pid_i,
pid_d: telemetry.pid_d,
e_value: telemetry.e_value,
frames_observed: telemetry.frames_observed,
frames_since_change: telemetry.frames_since_change,
in_warmup: telemetry.in_warmup,
conformal,
};
let conformal_snapshot = evidence
.conformal
.as_ref()
.map(|snapshot| ConformalSnapshot {
bucket_key: snapshot.bucket_key.clone(),
sample_count: snapshot.n_b,
upper_us: snapshot.upper_us,
risk: snapshot.risk,
});
set_budget_snapshot(Some(BudgetDecisionSnapshot {
frame_idx: evidence.frame_idx,
decision: evidence.decision,
controller_decision: evidence.controller_decision,
degradation_before: evidence.degradation_before,
degradation_after: evidence.degradation_after,
frame_time_us: evidence.frame_time_us,
budget_us: evidence.budget_us,
pid_output: evidence.pid_output,
e_value: evidence.e_value,
frames_observed: evidence.frames_observed,
frames_since_change: evidence.frames_since_change,
in_warmup: evidence.in_warmup,
conformal: conformal_snapshot,
}));
if let Some(ref sink) = self.evidence_sink {
let _ = sink.write_jsonl(&evidence.to_jsonl());
}
}
fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
if !self.widget_refresh_config.enabled {
self.widget_refresh_plan.clear();
return;
}
let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
let degradation = self.budget.degradation();
self.widget_refresh_plan.recompute(
frame_idx,
budget_us,
degradation,
&self.widget_signals,
&self.widget_refresh_config,
);
if let Some(ref sink) = self.evidence_sink {
let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
}
}
fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
self.frame_arena.reset();
let buffer = self.writer.take_render_buffer(self.width, frame_height);
let (pool, links) = self.writer.pool_and_links_mut();
let mut frame = Frame::from_buffer(buffer, pool);
frame.set_degradation(self.budget.degradation());
frame.set_links(links);
frame.set_widget_budget(self.widget_refresh_plan.as_budget());
frame.set_arena(&self.frame_arena);
let view_start = Instant::now();
let _view_span = debug_span!(
"ftui.program.view",
duration_us = tracing::field::Empty,
widget_count = tracing::field::Empty
)
.entered();
self.model.view(&mut frame);
self.widget_signals = frame.take_widget_signals();
tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
(frame.buffer, frame.cursor_position, frame.cursor_visible)
}
fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
let Some(ref sink) = self.evidence_sink else {
return;
};
let config = self.fairness_guard.config();
if !self.fairness_config_logged {
let config_entry = FairnessConfigEvidence {
enabled: config.enabled,
input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
dominance_threshold: config.dominance_threshold,
fairness_threshold: config.fairness_threshold,
};
let _ = sink.write_jsonl(&config_entry.to_jsonl());
self.fairness_config_logged = true;
}
let evidence = FairnessDecisionEvidence {
frame_idx: self.frame_idx,
decision: if decision.should_process {
"allow"
} else {
"yield"
},
reason: decision.reason.as_str(),
pending_input_latency_ms: decision
.pending_input_latency
.map(|latency| latency.as_millis() as u64),
jain_index: decision.jain_index,
resize_dominance_count: dominance_count,
dominance_threshold: config.dominance_threshold,
fairness_threshold: config.fairness_threshold,
input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
};
let _ = sink.write_jsonl(&evidence.to_jsonl());
}
fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
self.frame_arena.reset();
let pool = self.writer.pool_mut();
let mut frame = Frame::new(self.width, frame_height, pool);
frame.set_degradation(self.budget.degradation());
frame.set_arena(&self.frame_arena);
let view_start = Instant::now();
let _view_span = debug_span!(
"ftui.program.view",
duration_us = tracing::field::Empty,
widget_count = tracing::field::Empty
)
.entered();
self.model.view(&mut frame);
tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
(frame.buffer, frame.cursor_position)
}
fn effective_timeout(&self) -> Duration {
if let Some(tick_rate) = self.tick_rate {
let elapsed = self.last_tick.elapsed();
let mut timeout = tick_rate.saturating_sub(elapsed);
if self.resize_behavior.uses_coalescer()
&& let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
{
timeout = timeout.min(resize_timeout);
}
timeout
} else {
let mut timeout = self.poll_timeout;
if self.resize_behavior.uses_coalescer()
&& let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
{
timeout = timeout.min(resize_timeout);
}
timeout
}
}
fn should_tick(&mut self) -> bool {
if let Some(tick_rate) = self.tick_rate
&& self.last_tick.elapsed() >= tick_rate
{
self.last_tick = Instant::now();
return true;
}
false
}
fn process_resize_coalescer(&mut self) -> io::Result<()> {
if !self.resize_behavior.uses_coalescer() {
return Ok(());
}
let dominance_count = self.fairness_guard.resize_dominance_count();
let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
self.emit_fairness_evidence(&fairness_decision, dominance_count);
if !fairness_decision.should_process {
debug!(
reason = ?fairness_decision.reason,
pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
"Resize yielding to input for fairness"
);
return Ok(());
}
let action = self.resize_coalescer.tick();
let resize_snapshot =
self.resize_coalescer
.logs()
.last()
.map(|entry| ResizeDecisionSnapshot {
event_idx: entry.event_idx,
action: entry.action,
dt_ms: entry.dt_ms,
event_rate: entry.event_rate,
regime: entry.regime,
pending_size: entry.pending_size,
applied_size: entry.applied_size,
time_since_render_ms: entry.time_since_render_ms,
bocpd: self
.resize_coalescer
.bocpd()
.and_then(|detector| detector.last_evidence().cloned()),
});
set_resize_snapshot(resize_snapshot);
match action {
CoalesceAction::ApplyResize {
width,
height,
coalesce_time,
forced_by_deadline,
} => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
_ => Ok(()),
}
}
fn apply_resize(
&mut self,
width: u16,
height: u16,
coalesce_time: Duration,
forced_by_deadline: bool,
) -> io::Result<()> {
let width = width.max(1);
let height = height.max(1);
self.width = width;
self.height = height;
self.writer.set_size(width, height);
info!(
width = width,
height = height,
coalesce_ms = coalesce_time.as_millis() as u64,
forced = forced_by_deadline,
"Resize applied"
);
let msg = M::Message::from(Event::Resize { width, height });
let start = Instant::now();
let cmd = self.model.update(msg);
let elapsed_us = start.elapsed().as_micros() as u64;
self.last_update_us = Some(elapsed_us);
self.mark_dirty();
self.execute_cmd(cmd)?;
if self.running && self.dirty {
self.reconcile_subscriptions();
}
Ok(())
}
pub fn model(&self) -> &M {
&self.model
}
pub fn model_mut(&mut self) -> &mut M {
&mut self.model
}
pub fn is_running(&self) -> bool {
self.running
}
#[must_use]
pub const fn tick_rate(&self) -> Option<Duration> {
self.tick_rate
}
#[must_use]
pub const fn executed_cmd_count(&self) -> usize {
self.executed_cmd_count
}
pub fn quit(&mut self) {
self.running = false;
}
pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
self.state_registry.as_ref()
}
pub fn has_persistence(&self) -> bool {
self.state_registry.is_some()
}
#[must_use]
pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
self.tick_strategy
.as_ref()
.map(|s| s.debug_stats())
.unwrap_or_default()
}
pub fn trigger_save(&mut self) -> StorageResult<bool> {
if let Some(registry) = &self.state_registry {
registry.flush()
} else {
Ok(false)
}
}
pub fn trigger_load(&mut self) -> StorageResult<usize> {
if let Some(registry) = &self.state_registry {
registry.load()
} else {
Ok(0)
}
}
fn mark_dirty(&mut self) {
self.dirty = true;
}
fn check_locale_change(&mut self) {
let version = self.locale_context.version();
if version != self.locale_version {
self.locale_version = version;
self.mark_dirty();
}
}
pub fn request_redraw(&mut self) {
self.mark_dirty();
}
pub fn request_ui_height_remeasure(&mut self) {
if self.writer.inline_auto_bounds().is_some() {
self.writer.clear_auto_ui_height();
if let Some(state) = self.inline_auto_remeasure.as_mut() {
state.reset();
}
crate::voi_telemetry::clear_inline_auto_voi_snapshot();
self.mark_dirty();
}
}
pub fn start_recording(&mut self, name: impl Into<String>) {
let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
recorder.start();
self.event_recorder = Some(recorder);
}
pub fn stop_recording(&mut self) -> Option<InputMacro> {
self.event_recorder.take().map(EventRecorder::finish)
}
pub fn is_recording(&self) -> bool {
self.event_recorder
.as_ref()
.is_some_and(EventRecorder::is_recording)
}
}
pub struct App;
impl App {
#[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::default(),
}
}
pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::fullscreen(),
}
}
pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::inline(height),
}
}
pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::inline_auto(min_height, max_height),
}
}
pub fn string_model<S: crate::string_model::StringModel>(
model: S,
) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
AppBuilder {
model: crate::string_model::StringModelAdapter::new(model),
config: ProgramConfig::fullscreen(),
}
}
}
#[must_use]
pub struct AppBuilder<M: Model> {
model: M,
config: ProgramConfig,
}
impl<M: Model> AppBuilder<M> {
pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
self.config.screen_mode = mode;
self
}
pub fn anchor(mut self, anchor: UiAnchor) -> Self {
self.config.ui_anchor = anchor;
self
}
pub fn with_mouse(mut self) -> Self {
self.config.mouse_capture_policy = MouseCapturePolicy::On;
self
}
pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
self.config.mouse_capture_policy = policy;
self
}
pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
self.config.mouse_capture_policy = if enabled {
MouseCapturePolicy::On
} else {
MouseCapturePolicy::Off
};
self
}
pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
self.config.budget = budget;
self
}
pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
self.config.evidence_sink = config;
self
}
pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
self.config.render_trace = config;
self
}
pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
self.config.widget_refresh = config;
self
}
pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
self.config.effect_queue = config;
self
}
pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
self.config.inline_auto_remeasure = Some(config);
self
}
pub fn without_inline_auto_remeasure(mut self) -> Self {
self.config.inline_auto_remeasure = None;
self
}
pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
self.config.locale_context = locale_context;
self
}
pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
self.config.locale_context = LocaleContext::new(locale);
self
}
pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
self.config.resize_coalescer = config;
self
}
pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
self.config.resize_behavior = behavior;
self
}
pub fn legacy_resize(mut self, enabled: bool) -> Self {
if enabled {
self.config.resize_behavior = ResizeBehavior::Immediate;
}
self
}
pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
self.config.tick_strategy = Some(strategy);
self
}
#[cfg(feature = "crossterm-compat")]
pub fn run(self) -> io::Result<()>
where
M::Message: Send + 'static,
{
let mut program = Program::with_config(self.model, self.config)?;
let result = program.run();
if let Err(ref err) = result
&& let Some(signal) = signal_termination_from_error(err)
{
drop(program);
std::process::exit(128 + signal);
}
result
}
#[cfg(feature = "native-backend")]
pub fn run_native(self) -> io::Result<()>
where
M::Message: Send + 'static,
{
let mut program = Program::with_native_backend(self.model, self.config)?;
let result = program.run();
if let Err(ref err) = result
&& let Some(signal) = signal_termination_from_error(err)
{
drop(program);
std::process::exit(128 + signal);
}
result
}
#[cfg(not(feature = "crossterm-compat"))]
pub fn run(self) -> io::Result<()>
where
M::Message: Send + 'static,
{
let _ = (self.model, self.config);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"enable `crossterm-compat` feature to use AppBuilder::run()",
))
}
#[cfg(not(feature = "native-backend"))]
pub fn run_native(self) -> io::Result<()>
where
M::Message: Send + 'static,
{
let _ = (self.model, self.config);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"enable `native-backend` feature to use AppBuilder::run_native()",
))
}
}
#[derive(Debug, Clone)]
pub struct BatchController {
ema_inter_arrival_s: f64,
ema_service_s: f64,
alpha: f64,
tau_min_s: f64,
tau_max_s: f64,
headroom: f64,
last_arrival: Option<Instant>,
observations: u64,
}
impl BatchController {
pub fn new() -> Self {
Self {
ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
last_arrival: None,
observations: 0,
}
}
pub fn observe_arrival(&mut self, now: Instant) {
if let Some(last) = self.last_arrival {
let dt = now.saturating_duration_since(last).as_secs_f64();
if dt > 0.0 && dt < 10.0 {
self.ema_inter_arrival_s =
self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
self.observations += 1;
}
}
self.last_arrival = Some(now);
}
pub fn observe_service(&mut self, duration: Duration) {
let dt = duration.as_secs_f64();
if (0.0..10.0).contains(&dt) {
self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
}
}
#[inline]
pub fn lambda_est(&self) -> f64 {
if self.ema_inter_arrival_s > 0.0 {
1.0 / self.ema_inter_arrival_s
} else {
0.0
}
}
#[inline]
pub fn service_est_s(&self) -> f64 {
self.ema_service_s
}
#[inline]
pub fn rho_est(&self) -> f64 {
self.lambda_est() * self.ema_service_s
}
pub fn tau_s(&self) -> f64 {
let base = self.ema_service_s * self.headroom;
base.clamp(self.tau_min_s, self.tau_max_s)
}
pub fn tau(&self) -> Duration {
Duration::from_secs_f64(self.tau_s())
}
#[inline]
pub fn is_stable(&self) -> bool {
self.rho_est() < 1.0
}
#[inline]
pub fn observations(&self) -> u64 {
self.observations
}
}
impl Default for BatchController {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_core::terminal_capabilities::TerminalCapabilities;
use ftui_layout::PaneDragResizeEffect;
use ftui_render::buffer::Buffer;
use ftui_render::cell::Cell;
use ftui_render::diff_strategy::DiffStrategy;
use ftui_render::frame::CostEstimateSource;
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct TestModel {
value: i32,
}
#[derive(Debug)]
enum TestMsg {
Increment,
Decrement,
Quit,
}
impl From<Event> for TestMsg {
fn from(_event: Event) -> Self {
TestMsg::Increment
}
}
impl Model for TestModel {
type Message = TestMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TestMsg::Increment => {
self.value += 1;
Cmd::none()
}
TestMsg::Decrement => {
self.value -= 1;
Cmd::none()
}
TestMsg::Quit => Cmd::quit(),
}
}
fn view(&self, _frame: &mut Frame) {
}
}
#[test]
fn cmd_none() {
let cmd: Cmd<TestMsg> = Cmd::none();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_quit() {
let cmd: Cmd<TestMsg> = Cmd::quit();
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn cmd_msg() {
let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
}
#[test]
fn cmd_batch_empty() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_batch_single() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn cmd_batch_multiple() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
assert!(matches!(cmd, Cmd::Batch(_)));
}
#[test]
fn cmd_sequence_empty() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_tick() {
let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
assert!(matches!(cmd, Cmd::Tick(_)));
}
#[test]
fn cmd_task() {
let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
assert!(matches!(cmd, Cmd::Task(..)));
}
#[test]
fn cmd_debug_format() {
let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
let debug = format!("{cmd:?}");
assert_eq!(
debug,
"Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
);
}
#[test]
fn model_subscriptions_default_empty() {
let model = TestModel { value: 0 };
let subs = model.subscriptions();
assert!(subs.is_empty());
}
#[test]
fn program_config_default() {
let config = ProgramConfig::default();
assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
assert!(!config.resolved_mouse_capture());
assert!(config.bracketed_paste);
assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
assert!(config.inline_auto_remeasure.is_none());
assert!(config.conformal_config.is_none());
assert!(config.diff_config.bayesian_enabled);
assert!(config.diff_config.dirty_rows_enabled);
assert!(!config.resize_coalescer.enable_bocpd);
assert!(!config.effect_queue.enabled);
assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
assert_eq!(
config.immediate_drain.max_burst_duration,
Duration::from_millis(2)
);
assert_eq!(
config.immediate_drain.backoff_timeout,
Duration::from_millis(1)
);
assert_eq!(
config.resize_coalescer.steady_delay_ms,
CoalescerConfig::default().steady_delay_ms
);
}
#[test]
fn program_config_with_immediate_drain() {
let custom = ImmediateDrainConfig {
max_zero_timeout_polls_per_burst: 7,
max_burst_duration: Duration::from_millis(9),
backoff_timeout: Duration::from_millis(3),
};
let config = ProgramConfig::default().with_immediate_drain(custom.clone());
assert_eq!(
config.immediate_drain.max_zero_timeout_polls_per_burst,
custom.max_zero_timeout_polls_per_burst
);
assert_eq!(
config.immediate_drain.max_burst_duration,
custom.max_burst_duration
);
assert_eq!(
config.immediate_drain.backoff_timeout,
custom.backoff_timeout
);
}
#[test]
fn program_config_fullscreen() {
let config = ProgramConfig::fullscreen();
assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
}
#[test]
fn program_config_inline() {
let config = ProgramConfig::inline(10);
assert!(matches!(
config.screen_mode,
ScreenMode::Inline { ui_height: 10 }
));
}
#[test]
fn program_config_inline_auto() {
let config = ProgramConfig::inline_auto(3, 9);
assert!(matches!(
config.screen_mode,
ScreenMode::InlineAuto {
min_height: 3,
max_height: 9
}
));
assert!(config.inline_auto_remeasure.is_some());
}
#[test]
fn program_config_with_mouse() {
let config = ProgramConfig::default().with_mouse();
assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
assert!(config.resolved_mouse_capture());
}
#[cfg(feature = "native-backend")]
#[test]
fn sanitize_backend_features_disables_unsupported_features() {
let requested = BackendFeatures {
mouse_capture: true,
bracketed_paste: true,
focus_events: true,
kitty_keyboard: true,
};
let sanitized =
sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
assert_eq!(sanitized, BackendFeatures::default());
}
#[cfg(feature = "native-backend")]
#[test]
fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
let requested = BackendFeatures {
mouse_capture: true,
bracketed_paste: true,
focus_events: true,
kitty_keyboard: true,
};
let caps = TerminalCapabilities::builder()
.mouse_sgr(true)
.bracketed_paste(true)
.focus_events(true)
.kitty_keyboard(true)
.in_wezterm_mux(true)
.build();
let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
assert!(sanitized.mouse_capture);
assert!(sanitized.bracketed_paste);
assert!(!sanitized.focus_events);
assert!(!sanitized.kitty_keyboard);
}
#[cfg(feature = "native-backend")]
#[test]
fn sanitize_backend_features_is_conservative_in_tmux() {
let requested = BackendFeatures {
mouse_capture: true,
bracketed_paste: true,
focus_events: true,
kitty_keyboard: true,
};
let caps = TerminalCapabilities::builder()
.mouse_sgr(true)
.bracketed_paste(true)
.focus_events(true)
.kitty_keyboard(true)
.in_tmux(true)
.build();
let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
assert!(sanitized.mouse_capture);
assert!(sanitized.bracketed_paste);
assert!(!sanitized.focus_events);
assert!(!sanitized.kitty_keyboard);
}
#[test]
fn program_config_mouse_policy_auto_altscreen() {
let config = ProgramConfig::fullscreen();
assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
assert!(config.resolved_mouse_capture());
}
#[test]
fn program_config_mouse_policy_force_off() {
let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
assert!(!config.resolved_mouse_capture());
}
#[test]
fn program_config_mouse_policy_force_on_inline() {
let config = ProgramConfig::inline(6).with_mouse_enabled(true);
assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
assert!(config.resolved_mouse_capture());
}
fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
PaneResizeTarget {
split_id: ftui_layout::PaneId::MIN,
axis,
}
}
fn pane_id(raw: u64) -> ftui_layout::PaneId {
ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
}
fn nested_pane_tree() -> ftui_layout::PaneTree {
let root = pane_id(1);
let left = pane_id(2);
let right_split = pane_id(3);
let right_top = pane_id(4);
let right_bottom = pane_id(5);
let snapshot = ftui_layout::PaneTreeSnapshot {
schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
root,
next_id: pane_id(6),
nodes: vec![
ftui_layout::PaneNodeRecord::split(
root,
None,
ftui_layout::PaneSplit {
axis: SplitAxis::Horizontal,
ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
first: left,
second: right_split,
},
),
ftui_layout::PaneNodeRecord::leaf(
left,
Some(root),
ftui_layout::PaneLeaf::new("left"),
),
ftui_layout::PaneNodeRecord::split(
right_split,
Some(root),
ftui_layout::PaneSplit {
axis: SplitAxis::Vertical,
ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
first: right_top,
second: right_bottom,
},
),
ftui_layout::PaneNodeRecord::leaf(
right_top,
Some(right_split),
ftui_layout::PaneLeaf::new("right_top"),
),
ftui_layout::PaneNodeRecord::leaf(
right_bottom,
Some(right_split),
ftui_layout::PaneLeaf::new("right_bottom"),
),
],
extensions: std::collections::BTreeMap::new(),
};
ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
}
#[test]
fn pane_terminal_splitter_resolution_is_deterministic() {
let tree = nested_pane_tree();
let layout = tree
.solve_layout(Rect::new(0, 0, 50, 20))
.expect("layout should solve");
let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
assert_eq!(handles.len(), 2);
let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
.expect("overlap cell should resolve");
assert_eq!(overlap.split_id, pane_id(1));
assert_eq!(overlap.axis, SplitAxis::Horizontal);
let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
.expect("right split should resolve");
assert_eq!(right_only.split_id, pane_id(3));
assert_eq!(right_only.axis, SplitAxis::Vertical);
}
#[test]
fn pane_terminal_splitter_hits_register_and_decode_target() {
let tree = nested_pane_tree();
let layout = tree
.solve_layout(Rect::new(0, 0, 50, 20))
.expect("layout should solve");
let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
assert_eq!(registered, handles.len());
let root_hit = frame
.hit_test(25, 2)
.expect("root splitter should be hittable");
assert_eq!(root_hit.1, HitRegion::Handle);
let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
assert_eq!(root_target.split_id, pane_id(1));
assert_eq!(root_target.axis, SplitAxis::Horizontal);
let right_hit = frame
.hit_test(40, 10)
.expect("right splitter should be hittable");
assert_eq!(right_hit.1, HitRegion::Handle);
let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
assert_eq!(right_target.split_id, pane_id(3));
assert_eq!(right_target.axis, SplitAxis::Vertical);
}
#[test]
fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
10,
4,
));
let down_dispatch = adapter.translate(&down, Some(target));
let down_event = down_dispatch
.primary_event
.as_ref()
.expect("pointer down semantic event");
assert_eq!(down_event.sequence, 1);
assert!(matches!(
down_event.kind,
PaneSemanticInputEventKind::PointerDown {
target: actual_target,
pointer_id: 1,
button: PanePointerButton::Primary,
position
} if actual_target == target && position == PanePointerPosition::new(10, 4)
));
assert!(down_event.validate().is_ok());
let drag = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
14,
4,
));
let drag_dispatch = adapter.translate(&drag, None);
let drag_event = drag_dispatch
.primary_event
.as_ref()
.expect("pointer move semantic event");
assert_eq!(drag_event.sequence, 2);
assert!(matches!(
drag_event.kind,
PaneSemanticInputEventKind::PointerMove {
target: actual_target,
pointer_id: 1,
position,
delta_x: 4,
delta_y: 0
} if actual_target == target && position == PanePointerPosition::new(14, 4)
));
let drag_motion = drag_dispatch
.motion
.expect("drag should emit motion metadata");
assert_eq!(drag_motion.delta_x, 4);
assert_eq!(drag_motion.delta_y, 0);
assert_eq!(drag_motion.direction_changes, 0);
assert!(drag_motion.speed > 0.0);
assert!(drag_dispatch.pressure_snap_profile().is_some());
let up = Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
14,
4,
));
let up_dispatch = adapter.translate(&up, None);
let up_event = up_dispatch
.primary_event
.as_ref()
.expect("pointer up semantic event");
assert_eq!(up_event.sequence, 3);
assert!(matches!(
up_event.kind,
PaneSemanticInputEventKind::PointerUp {
target: actual_target,
pointer_id: 1,
button: PanePointerButton::Primary,
position
} if actual_target == target && position == PanePointerPosition::new(14, 4)
));
let up_motion = up_dispatch
.motion
.expect("up should emit final motion metadata");
assert_eq!(up_motion.delta_x, 4);
assert_eq!(up_motion.delta_y, 0);
assert_eq!(up_motion.direction_changes, 0);
let inertial_throw = up_dispatch
.inertial_throw
.expect("up should emit inertial throw metadata");
assert_eq!(
up_dispatch.projected_position,
Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
);
assert_eq!(adapter.active_pointer_id(), None);
assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
}
#[test]
fn pane_terminal_adapter_focus_loss_emits_cancel() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Vertical);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
3,
9,
));
let _ = adapter.translate(&down, Some(target));
assert_eq!(adapter.active_pointer_id(), Some(1));
let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
let cancel_event = cancel_dispatch
.primary_event
.as_ref()
.expect("focus-loss cancel event");
assert!(matches!(
cancel_event.kind,
PaneSemanticInputEventKind::Cancel {
target: Some(actual_target),
reason: PaneCancelReason::FocusLost
} if actual_target == target
));
assert_eq!(adapter.active_pointer_id(), None);
assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
}
#[test]
fn pane_terminal_adapter_recovers_missing_mouse_up() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let first_target = pane_target(SplitAxis::Horizontal);
let second_target = pane_target(SplitAxis::Vertical);
let first_down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let _ = adapter.translate(&first_down, Some(first_target));
let second_down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
8,
11,
));
let dispatch = adapter.translate(&second_down, Some(second_target));
let recovery = dispatch
.recovery_event
.as_ref()
.expect("recovery cancel expected");
assert!(matches!(
recovery.kind,
PaneSemanticInputEventKind::Cancel {
target: Some(actual_target),
reason: PaneCancelReason::PointerCancel
} if actual_target == first_target
));
let primary = dispatch
.primary_event
.as_ref()
.expect("second pointer down expected");
assert!(matches!(
primary.kind,
PaneSemanticInputEventKind::PointerDown {
target: actual_target,
pointer_id: 1,
button: PanePointerButton::Primary,
position
} if actual_target == second_target && position == PanePointerPosition::new(8, 11)
));
assert_eq!(recovery.sequence, 2);
assert_eq!(primary.sequence, 3);
assert!(matches!(
dispatch.log.outcome,
PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
));
assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
}
#[test]
fn pane_terminal_adapter_modifier_parity() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
.with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
let event = dispatch.primary_event.expect("semantic event");
assert!(event.modifiers.shift);
assert!(event.modifiers.alt);
assert!(event.modifiers.ctrl);
assert!(event.modifiers.meta);
}
#[test]
fn pane_terminal_adapter_keyboard_resize_mapping() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let key = KeyEvent::new(KeyCode::Right);
let dispatch = adapter.translate(&Event::Key(key), Some(target));
let event = dispatch.primary_event.expect("keyboard resize event");
assert!(matches!(
event.kind,
PaneSemanticInputEventKind::KeyboardResize {
target: actual_target,
direction: PaneResizeDirection::Increase,
units: 1
} if actual_target == target
));
let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
let shifted_event = shifted_dispatch
.primary_event
.expect("shifted resize event");
assert!(matches!(
shifted_event.kind,
PaneSemanticInputEventKind::KeyboardResize {
direction: PaneResizeDirection::Increase,
units: 5,
..
}
));
assert!(shifted_event.modifiers.shift);
}
#[test]
fn pane_terminal_adapter_keyboard_resize_requires_focus() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let _ = adapter.translate(&Event::Focus(false), None);
assert!(!adapter.window_focused());
let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
assert!(unfocused.primary_event.is_none());
assert!(matches!(
unfocused.log.outcome,
PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
));
let _ = adapter.translate(&Event::Focus(true), None);
assert!(adapter.window_focused());
let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
assert!(focused.primary_event.is_some());
}
#[test]
fn pane_terminal_adapter_drag_updates_are_coalesced() {
let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
drag_update_coalesce_distance: 2,
..PaneTerminalAdapterConfig::default()
})
.expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
10,
4,
));
let _ = adapter.translate(&down, Some(target));
let drag_start = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
14,
4,
));
let started = adapter.translate(&drag_start, None);
assert!(started.primary_event.is_some());
assert!(matches!(
adapter.machine_state(),
PaneDragResizeState::Dragging { .. }
));
let coalesced = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
15,
4,
));
let coalesced_dispatch = adapter.translate(&coalesced, None);
assert!(coalesced_dispatch.primary_event.is_none());
assert!(matches!(
coalesced_dispatch.log.outcome,
PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
));
let forwarded = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
16,
4,
));
let forwarded_dispatch = adapter.translate(&forwarded, None);
let forwarded_event = forwarded_dispatch
.primary_event
.as_ref()
.expect("coalesced movement should flush once threshold reached");
assert!(matches!(
forwarded_event.kind,
PaneSemanticInputEventKind::PointerMove {
delta_x: 2,
delta_y: 0,
..
}
));
}
#[test]
fn pane_terminal_adapter_motion_tracks_direction_changes() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
10,
4,
));
let _ = adapter.translate(&down, Some(target));
let drag_forward = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
14,
4,
));
let forward_dispatch = adapter.translate(&drag_forward, None);
let forward_motion = forward_dispatch
.motion
.expect("forward drag should emit motion metadata");
assert_eq!(forward_motion.direction_changes, 0);
let drag_reverse = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
12,
4,
));
let reverse_dispatch = adapter.translate(&drag_reverse, None);
let reverse_motion = reverse_dispatch
.motion
.expect("reverse drag should emit motion metadata");
assert_eq!(reverse_motion.direction_changes, 1);
let up = Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
12,
4,
));
let up_dispatch = adapter.translate(&up, None);
let up_motion = up_dispatch
.motion
.expect("release should include cumulative motion metadata");
assert_eq!(up_motion.direction_changes, 1);
}
#[test]
fn pane_terminal_adapter_translate_with_handles_resolves_target() {
let tree = nested_pane_tree();
let layout = tree
.solve_layout(Rect::new(0, 0, 50, 20))
.expect("layout should solve");
let handles =
pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
25,
10,
));
let dispatch = adapter.translate_with_handles(&down, &handles);
let event = dispatch
.primary_event
.as_ref()
.expect("pointer down should be routed from handles");
assert!(matches!(
event.kind,
PaneSemanticInputEventKind::PointerDown {
target:
PaneResizeTarget {
split_id,
axis: SplitAxis::Horizontal
},
..
} if split_id == pane_id(1)
));
}
#[test]
fn model_update() {
let mut model = TestModel { value: 0 };
model.update(TestMsg::Increment);
assert_eq!(model.value, 1);
model.update(TestMsg::Decrement);
assert_eq!(model.value, 0);
assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
}
#[test]
fn model_init_default() {
let mut model = TestModel { value: 0 };
let cmd = model.init();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_sequence_executes_in_order() {
use crate::simulator::ProgramSimulator;
struct SeqModel {
trace: Vec<i32>,
}
#[derive(Debug)]
enum SeqMsg {
Append(i32),
TriggerSequence,
}
impl From<Event> for SeqMsg {
fn from(_: Event) -> Self {
SeqMsg::Append(0)
}
}
impl Model for SeqModel {
type Message = SeqMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
SeqMsg::Append(n) => {
self.trace.push(n);
Cmd::none()
}
SeqMsg::TriggerSequence => Cmd::sequence(vec![
Cmd::msg(SeqMsg::Append(1)),
Cmd::msg(SeqMsg::Append(2)),
Cmd::msg(SeqMsg::Append(3)),
]),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
sim.init();
sim.send(SeqMsg::TriggerSequence);
assert_eq!(sim.model().trace, vec![1, 2, 3]);
}
#[test]
fn cmd_batch_executes_all_regardless_of_order() {
use crate::simulator::ProgramSimulator;
struct BatchModel {
values: Vec<i32>,
}
#[derive(Debug)]
enum BatchMsg {
Add(i32),
TriggerBatch,
}
impl From<Event> for BatchMsg {
fn from(_: Event) -> Self {
BatchMsg::Add(0)
}
}
impl Model for BatchModel {
type Message = BatchMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
BatchMsg::Add(n) => {
self.values.push(n);
Cmd::none()
}
BatchMsg::TriggerBatch => Cmd::batch(vec![
Cmd::msg(BatchMsg::Add(10)),
Cmd::msg(BatchMsg::Add(20)),
Cmd::msg(BatchMsg::Add(30)),
]),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
sim.init();
sim.send(BatchMsg::TriggerBatch);
assert_eq!(sim.model().values.len(), 3);
assert!(sim.model().values.contains(&10));
assert!(sim.model().values.contains(&20));
assert!(sim.model().values.contains(&30));
}
#[test]
fn cmd_sequence_stops_on_quit() {
use crate::simulator::ProgramSimulator;
struct SeqQuitModel {
trace: Vec<i32>,
}
#[derive(Debug)]
enum SeqQuitMsg {
Append(i32),
TriggerSequenceWithQuit,
}
impl From<Event> for SeqQuitMsg {
fn from(_: Event) -> Self {
SeqQuitMsg::Append(0)
}
}
impl Model for SeqQuitModel {
type Message = SeqQuitMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
SeqQuitMsg::Append(n) => {
self.trace.push(n);
Cmd::none()
}
SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
Cmd::msg(SeqQuitMsg::Append(1)),
Cmd::quit(),
Cmd::msg(SeqQuitMsg::Append(2)), ]),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
sim.init();
sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
assert_eq!(sim.model().trace, vec![1]);
assert!(!sim.is_running());
}
#[test]
fn identical_input_produces_identical_state() {
use crate::simulator::ProgramSimulator;
fn run_scenario() -> Vec<i32> {
struct DetModel {
values: Vec<i32>,
}
#[derive(Debug, Clone)]
enum DetMsg {
Add(i32),
Double,
}
impl From<Event> for DetMsg {
fn from(_: Event) -> Self {
DetMsg::Add(1)
}
}
impl Model for DetModel {
type Message = DetMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
DetMsg::Add(n) => {
self.values.push(n);
Cmd::none()
}
DetMsg::Double => {
if let Some(&last) = self.values.last() {
self.values.push(last * 2);
}
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
sim.init();
sim.send(DetMsg::Add(5));
sim.send(DetMsg::Double);
sim.send(DetMsg::Add(3));
sim.send(DetMsg::Double);
sim.model().values.clone()
}
let run1 = run_scenario();
let run2 = run_scenario();
let run3 = run_scenario();
assert_eq!(run1, run2);
assert_eq!(run2, run3);
assert_eq!(run1, vec![5, 10, 3, 6]);
}
#[test]
fn identical_state_produces_identical_render() {
use crate::simulator::ProgramSimulator;
struct RenderModel {
counter: i32,
}
#[derive(Debug)]
enum RenderMsg {
Set(i32),
}
impl From<Event> for RenderMsg {
fn from(_: Event) -> Self {
RenderMsg::Set(0)
}
}
impl Model for RenderModel {
type Message = RenderMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
RenderMsg::Set(n) => {
self.counter = n;
Cmd::none()
}
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("Value: {}", self.counter);
for (i, c) in text.chars().enumerate() {
if (i as u16) < frame.width() {
use ftui_render::cell::Cell;
frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
}
}
}
}
let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
let buf1 = sim1.capture_frame(80, 24);
let buf2 = sim2.capture_frame(80, 24);
for y in 0..24 {
for x in 0..80 {
let cell1 = buf1.get(x, y).unwrap();
let cell2 = buf2.get(x, y).unwrap();
assert_eq!(
cell1.content.as_char(),
cell2.content.as_char(),
"Mismatch at ({}, {})",
x,
y
);
}
}
}
#[test]
fn cmd_log_creates_log_command() {
let cmd: Cmd<TestMsg> = Cmd::log("test message");
assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
}
#[test]
fn cmd_log_from_string() {
let msg = String::from("dynamic message");
let cmd: Cmd<TestMsg> = Cmd::log(msg);
assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
}
#[test]
fn program_simulator_logs_jsonl_with_seed_and_run_id() {
use crate::simulator::ProgramSimulator;
struct LogModel {
run_id: &'static str,
seed: u64,
}
#[derive(Debug)]
enum LogMsg {
Emit,
}
impl From<Event> for LogMsg {
fn from(_: Event) -> Self {
LogMsg::Emit
}
}
impl Model for LogModel {
type Message = LogMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
let line = format!(
r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
self.run_id, self.seed
);
Cmd::log(line)
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(LogModel {
run_id: "test-run-001",
seed: 4242,
});
sim.init();
sim.send(LogMsg::Emit);
let logs = sim.logs();
assert_eq!(logs.len(), 1);
assert!(logs[0].contains(r#""run_id":"test-run-001""#));
assert!(logs[0].contains(r#""seed":4242"#));
}
#[test]
fn cmd_sequence_single_unwraps() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn cmd_sequence_multiple() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
assert!(matches!(cmd, Cmd::Sequence(_)));
}
#[test]
fn cmd_default_is_none() {
let cmd: Cmd<TestMsg> = Cmd::default();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_debug_all_variants() {
let none: Cmd<TestMsg> = Cmd::none();
assert_eq!(format!("{none:?}"), "None");
let quit: Cmd<TestMsg> = Cmd::quit();
assert_eq!(format!("{quit:?}"), "Quit");
let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
assert!(format!("{msg:?}").starts_with("Msg("));
let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
assert!(format!("{batch:?}").starts_with("Batch("));
let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
assert!(format!("{seq:?}").starts_with("Sequence("));
let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
assert!(format!("{tick:?}").starts_with("Tick("));
let log: Cmd<TestMsg> = Cmd::log("test");
assert!(format!("{log:?}").starts_with("Log("));
}
#[test]
fn program_config_with_budget() {
let budget = FrameBudgetConfig {
total: Duration::from_millis(50),
..Default::default()
};
let config = ProgramConfig::default().with_budget(budget);
assert_eq!(config.budget.total, Duration::from_millis(50));
}
#[test]
fn program_config_with_conformal() {
let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
alpha: 0.2,
..Default::default()
});
assert!(config.conformal_config.is_some());
assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
}
#[test]
fn program_config_forced_size_clamps_minimums() {
let config = ProgramConfig::default().with_forced_size(0, 0);
assert_eq!(config.forced_size, Some((1, 1)));
let cleared = config.without_forced_size();
assert!(cleared.forced_size.is_none());
}
#[test]
fn effect_queue_config_defaults_are_safe() {
let config = EffectQueueConfig::default();
assert!(!config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::Spawned);
assert!(config.scheduler.smith_enabled);
assert!(!config.scheduler.preemptive);
assert_eq!(config.scheduler.aging_factor, 0.0);
assert_eq!(config.scheduler.wait_starve_ms, 0.0);
}
#[test]
fn handle_effect_command_enqueues_or_executes_inline() {
let (result_tx, result_rx) = mpsc::channel::<u32>();
let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
let ran = Arc::new(AtomicUsize::new(0));
let ran_task = ran.clone();
let cmd = EffectCommand::Enqueue(
TaskSpec::default(),
Box::new(move || {
ran_task.fetch_add(1, Ordering::SeqCst);
7
}),
);
let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
assert_eq!(shutdown, EffectLoopControl::Continue);
assert_eq!(ran.load(Ordering::SeqCst), 0);
assert_eq!(tasks.len(), 1);
assert!(result_rx.try_recv().is_err());
let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
max_queue_size: 0,
..Default::default()
});
let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
let ran_full = Arc::new(AtomicUsize::new(0));
let ran_full_task = ran_full.clone();
let cmd_full = EffectCommand::Enqueue(
TaskSpec::default(),
Box::new(move || {
ran_full_task.fetch_add(1, Ordering::SeqCst);
42
}),
);
let shutdown_full = handle_effect_command(
cmd_full,
&mut full_scheduler,
&mut full_tasks,
&result_tx,
None,
0,
);
assert_eq!(shutdown_full, EffectLoopControl::Continue);
assert!(full_tasks.is_empty());
assert_eq!(ran_full.load(Ordering::SeqCst), 1);
assert_eq!(
result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
42
);
let shutdown = handle_effect_command(
EffectCommand::Shutdown,
&mut full_scheduler,
&mut full_tasks,
&result_tx,
None,
0,
);
assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
}
#[test]
fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
let evidence_path = temp_evidence_path("task_executor_backpressure");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let sink = EvidenceSink::from_config(&sink_config)
.expect("evidence sink config")
.expect("evidence sink enabled");
let (result_tx, result_rx) = mpsc::channel::<u32>();
let mut scheduler = QueueingScheduler::new(SchedulerConfig {
max_queue_size: 0,
..Default::default()
});
let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
let shutdown = handle_effect_command(
EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
&mut scheduler,
&mut tasks,
&result_tx,
Some(&sink),
0,
);
assert_eq!(shutdown, EffectLoopControl::Continue);
assert!(tasks.is_empty());
assert_eq!(
result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
7
);
let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
assert_eq!(backpressure_line["backend"], "queued");
assert_eq!(backpressure_line["action"], "inline_fallback");
assert_eq!(backpressure_line["max_queue_size"], 0);
assert_eq!(backpressure_line["total_rejected"], 1);
let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
assert_eq!(completion_line["backend"], "queued-inline-fallback");
assert!(completion_line["duration_us"].is_number());
}
#[test]
fn effect_queue_loop_executes_tasks_and_shutdowns() {
let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
let (result_tx, result_rx) = mpsc::channel::<u32>();
let config = EffectQueueConfig {
enabled: true,
backend: TaskExecutorBackend::EffectQueue,
scheduler: SchedulerConfig {
preemptive: false,
..Default::default()
},
explicit_backend: true,
..Default::default()
};
let handle = std::thread::spawn(move || {
effect_queue_loop(config, cmd_rx, result_tx, None);
});
cmd_tx
.send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
.unwrap();
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::new(2.0, 5.0).with_name("second"),
Box::new(|| 20),
))
.unwrap();
let mut results = vec![
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
];
results.sort_unstable();
assert_eq!(results, vec![10, 20]);
cmd_tx.send(EffectCommand::Shutdown).unwrap();
let _ = handle.join();
}
#[test]
fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
let (result_tx, result_rx) = mpsc::channel::<u32>();
let config = EffectQueueConfig {
enabled: true,
backend: TaskExecutorBackend::EffectQueue,
scheduler: SchedulerConfig {
preemptive: false,
..Default::default()
},
explicit_backend: true,
..Default::default()
};
let handle = std::thread::spawn(move || {
effect_queue_loop(config, cmd_rx, result_tx, None);
});
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::default().with_name("slow"),
Box::new(|| {
std::thread::sleep(Duration::from_millis(20));
10
}),
))
.unwrap();
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::new(2.0, 5.0).with_name("fast"),
Box::new(|| 20),
))
.unwrap();
cmd_tx.send(EffectCommand::Shutdown).unwrap();
let mut results = vec![
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
];
results.sort_unstable();
assert_eq!(results, vec![10, 20]);
handle
.join()
.expect("effect queue thread joins after draining");
}
#[test]
fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
let (result_tx, result_rx) = mpsc::channel::<u32>();
let config = EffectQueueConfig {
enabled: true,
backend: TaskExecutorBackend::EffectQueue,
scheduler: SchedulerConfig {
preemptive: false,
..Default::default()
},
explicit_backend: true,
..Default::default()
};
let handle = std::thread::spawn(move || {
effect_queue_loop(config, cmd_rx, result_tx, None);
});
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::new(3.0, 1.0).with_name("panic"),
Box::new(|| panic!("queued panic")),
))
.unwrap();
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::new(1.0, 5.0).with_name("after"),
Box::new(|| 99),
))
.unwrap();
assert_eq!(
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
99
);
cmd_tx.send(EffectCommand::Shutdown).unwrap();
handle
.join()
.expect("effect queue thread survives task panic");
}
#[test]
fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
let (result_tx, result_rx) = mpsc::channel::<u32>();
let config = EffectQueueConfig {
enabled: true,
backend: TaskExecutorBackend::EffectQueue,
scheduler: SchedulerConfig {
preemptive: false,
..Default::default()
},
explicit_backend: true,
..Default::default()
};
let handle = std::thread::spawn(move || {
effect_queue_loop(config, cmd_rx, result_tx, None);
});
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::default().with_name("slow"),
Box::new(|| {
std::thread::sleep(Duration::from_millis(20));
10
}),
))
.unwrap();
cmd_tx.send(EffectCommand::Shutdown).unwrap();
cmd_tx
.send(EffectCommand::Enqueue(
TaskSpec::new(1.0, 1.0).with_name("late"),
Box::new(|| 99),
))
.unwrap();
assert_eq!(
result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
10
);
assert!(
result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
"post-shutdown enqueue should not execute"
);
handle
.join()
.expect("effect queue thread joins after rejecting post-shutdown work");
}
#[test]
fn effect_queue_enqueue_after_shutdown_records_drop() {
let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
drop(rx);
let queue = EffectQueue {
sender: tx,
handle: None,
closed: true,
};
let runs = Arc::new(AtomicUsize::new(0));
let before = crate::effect_system::effects_queue_dropped();
queue.enqueue(
TaskSpec::default(),
Box::new({
let runs = Arc::clone(&runs);
move || {
runs.fetch_add(1, Ordering::SeqCst);
7
}
}),
);
let after = crate::effect_system::effects_queue_dropped();
assert_eq!(runs.load(Ordering::SeqCst), 0);
assert!(
after > before,
"enqueue after shutdown should increment dropped counter"
);
}
#[test]
fn effect_queue_enqueue_with_closed_channel_records_drop() {
let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
drop(rx);
let queue = EffectQueue {
sender: tx,
handle: None,
closed: false,
};
let runs = Arc::new(AtomicUsize::new(0));
let before = crate::effect_system::effects_queue_dropped();
queue.enqueue(
TaskSpec::default(),
Box::new({
let runs = Arc::clone(&runs);
move || {
runs.fetch_add(1, Ordering::SeqCst);
9
}
}),
);
let after = crate::effect_system::effects_queue_dropped();
assert_eq!(runs.load(Ordering::SeqCst), 0);
assert!(
after > before,
"enqueue into a closed queue channel should increment dropped counter"
);
}
#[test]
fn backpressure_drops_tasks_beyond_max_depth() {
let (result_tx, _result_rx) = mpsc::channel::<u32>();
let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
let r1 = handle_effect_command(
EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
&mut scheduler,
&mut tasks,
&result_tx,
None,
2,
);
assert_eq!(r1, EffectLoopControl::Continue);
assert_eq!(tasks.len(), 1);
let r2 = handle_effect_command(
EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
&mut scheduler,
&mut tasks,
&result_tx,
None,
2,
);
assert_eq!(r2, EffectLoopControl::Continue);
assert_eq!(tasks.len(), 2);
let dropped_before = crate::effect_system::effects_queue_dropped();
let r3 = handle_effect_command(
EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
&mut scheduler,
&mut tasks,
&result_tx,
None,
2,
);
assert_eq!(r3, EffectLoopControl::Continue);
assert_eq!(
tasks.len(),
2,
"task should have been dropped, not enqueued"
);
assert!(
crate::effect_system::effects_queue_dropped() > dropped_before,
"dropped counter should increment"
);
}
#[test]
fn backpressure_zero_depth_means_unbounded() {
let (result_tx, _result_rx) = mpsc::channel::<u32>();
let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
for i in 0..20 {
let r = handle_effect_command(
EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
&mut scheduler,
&mut tasks,
&result_tx,
None,
0,
);
assert_eq!(r, EffectLoopControl::Continue);
}
}
#[test]
fn inline_auto_remeasure_reset_clears_decision() {
let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
state.sampler.decide(Instant::now());
assert!(state.sampler.last_decision().is_some());
state.reset();
assert!(state.sampler.last_decision().is_none());
}
#[test]
fn budget_decision_jsonl_contains_required_fields() {
let evidence = BudgetDecisionEvidence {
frame_idx: 7,
decision: BudgetDecision::Degrade,
controller_decision: BudgetDecision::Hold,
degradation_before: DegradationLevel::Full,
degradation_after: DegradationLevel::NoStyling,
frame_time_us: 12_345.678,
budget_us: 16_000.0,
pid_output: 1.25,
pid_p: 0.5,
pid_i: 0.25,
pid_d: 0.5,
e_value: 2.0,
frames_observed: 42,
frames_since_change: 3,
in_warmup: false,
conformal: Some(ConformalEvidence {
bucket_key: "inline:dirty:10".to_string(),
n_b: 32,
alpha: 0.05,
q_b: 1000.0,
y_hat: 12_000.0,
upper_us: 13_000.0,
risk: true,
fallback_level: 1,
window_size: 256,
reset_count: 2,
}),
};
let jsonl = evidence.to_jsonl();
assert!(jsonl.contains("\"event\":\"budget_decision\""));
assert!(jsonl.contains("\"decision\":\"degrade\""));
assert!(jsonl.contains("\"decision_controller\":\"stay\""));
assert!(jsonl.contains("\"degradation_before\":\"Full\""));
assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
assert!(jsonl.contains("\"budget_us\":16000.000000"));
assert!(jsonl.contains("\"pid_output\":1.250000"));
assert!(jsonl.contains("\"e_value\":2.000000"));
assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
assert!(jsonl.contains("\"n_b\":32"));
assert!(jsonl.contains("\"alpha\":0.050000"));
assert!(jsonl.contains("\"q_b\":1000.000000"));
assert!(jsonl.contains("\"y_hat\":12000.000000"));
assert!(jsonl.contains("\"upper_us\":13000.000000"));
assert!(jsonl.contains("\"risk\":true"));
assert!(jsonl.contains("\"fallback_level\":1"));
assert!(jsonl.contains("\"window_size\":256"));
assert!(jsonl.contains("\"reset_count\":2"));
}
fn make_signal(
widget_id: u64,
essential: bool,
priority: f32,
staleness_ms: u64,
cost_us: f32,
) -> WidgetSignal {
WidgetSignal {
widget_id,
essential,
priority,
staleness_ms,
focus_boost: 0.0,
interaction_boost: 0.0,
area_cells: 1,
cost_estimate_us: cost_us,
recent_cost_us: 0.0,
estimate_source: CostEstimateSource::FixedDefault,
}
}
fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
let staleness_window = config.staleness_window_ms.max(1) as f32;
let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
let mut value = config.weight_priority * signal.priority
+ config.weight_staleness * staleness_score
+ config.weight_focus * signal.focus_boost
+ config.weight_interaction * signal.interaction_boost;
if starved {
value += config.starve_boost;
}
let raw_cost = if signal.recent_cost_us > 0.0 {
signal.recent_cost_us
} else {
signal.cost_estimate_us
};
let cost_us = raw_cost.max(config.min_cost_us);
(value, cost_us, starved)
}
fn fifo_select(
signals: &[WidgetSignal],
budget_us: f64,
config: &WidgetRefreshConfig,
) -> (Vec<u64>, f64, usize) {
let mut selected = Vec::new();
let mut total_value = 0.0f64;
let mut starved_selected = 0usize;
let mut remaining = budget_us;
for signal in signals {
if !signal.essential {
continue;
}
let (value, cost_us, starved) = signal_value_cost(signal, config);
remaining -= cost_us as f64;
total_value += value as f64;
if starved {
starved_selected = starved_selected.saturating_add(1);
}
selected.push(signal.widget_id);
}
for signal in signals {
if signal.essential {
continue;
}
let (value, cost_us, starved) = signal_value_cost(signal, config);
if remaining >= cost_us as f64 {
remaining -= cost_us as f64;
total_value += value as f64;
if starved {
starved_selected = starved_selected.saturating_add(1);
}
selected.push(signal.widget_id);
}
}
(selected, total_value, starved_selected)
}
fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
if signals.is_empty() {
return Vec::new();
}
let mut rotated = Vec::with_capacity(signals.len());
for idx in 0..signals.len() {
rotated.push(signals[(idx + offset) % signals.len()].clone());
}
rotated
}
#[test]
fn widget_refresh_selects_essentials_first() {
let signals = vec![
make_signal(1, true, 0.6, 0, 5.0),
make_signal(2, false, 0.9, 0, 4.0),
];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig::default();
plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
assert_eq!(selected, vec![1]);
assert!(!plan.over_budget);
}
#[test]
fn widget_refresh_degradation_essential_only_skips_nonessential() {
let signals = vec![
make_signal(1, true, 0.5, 0, 2.0),
make_signal(2, false, 1.0, 0, 1.0),
];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig::default();
plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
assert_eq!(selected, vec![1]);
assert_eq!(plan.skipped_count, 1);
}
#[test]
fn widget_refresh_starvation_guard_forces_one_starved() {
let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig {
starve_ms: 1_000,
max_starved_per_frame: 1,
..Default::default()
};
plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
assert_eq!(plan.selected.len(), 1);
assert!(plan.selected[0].starved);
assert!(plan.over_budget);
}
#[test]
fn widget_refresh_budget_blocks_when_no_selection() {
let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig {
starve_ms: 0,
max_starved_per_frame: 0,
..Default::default()
};
plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
let budget = plan.as_budget();
assert!(!budget.allows(42, false));
}
#[test]
fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
let signals = vec![
make_signal(1, false, 0.4, 0, 10.0),
make_signal(2, false, 0.4, 0, 10.0),
make_signal(3, false, 0.4, 0, 10.0),
make_signal(4, false, 0.4, 0, 10.0),
];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig {
starve_ms: 0,
max_starved_per_frame: 0,
max_drop_fraction: 0.5,
..Default::default()
};
plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
assert_eq!(selected, vec![1, 2]);
}
#[test]
fn widget_refresh_greedy_beats_fifo_and_round_robin() {
let signals = vec![
make_signal(1, false, 0.1, 0, 6.0),
make_signal(2, false, 0.2, 0, 6.0),
make_signal(3, false, 1.0, 0, 4.0),
make_signal(4, false, 0.9, 0, 3.0),
make_signal(5, false, 0.8, 0, 3.0),
make_signal(6, false, 0.1, 4_000, 2.0),
];
let budget_us = 10.0;
let config = WidgetRefreshConfig::default();
let mut plan = WidgetRefreshPlan::new();
plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
let greedy_value = plan.selected_value;
let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
let rotated = rotate_signals(&signals, 2);
let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
assert!(
greedy_value > fifo_value,
"greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
greedy_selected,
fifo_selected
);
assert!(
greedy_value > rr_value,
"greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
greedy_selected,
rr_selected
);
assert!(
plan.starved_selected > 0,
"greedy did not select starved widget; greedy={:?}",
greedy_selected
);
}
#[test]
fn widget_refresh_jsonl_contains_required_fields() {
let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
let mut plan = WidgetRefreshPlan::new();
let config = WidgetRefreshConfig::default();
plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
let jsonl = plan.to_jsonl();
assert!(jsonl.contains("\"event\":\"widget_refresh\""));
assert!(jsonl.contains("\"frame_idx\":9"));
assert!(jsonl.contains("\"selected_count\":1"));
assert!(jsonl.contains("\"id\":7"));
}
#[test]
fn program_config_with_resize_coalescer() {
let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
steady_delay_ms: 8,
burst_delay_ms: 20,
hard_deadline_ms: 80,
burst_enter_rate: 12.0,
burst_exit_rate: 6.0,
cooldown_frames: 2,
rate_window_size: 6,
enable_logging: true,
enable_bocpd: false,
bocpd_config: None,
});
assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
assert!(config.resize_coalescer.enable_logging);
}
#[test]
fn program_config_with_resize_behavior() {
let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
}
#[test]
fn program_config_with_legacy_resize_enabled() {
let config = ProgramConfig::default().with_legacy_resize(true);
assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
}
#[test]
fn program_config_with_legacy_resize_disabled_keeps_default() {
let config = ProgramConfig::default().with_legacy_resize(false);
assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
}
fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
let mut writer = TerminalWriter::with_diff_config(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::basic(),
config,
);
writer.set_size(8, 4);
let mut buffer = Buffer::new(8, 4);
let mut trace = Vec::new();
writer.present_ui(&buffer, None, false).unwrap();
trace.push(
writer
.last_diff_strategy()
.unwrap_or(DiffStrategy::FullRedraw),
);
buffer.set_raw(0, 0, Cell::from_char('A'));
writer.present_ui(&buffer, None, false).unwrap();
trace.push(
writer
.last_diff_strategy()
.unwrap_or(DiffStrategy::FullRedraw),
);
buffer.set_raw(1, 1, Cell::from_char('B'));
writer.present_ui(&buffer, None, false).unwrap();
trace.push(
writer
.last_diff_strategy()
.unwrap_or(DiffStrategy::FullRedraw),
);
trace
}
fn coalescer_checksum(enable_bocpd: bool) -> String {
let mut config = CoalescerConfig::default().with_logging(true);
if enable_bocpd {
config = config.with_bocpd();
}
let base = Instant::now();
let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
let events = [
(0_u64, (82_u16, 24_u16)),
(10, (83, 25)),
(20, (84, 26)),
(35, (90, 28)),
(55, (92, 30)),
];
let mut idx = 0usize;
for t_ms in (0_u64..=160).step_by(8) {
let now = base + Duration::from_millis(t_ms);
while idx < events.len() && events[idx].0 == t_ms {
let (w, h) = events[idx].1;
coalescer.handle_resize_at(w, h, now);
idx += 1;
}
coalescer.tick_at(now);
}
coalescer.decision_checksum_hex()
}
fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
if !enabled {
return Vec::new();
}
let mut predictor = ConformalPredictor::new(ConformalConfig::default());
let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
let mut trace = Vec::new();
for i in 0..30 {
let y_hat = 16_000.0 + (i as f64) * 15.0;
let observed = y_hat + (i % 7) as f64 * 120.0;
predictor.observe(key, y_hat, observed);
let prediction = predictor.predict(key, y_hat, 20_000.0);
trace.push((prediction.upper_us, prediction.risk));
}
trace
}
#[test]
fn policy_toggle_matrix_determinism() {
for &bayesian in &[false, true] {
for &bocpd in &[false, true] {
for &conformal in &[false, true] {
let diff_a = diff_strategy_trace(bayesian);
let diff_b = diff_strategy_trace(bayesian);
assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
let checksum_a = coalescer_checksum(bocpd);
let checksum_b = coalescer_checksum(bocpd);
assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
let conf_a = conformal_trace(conformal);
let conf_b = conformal_trace(conformal);
assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
if conformal {
assert!(!conf_a.is_empty(), "conformal trace should be populated");
} else {
assert!(conf_a.is_empty(), "conformal trace should be empty");
}
}
}
}
}
#[test]
fn resize_behavior_uses_coalescer_flag() {
assert!(ResizeBehavior::Throttled.uses_coalescer());
assert!(!ResizeBehavior::Immediate.uses_coalescer());
}
#[test]
fn nested_cmd_msg_executes_recursively() {
use crate::simulator::ProgramSimulator;
struct NestedModel {
depth: usize,
}
#[derive(Debug)]
enum NestedMsg {
Nest(usize),
}
impl From<Event> for NestedMsg {
fn from(_: Event) -> Self {
NestedMsg::Nest(0)
}
}
impl Model for NestedModel {
type Message = NestedMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
NestedMsg::Nest(n) => {
self.depth += 1;
if n > 0 {
Cmd::msg(NestedMsg::Nest(n - 1))
} else {
Cmd::none()
}
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
sim.init();
sim.send(NestedMsg::Nest(3));
assert_eq!(sim.model().depth, 4);
}
#[test]
fn task_executes_synchronously_in_simulator() {
use crate::simulator::ProgramSimulator;
struct TaskModel {
completed: bool,
}
#[derive(Debug)]
enum TaskMsg {
Complete,
SpawnTask,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Complete
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Complete => {
self.completed = true;
Cmd::none()
}
TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(TaskModel { completed: false });
sim.init();
sim.send(TaskMsg::SpawnTask);
assert!(sim.model().completed);
}
#[test]
fn multiple_updates_accumulate_correctly() {
use crate::simulator::ProgramSimulator;
struct AccumModel {
sum: i32,
}
#[derive(Debug)]
enum AccumMsg {
Add(i32),
Multiply(i32),
}
impl From<Event> for AccumMsg {
fn from(_: Event) -> Self {
AccumMsg::Add(1)
}
}
impl Model for AccumModel {
type Message = AccumMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
AccumMsg::Add(n) => {
self.sum += n;
Cmd::none()
}
AccumMsg::Multiply(n) => {
self.sum *= n;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
sim.init();
sim.send(AccumMsg::Add(5));
sim.send(AccumMsg::Multiply(2));
sim.send(AccumMsg::Add(3));
assert_eq!(sim.model().sum, 13);
}
#[test]
fn init_command_executes_before_first_update() {
use crate::simulator::ProgramSimulator;
struct InitModel {
initialized: bool,
updates: usize,
}
#[derive(Debug)]
enum InitMsg {
Update,
MarkInit,
}
impl From<Event> for InitMsg {
fn from(_: Event) -> Self {
InitMsg::Update
}
}
impl Model for InitModel {
type Message = InitMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::msg(InitMsg::MarkInit)
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
InitMsg::MarkInit => {
self.initialized = true;
Cmd::none()
}
InitMsg::Update => {
self.updates += 1;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(InitModel {
initialized: false,
updates: 0,
});
sim.init();
assert!(sim.model().initialized);
sim.send(InitMsg::Update);
assert_eq!(sim.model().updates, 1);
}
#[test]
fn ui_height_returns_correct_value_inline_mode() {
use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
use ftui_core::terminal_capabilities::TerminalCapabilities;
let output = Vec::new();
let writer = TerminalWriter::new(
output,
ScreenMode::Inline { ui_height: 10 },
UiAnchor::Bottom,
TerminalCapabilities::basic(),
);
assert_eq!(writer.ui_height(), 10);
}
#[test]
fn ui_height_returns_term_height_altscreen_mode() {
use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
use ftui_core::terminal_capabilities::TerminalCapabilities;
let output = Vec::new();
let mut writer = TerminalWriter::new(
output,
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::basic(),
);
writer.set_size(80, 24);
assert_eq!(writer.ui_height(), 24);
}
#[test]
fn inline_mode_frame_uses_ui_height_not_terminal_height() {
use crate::simulator::ProgramSimulator;
use std::cell::Cell as StdCell;
thread_local! {
static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
}
struct FrameSizeTracker;
#[derive(Debug)]
enum SizeMsg {
Check,
}
impl From<Event> for SizeMsg {
fn from(_: Event) -> Self {
SizeMsg::Check
}
}
impl Model for FrameSizeTracker {
type Message = SizeMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, frame: &mut Frame) {
CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
}
}
let mut sim = ProgramSimulator::new(FrameSizeTracker);
sim.init();
let buf = sim.capture_frame(80, 10);
assert_eq!(buf.height(), 10);
assert_eq!(buf.width(), 80);
}
#[test]
fn altscreen_frame_uses_full_terminal_height() {
use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
use ftui_core::terminal_capabilities::TerminalCapabilities;
let output = Vec::new();
let mut writer = TerminalWriter::new(
output,
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::basic(),
);
writer.set_size(80, 40);
assert_eq!(writer.ui_height(), 40);
}
#[test]
fn ui_height_clamped_to_terminal_height() {
use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
use ftui_core::terminal_capabilities::TerminalCapabilities;
let output = Vec::new();
let mut writer = TerminalWriter::new(
output,
ScreenMode::Inline { ui_height: 100 },
UiAnchor::Bottom,
TerminalCapabilities::basic(),
);
writer.set_size(80, 10);
assert_eq!(writer.ui_height(), 100);
}
#[test]
fn tick_event_delivered_to_model_update() {
use crate::simulator::ProgramSimulator;
struct TickTracker {
tick_count: usize,
}
#[derive(Debug)]
enum TickMsg {
Tick,
Other,
}
impl From<Event> for TickMsg {
fn from(event: Event) -> Self {
match event {
Event::Tick => TickMsg::Tick,
_ => TickMsg::Other,
}
}
}
impl Model for TickTracker {
type Message = TickMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TickMsg::Tick => {
self.tick_count += 1;
Cmd::none()
}
TickMsg::Other => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
sim.init();
sim.inject_event(Event::Tick);
assert_eq!(sim.model().tick_count, 1);
sim.inject_event(Event::Tick);
sim.inject_event(Event::Tick);
assert_eq!(sim.model().tick_count, 3);
}
#[test]
fn tick_command_sets_tick_rate() {
use crate::simulator::{CmdRecord, ProgramSimulator};
struct TickModel;
#[derive(Debug)]
enum Msg {
SetTick,
Noop,
}
impl From<Event> for Msg {
fn from(_: Event) -> Self {
Msg::Noop
}
}
impl Model for TickModel {
type Message = Msg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
Msg::Noop => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(TickModel);
sim.init();
sim.send(Msg::SetTick);
let commands = sim.command_log();
assert!(
commands
.iter()
.any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
);
}
#[test]
fn tick_can_trigger_further_commands() {
use crate::simulator::ProgramSimulator;
struct ChainModel {
stage: usize,
}
#[derive(Debug)]
enum ChainMsg {
Tick,
Advance,
Noop,
}
impl From<Event> for ChainMsg {
fn from(event: Event) -> Self {
match event {
Event::Tick => ChainMsg::Tick,
_ => ChainMsg::Noop,
}
}
}
impl Model for ChainModel {
type Message = ChainMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ChainMsg::Tick => {
self.stage += 1;
Cmd::msg(ChainMsg::Advance)
}
ChainMsg::Advance => {
self.stage += 10;
Cmd::none()
}
ChainMsg::Noop => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
sim.init();
sim.inject_event(Event::Tick);
assert_eq!(sim.model().stage, 11);
}
#[test]
fn tick_disabled_with_zero_duration() {
use crate::simulator::ProgramSimulator;
struct ZeroTickModel {
disabled: bool,
}
#[derive(Debug)]
enum ZeroMsg {
DisableTick,
Noop,
}
impl From<Event> for ZeroMsg {
fn from(_: Event) -> Self {
ZeroMsg::Noop
}
}
impl Model for ZeroTickModel {
type Message = ZeroMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::tick(Duration::from_millis(100))
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ZeroMsg::DisableTick => {
self.disabled = true;
Cmd::tick(Duration::ZERO)
}
ZeroMsg::Noop => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
sim.init();
assert!(sim.tick_rate().is_some());
assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
sim.send(ZeroMsg::DisableTick);
assert!(sim.model().disabled);
assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
}
#[test]
fn tick_event_distinguishable_from_other_events() {
let tick = Event::Tick;
let key = Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('a'),
));
assert!(matches!(tick, Event::Tick));
assert!(!matches!(key, Event::Tick));
}
#[test]
fn tick_event_clone_and_eq() {
let tick1 = Event::Tick;
let tick2 = tick1.clone();
assert_eq!(tick1, tick2);
}
#[test]
fn model_receives_tick_and_input_events() {
use crate::simulator::ProgramSimulator;
struct MixedModel {
ticks: usize,
keys: usize,
}
#[derive(Debug)]
enum MixedMsg {
Tick,
Key,
}
impl From<Event> for MixedMsg {
fn from(event: Event) -> Self {
match event {
Event::Tick => MixedMsg::Tick,
_ => MixedMsg::Key,
}
}
}
impl Model for MixedModel {
type Message = MixedMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
MixedMsg::Tick => {
self.ticks += 1;
Cmd::none()
}
MixedMsg::Key => {
self.keys += 1;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
sim.init();
sim.inject_event(Event::Tick);
sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('a'),
)));
sim.inject_event(Event::Tick);
sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('b'),
)));
sim.inject_event(Event::Tick);
assert_eq!(sim.model().ticks, 3);
assert_eq!(sim.model().keys, 2);
}
fn headless_program_with_resolved_config<M: Model>(
model: M,
config: ProgramConfig,
) -> Program<M, HeadlessEventSource, Vec<u8>>
where
M::Message: Send + 'static,
{
clear_termination_signal();
let effect_queue_config = config.resolved_effect_queue_config();
let capabilities = TerminalCapabilities::basic();
let mut writer = TerminalWriter::with_diff_config(
Vec::new(),
config.screen_mode,
config.ui_anchor,
capabilities,
config.diff_config.clone(),
);
let frame_timing = config.frame_timing.clone();
writer.set_timing_enabled(frame_timing.is_some());
let (width, height) = config.forced_size.unwrap_or((80, 24));
let width = width.max(1);
let height = height.max(1);
writer.set_size(width, height);
let mouse_capture = config.resolved_mouse_capture();
let initial_features = BackendFeatures {
mouse_capture,
bracketed_paste: config.bracketed_paste,
focus_events: config.focus_reporting,
kitty_keyboard: config.kitty_keyboard,
};
let events = HeadlessEventSource::new(width, height, initial_features);
let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
.expect("headless evidence sink config");
let budget = RenderBudget::from_config(&config.budget);
let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
let locale_context = config.locale_context.clone();
let locale_version = locale_context.version();
let mut resize_coalescer =
ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
if let Some(ref sink) = evidence_sink {
resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
}
let subscriptions = SubscriptionManager::new();
let (task_sender, task_receiver) = std::sync::mpsc::channel();
let inline_auto_remeasure = config
.inline_auto_remeasure
.clone()
.map(InlineAutoRemeasureState::new);
let guardrails = FrameGuardrails::new(config.guardrails);
let task_executor = TaskExecutor::new(
&effect_queue_config,
task_sender.clone(),
evidence_sink.clone(),
)
.expect("task executor");
Program {
model,
writer,
events,
backend_features: initial_features,
running: true,
tick_rate: None,
executed_cmd_count: 0,
last_tick: Instant::now(),
dirty: true,
frame_idx: 0,
tick_count: 0,
widget_signals: Vec::new(),
widget_refresh_config: config.widget_refresh,
widget_refresh_plan: WidgetRefreshPlan::new(),
width,
height,
forced_size: config.forced_size,
poll_timeout: config.poll_timeout,
intercept_signals: config.intercept_signals,
immediate_drain_config: config.immediate_drain,
immediate_drain_stats: ImmediateDrainStats::default(),
budget,
conformal_predictor,
last_frame_time_us: None,
last_update_us: None,
frame_timing,
locale_context,
locale_version,
resize_coalescer,
evidence_sink,
fairness_config_logged: false,
resize_behavior: config.resize_behavior,
fairness_guard: InputFairnessGuard::new(),
event_recorder: None,
subscriptions,
#[cfg(test)]
task_sender,
task_receiver,
task_executor,
state_registry: config.persistence.registry.clone(),
persistence_config: config.persistence,
last_checkpoint: Instant::now(),
inline_auto_remeasure,
frame_arena: FrameArena::default(),
guardrails,
tick_strategy: config
.tick_strategy
.map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
last_active_screen_for_strategy: None,
}
}
fn headless_program_with_config<M: Model>(
model: M,
config: ProgramConfig,
) -> Program<M, HeadlessEventSource, Vec<u8>>
where
M::Message: Send + 'static,
{
headless_program_with_resolved_config(model, config.with_signal_interception(false))
}
fn headless_signal_program_with_config<M: Model>(
model: M,
config: ProgramConfig,
) -> Program<M, HeadlessEventSource, Vec<u8>>
where
M::Message: Send + 'static,
{
headless_program_with_resolved_config(model, config)
}
fn temp_evidence_path(label: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let mut path = std::env::temp_dir();
path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
path
}
fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
let needle = format!("\"event\":\"{event}\"");
let missing_msg = format!("missing {event} line");
let line = jsonl
.lines()
.find(|line| line.contains(&needle))
.expect(&missing_msg);
serde_json::from_str(line).expect("valid evidence json")
}
#[test]
fn headless_apply_resize_updates_model_and_dimensions() {
struct ResizeModel {
last_size: Option<(u16, u16)>,
}
#[derive(Debug)]
enum ResizeMsg {
Resize(u16, u16),
Other,
}
impl From<Event> for ResizeMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => ResizeMsg::Resize(width, height),
_ => ResizeMsg::Other,
}
}
}
impl Model for ResizeModel {
type Message = ResizeMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let ResizeMsg::Resize(w, h) = msg {
self.last_size = Some((w, h));
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program =
headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
program.dirty = false;
program
.apply_resize(0, 0, Duration::ZERO, false)
.expect("resize");
assert_eq!(program.width, 1);
assert_eq!(program.height, 1);
assert_eq!(program.model().last_size, Some((1, 1)));
assert!(program.dirty);
}
#[test]
fn headless_apply_resize_reconciles_subscriptions() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct ResizeSubModel {
subscribed: bool,
}
#[derive(Debug)]
enum ResizeSubMsg {
Resize,
Other,
}
impl From<Event> for ResizeSubMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { .. } => Self::Resize,
_ => Self::Other,
}
}
}
impl Model for ResizeSubModel {
type Message = ResizeSubMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if matches!(msg, ResizeSubMsg::Resize) {
self.subscribed = true;
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
if self.subscribed {
vec![Box::new(ResizeSubscription)]
} else {
vec![]
}
}
}
struct ResizeSubscription;
impl Subscription<ResizeSubMsg> for ResizeSubscription {
fn id(&self) -> SubId {
1
}
fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
}
let mut program = headless_program_with_config(
ResizeSubModel { subscribed: false },
ProgramConfig::default(),
);
assert_eq!(program.subscriptions.active_count(), 0);
program
.apply_resize(120, 40, Duration::ZERO, false)
.expect("resize");
assert!(program.model().subscribed);
assert_eq!(program.subscriptions.active_count(), 1);
}
#[test]
fn headless_execute_cmd_log_writes_output() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program.execute_cmd(Cmd::log("hello world")).expect("log");
let bytes = program.writer.into_inner().expect("writer output");
let output = String::from_utf8_lossy(&bytes);
assert!(output.contains("hello world"));
}
#[test]
fn headless_process_task_results_updates_model() {
struct TaskModel {
updates: usize,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
self.updates += 1;
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program =
headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
program.dirty = false;
program.task_sender.send(TaskMsg::Done).unwrap();
program
.process_task_results()
.expect("process task results");
assert_eq!(program.model().updates, 1);
assert!(program.dirty);
}
#[test]
fn run_invokes_on_shutdown_after_quit() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ShutdownModel {
shutdowns: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum ShutdownMsg {
Quit,
ShutdownRan,
}
impl From<Event> for ShutdownMsg {
fn from(_: Event) -> Self {
ShutdownMsg::Quit
}
}
impl Model for ShutdownModel {
type Message = ShutdownMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::quit()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ShutdownMsg::Quit => Cmd::quit(),
ShutdownMsg::ShutdownRan => {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::msg(ShutdownMsg::ShutdownRan)
}
}
let shutdowns = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
ShutdownModel {
shutdowns: Arc::clone(&shutdowns),
},
ProgramConfig::default(),
);
program.run().expect("program run");
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
}
#[test]
fn run_processes_shutdown_task_results_before_exit() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ShutdownTaskModel {
shutdowns: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum ShutdownTaskMsg {
Quit,
ShutdownRan,
}
impl From<Event> for ShutdownTaskMsg {
fn from(_: Event) -> Self {
ShutdownTaskMsg::Quit
}
}
impl Model for ShutdownTaskModel {
type Message = ShutdownTaskMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::quit()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ShutdownTaskMsg::Quit => Cmd::quit(),
ShutdownTaskMsg::ShutdownRan => {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
}
}
let shutdowns = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
ShutdownTaskModel {
shutdowns: Arc::clone(&shutdowns),
},
ProgramConfig::default(),
);
program.run().expect("program run");
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
}
#[test]
fn run_processes_shutdown_task_results_with_effect_queue_backend() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ShutdownTaskModel {
shutdowns: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum ShutdownTaskMsg {
Quit,
ShutdownRan,
}
impl From<Event> for ShutdownTaskMsg {
fn from(_: Event) -> Self {
ShutdownTaskMsg::Quit
}
}
impl Model for ShutdownTaskModel {
type Message = ShutdownTaskMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::quit()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ShutdownTaskMsg::Quit => Cmd::quit(),
ShutdownTaskMsg::ShutdownRan => {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
}
}
let shutdowns = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
ShutdownTaskModel {
shutdowns: Arc::clone(&shutdowns),
},
ProgramConfig::default().with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
),
);
program.run().expect("program run");
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
}
#[test]
fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ShutdownTaskModel {
shutdowns: Arc<AtomicUsize>,
follow_up_runs: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum ShutdownTaskMsg {
Quit,
ShutdownRan,
FollowUp,
}
impl From<Event> for ShutdownTaskMsg {
fn from(_: Event) -> Self {
ShutdownTaskMsg::Quit
}
}
impl Model for ShutdownTaskModel {
type Message = ShutdownTaskMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::quit()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ShutdownTaskMsg::Quit => Cmd::quit(),
ShutdownTaskMsg::ShutdownRan => {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
let follow_up_runs = Arc::clone(&self.follow_up_runs);
Cmd::task(move || {
follow_up_runs.fetch_add(1, Ordering::SeqCst);
ShutdownTaskMsg::FollowUp
})
}
ShutdownTaskMsg::FollowUp => {
self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
}
}
let shutdowns = Arc::new(AtomicUsize::new(0));
let follow_up_runs = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
ShutdownTaskModel {
shutdowns: Arc::clone(&shutdowns),
follow_up_runs: Arc::clone(&follow_up_runs),
},
ProgramConfig::default(),
);
program.run().expect("program run");
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
}
#[test]
fn run_quit_from_init_skips_initial_render_and_subscription_start() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct InitQuitModel {
render_calls: Arc<AtomicUsize>,
subscription_starts: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum InitQuitMsg {
Noop,
}
impl From<Event> for InitQuitMsg {
fn from(_: Event) -> Self {
Self::Noop
}
}
impl Model for InitQuitModel {
type Message = InitQuitMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::quit()
}
fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {
self.render_calls.fetch_add(1, Ordering::SeqCst);
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
vec![Box::new(InitQuitSubscription {
starts: Arc::clone(&self.subscription_starts),
})]
}
}
struct InitQuitSubscription {
starts: Arc<AtomicUsize>,
}
impl Subscription<InitQuitMsg> for InitQuitSubscription {
fn id(&self) -> SubId {
1
}
fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
self.starts.fetch_add(1, Ordering::SeqCst);
let _ = stop.wait_timeout(Duration::from_millis(10));
}
}
let render_calls = Arc::new(AtomicUsize::new(0));
let subscription_starts = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
InitQuitModel {
render_calls: Arc::clone(&render_calls),
subscription_starts: Arc::clone(&subscription_starts),
},
ProgramConfig::default(),
);
program.run().expect("program run");
assert_eq!(render_calls.load(Ordering::SeqCst), 0);
assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
}
#[test]
fn run_invokes_on_shutdown_before_returning_signal_error() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct ShutdownModel {
shutdowns: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum ShutdownMsg {
Noop,
ShutdownRan,
}
impl From<Event> for ShutdownMsg {
fn from(_: Event) -> Self {
ShutdownMsg::Noop
}
}
impl Model for ShutdownModel {
type Message = ShutdownMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ShutdownMsg::Noop => Cmd::none(),
ShutdownMsg::ShutdownRan => {
self.shutdowns.fetch_add(1, Ordering::SeqCst);
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::msg(ShutdownMsg::ShutdownRan)
}
}
let shutdowns = Arc::new(AtomicUsize::new(0));
ftui_core::shutdown_signal::with_test_signal_serialization(|| {
let mut program = headless_signal_program_with_config(
ShutdownModel {
shutdowns: Arc::clone(&shutdowns),
},
ProgramConfig::default().with_signal_interception(true),
);
ftui_core::shutdown_signal::record_pending_termination_signal(2);
let err = program.run().expect_err("signal should stop runtime");
assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
assert_eq!(signal_termination_from_error(&err), Some(2));
assert_eq!(check_termination_signal(), None);
});
}
#[test]
fn run_pending_signal_skips_initial_render_and_subscription_start() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct SignalStopModel {
render_calls: Arc<AtomicUsize>,
subscription_starts: Arc<AtomicUsize>,
}
#[derive(Debug, Clone, Copy)]
enum SignalStopMsg {
Noop,
}
impl From<Event> for SignalStopMsg {
fn from(_: Event) -> Self {
Self::Noop
}
}
impl Model for SignalStopModel {
type Message = SignalStopMsg;
fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {
self.render_calls.fetch_add(1, Ordering::SeqCst);
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
vec![Box::new(SignalStopSubscription {
starts: Arc::clone(&self.subscription_starts),
})]
}
}
struct SignalStopSubscription {
starts: Arc<AtomicUsize>,
}
impl Subscription<SignalStopMsg> for SignalStopSubscription {
fn id(&self) -> SubId {
11
}
fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
self.starts.fetch_add(1, Ordering::SeqCst);
let _ = stop.wait_timeout(Duration::from_millis(10));
}
}
let render_calls = Arc::new(AtomicUsize::new(0));
let subscription_starts = Arc::new(AtomicUsize::new(0));
ftui_core::shutdown_signal::with_test_signal_serialization(|| {
let mut program = headless_signal_program_with_config(
SignalStopModel {
render_calls: Arc::clone(&render_calls),
subscription_starts: Arc::clone(&subscription_starts),
},
ProgramConfig::default().with_signal_interception(true),
);
ftui_core::shutdown_signal::record_pending_termination_signal(15);
let err = program.run().expect_err("signal should stop runtime");
assert_eq!(signal_termination_from_error(&err), Some(15));
assert_eq!(render_calls.load(Ordering::SeqCst), 0);
assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
assert_eq!(check_termination_signal(), None);
});
}
#[test]
fn headless_should_tick_and_timeout_behaviors() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program.tick_rate = Some(Duration::from_millis(5));
program.last_tick = Instant::now() - Duration::from_millis(10);
assert!(program.should_tick());
assert!(!program.should_tick());
let timeout = program.effective_timeout();
assert!(timeout <= Duration::from_millis(5));
program.tick_rate = None;
program.poll_timeout = Duration::from_millis(33);
assert_eq!(program.effective_timeout(), Duration::from_millis(33));
}
#[test]
fn headless_effective_timeout_respects_resize_coalescer() {
let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
config.resize_coalescer.steady_delay_ms = 0;
config.resize_coalescer.burst_delay_ms = 0;
let mut program = headless_program_with_config(TestModel { value: 0 }, config);
program.tick_rate = Some(Duration::from_millis(50));
program.resize_coalescer.handle_resize(120, 40);
assert!(program.resize_coalescer.has_pending());
let timeout = program.effective_timeout();
assert_eq!(timeout, Duration::ZERO);
}
#[test]
fn headless_ui_height_remeasure_clears_auto_height() {
let mut config = ProgramConfig::inline_auto(2, 6);
config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
let mut program = headless_program_with_config(TestModel { value: 0 }, config);
program.dirty = false;
program.writer.set_auto_ui_height(5);
assert_eq!(program.writer.auto_ui_height(), Some(5));
program.request_ui_height_remeasure();
assert_eq!(program.writer.auto_ui_height(), None);
assert!(program.dirty);
}
#[test]
fn headless_recording_lifecycle_and_locale_change() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program.dirty = false;
program.start_recording("demo");
assert!(program.is_recording());
let recorded = program.stop_recording();
assert!(recorded.is_some());
assert!(!program.is_recording());
let prev_dirty = program.dirty;
program.locale_context.set_locale("fr");
program.check_locale_change();
assert!(program.dirty || prev_dirty);
}
#[test]
fn headless_render_frame_marks_clean_and_sets_diff() {
struct RenderModel;
#[derive(Debug)]
enum RenderMsg {
Noop,
}
impl From<Event> for RenderMsg {
fn from(_: Event) -> Self {
RenderMsg::Noop
}
}
impl Model for RenderModel {
type Message = RenderMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, frame: &mut Frame) {
frame.buffer.set_raw(0, 0, Cell::from_char('X'));
}
}
let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
program.render_frame().expect("render frame");
assert!(!program.dirty);
assert!(program.writer.last_diff_strategy().is_some());
assert_eq!(program.frame_idx, 1);
}
#[test]
fn headless_render_frame_skips_when_budget_exhausted() {
let config = ProgramConfig {
budget: FrameBudgetConfig::with_total(Duration::ZERO),
..Default::default()
};
let mut program = headless_program_with_config(TestModel { value: 0 }, config);
program.dirty = true;
program.render_frame().expect("render frame");
assert!(program.dirty);
assert_eq!(program.frame_idx, 1);
}
#[test]
fn headless_render_frame_emits_budget_evidence_with_controller() {
use ftui_render::budget::BudgetControllerConfig;
struct RenderModel;
#[derive(Debug)]
enum RenderMsg {
Noop,
}
impl From<Event> for RenderMsg {
fn from(_: Event) -> Self {
RenderMsg::Noop
}
}
impl Model for RenderModel {
type Message = RenderMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, frame: &mut Frame) {
frame.buffer.set_raw(0, 0, Cell::from_char('E'));
}
}
let config =
ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
let mut program = headless_program_with_config(RenderModel, config);
program.budget = program
.budget
.with_controller(BudgetControllerConfig::default());
program.render_frame().expect("render frame");
assert!(program.budget.telemetry().is_some());
assert_eq!(program.frame_idx, 1);
}
#[test]
fn headless_handle_event_updates_model() {
struct EventModel {
events: usize,
last_resize: Option<(u16, u16)>,
}
#[derive(Debug)]
enum EventMsg {
Resize(u16, u16),
Other,
}
impl From<Event> for EventMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => EventMsg::Resize(width, height),
_ => EventMsg::Other,
}
}
}
impl Model for EventModel {
type Message = EventMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
self.events += 1;
if let EventMsg::Resize(w, h) = msg {
self.last_resize = Some((w, h));
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program = headless_program_with_config(
EventModel {
events: 0,
last_resize: None,
},
ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
);
program
.handle_event(Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('x'),
)))
.expect("handle key");
assert_eq!(program.model().events, 1);
program
.handle_event(Event::Resize {
width: 10,
height: 5,
})
.expect("handle resize");
assert_eq!(program.model().events, 2);
assert_eq!(program.model().last_resize, Some((10, 5)));
assert_eq!(program.width, 10);
assert_eq!(program.height, 5);
}
#[test]
fn headless_handle_event_quit_skips_subscription_reconcile() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct QuitSubModel {
quitting: bool,
subscription_starts: Arc<AtomicUsize>,
}
#[derive(Debug)]
enum QuitSubMsg {
Quit,
Other,
}
impl From<Event> for QuitSubMsg {
fn from(event: Event) -> Self {
match event {
Event::Key(_) => Self::Quit,
_ => Self::Other,
}
}
}
impl Model for QuitSubModel {
type Message = QuitSubMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
QuitSubMsg::Quit => {
self.quitting = true;
Cmd::quit()
}
QuitSubMsg::Other => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
if self.quitting {
vec![Box::new(QuitSubSubscription {
starts: Arc::clone(&self.subscription_starts),
})]
} else {
vec![]
}
}
}
struct QuitSubSubscription {
starts: Arc<AtomicUsize>,
}
impl Subscription<QuitSubMsg> for QuitSubSubscription {
fn id(&self) -> SubId {
7
}
fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
self.starts.fetch_add(1, Ordering::SeqCst);
let _ = stop.wait_timeout(Duration::from_millis(10));
}
}
let subscription_starts = Arc::new(AtomicUsize::new(0));
let mut program = headless_program_with_config(
QuitSubModel {
quitting: false,
subscription_starts: Arc::clone(&subscription_starts),
},
ProgramConfig::default(),
);
program
.handle_event(Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('q'),
)))
.expect("handle event");
assert!(!program.is_running());
assert_eq!(program.subscriptions.active_count(), 0);
assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
}
#[test]
fn headless_handle_resize_ignored_when_forced_size() {
struct ResizeModel {
resized: bool,
}
#[derive(Debug)]
enum ResizeMsg {
Resize,
Other,
}
impl From<Event> for ResizeMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { .. } => ResizeMsg::Resize,
_ => ResizeMsg::Other,
}
}
}
impl Model for ResizeModel {
type Message = ResizeMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if matches!(msg, ResizeMsg::Resize) {
self.resized = true;
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let config = ProgramConfig::default().with_forced_size(80, 24);
let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
program
.handle_event(Event::Resize {
width: 120,
height: 40,
})
.expect("handle resize");
assert_eq!(program.width, 80);
assert_eq!(program.height, 24);
assert!(!program.model().resized);
}
#[test]
fn headless_execute_cmd_batch_sequence_and_quit() {
struct BatchModel {
count: usize,
}
#[derive(Debug)]
enum BatchMsg {
Inc,
}
impl From<Event> for BatchMsg {
fn from(_: Event) -> Self {
BatchMsg::Inc
}
}
impl Model for BatchModel {
type Message = BatchMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
BatchMsg::Inc => {
self.count += 1;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program =
headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
program
.execute_cmd(Cmd::Batch(vec![
Cmd::msg(BatchMsg::Inc),
Cmd::Sequence(vec![
Cmd::msg(BatchMsg::Inc),
Cmd::quit(),
Cmd::msg(BatchMsg::Inc),
]),
]))
.expect("batch cmd");
assert_eq!(program.model().count, 2);
assert!(!program.running);
}
#[test]
fn headless_process_subscription_messages_updates_model() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct SubModel {
pings: usize,
ready_tx: mpsc::Sender<()>,
}
#[derive(Debug)]
enum SubMsg {
Ping,
Other,
}
impl From<Event> for SubMsg {
fn from(_: Event) -> Self {
SubMsg::Other
}
}
impl Model for SubModel {
type Message = SubMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let SubMsg::Ping = msg {
self.pings += 1;
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
vec![Box::new(TestSubscription {
ready_tx: self.ready_tx.clone(),
})]
}
}
struct TestSubscription {
ready_tx: mpsc::Sender<()>,
}
impl Subscription<SubMsg> for TestSubscription {
fn id(&self) -> SubId {
1
}
fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
let _ = sender.send(SubMsg::Ping);
let _ = self.ready_tx.send(());
}
}
let (ready_tx, ready_rx) = mpsc::channel();
let mut program =
headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
program.reconcile_subscriptions();
ready_rx
.recv_timeout(Duration::from_millis(200))
.expect("subscription started");
program
.process_subscription_messages()
.expect("process subscriptions");
assert_eq!(program.model().pings, 1);
}
#[test]
fn headless_execute_cmd_task_spawns_and_reaps() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program =
headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("task cmd");
let deadline = Instant::now() + Duration::from_millis(200);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
program.reap_finished_tasks();
}
assert!(program.model().done, "task result did not arrive in time");
}
#[test]
fn headless_default_task_executor_is_queued_for_structured_lane() {
let program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert_eq!(program.task_executor.kind_name(), "queued");
}
#[test]
fn headless_structured_lane_task_executor_writes_queued_backend_evidence() {
let evidence_path = temp_evidence_path("task_executor_queued_backend");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let config = ProgramConfig::default().with_evidence_sink(sink_config);
let _program = headless_program_with_config(TestModel { value: 0 }, config);
let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
assert_eq!(backend_line["backend"], "queued");
}
#[test]
fn headless_legacy_lane_task_executor_is_spawned() {
let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
let program = headless_program_with_config(TestModel { value: 0 }, config);
assert_eq!(program.task_executor.kind_name(), "spawned");
}
#[test]
fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
let config = ProgramConfig::default().with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
);
let program = headless_program_with_config(TestModel { value: 0 }, config);
assert_eq!(program.task_executor.kind_name(), "spawned");
}
#[cfg(feature = "asupersync-executor")]
#[test]
fn headless_asupersync_task_executor_is_selected() {
let config = ProgramConfig::default().with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
);
let program = headless_program_with_config(TestModel { value: 0 }, config);
assert_eq!(program.task_executor.kind_name(), "asupersync");
}
#[test]
fn headless_persistence_commands_with_registry() {
use crate::state_persistence::{MemoryStorage, StateRegistry};
use std::sync::Arc;
let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
let config = ProgramConfig::default().with_registry(registry.clone());
let mut program = headless_program_with_config(TestModel { value: 0 }, config);
assert!(program.has_persistence());
assert!(program.state_registry().is_some());
program.execute_cmd(Cmd::save_state()).expect("save");
program.execute_cmd(Cmd::restore_state()).expect("restore");
let saved = program.trigger_save().expect("trigger save");
let loaded = program.trigger_load().expect("trigger load");
assert!(!saved);
assert_eq!(loaded, 0);
}
#[test]
fn headless_process_resize_coalescer_applies_pending_resize() {
struct ResizeModel {
last_size: Option<(u16, u16)>,
}
#[derive(Debug)]
enum ResizeMsg {
Resize(u16, u16),
Other,
}
impl From<Event> for ResizeMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => ResizeMsg::Resize(width, height),
_ => ResizeMsg::Other,
}
}
}
impl Model for ResizeModel {
type Message = ResizeMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let ResizeMsg::Resize(w, h) = msg {
self.last_size = Some((w, h));
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let evidence_path = temp_evidence_path("fairness_allow");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
config.resize_coalescer.steady_delay_ms = 0;
config.resize_coalescer.burst_delay_ms = 0;
config.resize_coalescer.hard_deadline_ms = 1_000;
config.evidence_sink = sink_config.clone();
let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
let sink = EvidenceSink::from_config(&sink_config)
.expect("evidence sink config")
.expect("evidence sink enabled");
program.evidence_sink = Some(sink);
program.resize_coalescer.handle_resize(120, 40);
assert!(program.resize_coalescer.has_pending());
program
.process_resize_coalescer()
.expect("process resize coalescer");
assert_eq!(program.width, 120);
assert_eq!(program.height, 40);
assert_eq!(program.model().last_size, Some((120, 40)));
let config_line = read_evidence_event(&evidence_path, "fairness_config");
assert_eq!(config_line["event"], "fairness_config");
assert!(config_line["enabled"].is_boolean());
assert!(config_line["input_priority_threshold_ms"].is_number());
assert!(config_line["dominance_threshold"].is_number());
assert!(config_line["fairness_threshold"].is_number());
let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
assert_eq!(decision_line["event"], "fairness_decision");
assert_eq!(decision_line["decision"], "allow");
assert_eq!(decision_line["reason"], "none");
assert!(decision_line["pending_input_latency_ms"].is_null());
assert!(decision_line["jain_index"].is_number());
assert!(decision_line["resize_dominance_count"].is_number());
assert!(decision_line["dominance_threshold"].is_number());
assert!(decision_line["fairness_threshold"].is_number());
assert!(decision_line["input_priority_threshold_ms"].is_number());
}
#[test]
fn headless_process_resize_coalescer_yields_to_input() {
struct ResizeModel {
last_size: Option<(u16, u16)>,
}
#[derive(Debug)]
enum ResizeMsg {
Resize(u16, u16),
Other,
}
impl From<Event> for ResizeMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => ResizeMsg::Resize(width, height),
_ => ResizeMsg::Other,
}
}
}
impl Model for ResizeModel {
type Message = ResizeMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let ResizeMsg::Resize(w, h) = msg {
self.last_size = Some((w, h));
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let evidence_path = temp_evidence_path("fairness_yield");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
config.resize_coalescer.steady_delay_ms = 0;
config.resize_coalescer.burst_delay_ms = 0;
config.resize_coalescer.hard_deadline_ms = 10_000;
config.evidence_sink = sink_config.clone();
let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
let sink = EvidenceSink::from_config(&sink_config)
.expect("evidence sink config")
.expect("evidence sink enabled");
program.evidence_sink = Some(sink);
program.fairness_guard = InputFairnessGuard::with_config(
crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
);
program
.fairness_guard
.input_arrived(Instant::now() - Duration::from_millis(1));
program.resize_coalescer.handle_resize(120, 40);
assert!(program.resize_coalescer.has_pending());
program
.process_resize_coalescer()
.expect("process resize coalescer");
assert_eq!(program.width, 80);
assert_eq!(program.height, 24);
assert_eq!(program.model().last_size, None);
assert!(program.resize_coalescer.has_pending());
let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
assert_eq!(decision_line["event"], "fairness_decision");
assert_eq!(decision_line["decision"], "yield");
assert_eq!(decision_line["reason"], "input_latency");
assert!(decision_line["pending_input_latency_ms"].is_number());
assert!(decision_line["jain_index"].is_number());
assert!(decision_line["resize_dominance_count"].is_number());
assert!(decision_line["dominance_threshold"].is_number());
assert!(decision_line["fairness_threshold"].is_number());
assert!(decision_line["input_priority_threshold_ms"].is_number());
}
#[test]
fn headless_execute_cmd_task_with_effect_queue() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let effect_queue = EffectQueueConfig {
enabled: true,
backend: TaskExecutorBackend::EffectQueue,
scheduler: SchedulerConfig {
max_queue_size: 0,
..Default::default()
},
explicit_backend: true,
..Default::default()
};
let config = ProgramConfig::default().with_effect_queue(effect_queue);
let mut program = headless_program_with_config(TaskModel { done: false }, config);
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("task cmd");
let deadline = Instant::now() + Duration::from_millis(200);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
}
assert!(
program.model().done,
"effect queue task result did not arrive in time"
);
assert_eq!(program.task_executor.kind_name(), "queued");
}
#[test]
fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let evidence_path = temp_evidence_path("task_executor_spawned_complete");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let config = ProgramConfig::default()
.with_lane(RuntimeLane::Legacy)
.with_evidence_sink(sink_config);
let mut program = headless_program_with_config(TaskModel { done: false }, config);
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("task cmd");
let deadline = Instant::now() + Duration::from_millis(200);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
program.reap_finished_tasks();
}
assert!(
program.model().done,
"spawned task result did not arrive in time"
);
let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
assert_eq!(completion_line["backend"], "spawned");
assert!(completion_line["duration_us"].is_number());
}
#[test]
fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let evidence_path = temp_evidence_path("task_executor_queued_panic");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let config = ProgramConfig::default()
.with_evidence_sink(sink_config)
.with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
);
let mut program = headless_program_with_config(TaskModel { done: false }, config);
program
.execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
.expect("panic task cmd");
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("follow-up task cmd");
let deadline = Instant::now() + Duration::from_millis(500);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
}
assert!(
program.model().done,
"effect queue should continue after a panicking task"
);
let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
assert_eq!(panic_line["backend"], "queued");
assert_eq!(panic_line["panic_msg"], "queued panic evidence");
}
#[cfg(feature = "asupersync-executor")]
#[test]
fn headless_execute_cmd_task_with_asupersync_backend() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let config = ProgramConfig::default().with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
);
let mut program = headless_program_with_config(TaskModel { done: false }, config);
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("task cmd");
let deadline = Instant::now() + Duration::from_millis(200);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
program.reap_finished_tasks();
}
assert!(
program.model().done,
"asupersync task result did not arrive in time"
);
assert_eq!(program.task_executor.kind_name(), "asupersync");
}
#[cfg(feature = "asupersync-executor")]
#[test]
fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
struct TaskModel {
done: bool,
}
#[derive(Debug)]
enum TaskMsg {
Done,
}
impl From<Event> for TaskMsg {
fn from(_: Event) -> Self {
TaskMsg::Done
}
}
impl Model for TaskModel {
type Message = TaskMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
TaskMsg::Done => {
self.done = true;
Cmd::none()
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
let config = ProgramConfig::default()
.with_evidence_sink(sink_config)
.with_effect_queue(
EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
);
let mut program = headless_program_with_config(TaskModel { done: false }, config);
let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
assert_eq!(backend_line["backend"], "asupersync");
program
.execute_cmd(Cmd::task(|| TaskMsg::Done))
.expect("task cmd");
let deadline = Instant::now() + Duration::from_millis(200);
while !program.model().done && Instant::now() <= deadline {
program
.process_task_results()
.expect("process task results");
program.reap_finished_tasks();
}
assert!(
program.model().done,
"asupersync task result did not arrive in time"
);
let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
assert_eq!(completion_line["backend"], "asupersync");
assert!(completion_line["duration_us"].is_number());
}
#[test]
fn unit_tau_monotone() {
let mut bc = BatchController::new();
bc.observe_service(Duration::from_millis(20));
bc.observe_service(Duration::from_millis(20));
bc.observe_service(Duration::from_millis(20));
let tau_high = bc.tau_s();
for _ in 0..20 {
bc.observe_service(Duration::from_millis(1));
}
let tau_low = bc.tau_s();
assert!(
tau_low <= tau_high,
"τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
);
}
#[test]
fn unit_tau_monotone_lambda() {
let mut bc = BatchController::new();
let base = Instant::now();
for i in 0..10 {
bc.observe_arrival(base + Duration::from_millis(i * 10));
}
let rho_fast = bc.rho_est();
for i in 10..20 {
bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
}
let rho_slow = bc.rho_est();
assert!(
rho_slow < rho_fast,
"ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
);
}
#[test]
fn unit_stability() {
let mut bc = BatchController::new();
let base = Instant::now();
for i in 0..30 {
bc.observe_arrival(base + Duration::from_millis(i * 33));
bc.observe_service(Duration::from_millis(5)); }
assert!(
bc.is_stable(),
"should be stable at 30 events/sec with 5ms service: ρ={:.4}",
bc.rho_est()
);
assert!(
bc.rho_est() < 1.0,
"utilization should be < 1: ρ={:.4}",
bc.rho_est()
);
assert!(
bc.tau_s() > bc.service_est_s(),
"τ ({:.6}) must exceed E[S] ({:.6}) for stability",
bc.tau_s(),
bc.service_est_s()
);
}
#[test]
fn unit_stability_high_load() {
let mut bc = BatchController::new();
let base = Instant::now();
for i in 0..50 {
bc.observe_arrival(base + Duration::from_millis(i * 10));
bc.observe_service(Duration::from_millis(8));
}
let tau = bc.tau_s();
let rho_eff = bc.service_est_s() / tau;
assert!(
rho_eff < 1.0,
"effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
bc.service_est_s()
);
}
#[test]
fn batch_controller_defaults() {
let bc = BatchController::new();
assert!(bc.tau_s() >= bc.tau_min_s);
assert!(bc.tau_s() <= bc.tau_max_s);
assert_eq!(bc.observations(), 0);
assert!(bc.is_stable());
}
#[test]
fn batch_controller_tau_clamped() {
let mut bc = BatchController::new();
for _ in 0..20 {
bc.observe_service(Duration::from_micros(10));
}
assert!(
bc.tau_s() >= bc.tau_min_s,
"τ should be >= tau_min: τ={:.6}, min={:.6}",
bc.tau_s(),
bc.tau_min_s
);
for _ in 0..20 {
bc.observe_service(Duration::from_millis(100));
}
assert!(
bc.tau_s() <= bc.tau_max_s,
"τ should be <= tau_max: τ={:.6}, max={:.6}",
bc.tau_s(),
bc.tau_max_s
);
}
#[test]
fn batch_controller_duration_conversion() {
let bc = BatchController::new();
let tau = bc.tau();
let tau_s = bc.tau_s();
let diff = (tau.as_secs_f64() - tau_s).abs();
assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
}
#[test]
fn batch_controller_lambda_estimation() {
let mut bc = BatchController::new();
let base = Instant::now();
for i in 0..20 {
bc.observe_arrival(base + Duration::from_millis(i * 20));
}
let lambda = bc.lambda_est();
assert!(
lambda > 20.0 && lambda < 100.0,
"λ should be near 50: got {lambda:.1}"
);
}
#[test]
fn cmd_save_state() {
let cmd: Cmd<TestMsg> = Cmd::save_state();
assert!(matches!(cmd, Cmd::SaveState));
}
#[test]
fn cmd_restore_state() {
let cmd: Cmd<TestMsg> = Cmd::restore_state();
assert!(matches!(cmd, Cmd::RestoreState));
}
#[test]
fn persistence_config_default() {
let config = PersistenceConfig::default();
assert!(config.registry.is_none());
assert!(config.checkpoint_interval.is_none());
assert!(config.auto_load);
assert!(config.auto_save);
}
#[test]
fn persistence_config_disabled() {
let config = PersistenceConfig::disabled();
assert!(config.registry.is_none());
}
#[test]
fn persistence_config_with_registry() {
use crate::state_persistence::{MemoryStorage, StateRegistry};
use std::sync::Arc;
let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
let config = PersistenceConfig::with_registry(registry.clone());
assert!(config.registry.is_some());
assert!(config.auto_load);
assert!(config.auto_save);
}
#[test]
fn persistence_config_checkpoint_interval() {
use crate::state_persistence::{MemoryStorage, StateRegistry};
use std::sync::Arc;
let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
let config = PersistenceConfig::with_registry(registry)
.checkpoint_every(Duration::from_secs(30))
.auto_load(false)
.auto_save(true);
assert!(config.checkpoint_interval.is_some());
assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
assert!(!config.auto_load);
assert!(config.auto_save);
}
#[test]
fn program_config_with_persistence() {
use crate::state_persistence::{MemoryStorage, StateRegistry};
use std::sync::Arc;
let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
let config = ProgramConfig::default().with_registry(registry);
assert!(config.persistence.registry.is_some());
}
#[test]
fn task_spec_default() {
let spec = TaskSpec::default();
assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
assert!(spec.name.is_none());
}
#[test]
fn task_spec_new() {
let spec = TaskSpec::new(5.0, 20.0);
assert_eq!(spec.weight, 5.0);
assert_eq!(spec.estimate_ms, 20.0);
assert!(spec.name.is_none());
}
#[test]
fn task_spec_with_name() {
let spec = TaskSpec::default().with_name("fetch_data");
assert_eq!(spec.name.as_deref(), Some("fetch_data"));
}
#[test]
fn task_spec_debug() {
let spec = TaskSpec::new(2.0, 15.0).with_name("test");
let debug = format!("{spec:?}");
assert!(debug.contains("2.0"));
assert!(debug.contains("15.0"));
assert!(debug.contains("test"));
}
#[test]
fn cmd_count_none() {
let cmd: Cmd<TestMsg> = Cmd::none();
assert_eq!(cmd.count(), 0);
}
#[test]
fn cmd_count_atomic() {
assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
}
#[test]
fn cmd_count_batch() {
let cmd: Cmd<TestMsg> =
Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
assert_eq!(cmd.count(), 2); }
#[test]
fn cmd_count_nested() {
let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
Cmd::msg(TestMsg::Increment),
Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
]);
assert_eq!(cmd.count(), 3);
}
#[test]
fn cmd_type_name_all_variants() {
assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
assert_eq!(
Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
"Batch"
);
assert_eq!(
Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
"Sequence"
);
assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
assert_eq!(
Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
"Tick"
);
assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
assert_eq!(
Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
"Task"
);
assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
assert_eq!(
Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
"SetMouseCapture"
);
}
#[test]
fn cmd_batch_empty_returns_none() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_batch_single_unwraps() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn cmd_batch_multiple_stays_batch() {
let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
assert!(matches!(cmd, Cmd::Batch(_)));
}
#[test]
fn cmd_sequence_empty_returns_none() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn cmd_sequence_single_unwraps_to_inner() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn cmd_sequence_multiple_stays_sequence() {
let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
assert!(matches!(cmd, Cmd::Sequence(_)));
}
#[test]
fn cmd_task_with_spec() {
let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
match cmd {
Cmd::Task(s, _) => {
assert_eq!(s.weight, 3.0);
assert_eq!(s.estimate_ms, 25.0);
assert_eq!(s.name.as_deref(), Some("my_task"));
}
_ => panic!("expected Task variant"),
}
}
#[test]
fn cmd_task_weighted() {
let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
match cmd {
Cmd::Task(s, _) => {
assert_eq!(s.weight, 2.0);
assert_eq!(s.estimate_ms, 50.0);
assert!(s.name.is_none());
}
_ => panic!("expected Task variant"),
}
}
#[test]
fn cmd_task_named() {
let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
match cmd {
Cmd::Task(s, _) => {
assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
assert_eq!(s.name.as_deref(), Some("background_fetch"));
}
_ => panic!("expected Task variant"),
}
}
#[test]
fn cmd_debug_all_variant_strings() {
assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
assert!(
format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
);
assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
assert_eq!(
format!("{:?}", Cmd::<TestMsg>::restore_state()),
"RestoreState"
);
assert_eq!(
format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
"SetMouseCapture(true)"
);
}
#[test]
fn headless_execute_cmd_set_mouse_capture() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert!(!program.backend_features.mouse_capture);
program
.execute_cmd(Cmd::set_mouse_capture(true))
.expect("set mouse capture true");
assert!(program.backend_features.mouse_capture);
program
.execute_cmd(Cmd::set_mouse_capture(false))
.expect("set mouse capture false");
assert!(!program.backend_features.mouse_capture);
}
#[test]
fn resize_behavior_uses_coalescer() {
assert!(ResizeBehavior::Throttled.uses_coalescer());
assert!(!ResizeBehavior::Immediate.uses_coalescer());
}
#[test]
fn resize_behavior_eq_and_debug() {
assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
let debug = format!("{:?}", ResizeBehavior::Throttled);
assert_eq!(debug, "Throttled");
}
#[test]
fn widget_refresh_config_defaults() {
let config = WidgetRefreshConfig::default();
assert!(config.enabled);
assert_eq!(config.staleness_window_ms, 1_000);
assert_eq!(config.starve_ms, 3_000);
assert_eq!(config.max_starved_per_frame, 2);
assert_eq!(config.max_drop_fraction, 1.0);
assert_eq!(config.weight_priority, 1.0);
assert_eq!(config.weight_staleness, 0.5);
assert_eq!(config.weight_focus, 0.75);
assert_eq!(config.weight_interaction, 0.5);
assert_eq!(config.starve_boost, 1.5);
assert_eq!(config.min_cost_us, 1.0);
}
#[test]
fn effect_queue_config_default() {
let config = EffectQueueConfig::default();
assert!(!config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::Spawned);
assert!(!config.explicit_backend);
assert!(config.scheduler.smith_enabled);
assert!(!config.scheduler.force_fifo);
assert!(!config.scheduler.preemptive);
}
#[test]
fn effect_queue_config_with_enabled() {
let config = EffectQueueConfig::default().with_enabled(true);
assert!(config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
assert!(config.explicit_backend);
}
#[test]
fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
let config = EffectQueueConfig::default().with_enabled(false);
assert!(!config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::Spawned);
assert!(config.explicit_backend);
}
#[test]
fn effect_queue_config_with_backend() {
let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
assert!(config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
assert!(config.explicit_backend);
}
#[cfg(feature = "asupersync-executor")]
#[test]
fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
assert!(!config.enabled);
assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
}
#[test]
fn effect_queue_config_with_scheduler() {
let sched = SchedulerConfig {
force_fifo: true,
..Default::default()
};
let config = EffectQueueConfig::default().with_scheduler(sched);
assert!(config.scheduler.force_fifo);
}
#[test]
fn inline_auto_remeasure_config_defaults() {
let config = InlineAutoRemeasureConfig::default();
assert_eq!(config.change_threshold_rows, 1);
assert_eq!(config.voi.prior_alpha, 1.0);
assert_eq!(config.voi.prior_beta, 9.0);
assert_eq!(config.voi.max_interval_ms, 1000);
assert_eq!(config.voi.min_interval_ms, 100);
assert_eq!(config.voi.sample_cost, 0.08);
}
#[test]
fn headless_event_source_size() {
let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
assert_eq!(source.size().unwrap(), (120, 40));
}
#[test]
fn headless_event_source_poll_always_false() {
let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
}
#[test]
fn headless_event_source_read_always_none() {
let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
assert!(source.read_event().unwrap().is_none());
}
#[test]
fn headless_event_source_set_features() {
let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
let features = BackendFeatures {
mouse_capture: true,
bracketed_paste: true,
focus_events: true,
kitty_keyboard: true,
};
source.set_features(features).unwrap();
assert_eq!(source.features, features);
}
#[test]
fn immediate_drain_budget_adds_backoff_poll_under_burst() {
use ftui_core::event::{KeyCode, KeyEvent};
struct DrainBurstModel {
processed: usize,
quit_after: usize,
}
#[derive(Debug)]
#[allow(dead_code)]
enum DrainBurstMsg {
Event(Event),
}
impl From<Event> for DrainBurstMsg {
fn from(event: Event) -> Self {
DrainBurstMsg::Event(event)
}
}
impl Model for DrainBurstModel {
type Message = DrainBurstMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
DrainBurstMsg::Event(_) => {
self.processed = self.processed.saturating_add(1);
if self.processed >= self.quit_after {
Cmd::quit()
} else {
Cmd::none()
}
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
struct DrainBurstEventSource {
queue: VecDeque<Event>,
poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
size: (u16, u16),
}
impl BackendEventSource for DrainBurstEventSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), Self::Error> {
Ok(self.size)
}
fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
Ok(())
}
fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
self.poll_timeouts.lock().unwrap().push(timeout);
Ok(!self.queue.is_empty())
}
fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
Ok(self.queue.pop_front())
}
}
let burst_events = 24usize;
let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut queue = VecDeque::new();
for _ in 0..burst_events {
queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
}
let events = DrainBurstEventSource {
queue,
poll_timeouts: poll_timeouts.clone(),
size: (80, 24),
};
let writer = TerminalWriter::new(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::dumb(),
);
let config = ProgramConfig::default()
.with_forced_size(80, 24)
.with_signal_interception(false)
.with_immediate_drain(ImmediateDrainConfig {
max_zero_timeout_polls_per_burst: 3,
max_burst_duration: Duration::from_secs(1),
backoff_timeout: Duration::from_millis(1),
});
let model = DrainBurstModel {
processed: 0,
quit_after: burst_events,
};
let mut program =
Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
.expect("program creation");
program.run().expect("run burst");
assert_eq!(program.model().processed, burst_events);
let stats = program.immediate_drain_stats();
assert_eq!(stats.bursts, 1);
assert!(stats.capped_bursts >= 1);
assert!(stats.backoff_polls >= 1);
assert!(stats.zero_timeout_polls >= 1);
assert!(stats.max_zero_timeout_polls_in_burst <= 3);
let timeouts = poll_timeouts.lock().unwrap();
assert!(timeouts.contains(&Duration::ZERO));
assert!(timeouts.contains(&Duration::from_millis(1)));
}
#[test]
fn immediate_drain_zero_poll_limit_is_clamped() {
use ftui_core::event::{KeyCode, KeyEvent};
struct ClampModel {
processed: usize,
quit_after: usize,
}
#[derive(Debug)]
#[allow(dead_code)]
enum ClampMsg {
Event(Event),
}
impl From<Event> for ClampMsg {
fn from(event: Event) -> Self {
ClampMsg::Event(event)
}
}
impl Model for ClampModel {
type Message = ClampMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ClampMsg::Event(_) => {
self.processed = self.processed.saturating_add(1);
if self.processed >= self.quit_after {
Cmd::quit()
} else {
Cmd::none()
}
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
struct ClampSource {
queue: VecDeque<Event>,
}
impl BackendEventSource for ClampSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), Self::Error> {
Ok((80, 24))
}
fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
Ok(())
}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
Ok(!self.queue.is_empty())
}
fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
Ok(self.queue.pop_front())
}
}
let burst_events = 8usize;
let mut queue = VecDeque::new();
for _ in 0..burst_events {
queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
}
let events = ClampSource { queue };
let writer = TerminalWriter::new(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::dumb(),
);
let config = ProgramConfig::default()
.with_forced_size(80, 24)
.with_signal_interception(false)
.with_immediate_drain(ImmediateDrainConfig {
max_zero_timeout_polls_per_burst: 0,
max_burst_duration: Duration::from_secs(1),
backoff_timeout: Duration::from_millis(1),
});
let model = ClampModel {
processed: 0,
quit_after: burst_events,
};
let mut program =
Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
.expect("program creation");
program.run().expect("run clamp");
let stats = program.immediate_drain_stats();
assert!(stats.max_zero_timeout_polls_in_burst <= 1);
}
#[test]
fn quit_stops_draining_remaining_burst_events() {
use ftui_core::event::{KeyCode, KeyEvent};
struct QuitBurstModel {
processed: usize,
quit_after: usize,
}
#[derive(Debug)]
#[allow(dead_code)]
enum QuitBurstMsg {
Event(Event),
}
impl From<Event> for QuitBurstMsg {
fn from(event: Event) -> Self {
Self::Event(event)
}
}
impl Model for QuitBurstModel {
type Message = QuitBurstMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
QuitBurstMsg::Event(_) => {
self.processed = self.processed.saturating_add(1);
if self.processed >= self.quit_after {
Cmd::quit()
} else {
Cmd::none()
}
}
}
}
fn view(&self, _frame: &mut Frame) {}
}
struct QuitBurstSource {
queue: VecDeque<Event>,
}
impl BackendEventSource for QuitBurstSource {
type Error = io::Error;
fn size(&self) -> Result<(u16, u16), Self::Error> {
Ok((80, 24))
}
fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
Ok(())
}
fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
Ok(!self.queue.is_empty())
}
fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
Ok(self.queue.pop_front())
}
}
let total_events = 8usize;
let quit_after = 3usize;
let mut queue = VecDeque::new();
for _ in 0..total_events {
queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
}
let writer = TerminalWriter::new(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::dumb(),
);
let config = ProgramConfig::default()
.with_forced_size(80, 24)
.with_signal_interception(false)
.with_immediate_drain(ImmediateDrainConfig {
max_zero_timeout_polls_per_burst: 64,
max_burst_duration: Duration::from_secs(1),
backoff_timeout: Duration::from_millis(1),
});
let model = QuitBurstModel {
processed: 0,
quit_after,
};
let events = QuitBurstSource { queue };
let mut program =
Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
.expect("program creation");
program.run().expect("run burst quit");
assert_eq!(program.model().processed, quit_after);
}
#[test]
fn headless_program_quit_and_is_running() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert!(program.is_running());
program.quit();
assert!(!program.is_running());
}
#[test]
fn headless_program_model_mut() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert_eq!(program.model().value, 0);
program.model_mut().value = 42;
assert_eq!(program.model().value, 42);
}
#[test]
fn headless_program_request_redraw() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program.dirty = false;
program.request_redraw();
assert!(program.dirty);
}
#[test]
fn headless_program_last_widget_signals_initially_empty() {
let program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert!(program.last_widget_signals().is_empty());
}
#[test]
fn headless_program_no_persistence_by_default() {
let program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert!(!program.has_persistence());
assert!(program.state_registry().is_none());
}
#[test]
fn classify_event_fairness_key_is_input() {
let event = Event::Key(ftui_core::event::KeyEvent::new(
ftui_core::event::KeyCode::Char('a'),
));
let classification =
Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
assert_eq!(classification, FairnessEventType::Input);
}
#[test]
fn classify_event_fairness_resize_is_resize() {
let event = Event::Resize {
width: 80,
height: 24,
};
let classification =
Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
assert_eq!(classification, FairnessEventType::Resize);
}
#[test]
fn classify_event_fairness_tick_is_tick() {
let event = Event::Tick;
let classification =
Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
assert_eq!(classification, FairnessEventType::Tick);
}
#[test]
fn classify_event_fairness_paste_is_input() {
let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
let classification =
Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
assert_eq!(classification, FairnessEventType::Input);
}
#[test]
fn classify_event_fairness_focus_is_input() {
let event = Event::Focus(true);
let classification =
Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
assert_eq!(classification, FairnessEventType::Input);
}
#[test]
fn program_config_with_diff_config() {
let diff = RuntimeDiffConfig::default();
let config = ProgramConfig::default().with_diff_config(diff.clone());
let _ = format!("{:?}", config);
}
#[test]
fn program_config_with_evidence_sink() {
let config =
ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
let _ = format!("{:?}", config);
}
#[test]
fn program_config_with_render_trace() {
let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
let _ = format!("{:?}", config);
}
#[test]
fn program_config_with_locale() {
let config = ProgramConfig::default().with_locale("fr");
let _ = format!("{:?}", config);
}
#[test]
fn program_config_with_locale_context() {
let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
let _ = format!("{:?}", config);
}
#[test]
fn program_config_without_forced_size() {
let config = ProgramConfig::default()
.with_forced_size(80, 24)
.without_forced_size();
assert!(config.forced_size.is_none());
}
#[test]
fn program_config_forced_size_clamps_min() {
let config = ProgramConfig::default().with_forced_size(0, 0);
assert_eq!(config.forced_size, Some((1, 1)));
}
#[test]
fn program_config_with_widget_refresh() {
let wrc = WidgetRefreshConfig {
enabled: false,
..Default::default()
};
let config = ProgramConfig::default().with_widget_refresh(wrc);
assert!(!config.widget_refresh.enabled);
}
#[test]
fn program_config_with_effect_queue() {
let eqc = EffectQueueConfig::default().with_enabled(true);
let config = ProgramConfig::default().with_effect_queue(eqc);
assert!(config.effect_queue.enabled);
assert_eq!(
config.effect_queue.backend,
TaskExecutorBackend::EffectQueue
);
}
#[test]
fn program_config_with_resize_coalescer_custom() {
let cc = CoalescerConfig {
steady_delay_ms: 42,
..Default::default()
};
let config = ProgramConfig::default().with_resize_coalescer(cc);
assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
}
#[test]
fn program_config_with_inline_auto_remeasure() {
let config = ProgramConfig::default()
.with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
assert!(config.inline_auto_remeasure.is_some());
let config = config.without_inline_auto_remeasure();
assert!(config.inline_auto_remeasure.is_none());
}
#[test]
fn program_config_with_persistence_full() {
let pc = PersistenceConfig::disabled();
let config = ProgramConfig::default().with_persistence(pc);
assert!(config.persistence.registry.is_none());
}
#[test]
fn program_config_with_conformal_config() {
let config = ProgramConfig::default()
.with_conformal_config(ConformalConfig::default())
.without_conformal();
assert!(config.conformal_config.is_none());
}
#[test]
fn program_config_with_lane() {
let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
}
#[test]
fn program_config_default_lane_resolves_to_effect_queue_backend() {
let resolved = ProgramConfig::default().resolved_effect_queue_config();
assert!(resolved.enabled);
assert_eq!(resolved.backend, TaskExecutorBackend::EffectQueue);
}
#[test]
fn program_config_legacy_lane_resolves_to_spawned_backend() {
let resolved = ProgramConfig::default()
.with_lane(RuntimeLane::Legacy)
.resolved_effect_queue_config();
assert!(!resolved.enabled);
assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
}
#[test]
fn program_config_explicit_spawned_backend_is_preserved() {
let resolved = ProgramConfig::default()
.with_effect_queue(EffectQueueConfig::default().with_enabled(false))
.resolved_effect_queue_config();
assert!(!resolved.enabled);
assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
}
#[test]
fn program_config_with_rollout_policy() {
let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
}
#[test]
fn rollout_policy_labels() {
assert_eq!(RolloutPolicy::Off.label(), "off");
assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
}
#[test]
fn rollout_policy_is_shadow() {
assert!(!RolloutPolicy::Off.is_shadow());
assert!(RolloutPolicy::Shadow.is_shadow());
assert!(!RolloutPolicy::Enabled.is_shadow());
}
#[test]
fn rollout_policy_default_is_off() {
assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
}
#[test]
fn runtime_lane_parse_legacy() {
assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
}
#[test]
fn runtime_lane_parse_structured_case_insensitive() {
assert_eq!(
RuntimeLane::parse("Structured"),
Some(RuntimeLane::Structured)
);
}
#[test]
fn runtime_lane_parse_asupersync_uppercase() {
assert_eq!(
RuntimeLane::parse("ASUPERSYNC"),
Some(RuntimeLane::Asupersync)
);
}
#[test]
fn runtime_lane_parse_unrecognized() {
assert_eq!(RuntimeLane::parse("bogus"), None);
}
#[test]
fn rollout_policy_parse_shadow() {
assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
}
#[test]
fn rollout_policy_parse_enabled() {
assert_eq!(
RolloutPolicy::parse("enabled"),
Some(RolloutPolicy::Enabled)
);
}
#[test]
fn rollout_policy_parse_off() {
assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
}
#[test]
fn rollout_policy_parse_unrecognized() {
assert_eq!(RolloutPolicy::parse("bogus"), None);
}
#[test]
fn persistence_config_debug() {
let config = PersistenceConfig::default();
let debug = format!("{config:?}");
assert!(debug.contains("PersistenceConfig"));
assert!(debug.contains("auto_load"));
assert!(debug.contains("auto_save"));
}
#[test]
fn frame_timing_config_debug() {
use std::sync::Arc;
struct DummySink;
impl FrameTimingSink for DummySink {
fn record_frame(&self, _timing: &FrameTiming) {}
}
let config = FrameTimingConfig::new(Arc::new(DummySink));
let debug = format!("{config:?}");
assert!(debug.contains("FrameTimingConfig"));
}
#[test]
fn program_config_with_frame_timing() {
use std::sync::Arc;
struct DummySink;
impl FrameTimingSink for DummySink {
fn record_frame(&self, _timing: &FrameTiming) {}
}
let config =
ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
assert!(config.frame_timing.is_some());
}
#[test]
fn budget_decision_evidence_decision_from_levels() {
use ftui_render::budget::DegradationLevel;
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::Full,
DegradationLevel::SimpleBorders
),
BudgetDecision::Degrade
);
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::SimpleBorders,
DegradationLevel::Full
),
BudgetDecision::Upgrade
);
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::Full,
DegradationLevel::Full
),
BudgetDecision::Hold
);
}
#[test]
fn widget_refresh_plan_clear() {
let mut plan = WidgetRefreshPlan::new();
plan.frame_idx = 5;
plan.budget_us = 100.0;
plan.signal_count = 3;
plan.over_budget = true;
plan.clear();
assert_eq!(plan.frame_idx, 0);
assert_eq!(plan.budget_us, 0.0);
assert_eq!(plan.signal_count, 0);
assert!(!plan.over_budget);
}
#[test]
fn widget_refresh_plan_as_budget_empty_signals() {
let plan = WidgetRefreshPlan::new();
let budget = plan.as_budget();
assert!(budget.allows(0, false));
assert!(budget.allows(999, false));
}
#[test]
fn widget_refresh_plan_to_jsonl_structure() {
let plan = WidgetRefreshPlan::new();
let jsonl = plan.to_jsonl();
assert!(jsonl.contains("\"event\":\"widget_refresh\""));
assert!(jsonl.contains("\"frame_idx\":0"));
assert!(jsonl.contains("\"selected\":[]"));
}
#[test]
fn batch_controller_default_trait() {
let bc = BatchController::default();
let bc2 = BatchController::new();
assert_eq!(bc.tau_s(), bc2.tau_s());
assert_eq!(bc.observations(), bc2.observations());
}
#[test]
fn batch_controller_observe_arrival_stale_gap_ignored() {
let mut bc = BatchController::new();
let base = Instant::now();
bc.observe_arrival(base);
bc.observe_arrival(base + Duration::from_secs(15));
assert_eq!(bc.observations(), 0);
}
#[test]
fn batch_controller_observe_service_out_of_range() {
let mut bc = BatchController::new();
let original_service = bc.service_est_s();
bc.observe_service(Duration::from_secs(15));
assert_eq!(bc.service_est_s(), original_service);
}
#[test]
fn batch_controller_lambda_zero_inter_arrival() {
let bc = BatchController {
ema_inter_arrival_s: 0.0,
..BatchController::new()
};
assert_eq!(bc.lambda_est(), 0.0);
}
#[test]
fn headless_execute_cmd_log_appends_newline_if_missing() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program.execute_cmd(Cmd::log("no newline")).expect("log");
let bytes = program.writer.into_inner().expect("writer output");
let output = String::from_utf8_lossy(&bytes);
assert!(output.contains("no newline"));
}
#[test]
fn headless_execute_cmd_log_preserves_trailing_newline() {
let mut program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
program
.execute_cmd(Cmd::log("with newline\n"))
.expect("log");
let bytes = program.writer.into_inner().expect("writer output");
let output = String::from_utf8_lossy(&bytes);
assert!(output.contains("with newline"));
}
#[test]
fn headless_handle_event_immediate_resize() {
struct ResizeModel {
last_size: Option<(u16, u16)>,
}
#[derive(Debug)]
enum ResizeMsg {
Resize(u16, u16),
Other,
}
impl From<Event> for ResizeMsg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => ResizeMsg::Resize(width, height),
_ => ResizeMsg::Other,
}
}
}
impl Model for ResizeModel {
type Message = ResizeMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
if let ResizeMsg::Resize(w, h) = msg {
self.last_size = Some((w, h));
}
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
program
.handle_event(Event::Resize {
width: 120,
height: 40,
})
.expect("handle resize");
assert_eq!(program.width, 120);
assert_eq!(program.height, 40);
assert_eq!(program.model().last_size, Some((120, 40)));
}
#[test]
fn headless_apply_resize_clamps_zero_to_one() {
struct SimpleModel;
#[derive(Debug)]
enum SimpleMsg {
Noop,
}
impl From<Event> for SimpleMsg {
fn from(_: Event) -> Self {
SimpleMsg::Noop
}
}
impl Model for SimpleModel {
type Message = SimpleMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
program
.apply_resize(0, 0, Duration::ZERO, false)
.expect("resize");
assert_eq!(program.width, 1);
assert_eq!(program.height, 1);
}
#[test]
fn force_cancel_all_idle_returns_none() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
assert!(adapter.force_cancel_all().is_none());
}
#[test]
fn force_cancel_all_after_pointer_down_returns_diagnostics() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let _ = adapter.translate(&down, Some(target));
assert!(adapter.active_pointer_id().is_some());
let diag = adapter
.force_cancel_all()
.expect("should produce diagnostics");
assert!(diag.had_active_pointer);
assert_eq!(diag.active_pointer_id, Some(1));
assert!(diag.machine_transition.is_some());
assert_eq!(adapter.active_pointer_id(), None);
assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
}
#[test]
fn force_cancel_all_during_drag_returns_diagnostics() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Vertical);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
3,
3,
));
let _ = adapter.translate(&down, Some(target));
let drag = Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
8,
3,
));
let _ = adapter.translate(&drag, None);
let diag = adapter
.force_cancel_all()
.expect("should produce diagnostics");
assert!(diag.had_active_pointer);
assert!(diag.machine_transition.is_some());
let transition = diag.machine_transition.unwrap();
assert!(matches!(
transition.effect,
PaneDragResizeEffect::Canceled {
reason: PaneCancelReason::Programmatic,
..
}
));
}
#[test]
fn force_cancel_all_is_idempotent() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let _ = adapter.translate(&down, Some(target));
let first = adapter.force_cancel_all();
assert!(first.is_some());
let second = adapter.force_cancel_all();
assert!(second.is_none());
}
#[test]
fn pane_interaction_guard_finish_when_idle() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let guard = PaneInteractionGuard::new(&mut adapter);
let diag = guard.finish();
assert!(diag.is_none());
}
#[test]
fn pane_interaction_guard_finish_returns_diagnostics() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let _ = adapter.translate(&down, Some(target));
let guard = PaneInteractionGuard::new(&mut adapter);
let diag = guard.finish().expect("should produce diagnostics");
assert!(diag.had_active_pointer);
assert_eq!(diag.active_pointer_id, Some(1));
}
#[test]
fn pane_interaction_guard_drop_cancels_active_interaction() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Vertical);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
7,
7,
));
let _ = adapter.translate(&down, Some(target));
assert!(adapter.active_pointer_id().is_some());
{
let _guard = PaneInteractionGuard::new(&mut adapter);
}
assert_eq!(adapter.active_pointer_id(), None);
assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
}
#[test]
fn pane_interaction_guard_adapter_access_works() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let mut guard = PaneInteractionGuard::new(&mut adapter);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let dispatch = guard.adapter().translate(&down, Some(target));
assert!(dispatch.primary_event.is_some());
let diag = guard.finish().expect("should produce diagnostics");
assert!(diag.had_active_pointer);
}
#[test]
fn pane_interaction_guard_finish_then_drop_is_safe() {
let mut adapter =
PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
let target = pane_target(SplitAxis::Horizontal);
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
5,
5,
));
let _ = adapter.translate(&down, Some(target));
let guard = PaneInteractionGuard::new(&mut adapter);
let _diag = guard.finish();
assert_eq!(adapter.active_pointer_id(), None);
}
fn caps_modern() -> TerminalCapabilities {
TerminalCapabilities::modern()
}
fn caps_with_mux(
mux: PaneMuxEnvironment,
) -> ftui_core::terminal_capabilities::TerminalCapabilities {
let mut caps = TerminalCapabilities::modern();
match mux {
PaneMuxEnvironment::Tmux => caps.in_tmux = true,
PaneMuxEnvironment::Screen => caps.in_screen = true,
PaneMuxEnvironment::Zellij => caps.in_zellij = true,
PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
PaneMuxEnvironment::None => {}
}
caps
}
#[test]
fn capability_matrix_bare_terminal_modern() {
let caps = caps_modern();
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::None);
assert!(mat.mouse_sgr);
assert!(mat.mouse_drag_reliable);
assert!(mat.mouse_button_discrimination);
assert!(mat.focus_events);
assert!(mat.unicode_box_drawing);
assert!(mat.true_color);
assert!(!mat.degraded);
assert!(mat.drag_enabled());
assert!(mat.focus_cancel_effective());
assert!(mat.limitations().is_empty());
}
#[test]
fn capability_matrix_tmux() {
let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
assert!(mat.mouse_drag_reliable);
assert!(!mat.focus_events);
assert!(mat.drag_enabled());
assert!(!mat.focus_cancel_effective());
assert!(mat.degraded);
}
#[test]
fn capability_matrix_screen_degrades_drag() {
let caps = caps_with_mux(PaneMuxEnvironment::Screen);
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
assert!(!mat.mouse_drag_reliable);
assert!(!mat.focus_events);
assert!(!mat.drag_enabled());
assert!(!mat.focus_cancel_effective());
assert!(mat.degraded);
let lims = mat.limitations();
assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
assert!(lims.iter().any(|l| l.id == "no_focus_events"));
}
#[test]
fn capability_matrix_zellij() {
let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
assert!(mat.mouse_drag_reliable);
assert!(!mat.focus_events);
assert!(mat.drag_enabled());
assert!(!mat.focus_cancel_effective());
assert!(mat.degraded);
}
#[test]
fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
assert!(mat.mouse_drag_reliable);
assert!(!mat.focus_events);
assert!(mat.drag_enabled());
assert!(!mat.focus_cancel_effective());
assert!(mat.degraded);
}
#[test]
fn capability_matrix_no_sgr_mouse() {
let mut caps = caps_modern();
caps.mouse_sgr = false;
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert!(!mat.mouse_sgr);
assert!(!mat.mouse_button_discrimination);
assert!(mat.degraded);
let lims = mat.limitations();
assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
}
#[test]
fn capability_matrix_no_focus_events() {
let mut caps = caps_modern();
caps.focus_events = false;
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert!(!mat.focus_events);
assert!(!mat.focus_cancel_effective());
assert!(mat.degraded);
let lims = mat.limitations();
assert!(lims.iter().any(|l| l.id == "no_focus_events"));
}
#[test]
fn capability_matrix_dumb_terminal() {
let caps = TerminalCapabilities::dumb();
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
assert_eq!(mat.mux, PaneMuxEnvironment::None);
assert!(!mat.mouse_sgr);
assert!(!mat.focus_events);
assert!(!mat.unicode_box_drawing);
assert!(!mat.true_color);
assert!(mat.degraded);
assert!(mat.limitations().len() >= 3);
}
#[test]
fn capability_matrix_limitations_have_fallbacks() {
let caps = TerminalCapabilities::dumb();
let mat = PaneCapabilityMatrix::from_capabilities(&caps);
for lim in mat.limitations() {
assert!(!lim.id.is_empty());
assert!(!lim.description.is_empty());
assert!(!lim.fallback.is_empty());
}
}
struct MultiScreenModel {
active: String,
screens: Vec<String>,
ticked_screens: Vec<(String, u64)>,
}
#[derive(Debug)]
enum MultiScreenMsg {
#[expect(dead_code)]
Event(Event),
}
impl From<Event> for MultiScreenMsg {
fn from(event: Event) -> Self {
MultiScreenMsg::Event(event)
}
}
impl Model for MultiScreenModel {
type Message = MultiScreenMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
MultiScreenMsg::Event(_) => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
fn as_screen_tick_dispatch(
&mut self,
) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
Some(self)
}
}
impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
fn screen_ids(&self) -> Vec<String> {
self.screens.clone()
}
fn active_screen_id(&self) -> String {
self.active.clone()
}
fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
self.ticked_screens.push((screen_id.to_owned(), tick_count));
}
}
type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
struct RecordingStrategy {
log: TransitionLog,
}
impl RecordingStrategy {
fn new(log: TransitionLog) -> Self {
Self { log }
}
}
impl crate::tick_strategy::TickStrategy for RecordingStrategy {
fn should_tick(
&mut self,
_screen_id: &str,
_tick_count: u64,
_active_screen: &str,
) -> crate::tick_strategy::TickDecision {
crate::tick_strategy::TickDecision::Skip
}
fn on_screen_transition(&mut self, from: &str, to: &str) {
self.log
.lock()
.unwrap()
.push((from.to_owned(), to.to_owned()));
}
fn name(&self) -> &str {
"Recording"
}
fn debug_stats(&self) -> Vec<(String, String)> {
vec![("strategy".into(), "Recording".into())]
}
}
fn headless_multi_screen_program(
active: &str,
screens: &[&str],
) -> (
Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
TransitionLog,
) {
let model = MultiScreenModel {
active: active.to_owned(),
screens: screens.iter().map(|s| (*s).to_owned()).collect(),
ticked_screens: Vec::new(),
};
let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
let writer = TerminalWriter::new(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::dumb(),
);
let config = ProgramConfig {
forced_size: Some((80, 24)),
tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
..ProgramConfig::default()
};
let mut prog =
Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
.expect("headless program creation failed");
let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
(prog, log)
}
#[test]
fn check_screen_transition_first_call_records_active() {
let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
assert!(prog.last_active_screen_for_strategy.is_none());
prog.check_screen_transition();
assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
assert!(prog.model.ticked_screens.is_empty());
assert!(log.lock().unwrap().is_empty());
}
#[test]
fn check_screen_transition_no_change_is_noop() {
let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
prog.check_screen_transition();
prog.check_screen_transition();
assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
assert!(prog.model.ticked_screens.is_empty());
assert!(log.lock().unwrap().is_empty());
}
#[test]
fn check_screen_transition_detects_switch_and_force_ticks() {
let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
prog.check_screen_transition();
prog.model.active = "B".to_owned();
prog.check_screen_transition();
assert_eq!(prog.model.ticked_screens.len(), 1);
assert_eq!(prog.model.ticked_screens[0].0, "B");
let transitions = log.lock().unwrap();
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
}
#[test]
fn check_screen_transition_marks_dirty_on_change() {
let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
prog.check_screen_transition();
prog.dirty = false;
prog.model.active = "B".to_owned();
prog.check_screen_transition();
assert!(prog.dirty);
}
#[test]
fn check_screen_transition_not_dirty_when_unchanged() {
let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
prog.check_screen_transition();
prog.dirty = false;
prog.check_screen_transition();
assert!(!prog.dirty);
}
#[test]
fn check_screen_transition_noop_without_strategy() {
let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
prog.tick_strategy = None;
prog.check_screen_transition();
assert!(prog.last_active_screen_for_strategy.is_none());
}
#[test]
fn check_screen_transition_multiple_switches_notifies_strategy() {
let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
prog.check_screen_transition();
prog.model.active = "B".to_owned();
prog.check_screen_transition();
assert_eq!(prog.model.ticked_screens.len(), 1);
assert_eq!(prog.model.ticked_screens[0].0, "B");
prog.model.active = "C".to_owned();
prog.check_screen_transition();
assert_eq!(prog.model.ticked_screens.len(), 2);
assert_eq!(prog.model.ticked_screens[1].0, "C");
prog.model.active = "A".to_owned();
prog.check_screen_transition();
assert_eq!(prog.model.ticked_screens.len(), 3);
assert_eq!(prog.model.ticked_screens[2].0, "A");
let transitions = log.lock().unwrap();
assert_eq!(transitions.len(), 3);
assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
}
#[test]
fn check_screen_transition_uses_current_tick_count() {
let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
prog.tick_count = 42;
prog.check_screen_transition();
prog.model.active = "B".to_owned();
prog.check_screen_transition();
assert_eq!(prog.model.ticked_screens[0].1, 42);
}
#[test]
fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
use crate::subscription::{StopSignal, SubId, Subscription};
struct TransitionSubModel {
active: String,
screens: Vec<String>,
subscribed: bool,
}
#[derive(Debug)]
#[allow(dead_code)]
enum TransitionSubMsg {
Event(Event),
}
impl From<Event> for TransitionSubMsg {
fn from(event: Event) -> Self {
Self::Event(event)
}
}
impl Model for TransitionSubModel {
type Message = TransitionSubMsg;
fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
if self.subscribed {
vec![Box::new(TransitionSubscription)]
} else {
vec![]
}
}
fn as_screen_tick_dispatch(
&mut self,
) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
Some(self)
}
}
impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
fn screen_ids(&self) -> Vec<String> {
self.screens.clone()
}
fn active_screen_id(&self) -> String {
self.active.clone()
}
fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
if screen_id == self.active {
self.subscribed = true;
}
}
}
struct TransitionSubscription;
impl Subscription<TransitionSubMsg> for TransitionSubscription {
fn id(&self) -> SubId {
1
}
fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
}
struct TransitionStrategy;
impl crate::tick_strategy::TickStrategy for TransitionStrategy {
fn should_tick(
&mut self,
_screen_id: &str,
_tick_count: u64,
_active_screen: &str,
) -> crate::tick_strategy::TickDecision {
crate::tick_strategy::TickDecision::Skip
}
fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
fn name(&self) -> &str {
"TransitionStrategy"
}
fn debug_stats(&self) -> Vec<(String, String)> {
vec![]
}
}
let model = TransitionSubModel {
active: "A".to_owned(),
screens: vec!["A".to_owned(), "B".to_owned()],
subscribed: false,
};
let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
let writer = TerminalWriter::new(
Vec::<u8>::new(),
ScreenMode::AltScreen,
UiAnchor::Bottom,
TerminalCapabilities::dumb(),
);
let config = ProgramConfig::default().with_forced_size(80, 24);
let mut program =
Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
.expect("program creation");
program.tick_strategy = Some(Box::new(TransitionStrategy));
program.check_screen_transition();
assert_eq!(program.subscriptions.active_count(), 0);
program.model.active = "B".to_owned();
program.check_screen_transition();
assert!(program.model().subscribed);
assert_eq!(program.subscriptions.active_count(), 1);
}
#[test]
fn tick_strategy_stats_returns_empty_without_strategy() {
let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
prog.tick_strategy = None;
assert!(prog.tick_strategy_stats().is_empty());
}
#[test]
fn tick_strategy_stats_returns_strategy_fields() {
let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
let stats = prog.tick_strategy_stats();
assert!(
!stats.is_empty(),
"stats should not be empty when strategy is configured"
);
}
}