Skip to main content

ftui_runtime/
program.rs

1#![forbid(unsafe_code)]
2
3//! Bubbletea/Elm-style runtime for terminal applications.
4//!
5//! The program runtime manages the update/view loop, handling events and
6//! rendering frames. It separates state (Model) from rendering (View) and
7//! provides a command pattern for side effects.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ftui_runtime::program::{Model, Cmd};
13//! use ftui_core::event::Event;
14//! use ftui_render::frame::Frame;
15//!
16//! struct Counter {
17//!     count: i32,
18//! }
19//!
20//! enum Msg {
21//!     Increment,
22//!     Decrement,
23//!     Quit,
24//! }
25//!
26//! impl From<Event> for Msg {
27//!     fn from(event: Event) -> Self {
28//!         match event {
29//!             Event::Key(k) if k.is_char('q') => Msg::Quit,
30//!             Event::Key(k) if k.is_char('+') => Msg::Increment,
31//!             Event::Key(k) if k.is_char('-') => Msg::Decrement,
32//!             _ => Msg::Increment, // Default
33//!         }
34//!     }
35//! }
36//!
37//! impl Model for Counter {
38//!     type Message = Msg;
39//!
40//!     fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
41//!         match msg {
42//!             Msg::Increment => { self.count += 1; Cmd::none() }
43//!             Msg::Decrement => { self.count -= 1; Cmd::none() }
44//!             Msg::Quit => Cmd::quit(),
45//!         }
46//!     }
47//!
48//!     fn view(&self, frame: &mut Frame) {
49//!         // Render counter value to frame
50//!     }
51//! }
52//! ```
53
54use crate::StorageResult;
55use crate::evidence_sink::{EvidenceSink, EvidenceSinkConfig};
56use crate::evidence_telemetry::{
57    BudgetDecisionSnapshot, ConformalSnapshot, ResizeDecisionSnapshot, set_budget_snapshot,
58    set_resize_snapshot,
59};
60use crate::input_fairness::{FairnessDecision, FairnessEventType, InputFairnessGuard};
61use crate::input_macro::{EventRecorder, InputMacro};
62use crate::locale::LocaleContext;
63use crate::queueing_scheduler::{EstimateSource, QueueingScheduler, SchedulerConfig, WeightSource};
64use crate::render_trace::RenderTraceConfig;
65use crate::resize_coalescer::{CoalesceAction, CoalescerConfig, ResizeCoalescer};
66use crate::state_persistence::StateRegistry;
67use crate::subscription::SubscriptionManager;
68use crate::terminal_writer::{RuntimeDiffConfig, ScreenMode, TerminalWriter, UiAnchor};
69use crate::voi_sampling::{VoiConfig, VoiSampler};
70use crate::{BucketKey, ConformalConfig, ConformalPrediction, ConformalPredictor};
71use ftui_backend::{BackendEventSource, BackendFeatures};
72use ftui_core::event::{
73    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
74};
75#[cfg(feature = "crossterm-compat")]
76use ftui_core::terminal_capabilities::TerminalCapabilities;
77#[cfg(feature = "crossterm-compat")]
78use ftui_core::terminal_session::{SessionOptions, TerminalSession};
79use ftui_layout::{
80    PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
81    PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
82    PaneDragResizeTransition, PaneInertialThrow, PaneLayout, PaneModifierSnapshot,
83    PaneMotionVector, PaneNodeKind, PanePointerButton, PanePointerPosition,
84    PanePressureSnapProfile, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
85    PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
86};
87use ftui_render::arena::FrameArena;
88use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
89use ftui_render::buffer::Buffer;
90use ftui_render::diff_strategy::DiffStrategy;
91use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
92use ftui_render::sanitize::sanitize;
93use std::collections::HashMap;
94use std::io::{self, Stdout, Write};
95use std::sync::Arc;
96use std::sync::mpsc;
97use std::thread::{self, JoinHandle};
98use tracing::{debug, debug_span, info, info_span};
99use web_time::{Duration, Instant};
100
101/// The Model trait defines application state and behavior.
102///
103/// Implementations define how the application responds to events
104/// and renders its current state.
105pub trait Model: Sized {
106    /// The message type for this model.
107    ///
108    /// Messages represent actions that update the model state.
109    /// Must be convertible from terminal events.
110    type Message: From<Event> + Send + 'static;
111
112    /// Initialize the model with startup commands.
113    ///
114    /// Called once when the program starts. Return commands to execute
115    /// initial side effects like loading data.
116    fn init(&mut self) -> Cmd<Self::Message> {
117        Cmd::none()
118    }
119
120    /// Update the model in response to a message.
121    ///
122    /// This is the core state transition function. Returns commands
123    /// for any side effects that should be executed.
124    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
125
126    /// Render the current state to a frame.
127    ///
128    /// Called after updates when the UI needs to be redrawn.
129    fn view(&self, frame: &mut Frame);
130
131    /// Declare active subscriptions.
132    ///
133    /// Called after each `update()`. The runtime compares the returned set
134    /// (by `SubId`) against currently running subscriptions and starts/stops
135    /// as needed. Returning an empty vec stops all subscriptions.
136    ///
137    /// # Default
138    ///
139    /// Returns an empty vec (no subscriptions).
140    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
141        vec![]
142    }
143}
144
145/// Default weight assigned to background tasks.
146const DEFAULT_TASK_WEIGHT: f64 = 1.0;
147
148/// Default estimated task cost (ms) used for scheduling.
149const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
150
151/// Scheduling metadata for background tasks.
152#[derive(Debug, Clone)]
153pub struct TaskSpec {
154    /// Task weight (importance). Higher = more priority.
155    pub weight: f64,
156    /// Estimated task cost in milliseconds.
157    pub estimate_ms: f64,
158    /// Optional task name for evidence logging.
159    pub name: Option<String>,
160}
161
162impl Default for TaskSpec {
163    fn default() -> Self {
164        Self {
165            weight: DEFAULT_TASK_WEIGHT,
166            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
167            name: None,
168        }
169    }
170}
171
172impl TaskSpec {
173    /// Create a task spec with an explicit weight and estimate.
174    #[must_use]
175    pub fn new(weight: f64, estimate_ms: f64) -> Self {
176        Self {
177            weight,
178            estimate_ms,
179            name: None,
180        }
181    }
182
183    /// Attach a task name for diagnostics.
184    #[must_use]
185    pub fn with_name(mut self, name: impl Into<String>) -> Self {
186        self.name = Some(name.into());
187        self
188    }
189}
190
191/// Per-frame timing data for profiling.
192#[derive(Debug, Clone, Copy)]
193pub struct FrameTiming {
194    pub frame_idx: u64,
195    pub update_us: u64,
196    pub render_us: u64,
197    pub diff_us: u64,
198    pub present_us: u64,
199    pub total_us: u64,
200}
201
202/// Sink for frame timing events.
203pub trait FrameTimingSink: Send + Sync {
204    fn record_frame(&self, timing: &FrameTiming);
205}
206
207/// Configuration for frame timing capture.
208#[derive(Clone)]
209pub struct FrameTimingConfig {
210    pub sink: Arc<dyn FrameTimingSink>,
211}
212
213impl FrameTimingConfig {
214    #[must_use]
215    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
216        Self { sink }
217    }
218}
219
220impl std::fmt::Debug for FrameTimingConfig {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("FrameTimingConfig")
223            .field("sink", &"<dyn FrameTimingSink>")
224            .finish()
225    }
226}
227
228/// Commands represent side effects to be executed by the runtime.
229///
230/// Commands are returned from `init()` and `update()` to trigger
231/// actions like quitting, sending messages, or scheduling ticks.
232#[derive(Default)]
233pub enum Cmd<M> {
234    /// No operation.
235    #[default]
236    None,
237    /// Quit the application.
238    Quit,
239    /// Execute multiple commands as a batch (currently sequential).
240    Batch(Vec<Cmd<M>>),
241    /// Execute commands sequentially.
242    Sequence(Vec<Cmd<M>>),
243    /// Send a message to the model.
244    Msg(M),
245    /// Schedule a tick after a duration.
246    Tick(Duration),
247    /// Write a log message to the terminal output.
248    ///
249    /// This writes to the scrollback region in inline mode, or is ignored/handled
250    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
251    Log(String),
252    /// Execute a blocking operation on a background thread.
253    ///
254    /// When effect queue scheduling is enabled, tasks are enqueued and executed
255    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
256    /// runs on a spawned thread immediately. The return value is sent back
257    /// as a message to the model.
258    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
259    /// Save widget state to the persistence registry.
260    ///
261    /// Triggers a flush of the state registry to the storage backend.
262    /// No-op if persistence is not configured.
263    SaveState,
264    /// Restore widget state from the persistence registry.
265    ///
266    /// Triggers a load from the storage backend and updates the cache.
267    /// No-op if persistence is not configured. Returns a message via
268    /// callback if state was successfully restored.
269    RestoreState,
270    /// Toggle mouse capture at runtime.
271    ///
272    /// Instructs the terminal session to enable or disable mouse event capture.
273    /// No-op in test simulators.
274    SetMouseCapture(bool),
275}
276
277impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            Self::None => write!(f, "None"),
281            Self::Quit => write!(f, "Quit"),
282            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
283            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
284            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
285            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
286            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
287            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
288            Self::SaveState => write!(f, "SaveState"),
289            Self::RestoreState => write!(f, "RestoreState"),
290            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
291        }
292    }
293}
294
295impl<M> Cmd<M> {
296    /// Create a no-op command.
297    #[inline]
298    pub fn none() -> Self {
299        Self::None
300    }
301
302    /// Create a quit command.
303    #[inline]
304    pub fn quit() -> Self {
305        Self::Quit
306    }
307
308    /// Create a message command.
309    #[inline]
310    pub fn msg(m: M) -> Self {
311        Self::Msg(m)
312    }
313
314    /// Create a log command.
315    ///
316    /// The message will be sanitized and written to the terminal log (scrollback).
317    /// A newline is appended if not present.
318    #[inline]
319    pub fn log(msg: impl Into<String>) -> Self {
320        Self::Log(msg.into())
321    }
322
323    /// Create a batch of commands.
324    pub fn batch(cmds: Vec<Self>) -> Self {
325        if cmds.is_empty() {
326            Self::None
327        } else if cmds.len() == 1 {
328            cmds.into_iter().next().unwrap_or(Self::None)
329        } else {
330            Self::Batch(cmds)
331        }
332    }
333
334    /// Create a sequence of commands.
335    pub fn sequence(cmds: Vec<Self>) -> Self {
336        if cmds.is_empty() {
337            Self::None
338        } else if cmds.len() == 1 {
339            cmds.into_iter().next().unwrap_or(Self::None)
340        } else {
341            Self::Sequence(cmds)
342        }
343    }
344
345    /// Return a stable name for telemetry and tracing.
346    #[inline]
347    pub fn type_name(&self) -> &'static str {
348        match self {
349            Self::None => "None",
350            Self::Quit => "Quit",
351            Self::Batch(_) => "Batch",
352            Self::Sequence(_) => "Sequence",
353            Self::Msg(_) => "Msg",
354            Self::Tick(_) => "Tick",
355            Self::Log(_) => "Log",
356            Self::Task(..) => "Task",
357            Self::SaveState => "SaveState",
358            Self::RestoreState => "RestoreState",
359            Self::SetMouseCapture(_) => "SetMouseCapture",
360        }
361    }
362
363    /// Create a tick command.
364    #[inline]
365    pub fn tick(duration: Duration) -> Self {
366        Self::Tick(duration)
367    }
368
369    /// Create a background task command.
370    ///
371    /// The closure runs on a spawned thread (or the effect queue worker when
372    /// scheduling is enabled). When it completes, the returned message is
373    /// sent back to the model's `update()`.
374    pub fn task<F>(f: F) -> Self
375    where
376        F: FnOnce() -> M + Send + 'static,
377    {
378        Self::Task(TaskSpec::default(), Box::new(f))
379    }
380
381    /// Create a background task command with explicit scheduling metadata.
382    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
383    where
384        F: FnOnce() -> M + Send + 'static,
385    {
386        Self::Task(spec, Box::new(f))
387    }
388
389    /// Create a background task command with explicit weight and estimate.
390    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
391    where
392        F: FnOnce() -> M + Send + 'static,
393    {
394        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
395    }
396
397    /// Create a named background task command.
398    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
399    where
400        F: FnOnce() -> M + Send + 'static,
401    {
402        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
403    }
404
405    /// Create a save state command.
406    ///
407    /// Triggers a flush of the state registry to the storage backend.
408    /// No-op if persistence is not configured.
409    #[inline]
410    pub fn save_state() -> Self {
411        Self::SaveState
412    }
413
414    /// Create a restore state command.
415    ///
416    /// Triggers a load from the storage backend.
417    /// No-op if persistence is not configured.
418    #[inline]
419    pub fn restore_state() -> Self {
420        Self::RestoreState
421    }
422
423    /// Create a mouse capture toggle command.
424    ///
425    /// Instructs the runtime to enable or disable mouse event capture on the
426    /// underlying terminal session.
427    #[inline]
428    pub fn set_mouse_capture(enabled: bool) -> Self {
429        Self::SetMouseCapture(enabled)
430    }
431
432    /// Count the number of atomic commands in this command.
433    ///
434    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
435    pub fn count(&self) -> usize {
436        match self {
437            Self::None => 0,
438            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
439            _ => 1,
440        }
441    }
442}
443
444/// Resize handling behavior for the runtime.
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
446pub enum ResizeBehavior {
447    /// Apply resize immediately (no debounce, no placeholder).
448    Immediate,
449    /// Coalesce resize events for continuous reflow.
450    Throttled,
451}
452
453impl ResizeBehavior {
454    const fn uses_coalescer(self) -> bool {
455        matches!(self, ResizeBehavior::Throttled)
456    }
457}
458
459/// Policy controlling when terminal mouse capture is enabled.
460///
461/// Mouse capture can steal normal scrollback interaction in inline mode.
462/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
463/// alt-screen mode.
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
465pub enum MouseCapturePolicy {
466    /// Enable in alt-screen mode, disable in inline modes.
467    #[default]
468    Auto,
469    /// Always enable mouse capture.
470    On,
471    /// Always disable mouse capture.
472    Off,
473}
474
475impl MouseCapturePolicy {
476    /// Resolve the policy to a concrete mouse-capture toggle.
477    #[must_use]
478    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
479        match self {
480            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
481            Self::On => true,
482            Self::Off => false,
483        }
484    }
485}
486
487const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
488const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
489
490/// One splitter handle region in terminal cell-space.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub struct PaneTerminalSplitterHandle {
493    /// Semantic resize target represented by this handle.
494    pub target: PaneResizeTarget,
495    /// Cell-space hit rectangle for this handle.
496    pub rect: Rect,
497    /// Split boundary coordinate used for deterministic nearest-target ranking.
498    pub boundary: i32,
499}
500
501/// Build deterministic splitter handle regions for terminal hit-testing.
502///
503/// Handles are emitted in split-id order and are clamped to the split rect.
504#[must_use]
505pub fn pane_terminal_splitter_handles(
506    tree: &PaneTree,
507    layout: &PaneLayout,
508    hit_thickness: u16,
509) -> Vec<PaneTerminalSplitterHandle> {
510    let thickness = if hit_thickness == 0 {
511        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
512    } else {
513        hit_thickness
514    };
515    let mut handles = Vec::new();
516    for node in tree.nodes() {
517        let PaneNodeKind::Split(split) = &node.kind else {
518            continue;
519        };
520        let Some(split_rect) = layout.rect(node.id) else {
521            continue;
522        };
523        if split_rect.is_empty() {
524            continue;
525        }
526        let Some(first_rect) = layout.rect(split.first) else {
527            continue;
528        };
529        let Some(second_rect) = layout.rect(split.second) else {
530            continue;
531        };
532
533        let boundary_u16 = match split.axis {
534            SplitAxis::Horizontal => {
535                // Horizontal split => left/right panes => vertical splitter line.
536                if second_rect.x == split_rect.x {
537                    first_rect.right()
538                } else {
539                    second_rect.x
540                }
541            }
542            SplitAxis::Vertical => {
543                // Vertical split => top/bottom panes => horizontal splitter line.
544                if second_rect.y == split_rect.y {
545                    first_rect.bottom()
546                } else {
547                    second_rect.y
548                }
549            }
550        };
551        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
552            continue;
553        };
554        handles.push(PaneTerminalSplitterHandle {
555            target: PaneResizeTarget {
556                split_id: node.id,
557                axis: split.axis,
558            },
559            rect,
560            boundary: i32::from(boundary_u16),
561        });
562    }
563    handles
564}
565
566/// Resolve a semantic splitter target from a terminal cell position.
567///
568/// If multiple handles overlap, chooses deterministically by:
569/// 1) smallest distance to the splitter boundary, then
570/// 2) smaller split_id, then
571/// 3) horizontal axis before vertical axis.
572#[must_use]
573pub fn pane_terminal_resolve_splitter_target(
574    handles: &[PaneTerminalSplitterHandle],
575    x: u16,
576    y: u16,
577) -> Option<PaneResizeTarget> {
578    let px = i32::from(x);
579    let py = i32::from(y);
580    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
581
582    for handle in handles {
583        if !rect_contains_cell(handle.rect, x, y) {
584            continue;
585        }
586        let distance = match handle.target.axis {
587            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
588            SplitAxis::Vertical => py.abs_diff(handle.boundary),
589        };
590        let axis_rank = match handle.target.axis {
591            SplitAxis::Horizontal => 0,
592            SplitAxis::Vertical => 1,
593        };
594        let key = (distance, handle.target.split_id.get(), axis_rank);
595        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
596            best = Some((key, handle.target));
597        }
598    }
599
600    best.map(|(_, target)| target)
601}
602
603/// Register pane splitter handles into the frame hit-grid.
604///
605/// Each handle is registered as `HitRegion::Handle` with encoded target data.
606/// Returns number of successfully-registered regions.
607pub fn register_pane_terminal_splitter_hits(
608    frame: &mut Frame,
609    handles: &[PaneTerminalSplitterHandle],
610    hit_id_base: u32,
611) -> usize {
612    let mut registered = 0usize;
613    for (idx, handle) in handles.iter().enumerate() {
614        let Ok(offset) = u32::try_from(idx) else {
615            break;
616        };
617        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
618        if frame.register_hit(
619            handle.rect,
620            hit_id,
621            HitRegion::Handle,
622            encode_pane_resize_target(handle.target),
623        ) {
624            registered = registered.saturating_add(1);
625        }
626    }
627    registered
628}
629
630/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
631#[must_use]
632pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
633    let (_, region, data) = hit;
634    if region != HitRegion::Handle {
635        return None;
636    }
637    decode_pane_resize_target(data)
638}
639
640fn splitter_hit_rect(
641    axis: SplitAxis,
642    split_rect: Rect,
643    boundary: u16,
644    thickness: u16,
645) -> Option<Rect> {
646    let half = thickness.saturating_sub(1) / 2;
647    match axis {
648        SplitAxis::Horizontal => {
649            let start = boundary.saturating_sub(half).max(split_rect.x);
650            let end = boundary
651                .saturating_add(thickness.saturating_sub(half))
652                .min(split_rect.right());
653            let width = end.saturating_sub(start);
654            (width > 0 && split_rect.height > 0).then_some(Rect::new(
655                start,
656                split_rect.y,
657                width,
658                split_rect.height,
659            ))
660        }
661        SplitAxis::Vertical => {
662            let start = boundary.saturating_sub(half).max(split_rect.y);
663            let end = boundary
664                .saturating_add(thickness.saturating_sub(half))
665                .min(split_rect.bottom());
666            let height = end.saturating_sub(start);
667            (height > 0 && split_rect.width > 0).then_some(Rect::new(
668                split_rect.x,
669                start,
670                split_rect.width,
671                height,
672            ))
673        }
674    }
675}
676
677fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
678    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
679}
680
681fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
682    let axis = match target.axis {
683        SplitAxis::Horizontal => 0_u64,
684        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
685    };
686    (target.split_id.get() << 1) | axis
687}
688
689fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
690    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
691        SplitAxis::Horizontal
692    } else {
693        SplitAxis::Vertical
694    };
695    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
696    Some(PaneResizeTarget { split_id, axis })
697}
698
699// ============================================================================
700// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
701// ============================================================================
702
703/// Which multiplexer environment the terminal is running inside.
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
705pub enum PaneMuxEnvironment {
706    /// No multiplexer detected — direct terminal access.
707    None,
708    /// tmux (TMUX env var set, or DA2 terminal type 84).
709    Tmux,
710    /// GNU Screen (STY env var set, or DA2 terminal type 83).
711    Screen,
712    /// Zellij (ZELLIJ env var set).
713    Zellij,
714    /// WezTerm mux-served pane/session.
715    WeztermMux,
716}
717
718/// Resolved capability matrix describing which pane interaction features
719/// are available in the current terminal + multiplexer environment.
720///
721/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
722/// The adapter uses this to decide which code-paths are safe and which
723/// need deterministic fallbacks.
724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725pub struct PaneCapabilityMatrix {
726    /// Detected multiplexer environment.
727    pub mux: PaneMuxEnvironment,
728
729    // --- Mouse input capabilities ---
730    /// SGR (1006) extended mouse protocol available.
731    /// Without this, mouse coordinates are limited to 223 columns/rows.
732    pub mouse_sgr: bool,
733    /// Mouse drag events are reliably delivered.
734    /// False in some screen versions where drag tracking is incomplete.
735    pub mouse_drag_reliable: bool,
736    /// Mouse button events include correct button identity on release.
737    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
738    pub mouse_button_discrimination: bool,
739
740    // --- Focus / lifecycle ---
741    /// Terminal delivers CSI I / CSI O focus events.
742    pub focus_events: bool,
743    /// Bracketed paste mode available (affects interaction cancel heuristics).
744    pub bracketed_paste: bool,
745
746    // --- Rendering affordances ---
747    /// Unicode box-drawing glyphs available for splitter rendering.
748    pub unicode_box_drawing: bool,
749    /// True-color support for splitter highlight/drag feedback.
750    pub true_color: bool,
751
752    // --- Fallback summary ---
753    /// One or more pane features are degraded due to environment constraints.
754    pub degraded: bool,
755}
756
757/// Human-readable description of a known limitation and its fallback.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct PaneCapabilityLimitation {
760    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
761    pub id: &'static str,
762    /// What the limitation is.
763    pub description: &'static str,
764    /// What the adapter does instead.
765    pub fallback: &'static str,
766}
767
768impl PaneCapabilityMatrix {
769    /// Derive the pane capability matrix from terminal capabilities.
770    ///
771    /// This is the single source of truth for which pane features are
772    /// available. All fallback decisions flow from this matrix.
773    #[must_use]
774    pub fn from_capabilities(
775        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
776    ) -> Self {
777        let mux = if caps.in_tmux {
778            PaneMuxEnvironment::Tmux
779        } else if caps.in_screen {
780            PaneMuxEnvironment::Screen
781        } else if caps.in_zellij {
782            PaneMuxEnvironment::Zellij
783        } else if caps.in_wezterm_mux {
784            PaneMuxEnvironment::WeztermMux
785        } else {
786            PaneMuxEnvironment::None
787        };
788
789        let mouse_sgr = caps.mouse_sgr;
790
791        // GNU Screen has historically unreliable drag event delivery.
792        // tmux and zellij forward drags correctly in modern versions.
793        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
794
795        // Button discrimination requires SGR mouse protocol.
796        // Without it, X10/normal mode reports button 3 for all releases.
797        let mouse_button_discrimination = mouse_sgr;
798
799        // Focus events are conservatively disabled in any mux context.
800        let focus_events = caps.focus_events && !caps.in_any_mux();
801
802        let bracketed_paste = caps.bracketed_paste;
803        let unicode_box_drawing = caps.unicode_box_drawing;
804        let true_color = caps.true_color;
805
806        let degraded =
807            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
808
809        Self {
810            mux,
811            mouse_sgr,
812            mouse_drag_reliable,
813            mouse_button_discrimination,
814            focus_events,
815            bracketed_paste,
816            unicode_box_drawing,
817            true_color,
818            degraded,
819        }
820    }
821
822    /// Whether pane drag interactions should be enabled at all.
823    ///
824    /// Drag requires at minimum mouse event support. If drag events
825    /// are unreliable (e.g. GNU Screen), drag is disabled and the
826    /// adapter falls back to keyboard-only resize.
827    #[must_use]
828    pub const fn drag_enabled(&self) -> bool {
829        self.mouse_drag_reliable
830    }
831
832    /// Whether focus-loss auto-cancel is effective.
833    ///
834    /// When focus events are unavailable, the adapter cannot detect
835    /// window blur — interactions must rely on timeout or explicit
836    /// keyboard cancel instead.
837    #[must_use]
838    pub const fn focus_cancel_effective(&self) -> bool {
839        self.focus_events
840    }
841
842    /// Collect all active limitations with their fallback descriptions.
843    #[must_use]
844    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
845        let mut out = Vec::new();
846
847        if !self.mouse_sgr {
848            out.push(PaneCapabilityLimitation {
849                id: "no_sgr_mouse",
850                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
851                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
852            });
853        }
854
855        if !self.mouse_drag_reliable {
856            out.push(PaneCapabilityLimitation {
857                id: "mouse_drag_unreliable",
858                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
859                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
860            });
861        }
862
863        if !self.mouse_button_discrimination {
864            out.push(PaneCapabilityLimitation {
865                id: "no_button_discrimination",
866                description: "Mouse release events do not identify which button was released",
867                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
868            });
869        }
870
871        if !self.focus_events {
872            out.push(PaneCapabilityLimitation {
873                id: "no_focus_events",
874                description: "Terminal does not deliver focus-in/focus-out events",
875                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
876            });
877        }
878
879        out
880    }
881}
882
883/// Configuration for terminal-to-pane semantic input translation.
884///
885/// This adapter normalizes terminal `Event` streams into
886/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub struct PaneTerminalAdapterConfig {
889    /// Drag start threshold in pane-local units.
890    pub drag_threshold: u16,
891    /// Drag update hysteresis threshold in pane-local units.
892    pub update_hysteresis: u16,
893    /// Mouse button required to begin a drag sequence.
894    pub activation_button: PanePointerButton,
895    /// Minimum drag delta (Manhattan distance, cells) before forwarding
896    /// updates while already in the dragging state.
897    pub drag_update_coalesce_distance: u16,
898    /// Cancel active interactions on focus loss.
899    pub cancel_on_focus_lost: bool,
900    /// Cancel active interactions on terminal resize.
901    pub cancel_on_resize: bool,
902}
903
904impl Default for PaneTerminalAdapterConfig {
905    fn default() -> Self {
906        Self {
907            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
908            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
909            activation_button: PanePointerButton::Primary,
910            drag_update_coalesce_distance: 2,
911            cancel_on_focus_lost: true,
912            cancel_on_resize: true,
913        }
914    }
915}
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq)]
918struct PaneTerminalActivePointer {
919    pointer_id: u32,
920    target: PaneResizeTarget,
921    button: PanePointerButton,
922    last_position: PanePointerPosition,
923    cumulative_delta_x: i32,
924    cumulative_delta_y: i32,
925    direction_changes: u16,
926    sample_count: u32,
927    previous_step_delta_x: i32,
928    previous_step_delta_y: i32,
929}
930
931/// Lifecycle phase observed while translating a terminal event.
932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
933pub enum PaneTerminalLifecyclePhase {
934    MouseDown,
935    MouseDrag,
936    MouseMove,
937    MouseUp,
938    MouseScroll,
939    KeyResize,
940    KeyCancel,
941    FocusLoss,
942    ResizeInterrupt,
943    Other,
944}
945
946/// Deterministic reason a terminal event did not map to pane semantics.
947#[derive(Debug, Clone, Copy, PartialEq, Eq)]
948pub enum PaneTerminalIgnoredReason {
949    MissingTarget,
950    NoActivePointer,
951    PointerButtonMismatch,
952    ActivationButtonRequired,
953    UnsupportedKey,
954    FocusGainNoop,
955    ResizeNoop,
956    DragCoalesced,
957    NonSemanticEvent,
958    MachineRejectedEvent,
959}
960
961/// Translation outcome for one raw terminal event.
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum PaneTerminalLogOutcome {
964    SemanticForwarded,
965    SemanticForwardedAfterRecovery,
966    Ignored(PaneTerminalIgnoredReason),
967}
968
969/// Structured translation log entry for one raw terminal event.
970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971pub struct PaneTerminalLogEntry {
972    pub phase: PaneTerminalLifecyclePhase,
973    pub sequence: Option<u64>,
974    pub pointer_id: Option<u32>,
975    pub target: Option<PaneResizeTarget>,
976    pub recovery_cancel_sequence: Option<u64>,
977    pub outcome: PaneTerminalLogOutcome,
978}
979
980/// Output of one terminal event translation step.
981///
982/// `recovery_*` fields are populated when the adapter first emits an internal
983/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
984/// event as a fresh semantic event.
985#[derive(Debug, Clone, PartialEq)]
986pub struct PaneTerminalDispatch {
987    pub primary_event: Option<PaneSemanticInputEvent>,
988    pub primary_transition: Option<PaneDragResizeTransition>,
989    pub motion: Option<PaneMotionVector>,
990    pub inertial_throw: Option<PaneInertialThrow>,
991    pub projected_position: Option<PanePointerPosition>,
992    pub recovery_event: Option<PaneSemanticInputEvent>,
993    pub recovery_transition: Option<PaneDragResizeTransition>,
994    pub log: PaneTerminalLogEntry,
995}
996
997impl PaneTerminalDispatch {
998    fn ignored(
999        phase: PaneTerminalLifecyclePhase,
1000        reason: PaneTerminalIgnoredReason,
1001        pointer_id: Option<u32>,
1002        target: Option<PaneResizeTarget>,
1003    ) -> Self {
1004        Self {
1005            primary_event: None,
1006            primary_transition: None,
1007            motion: None,
1008            inertial_throw: None,
1009            projected_position: None,
1010            recovery_event: None,
1011            recovery_transition: None,
1012            log: PaneTerminalLogEntry {
1013                phase,
1014                sequence: None,
1015                pointer_id,
1016                target,
1017                recovery_cancel_sequence: None,
1018                outcome: PaneTerminalLogOutcome::Ignored(reason),
1019            },
1020        }
1021    }
1022
1023    fn forwarded(
1024        phase: PaneTerminalLifecyclePhase,
1025        pointer_id: Option<u32>,
1026        target: Option<PaneResizeTarget>,
1027        event: PaneSemanticInputEvent,
1028        transition: PaneDragResizeTransition,
1029    ) -> Self {
1030        let sequence = Some(event.sequence);
1031        Self {
1032            primary_event: Some(event),
1033            primary_transition: Some(transition),
1034            motion: None,
1035            inertial_throw: None,
1036            projected_position: None,
1037            recovery_event: None,
1038            recovery_transition: None,
1039            log: PaneTerminalLogEntry {
1040                phase,
1041                sequence,
1042                pointer_id,
1043                target,
1044                recovery_cancel_sequence: None,
1045                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1046            },
1047        }
1048    }
1049
1050    /// Derive dynamic snap profile from translated pointer motion.
1051    #[must_use]
1052    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1053        self.motion.map(PanePressureSnapProfile::from_motion)
1054    }
1055}
1056
1057/// Deterministic terminal adapter mapping raw `Event` values into
1058/// schema-validated pane semantic interaction events.
1059#[derive(Debug, Clone)]
1060pub struct PaneTerminalAdapter {
1061    machine: PaneDragResizeMachine,
1062    config: PaneTerminalAdapterConfig,
1063    active: Option<PaneTerminalActivePointer>,
1064    next_sequence: u64,
1065}
1066
1067impl PaneTerminalAdapter {
1068    /// Construct a new adapter with validated drag thresholds.
1069    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1070        let config = PaneTerminalAdapterConfig {
1071            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1072            ..config
1073        };
1074        let machine = PaneDragResizeMachine::new_with_hysteresis(
1075            config.drag_threshold,
1076            config.update_hysteresis,
1077        )?;
1078        Ok(Self {
1079            machine,
1080            config,
1081            active: None,
1082            next_sequence: 1,
1083        })
1084    }
1085
1086    /// Adapter configuration.
1087    #[must_use]
1088    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1089        self.config
1090    }
1091
1092    /// Active pointer id currently tracked by the adapter, if any.
1093    #[must_use]
1094    pub fn active_pointer_id(&self) -> Option<u32> {
1095        self.active.map(|active| active.pointer_id)
1096    }
1097
1098    /// Current pane drag/resize machine state.
1099    #[must_use]
1100    pub const fn machine_state(&self) -> PaneDragResizeState {
1101        self.machine.state()
1102    }
1103
1104    /// Translate one raw terminal event into pane semantic event(s).
1105    ///
1106    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1107    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1108    pub fn translate(
1109        &mut self,
1110        event: &Event,
1111        target_hint: Option<PaneResizeTarget>,
1112    ) -> PaneTerminalDispatch {
1113        match event {
1114            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1115            Event::Key(key) => self.translate_key(*key, target_hint),
1116            Event::Focus(focused) => self.translate_focus(*focused),
1117            Event::Resize { .. } => self.translate_resize(),
1118            _ => PaneTerminalDispatch::ignored(
1119                PaneTerminalLifecyclePhase::Other,
1120                PaneTerminalIgnoredReason::NonSemanticEvent,
1121                None,
1122                target_hint,
1123            ),
1124        }
1125    }
1126
1127    /// Translate one raw terminal event while resolving splitter targets from
1128    /// terminal hit regions.
1129    ///
1130    /// This is a convenience wrapper for host code that already has splitter
1131    /// handle regions from [`pane_terminal_splitter_handles`].
1132    pub fn translate_with_handles(
1133        &mut self,
1134        event: &Event,
1135        handles: &[PaneTerminalSplitterHandle],
1136    ) -> PaneTerminalDispatch {
1137        let active_target = self.active.map(|active| active.target);
1138        let target_hint = match event {
1139            Event::Mouse(mouse) => {
1140                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1141                match mouse.kind {
1142                    MouseEventKind::Down(_)
1143                    | MouseEventKind::ScrollUp
1144                    | MouseEventKind::ScrollDown
1145                    | MouseEventKind::ScrollLeft
1146                    | MouseEventKind::ScrollRight => resolved,
1147                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1148                        resolved.or(active_target)
1149                    }
1150                }
1151            }
1152            Event::Key(_) => active_target,
1153            _ => None,
1154        };
1155        self.translate(event, target_hint)
1156    }
1157
1158    fn translate_mouse(
1159        &mut self,
1160        mouse: MouseEvent,
1161        target_hint: Option<PaneResizeTarget>,
1162    ) -> PaneTerminalDispatch {
1163        let position = mouse_position(mouse);
1164        let modifiers = pane_modifiers(mouse.modifiers);
1165        match mouse.kind {
1166            MouseEventKind::Down(button) => {
1167                let pane_button = pane_button(button);
1168                if pane_button != self.config.activation_button {
1169                    return PaneTerminalDispatch::ignored(
1170                        PaneTerminalLifecyclePhase::MouseDown,
1171                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1172                        Some(pointer_id_for_button(pane_button)),
1173                        target_hint,
1174                    );
1175                }
1176                let Some(target) = target_hint else {
1177                    return PaneTerminalDispatch::ignored(
1178                        PaneTerminalLifecyclePhase::MouseDown,
1179                        PaneTerminalIgnoredReason::MissingTarget,
1180                        Some(pointer_id_for_button(pane_button)),
1181                        None,
1182                    );
1183                };
1184
1185                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1186                let pointer_id = pointer_id_for_button(pane_button);
1187                let kind = PaneSemanticInputEventKind::PointerDown {
1188                    target,
1189                    pointer_id,
1190                    button: pane_button,
1191                    position,
1192                };
1193                let mut dispatch = self.forward_semantic(
1194                    PaneTerminalLifecyclePhase::MouseDown,
1195                    Some(pointer_id),
1196                    Some(target),
1197                    kind,
1198                    modifiers,
1199                );
1200                if dispatch.primary_transition.is_some() {
1201                    self.active = Some(PaneTerminalActivePointer {
1202                        pointer_id,
1203                        target,
1204                        button: pane_button,
1205                        last_position: position,
1206                        cumulative_delta_x: 0,
1207                        cumulative_delta_y: 0,
1208                        direction_changes: 0,
1209                        sample_count: 0,
1210                        previous_step_delta_x: 0,
1211                        previous_step_delta_y: 0,
1212                    });
1213                }
1214                if let Some((cancel_event, cancel_transition)) = recovery {
1215                    dispatch.recovery_event = Some(cancel_event);
1216                    dispatch.recovery_transition = Some(cancel_transition);
1217                    dispatch.log.recovery_cancel_sequence =
1218                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1219                    if matches!(
1220                        dispatch.log.outcome,
1221                        PaneTerminalLogOutcome::SemanticForwarded
1222                    ) {
1223                        dispatch.log.outcome =
1224                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1225                    }
1226                }
1227                dispatch
1228            }
1229            MouseEventKind::Drag(button) => {
1230                let pane_button = pane_button(button);
1231                let Some(mut active) = self.active else {
1232                    return PaneTerminalDispatch::ignored(
1233                        PaneTerminalLifecyclePhase::MouseDrag,
1234                        PaneTerminalIgnoredReason::NoActivePointer,
1235                        Some(pointer_id_for_button(pane_button)),
1236                        target_hint,
1237                    );
1238                };
1239                if active.button != pane_button {
1240                    return PaneTerminalDispatch::ignored(
1241                        PaneTerminalLifecyclePhase::MouseDrag,
1242                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1243                        Some(pointer_id_for_button(pane_button)),
1244                        Some(active.target),
1245                    );
1246                }
1247                let delta_x = position.x.saturating_sub(active.last_position.x);
1248                let delta_y = position.y.saturating_sub(active.last_position.y);
1249                if self.should_coalesce_drag(delta_x, delta_y) {
1250                    return PaneTerminalDispatch::ignored(
1251                        PaneTerminalLifecyclePhase::MouseDrag,
1252                        PaneTerminalIgnoredReason::DragCoalesced,
1253                        Some(active.pointer_id),
1254                        Some(active.target),
1255                    );
1256                }
1257                if active.sample_count > 0 {
1258                    let flipped_x = delta_x.signum() != 0
1259                        && active.previous_step_delta_x.signum() != 0
1260                        && delta_x.signum() != active.previous_step_delta_x.signum();
1261                    let flipped_y = delta_y.signum() != 0
1262                        && active.previous_step_delta_y.signum() != 0
1263                        && delta_y.signum() != active.previous_step_delta_y.signum();
1264                    if flipped_x || flipped_y {
1265                        active.direction_changes = active.direction_changes.saturating_add(1);
1266                    }
1267                }
1268                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1269                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1270                active.sample_count = active.sample_count.saturating_add(1);
1271                active.previous_step_delta_x = delta_x;
1272                active.previous_step_delta_y = delta_y;
1273                let kind = PaneSemanticInputEventKind::PointerMove {
1274                    target: active.target,
1275                    pointer_id: active.pointer_id,
1276                    position,
1277                    delta_x,
1278                    delta_y,
1279                };
1280                let mut dispatch = self.forward_semantic(
1281                    PaneTerminalLifecyclePhase::MouseDrag,
1282                    Some(active.pointer_id),
1283                    Some(active.target),
1284                    kind,
1285                    modifiers,
1286                );
1287                if dispatch.primary_transition.is_some() {
1288                    active.last_position = position;
1289                    self.active = Some(active);
1290                    dispatch.motion = Some(PaneMotionVector::from_delta(
1291                        active.cumulative_delta_x,
1292                        active.cumulative_delta_y,
1293                        active.sample_count.saturating_mul(16),
1294                        active.direction_changes,
1295                    ));
1296                }
1297                dispatch
1298            }
1299            MouseEventKind::Moved => {
1300                let Some(mut active) = self.active else {
1301                    return PaneTerminalDispatch::ignored(
1302                        PaneTerminalLifecyclePhase::MouseMove,
1303                        PaneTerminalIgnoredReason::NoActivePointer,
1304                        None,
1305                        target_hint,
1306                    );
1307                };
1308                let delta_x = position.x.saturating_sub(active.last_position.x);
1309                let delta_y = position.y.saturating_sub(active.last_position.y);
1310                if self.should_coalesce_drag(delta_x, delta_y) {
1311                    return PaneTerminalDispatch::ignored(
1312                        PaneTerminalLifecyclePhase::MouseMove,
1313                        PaneTerminalIgnoredReason::DragCoalesced,
1314                        Some(active.pointer_id),
1315                        Some(active.target),
1316                    );
1317                }
1318                if active.sample_count > 0 {
1319                    let flipped_x = delta_x.signum() != 0
1320                        && active.previous_step_delta_x.signum() != 0
1321                        && delta_x.signum() != active.previous_step_delta_x.signum();
1322                    let flipped_y = delta_y.signum() != 0
1323                        && active.previous_step_delta_y.signum() != 0
1324                        && delta_y.signum() != active.previous_step_delta_y.signum();
1325                    if flipped_x || flipped_y {
1326                        active.direction_changes = active.direction_changes.saturating_add(1);
1327                    }
1328                }
1329                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1330                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1331                active.sample_count = active.sample_count.saturating_add(1);
1332                active.previous_step_delta_x = delta_x;
1333                active.previous_step_delta_y = delta_y;
1334                let kind = PaneSemanticInputEventKind::PointerMove {
1335                    target: active.target,
1336                    pointer_id: active.pointer_id,
1337                    position,
1338                    delta_x,
1339                    delta_y,
1340                };
1341                let mut dispatch = self.forward_semantic(
1342                    PaneTerminalLifecyclePhase::MouseMove,
1343                    Some(active.pointer_id),
1344                    Some(active.target),
1345                    kind,
1346                    modifiers,
1347                );
1348                if dispatch.primary_transition.is_some() {
1349                    active.last_position = position;
1350                    self.active = Some(active);
1351                    dispatch.motion = Some(PaneMotionVector::from_delta(
1352                        active.cumulative_delta_x,
1353                        active.cumulative_delta_y,
1354                        active.sample_count.saturating_mul(16),
1355                        active.direction_changes,
1356                    ));
1357                }
1358                dispatch
1359            }
1360            MouseEventKind::Up(button) => {
1361                let pane_button = pane_button(button);
1362                let Some(active) = self.active else {
1363                    return PaneTerminalDispatch::ignored(
1364                        PaneTerminalLifecyclePhase::MouseUp,
1365                        PaneTerminalIgnoredReason::NoActivePointer,
1366                        Some(pointer_id_for_button(pane_button)),
1367                        target_hint,
1368                    );
1369                };
1370                if active.button != pane_button {
1371                    return PaneTerminalDispatch::ignored(
1372                        PaneTerminalLifecyclePhase::MouseUp,
1373                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1374                        Some(pointer_id_for_button(pane_button)),
1375                        Some(active.target),
1376                    );
1377                }
1378                let kind = PaneSemanticInputEventKind::PointerUp {
1379                    target: active.target,
1380                    pointer_id: active.pointer_id,
1381                    button: active.button,
1382                    position,
1383                };
1384                let mut dispatch = self.forward_semantic(
1385                    PaneTerminalLifecyclePhase::MouseUp,
1386                    Some(active.pointer_id),
1387                    Some(active.target),
1388                    kind,
1389                    modifiers,
1390                );
1391                if dispatch.primary_transition.is_some() {
1392                    let motion = PaneMotionVector::from_delta(
1393                        active.cumulative_delta_x,
1394                        active.cumulative_delta_y,
1395                        active.sample_count.saturating_mul(16),
1396                        active.direction_changes,
1397                    );
1398                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1399                    dispatch.motion = Some(motion);
1400                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1401                    dispatch.inertial_throw = Some(inertial_throw);
1402                    self.active = None;
1403                }
1404                dispatch
1405            }
1406            MouseEventKind::ScrollUp
1407            | MouseEventKind::ScrollDown
1408            | MouseEventKind::ScrollLeft
1409            | MouseEventKind::ScrollRight => {
1410                let target = target_hint.or(self.active.map(|active| active.target));
1411                let Some(target) = target else {
1412                    return PaneTerminalDispatch::ignored(
1413                        PaneTerminalLifecyclePhase::MouseScroll,
1414                        PaneTerminalIgnoredReason::MissingTarget,
1415                        None,
1416                        None,
1417                    );
1418                };
1419                let lines = match mouse.kind {
1420                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1421                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1422                    _ => unreachable!("handled by outer match"),
1423                };
1424                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1425                self.forward_semantic(
1426                    PaneTerminalLifecyclePhase::MouseScroll,
1427                    None,
1428                    Some(target),
1429                    kind,
1430                    modifiers,
1431                )
1432            }
1433        }
1434    }
1435
1436    fn translate_key(
1437        &mut self,
1438        key: KeyEvent,
1439        target_hint: Option<PaneResizeTarget>,
1440    ) -> PaneTerminalDispatch {
1441        if key.kind == KeyEventKind::Release {
1442            return PaneTerminalDispatch::ignored(
1443                PaneTerminalLifecyclePhase::Other,
1444                PaneTerminalIgnoredReason::UnsupportedKey,
1445                None,
1446                target_hint,
1447            );
1448        }
1449        if matches!(key.code, KeyCode::Escape) {
1450            return self.cancel_active_dispatch(
1451                PaneTerminalLifecyclePhase::KeyCancel,
1452                PaneCancelReason::EscapeKey,
1453                PaneTerminalIgnoredReason::NoActivePointer,
1454            );
1455        }
1456        let target = target_hint.or(self.active.map(|active| active.target));
1457        let Some(target) = target else {
1458            return PaneTerminalDispatch::ignored(
1459                PaneTerminalLifecyclePhase::KeyResize,
1460                PaneTerminalIgnoredReason::MissingTarget,
1461                None,
1462                None,
1463            );
1464        };
1465        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1466            return PaneTerminalDispatch::ignored(
1467                PaneTerminalLifecyclePhase::KeyResize,
1468                PaneTerminalIgnoredReason::UnsupportedKey,
1469                None,
1470                Some(target),
1471            );
1472        };
1473        let units = keyboard_resize_units(key.modifiers);
1474        let kind = PaneSemanticInputEventKind::KeyboardResize {
1475            target,
1476            direction,
1477            units,
1478        };
1479        self.forward_semantic(
1480            PaneTerminalLifecyclePhase::KeyResize,
1481            self.active_pointer_id(),
1482            Some(target),
1483            kind,
1484            pane_modifiers(key.modifiers),
1485        )
1486    }
1487
1488    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1489        if focused {
1490            return PaneTerminalDispatch::ignored(
1491                PaneTerminalLifecyclePhase::Other,
1492                PaneTerminalIgnoredReason::FocusGainNoop,
1493                self.active_pointer_id(),
1494                self.active.map(|active| active.target),
1495            );
1496        }
1497        if !self.config.cancel_on_focus_lost {
1498            return PaneTerminalDispatch::ignored(
1499                PaneTerminalLifecyclePhase::FocusLoss,
1500                PaneTerminalIgnoredReason::ResizeNoop,
1501                self.active_pointer_id(),
1502                self.active.map(|active| active.target),
1503            );
1504        }
1505        self.cancel_active_dispatch(
1506            PaneTerminalLifecyclePhase::FocusLoss,
1507            PaneCancelReason::FocusLost,
1508            PaneTerminalIgnoredReason::NoActivePointer,
1509        )
1510    }
1511
1512    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1513        if !self.config.cancel_on_resize {
1514            return PaneTerminalDispatch::ignored(
1515                PaneTerminalLifecyclePhase::ResizeInterrupt,
1516                PaneTerminalIgnoredReason::ResizeNoop,
1517                self.active_pointer_id(),
1518                self.active.map(|active| active.target),
1519            );
1520        }
1521        self.cancel_active_dispatch(
1522            PaneTerminalLifecyclePhase::ResizeInterrupt,
1523            PaneCancelReason::Programmatic,
1524            PaneTerminalIgnoredReason::ResizeNoop,
1525        )
1526    }
1527
1528    fn cancel_active_dispatch(
1529        &mut self,
1530        phase: PaneTerminalLifecyclePhase,
1531        reason: PaneCancelReason,
1532        no_active_reason: PaneTerminalIgnoredReason,
1533    ) -> PaneTerminalDispatch {
1534        let Some(active) = self.active else {
1535            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1536        };
1537        let kind = PaneSemanticInputEventKind::Cancel {
1538            target: Some(active.target),
1539            reason,
1540        };
1541        let dispatch = self.forward_semantic(
1542            phase,
1543            Some(active.pointer_id),
1544            Some(active.target),
1545            kind,
1546            PaneModifierSnapshot::default(),
1547        );
1548        if dispatch.primary_transition.is_some() {
1549            self.active = None;
1550        }
1551        dispatch
1552    }
1553
1554    fn cancel_active_internal(
1555        &mut self,
1556        reason: PaneCancelReason,
1557    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1558        let active = self.active?;
1559        let kind = PaneSemanticInputEventKind::Cancel {
1560            target: Some(active.target),
1561            reason,
1562        };
1563        let result = self
1564            .apply_semantic(kind, PaneModifierSnapshot::default())
1565            .ok();
1566        if result.is_some() {
1567            self.active = None;
1568        }
1569        result
1570    }
1571
1572    fn forward_semantic(
1573        &mut self,
1574        phase: PaneTerminalLifecyclePhase,
1575        pointer_id: Option<u32>,
1576        target: Option<PaneResizeTarget>,
1577        kind: PaneSemanticInputEventKind,
1578        modifiers: PaneModifierSnapshot,
1579    ) -> PaneTerminalDispatch {
1580        match self.apply_semantic(kind, modifiers) {
1581            Ok((event, transition)) => {
1582                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1583            }
1584            Err(_) => PaneTerminalDispatch::ignored(
1585                phase,
1586                PaneTerminalIgnoredReason::MachineRejectedEvent,
1587                pointer_id,
1588                target,
1589            ),
1590        }
1591    }
1592
1593    fn apply_semantic(
1594        &mut self,
1595        kind: PaneSemanticInputEventKind,
1596        modifiers: PaneModifierSnapshot,
1597    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1598    {
1599        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1600        event.modifiers = modifiers;
1601        let transition = self.machine.apply_event(&event)?;
1602        Ok((event, transition))
1603    }
1604
1605    fn next_sequence(&mut self) -> u64 {
1606        let sequence = self.next_sequence;
1607        self.next_sequence = self.next_sequence.saturating_add(1);
1608        sequence
1609    }
1610
1611    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1612        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1613            return false;
1614        }
1615        let movement = delta_x
1616            .unsigned_abs()
1617            .saturating_add(delta_y.unsigned_abs());
1618        movement < u32::from(self.config.drag_update_coalesce_distance)
1619    }
1620
1621    /// Force-cancel any active pane interaction and return diagnostic info.
1622    ///
1623    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1624    /// handlers, panic hooks) where constructing a proper semantic event is
1625    /// not feasible. It resets both the underlying drag/resize state machine
1626    /// and the adapter's active-pointer tracking.
1627    ///
1628    /// Returns `None` if no interaction was active.
1629    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1630        let was_active = self.active.is_some();
1631        let machine_state_before = self.machine.state();
1632        let machine_transition = self.machine.force_cancel();
1633        let active_pointer = self.active.take();
1634        if !was_active && machine_transition.is_none() {
1635            return None;
1636        }
1637        Some(PaneCleanupDiagnostics {
1638            had_active_pointer: was_active,
1639            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1640            machine_state_before,
1641            machine_transition,
1642        })
1643    }
1644}
1645
1646/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1647///
1648/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1649/// so callers can convert this struct to JSON for evidence logging.
1650#[derive(Debug, Clone, PartialEq, Eq)]
1651pub struct PaneCleanupDiagnostics {
1652    /// Whether the adapter had an active pointer tracker when cleanup ran.
1653    pub had_active_pointer: bool,
1654    /// The pointer ID that was active (if any).
1655    pub active_pointer_id: Option<u32>,
1656    /// The machine state before force-cancel was applied.
1657    pub machine_state_before: PaneDragResizeState,
1658    /// The transition produced by force-cancel, or `None` if the machine
1659    /// was already idle.
1660    pub machine_transition: Option<PaneDragResizeTransition>,
1661}
1662
1663/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1664///
1665/// When a pane interaction session is active and the guard drops (due to
1666/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1667/// drag/resize and collects cleanup diagnostics.
1668///
1669/// # Usage
1670///
1671/// ```ignore
1672/// let guard = PaneInteractionGuard::new(&mut adapter);
1673/// // ... pane interaction event loop ...
1674/// // If this scope panics, guard's Drop will force-cancel the drag machine
1675/// let diagnostics = guard.finish(); // explicit clean finish
1676/// ```
1677pub struct PaneInteractionGuard<'a> {
1678    adapter: &'a mut PaneTerminalAdapter,
1679    finished: bool,
1680    diagnostics: Option<PaneCleanupDiagnostics>,
1681}
1682
1683impl<'a> PaneInteractionGuard<'a> {
1684    /// Create a new guard wrapping the given adapter.
1685    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1686        Self {
1687            adapter,
1688            finished: false,
1689            diagnostics: None,
1690        }
1691    }
1692
1693    /// Access the wrapped adapter for normal event translation.
1694    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1695        self.adapter
1696    }
1697
1698    /// Explicitly finish the guard, returning any cleanup diagnostics.
1699    ///
1700    /// Calling `finish()` is optional — the guard will also clean up on drop.
1701    /// However, `finish()` gives the caller access to the diagnostics.
1702    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1703        self.finished = true;
1704        let diagnostics = self.adapter.force_cancel_all();
1705        self.diagnostics = diagnostics.clone();
1706        diagnostics
1707    }
1708}
1709
1710impl Drop for PaneInteractionGuard<'_> {
1711    fn drop(&mut self) {
1712        if !self.finished {
1713            self.diagnostics = self.adapter.force_cancel_all();
1714        }
1715    }
1716}
1717
1718fn pane_button(button: MouseButton) -> PanePointerButton {
1719    match button {
1720        MouseButton::Left => PanePointerButton::Primary,
1721        MouseButton::Right => PanePointerButton::Secondary,
1722        MouseButton::Middle => PanePointerButton::Middle,
1723    }
1724}
1725
1726fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1727    match button {
1728        PanePointerButton::Primary => 1,
1729        PanePointerButton::Secondary => 2,
1730        PanePointerButton::Middle => 3,
1731    }
1732}
1733
1734fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1735    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1736}
1737
1738fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1739    PaneModifierSnapshot {
1740        shift: modifiers.contains(Modifiers::SHIFT),
1741        alt: modifiers.contains(Modifiers::ALT),
1742        ctrl: modifiers.contains(Modifiers::CTRL),
1743        meta: modifiers.contains(Modifiers::SUPER),
1744    }
1745}
1746
1747fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1748    match (axis, code) {
1749        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1750        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1751        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1752        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1753        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1754        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1755        _ => None,
1756    }
1757}
1758
1759fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1760    if modifiers.contains(Modifiers::SHIFT) {
1761        5
1762    } else {
1763        1
1764    }
1765}
1766
1767/// Configuration for state persistence in the program runtime.
1768///
1769/// Controls when and how widget state is saved/restored.
1770#[derive(Clone)]
1771pub struct PersistenceConfig {
1772    /// State registry for persistence. If None, persistence is disabled.
1773    pub registry: Option<std::sync::Arc<StateRegistry>>,
1774    /// Interval for periodic checkpoint saves. None disables checkpoints.
1775    pub checkpoint_interval: Option<Duration>,
1776    /// Automatically load state on program start.
1777    pub auto_load: bool,
1778    /// Automatically save state on program exit.
1779    pub auto_save: bool,
1780}
1781
1782impl Default for PersistenceConfig {
1783    fn default() -> Self {
1784        Self {
1785            registry: None,
1786            checkpoint_interval: None,
1787            auto_load: true,
1788            auto_save: true,
1789        }
1790    }
1791}
1792
1793impl std::fmt::Debug for PersistenceConfig {
1794    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1795        f.debug_struct("PersistenceConfig")
1796            .field(
1797                "registry",
1798                &self.registry.as_ref().map(|r| r.backend_name()),
1799            )
1800            .field("checkpoint_interval", &self.checkpoint_interval)
1801            .field("auto_load", &self.auto_load)
1802            .field("auto_save", &self.auto_save)
1803            .finish()
1804    }
1805}
1806
1807impl PersistenceConfig {
1808    /// Create a disabled persistence config.
1809    #[must_use]
1810    pub fn disabled() -> Self {
1811        Self::default()
1812    }
1813
1814    /// Create a persistence config with the given registry.
1815    #[must_use]
1816    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1817        Self {
1818            registry: Some(registry),
1819            ..Default::default()
1820        }
1821    }
1822
1823    /// Set the checkpoint interval.
1824    #[must_use]
1825    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1826        self.checkpoint_interval = Some(interval);
1827        self
1828    }
1829
1830    /// Enable or disable auto-load on start.
1831    #[must_use]
1832    pub fn auto_load(mut self, enabled: bool) -> Self {
1833        self.auto_load = enabled;
1834        self
1835    }
1836
1837    /// Enable or disable auto-save on exit.
1838    #[must_use]
1839    pub fn auto_save(mut self, enabled: bool) -> Self {
1840        self.auto_save = enabled;
1841        self
1842    }
1843}
1844
1845/// Configuration for widget refresh selection under render budget.
1846///
1847/// Defaults are conservative and deterministic:
1848/// - enabled: true
1849/// - staleness_window_ms: 1_000
1850/// - starve_ms: 3_000
1851/// - max_starved_per_frame: 2
1852/// - max_drop_fraction: 1.0 (disabled)
1853/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1854/// - starve_boost: 1.5
1855/// - min_cost_us: 1.0
1856#[derive(Debug, Clone)]
1857pub struct WidgetRefreshConfig {
1858    /// Enable budgeted widget refresh selection.
1859    pub enabled: bool,
1860    /// Staleness decay window (ms) used to normalize staleness scores.
1861    pub staleness_window_ms: u64,
1862    /// Staleness threshold that triggers starvation guard (ms).
1863    pub starve_ms: u64,
1864    /// Maximum number of starved widgets to force in per frame.
1865    pub max_starved_per_frame: usize,
1866    /// Maximum fraction of non-essential widgets that may be dropped.
1867    /// Set to 1.0 to disable the guardrail.
1868    pub max_drop_fraction: f32,
1869    /// Weight for base priority signal.
1870    pub weight_priority: f32,
1871    /// Weight for staleness signal.
1872    pub weight_staleness: f32,
1873    /// Weight for focus boost.
1874    pub weight_focus: f32,
1875    /// Weight for interaction boost.
1876    pub weight_interaction: f32,
1877    /// Additive boost to value for starved widgets.
1878    pub starve_boost: f32,
1879    /// Minimum cost (us) to avoid divide-by-zero.
1880    pub min_cost_us: f32,
1881}
1882
1883impl Default for WidgetRefreshConfig {
1884    fn default() -> Self {
1885        Self {
1886            enabled: true,
1887            staleness_window_ms: 1_000,
1888            starve_ms: 3_000,
1889            max_starved_per_frame: 2,
1890            max_drop_fraction: 1.0,
1891            weight_priority: 1.0,
1892            weight_staleness: 0.5,
1893            weight_focus: 0.75,
1894            weight_interaction: 0.5,
1895            starve_boost: 1.5,
1896            min_cost_us: 1.0,
1897        }
1898    }
1899}
1900
1901/// Configuration for effect queue scheduling.
1902#[derive(Debug, Clone)]
1903pub struct EffectQueueConfig {
1904    /// Whether effect queue scheduling is enabled.
1905    pub enabled: bool,
1906    /// Scheduler configuration (Smith's rule by default).
1907    pub scheduler: SchedulerConfig,
1908}
1909
1910impl Default for EffectQueueConfig {
1911    fn default() -> Self {
1912        let scheduler = SchedulerConfig {
1913            smith_enabled: true,
1914            force_fifo: false,
1915            preemptive: false,
1916            aging_factor: 0.0,
1917            wait_starve_ms: 0.0,
1918            enable_logging: false,
1919            ..Default::default()
1920        };
1921        Self {
1922            enabled: false,
1923            scheduler,
1924        }
1925    }
1926}
1927
1928impl EffectQueueConfig {
1929    /// Enable effect queue scheduling with the provided scheduler config.
1930    #[must_use]
1931    pub fn with_enabled(mut self, enabled: bool) -> Self {
1932        self.enabled = enabled;
1933        self
1934    }
1935
1936    /// Override the scheduler configuration.
1937    #[must_use]
1938    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
1939        self.scheduler = scheduler;
1940        self
1941    }
1942}
1943
1944/// Configuration for the program runtime.
1945#[derive(Debug, Clone)]
1946pub struct ProgramConfig {
1947    /// Screen mode (inline or alternate screen).
1948    pub screen_mode: ScreenMode,
1949    /// UI anchor for inline mode.
1950    pub ui_anchor: UiAnchor,
1951    /// Frame budget configuration.
1952    pub budget: FrameBudgetConfig,
1953    /// Diff strategy configuration for the terminal writer.
1954    pub diff_config: RuntimeDiffConfig,
1955    /// Evidence JSONL sink configuration.
1956    pub evidence_sink: EvidenceSinkConfig,
1957    /// Render-trace recorder configuration.
1958    pub render_trace: RenderTraceConfig,
1959    /// Optional frame timing sink.
1960    pub frame_timing: Option<FrameTimingConfig>,
1961    /// Conformal predictor configuration for frame-time risk gating.
1962    pub conformal_config: Option<ConformalConfig>,
1963    /// Locale context used for rendering.
1964    pub locale_context: LocaleContext,
1965    /// Input poll timeout.
1966    pub poll_timeout: Duration,
1967    /// Resize coalescer configuration.
1968    pub resize_coalescer: CoalescerConfig,
1969    /// Resize handling behavior (immediate/throttled).
1970    pub resize_behavior: ResizeBehavior,
1971    /// Forced terminal size override (when set, resize events are ignored).
1972    pub forced_size: Option<(u16, u16)>,
1973    /// Mouse capture policy (`Auto`, `On`, `Off`).
1974    ///
1975    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
1976    pub mouse_capture_policy: MouseCapturePolicy,
1977    /// Enable bracketed paste.
1978    pub bracketed_paste: bool,
1979    /// Enable focus reporting.
1980    pub focus_reporting: bool,
1981    /// Enable Kitty keyboard protocol (repeat/release events).
1982    pub kitty_keyboard: bool,
1983    /// State persistence configuration.
1984    pub persistence: PersistenceConfig,
1985    /// Inline auto UI height remeasurement policy.
1986    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
1987    /// Widget refresh selection configuration.
1988    pub widget_refresh: WidgetRefreshConfig,
1989    /// Effect queue scheduling configuration.
1990    pub effect_queue: EffectQueueConfig,
1991}
1992
1993impl Default for ProgramConfig {
1994    fn default() -> Self {
1995        Self {
1996            screen_mode: ScreenMode::Inline { ui_height: 4 },
1997            ui_anchor: UiAnchor::Bottom,
1998            budget: FrameBudgetConfig::default(),
1999            diff_config: RuntimeDiffConfig::default(),
2000            evidence_sink: EvidenceSinkConfig::default(),
2001            render_trace: RenderTraceConfig::default(),
2002            frame_timing: None,
2003            conformal_config: None,
2004            locale_context: LocaleContext::global(),
2005            poll_timeout: Duration::from_millis(100),
2006            resize_coalescer: CoalescerConfig::default(),
2007            resize_behavior: ResizeBehavior::Throttled,
2008            forced_size: None,
2009            mouse_capture_policy: MouseCapturePolicy::Auto,
2010            bracketed_paste: true,
2011            focus_reporting: false,
2012            kitty_keyboard: false,
2013            persistence: PersistenceConfig::default(),
2014            inline_auto_remeasure: None,
2015            widget_refresh: WidgetRefreshConfig::default(),
2016            effect_queue: EffectQueueConfig::default(),
2017        }
2018    }
2019}
2020
2021impl ProgramConfig {
2022    /// Create config for fullscreen applications.
2023    pub fn fullscreen() -> Self {
2024        Self {
2025            screen_mode: ScreenMode::AltScreen,
2026            ..Default::default()
2027        }
2028    }
2029
2030    /// Create config for inline mode with specified height.
2031    pub fn inline(height: u16) -> Self {
2032        Self {
2033            screen_mode: ScreenMode::Inline { ui_height: height },
2034            ..Default::default()
2035        }
2036    }
2037
2038    /// Create config for inline mode with automatic UI height.
2039    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
2040        Self {
2041            screen_mode: ScreenMode::InlineAuto {
2042                min_height,
2043                max_height,
2044            },
2045            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
2046            ..Default::default()
2047        }
2048    }
2049
2050    /// Enable mouse support.
2051    #[must_use]
2052    pub fn with_mouse(mut self) -> Self {
2053        self.mouse_capture_policy = MouseCapturePolicy::On;
2054        self
2055    }
2056
2057    /// Set mouse capture policy.
2058    #[must_use]
2059    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
2060        self.mouse_capture_policy = policy;
2061        self
2062    }
2063
2064    /// Force mouse capture enabled/disabled regardless of screen mode.
2065    #[must_use]
2066    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
2067        self.mouse_capture_policy = if enabled {
2068            MouseCapturePolicy::On
2069        } else {
2070            MouseCapturePolicy::Off
2071        };
2072        self
2073    }
2074
2075    /// Resolve mouse capture using the configured policy and screen mode.
2076    #[must_use]
2077    pub const fn resolved_mouse_capture(&self) -> bool {
2078        self.mouse_capture_policy.resolve(self.screen_mode)
2079    }
2080
2081    /// Set the budget configuration.
2082    #[must_use]
2083    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2084        self.budget = budget;
2085        self
2086    }
2087
2088    /// Set the diff strategy configuration for the terminal writer.
2089    #[must_use]
2090    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
2091        self.diff_config = diff_config;
2092        self
2093    }
2094
2095    /// Set the evidence JSONL sink configuration.
2096    #[must_use]
2097    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2098        self.evidence_sink = config;
2099        self
2100    }
2101
2102    /// Set the render-trace recorder configuration.
2103    #[must_use]
2104    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2105        self.render_trace = config;
2106        self
2107    }
2108
2109    /// Set a frame timing sink for per-frame profiling.
2110    #[must_use]
2111    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
2112        self.frame_timing = Some(config);
2113        self
2114    }
2115
2116    /// Enable conformal frame-time risk gating with the given config.
2117    #[must_use]
2118    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
2119        self.conformal_config = Some(config);
2120        self
2121    }
2122
2123    /// Disable conformal frame-time risk gating.
2124    #[must_use]
2125    pub fn without_conformal(mut self) -> Self {
2126        self.conformal_config = None;
2127        self
2128    }
2129
2130    /// Set the locale context used for rendering.
2131    #[must_use]
2132    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2133        self.locale_context = locale_context;
2134        self
2135    }
2136
2137    /// Set the base locale used for rendering.
2138    #[must_use]
2139    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2140        self.locale_context = LocaleContext::new(locale);
2141        self
2142    }
2143
2144    /// Set the widget refresh selection configuration.
2145    #[must_use]
2146    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2147        self.widget_refresh = config;
2148        self
2149    }
2150
2151    /// Set the effect queue scheduling configuration.
2152    #[must_use]
2153    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2154        self.effect_queue = config;
2155        self
2156    }
2157
2158    /// Set the resize coalescer configuration.
2159    #[must_use]
2160    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2161        self.resize_coalescer = config;
2162        self
2163    }
2164
2165    /// Set the resize handling behavior.
2166    #[must_use]
2167    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2168        self.resize_behavior = behavior;
2169        self
2170    }
2171
2172    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
2173    #[must_use]
2174    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
2175        let width = width.max(1);
2176        let height = height.max(1);
2177        self.forced_size = Some((width, height));
2178        self
2179    }
2180
2181    /// Clear any forced terminal size override.
2182    #[must_use]
2183    pub fn without_forced_size(mut self) -> Self {
2184        self.forced_size = None;
2185        self
2186    }
2187
2188    /// Toggle legacy immediate-resize behavior for migration.
2189    #[must_use]
2190    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
2191        if enabled {
2192            self.resize_behavior = ResizeBehavior::Immediate;
2193        }
2194        self
2195    }
2196
2197    /// Set the persistence configuration.
2198    #[must_use]
2199    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
2200        self.persistence = persistence;
2201        self
2202    }
2203
2204    /// Enable persistence with the given registry.
2205    #[must_use]
2206    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
2207        self.persistence = PersistenceConfig::with_registry(registry);
2208        self
2209    }
2210
2211    /// Enable inline auto UI height remeasurement with the given policy.
2212    #[must_use]
2213    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2214        self.inline_auto_remeasure = Some(config);
2215        self
2216    }
2217
2218    /// Disable inline auto UI height remeasurement.
2219    #[must_use]
2220    pub fn without_inline_auto_remeasure(mut self) -> Self {
2221        self.inline_auto_remeasure = None;
2222        self
2223    }
2224}
2225
2226enum EffectCommand<M> {
2227    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
2228    Shutdown,
2229}
2230
2231struct EffectQueue<M: Send + 'static> {
2232    sender: mpsc::Sender<EffectCommand<M>>,
2233    handle: Option<JoinHandle<()>>,
2234}
2235
2236impl<M: Send + 'static> EffectQueue<M> {
2237    fn start(
2238        config: EffectQueueConfig,
2239        result_sender: mpsc::Sender<M>,
2240        evidence_sink: Option<EvidenceSink>,
2241    ) -> io::Result<Self> {
2242        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
2243        let handle = thread::Builder::new()
2244            .name("ftui-effects".into())
2245            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
2246
2247        Ok(Self {
2248            sender: tx,
2249            handle: Some(handle),
2250        })
2251    }
2252
2253    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
2254        let _ = self.sender.send(EffectCommand::Enqueue(spec, task));
2255    }
2256
2257    fn shutdown(&mut self) {
2258        let _ = self.sender.send(EffectCommand::Shutdown);
2259        if let Some(handle) = self.handle.take() {
2260            let _ = handle.join();
2261        }
2262    }
2263}
2264
2265impl<M: Send + 'static> Drop for EffectQueue<M> {
2266    fn drop(&mut self) {
2267        self.shutdown();
2268    }
2269}
2270
2271fn effect_queue_loop<M: Send + 'static>(
2272    config: EffectQueueConfig,
2273    rx: mpsc::Receiver<EffectCommand<M>>,
2274    result_sender: mpsc::Sender<M>,
2275    evidence_sink: Option<EvidenceSink>,
2276) {
2277    let mut scheduler = QueueingScheduler::new(config.scheduler);
2278    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
2279
2280    loop {
2281        if tasks.is_empty() {
2282            match rx.recv() {
2283                Ok(cmd) => {
2284                    if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
2285                        return;
2286                    }
2287                }
2288                Err(_) => return,
2289            }
2290        }
2291
2292        while let Ok(cmd) = rx.try_recv() {
2293            if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
2294                return;
2295            }
2296        }
2297
2298        if tasks.is_empty() {
2299            continue;
2300        }
2301
2302        let Some(job) = scheduler.peek_next().cloned() else {
2303            continue;
2304        };
2305
2306        if let Some(ref sink) = evidence_sink {
2307            let evidence = scheduler.evidence();
2308            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
2309        }
2310
2311        let completed = scheduler.tick(job.remaining_time);
2312        for job_id in completed {
2313            if let Some(task) = tasks.remove(&job_id) {
2314                let msg = task();
2315                let _ = result_sender.send(msg);
2316            }
2317        }
2318    }
2319}
2320
2321fn handle_effect_command<M: Send + 'static>(
2322    cmd: EffectCommand<M>,
2323    scheduler: &mut QueueingScheduler,
2324    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
2325    result_sender: &mpsc::Sender<M>,
2326) -> bool {
2327    match cmd {
2328        EffectCommand::Enqueue(spec, task) => {
2329            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
2330                WeightSource::Default
2331            } else {
2332                WeightSource::Explicit
2333            };
2334            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
2335                EstimateSource::Default
2336            } else {
2337                EstimateSource::Explicit
2338            };
2339            let id = scheduler.submit_with_sources(
2340                spec.weight,
2341                spec.estimate_ms,
2342                weight_source,
2343                estimate_source,
2344                spec.name,
2345            );
2346            if let Some(id) = id {
2347                tasks.insert(id, task);
2348            } else {
2349                let msg = task();
2350                let _ = result_sender.send(msg);
2351            }
2352            false
2353        }
2354        EffectCommand::Shutdown => true,
2355    }
2356}
2357
2358// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
2359
2360/// Policy for remeasuring inline auto UI height.
2361///
2362/// Uses VOI (value-of-information) sampling to decide when to perform
2363/// a costly full-height measurement, with any-time valid guarantees via
2364/// the embedded e-process in `VoiSampler`.
2365#[derive(Debug, Clone)]
2366pub struct InlineAutoRemeasureConfig {
2367    /// VOI sampling configuration.
2368    pub voi: VoiConfig,
2369    /// Minimum row delta to count as a "violation".
2370    pub change_threshold_rows: u16,
2371}
2372
2373impl Default for InlineAutoRemeasureConfig {
2374    fn default() -> Self {
2375        Self {
2376            voi: VoiConfig {
2377                // Height changes are expected to be rare; bias toward fewer samples.
2378                prior_alpha: 1.0,
2379                prior_beta: 9.0,
2380                // Allow ~1s max latency to adapt to growth/shrink.
2381                max_interval_ms: 1000,
2382                // Avoid over-sampling in high-FPS loops.
2383                min_interval_ms: 100,
2384                // Disable event forcing; use time-based gating.
2385                max_interval_events: 0,
2386                min_interval_events: 0,
2387                // Treat sampling as moderately expensive.
2388                sample_cost: 0.08,
2389                ..VoiConfig::default()
2390            },
2391            change_threshold_rows: 1,
2392        }
2393    }
2394}
2395
2396#[derive(Debug)]
2397struct InlineAutoRemeasureState {
2398    config: InlineAutoRemeasureConfig,
2399    sampler: VoiSampler,
2400}
2401
2402impl InlineAutoRemeasureState {
2403    fn new(config: InlineAutoRemeasureConfig) -> Self {
2404        let sampler = VoiSampler::new(config.voi.clone());
2405        Self { config, sampler }
2406    }
2407
2408    fn reset(&mut self) {
2409        self.sampler = VoiSampler::new(self.config.voi.clone());
2410    }
2411}
2412
2413#[derive(Debug, Clone)]
2414struct ConformalEvidence {
2415    bucket_key: String,
2416    n_b: usize,
2417    alpha: f64,
2418    q_b: f64,
2419    y_hat: f64,
2420    upper_us: f64,
2421    risk: bool,
2422    fallback_level: u8,
2423    window_size: usize,
2424    reset_count: u64,
2425}
2426
2427impl ConformalEvidence {
2428    fn from_prediction(prediction: &ConformalPrediction) -> Self {
2429        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
2430        Self {
2431            bucket_key: prediction.bucket.to_string(),
2432            n_b: prediction.sample_count,
2433            alpha,
2434            q_b: prediction.quantile,
2435            y_hat: prediction.y_hat,
2436            upper_us: prediction.upper_us,
2437            risk: prediction.risk,
2438            fallback_level: prediction.fallback_level,
2439            window_size: prediction.window_size,
2440            reset_count: prediction.reset_count,
2441        }
2442    }
2443}
2444
2445#[derive(Debug, Clone)]
2446struct BudgetDecisionEvidence {
2447    frame_idx: u64,
2448    decision: BudgetDecision,
2449    controller_decision: BudgetDecision,
2450    degradation_before: DegradationLevel,
2451    degradation_after: DegradationLevel,
2452    frame_time_us: f64,
2453    budget_us: f64,
2454    pid_output: f64,
2455    pid_p: f64,
2456    pid_i: f64,
2457    pid_d: f64,
2458    e_value: f64,
2459    frames_observed: u32,
2460    frames_since_change: u32,
2461    in_warmup: bool,
2462    conformal: Option<ConformalEvidence>,
2463}
2464
2465impl BudgetDecisionEvidence {
2466    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
2467        if after > before {
2468            BudgetDecision::Degrade
2469        } else if after < before {
2470            BudgetDecision::Upgrade
2471        } else {
2472            BudgetDecision::Hold
2473        }
2474    }
2475
2476    #[must_use]
2477    fn to_jsonl(&self) -> String {
2478        let conformal = self.conformal.as_ref();
2479        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
2480        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
2481        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
2482        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
2483        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
2484        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
2485        let risk = Self::opt_bool(conformal.map(|c| c.risk));
2486        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
2487        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
2488        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
2489
2490        format!(
2491            r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
2492            self.frame_idx,
2493            self.decision.as_str(),
2494            self.controller_decision.as_str(),
2495            self.degradation_before.as_str(),
2496            self.degradation_after.as_str(),
2497            self.frame_time_us,
2498            self.budget_us,
2499            self.pid_output,
2500            self.pid_p,
2501            self.pid_i,
2502            self.pid_d,
2503            self.e_value,
2504            self.frames_observed,
2505            self.frames_since_change,
2506            self.in_warmup,
2507            bucket_key,
2508            n_b,
2509            alpha,
2510            q_b,
2511            y_hat,
2512            upper_us,
2513            risk,
2514            fallback_level,
2515            window_size,
2516            reset_count
2517        )
2518    }
2519
2520    fn opt_f64(value: Option<f64>) -> String {
2521        value
2522            .map(|v| format!("{v:.6}"))
2523            .unwrap_or_else(|| "null".to_string())
2524    }
2525
2526    fn opt_u64(value: Option<u64>) -> String {
2527        value
2528            .map(|v| v.to_string())
2529            .unwrap_or_else(|| "null".to_string())
2530    }
2531
2532    fn opt_u8(value: Option<u8>) -> String {
2533        value
2534            .map(|v| v.to_string())
2535            .unwrap_or_else(|| "null".to_string())
2536    }
2537
2538    fn opt_usize(value: Option<usize>) -> String {
2539        value
2540            .map(|v| v.to_string())
2541            .unwrap_or_else(|| "null".to_string())
2542    }
2543
2544    fn opt_bool(value: Option<bool>) -> String {
2545        value
2546            .map(|v| v.to_string())
2547            .unwrap_or_else(|| "null".to_string())
2548    }
2549
2550    fn opt_str(value: Option<&str>) -> String {
2551        value
2552            .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
2553            .unwrap_or_else(|| "null".to_string())
2554    }
2555}
2556
2557#[derive(Debug, Clone)]
2558struct FairnessConfigEvidence {
2559    enabled: bool,
2560    input_priority_threshold_ms: u64,
2561    dominance_threshold: u32,
2562    fairness_threshold: f64,
2563}
2564
2565impl FairnessConfigEvidence {
2566    #[must_use]
2567    fn to_jsonl(&self) -> String {
2568        format!(
2569            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
2570            self.enabled,
2571            self.input_priority_threshold_ms,
2572            self.dominance_threshold,
2573            self.fairness_threshold
2574        )
2575    }
2576}
2577
2578#[derive(Debug, Clone)]
2579struct FairnessDecisionEvidence {
2580    frame_idx: u64,
2581    decision: &'static str,
2582    reason: &'static str,
2583    pending_input_latency_ms: Option<u64>,
2584    jain_index: f64,
2585    resize_dominance_count: u32,
2586    dominance_threshold: u32,
2587    fairness_threshold: f64,
2588    input_priority_threshold_ms: u64,
2589}
2590
2591impl FairnessDecisionEvidence {
2592    #[must_use]
2593    fn to_jsonl(&self) -> String {
2594        let pending_latency = self
2595            .pending_input_latency_ms
2596            .map(|v| v.to_string())
2597            .unwrap_or_else(|| "null".to_string());
2598        format!(
2599            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":{}}}"#,
2600            self.frame_idx,
2601            self.decision,
2602            self.reason,
2603            pending_latency,
2604            self.jain_index,
2605            self.resize_dominance_count,
2606            self.dominance_threshold,
2607            self.fairness_threshold,
2608            self.input_priority_threshold_ms
2609        )
2610    }
2611}
2612
2613#[derive(Debug, Clone)]
2614struct WidgetRefreshEntry {
2615    widget_id: u64,
2616    essential: bool,
2617    starved: bool,
2618    value: f32,
2619    cost_us: f32,
2620    score: f32,
2621    staleness_ms: u64,
2622}
2623
2624impl WidgetRefreshEntry {
2625    fn to_json(&self) -> String {
2626        format!(
2627            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
2628            self.widget_id,
2629            self.cost_us,
2630            self.value,
2631            self.score,
2632            self.essential,
2633            self.starved,
2634            self.staleness_ms
2635        )
2636    }
2637}
2638
2639#[derive(Debug, Clone)]
2640struct WidgetRefreshPlan {
2641    frame_idx: u64,
2642    budget_us: f64,
2643    degradation: DegradationLevel,
2644    essentials_cost_us: f64,
2645    selected_cost_us: f64,
2646    selected_value: f64,
2647    signal_count: usize,
2648    selected: Vec<WidgetRefreshEntry>,
2649    skipped_count: usize,
2650    skipped_starved: usize,
2651    starved_selected: usize,
2652    over_budget: bool,
2653}
2654
2655impl WidgetRefreshPlan {
2656    fn new() -> Self {
2657        Self {
2658            frame_idx: 0,
2659            budget_us: 0.0,
2660            degradation: DegradationLevel::Full,
2661            essentials_cost_us: 0.0,
2662            selected_cost_us: 0.0,
2663            selected_value: 0.0,
2664            signal_count: 0,
2665            selected: Vec::new(),
2666            skipped_count: 0,
2667            skipped_starved: 0,
2668            starved_selected: 0,
2669            over_budget: false,
2670        }
2671    }
2672
2673    fn clear(&mut self) {
2674        self.frame_idx = 0;
2675        self.budget_us = 0.0;
2676        self.degradation = DegradationLevel::Full;
2677        self.essentials_cost_us = 0.0;
2678        self.selected_cost_us = 0.0;
2679        self.selected_value = 0.0;
2680        self.signal_count = 0;
2681        self.selected.clear();
2682        self.skipped_count = 0;
2683        self.skipped_starved = 0;
2684        self.starved_selected = 0;
2685        self.over_budget = false;
2686    }
2687
2688    fn as_budget(&self) -> WidgetBudget {
2689        if self.signal_count == 0 {
2690            return WidgetBudget::allow_all();
2691        }
2692        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
2693        WidgetBudget::allow_only(ids)
2694    }
2695
2696    fn recompute(
2697        &mut self,
2698        frame_idx: u64,
2699        budget_us: f64,
2700        degradation: DegradationLevel,
2701        signals: &[WidgetSignal],
2702        config: &WidgetRefreshConfig,
2703    ) {
2704        self.clear();
2705        self.frame_idx = frame_idx;
2706        self.budget_us = budget_us;
2707        self.degradation = degradation;
2708
2709        if !config.enabled || signals.is_empty() {
2710            return;
2711        }
2712
2713        self.signal_count = signals.len();
2714        let mut essentials_cost = 0.0f64;
2715        let mut selected_cost = 0.0f64;
2716        let mut selected_value = 0.0f64;
2717
2718        let staleness_window = config.staleness_window_ms.max(1) as f32;
2719        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
2720
2721        for signal in signals {
2722            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
2723            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
2724            let mut value = config.weight_priority * signal.priority
2725                + config.weight_staleness * staleness_score
2726                + config.weight_focus * signal.focus_boost
2727                + config.weight_interaction * signal.interaction_boost;
2728            if starved {
2729                value += config.starve_boost;
2730            }
2731            let raw_cost = if signal.recent_cost_us > 0.0 {
2732                signal.recent_cost_us
2733            } else {
2734                signal.cost_estimate_us
2735            };
2736            let cost_us = raw_cost.max(config.min_cost_us);
2737            let score = if cost_us > 0.0 {
2738                value / cost_us
2739            } else {
2740                value
2741            };
2742
2743            let entry = WidgetRefreshEntry {
2744                widget_id: signal.widget_id,
2745                essential: signal.essential,
2746                starved,
2747                value,
2748                cost_us,
2749                score,
2750                staleness_ms: signal.staleness_ms,
2751            };
2752
2753            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
2754                self.skipped_count += 1;
2755                if starved {
2756                    self.skipped_starved = self.skipped_starved.saturating_add(1);
2757                }
2758                continue;
2759            }
2760
2761            if signal.essential {
2762                essentials_cost += cost_us as f64;
2763                selected_cost += cost_us as f64;
2764                selected_value += value as f64;
2765                if starved {
2766                    self.starved_selected = self.starved_selected.saturating_add(1);
2767                }
2768                self.selected.push(entry);
2769            } else {
2770                candidates.push(entry);
2771            }
2772        }
2773
2774        let mut remaining = budget_us - selected_cost;
2775
2776        if degradation < DegradationLevel::EssentialOnly {
2777            let nonessential_total = candidates.len();
2778            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
2779            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
2780            let min_nonessential_selected = if enforce_drop_rate {
2781                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
2782                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
2783            } else {
2784                0
2785            };
2786
2787            candidates.sort_by(|a, b| {
2788                b.starved
2789                    .cmp(&a.starved)
2790                    .then_with(|| b.score.total_cmp(&a.score))
2791                    .then_with(|| b.value.total_cmp(&a.value))
2792                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
2793                    .then_with(|| a.widget_id.cmp(&b.widget_id))
2794            });
2795
2796            let mut forced_starved = 0usize;
2797            let mut nonessential_selected = 0usize;
2798            let mut skipped_candidates = if enforce_drop_rate {
2799                Vec::with_capacity(candidates.len())
2800            } else {
2801                Vec::new()
2802            };
2803
2804            for entry in candidates.into_iter() {
2805                if entry.starved && forced_starved >= config.max_starved_per_frame {
2806                    self.skipped_count += 1;
2807                    self.skipped_starved = self.skipped_starved.saturating_add(1);
2808                    if enforce_drop_rate {
2809                        skipped_candidates.push(entry);
2810                    }
2811                    continue;
2812                }
2813
2814                if remaining >= entry.cost_us as f64 {
2815                    remaining -= entry.cost_us as f64;
2816                    selected_cost += entry.cost_us as f64;
2817                    selected_value += entry.value as f64;
2818                    if entry.starved {
2819                        self.starved_selected = self.starved_selected.saturating_add(1);
2820                        forced_starved += 1;
2821                    }
2822                    nonessential_selected += 1;
2823                    self.selected.push(entry);
2824                } else if entry.starved
2825                    && forced_starved < config.max_starved_per_frame
2826                    && nonessential_selected == 0
2827                {
2828                    // Starvation guard: ensure at least one starved widget can refresh.
2829                    selected_cost += entry.cost_us as f64;
2830                    selected_value += entry.value as f64;
2831                    self.starved_selected = self.starved_selected.saturating_add(1);
2832                    forced_starved += 1;
2833                    nonessential_selected += 1;
2834                    self.selected.push(entry);
2835                } else {
2836                    self.skipped_count += 1;
2837                    if entry.starved {
2838                        self.skipped_starved = self.skipped_starved.saturating_add(1);
2839                    }
2840                    if enforce_drop_rate {
2841                        skipped_candidates.push(entry);
2842                    }
2843                }
2844            }
2845
2846            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
2847                for entry in skipped_candidates.into_iter() {
2848                    if nonessential_selected >= min_nonessential_selected {
2849                        break;
2850                    }
2851                    if entry.starved && forced_starved >= config.max_starved_per_frame {
2852                        continue;
2853                    }
2854                    selected_cost += entry.cost_us as f64;
2855                    selected_value += entry.value as f64;
2856                    if entry.starved {
2857                        self.starved_selected = self.starved_selected.saturating_add(1);
2858                        forced_starved += 1;
2859                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
2860                    }
2861                    self.skipped_count = self.skipped_count.saturating_sub(1);
2862                    nonessential_selected += 1;
2863                    self.selected.push(entry);
2864                }
2865            }
2866        }
2867
2868        self.essentials_cost_us = essentials_cost;
2869        self.selected_cost_us = selected_cost;
2870        self.selected_value = selected_value;
2871        self.over_budget = selected_cost > budget_us;
2872    }
2873
2874    #[must_use]
2875    fn to_jsonl(&self) -> String {
2876        let mut out = String::with_capacity(256 + self.selected.len() * 96);
2877        out.push_str(r#"{"event":"widget_refresh""#);
2878        out.push_str(&format!(
2879            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":{}"#,
2880            self.frame_idx,
2881            self.budget_us,
2882            self.degradation.as_str(),
2883            self.essentials_cost_us,
2884            self.selected_cost_us,
2885            self.selected_value,
2886            self.selected.len(),
2887            self.skipped_count,
2888            self.starved_selected,
2889            self.skipped_starved,
2890            self.over_budget
2891        ));
2892        out.push_str(r#","selected":["#);
2893        for (i, entry) in self.selected.iter().enumerate() {
2894            if i > 0 {
2895                out.push(',');
2896            }
2897            out.push_str(&entry.to_json());
2898        }
2899        out.push_str("]}");
2900        out
2901    }
2902}
2903
2904// =============================================================================
2905// CrosstermEventSource: BackendEventSource adapter for TerminalSession
2906// =============================================================================
2907
2908#[cfg(feature = "crossterm-compat")]
2909/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
2910///
2911/// This provides the bridge between the legacy crossterm-based terminal session
2912/// and the new backend abstraction. Once the native `ftui-tty` backend fully
2913/// replaces crossterm, this adapter will be removed.
2914pub struct CrosstermEventSource {
2915    session: TerminalSession,
2916    features: BackendFeatures,
2917}
2918
2919#[cfg(feature = "crossterm-compat")]
2920impl CrosstermEventSource {
2921    /// Create a new crossterm event source from a terminal session.
2922    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
2923        Self {
2924            session,
2925            features: initial_features,
2926        }
2927    }
2928}
2929
2930#[cfg(feature = "crossterm-compat")]
2931impl BackendEventSource for CrosstermEventSource {
2932    type Error = io::Error;
2933
2934    fn size(&self) -> Result<(u16, u16), io::Error> {
2935        self.session.size()
2936    }
2937
2938    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
2939        if features.mouse_capture != self.features.mouse_capture {
2940            self.session.set_mouse_capture(features.mouse_capture)?;
2941        }
2942        // bracketed_paste, focus_events, and kitty_keyboard are set at session
2943        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
2944        // is not supported by the crossterm backend.
2945        self.features = features;
2946        Ok(())
2947    }
2948
2949    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
2950        self.session.poll_event(timeout)
2951    }
2952
2953    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
2954        self.session.read_event()
2955    }
2956}
2957
2958// =============================================================================
2959// HeadlessEventSource: no-op event source for headless/test programs
2960// =============================================================================
2961
2962/// A no-op event source for headless and test programs.
2963///
2964/// Returns a fixed terminal size, accepts feature changes silently, and never
2965/// produces events. This allows the test helper to construct a `Program`
2966/// without depending on crossterm or a real terminal.
2967pub struct HeadlessEventSource {
2968    width: u16,
2969    height: u16,
2970    features: BackendFeatures,
2971}
2972
2973impl HeadlessEventSource {
2974    /// Create a headless event source with the given terminal size.
2975    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
2976        Self {
2977            width,
2978            height,
2979            features,
2980        }
2981    }
2982}
2983
2984impl BackendEventSource for HeadlessEventSource {
2985    type Error = io::Error;
2986
2987    fn size(&self) -> Result<(u16, u16), io::Error> {
2988        Ok((self.width, self.height))
2989    }
2990
2991    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
2992        self.features = features;
2993        Ok(())
2994    }
2995
2996    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
2997        Ok(false)
2998    }
2999
3000    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
3001        Ok(None)
3002    }
3003}
3004
3005// =============================================================================
3006// Program
3007// =============================================================================
3008
3009/// The program runtime that manages the update/view loop.
3010pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
3011    /// The application model.
3012    model: M,
3013    /// Terminal output coordinator.
3014    writer: TerminalWriter<W>,
3015    /// Event source (terminal input, size queries, feature toggles).
3016    events: E,
3017    /// Currently active backend feature toggles.
3018    backend_features: BackendFeatures,
3019    /// Whether the program is running.
3020    running: bool,
3021    /// Current tick rate (if any).
3022    tick_rate: Option<Duration>,
3023    /// Last tick time.
3024    last_tick: Instant,
3025    /// Whether the UI needs to be redrawn.
3026    dirty: bool,
3027    /// Monotonic frame index for evidence logging.
3028    frame_idx: u64,
3029    /// Widget scheduling signals captured during the last render.
3030    widget_signals: Vec<WidgetSignal>,
3031    /// Widget refresh selection configuration.
3032    widget_refresh_config: WidgetRefreshConfig,
3033    /// Last computed widget refresh plan.
3034    widget_refresh_plan: WidgetRefreshPlan,
3035    /// Current terminal width.
3036    width: u16,
3037    /// Current terminal height.
3038    height: u16,
3039    /// Forced terminal size override (when set, resize events are ignored).
3040    forced_size: Option<(u16, u16)>,
3041    /// Poll timeout when no tick is scheduled.
3042    poll_timeout: Duration,
3043    /// Frame budget configuration.
3044    budget: RenderBudget,
3045    /// Conformal predictor for frame-time risk gating.
3046    conformal_predictor: Option<ConformalPredictor>,
3047    /// Last observed frame time (microseconds), used as a baseline predictor.
3048    last_frame_time_us: Option<f64>,
3049    /// Last observed update duration (microseconds).
3050    last_update_us: Option<u64>,
3051    /// Optional frame timing sink for profiling.
3052    frame_timing: Option<FrameTimingConfig>,
3053    /// Locale context used for rendering.
3054    locale_context: LocaleContext,
3055    /// Last observed locale version.
3056    locale_version: u64,
3057    /// Resize coalescer for rapid resize events.
3058    resize_coalescer: ResizeCoalescer,
3059    /// Shared evidence sink for decision logs (optional).
3060    evidence_sink: Option<EvidenceSink>,
3061    /// Whether fairness config has been logged to evidence sink.
3062    fairness_config_logged: bool,
3063    /// Resize handling behavior.
3064    resize_behavior: ResizeBehavior,
3065    /// Input fairness guard for scheduler integration.
3066    fairness_guard: InputFairnessGuard,
3067    /// Optional event recorder for macro capture.
3068    event_recorder: Option<EventRecorder>,
3069    /// Subscription lifecycle manager.
3070    subscriptions: SubscriptionManager<M::Message>,
3071    /// Channel for receiving messages from background tasks.
3072    task_sender: std::sync::mpsc::Sender<M::Message>,
3073    /// Channel for receiving messages from background tasks.
3074    task_receiver: std::sync::mpsc::Receiver<M::Message>,
3075    /// Join handles for background tasks; reaped opportunistically.
3076    task_handles: Vec<std::thread::JoinHandle<()>>,
3077    /// Optional effect queue scheduler for background tasks.
3078    effect_queue: Option<EffectQueue<M::Message>>,
3079    /// Optional state registry for widget persistence.
3080    state_registry: Option<std::sync::Arc<StateRegistry>>,
3081    /// Persistence configuration.
3082    persistence_config: PersistenceConfig,
3083    /// Last checkpoint save time.
3084    last_checkpoint: Instant,
3085    /// Inline auto UI height remeasurement state.
3086    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
3087    /// Per-frame bump arena for temporary render-path allocations.
3088    frame_arena: FrameArena,
3089}
3090
3091#[cfg(feature = "crossterm-compat")]
3092impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
3093    /// Create a new program with default configuration.
3094    pub fn new(model: M) -> io::Result<Self>
3095    where
3096        M::Message: Send + 'static,
3097    {
3098        Self::with_config(model, ProgramConfig::default())
3099    }
3100
3101    /// Create a new program with the specified configuration.
3102    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
3103    where
3104        M::Message: Send + 'static,
3105    {
3106        let capabilities = TerminalCapabilities::with_overrides();
3107        let mouse_capture = config.resolved_mouse_capture();
3108        let requested_features = BackendFeatures {
3109            mouse_capture,
3110            bracketed_paste: config.bracketed_paste,
3111            focus_events: config.focus_reporting,
3112            kitty_keyboard: config.kitty_keyboard,
3113        };
3114        let initial_features =
3115            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
3116        let session = TerminalSession::new(SessionOptions {
3117            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
3118            mouse_capture: initial_features.mouse_capture,
3119            bracketed_paste: initial_features.bracketed_paste,
3120            focus_events: initial_features.focus_events,
3121            kitty_keyboard: initial_features.kitty_keyboard,
3122        })?;
3123        let events = CrosstermEventSource::new(session, initial_features);
3124
3125        let mut writer = TerminalWriter::with_diff_config(
3126            io::stdout(),
3127            config.screen_mode,
3128            config.ui_anchor,
3129            capabilities,
3130            config.diff_config.clone(),
3131        );
3132
3133        let frame_timing = config.frame_timing.clone();
3134        writer.set_timing_enabled(frame_timing.is_some());
3135
3136        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
3137        if let Some(ref sink) = evidence_sink {
3138            writer = writer.with_evidence_sink(sink.clone());
3139        }
3140
3141        let render_trace = crate::RenderTraceRecorder::from_config(
3142            &config.render_trace,
3143            crate::RenderTraceContext {
3144                capabilities: writer.capabilities(),
3145                diff_config: config.diff_config.clone(),
3146                resize_config: config.resize_coalescer.clone(),
3147                conformal_config: config.conformal_config.clone(),
3148            },
3149        )?;
3150        if let Some(recorder) = render_trace {
3151            writer = writer.with_render_trace(recorder);
3152        }
3153
3154        // Get terminal size for initial frame (or forced size override).
3155        let (w, h) = config
3156            .forced_size
3157            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
3158        let width = w.max(1);
3159        let height = h.max(1);
3160        writer.set_size(width, height);
3161
3162        let budget = RenderBudget::from_config(&config.budget);
3163        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
3164        let locale_context = config.locale_context.clone();
3165        let locale_version = locale_context.version();
3166        let mut resize_coalescer =
3167            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
3168                .with_screen_mode(config.screen_mode);
3169        if let Some(ref sink) = evidence_sink {
3170            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
3171        }
3172        let subscriptions = SubscriptionManager::new();
3173        let (task_sender, task_receiver) = std::sync::mpsc::channel();
3174        let inline_auto_remeasure = config
3175            .inline_auto_remeasure
3176            .clone()
3177            .map(InlineAutoRemeasureState::new);
3178        let effect_queue = if config.effect_queue.enabled {
3179            Some(EffectQueue::start(
3180                config.effect_queue.clone(),
3181                task_sender.clone(),
3182                evidence_sink.clone(),
3183            )?)
3184        } else {
3185            None
3186        };
3187
3188        Ok(Self {
3189            model,
3190            writer,
3191            events,
3192            backend_features: initial_features,
3193            running: true,
3194            tick_rate: None,
3195            last_tick: Instant::now(),
3196            dirty: true,
3197            frame_idx: 0,
3198            widget_signals: Vec::new(),
3199            widget_refresh_config: config.widget_refresh,
3200            widget_refresh_plan: WidgetRefreshPlan::new(),
3201            width,
3202            height,
3203            forced_size: config.forced_size,
3204            poll_timeout: config.poll_timeout,
3205            budget,
3206            conformal_predictor,
3207            last_frame_time_us: None,
3208            last_update_us: None,
3209            frame_timing,
3210            locale_context,
3211            locale_version,
3212            resize_coalescer,
3213            evidence_sink,
3214            fairness_config_logged: false,
3215            resize_behavior: config.resize_behavior,
3216            fairness_guard: InputFairnessGuard::new(),
3217            event_recorder: None,
3218            subscriptions,
3219            task_sender,
3220            task_receiver,
3221            task_handles: Vec::new(),
3222            effect_queue,
3223            state_registry: config.persistence.registry.clone(),
3224            persistence_config: config.persistence,
3225            last_checkpoint: Instant::now(),
3226            inline_auto_remeasure,
3227            frame_arena: FrameArena::default(),
3228        })
3229    }
3230}
3231
3232impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
3233    /// Create a program with an externally-constructed event source and writer.
3234    ///
3235    /// This is the generic entry point for alternative backends (native tty,
3236    /// WASM, headless testing). The caller is responsible for terminal
3237    /// lifecycle (raw mode, cleanup) — the event source should handle that
3238    /// via its `Drop` impl or an external RAII guard.
3239    pub fn with_event_source(
3240        model: M,
3241        events: E,
3242        backend_features: BackendFeatures,
3243        writer: TerminalWriter<W>,
3244        config: ProgramConfig,
3245    ) -> io::Result<Self>
3246    where
3247        M::Message: Send + 'static,
3248    {
3249        let (width, height) = config
3250            .forced_size
3251            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
3252        let width = width.max(1);
3253        let height = height.max(1);
3254
3255        let mut writer = writer;
3256        writer.set_size(width, height);
3257
3258        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
3259        if let Some(ref sink) = evidence_sink {
3260            writer = writer.with_evidence_sink(sink.clone());
3261        }
3262
3263        let render_trace = crate::RenderTraceRecorder::from_config(
3264            &config.render_trace,
3265            crate::RenderTraceContext {
3266                capabilities: writer.capabilities(),
3267                diff_config: config.diff_config.clone(),
3268                resize_config: config.resize_coalescer.clone(),
3269                conformal_config: config.conformal_config.clone(),
3270            },
3271        )?;
3272        if let Some(recorder) = render_trace {
3273            writer = writer.with_render_trace(recorder);
3274        }
3275
3276        let frame_timing = config.frame_timing.clone();
3277        writer.set_timing_enabled(frame_timing.is_some());
3278
3279        let budget = RenderBudget::from_config(&config.budget);
3280        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
3281        let locale_context = config.locale_context.clone();
3282        let locale_version = locale_context.version();
3283        let mut resize_coalescer =
3284            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
3285                .with_screen_mode(config.screen_mode);
3286        if let Some(ref sink) = evidence_sink {
3287            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
3288        }
3289        let subscriptions = SubscriptionManager::new();
3290        let (task_sender, task_receiver) = std::sync::mpsc::channel();
3291        let inline_auto_remeasure = config
3292            .inline_auto_remeasure
3293            .clone()
3294            .map(InlineAutoRemeasureState::new);
3295        let effect_queue = if config.effect_queue.enabled {
3296            Some(EffectQueue::start(
3297                config.effect_queue.clone(),
3298                task_sender.clone(),
3299                evidence_sink.clone(),
3300            )?)
3301        } else {
3302            None
3303        };
3304
3305        Ok(Self {
3306            model,
3307            writer,
3308            events,
3309            backend_features,
3310            running: true,
3311            tick_rate: None,
3312            last_tick: Instant::now(),
3313            dirty: true,
3314            frame_idx: 0,
3315            widget_signals: Vec::new(),
3316            widget_refresh_config: config.widget_refresh,
3317            widget_refresh_plan: WidgetRefreshPlan::new(),
3318            width,
3319            height,
3320            forced_size: config.forced_size,
3321            poll_timeout: config.poll_timeout,
3322            budget,
3323            conformal_predictor,
3324            last_frame_time_us: None,
3325            last_update_us: None,
3326            frame_timing,
3327            locale_context,
3328            locale_version,
3329            resize_coalescer,
3330            evidence_sink,
3331            fairness_config_logged: false,
3332            resize_behavior: config.resize_behavior,
3333            fairness_guard: InputFairnessGuard::new(),
3334            event_recorder: None,
3335            subscriptions,
3336            task_sender,
3337            task_receiver,
3338            task_handles: Vec::new(),
3339            effect_queue,
3340            state_registry: config.persistence.registry.clone(),
3341            persistence_config: config.persistence,
3342            last_checkpoint: Instant::now(),
3343            inline_auto_remeasure,
3344            frame_arena: FrameArena::default(),
3345        })
3346    }
3347}
3348
3349// =============================================================================
3350// Native TTY backend constructor (feature-gated)
3351// =============================================================================
3352
3353#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
3354#[inline]
3355const fn sanitize_backend_features_for_capabilities(
3356    requested: BackendFeatures,
3357    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
3358) -> BackendFeatures {
3359    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
3360    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
3361
3362    BackendFeatures {
3363        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
3364        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
3365        focus_events: requested.focus_events && focus_events_supported,
3366        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
3367    }
3368}
3369
3370#[cfg(feature = "native-backend")]
3371impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
3372    /// Create a program backed by the native TTY backend (no Crossterm).
3373    ///
3374    /// This opens a live terminal session via `ftui-tty`, entering raw mode
3375    /// and enabling the requested features. When the program exits (or panics),
3376    /// `TtyBackend::drop()` restores the terminal to its original state.
3377    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
3378    where
3379        M::Message: Send + 'static,
3380    {
3381        let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
3382        let mouse_capture = config.resolved_mouse_capture();
3383        let requested_features = BackendFeatures {
3384            mouse_capture,
3385            bracketed_paste: config.bracketed_paste,
3386            focus_events: config.focus_reporting,
3387            kitty_keyboard: config.kitty_keyboard,
3388        };
3389        let features =
3390            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
3391        let options = ftui_tty::TtySessionOptions {
3392            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
3393            features,
3394        };
3395        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
3396
3397        let writer = TerminalWriter::with_diff_config(
3398            io::stdout(),
3399            config.screen_mode,
3400            config.ui_anchor,
3401            capabilities,
3402            config.diff_config.clone(),
3403        );
3404
3405        Self::with_event_source(model, backend, features, writer, config)
3406    }
3407}
3408
3409impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
3410    /// Run the main event loop.
3411    ///
3412    /// This is the main entry point. It handles:
3413    /// 1. Initialization (terminal setup, raw mode)
3414    /// 2. Event polling and message dispatch
3415    /// 3. Frame rendering
3416    /// 4. Shutdown (terminal cleanup)
3417    pub fn run(&mut self) -> io::Result<()> {
3418        self.run_event_loop()
3419    }
3420
3421    /// Access widget scheduling signals captured on the last render.
3422    #[inline]
3423    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
3424        &self.widget_signals
3425    }
3426
3427    /// The inner event loop, separated for proper cleanup handling.
3428    fn run_event_loop(&mut self) -> io::Result<()> {
3429        // Auto-load state on start
3430        if self.persistence_config.auto_load {
3431            self.load_state();
3432        }
3433
3434        // Initialize
3435        let cmd = {
3436            let _span = info_span!("ftui.program.init").entered();
3437            self.model.init()
3438        };
3439        self.execute_cmd(cmd)?;
3440
3441        // Reconcile initial subscriptions
3442        self.reconcile_subscriptions();
3443
3444        // Initial render
3445        self.render_frame()?;
3446
3447        // Main loop
3448        let mut loop_count: u64 = 0;
3449        while self.running {
3450            loop_count += 1;
3451            // Log heartbeat every 100 iterations to avoid flooding stderr
3452            if loop_count.is_multiple_of(100) {
3453                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
3454            }
3455
3456            // Poll for input with tick timeout
3457            let timeout = self.effective_timeout();
3458
3459            // Poll for events with timeout
3460            let poll_result = self.events.poll_event(timeout)?;
3461            if poll_result {
3462                // Drain all pending events
3463                loop {
3464                    if let Some(event) = self.events.read_event()? {
3465                        self.handle_event(event)?;
3466                    }
3467                    if !self.events.poll_event(Duration::from_millis(0))? {
3468                        break;
3469                    }
3470                }
3471            }
3472
3473            // Process subscription messages
3474            self.process_subscription_messages()?;
3475
3476            // Process background task results
3477            self.process_task_results()?;
3478            self.reap_finished_tasks();
3479
3480            self.process_resize_coalescer()?;
3481
3482            // Check for tick - deliver to model so periodic logic can run
3483            if self.should_tick() {
3484                let msg = M::Message::from(Event::Tick);
3485                let cmd = {
3486                    let _span = debug_span!(
3487                        "ftui.program.update",
3488                        msg_type = "Tick",
3489                        duration_us = tracing::field::Empty,
3490                        cmd_type = tracing::field::Empty
3491                    )
3492                    .entered();
3493                    let start = Instant::now();
3494                    let cmd = self.model.update(msg);
3495                    tracing::Span::current()
3496                        .record("duration_us", start.elapsed().as_micros() as u64);
3497                    tracing::Span::current()
3498                        .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3499                    cmd
3500                };
3501                self.mark_dirty();
3502                self.execute_cmd(cmd)?;
3503                self.reconcile_subscriptions();
3504            }
3505
3506            // Check for periodic checkpoint save
3507            self.check_checkpoint_save();
3508
3509            // Detect locale changes outside the event loop.
3510            self.check_locale_change();
3511
3512            // Render if dirty
3513            if self.dirty {
3514                self.render_frame()?;
3515            }
3516
3517            // Periodic grapheme pool GC
3518            if loop_count.is_multiple_of(1000) {
3519                self.writer.gc(None);
3520            }
3521        }
3522
3523        // Auto-save state on exit
3524        if self.persistence_config.auto_save {
3525            self.save_state();
3526        }
3527
3528        // Stop all subscriptions on exit
3529        self.subscriptions.stop_all();
3530        self.reap_finished_tasks();
3531
3532        Ok(())
3533    }
3534
3535    /// Load state from the persistence registry.
3536    fn load_state(&mut self) {
3537        if let Some(registry) = &self.state_registry {
3538            match registry.load() {
3539                Ok(count) => {
3540                    info!(count, "loaded widget state from persistence");
3541                }
3542                Err(e) => {
3543                    tracing::warn!(error = %e, "failed to load widget state");
3544                }
3545            }
3546        }
3547    }
3548
3549    /// Save state to the persistence registry.
3550    fn save_state(&mut self) {
3551        if let Some(registry) = &self.state_registry {
3552            match registry.flush() {
3553                Ok(true) => {
3554                    debug!("saved widget state to persistence");
3555                }
3556                Ok(false) => {
3557                    // No changes to save
3558                }
3559                Err(e) => {
3560                    tracing::warn!(error = %e, "failed to save widget state");
3561                }
3562            }
3563        }
3564    }
3565
3566    /// Check if it's time for a periodic checkpoint save.
3567    fn check_checkpoint_save(&mut self) {
3568        if let Some(interval) = self.persistence_config.checkpoint_interval
3569            && self.last_checkpoint.elapsed() >= interval
3570        {
3571            self.save_state();
3572            self.last_checkpoint = Instant::now();
3573        }
3574    }
3575
3576    fn handle_event(&mut self, event: Event) -> io::Result<()> {
3577        // Track event start time and type for fairness scheduling.
3578        let event_start = Instant::now();
3579        let fairness_event_type = Self::classify_event_for_fairness(&event);
3580        if fairness_event_type == FairnessEventType::Input {
3581            self.fairness_guard.input_arrived(event_start);
3582        }
3583
3584        // Record event before processing (no-op when recorder is None or idle).
3585        if let Some(recorder) = &mut self.event_recorder {
3586            recorder.record(&event);
3587        }
3588
3589        let event = match event {
3590            Event::Resize { width, height } => {
3591                debug!(
3592                    width,
3593                    height,
3594                    behavior = ?self.resize_behavior,
3595                    "Resize event received"
3596                );
3597                if let Some((forced_width, forced_height)) = self.forced_size {
3598                    debug!(
3599                        forced_width,
3600                        forced_height, "Resize ignored due to forced size override"
3601                    );
3602                    self.fairness_guard.event_processed(
3603                        fairness_event_type,
3604                        event_start.elapsed(),
3605                        Instant::now(),
3606                    );
3607                    return Ok(());
3608                }
3609                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
3610                let width = width.max(1);
3611                let height = height.max(1);
3612                match self.resize_behavior {
3613                    ResizeBehavior::Immediate => {
3614                        self.resize_coalescer
3615                            .record_external_apply(width, height, Instant::now());
3616                        let result = self.apply_resize(width, height, Duration::ZERO, false);
3617                        self.fairness_guard.event_processed(
3618                            fairness_event_type,
3619                            event_start.elapsed(),
3620                            Instant::now(),
3621                        );
3622                        return result;
3623                    }
3624                    ResizeBehavior::Throttled => {
3625                        let action = self.resize_coalescer.handle_resize(width, height);
3626                        if let CoalesceAction::ApplyResize {
3627                            width,
3628                            height,
3629                            coalesce_time,
3630                            forced_by_deadline,
3631                        } = action
3632                        {
3633                            let result =
3634                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
3635                            self.fairness_guard.event_processed(
3636                                fairness_event_type,
3637                                event_start.elapsed(),
3638                                Instant::now(),
3639                            );
3640                            return result;
3641                        }
3642
3643                        self.fairness_guard.event_processed(
3644                            fairness_event_type,
3645                            event_start.elapsed(),
3646                            Instant::now(),
3647                        );
3648                        return Ok(());
3649                    }
3650                }
3651            }
3652            other => other,
3653        };
3654
3655        let msg = M::Message::from(event);
3656        let cmd = {
3657            let _span = debug_span!(
3658                "ftui.program.update",
3659                msg_type = "event",
3660                duration_us = tracing::field::Empty,
3661                cmd_type = tracing::field::Empty
3662            )
3663            .entered();
3664            let start = Instant::now();
3665            let cmd = self.model.update(msg);
3666            let elapsed_us = start.elapsed().as_micros() as u64;
3667            self.last_update_us = Some(elapsed_us);
3668            tracing::Span::current().record("duration_us", elapsed_us);
3669            tracing::Span::current()
3670                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3671            cmd
3672        };
3673        self.mark_dirty();
3674        self.execute_cmd(cmd)?;
3675        self.reconcile_subscriptions();
3676
3677        // Track input event processing for fairness.
3678        self.fairness_guard.event_processed(
3679            fairness_event_type,
3680            event_start.elapsed(),
3681            Instant::now(),
3682        );
3683
3684        Ok(())
3685    }
3686
3687    /// Classify an event for fairness tracking.
3688    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
3689        match event {
3690            Event::Key(_)
3691            | Event::Mouse(_)
3692            | Event::Paste(_)
3693            | Event::Focus(_)
3694            | Event::Clipboard(_) => FairnessEventType::Input,
3695            Event::Resize { .. } => FairnessEventType::Resize,
3696            Event::Tick => FairnessEventType::Tick,
3697        }
3698    }
3699
3700    /// Reconcile the model's declared subscriptions with running ones.
3701    fn reconcile_subscriptions(&mut self) {
3702        let _span = debug_span!(
3703            "ftui.program.subscriptions",
3704            active_count = tracing::field::Empty,
3705            started = tracing::field::Empty,
3706            stopped = tracing::field::Empty
3707        )
3708        .entered();
3709        let subs = self.model.subscriptions();
3710        let before_count = self.subscriptions.active_count();
3711        self.subscriptions.reconcile(subs);
3712        let after_count = self.subscriptions.active_count();
3713        let started = after_count.saturating_sub(before_count);
3714        let stopped = before_count.saturating_sub(after_count);
3715        crate::debug_trace!(
3716            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
3717            before_count,
3718            after_count,
3719            started,
3720            stopped
3721        );
3722        if after_count == 0 {
3723            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
3724        }
3725        let current = tracing::Span::current();
3726        current.record("active_count", after_count);
3727        // started/stopped would require tracking in SubscriptionManager
3728        current.record("started", started);
3729        current.record("stopped", stopped);
3730    }
3731
3732    /// Process pending messages from subscriptions.
3733    fn process_subscription_messages(&mut self) -> io::Result<()> {
3734        let messages = self.subscriptions.drain_messages();
3735        let msg_count = messages.len();
3736        if msg_count > 0 {
3737            crate::debug_trace!("processing {} subscription message(s)", msg_count);
3738        }
3739        for msg in messages {
3740            let cmd = {
3741                let _span = debug_span!(
3742                    "ftui.program.update",
3743                    msg_type = "subscription",
3744                    duration_us = tracing::field::Empty,
3745                    cmd_type = tracing::field::Empty
3746                )
3747                .entered();
3748                let start = Instant::now();
3749                let cmd = self.model.update(msg);
3750                let elapsed_us = start.elapsed().as_micros() as u64;
3751                self.last_update_us = Some(elapsed_us);
3752                tracing::Span::current().record("duration_us", elapsed_us);
3753                tracing::Span::current()
3754                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3755                cmd
3756            };
3757            self.mark_dirty();
3758            self.execute_cmd(cmd)?;
3759        }
3760        if self.dirty {
3761            self.reconcile_subscriptions();
3762        }
3763        Ok(())
3764    }
3765
3766    /// Process results from background tasks.
3767    fn process_task_results(&mut self) -> io::Result<()> {
3768        while let Ok(msg) = self.task_receiver.try_recv() {
3769            let cmd = {
3770                let _span = debug_span!(
3771                    "ftui.program.update",
3772                    msg_type = "task",
3773                    duration_us = tracing::field::Empty,
3774                    cmd_type = tracing::field::Empty
3775                )
3776                .entered();
3777                let start = Instant::now();
3778                let cmd = self.model.update(msg);
3779                let elapsed_us = start.elapsed().as_micros() as u64;
3780                self.last_update_us = Some(elapsed_us);
3781                tracing::Span::current().record("duration_us", elapsed_us);
3782                tracing::Span::current()
3783                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3784                cmd
3785            };
3786            self.mark_dirty();
3787            self.execute_cmd(cmd)?;
3788        }
3789        if self.dirty {
3790            self.reconcile_subscriptions();
3791        }
3792        Ok(())
3793    }
3794
3795    /// Execute a command.
3796    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
3797        match cmd {
3798            Cmd::None => {}
3799            Cmd::Quit => self.running = false,
3800            Cmd::Msg(m) => {
3801                let start = Instant::now();
3802                let cmd = self.model.update(m);
3803                let elapsed_us = start.elapsed().as_micros() as u64;
3804                self.last_update_us = Some(elapsed_us);
3805                self.mark_dirty();
3806                self.execute_cmd(cmd)?;
3807            }
3808            Cmd::Batch(cmds) => {
3809                // Batch currently executes sequentially. This is intentional
3810                // until an async runtime or task scheduler is added.
3811                for c in cmds {
3812                    self.execute_cmd(c)?;
3813                    if !self.running {
3814                        break;
3815                    }
3816                }
3817            }
3818            Cmd::Sequence(cmds) => {
3819                for c in cmds {
3820                    self.execute_cmd(c)?;
3821                    if !self.running {
3822                        break;
3823                    }
3824                }
3825            }
3826            Cmd::Tick(duration) => {
3827                self.tick_rate = Some(duration);
3828                self.last_tick = Instant::now();
3829            }
3830            Cmd::Log(text) => {
3831                let sanitized = sanitize(&text);
3832                if sanitized.ends_with('\n') {
3833                    self.writer.write_log(&sanitized)?;
3834                } else {
3835                    let mut owned = sanitized.into_owned();
3836                    owned.push('\n');
3837                    self.writer.write_log(&owned)?;
3838                }
3839            }
3840            Cmd::Task(spec, f) => {
3841                if let Some(ref queue) = self.effect_queue {
3842                    queue.enqueue(spec, f);
3843                } else {
3844                    let sender = self.task_sender.clone();
3845                    let handle = std::thread::spawn(move || {
3846                        let msg = f();
3847                        let _ = sender.send(msg);
3848                    });
3849                    self.task_handles.push(handle);
3850                }
3851            }
3852            Cmd::SaveState => {
3853                self.save_state();
3854            }
3855            Cmd::RestoreState => {
3856                self.load_state();
3857            }
3858            Cmd::SetMouseCapture(enabled) => {
3859                self.backend_features.mouse_capture = enabled;
3860                self.events.set_features(self.backend_features)?;
3861            }
3862        }
3863        Ok(())
3864    }
3865
3866    fn reap_finished_tasks(&mut self) {
3867        if self.task_handles.is_empty() {
3868            return;
3869        }
3870
3871        let mut i = 0;
3872        while i < self.task_handles.len() {
3873            if self.task_handles[i].is_finished() {
3874                let handle = self.task_handles.swap_remove(i);
3875                if let Err(payload) = handle.join() {
3876                    let msg = if let Some(s) = payload.downcast_ref::<&str>() {
3877                        (*s).to_owned()
3878                    } else if let Some(s) = payload.downcast_ref::<String>() {
3879                        s.clone()
3880                    } else {
3881                        "unknown panic payload".to_owned()
3882                    };
3883                    #[cfg(feature = "tracing")]
3884                    tracing::error!("spawned task panicked: {msg}");
3885                    #[cfg(not(feature = "tracing"))]
3886                    eprintln!("ftui: spawned task panicked: {msg}");
3887                }
3888            } else {
3889                i += 1;
3890            }
3891        }
3892    }
3893
3894    /// Render a frame with budget tracking.
3895    fn render_frame(&mut self) -> io::Result<()> {
3896        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
3897
3898        self.frame_idx = self.frame_idx.wrapping_add(1);
3899        let frame_idx = self.frame_idx;
3900        let degradation_start = self.budget.degradation();
3901
3902        // Reset budget for new frame, potentially upgrading quality
3903        self.budget.next_frame();
3904
3905        // Apply conformal risk gate before rendering (if enabled)
3906        let mut conformal_prediction = None;
3907        if let Some(predictor) = self.conformal_predictor.as_ref() {
3908            let baseline_us = self
3909                .last_frame_time_us
3910                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
3911            let diff_strategy = self
3912                .writer
3913                .last_diff_strategy()
3914                .unwrap_or(DiffStrategy::Full);
3915            let frame_height_hint = self.writer.render_height_hint().max(1);
3916            let key = BucketKey::from_context(
3917                self.writer.screen_mode(),
3918                diff_strategy,
3919                self.width,
3920                frame_height_hint,
3921            );
3922            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
3923            let prediction = predictor.predict(key, baseline_us, budget_us);
3924            if prediction.risk {
3925                self.budget.degrade();
3926                info!(
3927                    bucket = %prediction.bucket,
3928                    upper_us = prediction.upper_us,
3929                    budget_us = prediction.budget_us,
3930                    fallback_level = prediction.fallback_level,
3931                    degradation = self.budget.degradation().as_str(),
3932                    "conformal gate triggered strategy downgrade"
3933                );
3934                debug!(
3935                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
3936                    bucket = %prediction.bucket,
3937                    "conformal gate trigger"
3938                );
3939            }
3940            debug!(
3941                bucket = %prediction.bucket,
3942                upper_us = prediction.upper_us,
3943                budget_us = prediction.budget_us,
3944                fallback = prediction.fallback_level,
3945                risk = prediction.risk,
3946                "conformal risk gate"
3947            );
3948            debug!(
3949                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
3950                bucket = %prediction.bucket,
3951                "conformal prediction interval width"
3952            );
3953            conformal_prediction = Some(prediction);
3954        }
3955
3956        // Early skip if budget says to skip this frame entirely
3957        if self.budget.exhausted() {
3958            self.budget.record_frame_time(Duration::ZERO);
3959            self.emit_budget_evidence(
3960                frame_idx,
3961                degradation_start,
3962                0.0,
3963                conformal_prediction.as_ref(),
3964            );
3965            crate::debug_trace!(
3966                "frame skipped: budget exhausted (degradation={})",
3967                self.budget.degradation().as_str()
3968            );
3969            debug!(
3970                degradation = self.budget.degradation().as_str(),
3971                "frame skipped: budget exhausted before render"
3972            );
3973            self.dirty = false;
3974            return Ok(());
3975        }
3976
3977        let auto_bounds = self.writer.inline_auto_bounds();
3978        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
3979        let mut should_measure = needs_measure;
3980        if auto_bounds.is_some()
3981            && let Some(state) = self.inline_auto_remeasure.as_mut()
3982        {
3983            let decision = state.sampler.decide(Instant::now());
3984            if decision.should_sample {
3985                should_measure = true;
3986            }
3987        } else {
3988            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
3989        }
3990
3991        // --- Render phase ---
3992        let render_start = Instant::now();
3993        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
3994            let measure_height = if needs_measure {
3995                self.writer.render_height_hint().max(1)
3996            } else {
3997                max_height.max(1)
3998            };
3999            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
4000            let measured_height = measure_buffer.content_height();
4001            let clamped = measured_height.clamp(min_height, max_height);
4002            let previous_height = self.writer.auto_ui_height();
4003            self.writer.set_auto_ui_height(clamped);
4004            if let Some(state) = self.inline_auto_remeasure.as_mut() {
4005                let threshold = state.config.change_threshold_rows;
4006                let violated = previous_height
4007                    .map(|prev| prev.abs_diff(clamped) >= threshold)
4008                    .unwrap_or(false);
4009                state.sampler.observe(violated);
4010            }
4011        }
4012        if auto_bounds.is_some()
4013            && let Some(state) = self.inline_auto_remeasure.as_ref()
4014        {
4015            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
4016            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
4017        }
4018
4019        let frame_height = self.writer.render_height_hint().max(1);
4020        let _frame_span = info_span!(
4021            "ftui.render.frame",
4022            width = self.width,
4023            height = frame_height,
4024            duration_us = tracing::field::Empty
4025        )
4026        .entered();
4027        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
4028        self.update_widget_refresh_plan(frame_idx);
4029        let render_elapsed = render_start.elapsed();
4030        let mut present_elapsed = Duration::ZERO;
4031        let mut presented = false;
4032
4033        // Check if render phase overspent its budget
4034        let render_budget = self.budget.phase_budgets().render;
4035        if render_elapsed > render_budget {
4036            debug!(
4037                render_ms = render_elapsed.as_millis() as u32,
4038                budget_ms = render_budget.as_millis() as u32,
4039                "render phase exceeded budget"
4040            );
4041            // Trigger degradation if we're consistently over budget
4042            if self.budget.should_degrade(render_budget) {
4043                self.budget.degrade();
4044            }
4045        }
4046
4047        // --- Present phase ---
4048        if !self.budget.exhausted() {
4049            let present_start = Instant::now();
4050            {
4051                let _present_span = debug_span!("ftui.render.present").entered();
4052                self.writer
4053                    .present_ui_owned(buffer, cursor, cursor_visible)?;
4054            }
4055            presented = true;
4056            present_elapsed = present_start.elapsed();
4057
4058            let present_budget = self.budget.phase_budgets().present;
4059            if present_elapsed > present_budget {
4060                debug!(
4061                    present_ms = present_elapsed.as_millis() as u32,
4062                    budget_ms = present_budget.as_millis() as u32,
4063                    "present phase exceeded budget"
4064                );
4065            }
4066        } else {
4067            debug!(
4068                degradation = self.budget.degradation().as_str(),
4069                elapsed_ms = self.budget.elapsed().as_millis() as u32,
4070                "frame present skipped: budget exhausted after render"
4071            );
4072        }
4073
4074        if let Some(ref frame_timing) = self.frame_timing {
4075            let update_us = self.last_update_us.unwrap_or(0);
4076            let render_us = render_elapsed.as_micros() as u64;
4077            let present_us = present_elapsed.as_micros() as u64;
4078            let diff_us = if presented {
4079                self.writer
4080                    .take_last_present_timings()
4081                    .map(|timings| timings.diff_us)
4082                    .unwrap_or(0)
4083            } else {
4084                let _ = self.writer.take_last_present_timings();
4085                0
4086            };
4087            let total_us = update_us
4088                .saturating_add(render_us)
4089                .saturating_add(present_us);
4090            let timing = FrameTiming {
4091                frame_idx,
4092                update_us,
4093                render_us,
4094                diff_us,
4095                present_us,
4096                total_us,
4097            };
4098            frame_timing.sink.record_frame(&timing);
4099        }
4100
4101        let frame_time = render_elapsed.saturating_add(present_elapsed);
4102        self.budget.record_frame_time(frame_time);
4103        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
4104
4105        if let (Some(predictor), Some(prediction)) = (
4106            self.conformal_predictor.as_mut(),
4107            conformal_prediction.as_ref(),
4108        ) {
4109            let diff_strategy = self
4110                .writer
4111                .last_diff_strategy()
4112                .unwrap_or(DiffStrategy::Full);
4113            let key = BucketKey::from_context(
4114                self.writer.screen_mode(),
4115                diff_strategy,
4116                self.width,
4117                frame_height,
4118            );
4119            predictor.observe(key, prediction.y_hat, frame_time_us);
4120        }
4121        self.last_frame_time_us = Some(frame_time_us);
4122        self.emit_budget_evidence(
4123            frame_idx,
4124            degradation_start,
4125            frame_time_us,
4126            conformal_prediction.as_ref(),
4127        );
4128        self.dirty = false;
4129
4130        Ok(())
4131    }
4132
4133    fn emit_budget_evidence(
4134        &self,
4135        frame_idx: u64,
4136        degradation_start: DegradationLevel,
4137        frame_time_us: f64,
4138        conformal_prediction: Option<&ConformalPrediction>,
4139    ) {
4140        let Some(telemetry) = self.budget.telemetry() else {
4141            set_budget_snapshot(None);
4142            return;
4143        };
4144
4145        let budget_us = conformal_prediction
4146            .map(|prediction| prediction.budget_us)
4147            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
4148        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
4149        let degradation_after = self.budget.degradation();
4150
4151        let evidence = BudgetDecisionEvidence {
4152            frame_idx,
4153            decision: BudgetDecisionEvidence::decision_from_levels(
4154                degradation_start,
4155                degradation_after,
4156            ),
4157            controller_decision: telemetry.last_decision,
4158            degradation_before: degradation_start,
4159            degradation_after,
4160            frame_time_us,
4161            budget_us,
4162            pid_output: telemetry.pid_output,
4163            pid_p: telemetry.pid_p,
4164            pid_i: telemetry.pid_i,
4165            pid_d: telemetry.pid_d,
4166            e_value: telemetry.e_value,
4167            frames_observed: telemetry.frames_observed,
4168            frames_since_change: telemetry.frames_since_change,
4169            in_warmup: telemetry.in_warmup,
4170            conformal,
4171        };
4172
4173        let conformal_snapshot = evidence
4174            .conformal
4175            .as_ref()
4176            .map(|snapshot| ConformalSnapshot {
4177                bucket_key: snapshot.bucket_key.clone(),
4178                sample_count: snapshot.n_b,
4179                upper_us: snapshot.upper_us,
4180                risk: snapshot.risk,
4181            });
4182        set_budget_snapshot(Some(BudgetDecisionSnapshot {
4183            frame_idx: evidence.frame_idx,
4184            decision: evidence.decision,
4185            controller_decision: evidence.controller_decision,
4186            degradation_before: evidence.degradation_before,
4187            degradation_after: evidence.degradation_after,
4188            frame_time_us: evidence.frame_time_us,
4189            budget_us: evidence.budget_us,
4190            pid_output: evidence.pid_output,
4191            e_value: evidence.e_value,
4192            frames_observed: evidence.frames_observed,
4193            frames_since_change: evidence.frames_since_change,
4194            in_warmup: evidence.in_warmup,
4195            conformal: conformal_snapshot,
4196        }));
4197
4198        if let Some(ref sink) = self.evidence_sink {
4199            let _ = sink.write_jsonl(&evidence.to_jsonl());
4200        }
4201    }
4202
4203    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
4204        if !self.widget_refresh_config.enabled {
4205            self.widget_refresh_plan.clear();
4206            return;
4207        }
4208
4209        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
4210        let degradation = self.budget.degradation();
4211        self.widget_refresh_plan.recompute(
4212            frame_idx,
4213            budget_us,
4214            degradation,
4215            &self.widget_signals,
4216            &self.widget_refresh_config,
4217        );
4218
4219        if let Some(ref sink) = self.evidence_sink {
4220            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
4221        }
4222    }
4223
4224    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
4225        // Reset the per-frame arena so widgets get fresh scratch space.
4226        self.frame_arena.reset();
4227
4228        // Note: Frame borrows the pool and links from writer.
4229        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
4230        let buffer = self.writer.take_render_buffer(self.width, frame_height);
4231        let (pool, links) = self.writer.pool_and_links_mut();
4232        let mut frame = Frame::from_buffer(buffer, pool);
4233        frame.set_degradation(self.budget.degradation());
4234        frame.set_links(links);
4235        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
4236        frame.set_arena(&self.frame_arena);
4237
4238        let view_start = Instant::now();
4239        let _view_span = debug_span!(
4240            "ftui.program.view",
4241            duration_us = tracing::field::Empty,
4242            widget_count = tracing::field::Empty
4243        )
4244        .entered();
4245        self.model.view(&mut frame);
4246        self.widget_signals = frame.take_widget_signals();
4247        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
4248        // widget_count would require tracking in Frame
4249
4250        (frame.buffer, frame.cursor_position, frame.cursor_visible)
4251    }
4252
4253    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
4254        let Some(ref sink) = self.evidence_sink else {
4255            return;
4256        };
4257
4258        let config = self.fairness_guard.config();
4259        if !self.fairness_config_logged {
4260            let config_entry = FairnessConfigEvidence {
4261                enabled: config.enabled,
4262                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
4263                dominance_threshold: config.dominance_threshold,
4264                fairness_threshold: config.fairness_threshold,
4265            };
4266            let _ = sink.write_jsonl(&config_entry.to_jsonl());
4267            self.fairness_config_logged = true;
4268        }
4269
4270        let evidence = FairnessDecisionEvidence {
4271            frame_idx: self.frame_idx,
4272            decision: if decision.should_process {
4273                "allow"
4274            } else {
4275                "yield"
4276            },
4277            reason: decision.reason.as_str(),
4278            pending_input_latency_ms: decision
4279                .pending_input_latency
4280                .map(|latency| latency.as_millis() as u64),
4281            jain_index: decision.jain_index,
4282            resize_dominance_count: dominance_count,
4283            dominance_threshold: config.dominance_threshold,
4284            fairness_threshold: config.fairness_threshold,
4285            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
4286        };
4287
4288        let _ = sink.write_jsonl(&evidence.to_jsonl());
4289    }
4290
4291    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
4292        // Reset the per-frame arena for measurement pass.
4293        self.frame_arena.reset();
4294
4295        let pool = self.writer.pool_mut();
4296        let mut frame = Frame::new(self.width, frame_height, pool);
4297        frame.set_degradation(self.budget.degradation());
4298        frame.set_arena(&self.frame_arena);
4299
4300        let view_start = Instant::now();
4301        let _view_span = debug_span!(
4302            "ftui.program.view",
4303            duration_us = tracing::field::Empty,
4304            widget_count = tracing::field::Empty
4305        )
4306        .entered();
4307        self.model.view(&mut frame);
4308        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
4309
4310        (frame.buffer, frame.cursor_position)
4311    }
4312
4313    /// Calculate the effective poll timeout.
4314    fn effective_timeout(&self) -> Duration {
4315        if let Some(tick_rate) = self.tick_rate {
4316            let elapsed = self.last_tick.elapsed();
4317            let mut timeout = tick_rate.saturating_sub(elapsed);
4318            if self.resize_behavior.uses_coalescer()
4319                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
4320            {
4321                timeout = timeout.min(resize_timeout);
4322            }
4323            timeout
4324        } else {
4325            let mut timeout = self.poll_timeout;
4326            if self.resize_behavior.uses_coalescer()
4327                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
4328            {
4329                timeout = timeout.min(resize_timeout);
4330            }
4331            timeout
4332        }
4333    }
4334
4335    /// Check if we should send a tick.
4336    fn should_tick(&mut self) -> bool {
4337        if let Some(tick_rate) = self.tick_rate
4338            && self.last_tick.elapsed() >= tick_rate
4339        {
4340            self.last_tick = Instant::now();
4341            return true;
4342        }
4343        false
4344    }
4345
4346    fn process_resize_coalescer(&mut self) -> io::Result<()> {
4347        if !self.resize_behavior.uses_coalescer() {
4348            return Ok(());
4349        }
4350
4351        // Check fairness: if input is starving, skip resize application this cycle.
4352        // This ensures input events are processed before resize is finalized.
4353        let dominance_count = self.fairness_guard.resize_dominance_count();
4354        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
4355        self.emit_fairness_evidence(&fairness_decision, dominance_count);
4356        if !fairness_decision.should_process {
4357            debug!(
4358                reason = ?fairness_decision.reason,
4359                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
4360                "Resize yielding to input for fairness"
4361            );
4362            // Skip resize application this cycle to allow input processing.
4363            return Ok(());
4364        }
4365
4366        let action = self.resize_coalescer.tick();
4367        let resize_snapshot =
4368            self.resize_coalescer
4369                .logs()
4370                .last()
4371                .map(|entry| ResizeDecisionSnapshot {
4372                    event_idx: entry.event_idx,
4373                    action: entry.action,
4374                    dt_ms: entry.dt_ms,
4375                    event_rate: entry.event_rate,
4376                    regime: entry.regime,
4377                    pending_size: entry.pending_size,
4378                    applied_size: entry.applied_size,
4379                    time_since_render_ms: entry.time_since_render_ms,
4380                    bocpd: self
4381                        .resize_coalescer
4382                        .bocpd()
4383                        .and_then(|detector| detector.last_evidence().cloned()),
4384                });
4385        set_resize_snapshot(resize_snapshot);
4386
4387        match action {
4388            CoalesceAction::ApplyResize {
4389                width,
4390                height,
4391                coalesce_time,
4392                forced_by_deadline,
4393            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
4394            _ => Ok(()),
4395        }
4396    }
4397
4398    fn apply_resize(
4399        &mut self,
4400        width: u16,
4401        height: u16,
4402        coalesce_time: Duration,
4403        forced_by_deadline: bool,
4404    ) -> io::Result<()> {
4405        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
4406        let width = width.max(1);
4407        let height = height.max(1);
4408        self.width = width;
4409        self.height = height;
4410        self.writer.set_size(width, height);
4411        info!(
4412            width = width,
4413            height = height,
4414            coalesce_ms = coalesce_time.as_millis() as u64,
4415            forced = forced_by_deadline,
4416            "Resize applied"
4417        );
4418
4419        let msg = M::Message::from(Event::Resize { width, height });
4420        let start = Instant::now();
4421        let cmd = self.model.update(msg);
4422        let elapsed_us = start.elapsed().as_micros() as u64;
4423        self.last_update_us = Some(elapsed_us);
4424        self.mark_dirty();
4425        self.execute_cmd(cmd)
4426    }
4427
4428    // removed: resize placeholder rendering (continuous reflow preferred)
4429
4430    /// Get a reference to the model.
4431    pub fn model(&self) -> &M {
4432        &self.model
4433    }
4434
4435    /// Get a mutable reference to the model.
4436    pub fn model_mut(&mut self) -> &mut M {
4437        &mut self.model
4438    }
4439
4440    /// Check if the program is running.
4441    pub fn is_running(&self) -> bool {
4442        self.running
4443    }
4444
4445    /// Request a quit.
4446    pub fn quit(&mut self) {
4447        self.running = false;
4448    }
4449
4450    /// Get a reference to the state registry, if configured.
4451    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
4452        self.state_registry.as_ref()
4453    }
4454
4455    /// Check if state persistence is enabled.
4456    pub fn has_persistence(&self) -> bool {
4457        self.state_registry.is_some()
4458    }
4459
4460    /// Trigger a manual save of widget state.
4461    ///
4462    /// Returns the result of the flush operation, or `Ok(false)` if
4463    /// persistence is not configured.
4464    pub fn trigger_save(&mut self) -> StorageResult<bool> {
4465        if let Some(registry) = &self.state_registry {
4466            registry.flush()
4467        } else {
4468            Ok(false)
4469        }
4470    }
4471
4472    /// Trigger a manual load of widget state.
4473    ///
4474    /// Returns the number of entries loaded, or `Ok(0)` if persistence
4475    /// is not configured.
4476    pub fn trigger_load(&mut self) -> StorageResult<usize> {
4477        if let Some(registry) = &self.state_registry {
4478            registry.load()
4479        } else {
4480            Ok(0)
4481        }
4482    }
4483
4484    fn mark_dirty(&mut self) {
4485        self.dirty = true;
4486    }
4487
4488    fn check_locale_change(&mut self) {
4489        let version = self.locale_context.version();
4490        if version != self.locale_version {
4491            self.locale_version = version;
4492            self.mark_dirty();
4493        }
4494    }
4495
4496    /// Mark the UI as needing redraw.
4497    pub fn request_redraw(&mut self) {
4498        self.mark_dirty();
4499    }
4500
4501    /// Request a re-measure of inline auto UI height on next render.
4502    pub fn request_ui_height_remeasure(&mut self) {
4503        if self.writer.inline_auto_bounds().is_some() {
4504            self.writer.clear_auto_ui_height();
4505            if let Some(state) = self.inline_auto_remeasure.as_mut() {
4506                state.reset();
4507            }
4508            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
4509            self.mark_dirty();
4510        }
4511    }
4512
4513    /// Start recording events into a macro.
4514    ///
4515    /// If already recording, the current recording is discarded and a new one starts.
4516    /// The current terminal size is captured as metadata.
4517    pub fn start_recording(&mut self, name: impl Into<String>) {
4518        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
4519        recorder.start();
4520        self.event_recorder = Some(recorder);
4521    }
4522
4523    /// Stop recording and return the recorded macro, if any.
4524    ///
4525    /// Returns `None` if not currently recording.
4526    pub fn stop_recording(&mut self) -> Option<InputMacro> {
4527        self.event_recorder.take().map(EventRecorder::finish)
4528    }
4529
4530    /// Check if event recording is active.
4531    pub fn is_recording(&self) -> bool {
4532        self.event_recorder
4533            .as_ref()
4534            .is_some_and(EventRecorder::is_recording)
4535    }
4536}
4537
4538/// Builder for creating and running programs.
4539pub struct App;
4540
4541impl App {
4542    /// Create a new app builder with the given model.
4543    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
4544    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
4545        AppBuilder {
4546            model,
4547            config: ProgramConfig::default(),
4548        }
4549    }
4550
4551    /// Create a fullscreen app.
4552    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
4553        AppBuilder {
4554            model,
4555            config: ProgramConfig::fullscreen(),
4556        }
4557    }
4558
4559    /// Create an inline app with the given height.
4560    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
4561        AppBuilder {
4562            model,
4563            config: ProgramConfig::inline(height),
4564        }
4565    }
4566
4567    /// Create an inline app with automatic UI height.
4568    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
4569        AppBuilder {
4570            model,
4571            config: ProgramConfig::inline_auto(min_height, max_height),
4572        }
4573    }
4574
4575    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
4576    ///
4577    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
4578    /// so that `view_string()` output is rendered through the standard kernel pipeline.
4579    pub fn string_model<S: crate::string_model::StringModel>(
4580        model: S,
4581    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
4582        AppBuilder {
4583            model: crate::string_model::StringModelAdapter::new(model),
4584            config: ProgramConfig::fullscreen(),
4585        }
4586    }
4587}
4588
4589/// Builder for configuring and running programs.
4590#[must_use]
4591pub struct AppBuilder<M: Model> {
4592    model: M,
4593    config: ProgramConfig,
4594}
4595
4596impl<M: Model> AppBuilder<M> {
4597    /// Set the screen mode.
4598    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
4599        self.config.screen_mode = mode;
4600        self
4601    }
4602
4603    /// Set the UI anchor.
4604    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
4605        self.config.ui_anchor = anchor;
4606        self
4607    }
4608
4609    /// Force mouse capture on.
4610    pub fn with_mouse(mut self) -> Self {
4611        self.config.mouse_capture_policy = MouseCapturePolicy::On;
4612        self
4613    }
4614
4615    /// Set mouse capture policy for this app.
4616    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
4617        self.config.mouse_capture_policy = policy;
4618        self
4619    }
4620
4621    /// Force mouse capture enabled/disabled for this app.
4622    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
4623        self.config.mouse_capture_policy = if enabled {
4624            MouseCapturePolicy::On
4625        } else {
4626            MouseCapturePolicy::Off
4627        };
4628        self
4629    }
4630
4631    /// Set the frame budget configuration.
4632    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
4633        self.config.budget = budget;
4634        self
4635    }
4636
4637    /// Set the evidence JSONL sink configuration.
4638    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
4639        self.config.evidence_sink = config;
4640        self
4641    }
4642
4643    /// Set the render-trace recorder configuration.
4644    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
4645        self.config.render_trace = config;
4646        self
4647    }
4648
4649    /// Set the widget refresh selection configuration.
4650    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
4651        self.config.widget_refresh = config;
4652        self
4653    }
4654
4655    /// Set the effect queue scheduling configuration.
4656    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
4657        self.config.effect_queue = config;
4658        self
4659    }
4660
4661    /// Enable inline auto UI height remeasurement.
4662    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
4663        self.config.inline_auto_remeasure = Some(config);
4664        self
4665    }
4666
4667    /// Disable inline auto UI height remeasurement.
4668    pub fn without_inline_auto_remeasure(mut self) -> Self {
4669        self.config.inline_auto_remeasure = None;
4670        self
4671    }
4672
4673    /// Set the locale context used for rendering.
4674    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
4675        self.config.locale_context = locale_context;
4676        self
4677    }
4678
4679    /// Set the base locale used for rendering.
4680    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
4681        self.config.locale_context = LocaleContext::new(locale);
4682        self
4683    }
4684
4685    /// Set the resize coalescer configuration.
4686    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
4687        self.config.resize_coalescer = config;
4688        self
4689    }
4690
4691    /// Set the resize handling behavior.
4692    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
4693        self.config.resize_behavior = behavior;
4694        self
4695    }
4696
4697    /// Toggle legacy immediate-resize behavior for migration.
4698    pub fn legacy_resize(mut self, enabled: bool) -> Self {
4699        if enabled {
4700            self.config.resize_behavior = ResizeBehavior::Immediate;
4701        }
4702        self
4703    }
4704
4705    /// Run the application using the legacy Crossterm backend.
4706    #[cfg(feature = "crossterm-compat")]
4707    pub fn run(self) -> io::Result<()>
4708    where
4709        M::Message: Send + 'static,
4710    {
4711        let mut program = Program::with_config(self.model, self.config)?;
4712        program.run()
4713    }
4714
4715    /// Run the application using the native TTY backend.
4716    #[cfg(feature = "native-backend")]
4717    pub fn run_native(self) -> io::Result<()>
4718    where
4719        M::Message: Send + 'static,
4720    {
4721        let mut program = Program::with_native_backend(self.model, self.config)?;
4722        program.run()
4723    }
4724
4725    /// Run the application using the legacy Crossterm backend.
4726    #[cfg(not(feature = "crossterm-compat"))]
4727    pub fn run(self) -> io::Result<()>
4728    where
4729        M::Message: Send + 'static,
4730    {
4731        let _ = (self.model, self.config);
4732        Err(io::Error::new(
4733            io::ErrorKind::Unsupported,
4734            "enable `crossterm-compat` feature to use AppBuilder::run()",
4735        ))
4736    }
4737
4738    /// Run the application using the native TTY backend.
4739    #[cfg(not(feature = "native-backend"))]
4740    pub fn run_native(self) -> io::Result<()>
4741    where
4742        M::Message: Send + 'static,
4743    {
4744        let _ = (self.model, self.config);
4745        Err(io::Error::new(
4746            io::ErrorKind::Unsupported,
4747            "enable `native-backend` feature to use AppBuilder::run_native()",
4748        ))
4749    }
4750}
4751
4752// =============================================================================
4753// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
4754// =============================================================================
4755//
4756// # M/G/1 Queueing Model for Event Batching
4757//
4758// ## Problem
4759//
4760// The event loop must balance two objectives:
4761// 1. **Low latency**: Process events quickly (small batch window τ).
4762// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
4763//
4764// ## Model
4765//
4766// We model the event loop as an M/G/1 queue:
4767// - Events arrive at rate λ (Poisson process, reasonable for human input).
4768// - Service time S has mean E[S] and variance Var[S] (render + present).
4769// - Utilization ρ = λ·E[S] must be < 1 for stability.
4770//
4771// ## Pollaczek–Khinchine Mean Waiting Time
4772//
4773// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
4774// where E[S²] = Var[S] + E[S]².
4775//
4776// ## Optimal Batch Window τ
4777//
4778// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
4779// the per-frame render cost. The effective per-event latency is:
4780//
4781//   L(τ) = τ/2 + E[S]
4782//         (waiting in batch)  (service)
4783//
4784// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
4785// giving utilization ρ_eff = E[S]/τ.
4786//
4787// Minimizing L(τ) subject to ρ_eff < 1:
4788//   L(τ) = τ/2 + E[S]
4789//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
4790//
4791// But we need ρ_eff < 1, so τ > E[S].
4792//
4793// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
4794// where headroom_factor provides margin (typically 1.5–2.0).
4795//
4796// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
4797// where λ_target is the max frame rate we want to sustain.
4798//
4799// ## Failure Modes
4800//
4801// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
4802//    (degrade to lower frame rate), or drop stale events.
4803// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
4804//    exponential moving average of λ smooths this; high burst periods
4805//    temporarily increase τ.
4806// 3. **Variable service time**: Render complexity varies per frame. Using
4807//    EMA of E[S] tracks this adaptively.
4808//
4809// ## Observable Telemetry
4810//
4811// - λ_est: Exponential moving average of inter-arrival times.
4812// - es_est: Exponential moving average of service (render) times.
4813// - ρ_est: λ_est × es_est (estimated utilization).
4814
4815/// Adaptive batch window controller based on M/G/1 queueing model.
4816///
4817/// Estimates arrival rate λ and service time E[S] from observations,
4818/// then computes the optimal batch window τ to maintain stability
4819/// (ρ < 1) while minimizing latency.
4820#[derive(Debug, Clone)]
4821pub struct BatchController {
4822    /// Exponential moving average of inter-arrival time (seconds).
4823    ema_inter_arrival_s: f64,
4824    /// Exponential moving average of service time (seconds).
4825    ema_service_s: f64,
4826    /// EMA smoothing factor (0..1). Higher = more responsive.
4827    alpha: f64,
4828    /// Minimum batch window (floor).
4829    tau_min_s: f64,
4830    /// Maximum batch window (cap for responsiveness).
4831    tau_max_s: f64,
4832    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
4833    headroom: f64,
4834    /// Last event arrival timestamp.
4835    last_arrival: Option<Instant>,
4836    /// Number of observations.
4837    observations: u64,
4838}
4839
4840impl BatchController {
4841    /// Create a new controller with sensible defaults.
4842    ///
4843    /// - `alpha`: EMA smoothing (default 0.2)
4844    /// - `tau_min`: minimum batch window (default 1ms)
4845    /// - `tau_max`: maximum batch window (default 50ms)
4846    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
4847    pub fn new() -> Self {
4848        Self {
4849            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
4850            ema_service_s: 0.002,     // assume 2ms render initially
4851            alpha: 0.2,
4852            tau_min_s: 0.001, // 1ms floor
4853            tau_max_s: 0.050, // 50ms cap
4854            headroom: 2.0,
4855            last_arrival: None,
4856            observations: 0,
4857        }
4858    }
4859
4860    /// Record an event arrival, updating the inter-arrival estimate.
4861    pub fn observe_arrival(&mut self, now: Instant) {
4862        if let Some(last) = self.last_arrival {
4863            let dt = now.duration_since(last).as_secs_f64();
4864            if dt > 0.0 && dt < 10.0 {
4865                // Guard against stale gaps (e.g., app was suspended)
4866                self.ema_inter_arrival_s =
4867                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
4868                self.observations += 1;
4869            }
4870        }
4871        self.last_arrival = Some(now);
4872    }
4873
4874    /// Record a service (render) time observation.
4875    pub fn observe_service(&mut self, duration: Duration) {
4876        let dt = duration.as_secs_f64();
4877        if (0.0..10.0).contains(&dt) {
4878            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
4879        }
4880    }
4881
4882    /// Estimated arrival rate λ (events/second).
4883    #[inline]
4884    pub fn lambda_est(&self) -> f64 {
4885        if self.ema_inter_arrival_s > 0.0 {
4886            1.0 / self.ema_inter_arrival_s
4887        } else {
4888            0.0
4889        }
4890    }
4891
4892    /// Estimated service time E[S] (seconds).
4893    #[inline]
4894    pub fn service_est_s(&self) -> f64 {
4895        self.ema_service_s
4896    }
4897
4898    /// Estimated utilization ρ = λ × E[S].
4899    #[inline]
4900    pub fn rho_est(&self) -> f64 {
4901        self.lambda_est() * self.ema_service_s
4902    }
4903
4904    /// Compute the optimal batch window τ (seconds).
4905    ///
4906    /// τ = clamp(E[S] × headroom, τ_min, τ_max)
4907    ///
4908    /// When ρ approaches 1, τ increases to maintain stability.
4909    pub fn tau_s(&self) -> f64 {
4910        let base = self.ema_service_s * self.headroom;
4911        base.clamp(self.tau_min_s, self.tau_max_s)
4912    }
4913
4914    /// Compute the optimal batch window as a Duration.
4915    pub fn tau(&self) -> Duration {
4916        Duration::from_secs_f64(self.tau_s())
4917    }
4918
4919    /// Check if the system is stable (ρ < 1).
4920    #[inline]
4921    pub fn is_stable(&self) -> bool {
4922        self.rho_est() < 1.0
4923    }
4924
4925    /// Number of observations recorded.
4926    #[inline]
4927    pub fn observations(&self) -> u64 {
4928        self.observations
4929    }
4930}
4931
4932impl Default for BatchController {
4933    fn default() -> Self {
4934        Self::new()
4935    }
4936}
4937
4938#[cfg(test)]
4939mod tests {
4940    use super::*;
4941    use ftui_core::terminal_capabilities::TerminalCapabilities;
4942    use ftui_layout::PaneDragResizeEffect;
4943    use ftui_render::buffer::Buffer;
4944    use ftui_render::cell::Cell;
4945    use ftui_render::diff_strategy::DiffStrategy;
4946    use ftui_render::frame::CostEstimateSource;
4947    use serde_json::Value;
4948    use std::collections::HashMap;
4949    use std::path::PathBuf;
4950    use std::sync::mpsc;
4951    use std::sync::{
4952        Arc,
4953        atomic::{AtomicUsize, Ordering},
4954    };
4955
4956    // Simple test model
4957    struct TestModel {
4958        value: i32,
4959    }
4960
4961    #[derive(Debug)]
4962    enum TestMsg {
4963        Increment,
4964        Decrement,
4965        Quit,
4966    }
4967
4968    impl From<Event> for TestMsg {
4969        fn from(_event: Event) -> Self {
4970            TestMsg::Increment
4971        }
4972    }
4973
4974    impl Model for TestModel {
4975        type Message = TestMsg;
4976
4977        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4978            match msg {
4979                TestMsg::Increment => {
4980                    self.value += 1;
4981                    Cmd::none()
4982                }
4983                TestMsg::Decrement => {
4984                    self.value -= 1;
4985                    Cmd::none()
4986                }
4987                TestMsg::Quit => Cmd::quit(),
4988            }
4989        }
4990
4991        fn view(&self, _frame: &mut Frame) {
4992            // No-op for tests
4993        }
4994    }
4995
4996    #[test]
4997    fn cmd_none() {
4998        let cmd: Cmd<TestMsg> = Cmd::none();
4999        assert!(matches!(cmd, Cmd::None));
5000    }
5001
5002    #[test]
5003    fn cmd_quit() {
5004        let cmd: Cmd<TestMsg> = Cmd::quit();
5005        assert!(matches!(cmd, Cmd::Quit));
5006    }
5007
5008    #[test]
5009    fn cmd_msg() {
5010        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
5011        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
5012    }
5013
5014    #[test]
5015    fn cmd_batch_empty() {
5016        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
5017        assert!(matches!(cmd, Cmd::None));
5018    }
5019
5020    #[test]
5021    fn cmd_batch_single() {
5022        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
5023        assert!(matches!(cmd, Cmd::Quit));
5024    }
5025
5026    #[test]
5027    fn cmd_batch_multiple() {
5028        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
5029        assert!(matches!(cmd, Cmd::Batch(_)));
5030    }
5031
5032    #[test]
5033    fn cmd_sequence_empty() {
5034        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
5035        assert!(matches!(cmd, Cmd::None));
5036    }
5037
5038    #[test]
5039    fn cmd_tick() {
5040        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
5041        assert!(matches!(cmd, Cmd::Tick(_)));
5042    }
5043
5044    #[test]
5045    fn cmd_task() {
5046        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
5047        assert!(matches!(cmd, Cmd::Task(..)));
5048    }
5049
5050    #[test]
5051    fn cmd_debug_format() {
5052        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
5053        let debug = format!("{cmd:?}");
5054        assert_eq!(
5055            debug,
5056            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
5057        );
5058    }
5059
5060    #[test]
5061    fn model_subscriptions_default_empty() {
5062        let model = TestModel { value: 0 };
5063        let subs = model.subscriptions();
5064        assert!(subs.is_empty());
5065    }
5066
5067    #[test]
5068    fn program_config_default() {
5069        let config = ProgramConfig::default();
5070        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
5071        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
5072        assert!(!config.resolved_mouse_capture());
5073        assert!(config.bracketed_paste);
5074        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
5075        assert!(config.inline_auto_remeasure.is_none());
5076        assert!(config.conformal_config.is_none());
5077        assert!(config.diff_config.bayesian_enabled);
5078        assert!(config.diff_config.dirty_rows_enabled);
5079        assert!(!config.resize_coalescer.enable_bocpd);
5080        assert!(!config.effect_queue.enabled);
5081        assert_eq!(
5082            config.resize_coalescer.steady_delay_ms,
5083            CoalescerConfig::default().steady_delay_ms
5084        );
5085    }
5086
5087    #[test]
5088    fn program_config_fullscreen() {
5089        let config = ProgramConfig::fullscreen();
5090        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
5091    }
5092
5093    #[test]
5094    fn program_config_inline() {
5095        let config = ProgramConfig::inline(10);
5096        assert!(matches!(
5097            config.screen_mode,
5098            ScreenMode::Inline { ui_height: 10 }
5099        ));
5100    }
5101
5102    #[test]
5103    fn program_config_inline_auto() {
5104        let config = ProgramConfig::inline_auto(3, 9);
5105        assert!(matches!(
5106            config.screen_mode,
5107            ScreenMode::InlineAuto {
5108                min_height: 3,
5109                max_height: 9
5110            }
5111        ));
5112        assert!(config.inline_auto_remeasure.is_some());
5113    }
5114
5115    #[test]
5116    fn program_config_with_mouse() {
5117        let config = ProgramConfig::default().with_mouse();
5118        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
5119        assert!(config.resolved_mouse_capture());
5120    }
5121
5122    #[cfg(feature = "native-backend")]
5123    #[test]
5124    fn sanitize_backend_features_disables_unsupported_features() {
5125        let requested = BackendFeatures {
5126            mouse_capture: true,
5127            bracketed_paste: true,
5128            focus_events: true,
5129            kitty_keyboard: true,
5130        };
5131        let sanitized =
5132            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
5133        assert_eq!(sanitized, BackendFeatures::default());
5134    }
5135
5136    #[cfg(feature = "native-backend")]
5137    #[test]
5138    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
5139        let requested = BackendFeatures {
5140            mouse_capture: true,
5141            bracketed_paste: true,
5142            focus_events: true,
5143            kitty_keyboard: true,
5144        };
5145        let caps = TerminalCapabilities::builder()
5146            .mouse_sgr(true)
5147            .bracketed_paste(true)
5148            .focus_events(true)
5149            .kitty_keyboard(true)
5150            .in_wezterm_mux(true)
5151            .build();
5152        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
5153
5154        assert!(sanitized.mouse_capture);
5155        assert!(sanitized.bracketed_paste);
5156        assert!(!sanitized.focus_events);
5157        assert!(!sanitized.kitty_keyboard);
5158    }
5159
5160    #[cfg(feature = "native-backend")]
5161    #[test]
5162    fn sanitize_backend_features_is_conservative_in_tmux() {
5163        let requested = BackendFeatures {
5164            mouse_capture: true,
5165            bracketed_paste: true,
5166            focus_events: true,
5167            kitty_keyboard: true,
5168        };
5169        let caps = TerminalCapabilities::builder()
5170            .mouse_sgr(true)
5171            .bracketed_paste(true)
5172            .focus_events(true)
5173            .kitty_keyboard(true)
5174            .in_tmux(true)
5175            .build();
5176        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
5177
5178        assert!(sanitized.mouse_capture);
5179        assert!(sanitized.bracketed_paste);
5180        assert!(!sanitized.focus_events);
5181        assert!(!sanitized.kitty_keyboard);
5182    }
5183
5184    #[test]
5185    fn program_config_mouse_policy_auto_altscreen() {
5186        let config = ProgramConfig::fullscreen();
5187        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
5188        assert!(config.resolved_mouse_capture());
5189    }
5190
5191    #[test]
5192    fn program_config_mouse_policy_force_off() {
5193        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
5194        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
5195        assert!(!config.resolved_mouse_capture());
5196    }
5197
5198    #[test]
5199    fn program_config_mouse_policy_force_on_inline() {
5200        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
5201        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
5202        assert!(config.resolved_mouse_capture());
5203    }
5204
5205    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
5206        PaneResizeTarget {
5207            split_id: ftui_layout::PaneId::MIN,
5208            axis,
5209        }
5210    }
5211
5212    fn pane_id(raw: u64) -> ftui_layout::PaneId {
5213        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
5214    }
5215
5216    fn nested_pane_tree() -> ftui_layout::PaneTree {
5217        let root = pane_id(1);
5218        let left = pane_id(2);
5219        let right_split = pane_id(3);
5220        let right_top = pane_id(4);
5221        let right_bottom = pane_id(5);
5222        let snapshot = ftui_layout::PaneTreeSnapshot {
5223            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
5224            root,
5225            next_id: pane_id(6),
5226            nodes: vec![
5227                ftui_layout::PaneNodeRecord::split(
5228                    root,
5229                    None,
5230                    ftui_layout::PaneSplit {
5231                        axis: SplitAxis::Horizontal,
5232                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
5233                        first: left,
5234                        second: right_split,
5235                    },
5236                ),
5237                ftui_layout::PaneNodeRecord::leaf(
5238                    left,
5239                    Some(root),
5240                    ftui_layout::PaneLeaf::new("left"),
5241                ),
5242                ftui_layout::PaneNodeRecord::split(
5243                    right_split,
5244                    Some(root),
5245                    ftui_layout::PaneSplit {
5246                        axis: SplitAxis::Vertical,
5247                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
5248                        first: right_top,
5249                        second: right_bottom,
5250                    },
5251                ),
5252                ftui_layout::PaneNodeRecord::leaf(
5253                    right_top,
5254                    Some(right_split),
5255                    ftui_layout::PaneLeaf::new("right_top"),
5256                ),
5257                ftui_layout::PaneNodeRecord::leaf(
5258                    right_bottom,
5259                    Some(right_split),
5260                    ftui_layout::PaneLeaf::new("right_bottom"),
5261                ),
5262            ],
5263            extensions: std::collections::BTreeMap::new(),
5264        };
5265        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
5266    }
5267
5268    #[test]
5269    fn pane_terminal_splitter_resolution_is_deterministic() {
5270        let tree = nested_pane_tree();
5271        let layout = tree
5272            .solve_layout(Rect::new(0, 0, 50, 20))
5273            .expect("layout should solve");
5274        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
5275        assert_eq!(handles.len(), 2);
5276
5277        // Intersection between root vertical splitter and right-side horizontal
5278        // splitter deterministically resolves to smaller split ID.
5279        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
5280            .expect("overlap cell should resolve");
5281        assert_eq!(overlap.split_id, pane_id(1));
5282        assert_eq!(overlap.axis, SplitAxis::Horizontal);
5283
5284        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
5285            .expect("right split should resolve");
5286        assert_eq!(right_only.split_id, pane_id(3));
5287        assert_eq!(right_only.axis, SplitAxis::Vertical);
5288    }
5289
5290    #[test]
5291    fn pane_terminal_splitter_hits_register_and_decode_target() {
5292        let tree = nested_pane_tree();
5293        let layout = tree
5294            .solve_layout(Rect::new(0, 0, 50, 20))
5295            .expect("layout should solve");
5296        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
5297
5298        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
5299        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
5300        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
5301        assert_eq!(registered, handles.len());
5302
5303        let root_hit = frame
5304            .hit_test(25, 2)
5305            .expect("root splitter should be hittable");
5306        assert_eq!(root_hit.1, HitRegion::Handle);
5307        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
5308        assert_eq!(root_target.split_id, pane_id(1));
5309        assert_eq!(root_target.axis, SplitAxis::Horizontal);
5310
5311        let right_hit = frame
5312            .hit_test(40, 10)
5313            .expect("right splitter should be hittable");
5314        assert_eq!(right_hit.1, HitRegion::Handle);
5315        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
5316        assert_eq!(right_target.split_id, pane_id(3));
5317        assert_eq!(right_target.axis, SplitAxis::Vertical);
5318    }
5319
5320    #[test]
5321    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
5322        let mut adapter =
5323            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5324        let target = pane_target(SplitAxis::Horizontal);
5325
5326        let down = Event::Mouse(MouseEvent::new(
5327            MouseEventKind::Down(MouseButton::Left),
5328            10,
5329            4,
5330        ));
5331        let down_dispatch = adapter.translate(&down, Some(target));
5332        let down_event = down_dispatch
5333            .primary_event
5334            .as_ref()
5335            .expect("pointer down semantic event");
5336        assert_eq!(down_event.sequence, 1);
5337        assert!(matches!(
5338            down_event.kind,
5339            PaneSemanticInputEventKind::PointerDown {
5340                target: actual_target,
5341                pointer_id: 1,
5342                button: PanePointerButton::Primary,
5343                position
5344            } if actual_target == target && position == PanePointerPosition::new(10, 4)
5345        ));
5346        assert!(down_event.validate().is_ok());
5347
5348        let drag = Event::Mouse(MouseEvent::new(
5349            MouseEventKind::Drag(MouseButton::Left),
5350            14,
5351            4,
5352        ));
5353        let drag_dispatch = adapter.translate(&drag, None);
5354        let drag_event = drag_dispatch
5355            .primary_event
5356            .as_ref()
5357            .expect("pointer move semantic event");
5358        assert_eq!(drag_event.sequence, 2);
5359        assert!(matches!(
5360            drag_event.kind,
5361            PaneSemanticInputEventKind::PointerMove {
5362                target: actual_target,
5363                pointer_id: 1,
5364                position,
5365                delta_x: 4,
5366                delta_y: 0
5367            } if actual_target == target && position == PanePointerPosition::new(14, 4)
5368        ));
5369        let drag_motion = drag_dispatch
5370            .motion
5371            .expect("drag should emit motion metadata");
5372        assert_eq!(drag_motion.delta_x, 4);
5373        assert_eq!(drag_motion.delta_y, 0);
5374        assert_eq!(drag_motion.direction_changes, 0);
5375        assert!(drag_motion.speed > 0.0);
5376        assert!(drag_dispatch.pressure_snap_profile().is_some());
5377
5378        let up = Event::Mouse(MouseEvent::new(
5379            MouseEventKind::Up(MouseButton::Left),
5380            14,
5381            4,
5382        ));
5383        let up_dispatch = adapter.translate(&up, None);
5384        let up_event = up_dispatch
5385            .primary_event
5386            .as_ref()
5387            .expect("pointer up semantic event");
5388        assert_eq!(up_event.sequence, 3);
5389        assert!(matches!(
5390            up_event.kind,
5391            PaneSemanticInputEventKind::PointerUp {
5392                target: actual_target,
5393                pointer_id: 1,
5394                button: PanePointerButton::Primary,
5395                position
5396            } if actual_target == target && position == PanePointerPosition::new(14, 4)
5397        ));
5398        let up_motion = up_dispatch
5399            .motion
5400            .expect("up should emit final motion metadata");
5401        assert_eq!(up_motion.delta_x, 4);
5402        assert_eq!(up_motion.delta_y, 0);
5403        assert_eq!(up_motion.direction_changes, 0);
5404        let inertial_throw = up_dispatch
5405            .inertial_throw
5406            .expect("up should emit inertial throw metadata");
5407        assert_eq!(
5408            up_dispatch.projected_position,
5409            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
5410        );
5411        assert_eq!(adapter.active_pointer_id(), None);
5412        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
5413    }
5414
5415    #[test]
5416    fn pane_terminal_adapter_focus_loss_emits_cancel() {
5417        let mut adapter =
5418            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5419        let target = pane_target(SplitAxis::Vertical);
5420
5421        let down = Event::Mouse(MouseEvent::new(
5422            MouseEventKind::Down(MouseButton::Left),
5423            3,
5424            9,
5425        ));
5426        let _ = adapter.translate(&down, Some(target));
5427        assert_eq!(adapter.active_pointer_id(), Some(1));
5428
5429        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
5430        let cancel_event = cancel_dispatch
5431            .primary_event
5432            .as_ref()
5433            .expect("focus-loss cancel event");
5434        assert!(matches!(
5435            cancel_event.kind,
5436            PaneSemanticInputEventKind::Cancel {
5437                target: Some(actual_target),
5438                reason: PaneCancelReason::FocusLost
5439            } if actual_target == target
5440        ));
5441        assert_eq!(adapter.active_pointer_id(), None);
5442        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
5443    }
5444
5445    #[test]
5446    fn pane_terminal_adapter_recovers_missing_mouse_up() {
5447        let mut adapter =
5448            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5449        let first_target = pane_target(SplitAxis::Horizontal);
5450        let second_target = pane_target(SplitAxis::Vertical);
5451
5452        let first_down = Event::Mouse(MouseEvent::new(
5453            MouseEventKind::Down(MouseButton::Left),
5454            5,
5455            5,
5456        ));
5457        let _ = adapter.translate(&first_down, Some(first_target));
5458
5459        let second_down = Event::Mouse(MouseEvent::new(
5460            MouseEventKind::Down(MouseButton::Left),
5461            8,
5462            11,
5463        ));
5464        let dispatch = adapter.translate(&second_down, Some(second_target));
5465        let recovery = dispatch
5466            .recovery_event
5467            .as_ref()
5468            .expect("recovery cancel expected");
5469        assert!(matches!(
5470            recovery.kind,
5471            PaneSemanticInputEventKind::Cancel {
5472                target: Some(actual_target),
5473                reason: PaneCancelReason::PointerCancel
5474            } if actual_target == first_target
5475        ));
5476        let primary = dispatch
5477            .primary_event
5478            .as_ref()
5479            .expect("second pointer down expected");
5480        assert!(matches!(
5481            primary.kind,
5482            PaneSemanticInputEventKind::PointerDown {
5483                target: actual_target,
5484                pointer_id: 1,
5485                button: PanePointerButton::Primary,
5486                position
5487            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
5488        ));
5489        assert_eq!(recovery.sequence, 2);
5490        assert_eq!(primary.sequence, 3);
5491        assert!(matches!(
5492            dispatch.log.outcome,
5493            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
5494        ));
5495        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
5496    }
5497
5498    #[test]
5499    fn pane_terminal_adapter_modifier_parity() {
5500        let mut adapter =
5501            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5502        let target = pane_target(SplitAxis::Horizontal);
5503
5504        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
5505            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
5506        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
5507        let event = dispatch.primary_event.expect("semantic event");
5508        assert!(event.modifiers.shift);
5509        assert!(event.modifiers.alt);
5510        assert!(event.modifiers.ctrl);
5511        assert!(event.modifiers.meta);
5512    }
5513
5514    #[test]
5515    fn pane_terminal_adapter_keyboard_resize_mapping() {
5516        let mut adapter =
5517            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5518        let target = pane_target(SplitAxis::Horizontal);
5519
5520        let key = KeyEvent::new(KeyCode::Right);
5521        let dispatch = adapter.translate(&Event::Key(key), Some(target));
5522        let event = dispatch.primary_event.expect("keyboard resize event");
5523        assert!(matches!(
5524            event.kind,
5525            PaneSemanticInputEventKind::KeyboardResize {
5526                target: actual_target,
5527                direction: PaneResizeDirection::Increase,
5528                units: 1
5529            } if actual_target == target
5530        ));
5531
5532        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
5533        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
5534        let shifted_event = shifted_dispatch
5535            .primary_event
5536            .expect("shifted resize event");
5537        assert!(matches!(
5538            shifted_event.kind,
5539            PaneSemanticInputEventKind::KeyboardResize {
5540                direction: PaneResizeDirection::Increase,
5541                units: 5,
5542                ..
5543            }
5544        ));
5545        assert!(shifted_event.modifiers.shift);
5546    }
5547
5548    #[test]
5549    fn pane_terminal_adapter_drag_updates_are_coalesced() {
5550        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
5551            drag_update_coalesce_distance: 2,
5552            ..PaneTerminalAdapterConfig::default()
5553        })
5554        .expect("valid adapter");
5555        let target = pane_target(SplitAxis::Horizontal);
5556
5557        let down = Event::Mouse(MouseEvent::new(
5558            MouseEventKind::Down(MouseButton::Left),
5559            10,
5560            4,
5561        ));
5562        let _ = adapter.translate(&down, Some(target));
5563
5564        let drag_start = Event::Mouse(MouseEvent::new(
5565            MouseEventKind::Drag(MouseButton::Left),
5566            14,
5567            4,
5568        ));
5569        let started = adapter.translate(&drag_start, None);
5570        assert!(started.primary_event.is_some());
5571        assert!(matches!(
5572            adapter.machine_state(),
5573            PaneDragResizeState::Dragging { .. }
5574        ));
5575
5576        let coalesced = Event::Mouse(MouseEvent::new(
5577            MouseEventKind::Drag(MouseButton::Left),
5578            15,
5579            4,
5580        ));
5581        let coalesced_dispatch = adapter.translate(&coalesced, None);
5582        assert!(coalesced_dispatch.primary_event.is_none());
5583        assert!(matches!(
5584            coalesced_dispatch.log.outcome,
5585            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
5586        ));
5587
5588        let forwarded = Event::Mouse(MouseEvent::new(
5589            MouseEventKind::Drag(MouseButton::Left),
5590            16,
5591            4,
5592        ));
5593        let forwarded_dispatch = adapter.translate(&forwarded, None);
5594        let forwarded_event = forwarded_dispatch
5595            .primary_event
5596            .as_ref()
5597            .expect("coalesced movement should flush once threshold reached");
5598        assert!(matches!(
5599            forwarded_event.kind,
5600            PaneSemanticInputEventKind::PointerMove {
5601                delta_x: 2,
5602                delta_y: 0,
5603                ..
5604            }
5605        ));
5606    }
5607
5608    #[test]
5609    fn pane_terminal_adapter_motion_tracks_direction_changes() {
5610        let mut adapter =
5611            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5612        let target = pane_target(SplitAxis::Horizontal);
5613
5614        let down = Event::Mouse(MouseEvent::new(
5615            MouseEventKind::Down(MouseButton::Left),
5616            10,
5617            4,
5618        ));
5619        let _ = adapter.translate(&down, Some(target));
5620
5621        let drag_forward = Event::Mouse(MouseEvent::new(
5622            MouseEventKind::Drag(MouseButton::Left),
5623            14,
5624            4,
5625        ));
5626        let forward_dispatch = adapter.translate(&drag_forward, None);
5627        let forward_motion = forward_dispatch
5628            .motion
5629            .expect("forward drag should emit motion metadata");
5630        assert_eq!(forward_motion.direction_changes, 0);
5631
5632        let drag_reverse = Event::Mouse(MouseEvent::new(
5633            MouseEventKind::Drag(MouseButton::Left),
5634            12,
5635            4,
5636        ));
5637        let reverse_dispatch = adapter.translate(&drag_reverse, None);
5638        let reverse_motion = reverse_dispatch
5639            .motion
5640            .expect("reverse drag should emit motion metadata");
5641        assert_eq!(reverse_motion.direction_changes, 1);
5642
5643        let up = Event::Mouse(MouseEvent::new(
5644            MouseEventKind::Up(MouseButton::Left),
5645            12,
5646            4,
5647        ));
5648        let up_dispatch = adapter.translate(&up, None);
5649        let up_motion = up_dispatch
5650            .motion
5651            .expect("release should include cumulative motion metadata");
5652        assert_eq!(up_motion.direction_changes, 1);
5653    }
5654
5655    #[test]
5656    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
5657        let tree = nested_pane_tree();
5658        let layout = tree
5659            .solve_layout(Rect::new(0, 0, 50, 20))
5660            .expect("layout should solve");
5661        let handles =
5662            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
5663        let mut adapter =
5664            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5665
5666        let down = Event::Mouse(MouseEvent::new(
5667            MouseEventKind::Down(MouseButton::Left),
5668            25,
5669            10,
5670        ));
5671        let dispatch = adapter.translate_with_handles(&down, &handles);
5672        let event = dispatch
5673            .primary_event
5674            .as_ref()
5675            .expect("pointer down should be routed from handles");
5676        assert!(matches!(
5677            event.kind,
5678            PaneSemanticInputEventKind::PointerDown {
5679                target:
5680                    PaneResizeTarget {
5681                        split_id,
5682                        axis: SplitAxis::Horizontal
5683                    },
5684                ..
5685            } if split_id == pane_id(1)
5686        ));
5687    }
5688
5689    #[test]
5690    fn model_update() {
5691        let mut model = TestModel { value: 0 };
5692        model.update(TestMsg::Increment);
5693        assert_eq!(model.value, 1);
5694        model.update(TestMsg::Decrement);
5695        assert_eq!(model.value, 0);
5696        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
5697    }
5698
5699    #[test]
5700    fn model_init_default() {
5701        let mut model = TestModel { value: 0 };
5702        let cmd = model.init();
5703        assert!(matches!(cmd, Cmd::None));
5704    }
5705
5706    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
5707
5708    // =========================================================================
5709    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
5710    // =========================================================================
5711
5712    #[test]
5713    fn cmd_sequence_executes_in_order() {
5714        // Verify that Cmd::Sequence executes commands in declared order
5715        use crate::simulator::ProgramSimulator;
5716
5717        struct SeqModel {
5718            trace: Vec<i32>,
5719        }
5720
5721        #[derive(Debug)]
5722        enum SeqMsg {
5723            Append(i32),
5724            TriggerSequence,
5725        }
5726
5727        impl From<Event> for SeqMsg {
5728            fn from(_: Event) -> Self {
5729                SeqMsg::Append(0)
5730            }
5731        }
5732
5733        impl Model for SeqModel {
5734            type Message = SeqMsg;
5735
5736            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5737                match msg {
5738                    SeqMsg::Append(n) => {
5739                        self.trace.push(n);
5740                        Cmd::none()
5741                    }
5742                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
5743                        Cmd::msg(SeqMsg::Append(1)),
5744                        Cmd::msg(SeqMsg::Append(2)),
5745                        Cmd::msg(SeqMsg::Append(3)),
5746                    ]),
5747                }
5748            }
5749
5750            fn view(&self, _frame: &mut Frame) {}
5751        }
5752
5753        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
5754        sim.init();
5755        sim.send(SeqMsg::TriggerSequence);
5756
5757        assert_eq!(sim.model().trace, vec![1, 2, 3]);
5758    }
5759
5760    #[test]
5761    fn cmd_batch_executes_all_regardless_of_order() {
5762        // Verify that Cmd::Batch executes all commands
5763        use crate::simulator::ProgramSimulator;
5764
5765        struct BatchModel {
5766            values: Vec<i32>,
5767        }
5768
5769        #[derive(Debug)]
5770        enum BatchMsg {
5771            Add(i32),
5772            TriggerBatch,
5773        }
5774
5775        impl From<Event> for BatchMsg {
5776            fn from(_: Event) -> Self {
5777                BatchMsg::Add(0)
5778            }
5779        }
5780
5781        impl Model for BatchModel {
5782            type Message = BatchMsg;
5783
5784            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5785                match msg {
5786                    BatchMsg::Add(n) => {
5787                        self.values.push(n);
5788                        Cmd::none()
5789                    }
5790                    BatchMsg::TriggerBatch => Cmd::batch(vec![
5791                        Cmd::msg(BatchMsg::Add(10)),
5792                        Cmd::msg(BatchMsg::Add(20)),
5793                        Cmd::msg(BatchMsg::Add(30)),
5794                    ]),
5795                }
5796            }
5797
5798            fn view(&self, _frame: &mut Frame) {}
5799        }
5800
5801        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
5802        sim.init();
5803        sim.send(BatchMsg::TriggerBatch);
5804
5805        // All values should be present
5806        assert_eq!(sim.model().values.len(), 3);
5807        assert!(sim.model().values.contains(&10));
5808        assert!(sim.model().values.contains(&20));
5809        assert!(sim.model().values.contains(&30));
5810    }
5811
5812    #[test]
5813    fn cmd_sequence_stops_on_quit() {
5814        // Verify that Cmd::Sequence stops processing after Quit
5815        use crate::simulator::ProgramSimulator;
5816
5817        struct SeqQuitModel {
5818            trace: Vec<i32>,
5819        }
5820
5821        #[derive(Debug)]
5822        enum SeqQuitMsg {
5823            Append(i32),
5824            TriggerSequenceWithQuit,
5825        }
5826
5827        impl From<Event> for SeqQuitMsg {
5828            fn from(_: Event) -> Self {
5829                SeqQuitMsg::Append(0)
5830            }
5831        }
5832
5833        impl Model for SeqQuitModel {
5834            type Message = SeqQuitMsg;
5835
5836            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5837                match msg {
5838                    SeqQuitMsg::Append(n) => {
5839                        self.trace.push(n);
5840                        Cmd::none()
5841                    }
5842                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
5843                        Cmd::msg(SeqQuitMsg::Append(1)),
5844                        Cmd::quit(),
5845                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
5846                    ]),
5847                }
5848            }
5849
5850            fn view(&self, _frame: &mut Frame) {}
5851        }
5852
5853        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
5854        sim.init();
5855        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
5856
5857        assert_eq!(sim.model().trace, vec![1]);
5858        assert!(!sim.is_running());
5859    }
5860
5861    #[test]
5862    fn identical_input_produces_identical_state() {
5863        // Verify deterministic state transitions
5864        use crate::simulator::ProgramSimulator;
5865
5866        fn run_scenario() -> Vec<i32> {
5867            struct DetModel {
5868                values: Vec<i32>,
5869            }
5870
5871            #[derive(Debug, Clone)]
5872            enum DetMsg {
5873                Add(i32),
5874                Double,
5875            }
5876
5877            impl From<Event> for DetMsg {
5878                fn from(_: Event) -> Self {
5879                    DetMsg::Add(1)
5880                }
5881            }
5882
5883            impl Model for DetModel {
5884                type Message = DetMsg;
5885
5886                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5887                    match msg {
5888                        DetMsg::Add(n) => {
5889                            self.values.push(n);
5890                            Cmd::none()
5891                        }
5892                        DetMsg::Double => {
5893                            if let Some(&last) = self.values.last() {
5894                                self.values.push(last * 2);
5895                            }
5896                            Cmd::none()
5897                        }
5898                    }
5899                }
5900
5901                fn view(&self, _frame: &mut Frame) {}
5902            }
5903
5904            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
5905            sim.init();
5906            sim.send(DetMsg::Add(5));
5907            sim.send(DetMsg::Double);
5908            sim.send(DetMsg::Add(3));
5909            sim.send(DetMsg::Double);
5910
5911            sim.model().values.clone()
5912        }
5913
5914        // Run the same scenario multiple times
5915        let run1 = run_scenario();
5916        let run2 = run_scenario();
5917        let run3 = run_scenario();
5918
5919        assert_eq!(run1, run2);
5920        assert_eq!(run2, run3);
5921        assert_eq!(run1, vec![5, 10, 3, 6]);
5922    }
5923
5924    #[test]
5925    fn identical_state_produces_identical_render() {
5926        // Verify consistent render outputs for identical inputs
5927        use crate::simulator::ProgramSimulator;
5928
5929        struct RenderModel {
5930            counter: i32,
5931        }
5932
5933        #[derive(Debug)]
5934        enum RenderMsg {
5935            Set(i32),
5936        }
5937
5938        impl From<Event> for RenderMsg {
5939            fn from(_: Event) -> Self {
5940                RenderMsg::Set(0)
5941            }
5942        }
5943
5944        impl Model for RenderModel {
5945            type Message = RenderMsg;
5946
5947            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5948                match msg {
5949                    RenderMsg::Set(n) => {
5950                        self.counter = n;
5951                        Cmd::none()
5952                    }
5953                }
5954            }
5955
5956            fn view(&self, frame: &mut Frame) {
5957                let text = format!("Value: {}", self.counter);
5958                for (i, c) in text.chars().enumerate() {
5959                    if (i as u16) < frame.width() {
5960                        use ftui_render::cell::Cell;
5961                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
5962                    }
5963                }
5964            }
5965        }
5966
5967        // Create two simulators with the same state
5968        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
5969        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
5970
5971        let buf1 = sim1.capture_frame(80, 24);
5972        let buf2 = sim2.capture_frame(80, 24);
5973
5974        // Compare buffer contents
5975        for y in 0..24 {
5976            for x in 0..80 {
5977                let cell1 = buf1.get(x, y).unwrap();
5978                let cell2 = buf2.get(x, y).unwrap();
5979                assert_eq!(
5980                    cell1.content.as_char(),
5981                    cell2.content.as_char(),
5982                    "Mismatch at ({}, {})",
5983                    x,
5984                    y
5985                );
5986            }
5987        }
5988    }
5989
5990    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
5991
5992    #[test]
5993    fn cmd_log_creates_log_command() {
5994        let cmd: Cmd<TestMsg> = Cmd::log("test message");
5995        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
5996    }
5997
5998    #[test]
5999    fn cmd_log_from_string() {
6000        let msg = String::from("dynamic message");
6001        let cmd: Cmd<TestMsg> = Cmd::log(msg);
6002        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
6003    }
6004
6005    #[test]
6006    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
6007        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
6008        use crate::simulator::ProgramSimulator;
6009
6010        struct LogModel {
6011            run_id: &'static str,
6012            seed: u64,
6013        }
6014
6015        #[derive(Debug)]
6016        enum LogMsg {
6017            Emit,
6018        }
6019
6020        impl From<Event> for LogMsg {
6021            fn from(_: Event) -> Self {
6022                LogMsg::Emit
6023            }
6024        }
6025
6026        impl Model for LogModel {
6027            type Message = LogMsg;
6028
6029            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
6030                let line = format!(
6031                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
6032                    self.run_id, self.seed
6033                );
6034                Cmd::log(line)
6035            }
6036
6037            fn view(&self, _frame: &mut Frame) {}
6038        }
6039
6040        let mut sim = ProgramSimulator::new(LogModel {
6041            run_id: "test-run-001",
6042            seed: 4242,
6043        });
6044        sim.init();
6045        sim.send(LogMsg::Emit);
6046
6047        let logs = sim.logs();
6048        assert_eq!(logs.len(), 1);
6049        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
6050        assert!(logs[0].contains(r#""seed":4242"#));
6051    }
6052
6053    #[test]
6054    fn cmd_sequence_single_unwraps() {
6055        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
6056        // Single element sequence should unwrap to the inner command
6057        assert!(matches!(cmd, Cmd::Quit));
6058    }
6059
6060    #[test]
6061    fn cmd_sequence_multiple() {
6062        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
6063        assert!(matches!(cmd, Cmd::Sequence(_)));
6064    }
6065
6066    #[test]
6067    fn cmd_default_is_none() {
6068        let cmd: Cmd<TestMsg> = Cmd::default();
6069        assert!(matches!(cmd, Cmd::None));
6070    }
6071
6072    #[test]
6073    fn cmd_debug_all_variants() {
6074        // Test Debug impl for all variants
6075        let none: Cmd<TestMsg> = Cmd::none();
6076        assert_eq!(format!("{none:?}"), "None");
6077
6078        let quit: Cmd<TestMsg> = Cmd::quit();
6079        assert_eq!(format!("{quit:?}"), "Quit");
6080
6081        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
6082        assert!(format!("{msg:?}").starts_with("Msg("));
6083
6084        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
6085        assert!(format!("{batch:?}").starts_with("Batch("));
6086
6087        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
6088        assert!(format!("{seq:?}").starts_with("Sequence("));
6089
6090        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
6091        assert!(format!("{tick:?}").starts_with("Tick("));
6092
6093        let log: Cmd<TestMsg> = Cmd::log("test");
6094        assert!(format!("{log:?}").starts_with("Log("));
6095    }
6096
6097    #[test]
6098    fn program_config_with_budget() {
6099        let budget = FrameBudgetConfig {
6100            total: Duration::from_millis(50),
6101            ..Default::default()
6102        };
6103        let config = ProgramConfig::default().with_budget(budget);
6104        assert_eq!(config.budget.total, Duration::from_millis(50));
6105    }
6106
6107    #[test]
6108    fn program_config_with_conformal() {
6109        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
6110            alpha: 0.2,
6111            ..Default::default()
6112        });
6113        assert!(config.conformal_config.is_some());
6114        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
6115    }
6116
6117    #[test]
6118    fn program_config_forced_size_clamps_minimums() {
6119        let config = ProgramConfig::default().with_forced_size(0, 0);
6120        assert_eq!(config.forced_size, Some((1, 1)));
6121
6122        let cleared = config.without_forced_size();
6123        assert!(cleared.forced_size.is_none());
6124    }
6125
6126    #[test]
6127    fn effect_queue_config_defaults_are_safe() {
6128        let config = EffectQueueConfig::default();
6129        assert!(!config.enabled);
6130        assert!(config.scheduler.smith_enabled);
6131        assert!(!config.scheduler.preemptive);
6132        assert_eq!(config.scheduler.aging_factor, 0.0);
6133        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
6134    }
6135
6136    #[test]
6137    fn handle_effect_command_enqueues_or_executes_inline() {
6138        let (result_tx, result_rx) = mpsc::channel::<u32>();
6139        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
6140        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
6141
6142        let ran = Arc::new(AtomicUsize::new(0));
6143        let ran_task = ran.clone();
6144        let cmd = EffectCommand::Enqueue(
6145            TaskSpec::default(),
6146            Box::new(move || {
6147                ran_task.fetch_add(1, Ordering::SeqCst);
6148                7
6149            }),
6150        );
6151
6152        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx);
6153        assert!(!shutdown);
6154        assert_eq!(ran.load(Ordering::SeqCst), 0);
6155        assert_eq!(tasks.len(), 1);
6156        assert!(result_rx.try_recv().is_err());
6157
6158        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
6159            max_queue_size: 0,
6160            ..Default::default()
6161        });
6162        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
6163        let ran_full = Arc::new(AtomicUsize::new(0));
6164        let ran_full_task = ran_full.clone();
6165        let cmd_full = EffectCommand::Enqueue(
6166            TaskSpec::default(),
6167            Box::new(move || {
6168                ran_full_task.fetch_add(1, Ordering::SeqCst);
6169                42
6170            }),
6171        );
6172
6173        let shutdown_full =
6174            handle_effect_command(cmd_full, &mut full_scheduler, &mut full_tasks, &result_tx);
6175        assert!(!shutdown_full);
6176        assert!(full_tasks.is_empty());
6177        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
6178        assert_eq!(
6179            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
6180            42
6181        );
6182
6183        let shutdown = handle_effect_command(
6184            EffectCommand::Shutdown,
6185            &mut full_scheduler,
6186            &mut full_tasks,
6187            &result_tx,
6188        );
6189        assert!(shutdown);
6190    }
6191
6192    #[test]
6193    fn effect_queue_loop_executes_tasks_and_shutdowns() {
6194        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
6195        let (result_tx, result_rx) = mpsc::channel::<u32>();
6196        let config = EffectQueueConfig {
6197            enabled: true,
6198            scheduler: SchedulerConfig {
6199                preemptive: false,
6200                ..Default::default()
6201            },
6202        };
6203
6204        let handle = std::thread::spawn(move || {
6205            effect_queue_loop(config, cmd_rx, result_tx, None);
6206        });
6207
6208        cmd_tx
6209            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
6210            .unwrap();
6211        cmd_tx
6212            .send(EffectCommand::Enqueue(
6213                TaskSpec::new(2.0, 5.0).with_name("second"),
6214                Box::new(|| 20),
6215            ))
6216            .unwrap();
6217
6218        let mut results = vec![
6219            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
6220            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
6221        ];
6222        results.sort_unstable();
6223        assert_eq!(results, vec![10, 20]);
6224
6225        cmd_tx.send(EffectCommand::Shutdown).unwrap();
6226        let _ = handle.join();
6227    }
6228
6229    #[test]
6230    fn inline_auto_remeasure_reset_clears_decision() {
6231        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
6232        state.sampler.decide(Instant::now());
6233        assert!(state.sampler.last_decision().is_some());
6234
6235        state.reset();
6236        assert!(state.sampler.last_decision().is_none());
6237    }
6238
6239    #[test]
6240    fn budget_decision_jsonl_contains_required_fields() {
6241        let evidence = BudgetDecisionEvidence {
6242            frame_idx: 7,
6243            decision: BudgetDecision::Degrade,
6244            controller_decision: BudgetDecision::Hold,
6245            degradation_before: DegradationLevel::Full,
6246            degradation_after: DegradationLevel::NoStyling,
6247            frame_time_us: 12_345.678,
6248            budget_us: 16_000.0,
6249            pid_output: 1.25,
6250            pid_p: 0.5,
6251            pid_i: 0.25,
6252            pid_d: 0.5,
6253            e_value: 2.0,
6254            frames_observed: 42,
6255            frames_since_change: 3,
6256            in_warmup: false,
6257            conformal: Some(ConformalEvidence {
6258                bucket_key: "inline:dirty:10".to_string(),
6259                n_b: 32,
6260                alpha: 0.05,
6261                q_b: 1000.0,
6262                y_hat: 12_000.0,
6263                upper_us: 13_000.0,
6264                risk: true,
6265                fallback_level: 1,
6266                window_size: 256,
6267                reset_count: 2,
6268            }),
6269        };
6270
6271        let jsonl = evidence.to_jsonl();
6272        assert!(jsonl.contains("\"event\":\"budget_decision\""));
6273        assert!(jsonl.contains("\"decision\":\"degrade\""));
6274        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
6275        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
6276        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
6277        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
6278        assert!(jsonl.contains("\"budget_us\":16000.000000"));
6279        assert!(jsonl.contains("\"pid_output\":1.250000"));
6280        assert!(jsonl.contains("\"e_value\":2.000000"));
6281        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
6282        assert!(jsonl.contains("\"n_b\":32"));
6283        assert!(jsonl.contains("\"alpha\":0.050000"));
6284        assert!(jsonl.contains("\"q_b\":1000.000000"));
6285        assert!(jsonl.contains("\"y_hat\":12000.000000"));
6286        assert!(jsonl.contains("\"upper_us\":13000.000000"));
6287        assert!(jsonl.contains("\"risk\":true"));
6288        assert!(jsonl.contains("\"fallback_level\":1"));
6289        assert!(jsonl.contains("\"window_size\":256"));
6290        assert!(jsonl.contains("\"reset_count\":2"));
6291    }
6292
6293    fn make_signal(
6294        widget_id: u64,
6295        essential: bool,
6296        priority: f32,
6297        staleness_ms: u64,
6298        cost_us: f32,
6299    ) -> WidgetSignal {
6300        WidgetSignal {
6301            widget_id,
6302            essential,
6303            priority,
6304            staleness_ms,
6305            focus_boost: 0.0,
6306            interaction_boost: 0.0,
6307            area_cells: 1,
6308            cost_estimate_us: cost_us,
6309            recent_cost_us: 0.0,
6310            estimate_source: CostEstimateSource::FixedDefault,
6311        }
6312    }
6313
6314    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
6315        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
6316        let staleness_window = config.staleness_window_ms.max(1) as f32;
6317        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
6318        let mut value = config.weight_priority * signal.priority
6319            + config.weight_staleness * staleness_score
6320            + config.weight_focus * signal.focus_boost
6321            + config.weight_interaction * signal.interaction_boost;
6322        if starved {
6323            value += config.starve_boost;
6324        }
6325        let raw_cost = if signal.recent_cost_us > 0.0 {
6326            signal.recent_cost_us
6327        } else {
6328            signal.cost_estimate_us
6329        };
6330        let cost_us = raw_cost.max(config.min_cost_us);
6331        (value, cost_us, starved)
6332    }
6333
6334    fn fifo_select(
6335        signals: &[WidgetSignal],
6336        budget_us: f64,
6337        config: &WidgetRefreshConfig,
6338    ) -> (Vec<u64>, f64, usize) {
6339        let mut selected = Vec::new();
6340        let mut total_value = 0.0f64;
6341        let mut starved_selected = 0usize;
6342        let mut remaining = budget_us;
6343
6344        for signal in signals {
6345            if !signal.essential {
6346                continue;
6347            }
6348            let (value, cost_us, starved) = signal_value_cost(signal, config);
6349            remaining -= cost_us as f64;
6350            total_value += value as f64;
6351            if starved {
6352                starved_selected = starved_selected.saturating_add(1);
6353            }
6354            selected.push(signal.widget_id);
6355        }
6356        for signal in signals {
6357            if signal.essential {
6358                continue;
6359            }
6360            let (value, cost_us, starved) = signal_value_cost(signal, config);
6361            if remaining >= cost_us as f64 {
6362                remaining -= cost_us as f64;
6363                total_value += value as f64;
6364                if starved {
6365                    starved_selected = starved_selected.saturating_add(1);
6366                }
6367                selected.push(signal.widget_id);
6368            }
6369        }
6370
6371        (selected, total_value, starved_selected)
6372    }
6373
6374    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
6375        if signals.is_empty() {
6376            return Vec::new();
6377        }
6378        let mut rotated = Vec::with_capacity(signals.len());
6379        for idx in 0..signals.len() {
6380            rotated.push(signals[(idx + offset) % signals.len()].clone());
6381        }
6382        rotated
6383    }
6384
6385    #[test]
6386    fn widget_refresh_selects_essentials_first() {
6387        let signals = vec![
6388            make_signal(1, true, 0.6, 0, 5.0),
6389            make_signal(2, false, 0.9, 0, 4.0),
6390        ];
6391        let mut plan = WidgetRefreshPlan::new();
6392        let config = WidgetRefreshConfig::default();
6393        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
6394        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6395        assert_eq!(selected, vec![1]);
6396        assert!(!plan.over_budget);
6397    }
6398
6399    #[test]
6400    fn widget_refresh_degradation_essential_only_skips_nonessential() {
6401        let signals = vec![
6402            make_signal(1, true, 0.5, 0, 2.0),
6403            make_signal(2, false, 1.0, 0, 1.0),
6404        ];
6405        let mut plan = WidgetRefreshPlan::new();
6406        let config = WidgetRefreshConfig::default();
6407        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
6408        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6409        assert_eq!(selected, vec![1]);
6410        assert_eq!(plan.skipped_count, 1);
6411    }
6412
6413    #[test]
6414    fn widget_refresh_starvation_guard_forces_one_starved() {
6415        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
6416        let mut plan = WidgetRefreshPlan::new();
6417        let config = WidgetRefreshConfig {
6418            starve_ms: 1_000,
6419            max_starved_per_frame: 1,
6420            ..Default::default()
6421        };
6422        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
6423        assert_eq!(plan.selected.len(), 1);
6424        assert!(plan.selected[0].starved);
6425        assert!(plan.over_budget);
6426    }
6427
6428    #[test]
6429    fn widget_refresh_budget_blocks_when_no_selection() {
6430        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
6431        let mut plan = WidgetRefreshPlan::new();
6432        let config = WidgetRefreshConfig {
6433            starve_ms: 0,
6434            max_starved_per_frame: 0,
6435            ..Default::default()
6436        };
6437        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
6438        let budget = plan.as_budget();
6439        assert!(!budget.allows(42, false));
6440    }
6441
6442    #[test]
6443    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
6444        let signals = vec![
6445            make_signal(1, false, 0.4, 0, 10.0),
6446            make_signal(2, false, 0.4, 0, 10.0),
6447            make_signal(3, false, 0.4, 0, 10.0),
6448            make_signal(4, false, 0.4, 0, 10.0),
6449        ];
6450        let mut plan = WidgetRefreshPlan::new();
6451        let config = WidgetRefreshConfig {
6452            starve_ms: 0,
6453            max_starved_per_frame: 0,
6454            max_drop_fraction: 0.5,
6455            ..Default::default()
6456        };
6457        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
6458        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6459        assert_eq!(selected, vec![1, 2]);
6460    }
6461
6462    #[test]
6463    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
6464        let signals = vec![
6465            make_signal(1, false, 0.1, 0, 6.0),
6466            make_signal(2, false, 0.2, 0, 6.0),
6467            make_signal(3, false, 1.0, 0, 4.0),
6468            make_signal(4, false, 0.9, 0, 3.0),
6469            make_signal(5, false, 0.8, 0, 3.0),
6470            make_signal(6, false, 0.1, 4_000, 2.0),
6471        ];
6472        let budget_us = 10.0;
6473        let config = WidgetRefreshConfig::default();
6474
6475        let mut plan = WidgetRefreshPlan::new();
6476        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
6477        let greedy_value = plan.selected_value;
6478        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6479
6480        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
6481        let rotated = rotate_signals(&signals, 2);
6482        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
6483
6484        assert!(
6485            greedy_value > fifo_value,
6486            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
6487            greedy_selected,
6488            fifo_selected
6489        );
6490        assert!(
6491            greedy_value > rr_value,
6492            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
6493            greedy_selected,
6494            rr_selected
6495        );
6496        assert!(
6497            plan.starved_selected > 0,
6498            "greedy did not select starved widget; greedy={:?}",
6499            greedy_selected
6500        );
6501    }
6502
6503    #[test]
6504    fn widget_refresh_jsonl_contains_required_fields() {
6505        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
6506        let mut plan = WidgetRefreshPlan::new();
6507        let config = WidgetRefreshConfig::default();
6508        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
6509        let jsonl = plan.to_jsonl();
6510        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
6511        assert!(jsonl.contains("\"frame_idx\":9"));
6512        assert!(jsonl.contains("\"selected_count\":1"));
6513        assert!(jsonl.contains("\"id\":7"));
6514    }
6515
6516    #[test]
6517    fn program_config_with_resize_coalescer() {
6518        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
6519            steady_delay_ms: 8,
6520            burst_delay_ms: 20,
6521            hard_deadline_ms: 80,
6522            burst_enter_rate: 12.0,
6523            burst_exit_rate: 6.0,
6524            cooldown_frames: 2,
6525            rate_window_size: 6,
6526            enable_logging: true,
6527            enable_bocpd: false,
6528            bocpd_config: None,
6529        });
6530        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
6531        assert!(config.resize_coalescer.enable_logging);
6532    }
6533
6534    #[test]
6535    fn program_config_with_resize_behavior() {
6536        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
6537        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
6538    }
6539
6540    #[test]
6541    fn program_config_with_legacy_resize_enabled() {
6542        let config = ProgramConfig::default().with_legacy_resize(true);
6543        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
6544    }
6545
6546    #[test]
6547    fn program_config_with_legacy_resize_disabled_keeps_default() {
6548        let config = ProgramConfig::default().with_legacy_resize(false);
6549        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
6550    }
6551
6552    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
6553        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
6554        let mut writer = TerminalWriter::with_diff_config(
6555            Vec::<u8>::new(),
6556            ScreenMode::AltScreen,
6557            UiAnchor::Bottom,
6558            TerminalCapabilities::basic(),
6559            config,
6560        );
6561        writer.set_size(8, 4);
6562
6563        let mut buffer = Buffer::new(8, 4);
6564        let mut trace = Vec::new();
6565
6566        writer.present_ui(&buffer, None, false).unwrap();
6567        trace.push(
6568            writer
6569                .last_diff_strategy()
6570                .unwrap_or(DiffStrategy::FullRedraw),
6571        );
6572
6573        buffer.set_raw(0, 0, Cell::from_char('A'));
6574        writer.present_ui(&buffer, None, false).unwrap();
6575        trace.push(
6576            writer
6577                .last_diff_strategy()
6578                .unwrap_or(DiffStrategy::FullRedraw),
6579        );
6580
6581        buffer.set_raw(1, 1, Cell::from_char('B'));
6582        writer.present_ui(&buffer, None, false).unwrap();
6583        trace.push(
6584            writer
6585                .last_diff_strategy()
6586                .unwrap_or(DiffStrategy::FullRedraw),
6587        );
6588
6589        trace
6590    }
6591
6592    fn coalescer_checksum(enable_bocpd: bool) -> String {
6593        let mut config = CoalescerConfig::default().with_logging(true);
6594        if enable_bocpd {
6595            config = config.with_bocpd();
6596        }
6597
6598        let base = Instant::now();
6599        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
6600
6601        let events = [
6602            (0_u64, (82_u16, 24_u16)),
6603            (10, (83, 25)),
6604            (20, (84, 26)),
6605            (35, (90, 28)),
6606            (55, (92, 30)),
6607        ];
6608
6609        let mut idx = 0usize;
6610        for t_ms in (0_u64..=160).step_by(8) {
6611            let now = base + Duration::from_millis(t_ms);
6612            while idx < events.len() && events[idx].0 == t_ms {
6613                let (w, h) = events[idx].1;
6614                coalescer.handle_resize_at(w, h, now);
6615                idx += 1;
6616            }
6617            coalescer.tick_at(now);
6618        }
6619
6620        coalescer.decision_checksum_hex()
6621    }
6622
6623    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
6624        if !enabled {
6625            return Vec::new();
6626        }
6627
6628        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
6629        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
6630        let mut trace = Vec::new();
6631
6632        for i in 0..30 {
6633            let y_hat = 16_000.0 + (i as f64) * 15.0;
6634            let observed = y_hat + (i % 7) as f64 * 120.0;
6635            predictor.observe(key, y_hat, observed);
6636            let prediction = predictor.predict(key, y_hat, 20_000.0);
6637            trace.push((prediction.upper_us, prediction.risk));
6638        }
6639
6640        trace
6641    }
6642
6643    #[test]
6644    fn policy_toggle_matrix_determinism() {
6645        for &bayesian in &[false, true] {
6646            for &bocpd in &[false, true] {
6647                for &conformal in &[false, true] {
6648                    let diff_a = diff_strategy_trace(bayesian);
6649                    let diff_b = diff_strategy_trace(bayesian);
6650                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
6651
6652                    let checksum_a = coalescer_checksum(bocpd);
6653                    let checksum_b = coalescer_checksum(bocpd);
6654                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
6655
6656                    let conf_a = conformal_trace(conformal);
6657                    let conf_b = conformal_trace(conformal);
6658                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
6659
6660                    if conformal {
6661                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
6662                    } else {
6663                        assert!(conf_a.is_empty(), "conformal trace should be empty");
6664                    }
6665                }
6666            }
6667        }
6668    }
6669
6670    #[test]
6671    fn resize_behavior_uses_coalescer_flag() {
6672        assert!(ResizeBehavior::Throttled.uses_coalescer());
6673        assert!(!ResizeBehavior::Immediate.uses_coalescer());
6674    }
6675
6676    #[test]
6677    fn nested_cmd_msg_executes_recursively() {
6678        // Verify that Cmd::Msg triggers recursive update
6679        use crate::simulator::ProgramSimulator;
6680
6681        struct NestedModel {
6682            depth: usize,
6683        }
6684
6685        #[derive(Debug)]
6686        enum NestedMsg {
6687            Nest(usize),
6688        }
6689
6690        impl From<Event> for NestedMsg {
6691            fn from(_: Event) -> Self {
6692                NestedMsg::Nest(0)
6693            }
6694        }
6695
6696        impl Model for NestedModel {
6697            type Message = NestedMsg;
6698
6699            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6700                match msg {
6701                    NestedMsg::Nest(n) => {
6702                        self.depth += 1;
6703                        if n > 0 {
6704                            Cmd::msg(NestedMsg::Nest(n - 1))
6705                        } else {
6706                            Cmd::none()
6707                        }
6708                    }
6709                }
6710            }
6711
6712            fn view(&self, _frame: &mut Frame) {}
6713        }
6714
6715        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
6716        sim.init();
6717        sim.send(NestedMsg::Nest(3));
6718
6719        // Should have recursed 4 times (3, 2, 1, 0)
6720        assert_eq!(sim.model().depth, 4);
6721    }
6722
6723    #[test]
6724    fn task_executes_synchronously_in_simulator() {
6725        // In simulator, tasks execute synchronously
6726        use crate::simulator::ProgramSimulator;
6727
6728        struct TaskModel {
6729            completed: bool,
6730        }
6731
6732        #[derive(Debug)]
6733        enum TaskMsg {
6734            Complete,
6735            SpawnTask,
6736        }
6737
6738        impl From<Event> for TaskMsg {
6739            fn from(_: Event) -> Self {
6740                TaskMsg::Complete
6741            }
6742        }
6743
6744        impl Model for TaskModel {
6745            type Message = TaskMsg;
6746
6747            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6748                match msg {
6749                    TaskMsg::Complete => {
6750                        self.completed = true;
6751                        Cmd::none()
6752                    }
6753                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
6754                }
6755            }
6756
6757            fn view(&self, _frame: &mut Frame) {}
6758        }
6759
6760        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
6761        sim.init();
6762        sim.send(TaskMsg::SpawnTask);
6763
6764        // Task should have completed synchronously
6765        assert!(sim.model().completed);
6766    }
6767
6768    #[test]
6769    fn multiple_updates_accumulate_correctly() {
6770        // Verify state accumulates correctly across multiple updates
6771        use crate::simulator::ProgramSimulator;
6772
6773        struct AccumModel {
6774            sum: i32,
6775        }
6776
6777        #[derive(Debug)]
6778        enum AccumMsg {
6779            Add(i32),
6780            Multiply(i32),
6781        }
6782
6783        impl From<Event> for AccumMsg {
6784            fn from(_: Event) -> Self {
6785                AccumMsg::Add(1)
6786            }
6787        }
6788
6789        impl Model for AccumModel {
6790            type Message = AccumMsg;
6791
6792            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6793                match msg {
6794                    AccumMsg::Add(n) => {
6795                        self.sum += n;
6796                        Cmd::none()
6797                    }
6798                    AccumMsg::Multiply(n) => {
6799                        self.sum *= n;
6800                        Cmd::none()
6801                    }
6802                }
6803            }
6804
6805            fn view(&self, _frame: &mut Frame) {}
6806        }
6807
6808        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
6809        sim.init();
6810
6811        // (0 + 5) * 2 + 3 = 13
6812        sim.send(AccumMsg::Add(5));
6813        sim.send(AccumMsg::Multiply(2));
6814        sim.send(AccumMsg::Add(3));
6815
6816        assert_eq!(sim.model().sum, 13);
6817    }
6818
6819    #[test]
6820    fn init_command_executes_before_first_update() {
6821        // Verify init() command executes before any update
6822        use crate::simulator::ProgramSimulator;
6823
6824        struct InitModel {
6825            initialized: bool,
6826            updates: usize,
6827        }
6828
6829        #[derive(Debug)]
6830        enum InitMsg {
6831            Update,
6832            MarkInit,
6833        }
6834
6835        impl From<Event> for InitMsg {
6836            fn from(_: Event) -> Self {
6837                InitMsg::Update
6838            }
6839        }
6840
6841        impl Model for InitModel {
6842            type Message = InitMsg;
6843
6844            fn init(&mut self) -> Cmd<Self::Message> {
6845                Cmd::msg(InitMsg::MarkInit)
6846            }
6847
6848            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6849                match msg {
6850                    InitMsg::MarkInit => {
6851                        self.initialized = true;
6852                        Cmd::none()
6853                    }
6854                    InitMsg::Update => {
6855                        self.updates += 1;
6856                        Cmd::none()
6857                    }
6858                }
6859            }
6860
6861            fn view(&self, _frame: &mut Frame) {}
6862        }
6863
6864        let mut sim = ProgramSimulator::new(InitModel {
6865            initialized: false,
6866            updates: 0,
6867        });
6868        sim.init();
6869
6870        assert!(sim.model().initialized);
6871        sim.send(InitMsg::Update);
6872        assert_eq!(sim.model().updates, 1);
6873    }
6874
6875    // =========================================================================
6876    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
6877    // =========================================================================
6878
6879    #[test]
6880    fn ui_height_returns_correct_value_inline_mode() {
6881        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
6882        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6883        use ftui_core::terminal_capabilities::TerminalCapabilities;
6884
6885        let output = Vec::new();
6886        let writer = TerminalWriter::new(
6887            output,
6888            ScreenMode::Inline { ui_height: 10 },
6889            UiAnchor::Bottom,
6890            TerminalCapabilities::basic(),
6891        );
6892        assert_eq!(writer.ui_height(), 10);
6893    }
6894
6895    #[test]
6896    fn ui_height_returns_term_height_altscreen_mode() {
6897        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
6898        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6899        use ftui_core::terminal_capabilities::TerminalCapabilities;
6900
6901        let output = Vec::new();
6902        let mut writer = TerminalWriter::new(
6903            output,
6904            ScreenMode::AltScreen,
6905            UiAnchor::Bottom,
6906            TerminalCapabilities::basic(),
6907        );
6908        writer.set_size(80, 24);
6909        assert_eq!(writer.ui_height(), 24);
6910    }
6911
6912    #[test]
6913    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
6914        // Verify that in inline mode, the model receives a frame with ui_height,
6915        // not the full terminal height. This is the core fix for bd-20vg.
6916        use crate::simulator::ProgramSimulator;
6917        use std::cell::Cell as StdCell;
6918
6919        thread_local! {
6920            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
6921        }
6922
6923        struct FrameSizeTracker;
6924
6925        #[derive(Debug)]
6926        enum SizeMsg {
6927            Check,
6928        }
6929
6930        impl From<Event> for SizeMsg {
6931            fn from(_: Event) -> Self {
6932                SizeMsg::Check
6933            }
6934        }
6935
6936        impl Model for FrameSizeTracker {
6937            type Message = SizeMsg;
6938
6939            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
6940                Cmd::none()
6941            }
6942
6943            fn view(&self, frame: &mut Frame) {
6944                // Capture the frame height we receive
6945                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
6946            }
6947        }
6948
6949        // Use simulator to verify frame dimension handling
6950        let mut sim = ProgramSimulator::new(FrameSizeTracker);
6951        sim.init();
6952
6953        // Capture with specific dimensions (simulates inline mode ui_height=10)
6954        let buf = sim.capture_frame(80, 10);
6955        assert_eq!(buf.height(), 10);
6956        assert_eq!(buf.width(), 80);
6957
6958        // Verify the frame has the correct dimensions
6959        // In inline mode with ui_height=10, the frame should be 10 rows tall,
6960        // NOT the full terminal height (e.g., 24).
6961    }
6962
6963    #[test]
6964    fn altscreen_frame_uses_full_terminal_height() {
6965        // Regression test: in alt-screen mode, frame should use full terminal height.
6966        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6967        use ftui_core::terminal_capabilities::TerminalCapabilities;
6968
6969        let output = Vec::new();
6970        let mut writer = TerminalWriter::new(
6971            output,
6972            ScreenMode::AltScreen,
6973            UiAnchor::Bottom,
6974            TerminalCapabilities::basic(),
6975        );
6976        writer.set_size(80, 40);
6977
6978        // In alt-screen, ui_height equals terminal height
6979        assert_eq!(writer.ui_height(), 40);
6980    }
6981
6982    #[test]
6983    fn ui_height_clamped_to_terminal_height() {
6984        // Verify ui_height doesn't exceed terminal height
6985        // (This is handled in present_inline, but ui_height() returns the configured value)
6986        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6987        use ftui_core::terminal_capabilities::TerminalCapabilities;
6988
6989        let output = Vec::new();
6990        let mut writer = TerminalWriter::new(
6991            output,
6992            ScreenMode::Inline { ui_height: 100 },
6993            UiAnchor::Bottom,
6994            TerminalCapabilities::basic(),
6995        );
6996        writer.set_size(80, 10);
6997
6998        // ui_height() returns configured value, but present_inline clamps
6999        // The Frame should be created with ui_height (100), which is later
7000        // clamped during presentation. For safety, we should use the min.
7001        // Note: This documents current behavior. A stricter fix might
7002        // have ui_height() return min(ui_height, term_height).
7003        assert_eq!(writer.ui_height(), 100);
7004    }
7005
7006    // =========================================================================
7007    // TICK DELIVERY TESTS (bd-3ufh)
7008    // =========================================================================
7009
7010    #[test]
7011    fn tick_event_delivered_to_model_update() {
7012        // Verify that Event::Tick is delivered to model.update()
7013        // This is the core fix: ticks now flow through the update pipeline.
7014        use crate::simulator::ProgramSimulator;
7015
7016        struct TickTracker {
7017            tick_count: usize,
7018        }
7019
7020        #[derive(Debug)]
7021        enum TickMsg {
7022            Tick,
7023            Other,
7024        }
7025
7026        impl From<Event> for TickMsg {
7027            fn from(event: Event) -> Self {
7028                match event {
7029                    Event::Tick => TickMsg::Tick,
7030                    _ => TickMsg::Other,
7031                }
7032            }
7033        }
7034
7035        impl Model for TickTracker {
7036            type Message = TickMsg;
7037
7038            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7039                match msg {
7040                    TickMsg::Tick => {
7041                        self.tick_count += 1;
7042                        Cmd::none()
7043                    }
7044                    TickMsg::Other => Cmd::none(),
7045                }
7046            }
7047
7048            fn view(&self, _frame: &mut Frame) {}
7049        }
7050
7051        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
7052        sim.init();
7053
7054        // Manually inject tick event to simulate what the runtime does
7055        sim.inject_event(Event::Tick);
7056        assert_eq!(sim.model().tick_count, 1);
7057
7058        sim.inject_event(Event::Tick);
7059        sim.inject_event(Event::Tick);
7060        assert_eq!(sim.model().tick_count, 3);
7061    }
7062
7063    #[test]
7064    fn tick_command_sets_tick_rate() {
7065        // Verify Cmd::tick() sets the tick rate in the simulator
7066        use crate::simulator::{CmdRecord, ProgramSimulator};
7067
7068        struct TickModel;
7069
7070        #[derive(Debug)]
7071        enum Msg {
7072            SetTick,
7073            Noop,
7074        }
7075
7076        impl From<Event> for Msg {
7077            fn from(_: Event) -> Self {
7078                Msg::Noop
7079            }
7080        }
7081
7082        impl Model for TickModel {
7083            type Message = Msg;
7084
7085            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7086                match msg {
7087                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
7088                    Msg::Noop => Cmd::none(),
7089                }
7090            }
7091
7092            fn view(&self, _frame: &mut Frame) {}
7093        }
7094
7095        let mut sim = ProgramSimulator::new(TickModel);
7096        sim.init();
7097        sim.send(Msg::SetTick);
7098
7099        // Check that tick was recorded
7100        let commands = sim.command_log();
7101        assert!(
7102            commands
7103                .iter()
7104                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
7105        );
7106    }
7107
7108    #[test]
7109    fn tick_can_trigger_further_commands() {
7110        // Verify that tick handling can return commands that are executed
7111        use crate::simulator::ProgramSimulator;
7112
7113        struct ChainModel {
7114            stage: usize,
7115        }
7116
7117        #[derive(Debug)]
7118        enum ChainMsg {
7119            Tick,
7120            Advance,
7121            Noop,
7122        }
7123
7124        impl From<Event> for ChainMsg {
7125            fn from(event: Event) -> Self {
7126                match event {
7127                    Event::Tick => ChainMsg::Tick,
7128                    _ => ChainMsg::Noop,
7129                }
7130            }
7131        }
7132
7133        impl Model for ChainModel {
7134            type Message = ChainMsg;
7135
7136            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7137                match msg {
7138                    ChainMsg::Tick => {
7139                        self.stage += 1;
7140                        // Return another message to be processed
7141                        Cmd::msg(ChainMsg::Advance)
7142                    }
7143                    ChainMsg::Advance => {
7144                        self.stage += 10;
7145                        Cmd::none()
7146                    }
7147                    ChainMsg::Noop => Cmd::none(),
7148                }
7149            }
7150
7151            fn view(&self, _frame: &mut Frame) {}
7152        }
7153
7154        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
7155        sim.init();
7156        sim.inject_event(Event::Tick);
7157
7158        // Tick increments by 1, then Advance increments by 10
7159        assert_eq!(sim.model().stage, 11);
7160    }
7161
7162    #[test]
7163    fn tick_disabled_with_zero_duration() {
7164        // Verify that Duration::ZERO disables ticks (no busy loop)
7165        use crate::simulator::ProgramSimulator;
7166
7167        struct ZeroTickModel {
7168            disabled: bool,
7169        }
7170
7171        #[derive(Debug)]
7172        enum ZeroMsg {
7173            DisableTick,
7174            Noop,
7175        }
7176
7177        impl From<Event> for ZeroMsg {
7178            fn from(_: Event) -> Self {
7179                ZeroMsg::Noop
7180            }
7181        }
7182
7183        impl Model for ZeroTickModel {
7184            type Message = ZeroMsg;
7185
7186            fn init(&mut self) -> Cmd<Self::Message> {
7187                // Start with a tick enabled
7188                Cmd::tick(Duration::from_millis(100))
7189            }
7190
7191            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7192                match msg {
7193                    ZeroMsg::DisableTick => {
7194                        self.disabled = true;
7195                        // Setting tick to ZERO should effectively disable
7196                        Cmd::tick(Duration::ZERO)
7197                    }
7198                    ZeroMsg::Noop => Cmd::none(),
7199                }
7200            }
7201
7202            fn view(&self, _frame: &mut Frame) {}
7203        }
7204
7205        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
7206        sim.init();
7207
7208        // Verify initial tick rate is set
7209        assert!(sim.tick_rate().is_some());
7210        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
7211
7212        // Disable ticks
7213        sim.send(ZeroMsg::DisableTick);
7214        assert!(sim.model().disabled);
7215
7216        // Note: The simulator still records the ZERO tick, but the runtime's
7217        // should_tick() handles ZERO duration appropriately
7218        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
7219    }
7220
7221    #[test]
7222    fn tick_event_distinguishable_from_other_events() {
7223        // Verify Event::Tick can be distinguished in pattern matching
7224        let tick = Event::Tick;
7225        let key = Event::Key(ftui_core::event::KeyEvent::new(
7226            ftui_core::event::KeyCode::Char('a'),
7227        ));
7228
7229        assert!(matches!(tick, Event::Tick));
7230        assert!(!matches!(key, Event::Tick));
7231    }
7232
7233    #[test]
7234    fn tick_event_clone_and_eq() {
7235        // Verify Event::Tick implements Clone and Eq correctly
7236        let tick1 = Event::Tick;
7237        let tick2 = tick1.clone();
7238        assert_eq!(tick1, tick2);
7239    }
7240
7241    #[test]
7242    fn model_receives_tick_and_input_events() {
7243        // Verify model can handle both tick and input events correctly
7244        use crate::simulator::ProgramSimulator;
7245
7246        struct MixedModel {
7247            ticks: usize,
7248            keys: usize,
7249        }
7250
7251        #[derive(Debug)]
7252        enum MixedMsg {
7253            Tick,
7254            Key,
7255        }
7256
7257        impl From<Event> for MixedMsg {
7258            fn from(event: Event) -> Self {
7259                match event {
7260                    Event::Tick => MixedMsg::Tick,
7261                    _ => MixedMsg::Key,
7262                }
7263            }
7264        }
7265
7266        impl Model for MixedModel {
7267            type Message = MixedMsg;
7268
7269            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7270                match msg {
7271                    MixedMsg::Tick => {
7272                        self.ticks += 1;
7273                        Cmd::none()
7274                    }
7275                    MixedMsg::Key => {
7276                        self.keys += 1;
7277                        Cmd::none()
7278                    }
7279                }
7280            }
7281
7282            fn view(&self, _frame: &mut Frame) {}
7283        }
7284
7285        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
7286        sim.init();
7287
7288        // Interleave tick and input events
7289        sim.inject_event(Event::Tick);
7290        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
7291            ftui_core::event::KeyCode::Char('a'),
7292        )));
7293        sim.inject_event(Event::Tick);
7294        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
7295            ftui_core::event::KeyCode::Char('b'),
7296        )));
7297        sim.inject_event(Event::Tick);
7298
7299        assert_eq!(sim.model().ticks, 3);
7300        assert_eq!(sim.model().keys, 2);
7301    }
7302
7303    // =========================================================================
7304    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
7305    // =========================================================================
7306
7307    fn headless_program_with_config<M: Model>(
7308        model: M,
7309        config: ProgramConfig,
7310    ) -> Program<M, HeadlessEventSource, Vec<u8>>
7311    where
7312        M::Message: Send + 'static,
7313    {
7314        let capabilities = TerminalCapabilities::basic();
7315        let mut writer = TerminalWriter::with_diff_config(
7316            Vec::new(),
7317            config.screen_mode,
7318            config.ui_anchor,
7319            capabilities,
7320            config.diff_config.clone(),
7321        );
7322        let frame_timing = config.frame_timing.clone();
7323        writer.set_timing_enabled(frame_timing.is_some());
7324
7325        let (width, height) = config.forced_size.unwrap_or((80, 24));
7326        let width = width.max(1);
7327        let height = height.max(1);
7328        writer.set_size(width, height);
7329
7330        let mouse_capture = config.resolved_mouse_capture();
7331        let initial_features = BackendFeatures {
7332            mouse_capture,
7333            bracketed_paste: config.bracketed_paste,
7334            focus_events: config.focus_reporting,
7335            kitty_keyboard: config.kitty_keyboard,
7336        };
7337        let events = HeadlessEventSource::new(width, height, initial_features);
7338
7339        let budget = RenderBudget::from_config(&config.budget);
7340        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
7341        let locale_context = config.locale_context.clone();
7342        let locale_version = locale_context.version();
7343        let resize_coalescer =
7344            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
7345        let subscriptions = SubscriptionManager::new();
7346        let (task_sender, task_receiver) = std::sync::mpsc::channel();
7347        let inline_auto_remeasure = config
7348            .inline_auto_remeasure
7349            .clone()
7350            .map(InlineAutoRemeasureState::new);
7351
7352        Program {
7353            model,
7354            writer,
7355            events,
7356            backend_features: initial_features,
7357            running: true,
7358            tick_rate: None,
7359            last_tick: Instant::now(),
7360            dirty: true,
7361            frame_idx: 0,
7362            widget_signals: Vec::new(),
7363            widget_refresh_config: config.widget_refresh,
7364            widget_refresh_plan: WidgetRefreshPlan::new(),
7365            width,
7366            height,
7367            forced_size: config.forced_size,
7368            poll_timeout: config.poll_timeout,
7369            budget,
7370            conformal_predictor,
7371            last_frame_time_us: None,
7372            last_update_us: None,
7373            frame_timing,
7374            locale_context,
7375            locale_version,
7376            resize_coalescer,
7377            evidence_sink: None,
7378            fairness_config_logged: false,
7379            resize_behavior: config.resize_behavior,
7380            fairness_guard: InputFairnessGuard::new(),
7381            event_recorder: None,
7382            subscriptions,
7383            task_sender,
7384            task_receiver,
7385            task_handles: Vec::new(),
7386            effect_queue: None,
7387            state_registry: config.persistence.registry.clone(),
7388            persistence_config: config.persistence,
7389            last_checkpoint: Instant::now(),
7390            inline_auto_remeasure,
7391            frame_arena: FrameArena::default(),
7392        }
7393    }
7394
7395    fn temp_evidence_path(label: &str) -> PathBuf {
7396        static COUNTER: AtomicUsize = AtomicUsize::new(0);
7397        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
7398        let pid = std::process::id();
7399        let mut path = std::env::temp_dir();
7400        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
7401        path
7402    }
7403
7404    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
7405        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
7406        let needle = format!("\"event\":\"{event}\"");
7407        let missing_msg = format!("missing {event} line");
7408        let line = jsonl
7409            .lines()
7410            .find(|line| line.contains(&needle))
7411            .expect(&missing_msg);
7412        serde_json::from_str(line).expect("valid evidence json")
7413    }
7414
7415    #[test]
7416    fn headless_apply_resize_updates_model_and_dimensions() {
7417        struct ResizeModel {
7418            last_size: Option<(u16, u16)>,
7419        }
7420
7421        #[derive(Debug)]
7422        enum ResizeMsg {
7423            Resize(u16, u16),
7424            Other,
7425        }
7426
7427        impl From<Event> for ResizeMsg {
7428            fn from(event: Event) -> Self {
7429                match event {
7430                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
7431                    _ => ResizeMsg::Other,
7432                }
7433            }
7434        }
7435
7436        impl Model for ResizeModel {
7437            type Message = ResizeMsg;
7438
7439            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7440                if let ResizeMsg::Resize(w, h) = msg {
7441                    self.last_size = Some((w, h));
7442                }
7443                Cmd::none()
7444            }
7445
7446            fn view(&self, _frame: &mut Frame) {}
7447        }
7448
7449        let mut program =
7450            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
7451        program.dirty = false;
7452
7453        program
7454            .apply_resize(0, 0, Duration::ZERO, false)
7455            .expect("resize");
7456
7457        assert_eq!(program.width, 1);
7458        assert_eq!(program.height, 1);
7459        assert_eq!(program.model().last_size, Some((1, 1)));
7460        assert!(program.dirty);
7461    }
7462
7463    #[test]
7464    fn headless_execute_cmd_log_writes_output() {
7465        let mut program =
7466            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7467        program.execute_cmd(Cmd::log("hello world")).expect("log");
7468
7469        let bytes = program.writer.into_inner().expect("writer output");
7470        let output = String::from_utf8_lossy(&bytes);
7471        assert!(output.contains("hello world"));
7472    }
7473
7474    #[test]
7475    fn headless_process_task_results_updates_model() {
7476        struct TaskModel {
7477            updates: usize,
7478        }
7479
7480        #[derive(Debug)]
7481        enum TaskMsg {
7482            Done,
7483        }
7484
7485        impl From<Event> for TaskMsg {
7486            fn from(_: Event) -> Self {
7487                TaskMsg::Done
7488            }
7489        }
7490
7491        impl Model for TaskModel {
7492            type Message = TaskMsg;
7493
7494            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7495                self.updates += 1;
7496                Cmd::none()
7497            }
7498
7499            fn view(&self, _frame: &mut Frame) {}
7500        }
7501
7502        let mut program =
7503            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
7504        program.dirty = false;
7505        program.task_sender.send(TaskMsg::Done).unwrap();
7506
7507        program
7508            .process_task_results()
7509            .expect("process task results");
7510        assert_eq!(program.model().updates, 1);
7511        assert!(program.dirty);
7512    }
7513
7514    #[test]
7515    fn headless_should_tick_and_timeout_behaviors() {
7516        let mut program =
7517            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7518        program.tick_rate = Some(Duration::from_millis(5));
7519        program.last_tick = Instant::now() - Duration::from_millis(10);
7520
7521        assert!(program.should_tick());
7522        assert!(!program.should_tick());
7523
7524        let timeout = program.effective_timeout();
7525        assert!(timeout <= Duration::from_millis(5));
7526
7527        program.tick_rate = None;
7528        program.poll_timeout = Duration::from_millis(33);
7529        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
7530    }
7531
7532    #[test]
7533    fn headless_effective_timeout_respects_resize_coalescer() {
7534        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
7535        config.resize_coalescer.steady_delay_ms = 0;
7536        config.resize_coalescer.burst_delay_ms = 0;
7537
7538        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7539        program.tick_rate = Some(Duration::from_millis(50));
7540
7541        program.resize_coalescer.handle_resize(120, 40);
7542        assert!(program.resize_coalescer.has_pending());
7543
7544        let timeout = program.effective_timeout();
7545        assert_eq!(timeout, Duration::ZERO);
7546    }
7547
7548    #[test]
7549    fn headless_ui_height_remeasure_clears_auto_height() {
7550        let mut config = ProgramConfig::inline_auto(2, 6);
7551        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
7552
7553        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7554        program.dirty = false;
7555        program.writer.set_auto_ui_height(5);
7556
7557        assert_eq!(program.writer.auto_ui_height(), Some(5));
7558        program.request_ui_height_remeasure();
7559
7560        assert_eq!(program.writer.auto_ui_height(), None);
7561        assert!(program.dirty);
7562    }
7563
7564    #[test]
7565    fn headless_recording_lifecycle_and_locale_change() {
7566        let mut program =
7567            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7568        program.dirty = false;
7569
7570        program.start_recording("demo");
7571        assert!(program.is_recording());
7572        let recorded = program.stop_recording();
7573        assert!(recorded.is_some());
7574        assert!(!program.is_recording());
7575
7576        let prev_dirty = program.dirty;
7577        program.locale_context.set_locale("fr");
7578        program.check_locale_change();
7579        assert!(program.dirty || prev_dirty);
7580    }
7581
7582    #[test]
7583    fn headless_render_frame_marks_clean_and_sets_diff() {
7584        struct RenderModel;
7585
7586        #[derive(Debug)]
7587        enum RenderMsg {
7588            Noop,
7589        }
7590
7591        impl From<Event> for RenderMsg {
7592            fn from(_: Event) -> Self {
7593                RenderMsg::Noop
7594            }
7595        }
7596
7597        impl Model for RenderModel {
7598            type Message = RenderMsg;
7599
7600            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7601                Cmd::none()
7602            }
7603
7604            fn view(&self, frame: &mut Frame) {
7605                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
7606            }
7607        }
7608
7609        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
7610        program.render_frame().expect("render frame");
7611
7612        assert!(!program.dirty);
7613        assert!(program.writer.last_diff_strategy().is_some());
7614        assert_eq!(program.frame_idx, 1);
7615    }
7616
7617    #[test]
7618    fn headless_render_frame_skips_when_budget_exhausted() {
7619        let config = ProgramConfig {
7620            budget: FrameBudgetConfig::with_total(Duration::ZERO),
7621            ..Default::default()
7622        };
7623
7624        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7625        program.render_frame().expect("render frame");
7626
7627        assert!(!program.dirty);
7628        assert_eq!(program.frame_idx, 1);
7629    }
7630
7631    #[test]
7632    fn headless_render_frame_emits_budget_evidence_with_controller() {
7633        use ftui_render::budget::BudgetControllerConfig;
7634
7635        struct RenderModel;
7636
7637        #[derive(Debug)]
7638        enum RenderMsg {
7639            Noop,
7640        }
7641
7642        impl From<Event> for RenderMsg {
7643            fn from(_: Event) -> Self {
7644                RenderMsg::Noop
7645            }
7646        }
7647
7648        impl Model for RenderModel {
7649            type Message = RenderMsg;
7650
7651            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7652                Cmd::none()
7653            }
7654
7655            fn view(&self, frame: &mut Frame) {
7656                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
7657            }
7658        }
7659
7660        let config =
7661            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
7662        let mut program = headless_program_with_config(RenderModel, config);
7663        program.budget = program
7664            .budget
7665            .with_controller(BudgetControllerConfig::default());
7666
7667        program.render_frame().expect("render frame");
7668        assert!(program.budget.telemetry().is_some());
7669        assert_eq!(program.frame_idx, 1);
7670    }
7671
7672    #[test]
7673    fn headless_handle_event_updates_model() {
7674        struct EventModel {
7675            events: usize,
7676            last_resize: Option<(u16, u16)>,
7677        }
7678
7679        #[derive(Debug)]
7680        enum EventMsg {
7681            Resize(u16, u16),
7682            Other,
7683        }
7684
7685        impl From<Event> for EventMsg {
7686            fn from(event: Event) -> Self {
7687                match event {
7688                    Event::Resize { width, height } => EventMsg::Resize(width, height),
7689                    _ => EventMsg::Other,
7690                }
7691            }
7692        }
7693
7694        impl Model for EventModel {
7695            type Message = EventMsg;
7696
7697            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7698                self.events += 1;
7699                if let EventMsg::Resize(w, h) = msg {
7700                    self.last_resize = Some((w, h));
7701                }
7702                Cmd::none()
7703            }
7704
7705            fn view(&self, _frame: &mut Frame) {}
7706        }
7707
7708        let mut program = headless_program_with_config(
7709            EventModel {
7710                events: 0,
7711                last_resize: None,
7712            },
7713            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
7714        );
7715
7716        program
7717            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
7718                ftui_core::event::KeyCode::Char('x'),
7719            )))
7720            .expect("handle key");
7721        assert_eq!(program.model().events, 1);
7722
7723        program
7724            .handle_event(Event::Resize {
7725                width: 10,
7726                height: 5,
7727            })
7728            .expect("handle resize");
7729        assert_eq!(program.model().events, 2);
7730        assert_eq!(program.model().last_resize, Some((10, 5)));
7731        assert_eq!(program.width, 10);
7732        assert_eq!(program.height, 5);
7733    }
7734
7735    #[test]
7736    fn headless_handle_resize_ignored_when_forced_size() {
7737        struct ResizeModel {
7738            resized: bool,
7739        }
7740
7741        #[derive(Debug)]
7742        enum ResizeMsg {
7743            Resize,
7744            Other,
7745        }
7746
7747        impl From<Event> for ResizeMsg {
7748            fn from(event: Event) -> Self {
7749                match event {
7750                    Event::Resize { .. } => ResizeMsg::Resize,
7751                    _ => ResizeMsg::Other,
7752                }
7753            }
7754        }
7755
7756        impl Model for ResizeModel {
7757            type Message = ResizeMsg;
7758
7759            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7760                if matches!(msg, ResizeMsg::Resize) {
7761                    self.resized = true;
7762                }
7763                Cmd::none()
7764            }
7765
7766            fn view(&self, _frame: &mut Frame) {}
7767        }
7768
7769        let config = ProgramConfig::default().with_forced_size(80, 24);
7770        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
7771
7772        program
7773            .handle_event(Event::Resize {
7774                width: 120,
7775                height: 40,
7776            })
7777            .expect("handle resize");
7778
7779        assert_eq!(program.width, 80);
7780        assert_eq!(program.height, 24);
7781        assert!(!program.model().resized);
7782    }
7783
7784    #[test]
7785    fn headless_execute_cmd_batch_sequence_and_quit() {
7786        struct BatchModel {
7787            count: usize,
7788        }
7789
7790        #[derive(Debug)]
7791        enum BatchMsg {
7792            Inc,
7793        }
7794
7795        impl From<Event> for BatchMsg {
7796            fn from(_: Event) -> Self {
7797                BatchMsg::Inc
7798            }
7799        }
7800
7801        impl Model for BatchModel {
7802            type Message = BatchMsg;
7803
7804            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7805                match msg {
7806                    BatchMsg::Inc => {
7807                        self.count += 1;
7808                        Cmd::none()
7809                    }
7810                }
7811            }
7812
7813            fn view(&self, _frame: &mut Frame) {}
7814        }
7815
7816        let mut program =
7817            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
7818
7819        program
7820            .execute_cmd(Cmd::Batch(vec![
7821                Cmd::msg(BatchMsg::Inc),
7822                Cmd::Sequence(vec![
7823                    Cmd::msg(BatchMsg::Inc),
7824                    Cmd::quit(),
7825                    Cmd::msg(BatchMsg::Inc),
7826                ]),
7827            ]))
7828            .expect("batch cmd");
7829
7830        assert_eq!(program.model().count, 2);
7831        assert!(!program.running);
7832    }
7833
7834    #[test]
7835    fn headless_process_subscription_messages_updates_model() {
7836        use crate::subscription::{StopSignal, SubId, Subscription};
7837
7838        struct SubModel {
7839            pings: usize,
7840            ready_tx: mpsc::Sender<()>,
7841        }
7842
7843        #[derive(Debug)]
7844        enum SubMsg {
7845            Ping,
7846            Other,
7847        }
7848
7849        impl From<Event> for SubMsg {
7850            fn from(_: Event) -> Self {
7851                SubMsg::Other
7852            }
7853        }
7854
7855        impl Model for SubModel {
7856            type Message = SubMsg;
7857
7858            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7859                if let SubMsg::Ping = msg {
7860                    self.pings += 1;
7861                }
7862                Cmd::none()
7863            }
7864
7865            fn view(&self, _frame: &mut Frame) {}
7866
7867            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
7868                vec![Box::new(TestSubscription {
7869                    ready_tx: self.ready_tx.clone(),
7870                })]
7871            }
7872        }
7873
7874        struct TestSubscription {
7875            ready_tx: mpsc::Sender<()>,
7876        }
7877
7878        impl Subscription<SubMsg> for TestSubscription {
7879            fn id(&self) -> SubId {
7880                1
7881            }
7882
7883            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
7884                let _ = sender.send(SubMsg::Ping);
7885                let _ = self.ready_tx.send(());
7886            }
7887        }
7888
7889        let (ready_tx, ready_rx) = mpsc::channel();
7890        let mut program =
7891            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
7892
7893        program.reconcile_subscriptions();
7894        ready_rx
7895            .recv_timeout(Duration::from_millis(200))
7896            .expect("subscription started");
7897        program
7898            .process_subscription_messages()
7899            .expect("process subscriptions");
7900
7901        assert_eq!(program.model().pings, 1);
7902    }
7903
7904    #[test]
7905    fn headless_execute_cmd_task_spawns_and_reaps() {
7906        struct TaskModel {
7907            done: bool,
7908        }
7909
7910        #[derive(Debug)]
7911        enum TaskMsg {
7912            Done,
7913        }
7914
7915        impl From<Event> for TaskMsg {
7916            fn from(_: Event) -> Self {
7917                TaskMsg::Done
7918            }
7919        }
7920
7921        impl Model for TaskModel {
7922            type Message = TaskMsg;
7923
7924            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7925                match msg {
7926                    TaskMsg::Done => {
7927                        self.done = true;
7928                        Cmd::none()
7929                    }
7930                }
7931            }
7932
7933            fn view(&self, _frame: &mut Frame) {}
7934        }
7935
7936        let mut program =
7937            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
7938        program
7939            .execute_cmd(Cmd::task(|| TaskMsg::Done))
7940            .expect("task cmd");
7941
7942        let deadline = Instant::now() + Duration::from_millis(200);
7943        while !program.model().done && Instant::now() <= deadline {
7944            program
7945                .process_task_results()
7946                .expect("process task results");
7947            program.reap_finished_tasks();
7948        }
7949
7950        assert!(program.model().done, "task result did not arrive in time");
7951    }
7952
7953    #[test]
7954    fn headless_persistence_commands_with_registry() {
7955        use crate::state_persistence::{MemoryStorage, StateRegistry};
7956        use std::sync::Arc;
7957
7958        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
7959        let config = ProgramConfig::default().with_registry(registry.clone());
7960        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7961
7962        assert!(program.has_persistence());
7963        assert!(program.state_registry().is_some());
7964
7965        program.execute_cmd(Cmd::save_state()).expect("save");
7966        program.execute_cmd(Cmd::restore_state()).expect("restore");
7967
7968        let saved = program.trigger_save().expect("trigger save");
7969        let loaded = program.trigger_load().expect("trigger load");
7970        assert!(!saved);
7971        assert_eq!(loaded, 0);
7972    }
7973
7974    #[test]
7975    fn headless_process_resize_coalescer_applies_pending_resize() {
7976        struct ResizeModel {
7977            last_size: Option<(u16, u16)>,
7978        }
7979
7980        #[derive(Debug)]
7981        enum ResizeMsg {
7982            Resize(u16, u16),
7983            Other,
7984        }
7985
7986        impl From<Event> for ResizeMsg {
7987            fn from(event: Event) -> Self {
7988                match event {
7989                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
7990                    _ => ResizeMsg::Other,
7991                }
7992            }
7993        }
7994
7995        impl Model for ResizeModel {
7996            type Message = ResizeMsg;
7997
7998            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7999                if let ResizeMsg::Resize(w, h) = msg {
8000                    self.last_size = Some((w, h));
8001                }
8002                Cmd::none()
8003            }
8004
8005            fn view(&self, _frame: &mut Frame) {}
8006        }
8007
8008        let evidence_path = temp_evidence_path("fairness_allow");
8009        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
8010        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
8011        config.resize_coalescer.steady_delay_ms = 0;
8012        config.resize_coalescer.burst_delay_ms = 0;
8013        config.resize_coalescer.hard_deadline_ms = 1_000;
8014        config.evidence_sink = sink_config.clone();
8015
8016        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
8017        let sink = EvidenceSink::from_config(&sink_config)
8018            .expect("evidence sink config")
8019            .expect("evidence sink enabled");
8020        program.evidence_sink = Some(sink);
8021
8022        program.resize_coalescer.handle_resize(120, 40);
8023        assert!(program.resize_coalescer.has_pending());
8024
8025        program
8026            .process_resize_coalescer()
8027            .expect("process resize coalescer");
8028
8029        assert_eq!(program.width, 120);
8030        assert_eq!(program.height, 40);
8031        assert_eq!(program.model().last_size, Some((120, 40)));
8032
8033        let config_line = read_evidence_event(&evidence_path, "fairness_config");
8034        assert_eq!(config_line["event"], "fairness_config");
8035        assert!(config_line["enabled"].is_boolean());
8036        assert!(config_line["input_priority_threshold_ms"].is_number());
8037        assert!(config_line["dominance_threshold"].is_number());
8038        assert!(config_line["fairness_threshold"].is_number());
8039
8040        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
8041        assert_eq!(decision_line["event"], "fairness_decision");
8042        assert_eq!(decision_line["decision"], "allow");
8043        assert_eq!(decision_line["reason"], "none");
8044        assert!(decision_line["pending_input_latency_ms"].is_null());
8045        assert!(decision_line["jain_index"].is_number());
8046        assert!(decision_line["resize_dominance_count"].is_number());
8047        assert!(decision_line["dominance_threshold"].is_number());
8048        assert!(decision_line["fairness_threshold"].is_number());
8049        assert!(decision_line["input_priority_threshold_ms"].is_number());
8050    }
8051
8052    #[test]
8053    fn headless_process_resize_coalescer_yields_to_input() {
8054        struct ResizeModel {
8055            last_size: Option<(u16, u16)>,
8056        }
8057
8058        #[derive(Debug)]
8059        enum ResizeMsg {
8060            Resize(u16, u16),
8061            Other,
8062        }
8063
8064        impl From<Event> for ResizeMsg {
8065            fn from(event: Event) -> Self {
8066                match event {
8067                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
8068                    _ => ResizeMsg::Other,
8069                }
8070            }
8071        }
8072
8073        impl Model for ResizeModel {
8074            type Message = ResizeMsg;
8075
8076            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8077                if let ResizeMsg::Resize(w, h) = msg {
8078                    self.last_size = Some((w, h));
8079                }
8080                Cmd::none()
8081            }
8082
8083            fn view(&self, _frame: &mut Frame) {}
8084        }
8085
8086        let evidence_path = temp_evidence_path("fairness_yield");
8087        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
8088        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
8089        config.resize_coalescer.steady_delay_ms = 0;
8090        config.resize_coalescer.burst_delay_ms = 0;
8091        // Use a large hard deadline so elapsed wall-clock time between coalescer
8092        // construction and `handle_resize` never triggers an immediate apply.
8093        config.resize_coalescer.hard_deadline_ms = 10_000;
8094        config.evidence_sink = sink_config.clone();
8095
8096        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
8097        let sink = EvidenceSink::from_config(&sink_config)
8098            .expect("evidence sink config")
8099            .expect("evidence sink enabled");
8100        program.evidence_sink = Some(sink);
8101
8102        program.fairness_guard = InputFairnessGuard::with_config(
8103            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
8104        );
8105        program
8106            .fairness_guard
8107            .input_arrived(Instant::now() - Duration::from_millis(1));
8108
8109        program.resize_coalescer.handle_resize(120, 40);
8110        assert!(program.resize_coalescer.has_pending());
8111
8112        program
8113            .process_resize_coalescer()
8114            .expect("process resize coalescer");
8115
8116        assert_eq!(program.width, 80);
8117        assert_eq!(program.height, 24);
8118        assert_eq!(program.model().last_size, None);
8119        assert!(program.resize_coalescer.has_pending());
8120
8121        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
8122        assert_eq!(decision_line["event"], "fairness_decision");
8123        assert_eq!(decision_line["decision"], "yield");
8124        assert_eq!(decision_line["reason"], "input_latency");
8125        assert!(decision_line["pending_input_latency_ms"].is_number());
8126        assert!(decision_line["jain_index"].is_number());
8127        assert!(decision_line["resize_dominance_count"].is_number());
8128        assert!(decision_line["dominance_threshold"].is_number());
8129        assert!(decision_line["fairness_threshold"].is_number());
8130        assert!(decision_line["input_priority_threshold_ms"].is_number());
8131    }
8132
8133    #[test]
8134    fn headless_execute_cmd_task_with_effect_queue() {
8135        struct TaskModel {
8136            done: bool,
8137        }
8138
8139        #[derive(Debug)]
8140        enum TaskMsg {
8141            Done,
8142        }
8143
8144        impl From<Event> for TaskMsg {
8145            fn from(_: Event) -> Self {
8146                TaskMsg::Done
8147            }
8148        }
8149
8150        impl Model for TaskModel {
8151            type Message = TaskMsg;
8152
8153            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8154                match msg {
8155                    TaskMsg::Done => {
8156                        self.done = true;
8157                        Cmd::none()
8158                    }
8159                }
8160            }
8161
8162            fn view(&self, _frame: &mut Frame) {}
8163        }
8164
8165        let effect_queue = EffectQueueConfig {
8166            enabled: true,
8167            scheduler: SchedulerConfig {
8168                max_queue_size: 0,
8169                ..Default::default()
8170            },
8171        };
8172        let config = ProgramConfig::default().with_effect_queue(effect_queue);
8173        let mut program = headless_program_with_config(TaskModel { done: false }, config);
8174
8175        program
8176            .execute_cmd(Cmd::task(|| TaskMsg::Done))
8177            .expect("task cmd");
8178
8179        let deadline = Instant::now() + Duration::from_millis(200);
8180        while !program.model().done && Instant::now() <= deadline {
8181            program
8182                .process_task_results()
8183                .expect("process task results");
8184        }
8185
8186        assert!(
8187            program.model().done,
8188            "effect queue task result did not arrive in time"
8189        );
8190    }
8191
8192    // =========================================================================
8193    // BatchController Tests (bd-4kq0.8.1)
8194    // =========================================================================
8195
8196    #[test]
8197    fn unit_tau_monotone() {
8198        // τ should decrease (or stay constant) as service time decreases,
8199        // since τ = E[S] × headroom.
8200        let mut bc = BatchController::new();
8201
8202        // High service time → high τ
8203        bc.observe_service(Duration::from_millis(20));
8204        bc.observe_service(Duration::from_millis(20));
8205        bc.observe_service(Duration::from_millis(20));
8206        let tau_high = bc.tau_s();
8207
8208        // Low service time → lower τ
8209        for _ in 0..20 {
8210            bc.observe_service(Duration::from_millis(1));
8211        }
8212        let tau_low = bc.tau_s();
8213
8214        assert!(
8215            tau_low <= tau_high,
8216            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
8217        );
8218    }
8219
8220    #[test]
8221    fn unit_tau_monotone_lambda() {
8222        // As arrival rate λ decreases (longer inter-arrival times),
8223        // τ should not increase (it's based on service time, not λ).
8224        // But ρ should decrease.
8225        let mut bc = BatchController::new();
8226        let base = Instant::now();
8227
8228        // Fast arrivals (λ high)
8229        for i in 0..10 {
8230            bc.observe_arrival(base + Duration::from_millis(i * 10));
8231        }
8232        let rho_fast = bc.rho_est();
8233
8234        // Slow arrivals (λ low)
8235        for i in 10..20 {
8236            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
8237        }
8238        let rho_slow = bc.rho_est();
8239
8240        assert!(
8241            rho_slow < rho_fast,
8242            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
8243        );
8244    }
8245
8246    #[test]
8247    fn unit_stability() {
8248        // With reasonable service times, the controller should keep ρ < 1.
8249        let mut bc = BatchController::new();
8250        let base = Instant::now();
8251
8252        // Moderate arrival rate: 30 events/sec
8253        for i in 0..30 {
8254            bc.observe_arrival(base + Duration::from_millis(i * 33));
8255            bc.observe_service(Duration::from_millis(5)); // 5ms render
8256        }
8257
8258        assert!(
8259            bc.is_stable(),
8260            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
8261            bc.rho_est()
8262        );
8263        assert!(
8264            bc.rho_est() < 1.0,
8265            "utilization should be < 1: ρ={:.4}",
8266            bc.rho_est()
8267        );
8268
8269        // τ must be > E[S] (stability requirement)
8270        assert!(
8271            bc.tau_s() > bc.service_est_s(),
8272            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
8273            bc.tau_s(),
8274            bc.service_est_s()
8275        );
8276    }
8277
8278    #[test]
8279    fn unit_stability_high_load() {
8280        // Even under high load, τ keeps the system stable.
8281        let mut bc = BatchController::new();
8282        let base = Instant::now();
8283
8284        // 100 events/sec with 8ms render
8285        for i in 0..50 {
8286            bc.observe_arrival(base + Duration::from_millis(i * 10));
8287            bc.observe_service(Duration::from_millis(8));
8288        }
8289
8290        // τ × ρ_eff = E[S]/τ should be < 1
8291        let tau = bc.tau_s();
8292        let rho_eff = bc.service_est_s() / tau;
8293        assert!(
8294            rho_eff < 1.0,
8295            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
8296            bc.service_est_s()
8297        );
8298    }
8299
8300    #[test]
8301    fn batch_controller_defaults() {
8302        let bc = BatchController::new();
8303        assert!(bc.tau_s() >= bc.tau_min_s);
8304        assert!(bc.tau_s() <= bc.tau_max_s);
8305        assert_eq!(bc.observations(), 0);
8306        assert!(bc.is_stable());
8307    }
8308
8309    #[test]
8310    fn batch_controller_tau_clamped() {
8311        let mut bc = BatchController::new();
8312
8313        // Very fast service → τ clamped to tau_min
8314        for _ in 0..20 {
8315            bc.observe_service(Duration::from_micros(10));
8316        }
8317        assert!(
8318            bc.tau_s() >= bc.tau_min_s,
8319            "τ should be >= tau_min: τ={:.6}, min={:.6}",
8320            bc.tau_s(),
8321            bc.tau_min_s
8322        );
8323
8324        // Very slow service → τ clamped to tau_max
8325        for _ in 0..20 {
8326            bc.observe_service(Duration::from_millis(100));
8327        }
8328        assert!(
8329            bc.tau_s() <= bc.tau_max_s,
8330            "τ should be <= tau_max: τ={:.6}, max={:.6}",
8331            bc.tau_s(),
8332            bc.tau_max_s
8333        );
8334    }
8335
8336    #[test]
8337    fn batch_controller_duration_conversion() {
8338        let bc = BatchController::new();
8339        let tau = bc.tau();
8340        let tau_s = bc.tau_s();
8341        // Duration should match f64 representation
8342        let diff = (tau.as_secs_f64() - tau_s).abs();
8343        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
8344    }
8345
8346    #[test]
8347    fn batch_controller_lambda_estimation() {
8348        let mut bc = BatchController::new();
8349        let base = Instant::now();
8350
8351        // 50 events/sec (20ms apart)
8352        for i in 0..20 {
8353            bc.observe_arrival(base + Duration::from_millis(i * 20));
8354        }
8355
8356        // λ should converge near 50
8357        let lambda = bc.lambda_est();
8358        assert!(
8359            lambda > 20.0 && lambda < 100.0,
8360            "λ should be near 50: got {lambda:.1}"
8361        );
8362    }
8363
8364    // ─────────────────────────────────────────────────────────────────────────────
8365    // Persistence Config Tests
8366    // ─────────────────────────────────────────────────────────────────────────────
8367
8368    #[test]
8369    fn cmd_save_state() {
8370        let cmd: Cmd<TestMsg> = Cmd::save_state();
8371        assert!(matches!(cmd, Cmd::SaveState));
8372    }
8373
8374    #[test]
8375    fn cmd_restore_state() {
8376        let cmd: Cmd<TestMsg> = Cmd::restore_state();
8377        assert!(matches!(cmd, Cmd::RestoreState));
8378    }
8379
8380    #[test]
8381    fn persistence_config_default() {
8382        let config = PersistenceConfig::default();
8383        assert!(config.registry.is_none());
8384        assert!(config.checkpoint_interval.is_none());
8385        assert!(config.auto_load);
8386        assert!(config.auto_save);
8387    }
8388
8389    #[test]
8390    fn persistence_config_disabled() {
8391        let config = PersistenceConfig::disabled();
8392        assert!(config.registry.is_none());
8393    }
8394
8395    #[test]
8396    fn persistence_config_with_registry() {
8397        use crate::state_persistence::{MemoryStorage, StateRegistry};
8398        use std::sync::Arc;
8399
8400        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8401        let config = PersistenceConfig::with_registry(registry.clone());
8402
8403        assert!(config.registry.is_some());
8404        assert!(config.auto_load);
8405        assert!(config.auto_save);
8406    }
8407
8408    #[test]
8409    fn persistence_config_checkpoint_interval() {
8410        use crate::state_persistence::{MemoryStorage, StateRegistry};
8411        use std::sync::Arc;
8412
8413        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8414        let config = PersistenceConfig::with_registry(registry)
8415            .checkpoint_every(Duration::from_secs(30))
8416            .auto_load(false)
8417            .auto_save(true);
8418
8419        assert!(config.checkpoint_interval.is_some());
8420        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
8421        assert!(!config.auto_load);
8422        assert!(config.auto_save);
8423    }
8424
8425    #[test]
8426    fn program_config_with_persistence() {
8427        use crate::state_persistence::{MemoryStorage, StateRegistry};
8428        use std::sync::Arc;
8429
8430        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8431        let config = ProgramConfig::default().with_registry(registry);
8432
8433        assert!(config.persistence.registry.is_some());
8434    }
8435
8436    // =========================================================================
8437    // TaskSpec tests (bd-2yjus)
8438    // =========================================================================
8439
8440    #[test]
8441    fn task_spec_default() {
8442        let spec = TaskSpec::default();
8443        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
8444        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
8445        assert!(spec.name.is_none());
8446    }
8447
8448    #[test]
8449    fn task_spec_new() {
8450        let spec = TaskSpec::new(5.0, 20.0);
8451        assert_eq!(spec.weight, 5.0);
8452        assert_eq!(spec.estimate_ms, 20.0);
8453        assert!(spec.name.is_none());
8454    }
8455
8456    #[test]
8457    fn task_spec_with_name() {
8458        let spec = TaskSpec::default().with_name("fetch_data");
8459        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
8460    }
8461
8462    #[test]
8463    fn task_spec_debug() {
8464        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
8465        let debug = format!("{spec:?}");
8466        assert!(debug.contains("2.0"));
8467        assert!(debug.contains("15.0"));
8468        assert!(debug.contains("test"));
8469    }
8470
8471    // =========================================================================
8472    // Cmd::count() tests (bd-2yjus)
8473    // =========================================================================
8474
8475    #[test]
8476    fn cmd_count_none() {
8477        let cmd: Cmd<TestMsg> = Cmd::none();
8478        assert_eq!(cmd.count(), 0);
8479    }
8480
8481    #[test]
8482    fn cmd_count_atomic() {
8483        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
8484        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
8485        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
8486        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
8487        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
8488        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
8489        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
8490    }
8491
8492    #[test]
8493    fn cmd_count_batch() {
8494        let cmd: Cmd<TestMsg> =
8495            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
8496        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
8497    }
8498
8499    #[test]
8500    fn cmd_count_nested() {
8501        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
8502            Cmd::msg(TestMsg::Increment),
8503            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
8504        ]);
8505        assert_eq!(cmd.count(), 3);
8506    }
8507
8508    // =========================================================================
8509    // Cmd::type_name() tests (bd-2yjus)
8510    // =========================================================================
8511
8512    #[test]
8513    fn cmd_type_name_all_variants() {
8514        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
8515        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
8516        assert_eq!(
8517            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
8518            "Batch"
8519        );
8520        assert_eq!(
8521            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
8522            "Sequence"
8523        );
8524        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
8525        assert_eq!(
8526            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
8527            "Tick"
8528        );
8529        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
8530        assert_eq!(
8531            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
8532            "Task"
8533        );
8534        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
8535        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
8536        assert_eq!(
8537            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
8538            "SetMouseCapture"
8539        );
8540    }
8541
8542    // =========================================================================
8543    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
8544    // =========================================================================
8545
8546    #[test]
8547    fn cmd_batch_empty_returns_none() {
8548        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
8549        assert!(matches!(cmd, Cmd::None));
8550    }
8551
8552    #[test]
8553    fn cmd_batch_single_unwraps() {
8554        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
8555        assert!(matches!(cmd, Cmd::Quit));
8556    }
8557
8558    #[test]
8559    fn cmd_batch_multiple_stays_batch() {
8560        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
8561        assert!(matches!(cmd, Cmd::Batch(_)));
8562    }
8563
8564    #[test]
8565    fn cmd_sequence_empty_returns_none() {
8566        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
8567        assert!(matches!(cmd, Cmd::None));
8568    }
8569
8570    #[test]
8571    fn cmd_sequence_single_unwraps_to_inner() {
8572        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
8573        assert!(matches!(cmd, Cmd::Quit));
8574    }
8575
8576    #[test]
8577    fn cmd_sequence_multiple_stays_sequence() {
8578        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
8579        assert!(matches!(cmd, Cmd::Sequence(_)));
8580    }
8581
8582    // =========================================================================
8583    // Cmd task constructor variants (bd-2yjus)
8584    // =========================================================================
8585
8586    #[test]
8587    fn cmd_task_with_spec() {
8588        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
8589        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
8590        match cmd {
8591            Cmd::Task(s, _) => {
8592                assert_eq!(s.weight, 3.0);
8593                assert_eq!(s.estimate_ms, 25.0);
8594                assert_eq!(s.name.as_deref(), Some("my_task"));
8595            }
8596            _ => panic!("expected Task variant"),
8597        }
8598    }
8599
8600    #[test]
8601    fn cmd_task_weighted() {
8602        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
8603        match cmd {
8604            Cmd::Task(s, _) => {
8605                assert_eq!(s.weight, 2.0);
8606                assert_eq!(s.estimate_ms, 50.0);
8607                assert!(s.name.is_none());
8608            }
8609            _ => panic!("expected Task variant"),
8610        }
8611    }
8612
8613    #[test]
8614    fn cmd_task_named() {
8615        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
8616        match cmd {
8617            Cmd::Task(s, _) => {
8618                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
8619                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
8620                assert_eq!(s.name.as_deref(), Some("background_fetch"));
8621            }
8622            _ => panic!("expected Task variant"),
8623        }
8624    }
8625
8626    // =========================================================================
8627    // Cmd Debug formatting (bd-2yjus)
8628    // =========================================================================
8629
8630    #[test]
8631    fn cmd_debug_all_variant_strings() {
8632        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
8633        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
8634        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
8635        assert!(
8636            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
8637        );
8638        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
8639        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
8640        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
8641        assert_eq!(
8642            format!("{:?}", Cmd::<TestMsg>::restore_state()),
8643            "RestoreState"
8644        );
8645        assert_eq!(
8646            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
8647            "SetMouseCapture(true)"
8648        );
8649    }
8650
8651    // =========================================================================
8652    // Cmd::set_mouse_capture headless execution (bd-2yjus)
8653    // =========================================================================
8654
8655    #[test]
8656    fn headless_execute_cmd_set_mouse_capture() {
8657        let mut program =
8658            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8659        assert!(!program.backend_features.mouse_capture);
8660
8661        program
8662            .execute_cmd(Cmd::set_mouse_capture(true))
8663            .expect("set mouse capture true");
8664        assert!(program.backend_features.mouse_capture);
8665
8666        program
8667            .execute_cmd(Cmd::set_mouse_capture(false))
8668            .expect("set mouse capture false");
8669        assert!(!program.backend_features.mouse_capture);
8670    }
8671
8672    // =========================================================================
8673    // ResizeBehavior tests (bd-2yjus)
8674    // =========================================================================
8675
8676    #[test]
8677    fn resize_behavior_uses_coalescer() {
8678        assert!(ResizeBehavior::Throttled.uses_coalescer());
8679        assert!(!ResizeBehavior::Immediate.uses_coalescer());
8680    }
8681
8682    #[test]
8683    fn resize_behavior_eq_and_debug() {
8684        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
8685        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
8686        let debug = format!("{:?}", ResizeBehavior::Throttled);
8687        assert_eq!(debug, "Throttled");
8688    }
8689
8690    // =========================================================================
8691    // WidgetRefreshConfig default values (bd-2yjus)
8692    // =========================================================================
8693
8694    #[test]
8695    fn widget_refresh_config_defaults() {
8696        let config = WidgetRefreshConfig::default();
8697        assert!(config.enabled);
8698        assert_eq!(config.staleness_window_ms, 1_000);
8699        assert_eq!(config.starve_ms, 3_000);
8700        assert_eq!(config.max_starved_per_frame, 2);
8701        assert_eq!(config.max_drop_fraction, 1.0);
8702        assert_eq!(config.weight_priority, 1.0);
8703        assert_eq!(config.weight_staleness, 0.5);
8704        assert_eq!(config.weight_focus, 0.75);
8705        assert_eq!(config.weight_interaction, 0.5);
8706        assert_eq!(config.starve_boost, 1.5);
8707        assert_eq!(config.min_cost_us, 1.0);
8708    }
8709
8710    // =========================================================================
8711    // EffectQueueConfig tests (bd-2yjus)
8712    // =========================================================================
8713
8714    #[test]
8715    fn effect_queue_config_default() {
8716        let config = EffectQueueConfig::default();
8717        assert!(!config.enabled);
8718        assert!(config.scheduler.smith_enabled);
8719        assert!(!config.scheduler.force_fifo);
8720        assert!(!config.scheduler.preemptive);
8721    }
8722
8723    #[test]
8724    fn effect_queue_config_with_enabled() {
8725        let config = EffectQueueConfig::default().with_enabled(true);
8726        assert!(config.enabled);
8727    }
8728
8729    #[test]
8730    fn effect_queue_config_with_scheduler() {
8731        let sched = SchedulerConfig {
8732            force_fifo: true,
8733            ..Default::default()
8734        };
8735        let config = EffectQueueConfig::default().with_scheduler(sched);
8736        assert!(config.scheduler.force_fifo);
8737    }
8738
8739    // =========================================================================
8740    // InlineAutoRemeasureConfig defaults (bd-2yjus)
8741    // =========================================================================
8742
8743    #[test]
8744    fn inline_auto_remeasure_config_defaults() {
8745        let config = InlineAutoRemeasureConfig::default();
8746        assert_eq!(config.change_threshold_rows, 1);
8747        assert_eq!(config.voi.prior_alpha, 1.0);
8748        assert_eq!(config.voi.prior_beta, 9.0);
8749        assert_eq!(config.voi.max_interval_ms, 1000);
8750        assert_eq!(config.voi.min_interval_ms, 100);
8751        assert_eq!(config.voi.sample_cost, 0.08);
8752    }
8753
8754    // =========================================================================
8755    // HeadlessEventSource direct tests (bd-2yjus)
8756    // =========================================================================
8757
8758    #[test]
8759    fn headless_event_source_size() {
8760        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
8761        assert_eq!(source.size().unwrap(), (120, 40));
8762    }
8763
8764    #[test]
8765    fn headless_event_source_poll_always_false() {
8766        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8767        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
8768    }
8769
8770    #[test]
8771    fn headless_event_source_read_always_none() {
8772        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8773        assert!(source.read_event().unwrap().is_none());
8774    }
8775
8776    #[test]
8777    fn headless_event_source_set_features() {
8778        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8779        let features = BackendFeatures {
8780            mouse_capture: true,
8781            bracketed_paste: true,
8782            focus_events: true,
8783            kitty_keyboard: true,
8784        };
8785        source.set_features(features).unwrap();
8786        assert_eq!(source.features, features);
8787    }
8788
8789    // =========================================================================
8790    // Program helper methods (bd-2yjus)
8791    // =========================================================================
8792
8793    #[test]
8794    fn headless_program_quit_and_is_running() {
8795        let mut program =
8796            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8797        assert!(program.is_running());
8798
8799        program.quit();
8800        assert!(!program.is_running());
8801    }
8802
8803    #[test]
8804    fn headless_program_model_mut() {
8805        let mut program =
8806            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8807        assert_eq!(program.model().value, 0);
8808
8809        program.model_mut().value = 42;
8810        assert_eq!(program.model().value, 42);
8811    }
8812
8813    #[test]
8814    fn headless_program_request_redraw() {
8815        let mut program =
8816            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8817        program.dirty = false;
8818
8819        program.request_redraw();
8820        assert!(program.dirty);
8821    }
8822
8823    #[test]
8824    fn headless_program_last_widget_signals_initially_empty() {
8825        let program =
8826            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8827        assert!(program.last_widget_signals().is_empty());
8828    }
8829
8830    #[test]
8831    fn headless_program_no_persistence_by_default() {
8832        let program =
8833            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8834        assert!(!program.has_persistence());
8835        assert!(program.state_registry().is_none());
8836    }
8837
8838    // =========================================================================
8839    // classify_event_for_fairness (bd-2yjus)
8840    // =========================================================================
8841
8842    #[test]
8843    fn classify_event_fairness_key_is_input() {
8844        let event = Event::Key(ftui_core::event::KeyEvent::new(
8845            ftui_core::event::KeyCode::Char('a'),
8846        ));
8847        let classification =
8848            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8849        assert_eq!(classification, FairnessEventType::Input);
8850    }
8851
8852    #[test]
8853    fn classify_event_fairness_resize_is_resize() {
8854        let event = Event::Resize {
8855            width: 80,
8856            height: 24,
8857        };
8858        let classification =
8859            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8860        assert_eq!(classification, FairnessEventType::Resize);
8861    }
8862
8863    #[test]
8864    fn classify_event_fairness_tick_is_tick() {
8865        let event = Event::Tick;
8866        let classification =
8867            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8868        assert_eq!(classification, FairnessEventType::Tick);
8869    }
8870
8871    #[test]
8872    fn classify_event_fairness_paste_is_input() {
8873        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
8874        let classification =
8875            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8876        assert_eq!(classification, FairnessEventType::Input);
8877    }
8878
8879    #[test]
8880    fn classify_event_fairness_focus_is_input() {
8881        let event = Event::Focus(true);
8882        let classification =
8883            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8884        assert_eq!(classification, FairnessEventType::Input);
8885    }
8886
8887    // =========================================================================
8888    // ProgramConfig builder methods (bd-2yjus)
8889    // =========================================================================
8890
8891    #[test]
8892    fn program_config_with_diff_config() {
8893        let diff = RuntimeDiffConfig::default();
8894        let config = ProgramConfig::default().with_diff_config(diff.clone());
8895        // Just verify it doesn't panic and the field is set
8896        let _ = format!("{:?}", config);
8897    }
8898
8899    #[test]
8900    fn program_config_with_evidence_sink() {
8901        let config =
8902            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
8903        let _ = format!("{:?}", config);
8904    }
8905
8906    #[test]
8907    fn program_config_with_render_trace() {
8908        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
8909        let _ = format!("{:?}", config);
8910    }
8911
8912    #[test]
8913    fn program_config_with_locale() {
8914        let config = ProgramConfig::default().with_locale("fr");
8915        let _ = format!("{:?}", config);
8916    }
8917
8918    #[test]
8919    fn program_config_with_locale_context() {
8920        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
8921        let _ = format!("{:?}", config);
8922    }
8923
8924    #[test]
8925    fn program_config_without_forced_size() {
8926        let config = ProgramConfig::default()
8927            .with_forced_size(80, 24)
8928            .without_forced_size();
8929        assert!(config.forced_size.is_none());
8930    }
8931
8932    #[test]
8933    fn program_config_forced_size_clamps_min() {
8934        let config = ProgramConfig::default().with_forced_size(0, 0);
8935        assert_eq!(config.forced_size, Some((1, 1)));
8936    }
8937
8938    #[test]
8939    fn program_config_with_widget_refresh() {
8940        let wrc = WidgetRefreshConfig {
8941            enabled: false,
8942            ..Default::default()
8943        };
8944        let config = ProgramConfig::default().with_widget_refresh(wrc);
8945        assert!(!config.widget_refresh.enabled);
8946    }
8947
8948    #[test]
8949    fn program_config_with_effect_queue() {
8950        let eqc = EffectQueueConfig::default().with_enabled(true);
8951        let config = ProgramConfig::default().with_effect_queue(eqc);
8952        assert!(config.effect_queue.enabled);
8953    }
8954
8955    #[test]
8956    fn program_config_with_resize_coalescer_custom() {
8957        let cc = CoalescerConfig {
8958            steady_delay_ms: 42,
8959            ..Default::default()
8960        };
8961        let config = ProgramConfig::default().with_resize_coalescer(cc);
8962        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
8963    }
8964
8965    #[test]
8966    fn program_config_with_inline_auto_remeasure() {
8967        let config = ProgramConfig::default()
8968            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
8969        assert!(config.inline_auto_remeasure.is_some());
8970
8971        let config = config.without_inline_auto_remeasure();
8972        assert!(config.inline_auto_remeasure.is_none());
8973    }
8974
8975    #[test]
8976    fn program_config_with_persistence_full() {
8977        let pc = PersistenceConfig::disabled();
8978        let config = ProgramConfig::default().with_persistence(pc);
8979        assert!(config.persistence.registry.is_none());
8980    }
8981
8982    #[test]
8983    fn program_config_with_conformal_config() {
8984        let config = ProgramConfig::default()
8985            .with_conformal_config(ConformalConfig::default())
8986            .without_conformal();
8987        assert!(config.conformal_config.is_none());
8988    }
8989
8990    // =========================================================================
8991    // PersistenceConfig Debug (bd-2yjus)
8992    // =========================================================================
8993
8994    #[test]
8995    fn persistence_config_debug() {
8996        let config = PersistenceConfig::default();
8997        let debug = format!("{config:?}");
8998        assert!(debug.contains("PersistenceConfig"));
8999        assert!(debug.contains("auto_load"));
9000        assert!(debug.contains("auto_save"));
9001    }
9002
9003    // =========================================================================
9004    // FrameTimingConfig (bd-2yjus)
9005    // =========================================================================
9006
9007    #[test]
9008    fn frame_timing_config_debug() {
9009        use std::sync::Arc;
9010
9011        struct DummySink;
9012        impl FrameTimingSink for DummySink {
9013            fn record_frame(&self, _timing: &FrameTiming) {}
9014        }
9015
9016        let config = FrameTimingConfig::new(Arc::new(DummySink));
9017        let debug = format!("{config:?}");
9018        assert!(debug.contains("FrameTimingConfig"));
9019    }
9020
9021    #[test]
9022    fn program_config_with_frame_timing() {
9023        use std::sync::Arc;
9024
9025        struct DummySink;
9026        impl FrameTimingSink for DummySink {
9027            fn record_frame(&self, _timing: &FrameTiming) {}
9028        }
9029
9030        let config =
9031            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
9032        assert!(config.frame_timing.is_some());
9033    }
9034
9035    // =========================================================================
9036    // BudgetDecisionEvidence helper functions (bd-2yjus)
9037    // =========================================================================
9038
9039    #[test]
9040    fn budget_decision_evidence_decision_from_levels() {
9041        use ftui_render::budget::DegradationLevel;
9042        // Degrade: after > before
9043        assert_eq!(
9044            BudgetDecisionEvidence::decision_from_levels(
9045                DegradationLevel::Full,
9046                DegradationLevel::SimpleBorders
9047            ),
9048            BudgetDecision::Degrade
9049        );
9050        // Upgrade: after < before
9051        assert_eq!(
9052            BudgetDecisionEvidence::decision_from_levels(
9053                DegradationLevel::SimpleBorders,
9054                DegradationLevel::Full
9055            ),
9056            BudgetDecision::Upgrade
9057        );
9058        // Hold: same
9059        assert_eq!(
9060            BudgetDecisionEvidence::decision_from_levels(
9061                DegradationLevel::Full,
9062                DegradationLevel::Full
9063            ),
9064            BudgetDecision::Hold
9065        );
9066    }
9067
9068    // =========================================================================
9069    // WidgetRefreshPlan (bd-2yjus)
9070    // =========================================================================
9071
9072    #[test]
9073    fn widget_refresh_plan_clear() {
9074        let mut plan = WidgetRefreshPlan::new();
9075        plan.frame_idx = 5;
9076        plan.budget_us = 100.0;
9077        plan.signal_count = 3;
9078        plan.over_budget = true;
9079        plan.clear();
9080        assert_eq!(plan.frame_idx, 0);
9081        assert_eq!(plan.budget_us, 0.0);
9082        assert_eq!(plan.signal_count, 0);
9083        assert!(!plan.over_budget);
9084    }
9085
9086    #[test]
9087    fn widget_refresh_plan_as_budget_empty_signals() {
9088        let plan = WidgetRefreshPlan::new();
9089        let budget = plan.as_budget();
9090        // With signal_count == 0, should be allow_all (allows any widget)
9091        assert!(budget.allows(0, false));
9092        assert!(budget.allows(999, false));
9093    }
9094
9095    #[test]
9096    fn widget_refresh_plan_to_jsonl_structure() {
9097        let plan = WidgetRefreshPlan::new();
9098        let jsonl = plan.to_jsonl();
9099        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
9100        assert!(jsonl.contains("\"frame_idx\":0"));
9101        assert!(jsonl.contains("\"selected\":[]"));
9102    }
9103
9104    // =========================================================================
9105    // BatchController Default trait (bd-2yjus)
9106    // =========================================================================
9107
9108    #[test]
9109    fn batch_controller_default_trait() {
9110        let bc = BatchController::default();
9111        let bc2 = BatchController::new();
9112        // Should be equivalent
9113        assert_eq!(bc.tau_s(), bc2.tau_s());
9114        assert_eq!(bc.observations(), bc2.observations());
9115    }
9116
9117    #[test]
9118    fn batch_controller_observe_arrival_stale_gap_ignored() {
9119        let mut bc = BatchController::new();
9120        let base = Instant::now();
9121        // First arrival
9122        bc.observe_arrival(base);
9123        // Stale gap > 10s should be ignored
9124        bc.observe_arrival(base + Duration::from_secs(15));
9125        assert_eq!(bc.observations(), 0);
9126    }
9127
9128    #[test]
9129    fn batch_controller_observe_service_out_of_range() {
9130        let mut bc = BatchController::new();
9131        let original_service = bc.service_est_s();
9132        // Out-of-range (>= 10s) should be ignored
9133        bc.observe_service(Duration::from_secs(15));
9134        assert_eq!(bc.service_est_s(), original_service);
9135    }
9136
9137    #[test]
9138    fn batch_controller_lambda_zero_inter_arrival() {
9139        // When ema_inter_arrival_s is effectively 0, lambda should be 0
9140        let bc = BatchController {
9141            ema_inter_arrival_s: 0.0,
9142            ..BatchController::new()
9143        };
9144        assert_eq!(bc.lambda_est(), 0.0);
9145    }
9146
9147    // =========================================================================
9148    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
9149    // =========================================================================
9150
9151    #[test]
9152    fn headless_execute_cmd_log_appends_newline_if_missing() {
9153        let mut program =
9154            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9155        program.execute_cmd(Cmd::log("no newline")).expect("log");
9156
9157        let bytes = program.writer.into_inner().expect("writer output");
9158        let output = String::from_utf8_lossy(&bytes);
9159        // The sanitized output should end with a newline
9160        assert!(output.contains("no newline"));
9161    }
9162
9163    #[test]
9164    fn headless_execute_cmd_log_preserves_trailing_newline() {
9165        let mut program =
9166            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9167        program
9168            .execute_cmd(Cmd::log("with newline\n"))
9169            .expect("log");
9170
9171        let bytes = program.writer.into_inner().expect("writer output");
9172        let output = String::from_utf8_lossy(&bytes);
9173        assert!(output.contains("with newline"));
9174    }
9175
9176    // =========================================================================
9177    // Headless program: immediate resize behavior (bd-2yjus)
9178    // =========================================================================
9179
9180    #[test]
9181    fn headless_handle_event_immediate_resize() {
9182        struct ResizeModel {
9183            last_size: Option<(u16, u16)>,
9184        }
9185
9186        #[derive(Debug)]
9187        enum ResizeMsg {
9188            Resize(u16, u16),
9189            Other,
9190        }
9191
9192        impl From<Event> for ResizeMsg {
9193            fn from(event: Event) -> Self {
9194                match event {
9195                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
9196                    _ => ResizeMsg::Other,
9197                }
9198            }
9199        }
9200
9201        impl Model for ResizeModel {
9202            type Message = ResizeMsg;
9203
9204            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9205                if let ResizeMsg::Resize(w, h) = msg {
9206                    self.last_size = Some((w, h));
9207                }
9208                Cmd::none()
9209            }
9210
9211            fn view(&self, _frame: &mut Frame) {}
9212        }
9213
9214        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
9215        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
9216
9217        program
9218            .handle_event(Event::Resize {
9219                width: 120,
9220                height: 40,
9221            })
9222            .expect("handle resize");
9223
9224        assert_eq!(program.width, 120);
9225        assert_eq!(program.height, 40);
9226        assert_eq!(program.model().last_size, Some((120, 40)));
9227    }
9228
9229    // =========================================================================
9230    // Headless program: resize clamps zero dimensions (bd-2yjus)
9231    // =========================================================================
9232
9233    #[test]
9234    fn headless_apply_resize_clamps_zero_to_one() {
9235        struct SimpleModel;
9236
9237        #[derive(Debug)]
9238        enum SimpleMsg {
9239            Noop,
9240        }
9241
9242        impl From<Event> for SimpleMsg {
9243            fn from(_: Event) -> Self {
9244                SimpleMsg::Noop
9245            }
9246        }
9247
9248        impl Model for SimpleModel {
9249            type Message = SimpleMsg;
9250
9251            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9252                Cmd::none()
9253            }
9254
9255            fn view(&self, _frame: &mut Frame) {}
9256        }
9257
9258        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
9259        program
9260            .apply_resize(0, 0, Duration::ZERO, false)
9261            .expect("resize");
9262
9263        // Zero dimensions should be clamped to 1
9264        assert_eq!(program.width, 1);
9265        assert_eq!(program.height, 1);
9266    }
9267
9268    // =========================================================================
9269    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
9270    // =========================================================================
9271
9272    #[test]
9273    fn force_cancel_all_idle_returns_none() {
9274        let mut adapter =
9275            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9276        assert!(adapter.force_cancel_all().is_none());
9277    }
9278
9279    #[test]
9280    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
9281        let mut adapter =
9282            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9283        let target = pane_target(SplitAxis::Horizontal);
9284
9285        let down = Event::Mouse(MouseEvent::new(
9286            MouseEventKind::Down(MouseButton::Left),
9287            5,
9288            5,
9289        ));
9290        let _ = adapter.translate(&down, Some(target));
9291        assert!(adapter.active_pointer_id().is_some());
9292
9293        let diag = adapter
9294            .force_cancel_all()
9295            .expect("should produce diagnostics");
9296        assert!(diag.had_active_pointer);
9297        assert_eq!(diag.active_pointer_id, Some(1));
9298        assert!(diag.machine_transition.is_some());
9299
9300        // Adapter should be fully idle afterwards
9301        assert_eq!(adapter.active_pointer_id(), None);
9302        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
9303    }
9304
9305    #[test]
9306    fn force_cancel_all_during_drag_returns_diagnostics() {
9307        let mut adapter =
9308            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9309        let target = pane_target(SplitAxis::Vertical);
9310
9311        // Down → arm
9312        let down = Event::Mouse(MouseEvent::new(
9313            MouseEventKind::Down(MouseButton::Left),
9314            3,
9315            3,
9316        ));
9317        let _ = adapter.translate(&down, Some(target));
9318
9319        // Drag → transition to Dragging
9320        let drag = Event::Mouse(MouseEvent::new(
9321            MouseEventKind::Drag(MouseButton::Left),
9322            8,
9323            3,
9324        ));
9325        let _ = adapter.translate(&drag, None);
9326
9327        let diag = adapter
9328            .force_cancel_all()
9329            .expect("should produce diagnostics");
9330        assert!(diag.had_active_pointer);
9331        assert!(diag.machine_transition.is_some());
9332        let transition = diag.machine_transition.unwrap();
9333        assert!(matches!(
9334            transition.effect,
9335            PaneDragResizeEffect::Canceled {
9336                reason: PaneCancelReason::Programmatic,
9337                ..
9338            }
9339        ));
9340    }
9341
9342    #[test]
9343    fn force_cancel_all_is_idempotent() {
9344        let mut adapter =
9345            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9346        let target = pane_target(SplitAxis::Horizontal);
9347
9348        let down = Event::Mouse(MouseEvent::new(
9349            MouseEventKind::Down(MouseButton::Left),
9350            5,
9351            5,
9352        ));
9353        let _ = adapter.translate(&down, Some(target));
9354
9355        let first = adapter.force_cancel_all();
9356        assert!(first.is_some());
9357
9358        let second = adapter.force_cancel_all();
9359        assert!(second.is_none());
9360    }
9361
9362    // =========================================================================
9363    // PaneInteractionGuard (bd-24v9m)
9364    // =========================================================================
9365
9366    #[test]
9367    fn pane_interaction_guard_finish_when_idle() {
9368        let mut adapter =
9369            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9370        let guard = PaneInteractionGuard::new(&mut adapter);
9371        let diag = guard.finish();
9372        assert!(diag.is_none());
9373    }
9374
9375    #[test]
9376    fn pane_interaction_guard_finish_returns_diagnostics() {
9377        let mut adapter =
9378            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9379        let target = pane_target(SplitAxis::Horizontal);
9380
9381        // Start a drag interaction through the adapter directly
9382        let down = Event::Mouse(MouseEvent::new(
9383            MouseEventKind::Down(MouseButton::Left),
9384            5,
9385            5,
9386        ));
9387        let _ = adapter.translate(&down, Some(target));
9388
9389        let guard = PaneInteractionGuard::new(&mut adapter);
9390        let diag = guard.finish().expect("should produce diagnostics");
9391        assert!(diag.had_active_pointer);
9392        assert_eq!(diag.active_pointer_id, Some(1));
9393    }
9394
9395    #[test]
9396    fn pane_interaction_guard_drop_cancels_active_interaction() {
9397        let mut adapter =
9398            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9399        let target = pane_target(SplitAxis::Vertical);
9400
9401        let down = Event::Mouse(MouseEvent::new(
9402            MouseEventKind::Down(MouseButton::Left),
9403            7,
9404            7,
9405        ));
9406        let _ = adapter.translate(&down, Some(target));
9407        assert!(adapter.active_pointer_id().is_some());
9408
9409        {
9410            let _guard = PaneInteractionGuard::new(&mut adapter);
9411            // guard drops here without finish()
9412        }
9413
9414        // After guard drop, adapter should be idle
9415        assert_eq!(adapter.active_pointer_id(), None);
9416        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
9417    }
9418
9419    #[test]
9420    fn pane_interaction_guard_adapter_access_works() {
9421        let mut adapter =
9422            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9423        let target = pane_target(SplitAxis::Horizontal);
9424
9425        let mut guard = PaneInteractionGuard::new(&mut adapter);
9426
9427        // Use the adapter through the guard
9428        let down = Event::Mouse(MouseEvent::new(
9429            MouseEventKind::Down(MouseButton::Left),
9430            5,
9431            5,
9432        ));
9433        let dispatch = guard.adapter().translate(&down, Some(target));
9434        assert!(dispatch.primary_event.is_some());
9435
9436        // finish should clean up the interaction started through the guard
9437        let diag = guard.finish().expect("should produce diagnostics");
9438        assert!(diag.had_active_pointer);
9439    }
9440
9441    #[test]
9442    fn pane_interaction_guard_finish_then_drop_is_safe() {
9443        let mut adapter =
9444            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9445        let target = pane_target(SplitAxis::Horizontal);
9446
9447        let down = Event::Mouse(MouseEvent::new(
9448            MouseEventKind::Down(MouseButton::Left),
9449            5,
9450            5,
9451        ));
9452        let _ = adapter.translate(&down, Some(target));
9453
9454        let guard = PaneInteractionGuard::new(&mut adapter);
9455        let _diag = guard.finish();
9456        // guard is consumed by finish(), so drop doesn't double-cancel
9457        // This test proves the API is safe: finish() takes `self` not `&mut self`
9458        assert_eq!(adapter.active_pointer_id(), None);
9459    }
9460
9461    // =========================================================================
9462    // PaneCapabilityMatrix (bd-6u66i)
9463    // =========================================================================
9464
9465    fn caps_modern() -> TerminalCapabilities {
9466        TerminalCapabilities::modern()
9467    }
9468
9469    fn caps_with_mux(
9470        mux: PaneMuxEnvironment,
9471    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
9472        let mut caps = TerminalCapabilities::modern();
9473        match mux {
9474            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
9475            PaneMuxEnvironment::Screen => caps.in_screen = true,
9476            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
9477            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
9478            PaneMuxEnvironment::None => {}
9479        }
9480        caps
9481    }
9482
9483    #[test]
9484    fn capability_matrix_bare_terminal_modern() {
9485        let caps = caps_modern();
9486        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9487
9488        assert_eq!(mat.mux, PaneMuxEnvironment::None);
9489        assert!(mat.mouse_sgr);
9490        assert!(mat.mouse_drag_reliable);
9491        assert!(mat.mouse_button_discrimination);
9492        assert!(mat.focus_events);
9493        assert!(mat.unicode_box_drawing);
9494        assert!(mat.true_color);
9495        assert!(!mat.degraded);
9496        assert!(mat.drag_enabled());
9497        assert!(mat.focus_cancel_effective());
9498        assert!(mat.limitations().is_empty());
9499    }
9500
9501    #[test]
9502    fn capability_matrix_tmux() {
9503        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
9504        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9505
9506        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
9507        // Focus cancel path is conservatively disabled in all muxes.
9508        assert!(mat.mouse_drag_reliable);
9509        assert!(!mat.focus_events);
9510        assert!(mat.drag_enabled());
9511        assert!(!mat.focus_cancel_effective());
9512        assert!(mat.degraded);
9513    }
9514
9515    #[test]
9516    fn capability_matrix_screen_degrades_drag() {
9517        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
9518        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9519
9520        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
9521        assert!(!mat.mouse_drag_reliable);
9522        assert!(!mat.focus_events);
9523        assert!(!mat.drag_enabled());
9524        assert!(!mat.focus_cancel_effective());
9525        assert!(mat.degraded);
9526
9527        let lims = mat.limitations();
9528        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
9529        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
9530    }
9531
9532    #[test]
9533    fn capability_matrix_zellij() {
9534        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
9535        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9536
9537        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
9538        assert!(mat.mouse_drag_reliable);
9539        assert!(!mat.focus_events);
9540        assert!(mat.drag_enabled());
9541        assert!(!mat.focus_cancel_effective());
9542        assert!(mat.degraded);
9543    }
9544
9545    #[test]
9546    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
9547        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
9548        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9549
9550        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
9551        assert!(mat.mouse_drag_reliable);
9552        assert!(!mat.focus_events);
9553        assert!(mat.drag_enabled());
9554        assert!(!mat.focus_cancel_effective());
9555        assert!(mat.degraded);
9556    }
9557
9558    #[test]
9559    fn capability_matrix_no_sgr_mouse() {
9560        let mut caps = caps_modern();
9561        caps.mouse_sgr = false;
9562        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9563
9564        assert!(!mat.mouse_sgr);
9565        assert!(!mat.mouse_button_discrimination);
9566        assert!(mat.degraded);
9567
9568        let lims = mat.limitations();
9569        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
9570        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
9571    }
9572
9573    #[test]
9574    fn capability_matrix_no_focus_events() {
9575        let mut caps = caps_modern();
9576        caps.focus_events = false;
9577        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9578
9579        assert!(!mat.focus_events);
9580        assert!(!mat.focus_cancel_effective());
9581        assert!(mat.degraded);
9582
9583        let lims = mat.limitations();
9584        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
9585    }
9586
9587    #[test]
9588    fn capability_matrix_dumb_terminal() {
9589        let caps = TerminalCapabilities::dumb();
9590        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9591
9592        assert_eq!(mat.mux, PaneMuxEnvironment::None);
9593        assert!(!mat.mouse_sgr);
9594        assert!(!mat.focus_events);
9595        assert!(!mat.unicode_box_drawing);
9596        assert!(!mat.true_color);
9597        assert!(mat.degraded);
9598        assert!(mat.limitations().len() >= 3);
9599    }
9600
9601    #[test]
9602    fn capability_matrix_limitations_have_fallbacks() {
9603        let caps = TerminalCapabilities::dumb();
9604        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9605
9606        for lim in mat.limitations() {
9607            assert!(!lim.id.is_empty());
9608            assert!(!lim.description.is_empty());
9609            assert!(!lim.fallback.is_empty());
9610        }
9611    }
9612}