#![forbid(unsafe_code)]
//! Bubbletea/Elm-style runtime for terminal applications.
//!
//! The program runtime manages the update/view loop, handling events and
//! rendering frames. It separates state (Model) from rendering (View) and
//! provides a command pattern for side effects.
//!
//! # Example
//!
//! ```ignore
//! use ftui_runtime::program::{Model, Cmd};
//! use ftui_core::event::Event;
//! use ftui_render::frame::Frame;
//!
//! struct Counter {
//! count: i32,
//! }
//!
//! enum Msg {
//! Increment,
//! Decrement,
//! Quit,
//! }
//!
//! impl From<Event> for Msg {
//! fn from(event: Event) -> Self {
//! match event {
//! Event::Key(k) if k.is_char('q') => Msg::Quit,
//! Event::Key(k) if k.is_char('+') => Msg::Increment,
//! Event::Key(k) if k.is_char('-') => Msg::Decrement,
//! _ => Msg::Increment, // Default
//! }
//! }
//! }
//!
//! impl Model for Counter {
//! type Message = Msg;
//!
//! fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
//! match msg {
//! Msg::Increment => { self.count += 1; Cmd::none() }
//! Msg::Decrement => { self.count -= 1; Cmd::none() }
//! Msg::Quit => Cmd::quit(),
//! }
//! }
//!
//! fn view(&self, frame: &mut Frame) {
//! // Render counter value to frame
//! }
//! }
//! ```
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::{
BudgetControllerConfig, BudgetDecision, BudgetDecisionReason, 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;
/// Check for pending termination signal. Returns `None` when crossterm is not
/// enabled (headless / wasm builds don't install signal handlers).
#[inline]
fn check_termination_signal() -> Option<i32> {
ftui_core::shutdown_signal::pending_termination_signal()
}
/// Clear the 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};
/// The Model trait defines application state and behavior.
///
/// Implementations define how the application responds to events
/// and renders its current state.
pub trait Model: Sized {
/// The message type for this model.
///
/// Messages represent actions that update the model state.
/// Must be convertible from terminal events.
type Message: From<Event> + Send + 'static;
/// Initialize the model with startup commands.
///
/// Called once when the program starts. Return commands to execute
/// initial side effects like loading data.
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
/// Update the model in response to a message.
///
/// This is the core state transition function. Returns commands
/// for any side effects that should be executed.
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
/// Render the current state to a frame.
///
/// Called after updates when the UI needs to be redrawn.
fn view(&self, frame: &mut Frame);
/// Declare active subscriptions.
///
/// Called after each `update()`. The runtime compares the returned set
/// (by `SubId`) against currently running subscriptions and starts/stops
/// as needed. Returning an empty vec stops all subscriptions.
///
/// # Default
///
/// Returns an empty vec (no subscriptions).
fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
vec![]
}
/// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
/// for per-screen tick control.
///
/// Override this to return `Some(self)` in multi-screen Models. The runtime
/// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
/// for each inactive screen instead of ticking monolithically.
///
/// Default: `None` (all screens tick every frame, backwards-compatible).
fn as_screen_tick_dispatch(
&mut self,
) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
None
}
/// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
///
/// Return cleanup commands (e.g., saving state, closing connections).
/// The runtime executes these before teardown.
///
/// # Migration rationale
///
/// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
/// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
fn on_shutdown(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
/// Called when an unrecoverable error occurs during the runtime loop.
///
/// Return commands for error recovery or graceful degradation. The
/// `error` string contains the error description.
///
/// # Migration rationale
///
/// Source frameworks use `componentDidCatch`, error boundaries, or
/// `onError` hooks. This provides an equivalent error recovery point.
fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
Cmd::none()
}
}
/// Default weight assigned to background tasks.
const DEFAULT_TASK_WEIGHT: f64 = 1.0;
/// Default estimated task cost (ms) used for scheduling.
const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
/// Scheduling metadata for background tasks.
#[derive(Debug, Clone)]
pub struct TaskSpec {
/// Task weight (importance). Higher = more priority.
pub weight: f64,
/// Estimated task cost in milliseconds.
pub estimate_ms: f64,
/// Optional task name for evidence logging.
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 {
/// Create a task spec with an explicit weight and estimate.
#[must_use]
pub fn new(weight: f64, estimate_ms: f64) -> Self {
Self {
weight,
estimate_ms,
name: None,
}
}
/// Attach a task name for diagnostics.
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
/// Per-frame timing data for profiling.
#[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)
}
/// Sink for frame timing events.
pub trait FrameTimingSink: Send + Sync {
fn record_frame(&self, timing: &FrameTiming);
}
/// Configuration for frame timing capture.
#[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()
}
}
/// Commands represent side effects to be executed by the runtime.
///
/// Commands are returned from `init()` and `update()` to trigger
/// actions like quitting, sending messages, or scheduling ticks.
#[derive(Default)]
pub enum Cmd<M> {
/// No operation.
#[default]
None,
/// Quit the application.
Quit,
/// Execute multiple commands as a batch (currently sequential).
Batch(Vec<Cmd<M>>),
/// Execute commands sequentially.
Sequence(Vec<Cmd<M>>),
/// Send a message to the model.
Msg(M),
/// Schedule a tick after a duration.
Tick(Duration),
/// Write a log message to the terminal output.
///
/// This writes to the scrollback region in inline mode, or is ignored/handled
/// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
Log(String),
/// Execute a blocking operation on a background thread.
///
/// When effect queue scheduling is enabled, tasks are enqueued and executed
/// in Smith-rule order on a dedicated worker thread. Otherwise the closure
/// runs on a spawned thread immediately. The return value is sent back
/// as a message to the model.
Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
/// Save widget state to the persistence registry.
///
/// Triggers a flush of the state registry to the storage backend.
/// No-op if persistence is not configured.
SaveState,
/// Restore widget state from the persistence registry.
///
/// Triggers a load from the storage backend and updates the cache.
/// No-op if persistence is not configured. Returns a message via
/// callback if state was successfully restored.
RestoreState,
/// Toggle mouse capture at runtime.
///
/// Instructs the terminal session to enable or disable mouse event capture.
/// No-op in test simulators.
SetMouseCapture(bool),
/// Replace the tick strategy at runtime.
///
/// Takes ownership of a boxed strategy. Use when switching from one
/// strategy to another (e.g., `Uniform` → `Predictive` after loading
/// persisted transition data).
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> {
/// Create a no-op command.
#[inline]
pub fn none() -> Self {
Self::None
}
/// Create a quit command.
#[inline]
pub fn quit() -> Self {
Self::Quit
}
/// Create a message command.
#[inline]
pub fn msg(m: M) -> Self {
Self::Msg(m)
}
/// Create a log command.
///
/// The message will be sanitized and written to the terminal log (scrollback).
/// A newline is appended if not present.
#[inline]
pub fn log(msg: impl Into<String>) -> Self {
Self::Log(msg.into())
}
/// Create a batch of commands.
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)
}
}
/// Create a sequence of commands.
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)
}
}
/// Return a stable name for telemetry and tracing.
#[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",
}
}
/// Create a tick command.
#[inline]
pub fn tick(duration: Duration) -> Self {
Self::Tick(duration)
}
/// Create a background task command.
///
/// The closure runs on a spawned thread (or the effect queue worker when
/// scheduling is enabled). When it completes, the returned message is
/// sent back to the model's `update()`.
pub fn task<F>(f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(TaskSpec::default(), Box::new(f))
}
/// Create a background task command with explicit scheduling metadata.
pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
where
F: FnOnce() -> M + Send + 'static,
{
Self::Task(spec, Box::new(f))
}
/// Create a background task command with explicit weight and estimate.
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))
}
/// Create a named background task command.
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))
}
/// Replace the active tick strategy at runtime.
///
/// Use when switching strategies (e.g., `Uniform` → `Predictive` after
/// loading persisted transition data).
pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
Self::SetTickStrategy(Box::new(strategy))
}
/// Create a save state command.
///
/// Triggers a flush of the state registry to the storage backend.
/// No-op if persistence is not configured.
#[inline]
pub fn save_state() -> Self {
Self::SaveState
}
/// Create a restore state command.
///
/// Triggers a load from the storage backend.
/// No-op if persistence is not configured.
#[inline]
pub fn restore_state() -> Self {
Self::RestoreState
}
/// Create a mouse capture toggle command.
///
/// Instructs the runtime to enable or disable mouse event capture on the
/// underlying terminal session.
#[inline]
pub fn set_mouse_capture(enabled: bool) -> Self {
Self::SetMouseCapture(enabled)
}
/// Count the number of atomic commands in this command.
///
/// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
pub fn count(&self) -> usize {
match self {
Self::None => 0,
Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
_ => 1,
}
}
}
/// Resize handling behavior for the runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeBehavior {
/// Apply resize immediately (no debounce, no placeholder).
Immediate,
/// Coalesce resize events for continuous reflow.
Throttled,
}
impl ResizeBehavior {
const fn uses_coalescer(self) -> bool {
matches!(self, ResizeBehavior::Throttled)
}
}
/// Policy controlling when terminal mouse capture is enabled.
///
/// Mouse capture can steal normal scrollback interaction in inline mode.
/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
/// alt-screen mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MouseCapturePolicy {
/// Enable in alt-screen mode, disable in inline modes.
#[default]
Auto,
/// Always enable mouse capture.
On,
/// Always disable mouse capture.
Off,
}
impl MouseCapturePolicy {
/// Resolve the policy to a concrete mouse-capture toggle.
#[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;
/// One splitter handle region in terminal cell-space.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTerminalSplitterHandle {
/// Semantic resize target represented by this handle.
pub target: PaneResizeTarget,
/// Cell-space hit rectangle for this handle.
pub rect: Rect,
/// Split boundary coordinate used for deterministic nearest-target ranking.
pub boundary: i32,
}
/// Build deterministic splitter handle regions for terminal hit-testing.
///
/// Handles are emitted in split-id order and are clamped to the split rect.
#[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 => {
// Horizontal split => left/right panes => vertical splitter line.
if second_rect.x == split_rect.x {
first_rect.right()
} else {
second_rect.x
}
}
SplitAxis::Vertical => {
// Vertical split => top/bottom panes => horizontal splitter line.
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
}
/// Resolve a semantic splitter target from a terminal cell position.
///
/// If multiple handles overlap, chooses deterministically by:
/// 1) smallest distance to the splitter boundary, then
/// 2) smaller split_id, then
/// 3) horizontal axis before vertical axis.
#[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)
}
/// Register pane splitter handles into the frame hit-grid.
///
/// Each handle is registered as `HitRegion::Handle` with encoded target data.
/// Returns number of successfully-registered regions.
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
}
/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
#[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 })
}
// ============================================================================
// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
// ============================================================================
/// Which multiplexer environment the terminal is running inside.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PaneMuxEnvironment {
/// No multiplexer detected — direct terminal access.
None,
/// tmux (TMUX env var set, or DA2 terminal type 84).
Tmux,
/// GNU Screen (STY env var set, or DA2 terminal type 83).
Screen,
/// Zellij (ZELLIJ env var set).
Zellij,
/// WezTerm mux-served pane/session.
WeztermMux,
}
/// Resolved capability matrix describing which pane interaction features
/// are available in the current terminal + multiplexer environment.
///
/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
/// The adapter uses this to decide which code-paths are safe and which
/// need deterministic fallbacks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneCapabilityMatrix {
/// Detected multiplexer environment.
pub mux: PaneMuxEnvironment,
// --- Mouse input capabilities ---
/// SGR (1006) extended mouse protocol available.
/// Without this, mouse coordinates are limited to 223 columns/rows.
pub mouse_sgr: bool,
/// Mouse drag events are reliably delivered.
/// False in some screen versions where drag tracking is incomplete.
pub mouse_drag_reliable: bool,
/// Mouse button events include correct button identity on release.
/// X10/normal mode sends button 3 for all releases; SGR preserves it.
pub mouse_button_discrimination: bool,
// --- Focus / lifecycle ---
/// Terminal delivers CSI I / CSI O focus events.
pub focus_events: bool,
/// Bracketed paste mode available (affects interaction cancel heuristics).
pub bracketed_paste: bool,
// --- Rendering affordances ---
/// Unicode box-drawing glyphs available for splitter rendering.
pub unicode_box_drawing: bool,
/// True-color support for splitter highlight/drag feedback.
pub true_color: bool,
// --- Fallback summary ---
/// One or more pane features are degraded due to environment constraints.
pub degraded: bool,
}
/// Human-readable description of a known limitation and its fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCapabilityLimitation {
/// Short identifier (e.g. `"mouse_drag_unreliable"`).
pub id: &'static str,
/// What the limitation is.
pub description: &'static str,
/// What the adapter does instead.
pub fallback: &'static str,
}
impl PaneCapabilityMatrix {
/// Derive the pane capability matrix from terminal capabilities.
///
/// This is the single source of truth for which pane features are
/// available. All fallback decisions flow from this matrix.
#[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;
// GNU Screen has historically unreliable drag event delivery.
// tmux and zellij forward drags correctly in modern versions.
let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
// Button discrimination requires SGR mouse protocol.
// Without it, X10/normal mode reports button 3 for all releases.
let mouse_button_discrimination = mouse_sgr;
// Focus events are conservatively disabled in any mux context.
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,
}
}
/// Whether pane drag interactions should be enabled at all.
///
/// Drag requires at minimum mouse event support. If drag events
/// are unreliable (e.g. GNU Screen), drag is disabled and the
/// adapter falls back to keyboard-only resize.
#[must_use]
pub const fn drag_enabled(&self) -> bool {
self.mouse_drag_reliable
}
/// Whether focus-loss auto-cancel is effective.
///
/// When focus events are unavailable, the adapter cannot detect
/// window blur — interactions must rely on timeout or explicit
/// keyboard cancel instead.
#[must_use]
pub const fn focus_cancel_effective(&self) -> bool {
self.focus_events
}
/// Collect all active limitations with their fallback descriptions.
#[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
}
}
/// Configuration for terminal-to-pane semantic input translation.
///
/// This adapter normalizes terminal `Event` streams into
/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaneTerminalAdapterConfig {
/// Drag start threshold in pane-local units.
pub drag_threshold: u16,
/// Drag update hysteresis threshold in pane-local units.
pub update_hysteresis: u16,
/// Mouse button required to begin a drag sequence.
pub activation_button: PanePointerButton,
/// Minimum drag delta (Manhattan distance, cells) before forwarding
/// updates while already in the dragging state.
pub drag_update_coalesce_distance: u16,
/// Cancel active interactions on focus loss.
pub cancel_on_focus_lost: bool,
/// Cancel active interactions on terminal resize.
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,
}
/// Lifecycle phase observed while translating a terminal event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalLifecyclePhase {
MouseDown,
MouseDrag,
MouseMove,
MouseUp,
MouseScroll,
KeyResize,
KeyCancel,
FocusLoss,
ResizeInterrupt,
Other,
}
/// Deterministic reason a terminal event did not map to pane semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalIgnoredReason {
MissingTarget,
NoActivePointer,
PointerButtonMismatch,
ActivationButtonRequired,
WindowNotFocused,
UnsupportedKey,
FocusGainNoop,
ResizeNoop,
DragCoalesced,
NonSemanticEvent,
MachineRejectedEvent,
}
/// Translation outcome for one raw terminal event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneTerminalLogOutcome {
SemanticForwarded,
SemanticForwardedAfterRecovery,
Ignored(PaneTerminalIgnoredReason),
}
/// Structured translation log entry for one raw terminal event.
#[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,
}
/// Output of one terminal event translation step.
///
/// `recovery_*` fields are populated when the adapter first emits an internal
/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
/// event as a fresh semantic event.
#[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,
},
}
}
/// Derive dynamic snap profile from translated pointer motion.
#[must_use]
pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
self.motion.map(PanePressureSnapProfile::from_motion)
}
}
/// Deterministic terminal adapter mapping raw `Event` values into
/// schema-validated pane semantic interaction events.
#[derive(Debug, Clone)]
pub struct PaneTerminalAdapter {
machine: PaneDragResizeMachine,
config: PaneTerminalAdapterConfig,
active: Option<PaneTerminalActivePointer>,
window_focused: bool,
next_sequence: u64,
}
impl PaneTerminalAdapter {
/// Construct a new adapter with validated drag thresholds.
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,
})
}
/// Adapter configuration.
#[must_use]
pub const fn config(&self) -> PaneTerminalAdapterConfig {
self.config
}
/// Active pointer id currently tracked by the adapter, if any.
#[must_use]
pub fn active_pointer_id(&self) -> Option<u32> {
self.active.map(|active| active.pointer_id)
}
/// Whether the host window is currently focused.
#[must_use]
pub const fn window_focused(&self) -> bool {
self.window_focused
}
/// Current pane drag/resize machine state.
#[must_use]
pub const fn machine_state(&self) -> PaneDragResizeState {
self.machine.state()
}
/// Translate one raw terminal event into pane semantic event(s).
///
/// `target_hint` is provided by host hit-testing (upcoming pane-terminal
/// tasks). Pointer drag/move/up reuse active target continuity once armed.
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,
),
}
}
/// Translate one raw terminal event while resolving splitter targets from
/// terminal hit regions.
///
/// This is a convenience wrapper for host code that already has splitter
/// handle regions from [`pane_terminal_splitter_handles`].
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)
}
/// Force-cancel any active pane interaction and return diagnostic info.
///
/// This is the safety-valve for cleanup paths (RAII guard drops, signal
/// handlers, panic hooks) where constructing a proper semantic event is
/// not feasible. It resets both the underlying drag/resize state machine
/// and the adapter's active-pointer tracking.
///
/// Returns `None` if no interaction was active.
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,
})
}
}
/// Structured diagnostics emitted when pane interaction state is force-cleaned.
///
/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
/// so callers can convert this struct to JSON for evidence logging.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneCleanupDiagnostics {
/// Whether the adapter had an active pointer tracker when cleanup ran.
pub had_active_pointer: bool,
/// The pointer ID that was active (if any).
pub active_pointer_id: Option<u32>,
/// The machine state before force-cancel was applied.
pub machine_state_before: PaneDragResizeState,
/// The transition produced by force-cancel, or `None` if the machine
/// was already idle.
pub machine_transition: Option<PaneDragResizeTransition>,
}
/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
///
/// When a pane interaction session is active and the guard drops (due to
/// panic, scope exit, or any other unwind), it force-cancels any in-progress
/// drag/resize and collects cleanup diagnostics.
///
/// # Usage
///
/// ```ignore
/// let guard = PaneInteractionGuard::new(&mut adapter);
/// // ... pane interaction event loop ...
/// // If this scope panics, guard's Drop will force-cancel the drag machine
/// let diagnostics = guard.finish(); // explicit clean finish
/// ```
pub struct PaneInteractionGuard<'a> {
adapter: &'a mut PaneTerminalAdapter,
finished: bool,
diagnostics: Option<PaneCleanupDiagnostics>,
}
impl<'a> PaneInteractionGuard<'a> {
/// Create a new guard wrapping the given adapter.
pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
Self {
adapter,
finished: false,
diagnostics: None,
}
}
/// Access the wrapped adapter for normal event translation.
pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
self.adapter
}
/// Explicitly finish the guard, returning any cleanup diagnostics.
///
/// Calling `finish()` is optional — the guard will also clean up on drop.
/// However, `finish()` gives the caller access to the diagnostics.
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
}
}
/// Configuration for state persistence in the program runtime.
///
/// Controls when and how widget state is saved/restored.
#[derive(Clone)]
pub struct PersistenceConfig {
/// State registry for persistence. If None, persistence is disabled.
pub registry: Option<std::sync::Arc<StateRegistry>>,
/// Interval for periodic checkpoint saves. None disables checkpoints.
pub checkpoint_interval: Option<Duration>,
/// Automatically load state on program start.
pub auto_load: bool,
/// Automatically save state on program exit.
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 {
/// Create a disabled persistence config.
#[must_use]
pub fn disabled() -> Self {
Self::default()
}
/// Create a persistence config with the given registry.
#[must_use]
pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
Self {
registry: Some(registry),
..Default::default()
}
}
/// Set the checkpoint interval.
#[must_use]
pub fn checkpoint_every(mut self, interval: Duration) -> Self {
self.checkpoint_interval = Some(interval);
self
}
/// Enable or disable auto-load on start.
#[must_use]
pub fn auto_load(mut self, enabled: bool) -> Self {
self.auto_load = enabled;
self
}
/// Enable or disable auto-save on exit.
#[must_use]
pub fn auto_save(mut self, enabled: bool) -> Self {
self.auto_save = enabled;
self
}
}
/// Configuration for widget refresh selection under render budget.
///
/// Defaults are conservative and deterministic:
/// - enabled: true
/// - staleness_window_ms: 1_000
/// - starve_ms: 3_000
/// - max_starved_per_frame: 2
/// - max_drop_fraction: 1.0 (disabled)
/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
/// - starve_boost: 1.5
/// - min_cost_us: 1.0
#[derive(Debug, Clone)]
pub struct WidgetRefreshConfig {
/// Enable budgeted widget refresh selection.
pub enabled: bool,
/// Staleness decay window (ms) used to normalize staleness scores.
pub staleness_window_ms: u64,
/// Staleness threshold that triggers starvation guard (ms).
pub starve_ms: u64,
/// Maximum number of starved widgets to force in per frame.
pub max_starved_per_frame: usize,
/// Maximum fraction of non-essential widgets that may be dropped.
/// Set to 1.0 to disable the guardrail.
pub max_drop_fraction: f32,
/// Weight for base priority signal.
pub weight_priority: f32,
/// Weight for staleness signal.
pub weight_staleness: f32,
/// Weight for focus boost.
pub weight_focus: f32,
/// Weight for interaction boost.
pub weight_interaction: f32,
/// Additive boost to value for starved widgets.
pub starve_boost: f32,
/// Minimum cost (us) to avoid divide-by-zero.
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,
}
}
}
/// Configuration for effect queue scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TaskExecutorBackend {
/// Spawn one native thread per task and reap finished handles on the main loop.
#[default]
Spawned,
/// Route tasks through the runtime's queueing scheduler.
EffectQueue,
/// Route blocking task closures through an Asupersync blocking pool.
#[cfg(feature = "asupersync-executor")]
Asupersync,
}
#[derive(Debug, Clone)]
pub struct EffectQueueConfig {
/// Whether effect queue scheduling is enabled.
///
/// This legacy convenience flag is kept in sync with `backend`. New code
/// should prefer `backend` for executor selection.
pub enabled: bool,
/// Which task executor backend to use for `Cmd::Task`.
pub backend: TaskExecutorBackend,
/// Scheduler configuration (Smith's rule by default).
pub scheduler: SchedulerConfig,
/// Maximum queue depth before backpressure kicks in (bd-2zd0a).
///
/// When the queue depth exceeds this limit, new tasks are dropped with
/// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
/// A value of `0` means unbounded (no backpressure).
pub max_queue_depth: usize,
/// Whether the backend selection was set explicitly by the caller.
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 {
/// Enable effect queue scheduling with the provided scheduler config.
#[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
}
/// Select the task executor backend for `Cmd::Task`.
#[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
}
/// Override the scheduler configuration.
#[must_use]
pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
self.scheduler = scheduler;
self
}
/// Set the maximum queue depth for backpressure (bd-2zd0a).
///
/// When the queue depth exceeds this limit, new tasks are dropped.
/// A value of `0` means unbounded (no backpressure, the default).
#[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
}
}
/// Immediate event-drain policy for the runtime main loop.
///
/// When a poll reports readiness, the runtime drains events by repeatedly
/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
/// inputs. This policy bounds that immediate-drain path so bursty workloads do
/// not devolve into zero-timeout spin storms.
#[derive(Debug, Clone)]
pub struct ImmediateDrainConfig {
/// Maximum consecutive zero-timeout polls allowed in a single burst window.
pub max_zero_timeout_polls_per_burst: usize,
/// Maximum wall-clock time spent in a single immediate-drain burst window.
pub max_burst_duration: Duration,
/// Non-zero poll timeout used when the burst window budget is exhausted.
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),
}
}
}
/// Runtime counters for immediate-drain behavior.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ImmediateDrainStats {
/// Number of event-drain bursts observed.
pub bursts: u64,
/// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
pub zero_timeout_polls: u64,
/// Total non-zero backoff polls executed after exhausting burst budget.
pub backoff_polls: u64,
/// Number of bursts that hit the configured immediate-drain cap.
pub capped_bursts: u64,
/// Max number of zero-timeout polls seen in a single burst window.
pub max_zero_timeout_polls_in_burst: u64,
}
/// User-visible runtime mode reported by the conservative load governor.
///
/// These modes mirror the `bd-8vstx` runtime contract:
///
/// - `Healthy`: admit all work normally; no explicit fallback is active.
/// - `Stressed`: strict work is preserved while visible/coalescible or
/// background work may be slowed before user-visible ambiguity appears.
/// - `Degraded`: bounded fallback is active; strict interactive semantics
/// still hold while background/best-effort work is deferred or dropped.
/// - `Recovered`: a degraded interval has closed after the configured
/// hysteresis window; the next steady interval returns to `Healthy`.
/// - `Unsafe`: a strict semantic guarantee cannot be preserved, so optimistic
/// degradation must stop and the caller must surface explicit failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeLoadMode {
/// No sustained pressure and no active fallback.
Healthy,
/// Elevated pressure while strict guarantees still hold.
Stressed,
/// Explicit bounded fallback is active.
Degraded,
/// Recovery interval has been observed and reported.
Recovered,
/// A strict semantic guarantee was violated.
Unsafe,
}
impl RuntimeLoadMode {
/// Stable string for evidence logs.
#[inline]
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::Stressed => "stressed",
Self::Degraded => "degraded",
Self::Recovered => "recovered",
Self::Unsafe => "unsafe",
}
}
}
/// Pressure envelope inferred from measured runtime signals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimePressureClass {
/// Within steady-state envelope.
SteadyState,
/// Early overload band; bounded coalescing/deferment may begin.
SoftOverload,
/// Degraded band; explicit fallback is active.
HardOverload,
/// Terminal strict-semantics failure.
Unsafe,
}
impl RuntimePressureClass {
/// Stable string for evidence logs.
#[inline]
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::SteadyState => "steady_state",
Self::SoftOverload => "soft_overload",
Self::HardOverload => "hard_overload",
Self::Unsafe => "unsafe",
}
}
}
/// Work disposition allowed by the current runtime mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeWorkDisposition {
/// Admit all work normally.
AdmitAll,
/// Preserve strict work; coalesce visible work and slow background work.
CoalesceVisibleDeferBackground,
/// Preserve strict work; defer background work and drop best-effort work.
DeferBackgroundDropBestEffort,
/// Re-admit deferred work after the hysteresis window closed.
ReadmitAfterHysteresis,
/// Stop optimistic degradation because a strict guarantee failed.
FailFastStrictGuarantee,
}
impl RuntimeWorkDisposition {
/// Stable string for evidence logs.
#[inline]
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AdmitAll => "admit_all",
Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
}
}
}
/// Conservative policy for classifying runtime load.
///
/// The first runtime governor intentionally uses one primary control family:
/// render-budget degradation and explicit coalescing/admission evidence. Queue
/// watermarks are advisory inputs when a queue cap exists; when the queue is
/// uncapped, frame-budget and coalescer signals drive fallback classification.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LoadGovernorPolicy {
/// Queue occupancy ratio that enters `stressed`.
pub stressed_queue_watermark: f64,
/// Queue occupancy ratio that enters `degraded`.
pub degraded_queue_watermark: f64,
/// Queue occupancy ratio required before closing a degraded interval.
pub recovery_queue_watermark: f64,
/// Consecutive steady intervals required before reporting recovery.
pub recovery_intervals: u8,
/// Frame-time ratio over budget that enters `stressed` when uncapped.
pub budget_overrun_soft_ratio: f64,
}
impl Default for LoadGovernorPolicy {
fn default() -> Self {
Self {
stressed_queue_watermark: 0.5,
degraded_queue_watermark: 0.8,
recovery_queue_watermark: 0.25,
recovery_intervals: 3,
budget_overrun_soft_ratio: 1.0,
}
}
}
impl LoadGovernorPolicy {
#[must_use]
fn normalized(self) -> Self {
let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
Self {
recovery_queue_watermark: recovery,
stressed_queue_watermark: stressed,
degraded_queue_watermark: degraded,
recovery_intervals: self.recovery_intervals.max(1),
budget_overrun_soft_ratio: normalize_positive_ratio(
self.budget_overrun_soft_ratio,
1.0,
),
}
}
}
#[inline]
fn normalize_ratio(value: f64, fallback: f64) -> f64 {
if value.is_finite() {
value.clamp(0.0, 1.0)
} else {
fallback
}
}
#[inline]
fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value
} else {
fallback
}
}
/// Conservative runtime load-governor configuration.
///
/// The first runtime governor keeps one primary control objective:
/// responsiveness under render pressure. It drives adaptive render degradation
/// through `RenderBudget`, classifies runtime mode with queue/coalescer inputs,
/// and emits replayable evidence for the allowed work disposition. Disabling
/// this config falls back to the legacy threshold path.
#[derive(Debug, Clone, PartialEq)]
pub struct LoadGovernorConfig {
/// Whether the adaptive governor is active.
pub enabled: bool,
/// Controller used to decide degrade/upgrade transitions from frame timing.
pub budget_controller: BudgetControllerConfig,
/// Runtime-mode and work-disposition classification policy.
pub policy: LoadGovernorPolicy,
}
impl Default for LoadGovernorConfig {
fn default() -> Self {
Self {
enabled: true,
budget_controller: BudgetControllerConfig::default(),
policy: LoadGovernorPolicy::default(),
}
}
}
impl LoadGovernorConfig {
/// Create an enabled governor with conservative defaults.
#[must_use]
pub fn enabled() -> Self {
Self::default()
}
/// Disable the governor and use the legacy render-budget threshold path.
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
budget_controller: BudgetControllerConfig::default(),
policy: LoadGovernorPolicy::default(),
}
}
/// Toggle governor activation.
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Replace the adaptive budget controller configuration.
#[must_use]
pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
self.budget_controller = config;
self
}
/// Replace the runtime-mode classification policy.
#[must_use]
pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
self.policy = policy.normalized();
self
}
}
#[derive(Debug, Clone, Copy)]
struct LoadGovernorObservation {
frame_time_us: f64,
budget_us: f64,
degradation: DegradationLevel,
queue: crate::effect_system::QueueTelemetry,
resize_coalescing_active: bool,
strict_semantics_violation: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LoadGovernorSnapshot {
mode: RuntimeLoadMode,
mode_before: RuntimeLoadMode,
pressure_class: RuntimePressureClass,
disposition: RuntimeWorkDisposition,
reason_code: &'static str,
transition: bool,
strict_semantics_preserved: bool,
queue_in_flight: u64,
queue_max_depth: Option<usize>,
queue_dropped_delta: u64,
resize_coalescing_active: bool,
recovery_intervals_observed: u8,
recovery_intervals_required: u8,
deferred_work_total: u64,
coalesced_work_total: u64,
dropped_work_total: u64,
}
#[derive(Debug, Clone)]
struct LoadGovernorState {
enabled: bool,
policy: LoadGovernorPolicy,
max_queue_depth: usize,
mode: RuntimeLoadMode,
recovery_intervals_observed: u8,
last_queue_dropped: u64,
queue_baseline_initialized: bool,
deferred_work_total: u64,
coalesced_work_total: u64,
dropped_work_total: u64,
}
impl LoadGovernorState {
fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
Self {
enabled: config.enabled,
policy: config.policy.normalized(),
max_queue_depth,
mode: RuntimeLoadMode::Healthy,
recovery_intervals_observed: 0,
last_queue_dropped: 0,
queue_baseline_initialized: false,
deferred_work_total: 0,
coalesced_work_total: 0,
dropped_work_total: 0,
}
}
fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
if !self.enabled {
return self.snapshot(
RuntimeLoadMode::Healthy,
RuntimeLoadMode::Healthy,
RuntimePressureClass::SteadyState,
RuntimeWorkDisposition::AdmitAll,
"governor_disabled",
false,
true,
observation,
0,
);
}
let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
let pressure = self.classify_pressure(observation, dropped_delta);
let mode_before = self.mode;
let reason_code = self.reason_code(observation, pressure, dropped_delta);
match pressure {
RuntimePressureClass::Unsafe => {
self.mode = RuntimeLoadMode::Unsafe;
self.recovery_intervals_observed = 0;
}
// Strict-semantics failure is terminal: once Unsafe is entered the
// governor latches there regardless of later pressure (this mirrors
// the `Unsafe => {}` arm in `observe_steady_interval`, which already
// refuses to recover Unsafe under steady load). Without this guard,
// a subsequent overload interval would downgrade Unsafe to
// Degraded/Stressed and then recover to Healthy, silently escaping
// the fail-fast guarantee. Only an explicit reset clears Unsafe.
_ if self.mode == RuntimeLoadMode::Unsafe => {
self.recovery_intervals_observed = 0;
}
RuntimePressureClass::HardOverload => {
self.mode = RuntimeLoadMode::Degraded;
self.recovery_intervals_observed = 0;
}
RuntimePressureClass::SoftOverload => {
if self.mode != RuntimeLoadMode::Degraded {
self.mode = RuntimeLoadMode::Stressed;
}
self.recovery_intervals_observed = 0;
}
RuntimePressureClass::SteadyState => self.observe_steady_interval(),
}
self.record_work_disposition(observation, dropped_delta);
let disposition = Self::disposition_for_mode(self.mode);
self.snapshot(
self.mode,
mode_before,
pressure,
disposition,
if self.mode == RuntimeLoadMode::Unsafe {
// Whether freshly violated this interval or latched from a prior
// one, the terminal cause is always the strict-semantics failure.
"strict_semantics_violation"
} else if self.mode == RuntimeLoadMode::Recovered {
"recovery_hysteresis_satisfied"
} else if mode_before == RuntimeLoadMode::Recovered
&& self.mode == RuntimeLoadMode::Healthy
{
"recovered_interval_closed"
} else {
reason_code
},
mode_before != self.mode,
// Reflect the latched mode, not the instantaneous pressure: once the
// governor is Unsafe, strict semantics stay unpreserved even on an
// interval whose raw pressure classified lower.
self.mode != RuntimeLoadMode::Unsafe,
observation,
dropped_delta,
)
}
fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
if !self.queue_baseline_initialized {
self.queue_baseline_initialized = true;
self.last_queue_dropped = dropped_total;
return 0;
}
let delta = dropped_total.saturating_sub(self.last_queue_dropped);
self.last_queue_dropped = dropped_total;
delta
}
fn classify_pressure(
&self,
observation: LoadGovernorObservation,
dropped_delta: u64,
) -> RuntimePressureClass {
if observation.strict_semantics_violation {
return RuntimePressureClass::Unsafe;
}
if dropped_delta > 0
|| self
.queue_ratio(observation.queue.in_flight)
.is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
|| observation.degradation > DegradationLevel::Full
{
return RuntimePressureClass::HardOverload;
}
if self
.queue_ratio(observation.queue.in_flight)
.is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
|| observation.resize_coalescing_active
|| observation.frame_time_us
> observation.budget_us * self.policy.budget_overrun_soft_ratio
{
return RuntimePressureClass::SoftOverload;
}
RuntimePressureClass::SteadyState
}
fn observe_steady_interval(&mut self) {
match self.mode {
RuntimeLoadMode::Degraded => {
self.recovery_intervals_observed = self
.recovery_intervals_observed
.saturating_add(1)
.min(self.policy.recovery_intervals);
if self.recovery_intervals_observed >= self.policy.recovery_intervals {
self.mode = RuntimeLoadMode::Recovered;
}
}
RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
self.mode = RuntimeLoadMode::Healthy;
self.recovery_intervals_observed = 0;
}
RuntimeLoadMode::Healthy => {
self.recovery_intervals_observed = 0;
}
RuntimeLoadMode::Unsafe => {}
}
}
fn record_work_disposition(
&mut self,
observation: LoadGovernorObservation,
dropped_delta: u64,
) {
if dropped_delta > 0 {
self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
}
match self.mode {
RuntimeLoadMode::Stressed => {
if observation.resize_coalescing_active {
self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
}
}
RuntimeLoadMode::Degraded => {
self.deferred_work_total = self.deferred_work_total.saturating_add(1);
if observation.resize_coalescing_active {
self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
}
}
RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
}
}
fn reason_code(
&self,
observation: LoadGovernorObservation,
pressure: RuntimePressureClass,
dropped_delta: u64,
) -> &'static str {
match pressure {
RuntimePressureClass::Unsafe => "strict_semantics_violation",
RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
RuntimePressureClass::HardOverload
if self
.queue_ratio(observation.queue.in_flight)
.is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
{
"queue_degraded_watermark"
}
RuntimePressureClass::HardOverload => "budget_degradation_active",
RuntimePressureClass::SoftOverload
if self
.queue_ratio(observation.queue.in_flight)
.is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
{
"queue_stressed_watermark"
}
RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
"resize_coalescing_active"
}
RuntimePressureClass::SoftOverload => "frame_budget_overrun",
RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
"recovery_hysteresis_pending"
}
RuntimePressureClass::SteadyState => "steady_state",
}
}
fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
(self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
}
const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
match mode {
RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
}
}
// Internal constructor that gathers all snapshot fields in one place; the
// wide signature is intentional (a builder would add indirection for a
// private helper). Pre-existing lint, unrelated to the #78 fix.
#[allow(clippy::too_many_arguments)]
fn snapshot(
&self,
mode: RuntimeLoadMode,
mode_before: RuntimeLoadMode,
pressure_class: RuntimePressureClass,
disposition: RuntimeWorkDisposition,
reason_code: &'static str,
transition: bool,
strict_semantics_preserved: bool,
observation: LoadGovernorObservation,
dropped_delta: u64,
) -> LoadGovernorSnapshot {
LoadGovernorSnapshot {
mode,
mode_before,
pressure_class,
disposition,
reason_code,
transition,
strict_semantics_preserved,
queue_in_flight: observation.queue.in_flight,
queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
queue_dropped_delta: dropped_delta,
resize_coalescing_active: observation.resize_coalescing_active,
recovery_intervals_observed: self.recovery_intervals_observed,
recovery_intervals_required: self.policy.recovery_intervals,
deferred_work_total: self.deferred_work_total,
coalesced_work_total: self.coalesced_work_total,
dropped_work_total: self.dropped_work_total,
}
}
}
/// Runtime lane for the Asupersync migration rollout.
///
/// Controls which subscription/effect execution backend is active.
/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
///
/// # Migration rollout
///
/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
/// 3. `Asupersync` — full Asupersync-native execution (future)
///
/// Selection is logged at startup so operators can tell which lane is active.
/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuntimeLane {
/// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
/// This is the safe default that preserves all existing semantics.
Legacy,
/// Structured cancellation: subscriptions use CancellationToken internally.
/// Externally observable behavior is identical to Legacy.
#[default]
Structured,
/// Full Asupersync-native execution (reserved for future use).
/// Falls back to Structured if Asupersync primitives are unavailable.
Asupersync,
}
impl RuntimeLane {
/// Resolve the effective lane, applying fallback rules.
///
/// If the requested lane is not yet implemented, falls back to the
/// highest available lane. Currently: Asupersync → Structured.
#[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,
}
}
/// Returns a human-readable label for logging.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Legacy => "legacy",
Self::Structured => "structured",
Self::Asupersync => "asupersync",
}
}
/// Check if this lane uses structured cancellation (CancellationToken).
#[must_use]
pub fn uses_structured_cancellation(self) -> bool {
matches!(self, Self::Structured | Self::Asupersync)
}
/// Resolve the default task executor backend for this lane.
///
/// # Input-lag regression fix (#78)
///
/// The `Structured` lane changes *subscription cancellation* semantics
/// (CancellationToken-backed stop signals in `subscription.rs`); it must
/// NOT also collapse `Cmd::Task` concurrency. Earlier this returned
/// `EffectQueue`, which routed every `Cmd::Task` through a single
/// `effect_queue_loop` worker thread with a Smith's-rule (SPT) scheduler,
/// serializing all tasks. Apps that forward PTY output via per-pane
/// `Cmd::task` polling loops (e.g. 10ms drains) then contended for one
/// serialized worker, adding per-keystroke head-of-line latency versus
/// v0.2.1, where each `Cmd::task` got its own `std::thread::spawn`.
///
/// Structured cancellation does not depend on the effect queue (stop
/// signals are token-backed regardless of executor backend), so the two
/// concerns are decoupled here: `Structured` keeps the structured
/// cancellation semantics but restores per-task-thread (`Spawned`)
/// execution. Apps that explicitly opt into `EffectQueue` still get it
/// (the lane default only applies when the app uses the legacy default
/// backend — see `EffectQueueConfig::uses_legacy_default_backend`).
#[must_use]
fn task_executor_backend(self) -> TaskExecutorBackend {
match self {
// Legacy and Structured both use per-task `Spawned` execution.
// (Structured only changes cancellation semantics, not concurrency
// — see the regression note above.) Kept as a combined arm so the
// `match_same_arms` lint stays happy under `-D warnings`.
Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
Self::Asupersync => {
#[cfg(feature = "asupersync-executor")]
{
TaskExecutorBackend::Asupersync
}
#[cfg(not(feature = "asupersync-executor"))]
{
TaskExecutorBackend::EffectQueue
}
}
}
}
/// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
///
/// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
/// Returns `None` if the variable is unset or contains an unrecognized value.
#[must_use]
pub fn from_env() -> Option<Self> {
let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
Self::parse(&val)
}
/// Parse a lane name (case-insensitive).
///
/// Returns `None` for unrecognized values.
#[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
}
}
}
}
/// Rollout policy for the Asupersync migration (bd-2crbt).
///
/// Controls how the runtime lane transition is managed:
///
/// - `Off` — use only the configured lane, no shadow comparison.
/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
/// but use only the baseline lane for actual rendering. Evidence is emitted
/// to the configured JSONL sink for operator review.
/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
/// evidence showing deterministic match).
///
/// The policy is logged at startup and can be overridden via the
/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RolloutPolicy {
/// No rollout activity — use the configured lane directly.
#[default]
Off,
/// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
Shadow,
/// Candidate lane is live — requires prior shadow evidence.
Enabled,
}
impl RolloutPolicy {
/// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
///
/// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
/// Returns `None` if unset or unrecognized.
#[must_use]
pub fn from_env() -> Option<Self> {
let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
Self::parse(&val)
}
/// Parse a rollout policy name (case-insensitive).
///
/// Returns `None` for unrecognized values.
#[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
}
}
}
/// Returns a human-readable label for logging.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Shadow => "shadow",
Self::Enabled => "enabled",
}
}
/// Whether this policy involves shadow comparison.
#[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())
}
}
/// Configuration for the program runtime.
#[derive(Debug, Clone)]
pub struct ProgramConfig {
/// Screen mode (inline or alternate screen).
pub screen_mode: ScreenMode,
/// UI anchor for inline mode.
pub ui_anchor: UiAnchor,
/// Frame budget configuration.
pub budget: FrameBudgetConfig,
/// Runtime load-governor configuration.
pub load_governor: LoadGovernorConfig,
/// Diff strategy configuration for the terminal writer.
pub diff_config: RuntimeDiffConfig,
/// Evidence JSONL sink configuration.
pub evidence_sink: EvidenceSinkConfig,
/// Render-trace recorder configuration.
pub render_trace: RenderTraceConfig,
/// Optional frame timing sink.
pub frame_timing: Option<FrameTimingConfig>,
/// Conformal predictor configuration for frame-time risk gating.
pub conformal_config: Option<ConformalConfig>,
/// Locale context used for rendering.
pub locale_context: LocaleContext,
/// Input poll timeout.
pub poll_timeout: Duration,
/// Immediate event-drain policy for burst handling.
pub immediate_drain: ImmediateDrainConfig,
/// Resize coalescer configuration.
pub resize_coalescer: CoalescerConfig,
/// Resize handling behavior (immediate/throttled).
pub resize_behavior: ResizeBehavior,
/// Forced terminal size override (when set, resize events are ignored).
pub forced_size: Option<(u16, u16)>,
/// Mouse capture policy (`Auto`, `On`, `Off`).
///
/// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
pub mouse_capture_policy: MouseCapturePolicy,
/// Enable bracketed paste.
pub bracketed_paste: bool,
/// Enable focus reporting.
pub focus_reporting: bool,
/// Enable Kitty keyboard protocol (repeat/release events).
pub kitty_keyboard: bool,
/// State persistence configuration.
pub persistence: PersistenceConfig,
/// Inline auto UI height remeasurement policy.
pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
/// Widget refresh selection configuration.
pub widget_refresh: WidgetRefreshConfig,
/// Effect queue scheduling configuration.
pub effect_queue: EffectQueueConfig,
/// Frame guardrails configuration (memory + queue safety limits).
pub guardrails: GuardrailsConfig,
/// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
///
/// Defaults to `true` for application safety. Set to `false` in tests or
/// when the embedding application manages signals.
pub intercept_signals: bool,
/// Optional tick strategy for selective background screen ticking.
///
/// When `None` (default), all screens tick every frame (current behavior).
/// When set, the runtime consults the strategy for each inactive screen.
pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
/// Runtime execution lane for the Asupersync migration rollout.
///
/// Controls which subscription/effect backend is active.
/// Defaults to `Structured` (CancellationToken-backed, current migration state).
/// Logged at startup so operators can identify the active lane.
pub runtime_lane: RuntimeLane,
/// Rollout policy for the Asupersync migration (bd-2crbt).
///
/// Controls whether shadow-run comparison is active during this session.
/// When `Shadow`, both the baseline and candidate lanes run in parallel
/// and evidence is emitted; rendering uses the baseline lane only.
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(),
load_governor: LoadGovernorConfig::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 {
/// Create config for fullscreen applications.
pub fn fullscreen() -> Self {
Self {
screen_mode: ScreenMode::AltScreen,
..Default::default()
}
}
/// Create config for inline mode with specified height.
pub fn inline(height: u16) -> Self {
Self {
screen_mode: ScreenMode::Inline { ui_height: height },
..Default::default()
}
}
/// Create config for inline mode with automatic UI height.
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()
}
}
/// Enable mouse support.
#[must_use]
pub fn with_mouse(mut self) -> Self {
self.mouse_capture_policy = MouseCapturePolicy::On;
self
}
/// Set mouse capture policy.
#[must_use]
pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
self.mouse_capture_policy = policy;
self
}
/// Force mouse capture enabled/disabled regardless of screen mode.
#[must_use]
pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
self.mouse_capture_policy = if enabled {
MouseCapturePolicy::On
} else {
MouseCapturePolicy::Off
};
self
}
/// Resolve mouse capture using the configured policy and screen mode.
#[must_use]
pub const fn resolved_mouse_capture(&self) -> bool {
self.mouse_capture_policy.resolve(self.screen_mode)
}
/// Set the budget configuration.
#[must_use]
pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
self.budget = budget;
self
}
/// Set the runtime load-governor configuration.
#[must_use]
pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
self.load_governor = config;
self
}
/// Disable the adaptive load governor and use legacy render-budget behavior.
#[must_use]
pub fn without_load_governor(mut self) -> Self {
self.load_governor = LoadGovernorConfig::disabled();
self
}
/// Set the diff strategy configuration for the terminal writer.
#[must_use]
pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
self.diff_config = diff_config;
self
}
/// Set the evidence JSONL sink configuration.
#[must_use]
pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
self.evidence_sink = config;
self
}
/// Set the render-trace recorder configuration.
#[must_use]
pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
self.render_trace = config;
self
}
/// Set a frame timing sink for per-frame profiling.
#[must_use]
pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
self.frame_timing = Some(config);
self
}
/// Enable conformal frame-time risk gating with the given config.
#[must_use]
pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
self.conformal_config = Some(config);
self
}
/// Disable conformal frame-time risk gating.
#[must_use]
pub fn without_conformal(mut self) -> Self {
self.conformal_config = None;
self
}
/// Set the locale context used for rendering.
#[must_use]
pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
self.locale_context = locale_context;
self
}
/// Set the base locale used for rendering.
#[must_use]
pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
self.locale_context = LocaleContext::new(locale);
self
}
/// Set the widget refresh selection configuration.
#[must_use]
pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
self.widget_refresh = config;
self
}
/// Set the effect queue scheduling configuration.
#[must_use]
pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
self.effect_queue = config;
self
}
/// Set the resize coalescer configuration.
#[must_use]
pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
self.resize_coalescer = config;
self
}
/// Set the resize handling behavior.
#[must_use]
pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
self.resize_behavior = behavior;
self
}
/// Force a fixed terminal size (cols, rows). Resize events are ignored.
#[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
}
/// Clear any forced terminal size override.
#[must_use]
pub fn without_forced_size(mut self) -> Self {
self.forced_size = None;
self
}
/// Toggle legacy immediate-resize behavior for migration.
#[must_use]
pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
if enabled {
self.resize_behavior = ResizeBehavior::Immediate;
}
self
}
/// Set the persistence configuration.
#[must_use]
pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
self.persistence = persistence;
self
}
/// Enable persistence with the given registry.
#[must_use]
pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
self.persistence = PersistenceConfig::with_registry(registry);
self
}
/// Enable inline auto UI height remeasurement with the given policy.
#[must_use]
pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
self.inline_auto_remeasure = Some(config);
self
}
/// Disable inline auto UI height remeasurement.
#[must_use]
pub fn without_inline_auto_remeasure(mut self) -> Self {
self.inline_auto_remeasure = None;
self
}
/// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
#[must_use]
pub fn with_signal_interception(mut self, enabled: bool) -> Self {
self.intercept_signals = enabled;
self
}
/// Set frame guardrails configuration.
#[must_use]
pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
self.guardrails = config;
self
}
/// Set the immediate event-drain policy for burst handling.
#[must_use]
pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
self.immediate_drain = config;
self
}
/// Set the tick strategy for selective background screen ticking.
///
/// When set, the runtime consults the strategy to decide which inactive
/// screens should tick on each frame. Without a strategy, all screens
/// tick every frame (backwards-compatible default).
///
/// ```ignore
/// ProgramConfig::default()
/// .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
/// ```
#[must_use]
pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
self.tick_strategy = Some(strategy);
self
}
/// Set the runtime execution lane.
#[must_use]
pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
self.runtime_lane = lane;
self
}
/// Set the rollout policy for the Asupersync migration.
#[must_use]
pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
self.rollout_policy = policy;
self
}
/// Apply environment-variable overrides for lane and rollout policy.
///
/// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
/// are ignored. Unrecognized values emit a `tracing::warn` and are
/// ignored (the programmatic default or prior builder value is retained).
#[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())
}
}
fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
let budget = RenderBudget::from_config(&config.budget);
if config.load_governor.enabled {
let mut controller = config.load_governor.budget_controller.clone();
controller.target = config.budget.total;
budget.with_controller(controller)
} else {
budget
}
}
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");
}
}
/// Timeout for the effect-queue thread to finish after sending Shutdown.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
/// Poll interval when waiting for the effect-queue thread (bd-170o5).
///
/// This sleep-poll pattern is the idiomatic Rust approach for bounded
/// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
/// 1ms is chosen to minimize shutdown latency while avoiding spin.
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();
// Fast path: most shutdowns complete nearly instantly after the
// Shutdown command is drained. Check once before entering poll loop.
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;
}
// Slow path: bounded poll loop for in-flight tasks (bd-170o5).
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);
/// Poll interval for bounded thread joins (bd-170o5).
///
/// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
/// `join_timeout` in stable Rust, so we poll `is_finished()` with a
/// 1ms sleep to minimize shutdown latency while avoiding spin.
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();
// Fast path: reap any already-finished handles first.
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;
}
// Slow path: bounded poll loop for in-flight tasks (bd-170o5).
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) => {
// Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
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,
}
}
// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
/// Policy for remeasuring inline auto UI height.
///
/// Uses VOI (value-of-information) sampling to decide when to perform
/// a costly full-height measurement, with any-time valid guarantees via
/// the embedded e-process in `VoiSampler`.
#[derive(Debug, Clone)]
pub struct InlineAutoRemeasureConfig {
/// VOI sampling configuration.
pub voi: VoiConfig,
/// Minimum row delta to count as a "violation".
pub change_threshold_rows: u16,
}
impl Default for InlineAutoRemeasureConfig {
fn default() -> Self {
Self {
voi: VoiConfig {
// Height changes are expected to be rare; bias toward fewer samples.
prior_alpha: 1.0,
prior_beta: 9.0,
// Allow ~1s max latency to adapt to growth/shrink.
max_interval_ms: 1000,
// Avoid over-sampling in high-FPS loops.
min_interval_ms: 100,
// Disable event forcing; use time-based gating.
max_interval_events: 0,
min_interval_events: 0,
// Treat sampling as moderately expensive.
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,
controller_reason: BudgetDecisionReason,
load_governor: LoadGovernorSnapshot,
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));
let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
format!(
r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","decision_controller_reason":"{}","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":{},"runtime_mode":"{}","runtime_mode_before":"{}","pressure_class":"{}","work_disposition":"{}","governor_reason":"{}","governor_transition":{},"strict_semantics_preserved":{},"queue_in_flight":{},"queue_max_depth":{},"queue_dropped_delta":{},"resize_coalescing_active":{},"recovery_intervals_observed":{},"recovery_intervals_required":{},"deferred_work_total":{},"coalesced_work_total":{},"dropped_work_total":{},"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.controller_reason.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,
self.load_governor.mode.as_str(),
self.load_governor.mode_before.as_str(),
self.load_governor.pressure_class.as_str(),
self.load_governor.disposition.as_str(),
self.load_governor.reason_code,
self.load_governor.transition,
self.load_governor.strict_semantics_preserved,
self.load_governor.queue_in_flight,
queue_max_depth,
self.load_governor.queue_dropped_delta,
self.load_governor.resize_coalescing_active,
self.load_governor.recovery_intervals_observed,
self.load_governor.recovery_intervals_required,
self.load_governor.deferred_work_total,
self.load_governor.coalesced_work_total,
self.load_governor.dropped_work_total,
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
{
// Starvation guard: ensure at least one starved widget can refresh.
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
}
}
// =============================================================================
// CrosstermEventSource: BackendEventSource adapter for TerminalSession
// =============================================================================
#[cfg(feature = "crossterm-compat")]
/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
///
/// This provides the bridge between the legacy crossterm-based terminal session
/// and the new backend abstraction. Once the native `ftui-tty` backend fully
/// replaces crossterm, this adapter will be removed.
pub struct CrosstermEventSource {
session: TerminalSession,
features: BackendFeatures,
}
#[cfg(feature = "crossterm-compat")]
impl CrosstermEventSource {
/// Create a new crossterm event source from a terminal session.
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)?;
}
// bracketed_paste, focus_events, and kitty_keyboard are set at session
// construction and cleaned up in TerminalSession::Drop. Runtime toggling
// is not supported by the crossterm backend.
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()
}
}
// =============================================================================
// HeadlessEventSource: no-op event source for headless/test programs
// =============================================================================
/// A no-op event source for headless and test programs.
///
/// Returns a fixed terminal size, accepts feature changes silently, and never
/// produces events. This allows the test helper to construct a `Program`
/// without depending on crossterm or a real terminal.
pub struct HeadlessEventSource {
width: u16,
height: u16,
features: BackendFeatures,
}
impl HeadlessEventSource {
/// Create a headless event source with the given terminal size.
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)
}
}
// =============================================================================
// Program
// =============================================================================
/// The program runtime that manages the update/view loop.
pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
/// The application model.
model: M,
/// Terminal output coordinator.
writer: TerminalWriter<W>,
/// Event source (terminal input, size queries, feature toggles).
events: E,
/// Currently active backend feature toggles.
backend_features: BackendFeatures,
/// Whether the program is running.
running: bool,
/// Current tick rate (if any).
tick_rate: Option<Duration>,
/// Total commands actually executed by the runtime.
executed_cmd_count: usize,
/// Last tick time.
last_tick: Instant,
/// Whether the UI needs to be redrawn.
dirty: bool,
/// Monotonic frame index for evidence logging.
frame_idx: u64,
/// Monotonic tick index for tick-strategy scheduling.
tick_count: u64,
/// Widget scheduling signals captured during the last render.
widget_signals: Vec<WidgetSignal>,
/// Widget refresh selection configuration.
widget_refresh_config: WidgetRefreshConfig,
/// Last computed widget refresh plan.
widget_refresh_plan: WidgetRefreshPlan,
/// Current terminal width.
width: u16,
/// Current terminal height.
height: u16,
/// Forced terminal size override (when set, resize events are ignored).
forced_size: Option<(u16, u16)>,
/// Poll timeout when no tick is scheduled.
poll_timeout: Duration,
/// Whether the runtime should observe process-level termination signals.
intercept_signals: bool,
/// Immediate drain policy for bursty input handling.
immediate_drain_config: ImmediateDrainConfig,
/// Runtime counters for immediate-drain behavior.
immediate_drain_stats: ImmediateDrainStats,
/// Frame budget configuration.
budget: RenderBudget,
/// Runtime load-governor state for mode and fallback evidence.
load_governor: LoadGovernorState,
/// Conformal predictor for frame-time risk gating.
conformal_predictor: Option<ConformalPredictor>,
/// Last observed frame time (microseconds), used as a baseline predictor.
last_frame_time_us: Option<f64>,
/// Last observed update duration (microseconds).
last_update_us: Option<u64>,
/// Optional frame timing sink for profiling.
frame_timing: Option<FrameTimingConfig>,
/// Locale context used for rendering.
locale_context: LocaleContext,
/// Last observed locale version.
locale_version: u64,
/// Resize coalescer for rapid resize events.
resize_coalescer: ResizeCoalescer,
/// Shared evidence sink for decision logs (optional).
evidence_sink: Option<EvidenceSink>,
/// Whether fairness config has been logged to evidence sink.
fairness_config_logged: bool,
/// Resize handling behavior.
resize_behavior: ResizeBehavior,
/// Input fairness guard for scheduler integration.
fairness_guard: InputFairnessGuard,
/// Optional event recorder for macro capture.
event_recorder: Option<EventRecorder>,
/// Subscription lifecycle manager.
subscriptions: SubscriptionManager<M::Message>,
/// Channel for receiving messages from background tasks.
#[cfg(test)]
task_sender: std::sync::mpsc::Sender<M::Message>,
/// Channel for receiving messages from background tasks.
task_receiver: std::sync::mpsc::Receiver<M::Message>,
/// Internal task execution substrate behind `Cmd::Task`.
task_executor: TaskExecutor<M::Message>,
/// Optional state registry for widget persistence.
state_registry: Option<std::sync::Arc<StateRegistry>>,
/// Persistence configuration.
persistence_config: PersistenceConfig,
/// Last checkpoint save time.
last_checkpoint: Instant,
/// Inline auto UI height remeasurement state.
inline_auto_remeasure: Option<InlineAutoRemeasureState>,
/// Per-frame bump arena for temporary render-path allocations.
frame_arena: FrameArena,
/// Unified frame guardrails (memory/queue limits).
guardrails: FrameGuardrails,
/// Optional tick strategy for selective background screen ticking.
tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
/// Last active screen observed by the tick strategy dispatch path.
last_active_screen_for_strategy: Option<String>,
}
#[cfg(feature = "crossterm-compat")]
impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
/// Create a new program with default configuration.
pub fn new(model: M) -> io::Result<Self>
where
M::Message: Send + 'static,
{
Self::with_config(model, ProgramConfig::default())
}
/// Create a new program with the specified configuration.
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);
}
// Get terminal size for initial frame (or forced size override).
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 = render_budget_from_program_config(&config);
let load_governor = LoadGovernorState::new(
config.load_governor.clone(),
effect_queue_config.max_queue_depth,
);
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);
// Log runtime lane and rollout policy at startup (bd-2crbt)
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,
load_governor,
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> {
/// Create a program with an externally-constructed event source and writer.
///
/// This is the generic entry point for alternative backends (native tty,
/// WASM, headless testing). The caller is responsible for terminal
/// lifecycle (raw mode, cleanup) — the event source should handle that
/// via its `Drop` impl or an external RAII guard.
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 = render_budget_from_program_config(&config);
let load_governor = LoadGovernorState::new(
config.load_governor.clone(),
effect_queue_config.max_queue_depth,
);
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,
load_governor,
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,
})
}
}
// =============================================================================
// Native TTY backend constructor (feature-gated)
// =============================================================================
#[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> {
/// Create a program backed by the native TTY backend (no Crossterm).
///
/// This opens a live terminal session via `ftui-tty`, entering raw mode
/// and enabling the requested features. When the program exits (or panics),
/// `TtyBackend::drop()` restores the terminal to its original state.
///
/// **Unix-only.** `ftui-tty` does not yet have a Windows-native backend.
/// On non-Unix targets call [`Program::with_config`] (with `crossterm-compat`)
/// instead — calling `with_native_backend` from Windows used to silently
/// fall through to the headless 0×0 test backend and produce a single
/// init-frame-then-silence pattern that looks like a hung TUI.
#[cfg(unix)]
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,
};
let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
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> {
/// Run the main event loop.
///
/// This is the main entry point. It handles:
/// 1. Initialization (terminal setup, raw mode)
/// 2. Event polling and message dispatch
/// 3. Frame rendering
/// 4. Shutdown (terminal cleanup)
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
}
}
/// Access widget scheduling signals captured on the last render.
#[inline]
pub fn last_widget_signals(&self) -> &[WidgetSignal] {
&self.widget_signals
}
/// Snapshot immediate-drain runtime counters.
#[inline]
pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
self.immediate_drain_stats
}
/// The inner event loop, separated for proper cleanup handling.
fn run_event_loop(&mut self) -> io::Result<()> {
// Auto-load state on start
if self.persistence_config.auto_load {
self.load_state();
}
// Initialize
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() {
// Reconcile initial subscriptions
self.reconcile_subscriptions();
// Initial render
self.render_frame()?;
}
// Main loop
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;
// Log heartbeat every 100 iterations to avoid flooding stderr
if loop_count.is_multiple_of(100) {
crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
}
// Poll for input with tick timeout
let timeout = self.effective_timeout();
// Poll for events with 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;
}
// Process subscription messages
self.process_subscription_messages()?;
if !self.running {
break;
}
// Process background task results
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;
}
// Detect screen transitions from any update() calls above.
// A.2: notifies the tick strategy so predictive strategies learn.
// D.3: force-ticks the newly active screen for immediate refresh.
self.check_screen_transition();
// Check for tick - deliver to model so periodic logic can run
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;
// Per-screen tick dispatch: if the model supports multi-screen
// dispatch and a tick strategy is configured, tick individual
// screens selectively instead of calling monolithic
// `update(Tick)`.
if let Some(strategy) = self.tick_strategy.as_mut() {
// Snapshot screen topology first so the mutable borrow of the
// dispatch adapter does not overlap strategy decisions.
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;
// Feed active-screen transitions into the strategy so
// predictive strategies can learn from real navigation.
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));
// Active screen is always ticked.
tick_targets.push(active.clone());
// Tick inactive screens according to the strategy.
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);
}
}
// Compute skipped screens for tracing.
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"
);
// Maintenance tick for the strategy.
strategy.maintenance_tick(tick_count);
self.mark_dirty();
}
}
if used_screen_dispatch && self.running {
self.reconcile_subscriptions();
}
if !used_screen_dispatch {
// Monolithic model path does not expose active-screen
// transitions, so clear dispatch-local transition state.
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();
}
}
}
// Check for periodic checkpoint save
self.check_checkpoint_save();
// Detect locale changes outside the event loop.
self.check_locale_change();
termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
if termination_signal.is_some() {
self.running = false;
break;
}
// Render if dirty
if self.dirty {
self.render_frame()?;
}
// Periodic grapheme pool GC
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)?;
// Auto-save state on exit
if self.persistence_config.auto_save {
self.save_state();
}
// Shut down tick strategy (gives strategies a chance to persist state)
if let Some(ref mut strategy) = self.tick_strategy {
strategy.shutdown();
}
// Stop all subscriptions on exit
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(())
}
/// Drain ready events while bounding zero-timeout polling work.
///
/// The runtime preserves low-latency draining by polling with
/// `Duration::ZERO`, but switches to a bounded backoff path when a burst
/// exceeds configured immediate-drain budgets.
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(())
}
/// Load state from the persistence registry.
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");
}
}
}
}
/// Save state to the persistence registry.
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) => {
// No changes to save
}
Err(e) => {
tracing::warn!(error = %e, "failed to save widget state");
}
}
}
}
/// Check if it's time for a periodic checkpoint save.
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<()> {
// Track event start time and type for fairness scheduling.
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);
}
// Record event before processing (no-op when recorder is None or idle).
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(());
}
// Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
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();
}
// Track input event processing for fairness.
self.fairness_guard.event_processed(
fairness_event_type,
event_start.elapsed(),
Instant::now(),
);
Ok(())
}
/// Classify an event for fairness tracking.
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,
}
}
/// Reconcile the model's declared subscriptions with running ones.
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);
// started/stopped would require tracking in SubscriptionManager
current.record("started", started);
current.record("stopped", stopped);
}
/// Process pending messages from subscriptions.
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(())
}
/// Process results from background tasks.
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(())
}
/// Execute a command.
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) => {
// Batch currently executes sequentially. This is intentional
// until an async runtime or task scheduler is added.
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(())
}
/// Detect active-screen transitions after any `update()` call and react:
///
/// - **A.2** — notify the tick strategy via `on_screen_transition()` so
/// predictive strategies can learn navigation patterns.
/// - **D.3** — force-tick the newly active screen so it renders fresh
/// content immediately, without waiting for the next tick interval.
///
/// This is a no-op when no tick strategy is configured or when the model
/// does not implement [`ScreenTickDispatch`].
fn check_screen_transition(&mut self) {
if self.tick_strategy.is_none() {
return;
}
// Snapshot the current active screen (releases &mut self.model).
let current_active = match self.model.as_screen_tick_dispatch() {
Some(dispatch) => dispatch.active_screen_id(),
None => return,
};
// First observation: just record, no transition event.
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;
}
// A.2: Notify strategy of the transition.
if let Some(strategy) = self.tick_strategy.as_mut() {
strategy.on_screen_transition(&previous, ¤t_active);
}
// D.3: Force-tick the newly active screen immediately.
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(())
}
/// Render a frame with budget tracking.
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();
// Reset budget for new frame, potentially upgrading quality
self.budget.next_frame();
// Check frame guardrails (memory/queue limits)
let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
// Synchronous program has effectively zero queue depth.
let verdict = self.guardrails.check_frame(memory_bytes, 0);
if verdict.should_drop_frame() {
// Emergency shed: skip this frame entirely to prevent OOM
return Ok(());
}
if verdict.should_degrade() {
// Apply guardrail-recommended degradation if it's stricter than budget's
let current = self.budget.degradation();
if verdict.recommended_level > current {
self.budget.set_degradation(verdict.recommended_level);
}
}
// Apply conformal risk gate before rendering (if enabled)
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);
}
// Early skip if budget says to skip this frame entirely
if self.budget.exhausted() {
self.budget.record_frame_time(Duration::ZERO);
let load_snapshot =
self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
self.emit_budget_evidence(
frame_idx,
degradation_start,
0.0,
conformal_prediction.as_ref(),
&load_snapshot,
);
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"
);
// Keep dirty=true: the UI update was never presented, so a
// future frame must still pick it up.
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();
}
// --- Render phase ---
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;
// Check if render phase overspent its budget
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"
);
// With the load governor active, the controller decides degradation
// from measured frame history at the next frame boundary. The
// legacy path keeps its immediate threshold fallback.
if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
self.budget.degrade();
}
}
// --- Present phase ---
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);
let load_snapshot = self.update_load_governor_snapshot(
frame_idx,
frame_time_us,
conformal_prediction.as_ref(),
);
self.emit_budget_evidence(
frame_idx,
degradation_start,
frame_time_us,
conformal_prediction.as_ref(),
&load_snapshot,
);
// Only clear dirty when the frame was actually presented.
// If present was skipped (budget exhausted after render), the UI
// update was never shown and must be retried on the next frame.
if presented {
self.dirty = false;
}
Ok(())
}
fn update_load_governor_snapshot(
&mut self,
_frame_idx: u64,
frame_time_us: f64,
conformal_prediction: Option<&ConformalPrediction>,
) -> LoadGovernorSnapshot {
let budget_us = conformal_prediction
.map(|prediction| prediction.budget_us)
.unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
let resize_stats = self.resize_coalescer.stats();
self.load_governor.observe(LoadGovernorObservation {
frame_time_us,
budget_us,
degradation: self.budget.degradation(),
queue: crate::effect_system::queue_telemetry(),
resize_coalescing_active: resize_stats.has_pending
|| !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady),
strict_semantics_violation: false,
})
}
fn emit_budget_evidence(
&self,
frame_idx: u64,
degradation_start: DegradationLevel,
frame_time_us: f64,
conformal_prediction: Option<&ConformalPrediction>,
load_snapshot: &LoadGovernorSnapshot,
) {
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,
controller_reason: telemetry.decision_reason,
load_governor: *load_snapshot,
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) {
// Reset the per-frame arena so widgets get fresh scratch space.
self.frame_arena.reset();
// Note: Frame borrows the pool and links from writer.
// We scope it so it drops before we call present_ui (which needs exclusive writer access).
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);
// widget_count would require tracking in Frame
(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)>) {
// Reset the per-frame arena for measurement pass.
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)
}
/// Calculate the effective poll timeout.
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
}
}
/// Check if we should send a tick.
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(());
}
// Check fairness: if input is starving, skip resize application this cycle.
// This ensures input events are processed before resize is finalized.
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"
);
// Skip resize application this cycle to allow input processing.
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<()> {
// Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
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(())
}
// removed: resize placeholder rendering (continuous reflow preferred)
/// Get a reference to the model.
pub fn model(&self) -> &M {
&self.model
}
/// Get a mutable reference to the model.
pub fn model_mut(&mut self) -> &mut M {
&mut self.model
}
/// Check if the program is running.
pub fn is_running(&self) -> bool {
self.running
}
/// Get the current tick rate, if one has been installed.
#[must_use]
pub const fn tick_rate(&self) -> Option<Duration> {
self.tick_rate
}
/// Get the number of commands actually executed by the runtime.
#[must_use]
pub const fn executed_cmd_count(&self) -> usize {
self.executed_cmd_count
}
/// Request a quit.
pub fn quit(&mut self) {
self.running = false;
}
/// Get a reference to the state registry, if configured.
pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
self.state_registry.as_ref()
}
/// Check if state persistence is enabled.
pub fn has_persistence(&self) -> bool {
self.state_registry.is_some()
}
/// Query the current tick strategy's debug statistics.
///
/// Returns key-value pairs describing the strategy's internal state
/// (e.g. strategy name, divisors, confidence, transition counts).
/// Returns an empty vec if no tick strategy is configured.
#[must_use]
pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
self.tick_strategy
.as_ref()
.map(|s| s.debug_stats())
.unwrap_or_default()
}
/// Trigger a manual save of widget state.
///
/// Returns the result of the flush operation, or `Ok(false)` if
/// persistence is not configured.
pub fn trigger_save(&mut self) -> StorageResult<bool> {
if let Some(registry) = &self.state_registry {
registry.flush()
} else {
Ok(false)
}
}
/// Trigger a manual load of widget state.
///
/// Returns the number of entries loaded, or `Ok(0)` if persistence
/// is not configured.
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();
}
}
/// Mark the UI as needing redraw.
pub fn request_redraw(&mut self) {
self.mark_dirty();
}
/// Request a re-measure of inline auto UI height on next render.
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();
}
}
/// Start recording events into a macro.
///
/// If already recording, the current recording is discarded and a new one starts.
/// The current terminal size is captured as metadata.
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);
}
/// Stop recording and return the recorded macro, if any.
///
/// Returns `None` if not currently recording.
pub fn stop_recording(&mut self) -> Option<InputMacro> {
self.event_recorder.take().map(EventRecorder::finish)
}
/// Check if event recording is active.
pub fn is_recording(&self) -> bool {
self.event_recorder
.as_ref()
.is_some_and(EventRecorder::is_recording)
}
}
/// Builder for creating and running programs.
pub struct App;
impl App {
/// Create a new app builder with the given model.
#[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
pub fn new<M: Model>(model: M) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::default(),
}
}
/// Create a fullscreen app.
pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::fullscreen(),
}
}
/// Create an inline app with the given height.
pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
AppBuilder {
model,
config: ProgramConfig::inline(height),
}
}
/// Create an inline app with automatic UI 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),
}
}
/// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
///
/// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
/// so that `view_string()` output is rendered through the standard kernel pipeline.
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(),
}
}
}
/// Builder for configuring and running programs.
#[must_use]
pub struct AppBuilder<M: Model> {
model: M,
config: ProgramConfig,
}
impl<M: Model> AppBuilder<M> {
/// Set the screen mode.
pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
self.config.screen_mode = mode;
self
}
/// Set the UI anchor.
pub fn anchor(mut self, anchor: UiAnchor) -> Self {
self.config.ui_anchor = anchor;
self
}
/// Force mouse capture on.
pub fn with_mouse(mut self) -> Self {
self.config.mouse_capture_policy = MouseCapturePolicy::On;
self
}
/// Set mouse capture policy for this app.
pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
self.config.mouse_capture_policy = policy;
self
}
/// Force mouse capture enabled/disabled for this app.
pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
self.config.mouse_capture_policy = if enabled {
MouseCapturePolicy::On
} else {
MouseCapturePolicy::Off
};
self
}
/// Set the frame budget configuration.
pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
self.config.budget = budget;
self
}
/// Set the runtime load-governor configuration.
pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
self.config.load_governor = config;
self
}
/// Disable the adaptive load governor for this app.
pub fn without_load_governor(mut self) -> Self {
self.config.load_governor = LoadGovernorConfig::disabled();
self
}
/// Set the evidence JSONL sink configuration.
pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
self.config.evidence_sink = config;
self
}
/// Set the render-trace recorder configuration.
pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
self.config.render_trace = config;
self
}
/// Set the widget refresh selection configuration.
pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
self.config.widget_refresh = config;
self
}
/// Set the effect queue scheduling configuration.
pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
self.config.effect_queue = config;
self
}
/// Enable inline auto UI height remeasurement.
pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
self.config.inline_auto_remeasure = Some(config);
self
}
/// Disable inline auto UI height remeasurement.
pub fn without_inline_auto_remeasure(mut self) -> Self {
self.config.inline_auto_remeasure = None;
self
}
/// Set the locale context used for rendering.
pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
self.config.locale_context = locale_context;
self
}
/// Set the base locale used for rendering.
pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
self.config.locale_context = LocaleContext::new(locale);
self
}
/// Set the resize coalescer configuration.
pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
self.config.resize_coalescer = config;
self
}
/// Set the resize handling behavior.
pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
self.config.resize_behavior = behavior;
self
}
/// Toggle legacy immediate-resize behavior for migration.
pub fn legacy_resize(mut self, enabled: bool) -> Self {
if enabled {
self.config.resize_behavior = ResizeBehavior::Immediate;
}
self
}
/// Set the tick strategy for selective background screen ticking.
pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
self.config.tick_strategy = Some(strategy);
self
}
/// Run the application using the legacy Crossterm backend.
#[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
}
/// Run the application using the native TTY backend.
#[cfg(all(feature = "native-backend", unix))]
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
}
/// Run the application using the legacy Crossterm backend.
#[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()",
))
}
/// Run the application using the native TTY backend.
///
/// On non-Unix targets the native backend is unavailable; call
/// [`AppBuilder::run`] (with `crossterm-compat`) instead.
#[cfg(any(not(feature = "native-backend"), not(unix)))]
pub fn run_native(self) -> io::Result<()>
where
M::Message: Send + 'static,
{
let _ = (self.model, self.config);
// Prefer the platform-level message: a Windows user without
// `native-backend` enabled would otherwise be told to enable the
// feature, only to discover after rebuilding that it's still
// Unix-only. Pointing them at crossterm-compat up front avoids the
// two-step debug.
#[cfg(not(unix))]
let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
#[cfg(all(unix, not(feature = "native-backend")))]
let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
Err(io::Error::new(io::ErrorKind::Unsupported, msg))
}
}
// =============================================================================
// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
// =============================================================================
//
// # M/G/1 Queueing Model for Event Batching
//
// ## Problem
//
// The event loop must balance two objectives:
// 1. **Low latency**: Process events quickly (small batch window τ).
// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
//
// ## Model
//
// We model the event loop as an M/G/1 queue:
// - Events arrive at rate λ (Poisson process, reasonable for human input).
// - Service time S has mean E[S] and variance Var[S] (render + present).
// - Utilization ρ = λ·E[S] must be < 1 for stability.
//
// ## Pollaczek–Khinchine Mean Waiting Time
//
// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
// where E[S²] = Var[S] + E[S]².
//
// ## Optimal Batch Window τ
//
// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
// the per-frame render cost. The effective per-event latency is:
//
// L(τ) = τ/2 + E[S]
// (waiting in batch) (service)
//
// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
// giving utilization ρ_eff = E[S]/τ.
//
// Minimizing L(τ) subject to ρ_eff < 1:
// L(τ) = τ/2 + E[S]
// dL/dτ = 1/2 (always positive, so smaller τ is always better for latency)
//
// But we need ρ_eff < 1, so τ > E[S].
//
// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
// where headroom_factor provides margin (typically 1.5–2.0).
//
// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
// where λ_target is the max frame rate we want to sustain.
//
// ## Failure Modes
//
// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
// (degrade to lower frame rate), or drop stale events.
// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
// exponential moving average of λ smooths this; high burst periods
// temporarily increase τ.
// 3. **Variable service time**: Render complexity varies per frame. Using
// EMA of E[S] tracks this adaptively.
//
// ## Observable Telemetry
//
// - λ_est: Exponential moving average of inter-arrival times.
// - es_est: Exponential moving average of service (render) times.
// - ρ_est: λ_est × es_est (estimated utilization).
/// Adaptive batch window controller based on M/G/1 queueing model.
///
/// Estimates arrival rate λ and service time `E[S]` from observations,
/// then computes the optimal batch window τ to maintain stability
/// (ρ < 1) while minimizing latency.
#[derive(Debug, Clone)]
pub struct BatchController {
/// Exponential moving average of inter-arrival time (seconds).
ema_inter_arrival_s: f64,
/// Exponential moving average of service time (seconds).
ema_service_s: f64,
/// EMA smoothing factor (0..1). Higher = more responsive.
alpha: f64,
/// Minimum batch window (floor).
tau_min_s: f64,
/// Maximum batch window (cap for responsiveness).
tau_max_s: f64,
/// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
headroom: f64,
/// Last event arrival timestamp.
last_arrival: Option<Instant>,
/// Number of observations.
observations: u64,
}
impl BatchController {
/// Create a new controller with sensible defaults.
///
/// - `alpha`: EMA smoothing (default 0.2)
/// - `tau_min`: minimum batch window (default 1ms)
/// - `tau_max`: maximum batch window (default 50ms)
/// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
pub fn new() -> Self {
Self {
ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
ema_service_s: 0.002, // assume 2ms render initially
alpha: 0.2,
tau_min_s: 0.001, // 1ms floor
tau_max_s: 0.050, // 50ms cap
headroom: 2.0,
last_arrival: None,
observations: 0,
}
}
/// Record an event arrival, updating the inter-arrival estimate.
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 {
// Guard against stale gaps (e.g., app was suspended)
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);
}
/// Record a service (render) time observation.
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;
}
}
/// Estimated arrival rate λ (events/second).
#[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
}
}
/// Estimated service time `E[S]` (seconds).
#[inline]
pub fn service_est_s(&self) -> f64 {
self.ema_service_s
}
/// Estimated utilization ρ = λ × `E[S]`.
#[inline]
pub fn rho_est(&self) -> f64 {
self.lambda_est() * self.ema_service_s
}
/// Compute the optimal batch window τ (seconds).
///
/// τ = clamp(`E[S]` × headroom, τ_min, τ_max)
///
/// When ρ approaches 1, τ increases to maintain stability.
pub fn tau_s(&self) -> f64 {
let base = self.ema_service_s * self.headroom;
base.clamp(self.tau_min_s, self.tau_max_s)
}
/// Compute the optimal batch window as a Duration.
pub fn tau(&self) -> Duration {
Duration::from_secs_f64(self.tau_s())
}
/// Check if the system is stable (ρ < 1).
#[inline]
pub fn is_stable(&self) -> bool {
self.rho_est() < 1.0
}
/// Number of observations recorded.
#[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},
};
// Simple test model
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) {
// No-op for tests
}
}
#[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);
// Intersection between root vertical splitter and right-side horizontal
// splitter deterministically resolves to smaller split ID.
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));
}
// Resize coalescer behavior is covered by resize_coalescer.rs tests.
// =========================================================================
// DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
// =========================================================================
#[test]
fn cmd_sequence_executes_in_order() {
// Verify that Cmd::Sequence executes commands in declared 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() {
// Verify that Cmd::Batch executes all commands
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);
// All values should be present
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() {
// Verify that Cmd::Sequence stops processing after 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)), // Should not execute
]),
}
}
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() {
// Verify deterministic state transitions
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()
}
// Run the same scenario multiple times
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() {
// Verify consistent render outputs for identical inputs
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));
}
}
}
}
// Create two simulators with the same state
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);
// Compare buffer contents
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
);
}
}
}
// Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
#[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() {
// Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
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()]);
// Single element sequence should unwrap to the inner command
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() {
// Test Debug impl for 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 load_governor_default_is_enabled() {
let config = LoadGovernorConfig::default();
assert!(config.enabled);
assert_eq!(
config.budget_controller.degradation_floor,
DegradationLevel::SimpleBorders
);
}
#[test]
fn program_config_load_governor_builders() {
let governor = LoadGovernorConfig::disabled().with_enabled(true);
let config = ProgramConfig::default().with_load_governor(governor);
assert!(config.load_governor.enabled);
let config = config.without_load_governor();
assert!(!config.load_governor.enabled);
}
fn governor_observation(
frame_time_ms: f64,
in_flight: u64,
dropped: u64,
degradation: DegradationLevel,
resize_coalescing_active: bool,
strict_semantics_violation: bool,
) -> LoadGovernorObservation {
LoadGovernorObservation {
frame_time_us: frame_time_ms * 1_000.0,
budget_us: 16_000.0,
degradation,
queue: crate::effect_system::QueueTelemetry {
enqueued: in_flight.saturating_add(dropped),
processed: 0,
dropped,
high_water: in_flight,
in_flight,
},
resize_coalescing_active,
strict_semantics_violation,
}
}
fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
let policy = LoadGovernorPolicy {
recovery_intervals,
..Default::default()
};
LoadGovernorState::new(
LoadGovernorConfig::enabled().with_policy(policy),
max_queue_depth,
)
}
#[test]
fn load_governor_policy_defaults_match_runtime_contract() {
let policy = LoadGovernorPolicy::default().normalized();
assert_eq!(policy.stressed_queue_watermark, 0.5);
assert_eq!(policy.degraded_queue_watermark, 0.8);
assert_eq!(policy.recovery_queue_watermark, 0.25);
assert_eq!(policy.recovery_intervals, 3);
assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
}
#[test]
fn load_governor_classifies_queue_watermarks_and_recovery() {
let mut governor = test_load_governor(100, 2);
let steady = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
let stressed = governor.observe(governor_observation(
8.0,
50,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
assert_eq!(
stressed.disposition,
RuntimeWorkDisposition::CoalesceVisibleDeferBackground
);
assert_eq!(stressed.reason_code, "queue_stressed_watermark");
let degraded = governor.observe(governor_observation(
8.0,
80,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
assert_eq!(
degraded.disposition,
RuntimeWorkDisposition::DeferBackgroundDropBestEffort
);
assert_eq!(degraded.reason_code, "queue_degraded_watermark");
let recovery_pending = governor.observe(governor_observation(
8.0,
10,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
assert_eq!(recovery_pending.recovery_intervals_observed, 1);
let recovered = governor.observe(governor_observation(
8.0,
10,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
assert_eq!(
recovered.disposition,
RuntimeWorkDisposition::ReadmitAfterHysteresis
);
let healthy = governor.observe(governor_observation(
8.0,
10,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
assert_eq!(healthy.reason_code, "recovered_interval_closed");
}
#[test]
fn load_governor_uses_uncapped_budget_pressure_fallback() {
let mut governor = test_load_governor(0, 2);
let stressed = governor.observe(governor_observation(
20.0,
0,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
assert_eq!(stressed.reason_code, "frame_budget_overrun");
assert_eq!(stressed.queue_max_depth, None);
let degraded = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::SimpleBorders,
false,
false,
));
assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
assert_eq!(degraded.reason_code, "budget_degradation_active");
}
#[test]
fn load_governor_strict_semantics_failure_is_terminal() {
let mut governor = test_load_governor(100, 2);
let unsafe_snapshot = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
true,
));
assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
assert_eq!(
unsafe_snapshot.disposition,
RuntimeWorkDisposition::FailFastStrictGuarantee
);
assert!(!unsafe_snapshot.strict_semantics_preserved);
assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
}
#[test]
fn load_governor_unsafe_latches_through_later_pressure() {
let mut governor = test_load_governor(100, 2);
// Enter Unsafe via a strict-semantics violation.
let entered = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
true,
));
assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
// A later hard-overload interval (queue ratio >= degraded watermark) with
// NO fresh violation must not downgrade the terminal Unsafe state.
let hard = governor.observe(governor_observation(
8.0,
90,
0,
DegradationLevel::SimpleBorders,
false,
false,
));
assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
assert_eq!(
hard.disposition,
RuntimeWorkDisposition::FailFastStrictGuarantee
);
assert!(!hard.strict_semantics_preserved);
assert_eq!(hard.reason_code, "strict_semantics_violation");
// A soft-overload interval (would otherwise classify as Stressed) must
// also keep Unsafe latched rather than escaping downward.
let soft = governor.observe(governor_observation(
8.0,
60,
0,
DegradationLevel::Full,
true,
false,
));
assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
// And a fully steady interval never recovers out of Unsafe.
let steady = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
assert!(!steady.strict_semantics_preserved);
}
#[test]
fn load_governor_hysteresis_prevents_single_sample_recovery() {
let mut governor = test_load_governor(100, 3);
governor.observe(governor_observation(
8.0,
90,
0,
DegradationLevel::Full,
false,
false,
));
for expected in 1..=2 {
let snapshot = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
assert_eq!(snapshot.recovery_intervals_observed, expected);
assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
}
let recovered = governor.observe(governor_observation(
8.0,
0,
0,
DegradationLevel::Full,
false,
false,
));
assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
}
#[test]
fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
let mut governor = test_load_governor(50, 2);
let scenario = [
governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
];
let mut modes = Vec::new();
for observation in scenario {
let snapshot = governor.observe(observation);
modes.push(snapshot.mode);
println!(
"{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
snapshot.mode.as_str(),
snapshot.pressure_class.as_str(),
snapshot.disposition.as_str(),
snapshot.reason_code,
snapshot.transition,
snapshot.deferred_work_total,
snapshot.coalesced_work_total,
snapshot.dropped_work_total
);
}
assert!(modes.contains(&RuntimeLoadMode::Stressed));
assert!(modes.contains(&RuntimeLoadMode::Degraded));
assert!(modes.contains(&RuntimeLoadMode::Recovered));
assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
}
#[test]
fn headless_program_default_load_governor_attaches_controller() {
let program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert!(program.budget.controller().is_some());
}
#[test]
fn headless_program_load_governor_target_tracks_frame_budget() {
let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
total: Duration::from_millis(50),
..Default::default()
});
let program = headless_program_with_config(TestModel { value: 0 }, config);
assert_eq!(
program.budget.controller().unwrap().config().target,
Duration::from_millis(50)
);
}
#[test]
fn headless_program_without_load_governor_uses_legacy_budget() {
let program = headless_program_with_config(
TestModel { value: 0 },
ProgramConfig::default().without_load_governor(),
);
assert!(program.budget.controller().is_none());
}
#[test]
fn app_builder_without_load_governor_sets_config() {
let builder = App::new(TestModel { value: 0 }).without_load_governor();
assert!(!builder.config.load_governor.enabled);
}
#[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"
);
}
// =========================================================================
// Backpressure tests (bd-2zd0a)
// =========================================================================
#[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();
// Enqueue 2 tasks with max_depth=2 — should succeed
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);
// 3rd task should be dropped (depth=2 >= max_depth=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();
// With max_depth=0, can enqueue many tasks
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);
}
// All should be enqueued (some may have been inlined by scheduler, but none dropped)
}
#[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,
controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
load_governor: LoadGovernorSnapshot {
mode: RuntimeLoadMode::Degraded,
mode_before: RuntimeLoadMode::Stressed,
pressure_class: RuntimePressureClass::HardOverload,
disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
reason_code: "budget_degradation_active",
transition: true,
strict_semantics_preserved: true,
queue_in_flight: 8,
queue_max_depth: Some(10),
queue_dropped_delta: 0,
resize_coalescing_active: false,
recovery_intervals_observed: 0,
recovery_intervals_required: 3,
deferred_work_total: 2,
coalesced_work_total: 1,
dropped_work_total: 0,
},
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("\"decision_controller_reason\":\"overload_evidence_passed\""));
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("\"runtime_mode\":\"degraded\""));
assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
assert!(jsonl.contains("\"governor_transition\":true"));
assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
assert!(jsonl.contains("\"queue_in_flight\":8"));
assert!(jsonl.contains("\"queue_max_depth\":10"));
assert!(jsonl.contains("\"deferred_work_total\":2"));
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() {
// Verify that Cmd::Msg triggers recursive update
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));
// Should have recursed 4 times (3, 2, 1, 0)
assert_eq!(sim.model().depth, 4);
}
#[test]
fn task_executes_synchronously_in_simulator() {
// In simulator, tasks execute synchronously
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);
// Task should have completed synchronously
assert!(sim.model().completed);
}
#[test]
fn multiple_updates_accumulate_correctly() {
// Verify state accumulates correctly across multiple updates
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();
// (0 + 5) * 2 + 3 = 13
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() {
// Verify init() command executes before any 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);
}
// =========================================================================
// INLINE MODE FRAME SIZING TESTS (bd-20vg)
// =========================================================================
#[test]
fn ui_height_returns_correct_value_inline_mode() {
// Verify TerminalWriter.ui_height() returns ui_height in 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() {
// Verify TerminalWriter.ui_height() returns full terminal height in alt-screen 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() {
// Verify that in inline mode, the model receives a frame with ui_height,
// not the full terminal height. This is the core fix for bd-20vg.
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) {
// Capture the frame height we receive
CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
}
}
// Use simulator to verify frame dimension handling
let mut sim = ProgramSimulator::new(FrameSizeTracker);
sim.init();
// Capture with specific dimensions (simulates inline mode ui_height=10)
let buf = sim.capture_frame(80, 10);
assert_eq!(buf.height(), 10);
assert_eq!(buf.width(), 80);
// Verify the frame has the correct dimensions
// In inline mode with ui_height=10, the frame should be 10 rows tall,
// NOT the full terminal height (e.g., 24).
}
#[test]
fn altscreen_frame_uses_full_terminal_height() {
// Regression test: in alt-screen mode, frame should use 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);
// In alt-screen, ui_height equals terminal height
assert_eq!(writer.ui_height(), 40);
}
#[test]
fn ui_height_clamped_to_terminal_height() {
// Verify ui_height doesn't exceed terminal height
// (This is handled in present_inline, but ui_height() returns the configured value)
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);
// ui_height() returns configured value, but present_inline clamps
// The Frame should be created with ui_height (100), which is later
// clamped during presentation. For safety, we should use the min.
// Note: This documents current behavior. A stricter fix might
// have ui_height() return min(ui_height, term_height).
assert_eq!(writer.ui_height(), 100);
}
// =========================================================================
// TICK DELIVERY TESTS (bd-3ufh)
// =========================================================================
#[test]
fn tick_event_delivered_to_model_update() {
// Verify that Event::Tick is delivered to model.update()
// This is the core fix: ticks now flow through the update pipeline.
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();
// Manually inject tick event to simulate what the runtime does
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() {
// Verify Cmd::tick() sets the tick rate in the simulator
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);
// Check that tick was recorded
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() {
// Verify that tick handling can return commands that are executed
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;
// Return another message to be processed
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);
// Tick increments by 1, then Advance increments by 10
assert_eq!(sim.model().stage, 11);
}
#[test]
fn tick_disabled_with_zero_duration() {
// Verify that Duration::ZERO disables ticks (no busy loop)
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> {
// Start with a tick enabled
Cmd::tick(Duration::from_millis(100))
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
ZeroMsg::DisableTick => {
self.disabled = true;
// Setting tick to ZERO should effectively disable
Cmd::tick(Duration::ZERO)
}
ZeroMsg::Noop => Cmd::none(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
sim.init();
// Verify initial tick rate is set
assert!(sim.tick_rate().is_some());
assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
// Disable ticks
sim.send(ZeroMsg::DisableTick);
assert!(sim.model().disabled);
// Note: The simulator still records the ZERO tick, but the runtime's
// should_tick() handles ZERO duration appropriately
assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
}
#[test]
fn tick_event_distinguishable_from_other_events() {
// Verify Event::Tick can be distinguished in pattern matching
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() {
// Verify Event::Tick implements Clone and Eq correctly
let tick1 = Event::Tick;
let tick2 = tick1.clone();
assert_eq!(tick1, tick2);
}
#[test]
fn model_receives_tick_and_input_events() {
// Verify model can handle both tick and input events correctly
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();
// Interleave tick and input events
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);
}
// =========================================================================
// HEADLESS PROGRAM TESTS (bd-1av4o.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 = render_budget_from_program_config(&config);
let load_governor = LoadGovernorState::new(
config.load_governor.clone(),
effect_queue_config.max_queue_depth,
);
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,
load_governor,
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 unit tests should not observe process-global shutdown state
// unless they explicitly opt into signal interception.
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");
// Dirty state is preserved when frame is skipped — the UI update
// was never presented and must be retried.
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_spawned_for_structured_lane() {
// Input-lag regression fix (#78): the default Structured lane must use
// per-task `Spawned` execution, NOT the single-worker effect queue.
// Routing every `Cmd::Task` through one serialized `effect_queue_loop`
// worker added per-keystroke head-of-line latency for apps that forward
// PTY output via per-pane polling tasks. Structured cancellation does
// not require the effect queue, so the default backend is `Spawned`
// ("spawned") here, matching v0.2.1 task concurrency.
let program =
headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
assert_eq!(program.task_executor.kind_name(), "spawned");
}
#[test]
fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
// Input-lag regression fix (#78): the default Structured lane emits the
// "spawned" backend in startup evidence (was "queued"), reflecting the
// restored per-task-thread execution.
let evidence_path = temp_evidence_path("task_executor_spawned_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"], "spawned");
}
#[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;
// Use a large hard deadline so elapsed wall-clock time between coalescer
// construction and `handle_resize` never triggers an immediate apply.
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());
}
// =========================================================================
// BatchController Tests (bd-4kq0.8.1)
// =========================================================================
#[test]
fn unit_tau_monotone() {
// τ should decrease (or stay constant) as service time decreases,
// since τ = E[S] × headroom.
let mut bc = BatchController::new();
// High service time → high τ
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();
// Low service time → lower τ
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() {
// As arrival rate λ decreases (longer inter-arrival times),
// τ should not increase (it's based on service time, not λ).
// But ρ should decrease.
let mut bc = BatchController::new();
let base = Instant::now();
// Fast arrivals (λ high)
for i in 0..10 {
bc.observe_arrival(base + Duration::from_millis(i * 10));
}
let rho_fast = bc.rho_est();
// Slow arrivals (λ low)
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() {
// With reasonable service times, the controller should keep ρ < 1.
let mut bc = BatchController::new();
let base = Instant::now();
// Moderate arrival rate: 30 events/sec
for i in 0..30 {
bc.observe_arrival(base + Duration::from_millis(i * 33));
bc.observe_service(Duration::from_millis(5)); // 5ms render
}
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()
);
// τ must be > E[S] (stability requirement)
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() {
// Even under high load, τ keeps the system stable.
let mut bc = BatchController::new();
let base = Instant::now();
// 100 events/sec with 8ms render
for i in 0..50 {
bc.observe_arrival(base + Duration::from_millis(i * 10));
bc.observe_service(Duration::from_millis(8));
}
// τ × ρ_eff = E[S]/τ should be < 1
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();
// Very fast service → τ clamped to tau_min
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
);
// Very slow service → τ clamped to tau_max
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();
// Duration should match f64 representation
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();
// 50 events/sec (20ms apart)
for i in 0..20 {
bc.observe_arrival(base + Duration::from_millis(i * 20));
}
// λ should converge near 50
let lambda = bc.lambda_est();
assert!(
lambda > 20.0 && lambda < 100.0,
"λ should be near 50: got {lambda:.1}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Persistence Config Tests
// ─────────────────────────────────────────────────────────────────────────────
#[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());
}
// =========================================================================
// TaskSpec tests (bd-2yjus)
// =========================================================================
#[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"));
}
// =========================================================================
// Cmd::count() tests (bd-2yjus)
// =========================================================================
#[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); // quit + msg, none counts 0
}
#[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);
}
// =========================================================================
// Cmd::type_name() tests (bd-2yjus)
// =========================================================================
#[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"
);
}
// =========================================================================
// Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
// =========================================================================
#[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(_)));
}
// =========================================================================
// Cmd task constructor variants (bd-2yjus)
// =========================================================================
#[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"),
}
}
// =========================================================================
// Cmd Debug formatting (bd-2yjus)
// =========================================================================
#[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)"
);
}
// =========================================================================
// Cmd::set_mouse_capture headless execution (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// ResizeBehavior tests (bd-2yjus)
// =========================================================================
#[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");
}
// =========================================================================
// WidgetRefreshConfig default values (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// EffectQueueConfig tests (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// InlineAutoRemeasureConfig defaults (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// HeadlessEventSource direct tests (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// Program helper methods (bd-2yjus)
// =========================================================================
#[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());
}
// =========================================================================
// classify_event_for_fairness (bd-2yjus)
// =========================================================================
#[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);
}
// =========================================================================
// ProgramConfig builder methods (bd-2yjus)
// =========================================================================
#[test]
fn program_config_with_diff_config() {
let diff = RuntimeDiffConfig::default();
let config = ProgramConfig::default().with_diff_config(diff.clone());
// Just verify it doesn't panic and the field is set
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());
}
// =========================================================================
// Rollout config builder methods (bd-2crbt)
// =========================================================================
#[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_spawned_backend() {
// Input-lag regression fix (#78): the default Structured lane now
// resolves to per-task `Spawned` execution instead of the single-worker
// `EffectQueue`. Structured cancellation is independent of the task
// executor backend, so the lane no longer serializes `Cmd::Task` through
// one `effect_queue_loop` worker (which caused per-keystroke head-of-line
// latency for PTY-forwarding apps). `enabled` is the legacy convenience
// flag mirroring the backend, so it is now false for the default lane.
let resolved = ProgramConfig::default().resolved_effect_queue_config();
assert!(!resolved.enabled);
assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
}
#[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);
}
// =========================================================================
// PersistenceConfig Debug (bd-2yjus)
// =========================================================================
#[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"));
}
// =========================================================================
// FrameTimingConfig (bd-2yjus)
// =========================================================================
#[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());
}
// =========================================================================
// BudgetDecisionEvidence helper functions (bd-2yjus)
// =========================================================================
#[test]
fn budget_decision_evidence_decision_from_levels() {
use ftui_render::budget::DegradationLevel;
// Degrade: after > before
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::Full,
DegradationLevel::SimpleBorders
),
BudgetDecision::Degrade
);
// Upgrade: after < before
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::SimpleBorders,
DegradationLevel::Full
),
BudgetDecision::Upgrade
);
// Hold: same
assert_eq!(
BudgetDecisionEvidence::decision_from_levels(
DegradationLevel::Full,
DegradationLevel::Full
),
BudgetDecision::Hold
);
}
// =========================================================================
// WidgetRefreshPlan (bd-2yjus)
// =========================================================================
#[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();
// With signal_count == 0, should be allow_all (allows any widget)
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\":[]"));
}
// =========================================================================
// BatchController Default trait (bd-2yjus)
// =========================================================================
#[test]
fn batch_controller_default_trait() {
let bc = BatchController::default();
let bc2 = BatchController::new();
// Should be equivalent
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();
// First arrival
bc.observe_arrival(base);
// Stale gap > 10s should be ignored
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();
// Out-of-range (>= 10s) should be ignored
bc.observe_service(Duration::from_secs(15));
assert_eq!(bc.service_est_s(), original_service);
}
#[test]
fn batch_controller_lambda_zero_inter_arrival() {
// When ema_inter_arrival_s is effectively 0, lambda should be 0
let bc = BatchController {
ema_inter_arrival_s: 0.0,
..BatchController::new()
};
assert_eq!(bc.lambda_est(), 0.0);
}
// =========================================================================
// Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
// =========================================================================
#[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);
// The sanitized output should end with a newline
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"));
}
// =========================================================================
// Headless program: immediate resize behavior (bd-2yjus)
// =========================================================================
#[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)));
}
// =========================================================================
// Headless program: resize clamps zero dimensions (bd-2yjus)
// =========================================================================
#[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");
// Zero dimensions should be clamped to 1
assert_eq!(program.width, 1);
assert_eq!(program.height, 1);
}
// =========================================================================
// PaneTerminalAdapter::force_cancel_all (bd-24v9m)
// =========================================================================
#[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());
// Adapter should be fully idle afterwards
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);
// Down → arm
let down = Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
3,
3,
));
let _ = adapter.translate(&down, Some(target));
// Drag → transition to Dragging
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());
}
// =========================================================================
// PaneInteractionGuard (bd-24v9m)
// =========================================================================
#[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);
// Start a drag interaction through the adapter directly
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);
// guard drops here without finish()
}
// After guard drop, adapter should be idle
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);
// Use the adapter through the guard
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());
// finish should clean up the interaction started through the guard
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();
// guard is consumed by finish(), so drop doesn't double-cancel
// This test proves the API is safe: finish() takes `self` not `&mut self`
assert_eq!(adapter.active_pointer_id(), None);
}
// =========================================================================
// PaneCapabilityMatrix (bd-6u66i)
// =========================================================================
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);
// Focus cancel path is conservatively disabled in all muxes.
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());
}
}
// ========================================================================
// Screen transition detection tests (A.2 + D.3)
// ========================================================================
/// A multi-screen model that implements ScreenTickDispatch, for testing
/// the `check_screen_transition` logic.
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));
}
}
/// Shared log for recording strategy transitions (inspectable after test).
type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
/// A recording tick strategy that logs `on_screen_transition` calls
/// to a shared log that can be inspected from test assertions.
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())]
}
}
/// Helper to create a headless Program with a multi-screen model and
/// a recording tick strategy. Returns the program and a shared log of
/// `on_screen_transition` calls for assertions.
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");
// Replace the default strategy with our recording strategy.
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"));
// First observation: no transition event, no force-tick.
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"]);
// First call: records.
prog.check_screen_transition();
// Second call with same active screen: no-op.
prog.check_screen_transition();
assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
// No force-tick, no transition notification.
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(); // records "A"
// Simulate model switching to screen "B".
prog.model.active = "B".to_owned();
prog.check_screen_transition();
// D.3: force-tick should have been dispatched for "B".
assert_eq!(prog.model.ticked_screens.len(), 1);
assert_eq!(prog.model.ticked_screens[0].0, "B");
// A.2: strategy should have been notified of A → B.
let transitions = log.lock().unwrap();
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
// last_active should now be "B".
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"]);
// Remove the tick strategy.
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(); // records "A"
// A → B
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");
// B → C
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");
// C → A
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");
// A.2: strategy should have all three transitions.
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(); // records "A"
prog.model.active = "B".to_owned();
prog.check_screen_transition();
// Force-tick should use the current tick_count.
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();
// RecordingStrategy returns [("strategy", "Recording")]
assert!(
!stats.is_empty(),
"stats should not be empty when strategy is configured"
);
}
}