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