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};
71#[cfg(feature = "asupersync-executor")]
72use asupersync::runtime::{BlockingTaskHandle, Runtime as AsupersyncRuntime, RuntimeBuilder};
73use ftui_backend::{BackendEventSource, BackendFeatures};
74use ftui_core::event::{
75    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
76};
77#[cfg(feature = "crossterm-compat")]
78use ftui_core::terminal_capabilities::TerminalCapabilities;
79#[cfg(feature = "crossterm-compat")]
80use ftui_core::terminal_session::{SessionOptions, TerminalSession};
81use ftui_layout::{
82    PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
83    PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
84    PaneDragResizeTransition, PaneInertialThrow, PaneLayout, PaneModifierSnapshot,
85    PaneMotionVector, PaneNodeKind, PanePointerButton, PanePointerPosition,
86    PanePressureSnapProfile, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
87    PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
88};
89use ftui_render::arena::FrameArena;
90use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
91use ftui_render::buffer::Buffer;
92use ftui_render::diff_strategy::DiffStrategy;
93use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
94use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
95use ftui_render::sanitize::sanitize;
96use std::any::Any;
97use std::collections::HashMap;
98use std::io::{self, Stdout, Write};
99use std::panic::{self, AssertUnwindSafe};
100use std::sync::Arc;
101
102/// Check for pending termination signal. Returns `None` when crossterm is not
103/// enabled (headless / wasm builds don't install signal handlers).
104#[inline]
105fn check_termination_signal() -> Option<i32> {
106    ftui_core::shutdown_signal::pending_termination_signal()
107}
108
109/// Clear the pending termination signal.
110#[inline]
111fn clear_termination_signal() {
112    ftui_core::shutdown_signal::clear_pending_termination_signal();
113}
114use std::sync::mpsc;
115use std::thread::{self, JoinHandle};
116use tracing::{debug, debug_span, info, info_span, trace};
117use web_time::{Duration, Instant};
118
119/// The Model trait defines application state and behavior.
120///
121/// Implementations define how the application responds to events
122/// and renders its current state.
123pub trait Model: Sized {
124    /// The message type for this model.
125    ///
126    /// Messages represent actions that update the model state.
127    /// Must be convertible from terminal events.
128    type Message: From<Event> + Send + 'static;
129
130    /// Initialize the model with startup commands.
131    ///
132    /// Called once when the program starts. Return commands to execute
133    /// initial side effects like loading data.
134    fn init(&mut self) -> Cmd<Self::Message> {
135        Cmd::none()
136    }
137
138    /// Update the model in response to a message.
139    ///
140    /// This is the core state transition function. Returns commands
141    /// for any side effects that should be executed.
142    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
143
144    /// Render the current state to a frame.
145    ///
146    /// Called after updates when the UI needs to be redrawn.
147    fn view(&self, frame: &mut Frame);
148
149    /// Declare active subscriptions.
150    ///
151    /// Called after each `update()`. The runtime compares the returned set
152    /// (by `SubId`) against currently running subscriptions and starts/stops
153    /// as needed. Returning an empty vec stops all subscriptions.
154    ///
155    /// # Default
156    ///
157    /// Returns an empty vec (no subscriptions).
158    fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
159        vec![]
160    }
161
162    /// Downcast to [`ScreenTickDispatch`](crate::tick_strategy::ScreenTickDispatch)
163    /// for per-screen tick control.
164    ///
165    /// Override this to return `Some(self)` in multi-screen Models. The runtime
166    /// will then consult the active [`TickStrategy`](crate::tick_strategy::TickStrategy)
167    /// for each inactive screen instead of ticking monolithically.
168    ///
169    /// Default: `None` (all screens tick every frame, backwards-compatible).
170    fn as_screen_tick_dispatch(
171        &mut self,
172    ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
173        None
174    }
175
176    /// Called before the runtime exits, whether via [`Cmd::Quit`] or signal.
177    ///
178    /// Return cleanup commands (e.g., saving state, closing connections).
179    /// The runtime executes these before teardown.
180    ///
181    /// # Migration rationale
182    ///
183    /// Source frameworks use `componentWillUnmount`, `useEffect` cleanup, or
184    /// `beforeDestroy` hooks. This provides an equivalent lifecycle point.
185    fn on_shutdown(&mut self) -> Cmd<Self::Message> {
186        Cmd::none()
187    }
188
189    /// Called when an unrecoverable error occurs during the runtime loop.
190    ///
191    /// Return commands for error recovery or graceful degradation. The
192    /// `error` string contains the error description.
193    ///
194    /// # Migration rationale
195    ///
196    /// Source frameworks use `componentDidCatch`, error boundaries, or
197    /// `onError` hooks. This provides an equivalent error recovery point.
198    fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
199        Cmd::none()
200    }
201}
202
203/// Default weight assigned to background tasks.
204const DEFAULT_TASK_WEIGHT: f64 = 1.0;
205
206/// Default estimated task cost (ms) used for scheduling.
207const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
208
209/// Scheduling metadata for background tasks.
210#[derive(Debug, Clone)]
211pub struct TaskSpec {
212    /// Task weight (importance). Higher = more priority.
213    pub weight: f64,
214    /// Estimated task cost in milliseconds.
215    pub estimate_ms: f64,
216    /// Optional task name for evidence logging.
217    pub name: Option<String>,
218}
219
220impl Default for TaskSpec {
221    fn default() -> Self {
222        Self {
223            weight: DEFAULT_TASK_WEIGHT,
224            estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
225            name: None,
226        }
227    }
228}
229
230impl TaskSpec {
231    /// Create a task spec with an explicit weight and estimate.
232    #[must_use]
233    pub fn new(weight: f64, estimate_ms: f64) -> Self {
234        Self {
235            weight,
236            estimate_ms,
237            name: None,
238        }
239    }
240
241    /// Attach a task name for diagnostics.
242    #[must_use]
243    pub fn with_name(mut self, name: impl Into<String>) -> Self {
244        self.name = Some(name.into());
245        self
246    }
247}
248
249/// Per-frame timing data for profiling.
250#[derive(Debug, Clone, Copy)]
251pub struct FrameTiming {
252    pub frame_idx: u64,
253    pub update_us: u64,
254    pub render_us: u64,
255    pub diff_us: u64,
256    pub present_us: u64,
257    pub total_us: u64,
258}
259
260#[derive(Debug)]
261struct SignalTerminationError {
262    signal: i32,
263}
264
265impl std::fmt::Display for SignalTerminationError {
266    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267        write!(f, "terminated by signal {}", self.signal)
268    }
269}
270
271impl std::error::Error for SignalTerminationError {}
272
273fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
274    err.get_ref()
275        .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
276        .map(|inner| inner.signal)
277}
278
279/// Sink for frame timing events.
280pub trait FrameTimingSink: Send + Sync {
281    fn record_frame(&self, timing: &FrameTiming);
282}
283
284/// Configuration for frame timing capture.
285#[derive(Clone)]
286pub struct FrameTimingConfig {
287    pub sink: Arc<dyn FrameTimingSink>,
288}
289
290impl FrameTimingConfig {
291    #[must_use]
292    pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
293        Self { sink }
294    }
295}
296
297impl std::fmt::Debug for FrameTimingConfig {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.debug_struct("FrameTimingConfig")
300            .field("sink", &"<dyn FrameTimingSink>")
301            .finish()
302    }
303}
304
305/// Commands represent side effects to be executed by the runtime.
306///
307/// Commands are returned from `init()` and `update()` to trigger
308/// actions like quitting, sending messages, or scheduling ticks.
309#[derive(Default)]
310pub enum Cmd<M> {
311    /// No operation.
312    #[default]
313    None,
314    /// Quit the application.
315    Quit,
316    /// Execute multiple commands as a batch (currently sequential).
317    Batch(Vec<Cmd<M>>),
318    /// Execute commands sequentially.
319    Sequence(Vec<Cmd<M>>),
320    /// Send a message to the model.
321    Msg(M),
322    /// Schedule a tick after a duration.
323    Tick(Duration),
324    /// Write a log message to the terminal output.
325    ///
326    /// This writes to the scrollback region in inline mode, or is ignored/handled
327    /// appropriately in alternate screen mode. Safe to use with the One-Writer Rule.
328    Log(String),
329    /// Execute a blocking operation on a background thread.
330    ///
331    /// When effect queue scheduling is enabled, tasks are enqueued and executed
332    /// in Smith-rule order on a dedicated worker thread. Otherwise the closure
333    /// runs on a spawned thread immediately. The return value is sent back
334    /// as a message to the model.
335    Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
336    /// Save widget state to the persistence registry.
337    ///
338    /// Triggers a flush of the state registry to the storage backend.
339    /// No-op if persistence is not configured.
340    SaveState,
341    /// Restore widget state from the persistence registry.
342    ///
343    /// Triggers a load from the storage backend and updates the cache.
344    /// No-op if persistence is not configured. Returns a message via
345    /// callback if state was successfully restored.
346    RestoreState,
347    /// Toggle mouse capture at runtime.
348    ///
349    /// Instructs the terminal session to enable or disable mouse event capture.
350    /// No-op in test simulators.
351    SetMouseCapture(bool),
352    /// Replace the tick strategy at runtime.
353    ///
354    /// Takes ownership of a boxed strategy. Use when switching from one
355    /// strategy to another (e.g., `Uniform` → `Predictive` after loading
356    /// persisted transition data).
357    SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
358}
359
360impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            Self::None => write!(f, "None"),
364            Self::Quit => write!(f, "Quit"),
365            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
366            Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
367            Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
368            Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
369            Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
370            Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
371            Self::SaveState => write!(f, "SaveState"),
372            Self::RestoreState => write!(f, "RestoreState"),
373            Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
374            Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
375        }
376    }
377}
378
379impl<M> Cmd<M> {
380    /// Create a no-op command.
381    #[inline]
382    pub fn none() -> Self {
383        Self::None
384    }
385
386    /// Create a quit command.
387    #[inline]
388    pub fn quit() -> Self {
389        Self::Quit
390    }
391
392    /// Create a message command.
393    #[inline]
394    pub fn msg(m: M) -> Self {
395        Self::Msg(m)
396    }
397
398    /// Create a log command.
399    ///
400    /// The message will be sanitized and written to the terminal log (scrollback).
401    /// A newline is appended if not present.
402    #[inline]
403    pub fn log(msg: impl Into<String>) -> Self {
404        Self::Log(msg.into())
405    }
406
407    /// Create a batch of commands.
408    pub fn batch(cmds: Vec<Self>) -> Self {
409        if cmds.is_empty() {
410            Self::None
411        } else if cmds.len() == 1 {
412            cmds.into_iter().next().unwrap_or(Self::None)
413        } else {
414            Self::Batch(cmds)
415        }
416    }
417
418    /// Create a sequence of commands.
419    pub fn sequence(cmds: Vec<Self>) -> Self {
420        if cmds.is_empty() {
421            Self::None
422        } else if cmds.len() == 1 {
423            cmds.into_iter().next().unwrap_or(Self::None)
424        } else {
425            Self::Sequence(cmds)
426        }
427    }
428
429    /// Return a stable name for telemetry and tracing.
430    #[inline]
431    pub fn type_name(&self) -> &'static str {
432        match self {
433            Self::None => "None",
434            Self::Quit => "Quit",
435            Self::Batch(_) => "Batch",
436            Self::Sequence(_) => "Sequence",
437            Self::Msg(_) => "Msg",
438            Self::Tick(_) => "Tick",
439            Self::Log(_) => "Log",
440            Self::Task(..) => "Task",
441            Self::SaveState => "SaveState",
442            Self::RestoreState => "RestoreState",
443            Self::SetMouseCapture(_) => "SetMouseCapture",
444            Self::SetTickStrategy(_) => "SetTickStrategy",
445        }
446    }
447
448    /// Create a tick command.
449    #[inline]
450    pub fn tick(duration: Duration) -> Self {
451        Self::Tick(duration)
452    }
453
454    /// Create a background task command.
455    ///
456    /// The closure runs on a spawned thread (or the effect queue worker when
457    /// scheduling is enabled). When it completes, the returned message is
458    /// sent back to the model's `update()`.
459    pub fn task<F>(f: F) -> Self
460    where
461        F: FnOnce() -> M + Send + 'static,
462    {
463        Self::Task(TaskSpec::default(), Box::new(f))
464    }
465
466    /// Create a background task command with explicit scheduling metadata.
467    pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
468    where
469        F: FnOnce() -> M + Send + 'static,
470    {
471        Self::Task(spec, Box::new(f))
472    }
473
474    /// Create a background task command with explicit weight and estimate.
475    pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
476    where
477        F: FnOnce() -> M + Send + 'static,
478    {
479        Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
480    }
481
482    /// Create a named background task command.
483    pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
484    where
485        F: FnOnce() -> M + Send + 'static,
486    {
487        Self::Task(TaskSpec::default().with_name(name), Box::new(f))
488    }
489
490    /// Replace the active tick strategy at runtime.
491    ///
492    /// Use when switching strategies (e.g., `Uniform` → `Predictive` after
493    /// loading persisted transition data).
494    pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
495        Self::SetTickStrategy(Box::new(strategy))
496    }
497
498    /// Create a save state command.
499    ///
500    /// Triggers a flush of the state registry to the storage backend.
501    /// No-op if persistence is not configured.
502    #[inline]
503    pub fn save_state() -> Self {
504        Self::SaveState
505    }
506
507    /// Create a restore state command.
508    ///
509    /// Triggers a load from the storage backend.
510    /// No-op if persistence is not configured.
511    #[inline]
512    pub fn restore_state() -> Self {
513        Self::RestoreState
514    }
515
516    /// Create a mouse capture toggle command.
517    ///
518    /// Instructs the runtime to enable or disable mouse event capture on the
519    /// underlying terminal session.
520    #[inline]
521    pub fn set_mouse_capture(enabled: bool) -> Self {
522        Self::SetMouseCapture(enabled)
523    }
524
525    /// Count the number of atomic commands in this command.
526    ///
527    /// Returns 0 for None, 1 for atomic commands, and recursively counts for Batch/Sequence.
528    pub fn count(&self) -> usize {
529        match self {
530            Self::None => 0,
531            Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
532            _ => 1,
533        }
534    }
535}
536
537/// Resize handling behavior for the runtime.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum ResizeBehavior {
540    /// Apply resize immediately (no debounce, no placeholder).
541    Immediate,
542    /// Coalesce resize events for continuous reflow.
543    Throttled,
544}
545
546impl ResizeBehavior {
547    const fn uses_coalescer(self) -> bool {
548        matches!(self, ResizeBehavior::Throttled)
549    }
550}
551
552/// Policy controlling when terminal mouse capture is enabled.
553///
554/// Mouse capture can steal normal scrollback interaction in inline mode.
555/// `Auto` keeps inline mode scrollback-safe while still enabling mouse in
556/// alt-screen mode.
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
558pub enum MouseCapturePolicy {
559    /// Enable in alt-screen mode, disable in inline modes.
560    #[default]
561    Auto,
562    /// Always enable mouse capture.
563    On,
564    /// Always disable mouse capture.
565    Off,
566}
567
568impl MouseCapturePolicy {
569    /// Resolve the policy to a concrete mouse-capture toggle.
570    #[must_use]
571    pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
572        match self {
573            Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
574            Self::On => true,
575            Self::Off => false,
576        }
577    }
578}
579
580const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
581const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
582
583/// One splitter handle region in terminal cell-space.
584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
585pub struct PaneTerminalSplitterHandle {
586    /// Semantic resize target represented by this handle.
587    pub target: PaneResizeTarget,
588    /// Cell-space hit rectangle for this handle.
589    pub rect: Rect,
590    /// Split boundary coordinate used for deterministic nearest-target ranking.
591    pub boundary: i32,
592}
593
594/// Build deterministic splitter handle regions for terminal hit-testing.
595///
596/// Handles are emitted in split-id order and are clamped to the split rect.
597#[must_use]
598pub fn pane_terminal_splitter_handles(
599    tree: &PaneTree,
600    layout: &PaneLayout,
601    hit_thickness: u16,
602) -> Vec<PaneTerminalSplitterHandle> {
603    let thickness = if hit_thickness == 0 {
604        PANE_TERMINAL_DEFAULT_HIT_THICKNESS
605    } else {
606        hit_thickness
607    };
608    let mut handles = Vec::new();
609    for node in tree.nodes() {
610        let PaneNodeKind::Split(split) = &node.kind else {
611            continue;
612        };
613        let Some(split_rect) = layout.rect(node.id) else {
614            continue;
615        };
616        if split_rect.is_empty() {
617            continue;
618        }
619        let Some(first_rect) = layout.rect(split.first) else {
620            continue;
621        };
622        let Some(second_rect) = layout.rect(split.second) else {
623            continue;
624        };
625
626        let boundary_u16 = match split.axis {
627            SplitAxis::Horizontal => {
628                // Horizontal split => left/right panes => vertical splitter line.
629                if second_rect.x == split_rect.x {
630                    first_rect.right()
631                } else {
632                    second_rect.x
633                }
634            }
635            SplitAxis::Vertical => {
636                // Vertical split => top/bottom panes => horizontal splitter line.
637                if second_rect.y == split_rect.y {
638                    first_rect.bottom()
639                } else {
640                    second_rect.y
641                }
642            }
643        };
644        let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
645            continue;
646        };
647        handles.push(PaneTerminalSplitterHandle {
648            target: PaneResizeTarget {
649                split_id: node.id,
650                axis: split.axis,
651            },
652            rect,
653            boundary: i32::from(boundary_u16),
654        });
655    }
656    handles
657}
658
659/// Resolve a semantic splitter target from a terminal cell position.
660///
661/// If multiple handles overlap, chooses deterministically by:
662/// 1) smallest distance to the splitter boundary, then
663/// 2) smaller split_id, then
664/// 3) horizontal axis before vertical axis.
665#[must_use]
666pub fn pane_terminal_resolve_splitter_target(
667    handles: &[PaneTerminalSplitterHandle],
668    x: u16,
669    y: u16,
670) -> Option<PaneResizeTarget> {
671    let px = i32::from(x);
672    let py = i32::from(y);
673    let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
674
675    for handle in handles {
676        if !rect_contains_cell(handle.rect, x, y) {
677            continue;
678        }
679        let distance = match handle.target.axis {
680            SplitAxis::Horizontal => px.abs_diff(handle.boundary),
681            SplitAxis::Vertical => py.abs_diff(handle.boundary),
682        };
683        let axis_rank = match handle.target.axis {
684            SplitAxis::Horizontal => 0,
685            SplitAxis::Vertical => 1,
686        };
687        let key = (distance, handle.target.split_id.get(), axis_rank);
688        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
689            best = Some((key, handle.target));
690        }
691    }
692
693    best.map(|(_, target)| target)
694}
695
696/// Register pane splitter handles into the frame hit-grid.
697///
698/// Each handle is registered as `HitRegion::Handle` with encoded target data.
699/// Returns number of successfully-registered regions.
700pub fn register_pane_terminal_splitter_hits(
701    frame: &mut Frame,
702    handles: &[PaneTerminalSplitterHandle],
703    hit_id_base: u32,
704) -> usize {
705    let mut registered = 0usize;
706    for (idx, handle) in handles.iter().enumerate() {
707        let Ok(offset) = u32::try_from(idx) else {
708            break;
709        };
710        let hit_id = HitId::new(hit_id_base.saturating_add(offset));
711        if frame.register_hit(
712            handle.rect,
713            hit_id,
714            HitRegion::Handle,
715            encode_pane_resize_target(handle.target),
716        ) {
717            registered = registered.saturating_add(1);
718        }
719    }
720    registered
721}
722
723/// Decode pane resize target from a hit-grid tuple produced by pane handle registration.
724#[must_use]
725pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
726    let (_, region, data) = hit;
727    if region != HitRegion::Handle {
728        return None;
729    }
730    decode_pane_resize_target(data)
731}
732
733fn splitter_hit_rect(
734    axis: SplitAxis,
735    split_rect: Rect,
736    boundary: u16,
737    thickness: u16,
738) -> Option<Rect> {
739    let half = thickness.saturating_sub(1) / 2;
740    match axis {
741        SplitAxis::Horizontal => {
742            let start = boundary.saturating_sub(half).max(split_rect.x);
743            let end = boundary
744                .saturating_add(thickness.saturating_sub(half))
745                .min(split_rect.right());
746            let width = end.saturating_sub(start);
747            (width > 0 && split_rect.height > 0).then_some(Rect::new(
748                start,
749                split_rect.y,
750                width,
751                split_rect.height,
752            ))
753        }
754        SplitAxis::Vertical => {
755            let start = boundary.saturating_sub(half).max(split_rect.y);
756            let end = boundary
757                .saturating_add(thickness.saturating_sub(half))
758                .min(split_rect.bottom());
759            let height = end.saturating_sub(start);
760            (height > 0 && split_rect.width > 0).then_some(Rect::new(
761                split_rect.x,
762                start,
763                split_rect.width,
764                height,
765            ))
766        }
767    }
768}
769
770fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
771    x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
772}
773
774fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
775    let axis = match target.axis {
776        SplitAxis::Horizontal => 0_u64,
777        SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
778    };
779    (target.split_id.get() << 1) | axis
780}
781
782fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
783    let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
784        SplitAxis::Horizontal
785    } else {
786        SplitAxis::Vertical
787    };
788    let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
789    Some(PaneResizeTarget { split_id, axis })
790}
791
792// ============================================================================
793// Pane capability matrix for multiplexer / terminal compat (bd-6u66i)
794// ============================================================================
795
796/// Which multiplexer environment the terminal is running inside.
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
798pub enum PaneMuxEnvironment {
799    /// No multiplexer detected — direct terminal access.
800    None,
801    /// tmux (TMUX env var set, or DA2 terminal type 84).
802    Tmux,
803    /// GNU Screen (STY env var set, or DA2 terminal type 83).
804    Screen,
805    /// Zellij (ZELLIJ env var set).
806    Zellij,
807    /// WezTerm mux-served pane/session.
808    WeztermMux,
809}
810
811/// Resolved capability matrix describing which pane interaction features
812/// are available in the current terminal + multiplexer environment.
813///
814/// Derived from [`TerminalCapabilities`] via [`PaneCapabilityMatrix::from_capabilities`].
815/// The adapter uses this to decide which code-paths are safe and which
816/// need deterministic fallbacks.
817#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub struct PaneCapabilityMatrix {
819    /// Detected multiplexer environment.
820    pub mux: PaneMuxEnvironment,
821
822    // --- Mouse input capabilities ---
823    /// SGR (1006) extended mouse protocol available.
824    /// Without this, mouse coordinates are limited to 223 columns/rows.
825    pub mouse_sgr: bool,
826    /// Mouse drag events are reliably delivered.
827    /// False in some screen versions where drag tracking is incomplete.
828    pub mouse_drag_reliable: bool,
829    /// Mouse button events include correct button identity on release.
830    /// X10/normal mode sends button 3 for all releases; SGR preserves it.
831    pub mouse_button_discrimination: bool,
832
833    // --- Focus / lifecycle ---
834    /// Terminal delivers CSI I / CSI O focus events.
835    pub focus_events: bool,
836    /// Bracketed paste mode available (affects interaction cancel heuristics).
837    pub bracketed_paste: bool,
838
839    // --- Rendering affordances ---
840    /// Unicode box-drawing glyphs available for splitter rendering.
841    pub unicode_box_drawing: bool,
842    /// True-color support for splitter highlight/drag feedback.
843    pub true_color: bool,
844
845    // --- Fallback summary ---
846    /// One or more pane features are degraded due to environment constraints.
847    pub degraded: bool,
848}
849
850/// Human-readable description of a known limitation and its fallback.
851#[derive(Debug, Clone, PartialEq, Eq)]
852pub struct PaneCapabilityLimitation {
853    /// Short identifier (e.g. `"mouse_drag_unreliable"`).
854    pub id: &'static str,
855    /// What the limitation is.
856    pub description: &'static str,
857    /// What the adapter does instead.
858    pub fallback: &'static str,
859}
860
861impl PaneCapabilityMatrix {
862    /// Derive the pane capability matrix from terminal capabilities.
863    ///
864    /// This is the single source of truth for which pane features are
865    /// available. All fallback decisions flow from this matrix.
866    #[must_use]
867    pub fn from_capabilities(
868        caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
869    ) -> Self {
870        let mux = if caps.in_tmux {
871            PaneMuxEnvironment::Tmux
872        } else if caps.in_screen {
873            PaneMuxEnvironment::Screen
874        } else if caps.in_zellij {
875            PaneMuxEnvironment::Zellij
876        } else if caps.in_wezterm_mux {
877            PaneMuxEnvironment::WeztermMux
878        } else {
879            PaneMuxEnvironment::None
880        };
881
882        let mouse_sgr = caps.mouse_sgr;
883
884        // GNU Screen has historically unreliable drag event delivery.
885        // tmux and zellij forward drags correctly in modern versions.
886        let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
887
888        // Button discrimination requires SGR mouse protocol.
889        // Without it, X10/normal mode reports button 3 for all releases.
890        let mouse_button_discrimination = mouse_sgr;
891
892        // Focus events are conservatively disabled in any mux context.
893        let focus_events = caps.focus_events && !caps.in_any_mux();
894
895        let bracketed_paste = caps.bracketed_paste;
896        let unicode_box_drawing = caps.unicode_box_drawing;
897        let true_color = caps.true_color;
898
899        let degraded =
900            !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
901
902        Self {
903            mux,
904            mouse_sgr,
905            mouse_drag_reliable,
906            mouse_button_discrimination,
907            focus_events,
908            bracketed_paste,
909            unicode_box_drawing,
910            true_color,
911            degraded,
912        }
913    }
914
915    /// Whether pane drag interactions should be enabled at all.
916    ///
917    /// Drag requires at minimum mouse event support. If drag events
918    /// are unreliable (e.g. GNU Screen), drag is disabled and the
919    /// adapter falls back to keyboard-only resize.
920    #[must_use]
921    pub const fn drag_enabled(&self) -> bool {
922        self.mouse_drag_reliable
923    }
924
925    /// Whether focus-loss auto-cancel is effective.
926    ///
927    /// When focus events are unavailable, the adapter cannot detect
928    /// window blur — interactions must rely on timeout or explicit
929    /// keyboard cancel instead.
930    #[must_use]
931    pub const fn focus_cancel_effective(&self) -> bool {
932        self.focus_events
933    }
934
935    /// Collect all active limitations with their fallback descriptions.
936    #[must_use]
937    pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
938        let mut out = Vec::new();
939
940        if !self.mouse_sgr {
941            out.push(PaneCapabilityLimitation {
942                id: "no_sgr_mouse",
943                description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
944                fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
945            });
946        }
947
948        if !self.mouse_drag_reliable {
949            out.push(PaneCapabilityLimitation {
950                id: "mouse_drag_unreliable",
951                description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
952                fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
953            });
954        }
955
956        if !self.mouse_button_discrimination {
957            out.push(PaneCapabilityLimitation {
958                id: "no_button_discrimination",
959                description: "Mouse release events do not identify which button was released",
960                fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
961            });
962        }
963
964        if !self.focus_events {
965            out.push(PaneCapabilityLimitation {
966                id: "no_focus_events",
967                description: "Terminal does not deliver focus-in/focus-out events",
968                fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
969            });
970        }
971
972        out
973    }
974}
975
976/// Configuration for terminal-to-pane semantic input translation.
977///
978/// This adapter normalizes terminal `Event` streams into
979/// `PaneSemanticInputEvent` values accepted by `PaneDragResizeMachine`.
980#[derive(Debug, Clone, Copy, PartialEq, Eq)]
981pub struct PaneTerminalAdapterConfig {
982    /// Drag start threshold in pane-local units.
983    pub drag_threshold: u16,
984    /// Drag update hysteresis threshold in pane-local units.
985    pub update_hysteresis: u16,
986    /// Mouse button required to begin a drag sequence.
987    pub activation_button: PanePointerButton,
988    /// Minimum drag delta (Manhattan distance, cells) before forwarding
989    /// updates while already in the dragging state.
990    pub drag_update_coalesce_distance: u16,
991    /// Cancel active interactions on focus loss.
992    pub cancel_on_focus_lost: bool,
993    /// Cancel active interactions on terminal resize.
994    pub cancel_on_resize: bool,
995}
996
997impl Default for PaneTerminalAdapterConfig {
998    fn default() -> Self {
999        Self {
1000            drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1001            update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1002            activation_button: PanePointerButton::Primary,
1003            drag_update_coalesce_distance: 2,
1004            cancel_on_focus_lost: true,
1005            cancel_on_resize: true,
1006        }
1007    }
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1011struct PaneTerminalActivePointer {
1012    pointer_id: u32,
1013    target: PaneResizeTarget,
1014    button: PanePointerButton,
1015    last_position: PanePointerPosition,
1016    cumulative_delta_x: i32,
1017    cumulative_delta_y: i32,
1018    direction_changes: u16,
1019    sample_count: u32,
1020    previous_step_delta_x: i32,
1021    previous_step_delta_y: i32,
1022    start_time: Instant,
1023}
1024
1025/// Lifecycle phase observed while translating a terminal event.
1026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027pub enum PaneTerminalLifecyclePhase {
1028    MouseDown,
1029    MouseDrag,
1030    MouseMove,
1031    MouseUp,
1032    MouseScroll,
1033    KeyResize,
1034    KeyCancel,
1035    FocusLoss,
1036    ResizeInterrupt,
1037    Other,
1038}
1039
1040/// Deterministic reason a terminal event did not map to pane semantics.
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042pub enum PaneTerminalIgnoredReason {
1043    MissingTarget,
1044    NoActivePointer,
1045    PointerButtonMismatch,
1046    ActivationButtonRequired,
1047    WindowNotFocused,
1048    UnsupportedKey,
1049    FocusGainNoop,
1050    ResizeNoop,
1051    DragCoalesced,
1052    NonSemanticEvent,
1053    MachineRejectedEvent,
1054}
1055
1056/// Translation outcome for one raw terminal event.
1057#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1058pub enum PaneTerminalLogOutcome {
1059    SemanticForwarded,
1060    SemanticForwardedAfterRecovery,
1061    Ignored(PaneTerminalIgnoredReason),
1062}
1063
1064/// Structured translation log entry for one raw terminal event.
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub struct PaneTerminalLogEntry {
1067    pub phase: PaneTerminalLifecyclePhase,
1068    pub sequence: Option<u64>,
1069    pub pointer_id: Option<u32>,
1070    pub target: Option<PaneResizeTarget>,
1071    pub recovery_cancel_sequence: Option<u64>,
1072    pub outcome: PaneTerminalLogOutcome,
1073}
1074
1075/// Output of one terminal event translation step.
1076///
1077/// `recovery_*` fields are populated when the adapter first emits an internal
1078/// cancel (for stale/missing mouse-up recovery) and then forwards the incoming
1079/// event as a fresh semantic event.
1080#[derive(Debug, Clone, PartialEq)]
1081pub struct PaneTerminalDispatch {
1082    pub primary_event: Option<PaneSemanticInputEvent>,
1083    pub primary_transition: Option<PaneDragResizeTransition>,
1084    pub motion: Option<PaneMotionVector>,
1085    pub inertial_throw: Option<PaneInertialThrow>,
1086    pub projected_position: Option<PanePointerPosition>,
1087    pub recovery_event: Option<PaneSemanticInputEvent>,
1088    pub recovery_transition: Option<PaneDragResizeTransition>,
1089    pub log: PaneTerminalLogEntry,
1090}
1091
1092impl PaneTerminalDispatch {
1093    fn ignored(
1094        phase: PaneTerminalLifecyclePhase,
1095        reason: PaneTerminalIgnoredReason,
1096        pointer_id: Option<u32>,
1097        target: Option<PaneResizeTarget>,
1098    ) -> Self {
1099        Self {
1100            primary_event: None,
1101            primary_transition: None,
1102            motion: None,
1103            inertial_throw: None,
1104            projected_position: None,
1105            recovery_event: None,
1106            recovery_transition: None,
1107            log: PaneTerminalLogEntry {
1108                phase,
1109                sequence: None,
1110                pointer_id,
1111                target,
1112                recovery_cancel_sequence: None,
1113                outcome: PaneTerminalLogOutcome::Ignored(reason),
1114            },
1115        }
1116    }
1117
1118    fn forwarded(
1119        phase: PaneTerminalLifecyclePhase,
1120        pointer_id: Option<u32>,
1121        target: Option<PaneResizeTarget>,
1122        event: PaneSemanticInputEvent,
1123        transition: PaneDragResizeTransition,
1124    ) -> Self {
1125        let sequence = Some(event.sequence);
1126        Self {
1127            primary_event: Some(event),
1128            primary_transition: Some(transition),
1129            motion: None,
1130            inertial_throw: None,
1131            projected_position: None,
1132            recovery_event: None,
1133            recovery_transition: None,
1134            log: PaneTerminalLogEntry {
1135                phase,
1136                sequence,
1137                pointer_id,
1138                target,
1139                recovery_cancel_sequence: None,
1140                outcome: PaneTerminalLogOutcome::SemanticForwarded,
1141            },
1142        }
1143    }
1144
1145    /// Derive dynamic snap profile from translated pointer motion.
1146    #[must_use]
1147    pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1148        self.motion.map(PanePressureSnapProfile::from_motion)
1149    }
1150}
1151
1152/// Deterministic terminal adapter mapping raw `Event` values into
1153/// schema-validated pane semantic interaction events.
1154#[derive(Debug, Clone)]
1155pub struct PaneTerminalAdapter {
1156    machine: PaneDragResizeMachine,
1157    config: PaneTerminalAdapterConfig,
1158    active: Option<PaneTerminalActivePointer>,
1159    window_focused: bool,
1160    next_sequence: u64,
1161}
1162
1163impl PaneTerminalAdapter {
1164    /// Construct a new adapter with validated drag thresholds.
1165    pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1166        let config = PaneTerminalAdapterConfig {
1167            drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1168            ..config
1169        };
1170        let machine = PaneDragResizeMachine::new_with_hysteresis(
1171            config.drag_threshold,
1172            config.update_hysteresis,
1173        )?;
1174        Ok(Self {
1175            machine,
1176            config,
1177            active: None,
1178            window_focused: true,
1179            next_sequence: 1,
1180        })
1181    }
1182
1183    /// Adapter configuration.
1184    #[must_use]
1185    pub const fn config(&self) -> PaneTerminalAdapterConfig {
1186        self.config
1187    }
1188
1189    /// Active pointer id currently tracked by the adapter, if any.
1190    #[must_use]
1191    pub fn active_pointer_id(&self) -> Option<u32> {
1192        self.active.map(|active| active.pointer_id)
1193    }
1194
1195    /// Whether the host window is currently focused.
1196    #[must_use]
1197    pub const fn window_focused(&self) -> bool {
1198        self.window_focused
1199    }
1200
1201    /// Current pane drag/resize machine state.
1202    #[must_use]
1203    pub const fn machine_state(&self) -> PaneDragResizeState {
1204        self.machine.state()
1205    }
1206
1207    /// Translate one raw terminal event into pane semantic event(s).
1208    ///
1209    /// `target_hint` is provided by host hit-testing (upcoming pane-terminal
1210    /// tasks). Pointer drag/move/up reuse active target continuity once armed.
1211    pub fn translate(
1212        &mut self,
1213        event: &Event,
1214        target_hint: Option<PaneResizeTarget>,
1215    ) -> PaneTerminalDispatch {
1216        match event {
1217            Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1218            Event::Key(key) => self.translate_key(*key, target_hint),
1219            Event::Focus(focused) => self.translate_focus(*focused),
1220            Event::Resize { .. } => self.translate_resize(),
1221            _ => PaneTerminalDispatch::ignored(
1222                PaneTerminalLifecyclePhase::Other,
1223                PaneTerminalIgnoredReason::NonSemanticEvent,
1224                None,
1225                target_hint,
1226            ),
1227        }
1228    }
1229
1230    /// Translate one raw terminal event while resolving splitter targets from
1231    /// terminal hit regions.
1232    ///
1233    /// This is a convenience wrapper for host code that already has splitter
1234    /// handle regions from [`pane_terminal_splitter_handles`].
1235    pub fn translate_with_handles(
1236        &mut self,
1237        event: &Event,
1238        handles: &[PaneTerminalSplitterHandle],
1239    ) -> PaneTerminalDispatch {
1240        let active_target = self.active.map(|active| active.target);
1241        let target_hint = match event {
1242            Event::Mouse(mouse) => {
1243                let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1244                match mouse.kind {
1245                    MouseEventKind::Down(_)
1246                    | MouseEventKind::ScrollUp
1247                    | MouseEventKind::ScrollDown
1248                    | MouseEventKind::ScrollLeft
1249                    | MouseEventKind::ScrollRight => resolved,
1250                    MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1251                        resolved.or(active_target)
1252                    }
1253                }
1254            }
1255            Event::Key(_) => active_target,
1256            _ => None,
1257        };
1258        self.translate(event, target_hint)
1259    }
1260
1261    fn translate_mouse(
1262        &mut self,
1263        mouse: MouseEvent,
1264        target_hint: Option<PaneResizeTarget>,
1265    ) -> PaneTerminalDispatch {
1266        let position = mouse_position(mouse);
1267        let modifiers = pane_modifiers(mouse.modifiers);
1268        match mouse.kind {
1269            MouseEventKind::Down(button) => {
1270                let pane_button = pane_button(button);
1271                if pane_button != self.config.activation_button {
1272                    return PaneTerminalDispatch::ignored(
1273                        PaneTerminalLifecyclePhase::MouseDown,
1274                        PaneTerminalIgnoredReason::ActivationButtonRequired,
1275                        Some(pointer_id_for_button(pane_button)),
1276                        target_hint,
1277                    );
1278                }
1279                let Some(target) = target_hint else {
1280                    return PaneTerminalDispatch::ignored(
1281                        PaneTerminalLifecyclePhase::MouseDown,
1282                        PaneTerminalIgnoredReason::MissingTarget,
1283                        Some(pointer_id_for_button(pane_button)),
1284                        None,
1285                    );
1286                };
1287
1288                let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1289                let pointer_id = pointer_id_for_button(pane_button);
1290                let kind = PaneSemanticInputEventKind::PointerDown {
1291                    target,
1292                    pointer_id,
1293                    button: pane_button,
1294                    position,
1295                };
1296                let mut dispatch = self.forward_semantic(
1297                    PaneTerminalLifecyclePhase::MouseDown,
1298                    Some(pointer_id),
1299                    Some(target),
1300                    kind,
1301                    modifiers,
1302                );
1303                if dispatch.primary_transition.is_some() {
1304                    self.active = Some(PaneTerminalActivePointer {
1305                        pointer_id,
1306                        target,
1307                        button: pane_button,
1308                        last_position: position,
1309                        cumulative_delta_x: 0,
1310                        cumulative_delta_y: 0,
1311                        direction_changes: 0,
1312                        sample_count: 0,
1313                        previous_step_delta_x: 0,
1314                        previous_step_delta_y: 0,
1315                        start_time: Instant::now(),
1316                    });
1317                }
1318                if let Some((cancel_event, cancel_transition)) = recovery {
1319                    dispatch.recovery_event = Some(cancel_event);
1320                    dispatch.recovery_transition = Some(cancel_transition);
1321                    dispatch.log.recovery_cancel_sequence =
1322                        dispatch.recovery_event.as_ref().map(|event| event.sequence);
1323                    if matches!(
1324                        dispatch.log.outcome,
1325                        PaneTerminalLogOutcome::SemanticForwarded
1326                    ) {
1327                        dispatch.log.outcome =
1328                            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1329                    }
1330                }
1331                dispatch
1332            }
1333            MouseEventKind::Drag(button) => {
1334                let pane_button = pane_button(button);
1335                let Some(mut active) = self.active else {
1336                    return PaneTerminalDispatch::ignored(
1337                        PaneTerminalLifecyclePhase::MouseDrag,
1338                        PaneTerminalIgnoredReason::NoActivePointer,
1339                        Some(pointer_id_for_button(pane_button)),
1340                        target_hint,
1341                    );
1342                };
1343                if active.button != pane_button {
1344                    return PaneTerminalDispatch::ignored(
1345                        PaneTerminalLifecyclePhase::MouseDrag,
1346                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1347                        Some(pointer_id_for_button(pane_button)),
1348                        Some(active.target),
1349                    );
1350                }
1351                let delta_x = position.x.saturating_sub(active.last_position.x);
1352                let delta_y = position.y.saturating_sub(active.last_position.y);
1353                if self.should_coalesce_drag(delta_x, delta_y) {
1354                    return PaneTerminalDispatch::ignored(
1355                        PaneTerminalLifecyclePhase::MouseDrag,
1356                        PaneTerminalIgnoredReason::DragCoalesced,
1357                        Some(active.pointer_id),
1358                        Some(active.target),
1359                    );
1360                }
1361                if active.sample_count > 0 {
1362                    let flipped_x = delta_x.signum() != 0
1363                        && active.previous_step_delta_x.signum() != 0
1364                        && delta_x.signum() != active.previous_step_delta_x.signum();
1365                    let flipped_y = delta_y.signum() != 0
1366                        && active.previous_step_delta_y.signum() != 0
1367                        && delta_y.signum() != active.previous_step_delta_y.signum();
1368                    if flipped_x || flipped_y {
1369                        active.direction_changes = active.direction_changes.saturating_add(1);
1370                    }
1371                }
1372                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1373                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1374                active.sample_count = active.sample_count.saturating_add(1);
1375                active.previous_step_delta_x = delta_x;
1376                active.previous_step_delta_y = delta_y;
1377                let kind = PaneSemanticInputEventKind::PointerMove {
1378                    target: active.target,
1379                    pointer_id: active.pointer_id,
1380                    position,
1381                    delta_x,
1382                    delta_y,
1383                };
1384                let mut dispatch = self.forward_semantic(
1385                    PaneTerminalLifecyclePhase::MouseDrag,
1386                    Some(active.pointer_id),
1387                    Some(active.target),
1388                    kind,
1389                    modifiers,
1390                );
1391                if dispatch.primary_transition.is_some() {
1392                    active.last_position = position;
1393                    self.active = Some(active);
1394                    let duration = active.start_time.elapsed().as_millis() as u32;
1395                    dispatch.motion = Some(PaneMotionVector::from_delta(
1396                        active.cumulative_delta_x,
1397                        active.cumulative_delta_y,
1398                        duration,
1399                        active.direction_changes,
1400                    ));
1401                }
1402                dispatch
1403            }
1404            MouseEventKind::Moved => {
1405                let Some(mut active) = self.active else {
1406                    return PaneTerminalDispatch::ignored(
1407                        PaneTerminalLifecyclePhase::MouseMove,
1408                        PaneTerminalIgnoredReason::NoActivePointer,
1409                        None,
1410                        target_hint,
1411                    );
1412                };
1413                let delta_x = position.x.saturating_sub(active.last_position.x);
1414                let delta_y = position.y.saturating_sub(active.last_position.y);
1415                if self.should_coalesce_drag(delta_x, delta_y) {
1416                    return PaneTerminalDispatch::ignored(
1417                        PaneTerminalLifecyclePhase::MouseMove,
1418                        PaneTerminalIgnoredReason::DragCoalesced,
1419                        Some(active.pointer_id),
1420                        Some(active.target),
1421                    );
1422                }
1423                if active.sample_count > 0 {
1424                    let flipped_x = delta_x.signum() != 0
1425                        && active.previous_step_delta_x.signum() != 0
1426                        && delta_x.signum() != active.previous_step_delta_x.signum();
1427                    let flipped_y = delta_y.signum() != 0
1428                        && active.previous_step_delta_y.signum() != 0
1429                        && delta_y.signum() != active.previous_step_delta_y.signum();
1430                    if flipped_x || flipped_y {
1431                        active.direction_changes = active.direction_changes.saturating_add(1);
1432                    }
1433                }
1434                active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1435                active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1436                active.sample_count = active.sample_count.saturating_add(1);
1437                active.previous_step_delta_x = delta_x;
1438                active.previous_step_delta_y = delta_y;
1439                let kind = PaneSemanticInputEventKind::PointerMove {
1440                    target: active.target,
1441                    pointer_id: active.pointer_id,
1442                    position,
1443                    delta_x,
1444                    delta_y,
1445                };
1446                let mut dispatch = self.forward_semantic(
1447                    PaneTerminalLifecyclePhase::MouseMove,
1448                    Some(active.pointer_id),
1449                    Some(active.target),
1450                    kind,
1451                    modifiers,
1452                );
1453                if dispatch.primary_transition.is_some() {
1454                    active.last_position = position;
1455                    self.active = Some(active);
1456                    let duration = active.start_time.elapsed().as_millis() as u32;
1457                    dispatch.motion = Some(PaneMotionVector::from_delta(
1458                        active.cumulative_delta_x,
1459                        active.cumulative_delta_y,
1460                        duration,
1461                        active.direction_changes,
1462                    ));
1463                }
1464                dispatch
1465            }
1466            MouseEventKind::Up(button) => {
1467                let pane_button = pane_button(button);
1468                let Some(active) = self.active else {
1469                    return PaneTerminalDispatch::ignored(
1470                        PaneTerminalLifecyclePhase::MouseUp,
1471                        PaneTerminalIgnoredReason::NoActivePointer,
1472                        Some(pointer_id_for_button(pane_button)),
1473                        target_hint,
1474                    );
1475                };
1476                if active.button != pane_button {
1477                    return PaneTerminalDispatch::ignored(
1478                        PaneTerminalLifecyclePhase::MouseUp,
1479                        PaneTerminalIgnoredReason::PointerButtonMismatch,
1480                        Some(pointer_id_for_button(pane_button)),
1481                        Some(active.target),
1482                    );
1483                }
1484                let kind = PaneSemanticInputEventKind::PointerUp {
1485                    target: active.target,
1486                    pointer_id: active.pointer_id,
1487                    button: active.button,
1488                    position,
1489                };
1490                let mut dispatch = self.forward_semantic(
1491                    PaneTerminalLifecyclePhase::MouseUp,
1492                    Some(active.pointer_id),
1493                    Some(active.target),
1494                    kind,
1495                    modifiers,
1496                );
1497                if dispatch.primary_transition.is_some() {
1498                    let duration = active.start_time.elapsed().as_millis() as u32;
1499                    let motion = PaneMotionVector::from_delta(
1500                        active.cumulative_delta_x,
1501                        active.cumulative_delta_y,
1502                        duration,
1503                        active.direction_changes,
1504                    );
1505                    let inertial_throw = PaneInertialThrow::from_motion(motion);
1506                    dispatch.motion = Some(motion);
1507                    dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1508                    dispatch.inertial_throw = Some(inertial_throw);
1509                    self.active = None;
1510                }
1511                dispatch
1512            }
1513            MouseEventKind::ScrollUp
1514            | MouseEventKind::ScrollDown
1515            | MouseEventKind::ScrollLeft
1516            | MouseEventKind::ScrollRight => {
1517                let target = target_hint.or(self.active.map(|active| active.target));
1518                let Some(target) = target else {
1519                    return PaneTerminalDispatch::ignored(
1520                        PaneTerminalLifecyclePhase::MouseScroll,
1521                        PaneTerminalIgnoredReason::MissingTarget,
1522                        None,
1523                        None,
1524                    );
1525                };
1526                let lines = match mouse.kind {
1527                    MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1528                    MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1529                    _ => unreachable!("handled by outer match"),
1530                };
1531                let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1532                self.forward_semantic(
1533                    PaneTerminalLifecyclePhase::MouseScroll,
1534                    None,
1535                    Some(target),
1536                    kind,
1537                    modifiers,
1538                )
1539            }
1540        }
1541    }
1542
1543    fn translate_key(
1544        &mut self,
1545        key: KeyEvent,
1546        target_hint: Option<PaneResizeTarget>,
1547    ) -> PaneTerminalDispatch {
1548        if !self.window_focused {
1549            return PaneTerminalDispatch::ignored(
1550                PaneTerminalLifecyclePhase::KeyResize,
1551                PaneTerminalIgnoredReason::WindowNotFocused,
1552                self.active_pointer_id(),
1553                target_hint.or(self.active.map(|active| active.target)),
1554            );
1555        }
1556        if key.kind == KeyEventKind::Release {
1557            return PaneTerminalDispatch::ignored(
1558                PaneTerminalLifecyclePhase::Other,
1559                PaneTerminalIgnoredReason::UnsupportedKey,
1560                None,
1561                target_hint,
1562            );
1563        }
1564        if matches!(key.code, KeyCode::Escape) {
1565            return self.cancel_active_dispatch(
1566                PaneTerminalLifecyclePhase::KeyCancel,
1567                PaneCancelReason::EscapeKey,
1568                PaneTerminalIgnoredReason::NoActivePointer,
1569            );
1570        }
1571        let target = target_hint.or(self.active.map(|active| active.target));
1572        let Some(target) = target else {
1573            return PaneTerminalDispatch::ignored(
1574                PaneTerminalLifecyclePhase::KeyResize,
1575                PaneTerminalIgnoredReason::MissingTarget,
1576                None,
1577                None,
1578            );
1579        };
1580        let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1581            return PaneTerminalDispatch::ignored(
1582                PaneTerminalLifecyclePhase::KeyResize,
1583                PaneTerminalIgnoredReason::UnsupportedKey,
1584                None,
1585                Some(target),
1586            );
1587        };
1588        let units = keyboard_resize_units(key.modifiers);
1589        let kind = PaneSemanticInputEventKind::KeyboardResize {
1590            target,
1591            direction,
1592            units,
1593        };
1594        self.forward_semantic(
1595            PaneTerminalLifecyclePhase::KeyResize,
1596            self.active_pointer_id(),
1597            Some(target),
1598            kind,
1599            pane_modifiers(key.modifiers),
1600        )
1601    }
1602
1603    fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1604        if focused {
1605            self.window_focused = true;
1606            return PaneTerminalDispatch::ignored(
1607                PaneTerminalLifecyclePhase::Other,
1608                PaneTerminalIgnoredReason::FocusGainNoop,
1609                self.active_pointer_id(),
1610                self.active.map(|active| active.target),
1611            );
1612        }
1613        self.window_focused = false;
1614        if !self.config.cancel_on_focus_lost {
1615            return PaneTerminalDispatch::ignored(
1616                PaneTerminalLifecyclePhase::FocusLoss,
1617                PaneTerminalIgnoredReason::ResizeNoop,
1618                self.active_pointer_id(),
1619                self.active.map(|active| active.target),
1620            );
1621        }
1622        self.cancel_active_dispatch(
1623            PaneTerminalLifecyclePhase::FocusLoss,
1624            PaneCancelReason::FocusLost,
1625            PaneTerminalIgnoredReason::NoActivePointer,
1626        )
1627    }
1628
1629    fn translate_resize(&mut self) -> PaneTerminalDispatch {
1630        if !self.config.cancel_on_resize {
1631            return PaneTerminalDispatch::ignored(
1632                PaneTerminalLifecyclePhase::ResizeInterrupt,
1633                PaneTerminalIgnoredReason::ResizeNoop,
1634                self.active_pointer_id(),
1635                self.active.map(|active| active.target),
1636            );
1637        }
1638        self.cancel_active_dispatch(
1639            PaneTerminalLifecyclePhase::ResizeInterrupt,
1640            PaneCancelReason::Programmatic,
1641            PaneTerminalIgnoredReason::ResizeNoop,
1642        )
1643    }
1644
1645    fn cancel_active_dispatch(
1646        &mut self,
1647        phase: PaneTerminalLifecyclePhase,
1648        reason: PaneCancelReason,
1649        no_active_reason: PaneTerminalIgnoredReason,
1650    ) -> PaneTerminalDispatch {
1651        let Some(active) = self.active else {
1652            return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1653        };
1654        let kind = PaneSemanticInputEventKind::Cancel {
1655            target: Some(active.target),
1656            reason,
1657        };
1658        let dispatch = self.forward_semantic(
1659            phase,
1660            Some(active.pointer_id),
1661            Some(active.target),
1662            kind,
1663            PaneModifierSnapshot::default(),
1664        );
1665        if dispatch.primary_transition.is_some() {
1666            self.active = None;
1667        }
1668        dispatch
1669    }
1670
1671    fn cancel_active_internal(
1672        &mut self,
1673        reason: PaneCancelReason,
1674    ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1675        let active = self.active?;
1676        let kind = PaneSemanticInputEventKind::Cancel {
1677            target: Some(active.target),
1678            reason,
1679        };
1680        let result = self
1681            .apply_semantic(kind, PaneModifierSnapshot::default())
1682            .ok();
1683        if result.is_some() {
1684            self.active = None;
1685        }
1686        result
1687    }
1688
1689    fn forward_semantic(
1690        &mut self,
1691        phase: PaneTerminalLifecyclePhase,
1692        pointer_id: Option<u32>,
1693        target: Option<PaneResizeTarget>,
1694        kind: PaneSemanticInputEventKind,
1695        modifiers: PaneModifierSnapshot,
1696    ) -> PaneTerminalDispatch {
1697        match self.apply_semantic(kind, modifiers) {
1698            Ok((event, transition)) => {
1699                PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1700            }
1701            Err(_) => PaneTerminalDispatch::ignored(
1702                phase,
1703                PaneTerminalIgnoredReason::MachineRejectedEvent,
1704                pointer_id,
1705                target,
1706            ),
1707        }
1708    }
1709
1710    fn apply_semantic(
1711        &mut self,
1712        kind: PaneSemanticInputEventKind,
1713        modifiers: PaneModifierSnapshot,
1714    ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1715    {
1716        let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1717        event.modifiers = modifiers;
1718        let transition = self.machine.apply_event(&event)?;
1719        Ok((event, transition))
1720    }
1721
1722    fn next_sequence(&mut self) -> u64 {
1723        let sequence = self.next_sequence;
1724        self.next_sequence = self.next_sequence.saturating_add(1);
1725        sequence
1726    }
1727
1728    fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1729        if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1730            return false;
1731        }
1732        let movement = delta_x
1733            .unsigned_abs()
1734            .saturating_add(delta_y.unsigned_abs());
1735        movement < u32::from(self.config.drag_update_coalesce_distance)
1736    }
1737
1738    /// Force-cancel any active pane interaction and return diagnostic info.
1739    ///
1740    /// This is the safety-valve for cleanup paths (RAII guard drops, signal
1741    /// handlers, panic hooks) where constructing a proper semantic event is
1742    /// not feasible. It resets both the underlying drag/resize state machine
1743    /// and the adapter's active-pointer tracking.
1744    ///
1745    /// Returns `None` if no interaction was active.
1746    pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1747        let was_active = self.active.is_some();
1748        let machine_state_before = self.machine.state();
1749        let machine_transition = self.machine.force_cancel();
1750        let active_pointer = self.active.take();
1751        if !was_active && machine_transition.is_none() {
1752            return None;
1753        }
1754        Some(PaneCleanupDiagnostics {
1755            had_active_pointer: was_active,
1756            active_pointer_id: active_pointer.map(|a| a.pointer_id),
1757            machine_state_before,
1758            machine_transition,
1759        })
1760    }
1761}
1762
1763/// Structured diagnostics emitted when pane interaction state is force-cleaned.
1764///
1765/// Fields mirror the pane layout types which are already `Serialize`/`Deserialize`,
1766/// so callers can convert this struct to JSON for evidence logging.
1767#[derive(Debug, Clone, PartialEq, Eq)]
1768pub struct PaneCleanupDiagnostics {
1769    /// Whether the adapter had an active pointer tracker when cleanup ran.
1770    pub had_active_pointer: bool,
1771    /// The pointer ID that was active (if any).
1772    pub active_pointer_id: Option<u32>,
1773    /// The machine state before force-cancel was applied.
1774    pub machine_state_before: PaneDragResizeState,
1775    /// The transition produced by force-cancel, or `None` if the machine
1776    /// was already idle.
1777    pub machine_transition: Option<PaneDragResizeTransition>,
1778}
1779
1780/// RAII guard that ensures pane interaction state is cleanly canceled on drop.
1781///
1782/// When a pane interaction session is active and the guard drops (due to
1783/// panic, scope exit, or any other unwind), it force-cancels any in-progress
1784/// drag/resize and collects cleanup diagnostics.
1785///
1786/// # Usage
1787///
1788/// ```ignore
1789/// let guard = PaneInteractionGuard::new(&mut adapter);
1790/// // ... pane interaction event loop ...
1791/// // If this scope panics, guard's Drop will force-cancel the drag machine
1792/// let diagnostics = guard.finish(); // explicit clean finish
1793/// ```
1794pub struct PaneInteractionGuard<'a> {
1795    adapter: &'a mut PaneTerminalAdapter,
1796    finished: bool,
1797    diagnostics: Option<PaneCleanupDiagnostics>,
1798}
1799
1800impl<'a> PaneInteractionGuard<'a> {
1801    /// Create a new guard wrapping the given adapter.
1802    pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1803        Self {
1804            adapter,
1805            finished: false,
1806            diagnostics: None,
1807        }
1808    }
1809
1810    /// Access the wrapped adapter for normal event translation.
1811    pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1812        self.adapter
1813    }
1814
1815    /// Explicitly finish the guard, returning any cleanup diagnostics.
1816    ///
1817    /// Calling `finish()` is optional — the guard will also clean up on drop.
1818    /// However, `finish()` gives the caller access to the diagnostics.
1819    pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1820        self.finished = true;
1821        let diagnostics = self.adapter.force_cancel_all();
1822        self.diagnostics = diagnostics.clone();
1823        diagnostics
1824    }
1825}
1826
1827impl Drop for PaneInteractionGuard<'_> {
1828    fn drop(&mut self) {
1829        if !self.finished {
1830            self.diagnostics = self.adapter.force_cancel_all();
1831        }
1832    }
1833}
1834
1835fn pane_button(button: MouseButton) -> PanePointerButton {
1836    match button {
1837        MouseButton::Left => PanePointerButton::Primary,
1838        MouseButton::Right => PanePointerButton::Secondary,
1839        MouseButton::Middle => PanePointerButton::Middle,
1840    }
1841}
1842
1843fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1844    match button {
1845        PanePointerButton::Primary => 1,
1846        PanePointerButton::Secondary => 2,
1847        PanePointerButton::Middle => 3,
1848    }
1849}
1850
1851fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1852    PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1853}
1854
1855fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1856    PaneModifierSnapshot {
1857        shift: modifiers.contains(Modifiers::SHIFT),
1858        alt: modifiers.contains(Modifiers::ALT),
1859        ctrl: modifiers.contains(Modifiers::CTRL),
1860        meta: modifiers.contains(Modifiers::SUPER),
1861    }
1862}
1863
1864fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1865    match (axis, code) {
1866        (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1867        (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1868        (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1869        (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1870        (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1871        (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1872        _ => None,
1873    }
1874}
1875
1876fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1877    if modifiers.contains(Modifiers::SHIFT) {
1878        5
1879    } else {
1880        1
1881    }
1882}
1883
1884/// Configuration for state persistence in the program runtime.
1885///
1886/// Controls when and how widget state is saved/restored.
1887#[derive(Clone)]
1888pub struct PersistenceConfig {
1889    /// State registry for persistence. If None, persistence is disabled.
1890    pub registry: Option<std::sync::Arc<StateRegistry>>,
1891    /// Interval for periodic checkpoint saves. None disables checkpoints.
1892    pub checkpoint_interval: Option<Duration>,
1893    /// Automatically load state on program start.
1894    pub auto_load: bool,
1895    /// Automatically save state on program exit.
1896    pub auto_save: bool,
1897}
1898
1899impl Default for PersistenceConfig {
1900    fn default() -> Self {
1901        Self {
1902            registry: None,
1903            checkpoint_interval: None,
1904            auto_load: true,
1905            auto_save: true,
1906        }
1907    }
1908}
1909
1910impl std::fmt::Debug for PersistenceConfig {
1911    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1912        f.debug_struct("PersistenceConfig")
1913            .field(
1914                "registry",
1915                &self.registry.as_ref().map(|r| r.backend_name()),
1916            )
1917            .field("checkpoint_interval", &self.checkpoint_interval)
1918            .field("auto_load", &self.auto_load)
1919            .field("auto_save", &self.auto_save)
1920            .finish()
1921    }
1922}
1923
1924impl PersistenceConfig {
1925    /// Create a disabled persistence config.
1926    #[must_use]
1927    pub fn disabled() -> Self {
1928        Self::default()
1929    }
1930
1931    /// Create a persistence config with the given registry.
1932    #[must_use]
1933    pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1934        Self {
1935            registry: Some(registry),
1936            ..Default::default()
1937        }
1938    }
1939
1940    /// Set the checkpoint interval.
1941    #[must_use]
1942    pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1943        self.checkpoint_interval = Some(interval);
1944        self
1945    }
1946
1947    /// Enable or disable auto-load on start.
1948    #[must_use]
1949    pub fn auto_load(mut self, enabled: bool) -> Self {
1950        self.auto_load = enabled;
1951        self
1952    }
1953
1954    /// Enable or disable auto-save on exit.
1955    #[must_use]
1956    pub fn auto_save(mut self, enabled: bool) -> Self {
1957        self.auto_save = enabled;
1958        self
1959    }
1960}
1961
1962/// Configuration for widget refresh selection under render budget.
1963///
1964/// Defaults are conservative and deterministic:
1965/// - enabled: true
1966/// - staleness_window_ms: 1_000
1967/// - starve_ms: 3_000
1968/// - max_starved_per_frame: 2
1969/// - max_drop_fraction: 1.0 (disabled)
1970/// - weights: priority 1.0, staleness 0.5, focus 0.75, interaction 0.5
1971/// - starve_boost: 1.5
1972/// - min_cost_us: 1.0
1973#[derive(Debug, Clone)]
1974pub struct WidgetRefreshConfig {
1975    /// Enable budgeted widget refresh selection.
1976    pub enabled: bool,
1977    /// Staleness decay window (ms) used to normalize staleness scores.
1978    pub staleness_window_ms: u64,
1979    /// Staleness threshold that triggers starvation guard (ms).
1980    pub starve_ms: u64,
1981    /// Maximum number of starved widgets to force in per frame.
1982    pub max_starved_per_frame: usize,
1983    /// Maximum fraction of non-essential widgets that may be dropped.
1984    /// Set to 1.0 to disable the guardrail.
1985    pub max_drop_fraction: f32,
1986    /// Weight for base priority signal.
1987    pub weight_priority: f32,
1988    /// Weight for staleness signal.
1989    pub weight_staleness: f32,
1990    /// Weight for focus boost.
1991    pub weight_focus: f32,
1992    /// Weight for interaction boost.
1993    pub weight_interaction: f32,
1994    /// Additive boost to value for starved widgets.
1995    pub starve_boost: f32,
1996    /// Minimum cost (us) to avoid divide-by-zero.
1997    pub min_cost_us: f32,
1998}
1999
2000impl Default for WidgetRefreshConfig {
2001    fn default() -> Self {
2002        Self {
2003            enabled: true,
2004            staleness_window_ms: 1_000,
2005            starve_ms: 3_000,
2006            max_starved_per_frame: 2,
2007            max_drop_fraction: 1.0,
2008            weight_priority: 1.0,
2009            weight_staleness: 0.5,
2010            weight_focus: 0.75,
2011            weight_interaction: 0.5,
2012            starve_boost: 1.5,
2013            min_cost_us: 1.0,
2014        }
2015    }
2016}
2017
2018/// Configuration for effect queue scheduling.
2019#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2020pub enum TaskExecutorBackend {
2021    /// Spawn one native thread per task and reap finished handles on the main loop.
2022    #[default]
2023    Spawned,
2024    /// Route tasks through the runtime's queueing scheduler.
2025    EffectQueue,
2026    /// Route blocking task closures through an Asupersync blocking pool.
2027    #[cfg(feature = "asupersync-executor")]
2028    Asupersync,
2029}
2030
2031#[derive(Debug, Clone)]
2032pub struct EffectQueueConfig {
2033    /// Whether effect queue scheduling is enabled.
2034    ///
2035    /// This legacy convenience flag is kept in sync with `backend`. New code
2036    /// should prefer `backend` for executor selection.
2037    pub enabled: bool,
2038    /// Which task executor backend to use for `Cmd::Task`.
2039    pub backend: TaskExecutorBackend,
2040    /// Scheduler configuration (Smith's rule by default).
2041    pub scheduler: SchedulerConfig,
2042    /// Maximum queue depth before backpressure kicks in (bd-2zd0a).
2043    ///
2044    /// When the queue depth exceeds this limit, new tasks are dropped with
2045    /// a `tracing::warn!` and the `effects_queue_dropped` counter increments.
2046    /// A value of `0` means unbounded (no backpressure).
2047    pub max_queue_depth: usize,
2048    /// Whether the backend selection was set explicitly by the caller.
2049    explicit_backend: bool,
2050}
2051
2052impl Default for EffectQueueConfig {
2053    fn default() -> Self {
2054        let scheduler = SchedulerConfig {
2055            smith_enabled: true,
2056            force_fifo: false,
2057            preemptive: false,
2058            aging_factor: 0.0,
2059            wait_starve_ms: 0.0,
2060            enable_logging: false,
2061            ..Default::default()
2062        };
2063        Self {
2064            enabled: false,
2065            backend: TaskExecutorBackend::Spawned,
2066            scheduler,
2067            max_queue_depth: 0,
2068            explicit_backend: false,
2069        }
2070    }
2071}
2072
2073impl EffectQueueConfig {
2074    /// Enable effect queue scheduling with the provided scheduler config.
2075    #[must_use]
2076    pub fn with_enabled(mut self, enabled: bool) -> Self {
2077        self.enabled = enabled;
2078        self.backend = if enabled {
2079            TaskExecutorBackend::EffectQueue
2080        } else {
2081            TaskExecutorBackend::Spawned
2082        };
2083        self.explicit_backend = true;
2084        self
2085    }
2086
2087    /// Select the task executor backend for `Cmd::Task`.
2088    #[must_use]
2089    pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2090        self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2091        self.backend = backend;
2092        self.explicit_backend = true;
2093        self
2094    }
2095
2096    /// Override the scheduler configuration.
2097    #[must_use]
2098    pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2099        self.scheduler = scheduler;
2100        self
2101    }
2102
2103    /// Set the maximum queue depth for backpressure (bd-2zd0a).
2104    ///
2105    /// When the queue depth exceeds this limit, new tasks are dropped.
2106    /// A value of `0` means unbounded (no backpressure, the default).
2107    #[must_use]
2108    pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2109        self.max_queue_depth = depth;
2110        self
2111    }
2112
2113    #[must_use]
2114    fn uses_legacy_default_backend(&self) -> bool {
2115        !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2116    }
2117}
2118
2119/// Immediate event-drain policy for the runtime main loop.
2120///
2121/// When a poll reports readiness, the runtime drains events by repeatedly
2122/// checking `poll_event(Duration::ZERO)` to avoid latency between buffered
2123/// inputs. This policy bounds that immediate-drain path so bursty workloads do
2124/// not devolve into zero-timeout spin storms.
2125#[derive(Debug, Clone)]
2126pub struct ImmediateDrainConfig {
2127    /// Maximum consecutive zero-timeout polls allowed in a single burst window.
2128    pub max_zero_timeout_polls_per_burst: usize,
2129    /// Maximum wall-clock time spent in a single immediate-drain burst window.
2130    pub max_burst_duration: Duration,
2131    /// Non-zero poll timeout used when the burst window budget is exhausted.
2132    pub backoff_timeout: Duration,
2133}
2134
2135impl Default for ImmediateDrainConfig {
2136    fn default() -> Self {
2137        Self {
2138            max_zero_timeout_polls_per_burst: 64,
2139            max_burst_duration: Duration::from_millis(2),
2140            backoff_timeout: Duration::from_millis(1),
2141        }
2142    }
2143}
2144
2145/// Runtime counters for immediate-drain behavior.
2146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2147pub struct ImmediateDrainStats {
2148    /// Number of event-drain bursts observed.
2149    pub bursts: u64,
2150    /// Total zero-timeout polls executed (`poll_event(Duration::ZERO)`).
2151    pub zero_timeout_polls: u64,
2152    /// Total non-zero backoff polls executed after exhausting burst budget.
2153    pub backoff_polls: u64,
2154    /// Number of bursts that hit the configured immediate-drain cap.
2155    pub capped_bursts: u64,
2156    /// Max number of zero-timeout polls seen in a single burst window.
2157    pub max_zero_timeout_polls_in_burst: u64,
2158}
2159
2160/// Runtime lane for the Asupersync migration rollout.
2161///
2162/// Controls which subscription/effect execution backend is active.
2163/// The default is `Structured`, reflecting the completed CancellationToken migration (bd-3tmu4).
2164///
2165/// # Migration rollout
2166///
2167/// 1. `Legacy` — pre-migration thread-based subscriptions with manual stop coordination
2168/// 2. `Structured` — CancellationToken-backed subscriptions (current default after bd-3tmu4)
2169/// 3. `Asupersync` — full Asupersync-native execution (future)
2170///
2171/// Selection is logged at startup so operators can tell which lane is active.
2172/// Fallback from `Asupersync` → `Structured` → `Legacy` is automatic on error.
2173#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2174pub enum RuntimeLane {
2175    /// Pre-migration behavior: thread-based subscriptions with manual stop coordination.
2176    /// This is the safe default that preserves all existing semantics.
2177    Legacy,
2178    /// Structured cancellation: subscriptions use CancellationToken internally.
2179    /// Externally observable behavior is identical to Legacy.
2180    #[default]
2181    Structured,
2182    /// Full Asupersync-native execution (reserved for future use).
2183    /// Falls back to Structured if Asupersync primitives are unavailable.
2184    Asupersync,
2185}
2186
2187impl RuntimeLane {
2188    /// Resolve the effective lane, applying fallback rules.
2189    ///
2190    /// If the requested lane is not yet implemented, falls back to the
2191    /// highest available lane. Currently: Asupersync → Structured.
2192    #[must_use]
2193    pub fn resolve(self) -> Self {
2194        match self {
2195            Self::Asupersync => {
2196                tracing::info!(
2197                    target: "ftui.runtime",
2198                    requested = "asupersync",
2199                    resolved = "structured",
2200                    "Asupersync lane not yet available; falling back to structured cancellation"
2201                );
2202                Self::Structured
2203            }
2204            other => other,
2205        }
2206    }
2207
2208    /// Returns a human-readable label for logging.
2209    #[must_use]
2210    pub fn label(self) -> &'static str {
2211        match self {
2212            Self::Legacy => "legacy",
2213            Self::Structured => "structured",
2214            Self::Asupersync => "asupersync",
2215        }
2216    }
2217
2218    /// Check if this lane uses structured cancellation (CancellationToken).
2219    #[must_use]
2220    pub fn uses_structured_cancellation(self) -> bool {
2221        matches!(self, Self::Structured | Self::Asupersync)
2222    }
2223
2224    /// Resolve the default task executor backend for this lane.
2225    #[must_use]
2226    fn task_executor_backend(self) -> TaskExecutorBackend {
2227        match self {
2228            Self::Legacy => TaskExecutorBackend::Spawned,
2229            Self::Structured => TaskExecutorBackend::EffectQueue,
2230            Self::Asupersync => {
2231                #[cfg(feature = "asupersync-executor")]
2232                {
2233                    TaskExecutorBackend::Asupersync
2234                }
2235                #[cfg(not(feature = "asupersync-executor"))]
2236                {
2237                    TaskExecutorBackend::EffectQueue
2238                }
2239            }
2240        }
2241    }
2242
2243    /// Read the lane from the `FTUI_RUNTIME_LANE` environment variable.
2244    ///
2245    /// Accepted values (case-insensitive): `legacy`, `structured`, `asupersync`.
2246    /// Returns `None` if the variable is unset or contains an unrecognized value.
2247    #[must_use]
2248    pub fn from_env() -> Option<Self> {
2249        let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2250        Self::parse(&val)
2251    }
2252
2253    /// Parse a lane name (case-insensitive).
2254    ///
2255    /// Returns `None` for unrecognized values.
2256    #[must_use]
2257    pub fn parse(s: &str) -> Option<Self> {
2258        match s.to_ascii_lowercase().as_str() {
2259            "legacy" => Some(Self::Legacy),
2260            "structured" => Some(Self::Structured),
2261            "asupersync" => Some(Self::Asupersync),
2262            _ => {
2263                tracing::warn!(
2264                    target: "ftui.runtime",
2265                    value = s,
2266                    "RuntimeLane::parse: unrecognized value"
2267                );
2268                None
2269            }
2270        }
2271    }
2272}
2273
2274/// Rollout policy for the Asupersync migration (bd-2crbt).
2275///
2276/// Controls how the runtime lane transition is managed:
2277///
2278/// - `Off` — use only the configured lane, no shadow comparison.
2279/// - `Shadow` — run both baseline and candidate lanes, compare outputs,
2280///   but use only the baseline lane for actual rendering. Evidence is emitted
2281///   to the configured JSONL sink for operator review.
2282/// - `Enabled` — use the candidate lane for rendering (requires prior shadow
2283///   evidence showing deterministic match).
2284///
2285/// The policy is logged at startup and can be overridden via the
2286/// `FTUI_ROLLOUT_POLICY` environment variable (`off`, `shadow`, `enabled`).
2287#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2288pub enum RolloutPolicy {
2289    /// No rollout activity — use the configured lane directly.
2290    #[default]
2291    Off,
2292    /// Shadow-run comparison mode: run both lanes, emit evidence, use baseline.
2293    Shadow,
2294    /// Candidate lane is live — requires prior shadow evidence.
2295    Enabled,
2296}
2297
2298impl RolloutPolicy {
2299    /// Read the policy from the `FTUI_ROLLOUT_POLICY` environment variable.
2300    ///
2301    /// Accepted values (case-insensitive): `off`, `shadow`, `enabled`.
2302    /// Returns `None` if unset or unrecognized.
2303    #[must_use]
2304    pub fn from_env() -> Option<Self> {
2305        let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2306        Self::parse(&val)
2307    }
2308
2309    /// Parse a rollout policy name (case-insensitive).
2310    ///
2311    /// Returns `None` for unrecognized values.
2312    #[must_use]
2313    pub fn parse(s: &str) -> Option<Self> {
2314        match s.to_ascii_lowercase().as_str() {
2315            "off" => Some(Self::Off),
2316            "shadow" => Some(Self::Shadow),
2317            "enabled" => Some(Self::Enabled),
2318            _ => {
2319                tracing::warn!(
2320                    target: "ftui.runtime",
2321                    value = s,
2322                    "RolloutPolicy::parse: unrecognized value"
2323                );
2324                None
2325            }
2326        }
2327    }
2328
2329    /// Returns a human-readable label for logging.
2330    #[must_use]
2331    pub fn label(self) -> &'static str {
2332        match self {
2333            Self::Off => "off",
2334            Self::Shadow => "shadow",
2335            Self::Enabled => "enabled",
2336        }
2337    }
2338
2339    /// Whether this policy involves shadow comparison.
2340    #[must_use]
2341    pub fn is_shadow(self) -> bool {
2342        matches!(self, Self::Shadow)
2343    }
2344}
2345
2346impl std::fmt::Display for RolloutPolicy {
2347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2348        f.write_str(self.label())
2349    }
2350}
2351
2352impl std::fmt::Display for RuntimeLane {
2353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2354        f.write_str(self.label())
2355    }
2356}
2357
2358/// Configuration for the program runtime.
2359#[derive(Debug, Clone)]
2360pub struct ProgramConfig {
2361    /// Screen mode (inline or alternate screen).
2362    pub screen_mode: ScreenMode,
2363    /// UI anchor for inline mode.
2364    pub ui_anchor: UiAnchor,
2365    /// Frame budget configuration.
2366    pub budget: FrameBudgetConfig,
2367    /// Diff strategy configuration for the terminal writer.
2368    pub diff_config: RuntimeDiffConfig,
2369    /// Evidence JSONL sink configuration.
2370    pub evidence_sink: EvidenceSinkConfig,
2371    /// Render-trace recorder configuration.
2372    pub render_trace: RenderTraceConfig,
2373    /// Optional frame timing sink.
2374    pub frame_timing: Option<FrameTimingConfig>,
2375    /// Conformal predictor configuration for frame-time risk gating.
2376    pub conformal_config: Option<ConformalConfig>,
2377    /// Locale context used for rendering.
2378    pub locale_context: LocaleContext,
2379    /// Input poll timeout.
2380    pub poll_timeout: Duration,
2381    /// Immediate event-drain policy for burst handling.
2382    pub immediate_drain: ImmediateDrainConfig,
2383    /// Resize coalescer configuration.
2384    pub resize_coalescer: CoalescerConfig,
2385    /// Resize handling behavior (immediate/throttled).
2386    pub resize_behavior: ResizeBehavior,
2387    /// Forced terminal size override (when set, resize events are ignored).
2388    pub forced_size: Option<(u16, u16)>,
2389    /// Mouse capture policy (`Auto`, `On`, `Off`).
2390    ///
2391    /// `Auto` is inline-safe: off in inline modes, on in alt-screen mode.
2392    pub mouse_capture_policy: MouseCapturePolicy,
2393    /// Enable bracketed paste.
2394    pub bracketed_paste: bool,
2395    /// Enable focus reporting.
2396    pub focus_reporting: bool,
2397    /// Enable Kitty keyboard protocol (repeat/release events).
2398    pub kitty_keyboard: bool,
2399    /// State persistence configuration.
2400    pub persistence: PersistenceConfig,
2401    /// Inline auto UI height remeasurement policy.
2402    pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2403    /// Widget refresh selection configuration.
2404    pub widget_refresh: WidgetRefreshConfig,
2405    /// Effect queue scheduling configuration.
2406    pub effect_queue: EffectQueueConfig,
2407    /// Frame guardrails configuration (memory + queue safety limits).
2408    pub guardrails: GuardrailsConfig,
2409    /// Install signal handlers for cleanup on SIGINT/SIGTERM/SIGHUP.
2410    ///
2411    /// Defaults to `true` for application safety. Set to `false` in tests or
2412    /// when the embedding application manages signals.
2413    pub intercept_signals: bool,
2414    /// Optional tick strategy for selective background screen ticking.
2415    ///
2416    /// When `None` (default), all screens tick every frame (current behavior).
2417    /// When set, the runtime consults the strategy for each inactive screen.
2418    pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2419    /// Runtime execution lane for the Asupersync migration rollout.
2420    ///
2421    /// Controls which subscription/effect backend is active.
2422    /// Defaults to `Structured` (CancellationToken-backed, current migration state).
2423    /// Logged at startup so operators can identify the active lane.
2424    pub runtime_lane: RuntimeLane,
2425    /// Rollout policy for the Asupersync migration (bd-2crbt).
2426    ///
2427    /// Controls whether shadow-run comparison is active during this session.
2428    /// When `Shadow`, both the baseline and candidate lanes run in parallel
2429    /// and evidence is emitted; rendering uses the baseline lane only.
2430    pub rollout_policy: RolloutPolicy,
2431}
2432
2433impl Default for ProgramConfig {
2434    fn default() -> Self {
2435        Self {
2436            screen_mode: ScreenMode::Inline { ui_height: 4 },
2437            ui_anchor: UiAnchor::Bottom,
2438            budget: FrameBudgetConfig::default(),
2439            diff_config: RuntimeDiffConfig::default(),
2440            evidence_sink: EvidenceSinkConfig::default(),
2441            render_trace: RenderTraceConfig::default(),
2442            frame_timing: None,
2443            conformal_config: None,
2444            locale_context: LocaleContext::global(),
2445            poll_timeout: Duration::from_millis(100),
2446            immediate_drain: ImmediateDrainConfig::default(),
2447            resize_coalescer: CoalescerConfig::default(),
2448            resize_behavior: ResizeBehavior::Throttled,
2449            forced_size: None,
2450            mouse_capture_policy: MouseCapturePolicy::Auto,
2451            bracketed_paste: true,
2452            focus_reporting: false,
2453            kitty_keyboard: false,
2454            persistence: PersistenceConfig::default(),
2455            inline_auto_remeasure: None,
2456            widget_refresh: WidgetRefreshConfig::default(),
2457            effect_queue: EffectQueueConfig::default(),
2458            guardrails: GuardrailsConfig::default(),
2459            intercept_signals: true,
2460            tick_strategy: None,
2461            runtime_lane: RuntimeLane::default(),
2462            rollout_policy: RolloutPolicy::default(),
2463        }
2464    }
2465}
2466
2467impl ProgramConfig {
2468    /// Create config for fullscreen applications.
2469    pub fn fullscreen() -> Self {
2470        Self {
2471            screen_mode: ScreenMode::AltScreen,
2472            ..Default::default()
2473        }
2474    }
2475
2476    /// Create config for inline mode with specified height.
2477    pub fn inline(height: u16) -> Self {
2478        Self {
2479            screen_mode: ScreenMode::Inline { ui_height: height },
2480            ..Default::default()
2481        }
2482    }
2483
2484    /// Create config for inline mode with automatic UI height.
2485    pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
2486        Self {
2487            screen_mode: ScreenMode::InlineAuto {
2488                min_height,
2489                max_height,
2490            },
2491            inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
2492            ..Default::default()
2493        }
2494    }
2495
2496    /// Enable mouse support.
2497    #[must_use]
2498    pub fn with_mouse(mut self) -> Self {
2499        self.mouse_capture_policy = MouseCapturePolicy::On;
2500        self
2501    }
2502
2503    /// Set mouse capture policy.
2504    #[must_use]
2505    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
2506        self.mouse_capture_policy = policy;
2507        self
2508    }
2509
2510    /// Force mouse capture enabled/disabled regardless of screen mode.
2511    #[must_use]
2512    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
2513        self.mouse_capture_policy = if enabled {
2514            MouseCapturePolicy::On
2515        } else {
2516            MouseCapturePolicy::Off
2517        };
2518        self
2519    }
2520
2521    /// Resolve mouse capture using the configured policy and screen mode.
2522    #[must_use]
2523    pub const fn resolved_mouse_capture(&self) -> bool {
2524        self.mouse_capture_policy.resolve(self.screen_mode)
2525    }
2526
2527    /// Set the budget configuration.
2528    #[must_use]
2529    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2530        self.budget = budget;
2531        self
2532    }
2533
2534    /// Set the diff strategy configuration for the terminal writer.
2535    #[must_use]
2536    pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
2537        self.diff_config = diff_config;
2538        self
2539    }
2540
2541    /// Set the evidence JSONL sink configuration.
2542    #[must_use]
2543    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2544        self.evidence_sink = config;
2545        self
2546    }
2547
2548    /// Set the render-trace recorder configuration.
2549    #[must_use]
2550    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2551        self.render_trace = config;
2552        self
2553    }
2554
2555    /// Set a frame timing sink for per-frame profiling.
2556    #[must_use]
2557    pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
2558        self.frame_timing = Some(config);
2559        self
2560    }
2561
2562    /// Enable conformal frame-time risk gating with the given config.
2563    #[must_use]
2564    pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
2565        self.conformal_config = Some(config);
2566        self
2567    }
2568
2569    /// Disable conformal frame-time risk gating.
2570    #[must_use]
2571    pub fn without_conformal(mut self) -> Self {
2572        self.conformal_config = None;
2573        self
2574    }
2575
2576    /// Set the locale context used for rendering.
2577    #[must_use]
2578    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2579        self.locale_context = locale_context;
2580        self
2581    }
2582
2583    /// Set the base locale used for rendering.
2584    #[must_use]
2585    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2586        self.locale_context = LocaleContext::new(locale);
2587        self
2588    }
2589
2590    /// Set the widget refresh selection configuration.
2591    #[must_use]
2592    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2593        self.widget_refresh = config;
2594        self
2595    }
2596
2597    /// Set the effect queue scheduling configuration.
2598    #[must_use]
2599    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2600        self.effect_queue = config;
2601        self
2602    }
2603
2604    /// Set the resize coalescer configuration.
2605    #[must_use]
2606    pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2607        self.resize_coalescer = config;
2608        self
2609    }
2610
2611    /// Set the resize handling behavior.
2612    #[must_use]
2613    pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2614        self.resize_behavior = behavior;
2615        self
2616    }
2617
2618    /// Force a fixed terminal size (cols, rows). Resize events are ignored.
2619    #[must_use]
2620    pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
2621        let width = width.max(1);
2622        let height = height.max(1);
2623        self.forced_size = Some((width, height));
2624        self
2625    }
2626
2627    /// Clear any forced terminal size override.
2628    #[must_use]
2629    pub fn without_forced_size(mut self) -> Self {
2630        self.forced_size = None;
2631        self
2632    }
2633
2634    /// Toggle legacy immediate-resize behavior for migration.
2635    #[must_use]
2636    pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
2637        if enabled {
2638            self.resize_behavior = ResizeBehavior::Immediate;
2639        }
2640        self
2641    }
2642
2643    /// Set the persistence configuration.
2644    #[must_use]
2645    pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
2646        self.persistence = persistence;
2647        self
2648    }
2649
2650    /// Enable persistence with the given registry.
2651    #[must_use]
2652    pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
2653        self.persistence = PersistenceConfig::with_registry(registry);
2654        self
2655    }
2656
2657    /// Enable inline auto UI height remeasurement with the given policy.
2658    #[must_use]
2659    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2660        self.inline_auto_remeasure = Some(config);
2661        self
2662    }
2663
2664    /// Disable inline auto UI height remeasurement.
2665    #[must_use]
2666    pub fn without_inline_auto_remeasure(mut self) -> Self {
2667        self.inline_auto_remeasure = None;
2668        self
2669    }
2670
2671    /// Enable or disable signal interception (SIGHUP/SIGTERM/SIGINT) for cleanup.
2672    #[must_use]
2673    pub fn with_signal_interception(mut self, enabled: bool) -> Self {
2674        self.intercept_signals = enabled;
2675        self
2676    }
2677
2678    /// Set frame guardrails configuration.
2679    #[must_use]
2680    pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
2681        self.guardrails = config;
2682        self
2683    }
2684
2685    /// Set the immediate event-drain policy for burst handling.
2686    #[must_use]
2687    pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
2688        self.immediate_drain = config;
2689        self
2690    }
2691
2692    /// Set the tick strategy for selective background screen ticking.
2693    ///
2694    /// When set, the runtime consults the strategy to decide which inactive
2695    /// screens should tick on each frame. Without a strategy, all screens
2696    /// tick every frame (backwards-compatible default).
2697    ///
2698    /// ```ignore
2699    /// ProgramConfig::default()
2700    ///     .with_tick_strategy(TickStrategyKind::Uniform { divisor: 5 })
2701    /// ```
2702    #[must_use]
2703    pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
2704        self.tick_strategy = Some(strategy);
2705        self
2706    }
2707
2708    /// Set the runtime execution lane.
2709    #[must_use]
2710    pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
2711        self.runtime_lane = lane;
2712        self
2713    }
2714
2715    /// Set the rollout policy for the Asupersync migration.
2716    #[must_use]
2717    pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
2718        self.rollout_policy = policy;
2719        self
2720    }
2721
2722    /// Apply environment-variable overrides for lane and rollout policy.
2723    ///
2724    /// Reads `FTUI_RUNTIME_LANE` and `FTUI_ROLLOUT_POLICY`. Unset variables
2725    /// are ignored. Unrecognized values emit a `tracing::warn` and are
2726    /// ignored (the programmatic default or prior builder value is retained).
2727    #[must_use]
2728    pub fn with_env_overrides(mut self) -> Self {
2729        if let Some(lane) = RuntimeLane::from_env() {
2730            self.runtime_lane = lane;
2731        }
2732        if let Some(policy) = RolloutPolicy::from_env() {
2733            self.rollout_policy = policy;
2734        }
2735        self
2736    }
2737
2738    #[must_use]
2739    fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
2740        if !self.effect_queue.uses_legacy_default_backend() {
2741            return self.effect_queue.clone();
2742        }
2743
2744        self.effect_queue
2745            .clone()
2746            .with_backend(self.runtime_lane.resolve().task_executor_backend())
2747    }
2748}
2749
2750enum EffectCommand<M> {
2751    Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
2752    Shutdown,
2753}
2754
2755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2756enum EffectLoopControl {
2757    Continue,
2758    ShutdownRequested,
2759}
2760
2761struct EffectQueue<M: Send + 'static> {
2762    sender: mpsc::Sender<EffectCommand<M>>,
2763    handle: Option<JoinHandle<()>>,
2764    closed: bool,
2765}
2766
2767impl<M: Send + 'static> EffectQueue<M> {
2768    fn start(
2769        config: EffectQueueConfig,
2770        result_sender: mpsc::Sender<M>,
2771        evidence_sink: Option<EvidenceSink>,
2772    ) -> io::Result<Self> {
2773        let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
2774        let handle = thread::Builder::new()
2775            .name("ftui-effects".into())
2776            .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
2777
2778        Ok(Self {
2779            sender: tx,
2780            handle: Some(handle),
2781            closed: false,
2782        })
2783    }
2784
2785    fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
2786        if self.closed {
2787            crate::effect_system::record_queue_drop("post_shutdown");
2788            tracing::debug!("rejecting task enqueue after effect queue shutdown");
2789            return;
2790        }
2791        if self
2792            .sender
2793            .send(EffectCommand::Enqueue(spec, task))
2794            .is_err()
2795        {
2796            crate::effect_system::record_queue_drop("channel_closed");
2797        }
2798    }
2799
2800    /// Timeout for the effect-queue thread to finish after sending Shutdown.
2801    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
2802    /// Poll interval when waiting for the effect-queue thread (bd-170o5).
2803    ///
2804    /// This sleep-poll pattern is the idiomatic Rust approach for bounded
2805    /// thread joins — `JoinHandle` has no `join_timeout` in stable Rust.
2806    /// 1ms is chosen to minimize shutdown latency while avoiding spin.
2807    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
2808
2809    fn shutdown(&mut self) {
2810        self.closed = true;
2811        let _ = self.sender.send(EffectCommand::Shutdown);
2812        if let Some(handle) = self.handle.take() {
2813            let start = Instant::now();
2814            // Fast path: most shutdowns complete nearly instantly after the
2815            // Shutdown command is drained. Check once before entering poll loop.
2816            if handle.is_finished() {
2817                let _ = handle.join();
2818                let elapsed_us = start.elapsed().as_micros() as u64;
2819                tracing::debug!(
2820                    target: "ftui.runtime",
2821                    elapsed_us,
2822                    "effect-queue shutdown (fast path)"
2823                );
2824                return;
2825            }
2826            // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
2827            while !handle.is_finished() {
2828                if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
2829                    tracing::warn!(
2830                        target: "ftui.runtime",
2831                        timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
2832                        "effect-queue thread did not stop within timeout; detaching"
2833                    );
2834                    return;
2835                }
2836                thread::sleep(Self::SHUTDOWN_POLL);
2837            }
2838            let _ = handle.join();
2839            let elapsed_us = start.elapsed().as_micros() as u64;
2840            tracing::debug!(
2841                target: "ftui.runtime",
2842                elapsed_us,
2843                "effect-queue shutdown (slow path)"
2844            );
2845        }
2846    }
2847}
2848
2849impl<M: Send + 'static> Drop for EffectQueue<M> {
2850    fn drop(&mut self) {
2851        self.shutdown();
2852    }
2853}
2854
2855struct SpawnTaskExecutor<M: Send + 'static> {
2856    result_sender: mpsc::Sender<M>,
2857    evidence_sink: Option<EvidenceSink>,
2858    handles: Vec<JoinHandle<()>>,
2859    closed: bool,
2860}
2861
2862impl<M: Send + 'static> SpawnTaskExecutor<M> {
2863    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
2864    /// Poll interval for bounded thread joins (bd-170o5).
2865    ///
2866    /// Same rationale as `EffectQueue::SHUTDOWN_POLL` — `JoinHandle` has no
2867    /// `join_timeout` in stable Rust, so we poll `is_finished()` with a
2868    /// 1ms sleep to minimize shutdown latency while avoiding spin.
2869    const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
2870
2871    fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
2872        Self {
2873            result_sender,
2874            evidence_sink,
2875            handles: Vec::new(),
2876            closed: false,
2877        }
2878    }
2879
2880    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
2881        if self.closed {
2882            tracing::debug!("rejecting spawned task submit after shutdown");
2883            return;
2884        }
2885        let sender = self.result_sender.clone();
2886        let evidence_sink = self.evidence_sink.clone();
2887        let handle = thread::spawn(move || {
2888            let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
2889        });
2890        self.handles.push(handle);
2891    }
2892
2893    fn reap_finished(&mut self) {
2894        if self.handles.is_empty() {
2895            return;
2896        }
2897
2898        let mut i = 0;
2899        while i < self.handles.len() {
2900            if self.handles[i].is_finished() {
2901                let handle = self.handles.swap_remove(i);
2902                let _ = handle.join();
2903            } else {
2904                i += 1;
2905            }
2906        }
2907    }
2908
2909    fn shutdown(&mut self) {
2910        self.closed = true;
2911        let start = Instant::now();
2912        // Fast path: reap any already-finished handles first.
2913        self.reap_finished();
2914        if self.handles.is_empty() {
2915            let elapsed_us = start.elapsed().as_micros() as u64;
2916            tracing::debug!(
2917                target: "ftui.runtime",
2918                elapsed_us,
2919                "spawn-executor shutdown (fast path, all tasks already finished)"
2920            );
2921            return;
2922        }
2923        // Slow path: bounded poll loop for in-flight tasks (bd-170o5).
2924        let pending_at_start = self.handles.len();
2925        while self.handles.iter().any(|handle| !handle.is_finished()) {
2926            if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
2927                let still_pending = self
2928                    .handles
2929                    .iter()
2930                    .filter(|handle| !handle.is_finished())
2931                    .count();
2932                tracing::warn!(
2933                    target: "ftui.runtime",
2934                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
2935                    pending_handles = still_pending,
2936                    "background task threads did not stop within timeout; detaching"
2937                );
2938                self.handles.clear();
2939                return;
2940            }
2941            thread::sleep(Self::SHUTDOWN_POLL);
2942        }
2943        self.reap_finished();
2944        let elapsed_us = start.elapsed().as_micros() as u64;
2945        tracing::debug!(
2946            target: "ftui.runtime",
2947            elapsed_us,
2948            pending_at_start,
2949            "spawn-executor shutdown (slow path)"
2950        );
2951    }
2952}
2953
2954#[cfg(feature = "asupersync-executor")]
2955struct AsupersyncTaskExecutor<M: Send + 'static> {
2956    result_sender: mpsc::Sender<M>,
2957    evidence_sink: Option<EvidenceSink>,
2958    runtime: AsupersyncRuntime,
2959    handles: Vec<BlockingTaskHandle>,
2960    closed: bool,
2961}
2962
2963#[cfg(feature = "asupersync-executor")]
2964impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
2965    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
2966
2967    fn new(
2968        result_sender: mpsc::Sender<M>,
2969        evidence_sink: Option<EvidenceSink>,
2970    ) -> io::Result<Self> {
2971        let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
2972        let runtime = RuntimeBuilder::new()
2973            .blocking_threads(1, max_threads)
2974            .thread_name_prefix("ftui-asupersync-task")
2975            .build()
2976            .map_err(|error| {
2977                io::Error::other(format!("asupersync runtime init failed: {error}"))
2978            })?;
2979
2980        Ok(Self {
2981            result_sender,
2982            evidence_sink,
2983            runtime,
2984            handles: Vec::new(),
2985            closed: false,
2986        })
2987    }
2988
2989    fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
2990        if self.closed {
2991            tracing::debug!("rejecting asupersync task submit after shutdown");
2992            return;
2993        }
2994        let sender = self.result_sender.clone();
2995        let evidence_sink = self.evidence_sink.clone();
2996        let handle = self
2997            .runtime
2998            .spawn_blocking(move || {
2999                let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3000            })
3001            .expect("asupersync blocking pool must be configured");
3002        self.handles.push(handle);
3003    }
3004
3005    fn reap_finished(&mut self) {
3006        self.handles.retain(|handle| !handle.is_done());
3007    }
3008
3009    fn shutdown(&mut self) {
3010        self.closed = true;
3011        let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3012        for handle in &self.handles {
3013            let remaining = deadline.saturating_duration_since(Instant::now());
3014            if remaining.is_zero() || !handle.wait_timeout(remaining) {
3015                tracing::warn!(
3016                    timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3017                    pending_handles = self
3018                        .handles
3019                        .iter()
3020                        .filter(|pending| !pending.is_done())
3021                        .count(),
3022                    "Asupersync blocking tasks did not stop within timeout; detaching"
3023                );
3024                self.handles.clear();
3025                return;
3026            }
3027        }
3028        self.handles.clear();
3029    }
3030}
3031
3032enum TaskExecutor<M: Send + 'static> {
3033    Spawned(SpawnTaskExecutor<M>),
3034    Queued(EffectQueue<M>),
3035    #[cfg(feature = "asupersync-executor")]
3036    Asupersync(AsupersyncTaskExecutor<M>),
3037}
3038
3039impl<M: Send + 'static> TaskExecutor<M> {
3040    fn new(
3041        config: &EffectQueueConfig,
3042        result_sender: mpsc::Sender<M>,
3043        evidence_sink: Option<EvidenceSink>,
3044    ) -> io::Result<Self> {
3045        let executor = match config.backend {
3046            TaskExecutorBackend::Spawned => {
3047                Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3048            }
3049            TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3050                config.clone(),
3051                result_sender,
3052                evidence_sink.clone(),
3053            )?),
3054            #[cfg(feature = "asupersync-executor")]
3055            TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3056                result_sender,
3057                evidence_sink.clone(),
3058            )?),
3059        };
3060
3061        emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3062        Ok(executor)
3063    }
3064
3065    fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3066        match self {
3067            Self::Spawned(executor) => executor.submit(task),
3068            Self::Queued(queue) => queue.enqueue(spec, task),
3069            #[cfg(feature = "asupersync-executor")]
3070            Self::Asupersync(executor) => executor.submit(task),
3071        }
3072    }
3073
3074    fn reap_finished(&mut self) {
3075        match self {
3076            Self::Spawned(executor) => executor.reap_finished(),
3077            #[cfg(feature = "asupersync-executor")]
3078            Self::Asupersync(executor) => executor.reap_finished(),
3079            Self::Queued(_) => {}
3080        }
3081    }
3082
3083    fn shutdown(&mut self) {
3084        match self {
3085            Self::Spawned(executor) => executor.shutdown(),
3086            Self::Queued(queue) => queue.shutdown(),
3087            #[cfg(feature = "asupersync-executor")]
3088            Self::Asupersync(executor) => executor.shutdown(),
3089        }
3090    }
3091
3092    #[cfg(test)]
3093    fn kind_name(&self) -> &'static str {
3094        self.kind_name_for_logs()
3095    }
3096
3097    fn kind_name_for_logs(&self) -> &'static str {
3098        match self {
3099            Self::Spawned(_) => "spawned",
3100            Self::Queued(_) => "queued",
3101            #[cfg(feature = "asupersync-executor")]
3102            Self::Asupersync(_) => "asupersync",
3103        }
3104    }
3105}
3106
3107fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3108    let Some(sink) = sink else {
3109        return;
3110    };
3111    let _ = sink.write_jsonl(&format!(
3112        r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3113    ));
3114}
3115
3116fn emit_task_executor_completion_evidence(
3117    sink: Option<&EvidenceSink>,
3118    backend: &str,
3119    duration_us: u64,
3120) {
3121    let Some(sink) = sink else {
3122        return;
3123    };
3124    let _ = sink.write_jsonl(&format!(
3125        r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3126    ));
3127}
3128
3129fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3130    let Some(sink) = sink else {
3131        return;
3132    };
3133    let escaped = panic_msg
3134        .replace('\\', "\\\\")
3135        .replace('"', "\\\"")
3136        .replace('\n', "\\n")
3137        .replace('\r', "\\r")
3138        .replace('\t', "\\t");
3139    let _ = sink.write_jsonl(&format!(
3140        r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3141    ));
3142}
3143
3144fn emit_task_executor_backpressure_evidence(
3145    sink: Option<&EvidenceSink>,
3146    backend: &str,
3147    action: &str,
3148    queue_length: usize,
3149    max_queue_size: usize,
3150    total_rejected: u64,
3151) {
3152    let Some(sink) = sink else {
3153        return;
3154    };
3155    let _ = sink.write_jsonl(&format!(
3156        r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3157    ));
3158}
3159
3160fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3161    if let Some(s) = payload.downcast_ref::<&str>() {
3162        (*s).to_owned()
3163    } else if let Some(s) = payload.downcast_ref::<String>() {
3164        s.clone()
3165    } else {
3166        "unknown panic payload".to_owned()
3167    }
3168}
3169
3170fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3171    #[cfg(feature = "tracing")]
3172    tracing::error!(
3173        executor_backend = backend,
3174        panic_msg,
3175        "task executor task panicked"
3176    );
3177    #[cfg(not(feature = "tracing"))]
3178    eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3179}
3180
3181fn run_task_closure<M: Send + 'static>(
3182    task: Box<dyn FnOnce() -> M + Send>,
3183    backend: &str,
3184    evidence_sink: Option<&EvidenceSink>,
3185    result_sender: &mpsc::Sender<M>,
3186) -> bool {
3187    let start = Instant::now();
3188    match panic::catch_unwind(AssertUnwindSafe(task)) {
3189        Ok(msg) => {
3190            let duration_us = start.elapsed().as_micros() as u64;
3191            tracing::debug!(
3192                target: "ftui.effect",
3193                command_type = "task",
3194                executor_backend = backend,
3195                duration_us = duration_us,
3196                effect_duration_us = duration_us,
3197                "task effect completed"
3198            );
3199            emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3200            let _ = result_sender.send(msg);
3201            true
3202        }
3203        Err(payload) => {
3204            let panic_msg = panic_payload_message(payload);
3205            log_task_executor_panic(backend, &panic_msg);
3206            emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3207            false
3208        }
3209    }
3210}
3211
3212fn effect_queue_loop<M: Send + 'static>(
3213    config: EffectQueueConfig,
3214    rx: mpsc::Receiver<EffectCommand<M>>,
3215    result_sender: mpsc::Sender<M>,
3216    evidence_sink: Option<EvidenceSink>,
3217) {
3218    let mut scheduler = QueueingScheduler::new(config.scheduler);
3219    let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3220    let mut shutdown_requested = false;
3221    let max_depth = config.max_queue_depth;
3222
3223    loop {
3224        if tasks.is_empty() {
3225            if shutdown_requested {
3226                return;
3227            }
3228            match rx.recv() {
3229                Ok(cmd) => {
3230                    if matches!(
3231                        handle_effect_command(
3232                            cmd,
3233                            &mut scheduler,
3234                            &mut tasks,
3235                            &result_sender,
3236                            evidence_sink.as_ref(),
3237                            max_depth,
3238                        ),
3239                        EffectLoopControl::ShutdownRequested
3240                    ) {
3241                        shutdown_requested = true;
3242                    }
3243                }
3244                Err(_) => return,
3245            }
3246        }
3247
3248        while let Ok(cmd) = rx.try_recv() {
3249            if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3250                crate::effect_system::record_queue_drop("post_shutdown");
3251                continue;
3252            }
3253            if matches!(
3254                handle_effect_command(
3255                    cmd,
3256                    &mut scheduler,
3257                    &mut tasks,
3258                    &result_sender,
3259                    evidence_sink.as_ref(),
3260                    max_depth,
3261                ),
3262                EffectLoopControl::ShutdownRequested
3263            ) {
3264                shutdown_requested = true;
3265            }
3266        }
3267
3268        if tasks.is_empty() {
3269            if shutdown_requested {
3270                return;
3271            }
3272            continue;
3273        }
3274
3275        let Some(job) = scheduler.peek_next().cloned() else {
3276            continue;
3277        };
3278
3279        if let Some(ref sink) = evidence_sink {
3280            let evidence = scheduler.evidence();
3281            let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3282        }
3283
3284        let completed = scheduler.tick(job.remaining_time);
3285        for job_id in completed {
3286            if let Some(task) = tasks.remove(&job_id) {
3287                let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3288                crate::effect_system::record_queue_processed();
3289            }
3290        }
3291    }
3292}
3293
3294fn handle_effect_command<M: Send + 'static>(
3295    cmd: EffectCommand<M>,
3296    scheduler: &mut QueueingScheduler,
3297    tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3298    result_sender: &mpsc::Sender<M>,
3299    evidence_sink: Option<&EvidenceSink>,
3300    max_depth: usize,
3301) -> EffectLoopControl {
3302    match cmd {
3303        EffectCommand::Enqueue(spec, task) => {
3304            // Backpressure: drop task if queue depth exceeds limit (bd-2zd0a)
3305            if max_depth > 0 && tasks.len() >= max_depth {
3306                crate::effect_system::record_queue_drop("backpressure");
3307                return EffectLoopControl::Continue;
3308            }
3309            let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3310                WeightSource::Default
3311            } else {
3312                WeightSource::Explicit
3313            };
3314            let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3315                EstimateSource::Default
3316            } else {
3317                EstimateSource::Explicit
3318            };
3319            let id = scheduler.submit_with_sources(
3320                spec.weight,
3321                spec.estimate_ms,
3322                weight_source,
3323                estimate_source,
3324                spec.name,
3325            );
3326            if let Some(id) = id {
3327                tasks.insert(id, task);
3328                crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3329            } else {
3330                let stats = scheduler.stats();
3331                emit_task_executor_backpressure_evidence(
3332                    evidence_sink,
3333                    "queued",
3334                    "inline_fallback",
3335                    stats.queue_length,
3336                    scheduler.max_queue_size(),
3337                    stats.total_rejected,
3338                );
3339                let _ =
3340                    run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3341            }
3342            EffectLoopControl::Continue
3343        }
3344        EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3345    }
3346}
3347
3348// removed: legacy ResizeDebouncer (superseded by ResizeCoalescer)
3349
3350/// Policy for remeasuring inline auto UI height.
3351///
3352/// Uses VOI (value-of-information) sampling to decide when to perform
3353/// a costly full-height measurement, with any-time valid guarantees via
3354/// the embedded e-process in `VoiSampler`.
3355#[derive(Debug, Clone)]
3356pub struct InlineAutoRemeasureConfig {
3357    /// VOI sampling configuration.
3358    pub voi: VoiConfig,
3359    /// Minimum row delta to count as a "violation".
3360    pub change_threshold_rows: u16,
3361}
3362
3363impl Default for InlineAutoRemeasureConfig {
3364    fn default() -> Self {
3365        Self {
3366            voi: VoiConfig {
3367                // Height changes are expected to be rare; bias toward fewer samples.
3368                prior_alpha: 1.0,
3369                prior_beta: 9.0,
3370                // Allow ~1s max latency to adapt to growth/shrink.
3371                max_interval_ms: 1000,
3372                // Avoid over-sampling in high-FPS loops.
3373                min_interval_ms: 100,
3374                // Disable event forcing; use time-based gating.
3375                max_interval_events: 0,
3376                min_interval_events: 0,
3377                // Treat sampling as moderately expensive.
3378                sample_cost: 0.08,
3379                ..VoiConfig::default()
3380            },
3381            change_threshold_rows: 1,
3382        }
3383    }
3384}
3385
3386#[derive(Debug)]
3387struct InlineAutoRemeasureState {
3388    config: InlineAutoRemeasureConfig,
3389    sampler: VoiSampler,
3390}
3391
3392impl InlineAutoRemeasureState {
3393    fn new(config: InlineAutoRemeasureConfig) -> Self {
3394        let sampler = VoiSampler::new(config.voi.clone());
3395        Self { config, sampler }
3396    }
3397
3398    fn reset(&mut self) {
3399        self.sampler = VoiSampler::new(self.config.voi.clone());
3400    }
3401}
3402
3403#[derive(Debug, Clone)]
3404struct ConformalEvidence {
3405    bucket_key: String,
3406    n_b: usize,
3407    alpha: f64,
3408    q_b: f64,
3409    y_hat: f64,
3410    upper_us: f64,
3411    risk: bool,
3412    fallback_level: u8,
3413    window_size: usize,
3414    reset_count: u64,
3415}
3416
3417impl ConformalEvidence {
3418    fn from_prediction(prediction: &ConformalPrediction) -> Self {
3419        let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
3420        Self {
3421            bucket_key: prediction.bucket.to_string(),
3422            n_b: prediction.sample_count,
3423            alpha,
3424            q_b: prediction.quantile,
3425            y_hat: prediction.y_hat,
3426            upper_us: prediction.upper_us,
3427            risk: prediction.risk,
3428            fallback_level: prediction.fallback_level,
3429            window_size: prediction.window_size,
3430            reset_count: prediction.reset_count,
3431        }
3432    }
3433}
3434
3435#[derive(Debug, Clone)]
3436struct BudgetDecisionEvidence {
3437    frame_idx: u64,
3438    decision: BudgetDecision,
3439    controller_decision: BudgetDecision,
3440    degradation_before: DegradationLevel,
3441    degradation_after: DegradationLevel,
3442    frame_time_us: f64,
3443    budget_us: f64,
3444    pid_output: f64,
3445    pid_p: f64,
3446    pid_i: f64,
3447    pid_d: f64,
3448    e_value: f64,
3449    frames_observed: u32,
3450    frames_since_change: u32,
3451    in_warmup: bool,
3452    conformal: Option<ConformalEvidence>,
3453}
3454
3455impl BudgetDecisionEvidence {
3456    fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
3457        if after > before {
3458            BudgetDecision::Degrade
3459        } else if after < before {
3460            BudgetDecision::Upgrade
3461        } else {
3462            BudgetDecision::Hold
3463        }
3464    }
3465
3466    #[must_use]
3467    fn to_jsonl(&self) -> String {
3468        let conformal = self.conformal.as_ref();
3469        let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
3470        let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
3471        let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
3472        let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
3473        let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
3474        let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
3475        let risk = Self::opt_bool(conformal.map(|c| c.risk));
3476        let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
3477        let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
3478        let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
3479
3480        format!(
3481            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":{}}}"#,
3482            self.frame_idx,
3483            self.decision.as_str(),
3484            self.controller_decision.as_str(),
3485            self.degradation_before.as_str(),
3486            self.degradation_after.as_str(),
3487            self.frame_time_us,
3488            self.budget_us,
3489            self.pid_output,
3490            self.pid_p,
3491            self.pid_i,
3492            self.pid_d,
3493            self.e_value,
3494            self.frames_observed,
3495            self.frames_since_change,
3496            self.in_warmup,
3497            bucket_key,
3498            n_b,
3499            alpha,
3500            q_b,
3501            y_hat,
3502            upper_us,
3503            risk,
3504            fallback_level,
3505            window_size,
3506            reset_count
3507        )
3508    }
3509
3510    fn opt_f64(value: Option<f64>) -> String {
3511        value
3512            .map(|v| format!("{v:.6}"))
3513            .unwrap_or_else(|| "null".to_string())
3514    }
3515
3516    fn opt_u64(value: Option<u64>) -> String {
3517        value
3518            .map(|v| v.to_string())
3519            .unwrap_or_else(|| "null".to_string())
3520    }
3521
3522    fn opt_u8(value: Option<u8>) -> String {
3523        value
3524            .map(|v| v.to_string())
3525            .unwrap_or_else(|| "null".to_string())
3526    }
3527
3528    fn opt_usize(value: Option<usize>) -> String {
3529        value
3530            .map(|v| v.to_string())
3531            .unwrap_or_else(|| "null".to_string())
3532    }
3533
3534    fn opt_bool(value: Option<bool>) -> String {
3535        value
3536            .map(|v| v.to_string())
3537            .unwrap_or_else(|| "null".to_string())
3538    }
3539
3540    fn opt_str(value: Option<&str>) -> String {
3541        value
3542            .map(|v| {
3543                format!(
3544                    "\"{}\"",
3545                    v.replace('\\', "\\\\")
3546                        .replace('"', "\\\"")
3547                        .replace('\n', "\\n")
3548                        .replace('\r', "\\r")
3549                        .replace('\t', "\\t")
3550                )
3551            })
3552            .unwrap_or_else(|| "null".to_string())
3553    }
3554}
3555
3556#[derive(Debug, Clone)]
3557struct FairnessConfigEvidence {
3558    enabled: bool,
3559    input_priority_threshold_ms: u64,
3560    dominance_threshold: u32,
3561    fairness_threshold: f64,
3562}
3563
3564impl FairnessConfigEvidence {
3565    #[must_use]
3566    fn to_jsonl(&self) -> String {
3567        format!(
3568            r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
3569            self.enabled,
3570            self.input_priority_threshold_ms,
3571            self.dominance_threshold,
3572            self.fairness_threshold
3573        )
3574    }
3575}
3576
3577#[derive(Debug, Clone)]
3578struct FairnessDecisionEvidence {
3579    frame_idx: u64,
3580    decision: &'static str,
3581    reason: &'static str,
3582    pending_input_latency_ms: Option<u64>,
3583    jain_index: f64,
3584    resize_dominance_count: u32,
3585    dominance_threshold: u32,
3586    fairness_threshold: f64,
3587    input_priority_threshold_ms: u64,
3588}
3589
3590impl FairnessDecisionEvidence {
3591    #[must_use]
3592    fn to_jsonl(&self) -> String {
3593        let pending_latency = self
3594            .pending_input_latency_ms
3595            .map(|v| v.to_string())
3596            .unwrap_or_else(|| "null".to_string());
3597        format!(
3598            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":{}}}"#,
3599            self.frame_idx,
3600            self.decision,
3601            self.reason,
3602            pending_latency,
3603            self.jain_index,
3604            self.resize_dominance_count,
3605            self.dominance_threshold,
3606            self.fairness_threshold,
3607            self.input_priority_threshold_ms
3608        )
3609    }
3610}
3611
3612#[derive(Debug, Clone)]
3613struct WidgetRefreshEntry {
3614    widget_id: u64,
3615    essential: bool,
3616    starved: bool,
3617    value: f32,
3618    cost_us: f32,
3619    score: f32,
3620    staleness_ms: u64,
3621}
3622
3623impl WidgetRefreshEntry {
3624    fn to_json(&self) -> String {
3625        format!(
3626            r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
3627            self.widget_id,
3628            self.cost_us,
3629            self.value,
3630            self.score,
3631            self.essential,
3632            self.starved,
3633            self.staleness_ms
3634        )
3635    }
3636}
3637
3638#[derive(Debug, Clone)]
3639struct WidgetRefreshPlan {
3640    frame_idx: u64,
3641    budget_us: f64,
3642    degradation: DegradationLevel,
3643    essentials_cost_us: f64,
3644    selected_cost_us: f64,
3645    selected_value: f64,
3646    signal_count: usize,
3647    selected: Vec<WidgetRefreshEntry>,
3648    skipped_count: usize,
3649    skipped_starved: usize,
3650    starved_selected: usize,
3651    over_budget: bool,
3652}
3653
3654impl WidgetRefreshPlan {
3655    fn new() -> Self {
3656        Self {
3657            frame_idx: 0,
3658            budget_us: 0.0,
3659            degradation: DegradationLevel::Full,
3660            essentials_cost_us: 0.0,
3661            selected_cost_us: 0.0,
3662            selected_value: 0.0,
3663            signal_count: 0,
3664            selected: Vec::new(),
3665            skipped_count: 0,
3666            skipped_starved: 0,
3667            starved_selected: 0,
3668            over_budget: false,
3669        }
3670    }
3671
3672    fn clear(&mut self) {
3673        self.frame_idx = 0;
3674        self.budget_us = 0.0;
3675        self.degradation = DegradationLevel::Full;
3676        self.essentials_cost_us = 0.0;
3677        self.selected_cost_us = 0.0;
3678        self.selected_value = 0.0;
3679        self.signal_count = 0;
3680        self.selected.clear();
3681        self.skipped_count = 0;
3682        self.skipped_starved = 0;
3683        self.starved_selected = 0;
3684        self.over_budget = false;
3685    }
3686
3687    fn as_budget(&self) -> WidgetBudget {
3688        if self.signal_count == 0 {
3689            return WidgetBudget::allow_all();
3690        }
3691        let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
3692        WidgetBudget::allow_only(ids)
3693    }
3694
3695    fn recompute(
3696        &mut self,
3697        frame_idx: u64,
3698        budget_us: f64,
3699        degradation: DegradationLevel,
3700        signals: &[WidgetSignal],
3701        config: &WidgetRefreshConfig,
3702    ) {
3703        self.clear();
3704        self.frame_idx = frame_idx;
3705        self.budget_us = budget_us;
3706        self.degradation = degradation;
3707
3708        if !config.enabled || signals.is_empty() {
3709            return;
3710        }
3711
3712        self.signal_count = signals.len();
3713        let mut essentials_cost = 0.0f64;
3714        let mut selected_cost = 0.0f64;
3715        let mut selected_value = 0.0f64;
3716
3717        let staleness_window = config.staleness_window_ms.max(1) as f32;
3718        let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
3719
3720        for signal in signals {
3721            let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
3722            let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
3723            let mut value = config.weight_priority * signal.priority
3724                + config.weight_staleness * staleness_score
3725                + config.weight_focus * signal.focus_boost
3726                + config.weight_interaction * signal.interaction_boost;
3727            if starved {
3728                value += config.starve_boost;
3729            }
3730            let raw_cost = if signal.recent_cost_us > 0.0 {
3731                signal.recent_cost_us
3732            } else {
3733                signal.cost_estimate_us
3734            };
3735            let cost_us = raw_cost.max(config.min_cost_us);
3736            let score = if cost_us > 0.0 {
3737                value / cost_us
3738            } else {
3739                value
3740            };
3741
3742            let entry = WidgetRefreshEntry {
3743                widget_id: signal.widget_id,
3744                essential: signal.essential,
3745                starved,
3746                value,
3747                cost_us,
3748                score,
3749                staleness_ms: signal.staleness_ms,
3750            };
3751
3752            if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
3753                self.skipped_count += 1;
3754                if starved {
3755                    self.skipped_starved = self.skipped_starved.saturating_add(1);
3756                }
3757                continue;
3758            }
3759
3760            if signal.essential {
3761                essentials_cost += cost_us as f64;
3762                selected_cost += cost_us as f64;
3763                selected_value += value as f64;
3764                if starved {
3765                    self.starved_selected = self.starved_selected.saturating_add(1);
3766                }
3767                self.selected.push(entry);
3768            } else {
3769                candidates.push(entry);
3770            }
3771        }
3772
3773        let mut remaining = budget_us - selected_cost;
3774
3775        if degradation < DegradationLevel::EssentialOnly {
3776            let nonessential_total = candidates.len();
3777            let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
3778            let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
3779            let min_nonessential_selected = if enforce_drop_rate {
3780                let min_fraction = (1.0 - max_drop_fraction).max(0.0);
3781                ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
3782            } else {
3783                0
3784            };
3785
3786            candidates.sort_by(|a, b| {
3787                b.starved
3788                    .cmp(&a.starved)
3789                    .then_with(|| b.score.total_cmp(&a.score))
3790                    .then_with(|| b.value.total_cmp(&a.value))
3791                    .then_with(|| a.cost_us.total_cmp(&b.cost_us))
3792                    .then_with(|| a.widget_id.cmp(&b.widget_id))
3793            });
3794
3795            let mut forced_starved = 0usize;
3796            let mut nonessential_selected = 0usize;
3797            let mut skipped_candidates = if enforce_drop_rate {
3798                Vec::with_capacity(candidates.len())
3799            } else {
3800                Vec::new()
3801            };
3802
3803            for entry in candidates.into_iter() {
3804                if entry.starved && forced_starved >= config.max_starved_per_frame {
3805                    self.skipped_count += 1;
3806                    self.skipped_starved = self.skipped_starved.saturating_add(1);
3807                    if enforce_drop_rate {
3808                        skipped_candidates.push(entry);
3809                    }
3810                    continue;
3811                }
3812
3813                if remaining >= entry.cost_us as f64 {
3814                    remaining -= entry.cost_us as f64;
3815                    selected_cost += entry.cost_us as f64;
3816                    selected_value += entry.value as f64;
3817                    if entry.starved {
3818                        self.starved_selected = self.starved_selected.saturating_add(1);
3819                        forced_starved += 1;
3820                    }
3821                    nonessential_selected += 1;
3822                    self.selected.push(entry);
3823                } else if entry.starved
3824                    && forced_starved < config.max_starved_per_frame
3825                    && nonessential_selected == 0
3826                {
3827                    // Starvation guard: ensure at least one starved widget can refresh.
3828                    selected_cost += entry.cost_us as f64;
3829                    selected_value += entry.value as f64;
3830                    self.starved_selected = self.starved_selected.saturating_add(1);
3831                    forced_starved += 1;
3832                    nonessential_selected += 1;
3833                    self.selected.push(entry);
3834                } else {
3835                    self.skipped_count += 1;
3836                    if entry.starved {
3837                        self.skipped_starved = self.skipped_starved.saturating_add(1);
3838                    }
3839                    if enforce_drop_rate {
3840                        skipped_candidates.push(entry);
3841                    }
3842                }
3843            }
3844
3845            if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
3846                for entry in skipped_candidates.into_iter() {
3847                    if nonessential_selected >= min_nonessential_selected {
3848                        break;
3849                    }
3850                    if entry.starved && forced_starved >= config.max_starved_per_frame {
3851                        continue;
3852                    }
3853                    selected_cost += entry.cost_us as f64;
3854                    selected_value += entry.value as f64;
3855                    if entry.starved {
3856                        self.starved_selected = self.starved_selected.saturating_add(1);
3857                        forced_starved += 1;
3858                        self.skipped_starved = self.skipped_starved.saturating_sub(1);
3859                    }
3860                    self.skipped_count = self.skipped_count.saturating_sub(1);
3861                    nonessential_selected += 1;
3862                    self.selected.push(entry);
3863                }
3864            }
3865        }
3866
3867        self.essentials_cost_us = essentials_cost;
3868        self.selected_cost_us = selected_cost;
3869        self.selected_value = selected_value;
3870        self.over_budget = selected_cost > budget_us;
3871    }
3872
3873    #[must_use]
3874    fn to_jsonl(&self) -> String {
3875        let mut out = String::with_capacity(256 + self.selected.len() * 96);
3876        out.push_str(r#"{"event":"widget_refresh""#);
3877        out.push_str(&format!(
3878            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":{}"#,
3879            self.frame_idx,
3880            self.budget_us,
3881            self.degradation.as_str(),
3882            self.essentials_cost_us,
3883            self.selected_cost_us,
3884            self.selected_value,
3885            self.selected.len(),
3886            self.skipped_count,
3887            self.starved_selected,
3888            self.skipped_starved,
3889            self.over_budget
3890        ));
3891        out.push_str(r#","selected":["#);
3892        for (i, entry) in self.selected.iter().enumerate() {
3893            if i > 0 {
3894                out.push(',');
3895            }
3896            out.push_str(&entry.to_json());
3897        }
3898        out.push_str("]}");
3899        out
3900    }
3901}
3902
3903// =============================================================================
3904// CrosstermEventSource: BackendEventSource adapter for TerminalSession
3905// =============================================================================
3906
3907#[cfg(feature = "crossterm-compat")]
3908/// Adapter that wraps [`TerminalSession`] to implement [`BackendEventSource`].
3909///
3910/// This provides the bridge between the legacy crossterm-based terminal session
3911/// and the new backend abstraction. Once the native `ftui-tty` backend fully
3912/// replaces crossterm, this adapter will be removed.
3913pub struct CrosstermEventSource {
3914    session: TerminalSession,
3915    features: BackendFeatures,
3916}
3917
3918#[cfg(feature = "crossterm-compat")]
3919impl CrosstermEventSource {
3920    /// Create a new crossterm event source from a terminal session.
3921    pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
3922        Self {
3923            session,
3924            features: initial_features,
3925        }
3926    }
3927}
3928
3929#[cfg(feature = "crossterm-compat")]
3930impl BackendEventSource for CrosstermEventSource {
3931    type Error = io::Error;
3932
3933    fn size(&self) -> Result<(u16, u16), io::Error> {
3934        self.session.size()
3935    }
3936
3937    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
3938        if features.mouse_capture != self.features.mouse_capture {
3939            self.session.set_mouse_capture(features.mouse_capture)?;
3940        }
3941        // bracketed_paste, focus_events, and kitty_keyboard are set at session
3942        // construction and cleaned up in TerminalSession::Drop. Runtime toggling
3943        // is not supported by the crossterm backend.
3944        self.features = features;
3945        Ok(())
3946    }
3947
3948    fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
3949        self.session.poll_event(timeout)
3950    }
3951
3952    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
3953        self.session.read_event()
3954    }
3955}
3956
3957// =============================================================================
3958// HeadlessEventSource: no-op event source for headless/test programs
3959// =============================================================================
3960
3961/// A no-op event source for headless and test programs.
3962///
3963/// Returns a fixed terminal size, accepts feature changes silently, and never
3964/// produces events. This allows the test helper to construct a `Program`
3965/// without depending on crossterm or a real terminal.
3966pub struct HeadlessEventSource {
3967    width: u16,
3968    height: u16,
3969    features: BackendFeatures,
3970}
3971
3972impl HeadlessEventSource {
3973    /// Create a headless event source with the given terminal size.
3974    pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
3975        Self {
3976            width,
3977            height,
3978            features,
3979        }
3980    }
3981}
3982
3983impl BackendEventSource for HeadlessEventSource {
3984    type Error = io::Error;
3985
3986    fn size(&self) -> Result<(u16, u16), io::Error> {
3987        Ok((self.width, self.height))
3988    }
3989
3990    fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
3991        self.features = features;
3992        Ok(())
3993    }
3994
3995    fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
3996        Ok(false)
3997    }
3998
3999    fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4000        Ok(None)
4001    }
4002}
4003
4004// =============================================================================
4005// Program
4006// =============================================================================
4007
4008/// The program runtime that manages the update/view loop.
4009pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4010    /// The application model.
4011    model: M,
4012    /// Terminal output coordinator.
4013    writer: TerminalWriter<W>,
4014    /// Event source (terminal input, size queries, feature toggles).
4015    events: E,
4016    /// Currently active backend feature toggles.
4017    backend_features: BackendFeatures,
4018    /// Whether the program is running.
4019    running: bool,
4020    /// Current tick rate (if any).
4021    tick_rate: Option<Duration>,
4022    /// Total commands actually executed by the runtime.
4023    executed_cmd_count: usize,
4024    /// Last tick time.
4025    last_tick: Instant,
4026    /// Whether the UI needs to be redrawn.
4027    dirty: bool,
4028    /// Monotonic frame index for evidence logging.
4029    frame_idx: u64,
4030    /// Monotonic tick index for tick-strategy scheduling.
4031    tick_count: u64,
4032    /// Widget scheduling signals captured during the last render.
4033    widget_signals: Vec<WidgetSignal>,
4034    /// Widget refresh selection configuration.
4035    widget_refresh_config: WidgetRefreshConfig,
4036    /// Last computed widget refresh plan.
4037    widget_refresh_plan: WidgetRefreshPlan,
4038    /// Current terminal width.
4039    width: u16,
4040    /// Current terminal height.
4041    height: u16,
4042    /// Forced terminal size override (when set, resize events are ignored).
4043    forced_size: Option<(u16, u16)>,
4044    /// Poll timeout when no tick is scheduled.
4045    poll_timeout: Duration,
4046    /// Whether the runtime should observe process-level termination signals.
4047    intercept_signals: bool,
4048    /// Immediate drain policy for bursty input handling.
4049    immediate_drain_config: ImmediateDrainConfig,
4050    /// Runtime counters for immediate-drain behavior.
4051    immediate_drain_stats: ImmediateDrainStats,
4052    /// Frame budget configuration.
4053    budget: RenderBudget,
4054    /// Conformal predictor for frame-time risk gating.
4055    conformal_predictor: Option<ConformalPredictor>,
4056    /// Last observed frame time (microseconds), used as a baseline predictor.
4057    last_frame_time_us: Option<f64>,
4058    /// Last observed update duration (microseconds).
4059    last_update_us: Option<u64>,
4060    /// Optional frame timing sink for profiling.
4061    frame_timing: Option<FrameTimingConfig>,
4062    /// Locale context used for rendering.
4063    locale_context: LocaleContext,
4064    /// Last observed locale version.
4065    locale_version: u64,
4066    /// Resize coalescer for rapid resize events.
4067    resize_coalescer: ResizeCoalescer,
4068    /// Shared evidence sink for decision logs (optional).
4069    evidence_sink: Option<EvidenceSink>,
4070    /// Whether fairness config has been logged to evidence sink.
4071    fairness_config_logged: bool,
4072    /// Resize handling behavior.
4073    resize_behavior: ResizeBehavior,
4074    /// Input fairness guard for scheduler integration.
4075    fairness_guard: InputFairnessGuard,
4076    /// Optional event recorder for macro capture.
4077    event_recorder: Option<EventRecorder>,
4078    /// Subscription lifecycle manager.
4079    subscriptions: SubscriptionManager<M::Message>,
4080    /// Channel for receiving messages from background tasks.
4081    #[cfg(test)]
4082    task_sender: std::sync::mpsc::Sender<M::Message>,
4083    /// Channel for receiving messages from background tasks.
4084    task_receiver: std::sync::mpsc::Receiver<M::Message>,
4085    /// Internal task execution substrate behind `Cmd::Task`.
4086    task_executor: TaskExecutor<M::Message>,
4087    /// Optional state registry for widget persistence.
4088    state_registry: Option<std::sync::Arc<StateRegistry>>,
4089    /// Persistence configuration.
4090    persistence_config: PersistenceConfig,
4091    /// Last checkpoint save time.
4092    last_checkpoint: Instant,
4093    /// Inline auto UI height remeasurement state.
4094    inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4095    /// Per-frame bump arena for temporary render-path allocations.
4096    frame_arena: FrameArena,
4097    /// Unified frame guardrails (memory/queue limits).
4098    guardrails: FrameGuardrails,
4099    /// Optional tick strategy for selective background screen ticking.
4100    tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4101    /// Last active screen observed by the tick strategy dispatch path.
4102    last_active_screen_for_strategy: Option<String>,
4103}
4104
4105#[cfg(feature = "crossterm-compat")]
4106impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4107    /// Create a new program with default configuration.
4108    pub fn new(model: M) -> io::Result<Self>
4109    where
4110        M::Message: Send + 'static,
4111    {
4112        Self::with_config(model, ProgramConfig::default())
4113    }
4114
4115    /// Create a new program with the specified configuration.
4116    pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4117    where
4118        M::Message: Send + 'static,
4119    {
4120        let resolved_lane = config.runtime_lane.resolve();
4121        let effect_queue_config = config.resolved_effect_queue_config();
4122        let capabilities = TerminalCapabilities::with_overrides();
4123        let mouse_capture = config.resolved_mouse_capture();
4124        let requested_features = BackendFeatures {
4125            mouse_capture,
4126            bracketed_paste: config.bracketed_paste,
4127            focus_events: config.focus_reporting,
4128            kitty_keyboard: config.kitty_keyboard,
4129        };
4130        let initial_features =
4131            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4132        let session = TerminalSession::new(SessionOptions {
4133            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4134            mouse_capture: initial_features.mouse_capture,
4135            bracketed_paste: initial_features.bracketed_paste,
4136            focus_events: initial_features.focus_events,
4137            kitty_keyboard: initial_features.kitty_keyboard,
4138            intercept_signals: config.intercept_signals,
4139        })?;
4140        let events = CrosstermEventSource::new(session, initial_features);
4141
4142        let mut writer = TerminalWriter::with_diff_config(
4143            io::stdout(),
4144            config.screen_mode,
4145            config.ui_anchor,
4146            capabilities,
4147            config.diff_config.clone(),
4148        );
4149
4150        let frame_timing = config.frame_timing.clone();
4151        writer.set_timing_enabled(frame_timing.is_some());
4152
4153        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4154        if let Some(ref sink) = evidence_sink {
4155            writer = writer.with_evidence_sink(sink.clone());
4156        }
4157
4158        let render_trace = crate::RenderTraceRecorder::from_config(
4159            &config.render_trace,
4160            crate::RenderTraceContext {
4161                capabilities: writer.capabilities(),
4162                diff_config: config.diff_config.clone(),
4163                resize_config: config.resize_coalescer.clone(),
4164                conformal_config: config.conformal_config.clone(),
4165            },
4166        )?;
4167        if let Some(recorder) = render_trace {
4168            writer = writer.with_render_trace(recorder);
4169        }
4170
4171        // Get terminal size for initial frame (or forced size override).
4172        let (w, h) = config
4173            .forced_size
4174            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4175        let width = w.max(1);
4176        let height = h.max(1);
4177        writer.set_size(width, height);
4178
4179        let budget = RenderBudget::from_config(&config.budget);
4180        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4181        let locale_context = config.locale_context.clone();
4182        let locale_version = locale_context.version();
4183        let mut resize_coalescer =
4184            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4185                .with_screen_mode(config.screen_mode);
4186        if let Some(ref sink) = evidence_sink {
4187            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4188        }
4189        let subscriptions = SubscriptionManager::new();
4190        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4191        let inline_auto_remeasure = config
4192            .inline_auto_remeasure
4193            .clone()
4194            .map(InlineAutoRemeasureState::new);
4195        let task_executor = TaskExecutor::new(
4196            &effect_queue_config,
4197            task_sender.clone(),
4198            evidence_sink.clone(),
4199        )?;
4200        let guardrails = FrameGuardrails::new(config.guardrails);
4201
4202        // Log runtime lane and rollout policy at startup (bd-2crbt)
4203        tracing::info!(
4204            target: "ftui.runtime",
4205            requested_lane = config.runtime_lane.label(),
4206            resolved_lane = resolved_lane.label(),
4207            rollout_policy = config.rollout_policy.label(),
4208            "runtime startup: lane={}, rollout={}",
4209            resolved_lane.label(),
4210            config.rollout_policy.label(),
4211        );
4212
4213        Ok(Self {
4214            model,
4215            writer,
4216            events,
4217            backend_features: initial_features,
4218            running: true,
4219            tick_rate: None,
4220            executed_cmd_count: 0,
4221            last_tick: Instant::now(),
4222            dirty: true,
4223            frame_idx: 0,
4224            tick_count: 0,
4225            widget_signals: Vec::new(),
4226            widget_refresh_config: config.widget_refresh,
4227            widget_refresh_plan: WidgetRefreshPlan::new(),
4228            width,
4229            height,
4230            forced_size: config.forced_size,
4231            poll_timeout: config.poll_timeout,
4232            intercept_signals: config.intercept_signals,
4233            immediate_drain_config: config.immediate_drain,
4234            immediate_drain_stats: ImmediateDrainStats::default(),
4235            budget,
4236            conformal_predictor,
4237            last_frame_time_us: None,
4238            last_update_us: None,
4239            frame_timing,
4240            locale_context,
4241            locale_version,
4242            resize_coalescer,
4243            evidence_sink,
4244            fairness_config_logged: false,
4245            resize_behavior: config.resize_behavior,
4246            fairness_guard: InputFairnessGuard::new(),
4247            event_recorder: None,
4248            subscriptions,
4249            #[cfg(test)]
4250            task_sender,
4251            task_receiver,
4252            task_executor,
4253            state_registry: config.persistence.registry.clone(),
4254            persistence_config: config.persistence,
4255            last_checkpoint: Instant::now(),
4256            inline_auto_remeasure,
4257            frame_arena: FrameArena::default(),
4258            guardrails,
4259            tick_strategy: config
4260                .tick_strategy
4261                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4262            last_active_screen_for_strategy: None,
4263        })
4264    }
4265}
4266
4267impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4268    /// Create a program with an externally-constructed event source and writer.
4269    ///
4270    /// This is the generic entry point for alternative backends (native tty,
4271    /// WASM, headless testing). The caller is responsible for terminal
4272    /// lifecycle (raw mode, cleanup) — the event source should handle that
4273    /// via its `Drop` impl or an external RAII guard.
4274    pub fn with_event_source(
4275        model: M,
4276        events: E,
4277        backend_features: BackendFeatures,
4278        writer: TerminalWriter<W>,
4279        config: ProgramConfig,
4280    ) -> io::Result<Self>
4281    where
4282        M::Message: Send + 'static,
4283    {
4284        let effect_queue_config = config.resolved_effect_queue_config();
4285        let (width, height) = config
4286            .forced_size
4287            .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4288        let width = width.max(1);
4289        let height = height.max(1);
4290
4291        let mut writer = writer;
4292        writer.set_size(width, height);
4293
4294        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4295        if let Some(ref sink) = evidence_sink {
4296            writer = writer.with_evidence_sink(sink.clone());
4297        }
4298
4299        let render_trace = crate::RenderTraceRecorder::from_config(
4300            &config.render_trace,
4301            crate::RenderTraceContext {
4302                capabilities: writer.capabilities(),
4303                diff_config: config.diff_config.clone(),
4304                resize_config: config.resize_coalescer.clone(),
4305                conformal_config: config.conformal_config.clone(),
4306            },
4307        )?;
4308        if let Some(recorder) = render_trace {
4309            writer = writer.with_render_trace(recorder);
4310        }
4311
4312        let frame_timing = config.frame_timing.clone();
4313        writer.set_timing_enabled(frame_timing.is_some());
4314
4315        let budget = RenderBudget::from_config(&config.budget);
4316        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4317        let locale_context = config.locale_context.clone();
4318        let locale_version = locale_context.version();
4319        let mut resize_coalescer =
4320            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4321                .with_screen_mode(config.screen_mode);
4322        if let Some(ref sink) = evidence_sink {
4323            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4324        }
4325        let subscriptions = SubscriptionManager::new();
4326        let (task_sender, task_receiver) = std::sync::mpsc::channel();
4327        let inline_auto_remeasure = config
4328            .inline_auto_remeasure
4329            .clone()
4330            .map(InlineAutoRemeasureState::new);
4331        let task_executor = TaskExecutor::new(
4332            &effect_queue_config,
4333            task_sender.clone(),
4334            evidence_sink.clone(),
4335        )?;
4336
4337        let guardrails = FrameGuardrails::new(config.guardrails);
4338
4339        Ok(Self {
4340            model,
4341            writer,
4342            events,
4343            backend_features,
4344            running: true,
4345            tick_rate: None,
4346            executed_cmd_count: 0,
4347            last_tick: Instant::now(),
4348            dirty: true,
4349            frame_idx: 0,
4350            tick_count: 0,
4351            widget_signals: Vec::new(),
4352            widget_refresh_config: config.widget_refresh,
4353            widget_refresh_plan: WidgetRefreshPlan::new(),
4354            width,
4355            height,
4356            forced_size: config.forced_size,
4357            poll_timeout: config.poll_timeout,
4358            intercept_signals: config.intercept_signals,
4359            immediate_drain_config: config.immediate_drain,
4360            immediate_drain_stats: ImmediateDrainStats::default(),
4361            budget,
4362            conformal_predictor,
4363            last_frame_time_us: None,
4364            last_update_us: None,
4365            frame_timing,
4366            locale_context,
4367            locale_version,
4368            resize_coalescer,
4369            evidence_sink,
4370            fairness_config_logged: false,
4371            resize_behavior: config.resize_behavior,
4372            fairness_guard: InputFairnessGuard::new(),
4373            event_recorder: None,
4374            subscriptions,
4375            #[cfg(test)]
4376            task_sender,
4377            task_receiver,
4378            task_executor,
4379            state_registry: config.persistence.registry.clone(),
4380            persistence_config: config.persistence,
4381            last_checkpoint: Instant::now(),
4382            inline_auto_remeasure,
4383            frame_arena: FrameArena::default(),
4384            guardrails,
4385            tick_strategy: config
4386                .tick_strategy
4387                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4388            last_active_screen_for_strategy: None,
4389        })
4390    }
4391}
4392
4393// =============================================================================
4394// Native TTY backend constructor (feature-gated)
4395// =============================================================================
4396
4397#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
4398#[inline]
4399const fn sanitize_backend_features_for_capabilities(
4400    requested: BackendFeatures,
4401    capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
4402) -> BackendFeatures {
4403    let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
4404    let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
4405
4406    BackendFeatures {
4407        mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
4408        bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
4409        focus_events: requested.focus_events && focus_events_supported,
4410        kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
4411    }
4412}
4413
4414#[cfg(feature = "native-backend")]
4415impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
4416    /// Create a program backed by the native TTY backend (no Crossterm).
4417    ///
4418    /// This opens a live terminal session via `ftui-tty`, entering raw mode
4419    /// and enabling the requested features. When the program exits (or panics),
4420    /// `TtyBackend::drop()` restores the terminal to its original state.
4421    pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
4422    where
4423        M::Message: Send + 'static,
4424    {
4425        let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
4426        let mouse_capture = config.resolved_mouse_capture();
4427        let requested_features = BackendFeatures {
4428            mouse_capture,
4429            bracketed_paste: config.bracketed_paste,
4430            focus_events: config.focus_reporting,
4431            kitty_keyboard: config.kitty_keyboard,
4432        };
4433        let features =
4434            sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4435        let options = ftui_tty::TtySessionOptions {
4436            alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4437            features,
4438            intercept_signals: config.intercept_signals,
4439        };
4440        #[cfg(unix)]
4441        let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
4442        #[cfg(not(unix))]
4443        let backend = ftui_tty::TtyBackend::new(0, 0);
4444
4445        let writer = TerminalWriter::with_diff_config(
4446            io::stdout(),
4447            config.screen_mode,
4448            config.ui_anchor,
4449            capabilities,
4450            config.diff_config.clone(),
4451        );
4452
4453        Self::with_event_source(model, backend, features, writer, config)
4454    }
4455}
4456
4457impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4458    /// Run the main event loop.
4459    ///
4460    /// This is the main entry point. It handles:
4461    /// 1. Initialization (terminal setup, raw mode)
4462    /// 2. Event polling and message dispatch
4463    /// 3. Frame rendering
4464    /// 4. Shutdown (terminal cleanup)
4465    pub fn run(&mut self) -> io::Result<()> {
4466        self.run_event_loop()
4467    }
4468
4469    #[inline]
4470    fn observed_termination_signal(&self) -> Option<i32> {
4471        if self.intercept_signals {
4472            check_termination_signal()
4473        } else {
4474            None
4475        }
4476    }
4477
4478    /// Access widget scheduling signals captured on the last render.
4479    #[inline]
4480    pub fn last_widget_signals(&self) -> &[WidgetSignal] {
4481        &self.widget_signals
4482    }
4483
4484    /// Snapshot immediate-drain runtime counters.
4485    #[inline]
4486    pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
4487        self.immediate_drain_stats
4488    }
4489
4490    /// The inner event loop, separated for proper cleanup handling.
4491    fn run_event_loop(&mut self) -> io::Result<()> {
4492        // Auto-load state on start
4493        if self.persistence_config.auto_load {
4494            self.load_state();
4495        }
4496
4497        // Initialize
4498        let cmd = {
4499            let _span = info_span!("ftui.program.init").entered();
4500            self.model.init()
4501        };
4502        self.execute_cmd(cmd)?;
4503
4504        let mut termination_signal = self.observed_termination_signal();
4505        if self.running && termination_signal.is_none() {
4506            // Reconcile initial subscriptions
4507            self.reconcile_subscriptions();
4508
4509            // Initial render
4510            self.render_frame()?;
4511        }
4512
4513        // Main loop
4514        let mut loop_count: u64 = 0;
4515        while self.running {
4516            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4517            if termination_signal.is_some() {
4518                self.running = false;
4519                break;
4520            }
4521
4522            loop_count += 1;
4523            // Log heartbeat every 100 iterations to avoid flooding stderr
4524            if loop_count.is_multiple_of(100) {
4525                crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
4526            }
4527
4528            // Poll for input with tick timeout
4529            let timeout = self.effective_timeout();
4530
4531            // Poll for events with timeout
4532            let poll_result = self.events.poll_event(timeout)?;
4533            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4534            if termination_signal.is_some() {
4535                self.running = false;
4536                break;
4537            }
4538            if poll_result {
4539                self.drain_ready_events()?;
4540            }
4541            if !self.running {
4542                break;
4543            }
4544            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4545            if termination_signal.is_some() {
4546                self.running = false;
4547                break;
4548            }
4549
4550            // Process subscription messages
4551            self.process_subscription_messages()?;
4552            if !self.running {
4553                break;
4554            }
4555
4556            // Process background task results
4557            self.process_task_results()?;
4558            self.reap_finished_tasks();
4559            if !self.running {
4560                break;
4561            }
4562
4563            self.process_resize_coalescer()?;
4564            if !self.running {
4565                break;
4566            }
4567            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4568            if termination_signal.is_some() {
4569                self.running = false;
4570                break;
4571            }
4572
4573            // Detect screen transitions from any update() calls above.
4574            // A.2: notifies the tick strategy so predictive strategies learn.
4575            // D.3: force-ticks the newly active screen for immediate refresh.
4576            self.check_screen_transition();
4577
4578            // Check for tick - deliver to model so periodic logic can run
4579            if self.should_tick() {
4580                self.tick_count = self.tick_count.wrapping_add(1);
4581                let tick_count = self.tick_count;
4582
4583                let mut used_screen_dispatch = false;
4584
4585                // Per-screen tick dispatch: if the model supports multi-screen
4586                // dispatch and a tick strategy is configured, tick individual
4587                // screens selectively instead of calling monolithic
4588                // `update(Tick)`.
4589                if let Some(strategy) = self.tick_strategy.as_mut() {
4590                    // Snapshot screen topology first so the mutable borrow of the
4591                    // dispatch adapter does not overlap strategy decisions.
4592                    let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
4593                        let active = dispatch.active_screen_id();
4594                        let all_screens = dispatch.screen_ids();
4595                        (active, all_screens)
4596                    });
4597
4598                    if let Some((active, all_screens)) = dispatch_snapshot {
4599                        used_screen_dispatch = true;
4600
4601                        // Feed active-screen transitions into the strategy so
4602                        // predictive strategies can learn from real navigation.
4603                        if let Some(previous_active) =
4604                            self.last_active_screen_for_strategy.as_deref()
4605                            && previous_active != active
4606                        {
4607                            strategy.on_screen_transition(previous_active, &active);
4608                        }
4609                        self.last_active_screen_for_strategy = Some(active.clone());
4610
4611                        let all_screens_count = all_screens.len();
4612                        let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
4613                        // Active screen is always ticked.
4614                        tick_targets.push(active.clone());
4615
4616                        // Tick inactive screens according to the strategy.
4617                        for screen_id in all_screens {
4618                            if screen_id != active
4619                                && strategy.should_tick(&screen_id, tick_count, &active)
4620                                    == crate::tick_strategy::TickDecision::Tick
4621                            {
4622                                tick_targets.push(screen_id);
4623                            }
4624                        }
4625
4626                        // Compute skipped screens for tracing.
4627                        let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
4628
4629                        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
4630                            for screen_id in &tick_targets {
4631                                dispatch.tick_screen(screen_id, tick_count);
4632                            }
4633                        }
4634
4635                        trace!(
4636                            tick = tick_count,
4637                            active = %active,
4638                            ticked = tick_targets.len(),
4639                            skipped = skipped_count,
4640                            "tick_strategy.frame"
4641                        );
4642
4643                        // Maintenance tick for the strategy.
4644                        strategy.maintenance_tick(tick_count);
4645                        self.mark_dirty();
4646                    }
4647                }
4648
4649                if used_screen_dispatch && self.running {
4650                    self.reconcile_subscriptions();
4651                }
4652
4653                if !used_screen_dispatch {
4654                    // Monolithic model path does not expose active-screen
4655                    // transitions, so clear dispatch-local transition state.
4656                    self.last_active_screen_for_strategy = None;
4657                    let msg = M::Message::from(Event::Tick);
4658                    let cmd = {
4659                        let _span = debug_span!(
4660                            "ftui.program.update",
4661                            msg_type = "Tick",
4662                            duration_us = tracing::field::Empty,
4663                            cmd_type = tracing::field::Empty
4664                        )
4665                        .entered();
4666                        let start = Instant::now();
4667                        let cmd = self.model.update(msg);
4668                        tracing::Span::current()
4669                            .record("duration_us", start.elapsed().as_micros() as u64);
4670                        tracing::Span::current()
4671                            .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
4672                        cmd
4673                    };
4674                    self.mark_dirty();
4675                    self.execute_cmd(cmd)?;
4676                    if self.running {
4677                        self.reconcile_subscriptions();
4678                    }
4679                }
4680            }
4681
4682            // Check for periodic checkpoint save
4683            self.check_checkpoint_save();
4684
4685            // Detect locale changes outside the event loop.
4686            self.check_locale_change();
4687            termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
4688            if termination_signal.is_some() {
4689                self.running = false;
4690                break;
4691            }
4692
4693            // Render if dirty
4694            if self.dirty {
4695                self.render_frame()?;
4696            }
4697
4698            // Periodic grapheme pool GC
4699            if loop_count.is_multiple_of(1000) {
4700                self.writer.gc(None);
4701            }
4702        }
4703
4704        let shutdown_cmd = {
4705            let _span = info_span!("ftui.program.shutdown").entered();
4706            self.model.on_shutdown()
4707        };
4708        self.execute_cmd(shutdown_cmd)?;
4709
4710        // Auto-save state on exit
4711        if self.persistence_config.auto_save {
4712            self.save_state();
4713        }
4714
4715        // Shut down tick strategy (gives strategies a chance to persist state)
4716        if let Some(ref mut strategy) = self.tick_strategy {
4717            strategy.shutdown();
4718        }
4719
4720        // Stop all subscriptions on exit
4721        self.subscriptions.stop_all();
4722        self.task_executor.shutdown();
4723        self.reap_finished_tasks();
4724        self.drain_shutdown_task_results()?;
4725
4726        if let Some(signal) = termination_signal {
4727            clear_termination_signal();
4728            let err = io::Error::new(
4729                io::ErrorKind::Interrupted,
4730                SignalTerminationError { signal },
4731            );
4732            debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
4733            return Err(err);
4734        }
4735
4736        Ok(())
4737    }
4738
4739    /// Drain ready events while bounding zero-timeout polling work.
4740    ///
4741    /// The runtime preserves low-latency draining by polling with
4742    /// `Duration::ZERO`, but switches to a bounded backoff path when a burst
4743    /// exceeds configured immediate-drain budgets.
4744    fn drain_ready_events(&mut self) -> io::Result<()> {
4745        self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
4746
4747        let zero_poll_limit = self
4748            .immediate_drain_config
4749            .max_zero_timeout_polls_per_burst
4750            .max(1);
4751        let max_burst_duration = self.immediate_drain_config.max_burst_duration;
4752        let backoff_timeout = self.immediate_drain_config.backoff_timeout;
4753
4754        let mut burst_start = Instant::now();
4755        let mut zero_polls_in_burst_window: u64 = 0;
4756        let mut capped_this_burst = false;
4757
4758        loop {
4759            if let Some(event) = self.events.read_event()? {
4760                self.handle_event(event)?;
4761                if !self.running {
4762                    break;
4763                }
4764            }
4765
4766            let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
4767                || burst_start.elapsed() >= max_burst_duration;
4768
4769            if budget_exhausted {
4770                if !capped_this_burst {
4771                    capped_this_burst = true;
4772                    self.immediate_drain_stats.capped_bursts =
4773                        self.immediate_drain_stats.capped_bursts.saturating_add(1);
4774                }
4775
4776                self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
4777                    .immediate_drain_stats
4778                    .max_zero_timeout_polls_in_burst
4779                    .max(zero_polls_in_burst_window);
4780
4781                std::thread::yield_now();
4782                self.immediate_drain_stats.backoff_polls =
4783                    self.immediate_drain_stats.backoff_polls.saturating_add(1);
4784                if !self.events.poll_event(backoff_timeout)? {
4785                    break;
4786                }
4787                zero_polls_in_burst_window = 0;
4788                burst_start = Instant::now();
4789                continue;
4790            }
4791
4792            self.immediate_drain_stats.zero_timeout_polls = self
4793                .immediate_drain_stats
4794                .zero_timeout_polls
4795                .saturating_add(1);
4796            zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
4797            if !self.events.poll_event(Duration::ZERO)? {
4798                break;
4799            }
4800        }
4801
4802        self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
4803            .immediate_drain_stats
4804            .max_zero_timeout_polls_in_burst
4805            .max(zero_polls_in_burst_window);
4806
4807        Ok(())
4808    }
4809
4810    /// Load state from the persistence registry.
4811    fn load_state(&mut self) {
4812        if let Some(registry) = &self.state_registry {
4813            match registry.load() {
4814                Ok(count) => {
4815                    info!(count, "loaded widget state from persistence");
4816                }
4817                Err(e) => {
4818                    tracing::warn!(error = %e, "failed to load widget state");
4819                }
4820            }
4821        }
4822    }
4823
4824    /// Save state to the persistence registry.
4825    fn save_state(&mut self) {
4826        if let Some(registry) = &self.state_registry {
4827            match registry.flush() {
4828                Ok(true) => {
4829                    debug!("saved widget state to persistence");
4830                }
4831                Ok(false) => {
4832                    // No changes to save
4833                }
4834                Err(e) => {
4835                    tracing::warn!(error = %e, "failed to save widget state");
4836                }
4837            }
4838        }
4839    }
4840
4841    /// Check if it's time for a periodic checkpoint save.
4842    fn check_checkpoint_save(&mut self) {
4843        if let Some(interval) = self.persistence_config.checkpoint_interval
4844            && self.last_checkpoint.elapsed() >= interval
4845        {
4846            self.save_state();
4847            self.last_checkpoint = Instant::now();
4848        }
4849    }
4850
4851    fn handle_event(&mut self, event: Event) -> io::Result<()> {
4852        // Track event start time and type for fairness scheduling.
4853        let event_start = Instant::now();
4854        let fairness_event_type = Self::classify_event_for_fairness(&event);
4855        if fairness_event_type == FairnessEventType::Input {
4856            self.fairness_guard.input_arrived(event_start);
4857        }
4858
4859        // Record event before processing (no-op when recorder is None or idle).
4860        if let Some(recorder) = &mut self.event_recorder {
4861            recorder.record(&event);
4862        }
4863
4864        let event = match event {
4865            Event::Resize { width, height } => {
4866                debug!(
4867                    width,
4868                    height,
4869                    behavior = ?self.resize_behavior,
4870                    "Resize event received"
4871                );
4872                if let Some((forced_width, forced_height)) = self.forced_size {
4873                    debug!(
4874                        forced_width,
4875                        forced_height, "Resize ignored due to forced size override"
4876                    );
4877                    self.fairness_guard.event_processed(
4878                        fairness_event_type,
4879                        event_start.elapsed(),
4880                        Instant::now(),
4881                    );
4882                    return Ok(());
4883                }
4884                // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
4885                let width = width.max(1);
4886                let height = height.max(1);
4887                match self.resize_behavior {
4888                    ResizeBehavior::Immediate => {
4889                        self.resize_coalescer
4890                            .record_external_apply(width, height, Instant::now());
4891                        let result = self.apply_resize(width, height, Duration::ZERO, false);
4892                        self.fairness_guard.event_processed(
4893                            fairness_event_type,
4894                            event_start.elapsed(),
4895                            Instant::now(),
4896                        );
4897                        return result;
4898                    }
4899                    ResizeBehavior::Throttled => {
4900                        let action = self.resize_coalescer.handle_resize(width, height);
4901                        if let CoalesceAction::ApplyResize {
4902                            width,
4903                            height,
4904                            coalesce_time,
4905                            forced_by_deadline,
4906                        } = action
4907                        {
4908                            let result =
4909                                self.apply_resize(width, height, coalesce_time, forced_by_deadline);
4910                            self.fairness_guard.event_processed(
4911                                fairness_event_type,
4912                                event_start.elapsed(),
4913                                Instant::now(),
4914                            );
4915                            return result;
4916                        }
4917
4918                        self.fairness_guard.event_processed(
4919                            fairness_event_type,
4920                            event_start.elapsed(),
4921                            Instant::now(),
4922                        );
4923                        return Ok(());
4924                    }
4925                }
4926            }
4927            other => other,
4928        };
4929
4930        let msg = M::Message::from(event);
4931        let cmd = {
4932            let _span = debug_span!(
4933                "ftui.program.update",
4934                msg_type = "event",
4935                duration_us = tracing::field::Empty,
4936                cmd_type = tracing::field::Empty
4937            )
4938            .entered();
4939            let start = Instant::now();
4940            let cmd = self.model.update(msg);
4941            let elapsed_us = start.elapsed().as_micros() as u64;
4942            self.last_update_us = Some(elapsed_us);
4943            tracing::Span::current().record("duration_us", elapsed_us);
4944            tracing::Span::current()
4945                .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
4946            cmd
4947        };
4948        self.mark_dirty();
4949        self.execute_cmd(cmd)?;
4950        if self.running {
4951            self.reconcile_subscriptions();
4952        }
4953
4954        // Track input event processing for fairness.
4955        self.fairness_guard.event_processed(
4956            fairness_event_type,
4957            event_start.elapsed(),
4958            Instant::now(),
4959        );
4960
4961        Ok(())
4962    }
4963
4964    /// Classify an event for fairness tracking.
4965    fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
4966        match event {
4967            Event::Key(_)
4968            | Event::Mouse(_)
4969            | Event::Paste(_)
4970            | Event::Ime(_)
4971            | Event::Focus(_)
4972            | Event::Clipboard(_) => FairnessEventType::Input,
4973            Event::Resize { .. } => FairnessEventType::Resize,
4974            Event::Tick => FairnessEventType::Tick,
4975        }
4976    }
4977
4978    /// Reconcile the model's declared subscriptions with running ones.
4979    fn reconcile_subscriptions(&mut self) {
4980        let _span = debug_span!(
4981            "ftui.program.subscriptions",
4982            active_count = tracing::field::Empty,
4983            started = tracing::field::Empty,
4984            stopped = tracing::field::Empty
4985        )
4986        .entered();
4987        let subs = self.model.subscriptions();
4988        let before_count = self.subscriptions.active_count();
4989        self.subscriptions.reconcile(subs);
4990        let after_count = self.subscriptions.active_count();
4991        let started = after_count.saturating_sub(before_count);
4992        let stopped = before_count.saturating_sub(after_count);
4993        crate::debug_trace!(
4994            "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
4995            before_count,
4996            after_count,
4997            started,
4998            stopped
4999        );
5000        if after_count == 0 {
5001            crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5002        }
5003        let current = tracing::Span::current();
5004        current.record("active_count", after_count);
5005        // started/stopped would require tracking in SubscriptionManager
5006        current.record("started", started);
5007        current.record("stopped", stopped);
5008    }
5009
5010    /// Process pending messages from subscriptions.
5011    fn process_subscription_messages(&mut self) -> io::Result<()> {
5012        let messages = self.subscriptions.drain_messages();
5013        let msg_count = messages.len();
5014        if msg_count > 0 {
5015            crate::debug_trace!("processing {} subscription message(s)", msg_count);
5016        }
5017        for msg in messages {
5018            let cmd = {
5019                let _span = debug_span!(
5020                    "ftui.program.update",
5021                    msg_type = "subscription",
5022                    duration_us = tracing::field::Empty,
5023                    cmd_type = tracing::field::Empty
5024                )
5025                .entered();
5026                let start = Instant::now();
5027                let cmd = self.model.update(msg);
5028                let elapsed_us = start.elapsed().as_micros() as u64;
5029                self.last_update_us = Some(elapsed_us);
5030                tracing::Span::current().record("duration_us", elapsed_us);
5031                tracing::Span::current()
5032                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5033                cmd
5034            };
5035            self.mark_dirty();
5036            self.execute_cmd(cmd)?;
5037            if !self.running {
5038                break;
5039            }
5040        }
5041        if self.running && self.dirty {
5042            self.reconcile_subscriptions();
5043        }
5044        Ok(())
5045    }
5046
5047    /// Process results from background tasks.
5048    fn process_task_results(&mut self) -> io::Result<()> {
5049        while let Ok(msg) = self.task_receiver.try_recv() {
5050            let cmd = {
5051                let _span = debug_span!(
5052                    "ftui.program.update",
5053                    msg_type = "task",
5054                    duration_us = tracing::field::Empty,
5055                    cmd_type = tracing::field::Empty
5056                )
5057                .entered();
5058                let start = Instant::now();
5059                let cmd = self.model.update(msg);
5060                let elapsed_us = start.elapsed().as_micros() as u64;
5061                self.last_update_us = Some(elapsed_us);
5062                tracing::Span::current().record("duration_us", elapsed_us);
5063                tracing::Span::current()
5064                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5065                cmd
5066            };
5067            self.mark_dirty();
5068            self.execute_cmd(cmd)?;
5069            if !self.running {
5070                break;
5071            }
5072        }
5073        if self.running && self.dirty {
5074            self.reconcile_subscriptions();
5075        }
5076        Ok(())
5077    }
5078
5079    /// Execute a command.
5080    fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5081        self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5082        match cmd {
5083            Cmd::None => {}
5084            Cmd::Quit => self.running = false,
5085            Cmd::Msg(m) => {
5086                let start = Instant::now();
5087                let cmd = self.model.update(m);
5088                let elapsed_us = start.elapsed().as_micros() as u64;
5089                self.last_update_us = Some(elapsed_us);
5090                self.mark_dirty();
5091                self.execute_cmd(cmd)?;
5092            }
5093            Cmd::Batch(cmds) => {
5094                // Batch currently executes sequentially. This is intentional
5095                // until an async runtime or task scheduler is added.
5096                for c in cmds {
5097                    self.execute_cmd(c)?;
5098                    if !self.running {
5099                        break;
5100                    }
5101                }
5102            }
5103            Cmd::Sequence(cmds) => {
5104                for c in cmds {
5105                    self.execute_cmd(c)?;
5106                    if !self.running {
5107                        break;
5108                    }
5109                }
5110            }
5111            Cmd::Tick(duration) => {
5112                self.tick_rate = Some(duration);
5113                self.last_tick = Instant::now();
5114            }
5115            Cmd::Log(text) => {
5116                let sanitized = sanitize(&text);
5117                let mut text_crlf = if sanitized.contains('\n') {
5118                    sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5119                } else {
5120                    sanitized.into_owned()
5121                };
5122                if !text_crlf.ends_with("\r\n") {
5123                    if text_crlf.ends_with('\n') {
5124                        text_crlf.pop();
5125                    }
5126                    text_crlf.push_str("\r\n");
5127                }
5128                self.writer.write_log(&text_crlf)?;
5129            }
5130            Cmd::Task(spec, f) => {
5131                crate::effect_system::record_command_effect("task", 0);
5132                self.task_executor.submit(spec, f);
5133            }
5134            Cmd::SaveState => {
5135                self.save_state();
5136            }
5137            Cmd::RestoreState => {
5138                self.load_state();
5139            }
5140            Cmd::SetMouseCapture(enabled) => {
5141                self.backend_features.mouse_capture = enabled;
5142                self.events.set_features(self.backend_features)?;
5143            }
5144            Cmd::SetTickStrategy(strategy) => {
5145                let new_name = strategy.name().to_owned();
5146                if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5147                    let old_name = previous.name().to_owned();
5148                    previous.shutdown();
5149                    info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5150                } else {
5151                    info!(new = %new_name, "tick strategy changed at runtime");
5152                }
5153                self.last_active_screen_for_strategy = None;
5154            }
5155        }
5156        Ok(())
5157    }
5158
5159    /// Detect active-screen transitions after any `update()` call and react:
5160    ///
5161    /// - **A.2** — notify the tick strategy via `on_screen_transition()` so
5162    ///   predictive strategies can learn navigation patterns.
5163    /// - **D.3** — force-tick the newly active screen so it renders fresh
5164    ///   content immediately, without waiting for the next tick interval.
5165    ///
5166    /// This is a no-op when no tick strategy is configured or when the model
5167    /// does not implement [`ScreenTickDispatch`].
5168    fn check_screen_transition(&mut self) {
5169        if self.tick_strategy.is_none() {
5170            return;
5171        }
5172
5173        // Snapshot the current active screen (releases &mut self.model).
5174        let current_active = match self.model.as_screen_tick_dispatch() {
5175            Some(dispatch) => dispatch.active_screen_id(),
5176            None => return,
5177        };
5178
5179        // First observation: just record, no transition event.
5180        let previous = match self.last_active_screen_for_strategy.take() {
5181            Some(prev) => prev,
5182            None => {
5183                self.last_active_screen_for_strategy = Some(current_active);
5184                return;
5185            }
5186        };
5187
5188        if previous == current_active {
5189            self.last_active_screen_for_strategy = Some(current_active);
5190            return;
5191        }
5192
5193        // A.2: Notify strategy of the transition.
5194        if let Some(strategy) = self.tick_strategy.as_mut() {
5195            strategy.on_screen_transition(&previous, &current_active);
5196        }
5197
5198        // D.3: Force-tick the newly active screen immediately.
5199        let mut force_ticked = false;
5200        if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5201            dispatch.tick_screen(&current_active, self.tick_count);
5202            force_ticked = true;
5203        }
5204        if force_ticked && self.running {
5205            self.reconcile_subscriptions();
5206        }
5207
5208        self.last_active_screen_for_strategy = Some(current_active);
5209        self.mark_dirty();
5210    }
5211
5212    fn reap_finished_tasks(&mut self) {
5213        self.task_executor.reap_finished();
5214    }
5215
5216    fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5217        while let Ok(msg) = self.task_receiver.try_recv() {
5218            let cmd = {
5219                let _span = debug_span!(
5220                    "ftui.program.update",
5221                    msg_type = "shutdown_task",
5222                    duration_us = tracing::field::Empty,
5223                    cmd_type = tracing::field::Empty
5224                )
5225                .entered();
5226                let start = Instant::now();
5227                let cmd = self.model.update(msg);
5228                let elapsed_us = start.elapsed().as_micros() as u64;
5229                self.last_update_us = Some(elapsed_us);
5230                tracing::Span::current().record("duration_us", elapsed_us);
5231                tracing::Span::current()
5232                    .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5233                cmd
5234            };
5235            self.mark_dirty();
5236            self.execute_cmd(cmd)?;
5237        }
5238        Ok(())
5239    }
5240
5241    /// Render a frame with budget tracking.
5242    fn render_frame(&mut self) -> io::Result<()> {
5243        crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5244
5245        self.frame_idx = self.frame_idx.wrapping_add(1);
5246        let frame_idx = self.frame_idx;
5247        let degradation_start = self.budget.degradation();
5248
5249        // Reset budget for new frame, potentially upgrading quality
5250        self.budget.next_frame();
5251
5252        // Check frame guardrails (memory/queue limits)
5253        let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5254        // Synchronous program has effectively zero queue depth.
5255        let verdict = self.guardrails.check_frame(memory_bytes, 0);
5256
5257        if verdict.should_drop_frame() {
5258            // Emergency shed: skip this frame entirely to prevent OOM
5259            return Ok(());
5260        }
5261
5262        if verdict.should_degrade() {
5263            // Apply guardrail-recommended degradation if it's stricter than budget's
5264            let current = self.budget.degradation();
5265            if verdict.recommended_level > current {
5266                self.budget.set_degradation(verdict.recommended_level);
5267            }
5268        }
5269
5270        // Apply conformal risk gate before rendering (if enabled)
5271        let mut conformal_prediction = None;
5272        if let Some(predictor) = self.conformal_predictor.as_ref() {
5273            let baseline_us = self
5274                .last_frame_time_us
5275                .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5276            let diff_strategy = self
5277                .writer
5278                .last_diff_strategy()
5279                .unwrap_or(DiffStrategy::Full);
5280            let frame_height_hint = self.writer.render_height_hint().max(1);
5281            let key = BucketKey::from_context(
5282                self.writer.screen_mode(),
5283                diff_strategy,
5284                self.width,
5285                frame_height_hint,
5286            );
5287            let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5288            let prediction = predictor.predict(key, baseline_us, budget_us);
5289            if prediction.risk {
5290                self.budget.degrade();
5291                info!(
5292                    bucket = %prediction.bucket,
5293                    upper_us = prediction.upper_us,
5294                    budget_us = prediction.budget_us,
5295                    fallback_level = prediction.fallback_level,
5296                    degradation = self.budget.degradation().as_str(),
5297                    "conformal gate triggered strategy downgrade"
5298                );
5299                debug!(
5300                    monotonic.counter.conformal_gate_triggers_total = 1_u64,
5301                    bucket = %prediction.bucket,
5302                    "conformal gate trigger"
5303                );
5304            }
5305            debug!(
5306                bucket = %prediction.bucket,
5307                upper_us = prediction.upper_us,
5308                budget_us = prediction.budget_us,
5309                fallback = prediction.fallback_level,
5310                risk = prediction.risk,
5311                "conformal risk gate"
5312            );
5313            debug!(
5314                monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5315                bucket = %prediction.bucket,
5316                "conformal prediction interval width"
5317            );
5318            conformal_prediction = Some(prediction);
5319        }
5320
5321        // Early skip if budget says to skip this frame entirely
5322        if self.budget.exhausted() {
5323            self.budget.record_frame_time(Duration::ZERO);
5324            self.emit_budget_evidence(
5325                frame_idx,
5326                degradation_start,
5327                0.0,
5328                conformal_prediction.as_ref(),
5329            );
5330            crate::debug_trace!(
5331                "frame skipped: budget exhausted (degradation={})",
5332                self.budget.degradation().as_str()
5333            );
5334            debug!(
5335                degradation = self.budget.degradation().as_str(),
5336                "frame skipped: budget exhausted before render"
5337            );
5338            // Keep dirty=true: the UI update was never presented, so a
5339            // future frame must still pick it up.
5340            return Ok(());
5341        }
5342
5343        let auto_bounds = self.writer.inline_auto_bounds();
5344        let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
5345        let mut should_measure = needs_measure;
5346        if auto_bounds.is_some()
5347            && let Some(state) = self.inline_auto_remeasure.as_mut()
5348        {
5349            let decision = state.sampler.decide(Instant::now());
5350            if decision.should_sample {
5351                should_measure = true;
5352            }
5353        } else {
5354            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5355        }
5356
5357        // --- Render phase ---
5358        let render_start = Instant::now();
5359        if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
5360            let measure_height = if needs_measure {
5361                self.writer.render_height_hint().max(1)
5362            } else {
5363                max_height.max(1)
5364            };
5365            let (measure_buffer, _) = self.render_measure_buffer(measure_height);
5366            let measured_height = measure_buffer.content_height();
5367            let clamped = measured_height.clamp(min_height, max_height);
5368            let previous_height = self.writer.auto_ui_height();
5369            self.writer.set_auto_ui_height(clamped);
5370            if let Some(state) = self.inline_auto_remeasure.as_mut() {
5371                let threshold = state.config.change_threshold_rows;
5372                let violated = previous_height
5373                    .map(|prev| prev.abs_diff(clamped) >= threshold)
5374                    .unwrap_or(false);
5375                state.sampler.observe(violated);
5376            }
5377        }
5378        if auto_bounds.is_some()
5379            && let Some(state) = self.inline_auto_remeasure.as_ref()
5380        {
5381            let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
5382            crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
5383        }
5384
5385        let frame_height = self.writer.render_height_hint().max(1);
5386        let _frame_span = info_span!(
5387            "ftui.render.frame",
5388            width = self.width,
5389            height = frame_height,
5390            duration_us = tracing::field::Empty
5391        )
5392        .entered();
5393        let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
5394        self.update_widget_refresh_plan(frame_idx);
5395        let render_elapsed = render_start.elapsed();
5396        let mut present_elapsed = Duration::ZERO;
5397        let mut presented = false;
5398
5399        // Check if render phase overspent its budget
5400        let render_budget = self.budget.phase_budgets().render;
5401        if render_elapsed > render_budget {
5402            debug!(
5403                render_ms = render_elapsed.as_millis() as u32,
5404                budget_ms = render_budget.as_millis() as u32,
5405                "render phase exceeded budget"
5406            );
5407            // Trigger degradation if we're consistently over budget
5408            if self.budget.should_degrade(render_budget) {
5409                self.budget.degrade();
5410            }
5411        }
5412
5413        // --- Present phase ---
5414        if !self.budget.exhausted() {
5415            let present_start = Instant::now();
5416            {
5417                let _present_span = debug_span!("ftui.render.present").entered();
5418                self.writer
5419                    .present_ui_owned(buffer, cursor, cursor_visible)?;
5420            }
5421            presented = true;
5422            present_elapsed = present_start.elapsed();
5423
5424            let present_budget = self.budget.phase_budgets().present;
5425            if present_elapsed > present_budget {
5426                debug!(
5427                    present_ms = present_elapsed.as_millis() as u32,
5428                    budget_ms = present_budget.as_millis() as u32,
5429                    "present phase exceeded budget"
5430                );
5431            }
5432        } else {
5433            debug!(
5434                degradation = self.budget.degradation().as_str(),
5435                elapsed_ms = self.budget.elapsed().as_millis() as u32,
5436                "frame present skipped: budget exhausted after render"
5437            );
5438        }
5439
5440        if let Some(ref frame_timing) = self.frame_timing {
5441            let update_us = self.last_update_us.unwrap_or(0);
5442            let render_us = render_elapsed.as_micros() as u64;
5443            let present_us = present_elapsed.as_micros() as u64;
5444            let diff_us = if presented {
5445                self.writer
5446                    .take_last_present_timings()
5447                    .map(|timings| timings.diff_us)
5448                    .unwrap_or(0)
5449            } else {
5450                let _ = self.writer.take_last_present_timings();
5451                0
5452            };
5453            let total_us = update_us
5454                .saturating_add(render_us)
5455                .saturating_add(present_us);
5456            let timing = FrameTiming {
5457                frame_idx,
5458                update_us,
5459                render_us,
5460                diff_us,
5461                present_us,
5462                total_us,
5463            };
5464            frame_timing.sink.record_frame(&timing);
5465        }
5466
5467        let frame_time = render_elapsed.saturating_add(present_elapsed);
5468        self.budget.record_frame_time(frame_time);
5469        let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
5470
5471        if let (Some(predictor), Some(prediction)) = (
5472            self.conformal_predictor.as_mut(),
5473            conformal_prediction.as_ref(),
5474        ) {
5475            let diff_strategy = self
5476                .writer
5477                .last_diff_strategy()
5478                .unwrap_or(DiffStrategy::Full);
5479            let key = BucketKey::from_context(
5480                self.writer.screen_mode(),
5481                diff_strategy,
5482                self.width,
5483                frame_height,
5484            );
5485            predictor.observe(key, prediction.y_hat, frame_time_us);
5486        }
5487        self.last_frame_time_us = Some(frame_time_us);
5488        self.emit_budget_evidence(
5489            frame_idx,
5490            degradation_start,
5491            frame_time_us,
5492            conformal_prediction.as_ref(),
5493        );
5494
5495        // Only clear dirty when the frame was actually presented.
5496        // If present was skipped (budget exhausted after render), the UI
5497        // update was never shown and must be retried on the next frame.
5498        if presented {
5499            self.dirty = false;
5500        }
5501
5502        Ok(())
5503    }
5504
5505    fn emit_budget_evidence(
5506        &self,
5507        frame_idx: u64,
5508        degradation_start: DegradationLevel,
5509        frame_time_us: f64,
5510        conformal_prediction: Option<&ConformalPrediction>,
5511    ) {
5512        let Some(telemetry) = self.budget.telemetry() else {
5513            set_budget_snapshot(None);
5514            return;
5515        };
5516
5517        let budget_us = conformal_prediction
5518            .map(|prediction| prediction.budget_us)
5519            .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5520        let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
5521        let degradation_after = self.budget.degradation();
5522
5523        let evidence = BudgetDecisionEvidence {
5524            frame_idx,
5525            decision: BudgetDecisionEvidence::decision_from_levels(
5526                degradation_start,
5527                degradation_after,
5528            ),
5529            controller_decision: telemetry.last_decision,
5530            degradation_before: degradation_start,
5531            degradation_after,
5532            frame_time_us,
5533            budget_us,
5534            pid_output: telemetry.pid_output,
5535            pid_p: telemetry.pid_p,
5536            pid_i: telemetry.pid_i,
5537            pid_d: telemetry.pid_d,
5538            e_value: telemetry.e_value,
5539            frames_observed: telemetry.frames_observed,
5540            frames_since_change: telemetry.frames_since_change,
5541            in_warmup: telemetry.in_warmup,
5542            conformal,
5543        };
5544
5545        let conformal_snapshot = evidence
5546            .conformal
5547            .as_ref()
5548            .map(|snapshot| ConformalSnapshot {
5549                bucket_key: snapshot.bucket_key.clone(),
5550                sample_count: snapshot.n_b,
5551                upper_us: snapshot.upper_us,
5552                risk: snapshot.risk,
5553            });
5554        set_budget_snapshot(Some(BudgetDecisionSnapshot {
5555            frame_idx: evidence.frame_idx,
5556            decision: evidence.decision,
5557            controller_decision: evidence.controller_decision,
5558            degradation_before: evidence.degradation_before,
5559            degradation_after: evidence.degradation_after,
5560            frame_time_us: evidence.frame_time_us,
5561            budget_us: evidence.budget_us,
5562            pid_output: evidence.pid_output,
5563            e_value: evidence.e_value,
5564            frames_observed: evidence.frames_observed,
5565            frames_since_change: evidence.frames_since_change,
5566            in_warmup: evidence.in_warmup,
5567            conformal: conformal_snapshot,
5568        }));
5569
5570        if let Some(ref sink) = self.evidence_sink {
5571            let _ = sink.write_jsonl(&evidence.to_jsonl());
5572        }
5573    }
5574
5575    fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
5576        if !self.widget_refresh_config.enabled {
5577            self.widget_refresh_plan.clear();
5578            return;
5579        }
5580
5581        let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
5582        let degradation = self.budget.degradation();
5583        self.widget_refresh_plan.recompute(
5584            frame_idx,
5585            budget_us,
5586            degradation,
5587            &self.widget_signals,
5588            &self.widget_refresh_config,
5589        );
5590
5591        if let Some(ref sink) = self.evidence_sink {
5592            let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
5593        }
5594    }
5595
5596    fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
5597        // Reset the per-frame arena so widgets get fresh scratch space.
5598        self.frame_arena.reset();
5599
5600        // Note: Frame borrows the pool and links from writer.
5601        // We scope it so it drops before we call present_ui (which needs exclusive writer access).
5602        let buffer = self.writer.take_render_buffer(self.width, frame_height);
5603        let (pool, links) = self.writer.pool_and_links_mut();
5604        let mut frame = Frame::from_buffer(buffer, pool);
5605        frame.set_degradation(self.budget.degradation());
5606        frame.set_links(links);
5607        frame.set_widget_budget(self.widget_refresh_plan.as_budget());
5608        frame.set_arena(&self.frame_arena);
5609
5610        let view_start = Instant::now();
5611        let _view_span = debug_span!(
5612            "ftui.program.view",
5613            duration_us = tracing::field::Empty,
5614            widget_count = tracing::field::Empty
5615        )
5616        .entered();
5617        self.model.view(&mut frame);
5618        self.widget_signals = frame.take_widget_signals();
5619        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
5620        // widget_count would require tracking in Frame
5621
5622        (frame.buffer, frame.cursor_position, frame.cursor_visible)
5623    }
5624
5625    fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
5626        let Some(ref sink) = self.evidence_sink else {
5627            return;
5628        };
5629
5630        let config = self.fairness_guard.config();
5631        if !self.fairness_config_logged {
5632            let config_entry = FairnessConfigEvidence {
5633                enabled: config.enabled,
5634                input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
5635                dominance_threshold: config.dominance_threshold,
5636                fairness_threshold: config.fairness_threshold,
5637            };
5638            let _ = sink.write_jsonl(&config_entry.to_jsonl());
5639            self.fairness_config_logged = true;
5640        }
5641
5642        let evidence = FairnessDecisionEvidence {
5643            frame_idx: self.frame_idx,
5644            decision: if decision.should_process {
5645                "allow"
5646            } else {
5647                "yield"
5648            },
5649            reason: decision.reason.as_str(),
5650            pending_input_latency_ms: decision
5651                .pending_input_latency
5652                .map(|latency| latency.as_millis() as u64),
5653            jain_index: decision.jain_index,
5654            resize_dominance_count: dominance_count,
5655            dominance_threshold: config.dominance_threshold,
5656            fairness_threshold: config.fairness_threshold,
5657            input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
5658        };
5659
5660        let _ = sink.write_jsonl(&evidence.to_jsonl());
5661    }
5662
5663    fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
5664        // Reset the per-frame arena for measurement pass.
5665        self.frame_arena.reset();
5666
5667        let pool = self.writer.pool_mut();
5668        let mut frame = Frame::new(self.width, frame_height, pool);
5669        frame.set_degradation(self.budget.degradation());
5670        frame.set_arena(&self.frame_arena);
5671
5672        let view_start = Instant::now();
5673        let _view_span = debug_span!(
5674            "ftui.program.view",
5675            duration_us = tracing::field::Empty,
5676            widget_count = tracing::field::Empty
5677        )
5678        .entered();
5679        self.model.view(&mut frame);
5680        tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
5681
5682        (frame.buffer, frame.cursor_position)
5683    }
5684
5685    /// Calculate the effective poll timeout.
5686    fn effective_timeout(&self) -> Duration {
5687        if let Some(tick_rate) = self.tick_rate {
5688            let elapsed = self.last_tick.elapsed();
5689            let mut timeout = tick_rate.saturating_sub(elapsed);
5690            if self.resize_behavior.uses_coalescer()
5691                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
5692            {
5693                timeout = timeout.min(resize_timeout);
5694            }
5695            timeout
5696        } else {
5697            let mut timeout = self.poll_timeout;
5698            if self.resize_behavior.uses_coalescer()
5699                && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
5700            {
5701                timeout = timeout.min(resize_timeout);
5702            }
5703            timeout
5704        }
5705    }
5706
5707    /// Check if we should send a tick.
5708    fn should_tick(&mut self) -> bool {
5709        if let Some(tick_rate) = self.tick_rate
5710            && self.last_tick.elapsed() >= tick_rate
5711        {
5712            self.last_tick = Instant::now();
5713            return true;
5714        }
5715        false
5716    }
5717
5718    fn process_resize_coalescer(&mut self) -> io::Result<()> {
5719        if !self.resize_behavior.uses_coalescer() {
5720            return Ok(());
5721        }
5722
5723        // Check fairness: if input is starving, skip resize application this cycle.
5724        // This ensures input events are processed before resize is finalized.
5725        let dominance_count = self.fairness_guard.resize_dominance_count();
5726        let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
5727        self.emit_fairness_evidence(&fairness_decision, dominance_count);
5728        if !fairness_decision.should_process {
5729            debug!(
5730                reason = ?fairness_decision.reason,
5731                pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
5732                "Resize yielding to input for fairness"
5733            );
5734            // Skip resize application this cycle to allow input processing.
5735            return Ok(());
5736        }
5737
5738        let action = self.resize_coalescer.tick();
5739        let resize_snapshot =
5740            self.resize_coalescer
5741                .logs()
5742                .last()
5743                .map(|entry| ResizeDecisionSnapshot {
5744                    event_idx: entry.event_idx,
5745                    action: entry.action,
5746                    dt_ms: entry.dt_ms,
5747                    event_rate: entry.event_rate,
5748                    regime: entry.regime,
5749                    pending_size: entry.pending_size,
5750                    applied_size: entry.applied_size,
5751                    time_since_render_ms: entry.time_since_render_ms,
5752                    bocpd: self
5753                        .resize_coalescer
5754                        .bocpd()
5755                        .and_then(|detector| detector.last_evidence().cloned()),
5756                });
5757        set_resize_snapshot(resize_snapshot);
5758
5759        match action {
5760            CoalesceAction::ApplyResize {
5761                width,
5762                height,
5763                coalesce_time,
5764                forced_by_deadline,
5765            } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
5766            _ => Ok(()),
5767        }
5768    }
5769
5770    fn apply_resize(
5771        &mut self,
5772        width: u16,
5773        height: u16,
5774        coalesce_time: Duration,
5775        forced_by_deadline: bool,
5776    ) -> io::Result<()> {
5777        // Clamp to minimum 1 to prevent Buffer::new panic on zero dimensions
5778        let width = width.max(1);
5779        let height = height.max(1);
5780        self.width = width;
5781        self.height = height;
5782        self.writer.set_size(width, height);
5783        info!(
5784            width = width,
5785            height = height,
5786            coalesce_ms = coalesce_time.as_millis() as u64,
5787            forced = forced_by_deadline,
5788            "Resize applied"
5789        );
5790
5791        let msg = M::Message::from(Event::Resize { width, height });
5792        let start = Instant::now();
5793        let cmd = self.model.update(msg);
5794        let elapsed_us = start.elapsed().as_micros() as u64;
5795        self.last_update_us = Some(elapsed_us);
5796        self.mark_dirty();
5797        self.execute_cmd(cmd)?;
5798        if self.running && self.dirty {
5799            self.reconcile_subscriptions();
5800        }
5801        Ok(())
5802    }
5803
5804    // removed: resize placeholder rendering (continuous reflow preferred)
5805
5806    /// Get a reference to the model.
5807    pub fn model(&self) -> &M {
5808        &self.model
5809    }
5810
5811    /// Get a mutable reference to the model.
5812    pub fn model_mut(&mut self) -> &mut M {
5813        &mut self.model
5814    }
5815
5816    /// Check if the program is running.
5817    pub fn is_running(&self) -> bool {
5818        self.running
5819    }
5820
5821    /// Get the current tick rate, if one has been installed.
5822    #[must_use]
5823    pub const fn tick_rate(&self) -> Option<Duration> {
5824        self.tick_rate
5825    }
5826
5827    /// Get the number of commands actually executed by the runtime.
5828    #[must_use]
5829    pub const fn executed_cmd_count(&self) -> usize {
5830        self.executed_cmd_count
5831    }
5832
5833    /// Request a quit.
5834    pub fn quit(&mut self) {
5835        self.running = false;
5836    }
5837
5838    /// Get a reference to the state registry, if configured.
5839    pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
5840        self.state_registry.as_ref()
5841    }
5842
5843    /// Check if state persistence is enabled.
5844    pub fn has_persistence(&self) -> bool {
5845        self.state_registry.is_some()
5846    }
5847
5848    /// Query the current tick strategy's debug statistics.
5849    ///
5850    /// Returns key-value pairs describing the strategy's internal state
5851    /// (e.g. strategy name, divisors, confidence, transition counts).
5852    /// Returns an empty vec if no tick strategy is configured.
5853    #[must_use]
5854    pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
5855        self.tick_strategy
5856            .as_ref()
5857            .map(|s| s.debug_stats())
5858            .unwrap_or_default()
5859    }
5860
5861    /// Trigger a manual save of widget state.
5862    ///
5863    /// Returns the result of the flush operation, or `Ok(false)` if
5864    /// persistence is not configured.
5865    pub fn trigger_save(&mut self) -> StorageResult<bool> {
5866        if let Some(registry) = &self.state_registry {
5867            registry.flush()
5868        } else {
5869            Ok(false)
5870        }
5871    }
5872
5873    /// Trigger a manual load of widget state.
5874    ///
5875    /// Returns the number of entries loaded, or `Ok(0)` if persistence
5876    /// is not configured.
5877    pub fn trigger_load(&mut self) -> StorageResult<usize> {
5878        if let Some(registry) = &self.state_registry {
5879            registry.load()
5880        } else {
5881            Ok(0)
5882        }
5883    }
5884
5885    fn mark_dirty(&mut self) {
5886        self.dirty = true;
5887    }
5888
5889    fn check_locale_change(&mut self) {
5890        let version = self.locale_context.version();
5891        if version != self.locale_version {
5892            self.locale_version = version;
5893            self.mark_dirty();
5894        }
5895    }
5896
5897    /// Mark the UI as needing redraw.
5898    pub fn request_redraw(&mut self) {
5899        self.mark_dirty();
5900    }
5901
5902    /// Request a re-measure of inline auto UI height on next render.
5903    pub fn request_ui_height_remeasure(&mut self) {
5904        if self.writer.inline_auto_bounds().is_some() {
5905            self.writer.clear_auto_ui_height();
5906            if let Some(state) = self.inline_auto_remeasure.as_mut() {
5907                state.reset();
5908            }
5909            crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5910            self.mark_dirty();
5911        }
5912    }
5913
5914    /// Start recording events into a macro.
5915    ///
5916    /// If already recording, the current recording is discarded and a new one starts.
5917    /// The current terminal size is captured as metadata.
5918    pub fn start_recording(&mut self, name: impl Into<String>) {
5919        let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
5920        recorder.start();
5921        self.event_recorder = Some(recorder);
5922    }
5923
5924    /// Stop recording and return the recorded macro, if any.
5925    ///
5926    /// Returns `None` if not currently recording.
5927    pub fn stop_recording(&mut self) -> Option<InputMacro> {
5928        self.event_recorder.take().map(EventRecorder::finish)
5929    }
5930
5931    /// Check if event recording is active.
5932    pub fn is_recording(&self) -> bool {
5933        self.event_recorder
5934            .as_ref()
5935            .is_some_and(EventRecorder::is_recording)
5936    }
5937}
5938
5939/// Builder for creating and running programs.
5940pub struct App;
5941
5942impl App {
5943    /// Create a new app builder with the given model.
5944    #[allow(clippy::new_ret_no_self)] // App is a namespace for builder methods
5945    pub fn new<M: Model>(model: M) -> AppBuilder<M> {
5946        AppBuilder {
5947            model,
5948            config: ProgramConfig::default(),
5949        }
5950    }
5951
5952    /// Create a fullscreen app.
5953    pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
5954        AppBuilder {
5955            model,
5956            config: ProgramConfig::fullscreen(),
5957        }
5958    }
5959
5960    /// Create an inline app with the given height.
5961    pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
5962        AppBuilder {
5963            model,
5964            config: ProgramConfig::inline(height),
5965        }
5966    }
5967
5968    /// Create an inline app with automatic UI height.
5969    pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
5970        AppBuilder {
5971            model,
5972            config: ProgramConfig::inline_auto(min_height, max_height),
5973        }
5974    }
5975
5976    /// Create a fullscreen app from a [`StringModel`](crate::string_model::StringModel).
5977    ///
5978    /// This wraps the string model in a [`StringModelAdapter`](crate::string_model::StringModelAdapter)
5979    /// so that `view_string()` output is rendered through the standard kernel pipeline.
5980    pub fn string_model<S: crate::string_model::StringModel>(
5981        model: S,
5982    ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
5983        AppBuilder {
5984            model: crate::string_model::StringModelAdapter::new(model),
5985            config: ProgramConfig::fullscreen(),
5986        }
5987    }
5988}
5989
5990/// Builder for configuring and running programs.
5991#[must_use]
5992pub struct AppBuilder<M: Model> {
5993    model: M,
5994    config: ProgramConfig,
5995}
5996
5997impl<M: Model> AppBuilder<M> {
5998    /// Set the screen mode.
5999    pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6000        self.config.screen_mode = mode;
6001        self
6002    }
6003
6004    /// Set the UI anchor.
6005    pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6006        self.config.ui_anchor = anchor;
6007        self
6008    }
6009
6010    /// Force mouse capture on.
6011    pub fn with_mouse(mut self) -> Self {
6012        self.config.mouse_capture_policy = MouseCapturePolicy::On;
6013        self
6014    }
6015
6016    /// Set mouse capture policy for this app.
6017    pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6018        self.config.mouse_capture_policy = policy;
6019        self
6020    }
6021
6022    /// Force mouse capture enabled/disabled for this app.
6023    pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6024        self.config.mouse_capture_policy = if enabled {
6025            MouseCapturePolicy::On
6026        } else {
6027            MouseCapturePolicy::Off
6028        };
6029        self
6030    }
6031
6032    /// Set the frame budget configuration.
6033    pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6034        self.config.budget = budget;
6035        self
6036    }
6037
6038    /// Set the evidence JSONL sink configuration.
6039    pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6040        self.config.evidence_sink = config;
6041        self
6042    }
6043
6044    /// Set the render-trace recorder configuration.
6045    pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6046        self.config.render_trace = config;
6047        self
6048    }
6049
6050    /// Set the widget refresh selection configuration.
6051    pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6052        self.config.widget_refresh = config;
6053        self
6054    }
6055
6056    /// Set the effect queue scheduling configuration.
6057    pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6058        self.config.effect_queue = config;
6059        self
6060    }
6061
6062    /// Enable inline auto UI height remeasurement.
6063    pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6064        self.config.inline_auto_remeasure = Some(config);
6065        self
6066    }
6067
6068    /// Disable inline auto UI height remeasurement.
6069    pub fn without_inline_auto_remeasure(mut self) -> Self {
6070        self.config.inline_auto_remeasure = None;
6071        self
6072    }
6073
6074    /// Set the locale context used for rendering.
6075    pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6076        self.config.locale_context = locale_context;
6077        self
6078    }
6079
6080    /// Set the base locale used for rendering.
6081    pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6082        self.config.locale_context = LocaleContext::new(locale);
6083        self
6084    }
6085
6086    /// Set the resize coalescer configuration.
6087    pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6088        self.config.resize_coalescer = config;
6089        self
6090    }
6091
6092    /// Set the resize handling behavior.
6093    pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6094        self.config.resize_behavior = behavior;
6095        self
6096    }
6097
6098    /// Toggle legacy immediate-resize behavior for migration.
6099    pub fn legacy_resize(mut self, enabled: bool) -> Self {
6100        if enabled {
6101            self.config.resize_behavior = ResizeBehavior::Immediate;
6102        }
6103        self
6104    }
6105
6106    /// Set the tick strategy for selective background screen ticking.
6107    pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6108        self.config.tick_strategy = Some(strategy);
6109        self
6110    }
6111
6112    /// Run the application using the legacy Crossterm backend.
6113    #[cfg(feature = "crossterm-compat")]
6114    pub fn run(self) -> io::Result<()>
6115    where
6116        M::Message: Send + 'static,
6117    {
6118        let mut program = Program::with_config(self.model, self.config)?;
6119        let result = program.run();
6120        if let Err(ref err) = result
6121            && let Some(signal) = signal_termination_from_error(err)
6122        {
6123            drop(program);
6124            std::process::exit(128 + signal);
6125        }
6126        result
6127    }
6128
6129    /// Run the application using the native TTY backend.
6130    #[cfg(feature = "native-backend")]
6131    pub fn run_native(self) -> io::Result<()>
6132    where
6133        M::Message: Send + 'static,
6134    {
6135        let mut program = Program::with_native_backend(self.model, self.config)?;
6136        let result = program.run();
6137        if let Err(ref err) = result
6138            && let Some(signal) = signal_termination_from_error(err)
6139        {
6140            drop(program);
6141            std::process::exit(128 + signal);
6142        }
6143        result
6144    }
6145
6146    /// Run the application using the legacy Crossterm backend.
6147    #[cfg(not(feature = "crossterm-compat"))]
6148    pub fn run(self) -> io::Result<()>
6149    where
6150        M::Message: Send + 'static,
6151    {
6152        let _ = (self.model, self.config);
6153        Err(io::Error::new(
6154            io::ErrorKind::Unsupported,
6155            "enable `crossterm-compat` feature to use AppBuilder::run()",
6156        ))
6157    }
6158
6159    /// Run the application using the native TTY backend.
6160    #[cfg(not(feature = "native-backend"))]
6161    pub fn run_native(self) -> io::Result<()>
6162    where
6163        M::Message: Send + 'static,
6164    {
6165        let _ = (self.model, self.config);
6166        Err(io::Error::new(
6167            io::ErrorKind::Unsupported,
6168            "enable `native-backend` feature to use AppBuilder::run_native()",
6169        ))
6170    }
6171}
6172
6173// =============================================================================
6174// Adaptive Batch Window: Queueing Model (bd-4kq0.8.1)
6175// =============================================================================
6176//
6177// # M/G/1 Queueing Model for Event Batching
6178//
6179// ## Problem
6180//
6181// The event loop must balance two objectives:
6182// 1. **Low latency**: Process events quickly (small batch window τ).
6183// 2. **Efficiency**: Batch multiple events to amortize render cost (large τ).
6184//
6185// ## Model
6186//
6187// We model the event loop as an M/G/1 queue:
6188// - Events arrive at rate λ (Poisson process, reasonable for human input).
6189// - Service time S has mean E[S] and variance Var[S] (render + present).
6190// - Utilization ρ = λ·E[S] must be < 1 for stability.
6191//
6192// ## Pollaczek–Khinchine Mean Waiting Time
6193//
6194// For M/G/1: E[W] = (λ·E[S²]) / (2·(1 − ρ))
6195// where E[S²] = Var[S] + E[S]².
6196//
6197// ## Optimal Batch Window τ
6198//
6199// With batching window τ, we collect ~(λ·τ) events per batch, amortizing
6200// the per-frame render cost. The effective per-event latency is:
6201//
6202//   L(τ) = τ/2 + E[S]
6203//         (waiting in batch)  (service)
6204//
6205// The batch reduces arrival rate to λ_eff = 1/τ (one batch per window),
6206// giving utilization ρ_eff = E[S]/τ.
6207//
6208// Minimizing L(τ) subject to ρ_eff < 1:
6209//   L(τ) = τ/2 + E[S]
6210//   dL/dτ = 1/2  (always positive, so smaller τ is always better for latency)
6211//
6212// But we need ρ_eff < 1, so τ > E[S].
6213//
6214// The practical rule: τ = max(E[S] · headroom_factor, τ_min)
6215// where headroom_factor provides margin (typically 1.5–2.0).
6216//
6217// For high arrival rates: τ = max(E[S] · headroom, 1/λ_target)
6218// where λ_target is the max frame rate we want to sustain.
6219//
6220// ## Failure Modes
6221//
6222// 1. **Overload (ρ ≥ 1)**: Queue grows unbounded. Mitigation: increase τ
6223//    (degrade to lower frame rate), or drop stale events.
6224// 2. **Bursty arrivals**: Real input is bursty (typing, mouse drag). The
6225//    exponential moving average of λ smooths this; high burst periods
6226//    temporarily increase τ.
6227// 3. **Variable service time**: Render complexity varies per frame. Using
6228//    EMA of E[S] tracks this adaptively.
6229//
6230// ## Observable Telemetry
6231//
6232// - λ_est: Exponential moving average of inter-arrival times.
6233// - es_est: Exponential moving average of service (render) times.
6234// - ρ_est: λ_est × es_est (estimated utilization).
6235
6236/// Adaptive batch window controller based on M/G/1 queueing model.
6237///
6238/// Estimates arrival rate λ and service time E[S] from observations,
6239/// then computes the optimal batch window τ to maintain stability
6240/// (ρ < 1) while minimizing latency.
6241#[derive(Debug, Clone)]
6242pub struct BatchController {
6243    /// Exponential moving average of inter-arrival time (seconds).
6244    ema_inter_arrival_s: f64,
6245    /// Exponential moving average of service time (seconds).
6246    ema_service_s: f64,
6247    /// EMA smoothing factor (0..1). Higher = more responsive.
6248    alpha: f64,
6249    /// Minimum batch window (floor).
6250    tau_min_s: f64,
6251    /// Maximum batch window (cap for responsiveness).
6252    tau_max_s: f64,
6253    /// Headroom factor: τ >= E[S] × headroom to keep ρ < 1.
6254    headroom: f64,
6255    /// Last event arrival timestamp.
6256    last_arrival: Option<Instant>,
6257    /// Number of observations.
6258    observations: u64,
6259}
6260
6261impl BatchController {
6262    /// Create a new controller with sensible defaults.
6263    ///
6264    /// - `alpha`: EMA smoothing (default 0.2)
6265    /// - `tau_min`: minimum batch window (default 1ms)
6266    /// - `tau_max`: maximum batch window (default 50ms)
6267    /// - `headroom`: stability margin (default 2.0, keeps ρ ≤ 0.5)
6268    pub fn new() -> Self {
6269        Self {
6270            ema_inter_arrival_s: 0.1, // assume 10 events/sec initially
6271            ema_service_s: 0.002,     // assume 2ms render initially
6272            alpha: 0.2,
6273            tau_min_s: 0.001, // 1ms floor
6274            tau_max_s: 0.050, // 50ms cap
6275            headroom: 2.0,
6276            last_arrival: None,
6277            observations: 0,
6278        }
6279    }
6280
6281    /// Record an event arrival, updating the inter-arrival estimate.
6282    pub fn observe_arrival(&mut self, now: Instant) {
6283        if let Some(last) = self.last_arrival {
6284            let dt = now.saturating_duration_since(last).as_secs_f64();
6285            if dt > 0.0 && dt < 10.0 {
6286                // Guard against stale gaps (e.g., app was suspended)
6287                self.ema_inter_arrival_s =
6288                    self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
6289                self.observations += 1;
6290            }
6291        }
6292        self.last_arrival = Some(now);
6293    }
6294
6295    /// Record a service (render) time observation.
6296    pub fn observe_service(&mut self, duration: Duration) {
6297        let dt = duration.as_secs_f64();
6298        if (0.0..10.0).contains(&dt) {
6299            self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
6300        }
6301    }
6302
6303    /// Estimated arrival rate λ (events/second).
6304    #[inline]
6305    pub fn lambda_est(&self) -> f64 {
6306        if self.ema_inter_arrival_s > 0.0 {
6307            1.0 / self.ema_inter_arrival_s
6308        } else {
6309            0.0
6310        }
6311    }
6312
6313    /// Estimated service time E[S] (seconds).
6314    #[inline]
6315    pub fn service_est_s(&self) -> f64 {
6316        self.ema_service_s
6317    }
6318
6319    /// Estimated utilization ρ = λ × E[S].
6320    #[inline]
6321    pub fn rho_est(&self) -> f64 {
6322        self.lambda_est() * self.ema_service_s
6323    }
6324
6325    /// Compute the optimal batch window τ (seconds).
6326    ///
6327    /// τ = clamp(E[S] × headroom, τ_min, τ_max)
6328    ///
6329    /// When ρ approaches 1, τ increases to maintain stability.
6330    pub fn tau_s(&self) -> f64 {
6331        let base = self.ema_service_s * self.headroom;
6332        base.clamp(self.tau_min_s, self.tau_max_s)
6333    }
6334
6335    /// Compute the optimal batch window as a Duration.
6336    pub fn tau(&self) -> Duration {
6337        Duration::from_secs_f64(self.tau_s())
6338    }
6339
6340    /// Check if the system is stable (ρ < 1).
6341    #[inline]
6342    pub fn is_stable(&self) -> bool {
6343        self.rho_est() < 1.0
6344    }
6345
6346    /// Number of observations recorded.
6347    #[inline]
6348    pub fn observations(&self) -> u64 {
6349        self.observations
6350    }
6351}
6352
6353impl Default for BatchController {
6354    fn default() -> Self {
6355        Self::new()
6356    }
6357}
6358
6359#[cfg(test)]
6360mod tests {
6361    use super::*;
6362    use ftui_core::terminal_capabilities::TerminalCapabilities;
6363    use ftui_layout::PaneDragResizeEffect;
6364    use ftui_render::buffer::Buffer;
6365    use ftui_render::cell::Cell;
6366    use ftui_render::diff_strategy::DiffStrategy;
6367    use ftui_render::frame::CostEstimateSource;
6368    use serde_json::Value;
6369    use std::collections::{HashMap, VecDeque};
6370    use std::path::PathBuf;
6371    use std::sync::mpsc;
6372    use std::sync::{
6373        Arc,
6374        atomic::{AtomicUsize, Ordering},
6375    };
6376
6377    // Simple test model
6378    struct TestModel {
6379        value: i32,
6380    }
6381
6382    #[derive(Debug)]
6383    enum TestMsg {
6384        Increment,
6385        Decrement,
6386        Quit,
6387    }
6388
6389    impl From<Event> for TestMsg {
6390        fn from(_event: Event) -> Self {
6391            TestMsg::Increment
6392        }
6393    }
6394
6395    impl Model for TestModel {
6396        type Message = TestMsg;
6397
6398        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6399            match msg {
6400                TestMsg::Increment => {
6401                    self.value += 1;
6402                    Cmd::none()
6403                }
6404                TestMsg::Decrement => {
6405                    self.value -= 1;
6406                    Cmd::none()
6407                }
6408                TestMsg::Quit => Cmd::quit(),
6409            }
6410        }
6411
6412        fn view(&self, _frame: &mut Frame) {
6413            // No-op for tests
6414        }
6415    }
6416
6417    #[test]
6418    fn cmd_none() {
6419        let cmd: Cmd<TestMsg> = Cmd::none();
6420        assert!(matches!(cmd, Cmd::None));
6421    }
6422
6423    #[test]
6424    fn cmd_quit() {
6425        let cmd: Cmd<TestMsg> = Cmd::quit();
6426        assert!(matches!(cmd, Cmd::Quit));
6427    }
6428
6429    #[test]
6430    fn cmd_msg() {
6431        let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
6432        assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
6433    }
6434
6435    #[test]
6436    fn cmd_batch_empty() {
6437        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
6438        assert!(matches!(cmd, Cmd::None));
6439    }
6440
6441    #[test]
6442    fn cmd_batch_single() {
6443        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
6444        assert!(matches!(cmd, Cmd::Quit));
6445    }
6446
6447    #[test]
6448    fn cmd_batch_multiple() {
6449        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
6450        assert!(matches!(cmd, Cmd::Batch(_)));
6451    }
6452
6453    #[test]
6454    fn cmd_sequence_empty() {
6455        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
6456        assert!(matches!(cmd, Cmd::None));
6457    }
6458
6459    #[test]
6460    fn cmd_tick() {
6461        let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
6462        assert!(matches!(cmd, Cmd::Tick(_)));
6463    }
6464
6465    #[test]
6466    fn cmd_task() {
6467        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
6468        assert!(matches!(cmd, Cmd::Task(..)));
6469    }
6470
6471    #[test]
6472    fn cmd_debug_format() {
6473        let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
6474        let debug = format!("{cmd:?}");
6475        assert_eq!(
6476            debug,
6477            "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
6478        );
6479    }
6480
6481    #[test]
6482    fn model_subscriptions_default_empty() {
6483        let model = TestModel { value: 0 };
6484        let subs = model.subscriptions();
6485        assert!(subs.is_empty());
6486    }
6487
6488    #[test]
6489    fn program_config_default() {
6490        let config = ProgramConfig::default();
6491        assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
6492        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
6493        assert!(!config.resolved_mouse_capture());
6494        assert!(config.bracketed_paste);
6495        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
6496        assert!(config.inline_auto_remeasure.is_none());
6497        assert!(config.conformal_config.is_none());
6498        assert!(config.diff_config.bayesian_enabled);
6499        assert!(config.diff_config.dirty_rows_enabled);
6500        assert!(!config.resize_coalescer.enable_bocpd);
6501        assert!(!config.effect_queue.enabled);
6502        assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
6503        assert_eq!(
6504            config.immediate_drain.max_burst_duration,
6505            Duration::from_millis(2)
6506        );
6507        assert_eq!(
6508            config.immediate_drain.backoff_timeout,
6509            Duration::from_millis(1)
6510        );
6511        assert_eq!(
6512            config.resize_coalescer.steady_delay_ms,
6513            CoalescerConfig::default().steady_delay_ms
6514        );
6515    }
6516
6517    #[test]
6518    fn program_config_with_immediate_drain() {
6519        let custom = ImmediateDrainConfig {
6520            max_zero_timeout_polls_per_burst: 7,
6521            max_burst_duration: Duration::from_millis(9),
6522            backoff_timeout: Duration::from_millis(3),
6523        };
6524        let config = ProgramConfig::default().with_immediate_drain(custom.clone());
6525        assert_eq!(
6526            config.immediate_drain.max_zero_timeout_polls_per_burst,
6527            custom.max_zero_timeout_polls_per_burst
6528        );
6529        assert_eq!(
6530            config.immediate_drain.max_burst_duration,
6531            custom.max_burst_duration
6532        );
6533        assert_eq!(
6534            config.immediate_drain.backoff_timeout,
6535            custom.backoff_timeout
6536        );
6537    }
6538
6539    #[test]
6540    fn program_config_fullscreen() {
6541        let config = ProgramConfig::fullscreen();
6542        assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
6543    }
6544
6545    #[test]
6546    fn program_config_inline() {
6547        let config = ProgramConfig::inline(10);
6548        assert!(matches!(
6549            config.screen_mode,
6550            ScreenMode::Inline { ui_height: 10 }
6551        ));
6552    }
6553
6554    #[test]
6555    fn program_config_inline_auto() {
6556        let config = ProgramConfig::inline_auto(3, 9);
6557        assert!(matches!(
6558            config.screen_mode,
6559            ScreenMode::InlineAuto {
6560                min_height: 3,
6561                max_height: 9
6562            }
6563        ));
6564        assert!(config.inline_auto_remeasure.is_some());
6565    }
6566
6567    #[test]
6568    fn program_config_with_mouse() {
6569        let config = ProgramConfig::default().with_mouse();
6570        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
6571        assert!(config.resolved_mouse_capture());
6572    }
6573
6574    #[cfg(feature = "native-backend")]
6575    #[test]
6576    fn sanitize_backend_features_disables_unsupported_features() {
6577        let requested = BackendFeatures {
6578            mouse_capture: true,
6579            bracketed_paste: true,
6580            focus_events: true,
6581            kitty_keyboard: true,
6582        };
6583        let sanitized =
6584            sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
6585        assert_eq!(sanitized, BackendFeatures::default());
6586    }
6587
6588    #[cfg(feature = "native-backend")]
6589    #[test]
6590    fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
6591        let requested = BackendFeatures {
6592            mouse_capture: true,
6593            bracketed_paste: true,
6594            focus_events: true,
6595            kitty_keyboard: true,
6596        };
6597        let caps = TerminalCapabilities::builder()
6598            .mouse_sgr(true)
6599            .bracketed_paste(true)
6600            .focus_events(true)
6601            .kitty_keyboard(true)
6602            .in_wezterm_mux(true)
6603            .build();
6604        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
6605
6606        assert!(sanitized.mouse_capture);
6607        assert!(sanitized.bracketed_paste);
6608        assert!(!sanitized.focus_events);
6609        assert!(!sanitized.kitty_keyboard);
6610    }
6611
6612    #[cfg(feature = "native-backend")]
6613    #[test]
6614    fn sanitize_backend_features_is_conservative_in_tmux() {
6615        let requested = BackendFeatures {
6616            mouse_capture: true,
6617            bracketed_paste: true,
6618            focus_events: true,
6619            kitty_keyboard: true,
6620        };
6621        let caps = TerminalCapabilities::builder()
6622            .mouse_sgr(true)
6623            .bracketed_paste(true)
6624            .focus_events(true)
6625            .kitty_keyboard(true)
6626            .in_tmux(true)
6627            .build();
6628        let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
6629
6630        assert!(sanitized.mouse_capture);
6631        assert!(sanitized.bracketed_paste);
6632        assert!(!sanitized.focus_events);
6633        assert!(!sanitized.kitty_keyboard);
6634    }
6635
6636    #[test]
6637    fn program_config_mouse_policy_auto_altscreen() {
6638        let config = ProgramConfig::fullscreen();
6639        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
6640        assert!(config.resolved_mouse_capture());
6641    }
6642
6643    #[test]
6644    fn program_config_mouse_policy_force_off() {
6645        let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
6646        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
6647        assert!(!config.resolved_mouse_capture());
6648    }
6649
6650    #[test]
6651    fn program_config_mouse_policy_force_on_inline() {
6652        let config = ProgramConfig::inline(6).with_mouse_enabled(true);
6653        assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
6654        assert!(config.resolved_mouse_capture());
6655    }
6656
6657    fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
6658        PaneResizeTarget {
6659            split_id: ftui_layout::PaneId::MIN,
6660            axis,
6661        }
6662    }
6663
6664    fn pane_id(raw: u64) -> ftui_layout::PaneId {
6665        ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
6666    }
6667
6668    fn nested_pane_tree() -> ftui_layout::PaneTree {
6669        let root = pane_id(1);
6670        let left = pane_id(2);
6671        let right_split = pane_id(3);
6672        let right_top = pane_id(4);
6673        let right_bottom = pane_id(5);
6674        let snapshot = ftui_layout::PaneTreeSnapshot {
6675            schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
6676            root,
6677            next_id: pane_id(6),
6678            nodes: vec![
6679                ftui_layout::PaneNodeRecord::split(
6680                    root,
6681                    None,
6682                    ftui_layout::PaneSplit {
6683                        axis: SplitAxis::Horizontal,
6684                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
6685                        first: left,
6686                        second: right_split,
6687                    },
6688                ),
6689                ftui_layout::PaneNodeRecord::leaf(
6690                    left,
6691                    Some(root),
6692                    ftui_layout::PaneLeaf::new("left"),
6693                ),
6694                ftui_layout::PaneNodeRecord::split(
6695                    right_split,
6696                    Some(root),
6697                    ftui_layout::PaneSplit {
6698                        axis: SplitAxis::Vertical,
6699                        ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
6700                        first: right_top,
6701                        second: right_bottom,
6702                    },
6703                ),
6704                ftui_layout::PaneNodeRecord::leaf(
6705                    right_top,
6706                    Some(right_split),
6707                    ftui_layout::PaneLeaf::new("right_top"),
6708                ),
6709                ftui_layout::PaneNodeRecord::leaf(
6710                    right_bottom,
6711                    Some(right_split),
6712                    ftui_layout::PaneLeaf::new("right_bottom"),
6713                ),
6714            ],
6715            extensions: std::collections::BTreeMap::new(),
6716        };
6717        ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
6718    }
6719
6720    #[test]
6721    fn pane_terminal_splitter_resolution_is_deterministic() {
6722        let tree = nested_pane_tree();
6723        let layout = tree
6724            .solve_layout(Rect::new(0, 0, 50, 20))
6725            .expect("layout should solve");
6726        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
6727        assert_eq!(handles.len(), 2);
6728
6729        // Intersection between root vertical splitter and right-side horizontal
6730        // splitter deterministically resolves to smaller split ID.
6731        let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
6732            .expect("overlap cell should resolve");
6733        assert_eq!(overlap.split_id, pane_id(1));
6734        assert_eq!(overlap.axis, SplitAxis::Horizontal);
6735
6736        let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
6737            .expect("right split should resolve");
6738        assert_eq!(right_only.split_id, pane_id(3));
6739        assert_eq!(right_only.axis, SplitAxis::Vertical);
6740    }
6741
6742    #[test]
6743    fn pane_terminal_splitter_hits_register_and_decode_target() {
6744        let tree = nested_pane_tree();
6745        let layout = tree
6746            .solve_layout(Rect::new(0, 0, 50, 20))
6747            .expect("layout should solve");
6748        let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
6749
6750        let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
6751        let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
6752        let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
6753        assert_eq!(registered, handles.len());
6754
6755        let root_hit = frame
6756            .hit_test(25, 2)
6757            .expect("root splitter should be hittable");
6758        assert_eq!(root_hit.1, HitRegion::Handle);
6759        let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
6760        assert_eq!(root_target.split_id, pane_id(1));
6761        assert_eq!(root_target.axis, SplitAxis::Horizontal);
6762
6763        let right_hit = frame
6764            .hit_test(40, 10)
6765            .expect("right splitter should be hittable");
6766        assert_eq!(right_hit.1, HitRegion::Handle);
6767        let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
6768        assert_eq!(right_target.split_id, pane_id(3));
6769        assert_eq!(right_target.axis, SplitAxis::Vertical);
6770    }
6771
6772    #[test]
6773    fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
6774        let mut adapter =
6775            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6776        let target = pane_target(SplitAxis::Horizontal);
6777
6778        let down = Event::Mouse(MouseEvent::new(
6779            MouseEventKind::Down(MouseButton::Left),
6780            10,
6781            4,
6782        ));
6783        let down_dispatch = adapter.translate(&down, Some(target));
6784        let down_event = down_dispatch
6785            .primary_event
6786            .as_ref()
6787            .expect("pointer down semantic event");
6788        assert_eq!(down_event.sequence, 1);
6789        assert!(matches!(
6790            down_event.kind,
6791            PaneSemanticInputEventKind::PointerDown {
6792                target: actual_target,
6793                pointer_id: 1,
6794                button: PanePointerButton::Primary,
6795                position
6796            } if actual_target == target && position == PanePointerPosition::new(10, 4)
6797        ));
6798        assert!(down_event.validate().is_ok());
6799
6800        let drag = Event::Mouse(MouseEvent::new(
6801            MouseEventKind::Drag(MouseButton::Left),
6802            14,
6803            4,
6804        ));
6805        let drag_dispatch = adapter.translate(&drag, None);
6806        let drag_event = drag_dispatch
6807            .primary_event
6808            .as_ref()
6809            .expect("pointer move semantic event");
6810        assert_eq!(drag_event.sequence, 2);
6811        assert!(matches!(
6812            drag_event.kind,
6813            PaneSemanticInputEventKind::PointerMove {
6814                target: actual_target,
6815                pointer_id: 1,
6816                position,
6817                delta_x: 4,
6818                delta_y: 0
6819            } if actual_target == target && position == PanePointerPosition::new(14, 4)
6820        ));
6821        let drag_motion = drag_dispatch
6822            .motion
6823            .expect("drag should emit motion metadata");
6824        assert_eq!(drag_motion.delta_x, 4);
6825        assert_eq!(drag_motion.delta_y, 0);
6826        assert_eq!(drag_motion.direction_changes, 0);
6827        assert!(drag_motion.speed > 0.0);
6828        assert!(drag_dispatch.pressure_snap_profile().is_some());
6829
6830        let up = Event::Mouse(MouseEvent::new(
6831            MouseEventKind::Up(MouseButton::Left),
6832            14,
6833            4,
6834        ));
6835        let up_dispatch = adapter.translate(&up, None);
6836        let up_event = up_dispatch
6837            .primary_event
6838            .as_ref()
6839            .expect("pointer up semantic event");
6840        assert_eq!(up_event.sequence, 3);
6841        assert!(matches!(
6842            up_event.kind,
6843            PaneSemanticInputEventKind::PointerUp {
6844                target: actual_target,
6845                pointer_id: 1,
6846                button: PanePointerButton::Primary,
6847                position
6848            } if actual_target == target && position == PanePointerPosition::new(14, 4)
6849        ));
6850        let up_motion = up_dispatch
6851            .motion
6852            .expect("up should emit final motion metadata");
6853        assert_eq!(up_motion.delta_x, 4);
6854        assert_eq!(up_motion.delta_y, 0);
6855        assert_eq!(up_motion.direction_changes, 0);
6856        let inertial_throw = up_dispatch
6857            .inertial_throw
6858            .expect("up should emit inertial throw metadata");
6859        assert_eq!(
6860            up_dispatch.projected_position,
6861            Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
6862        );
6863        assert_eq!(adapter.active_pointer_id(), None);
6864        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
6865    }
6866
6867    #[test]
6868    fn pane_terminal_adapter_focus_loss_emits_cancel() {
6869        let mut adapter =
6870            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6871        let target = pane_target(SplitAxis::Vertical);
6872
6873        let down = Event::Mouse(MouseEvent::new(
6874            MouseEventKind::Down(MouseButton::Left),
6875            3,
6876            9,
6877        ));
6878        let _ = adapter.translate(&down, Some(target));
6879        assert_eq!(adapter.active_pointer_id(), Some(1));
6880
6881        let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
6882        let cancel_event = cancel_dispatch
6883            .primary_event
6884            .as_ref()
6885            .expect("focus-loss cancel event");
6886        assert!(matches!(
6887            cancel_event.kind,
6888            PaneSemanticInputEventKind::Cancel {
6889                target: Some(actual_target),
6890                reason: PaneCancelReason::FocusLost
6891            } if actual_target == target
6892        ));
6893        assert_eq!(adapter.active_pointer_id(), None);
6894        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
6895    }
6896
6897    #[test]
6898    fn pane_terminal_adapter_recovers_missing_mouse_up() {
6899        let mut adapter =
6900            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6901        let first_target = pane_target(SplitAxis::Horizontal);
6902        let second_target = pane_target(SplitAxis::Vertical);
6903
6904        let first_down = Event::Mouse(MouseEvent::new(
6905            MouseEventKind::Down(MouseButton::Left),
6906            5,
6907            5,
6908        ));
6909        let _ = adapter.translate(&first_down, Some(first_target));
6910
6911        let second_down = Event::Mouse(MouseEvent::new(
6912            MouseEventKind::Down(MouseButton::Left),
6913            8,
6914            11,
6915        ));
6916        let dispatch = adapter.translate(&second_down, Some(second_target));
6917        let recovery = dispatch
6918            .recovery_event
6919            .as_ref()
6920            .expect("recovery cancel expected");
6921        assert!(matches!(
6922            recovery.kind,
6923            PaneSemanticInputEventKind::Cancel {
6924                target: Some(actual_target),
6925                reason: PaneCancelReason::PointerCancel
6926            } if actual_target == first_target
6927        ));
6928        let primary = dispatch
6929            .primary_event
6930            .as_ref()
6931            .expect("second pointer down expected");
6932        assert!(matches!(
6933            primary.kind,
6934            PaneSemanticInputEventKind::PointerDown {
6935                target: actual_target,
6936                pointer_id: 1,
6937                button: PanePointerButton::Primary,
6938                position
6939            } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
6940        ));
6941        assert_eq!(recovery.sequence, 2);
6942        assert_eq!(primary.sequence, 3);
6943        assert!(matches!(
6944            dispatch.log.outcome,
6945            PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
6946        ));
6947        assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
6948    }
6949
6950    #[test]
6951    fn pane_terminal_adapter_modifier_parity() {
6952        let mut adapter =
6953            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6954        let target = pane_target(SplitAxis::Horizontal);
6955
6956        let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
6957            .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
6958        let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
6959        let event = dispatch.primary_event.expect("semantic event");
6960        assert!(event.modifiers.shift);
6961        assert!(event.modifiers.alt);
6962        assert!(event.modifiers.ctrl);
6963        assert!(event.modifiers.meta);
6964    }
6965
6966    #[test]
6967    fn pane_terminal_adapter_keyboard_resize_mapping() {
6968        let mut adapter =
6969            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
6970        let target = pane_target(SplitAxis::Horizontal);
6971
6972        let key = KeyEvent::new(KeyCode::Right);
6973        let dispatch = adapter.translate(&Event::Key(key), Some(target));
6974        let event = dispatch.primary_event.expect("keyboard resize event");
6975        assert!(matches!(
6976            event.kind,
6977            PaneSemanticInputEventKind::KeyboardResize {
6978                target: actual_target,
6979                direction: PaneResizeDirection::Increase,
6980                units: 1
6981            } if actual_target == target
6982        ));
6983
6984        let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
6985        let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
6986        let shifted_event = shifted_dispatch
6987            .primary_event
6988            .expect("shifted resize event");
6989        assert!(matches!(
6990            shifted_event.kind,
6991            PaneSemanticInputEventKind::KeyboardResize {
6992                direction: PaneResizeDirection::Increase,
6993                units: 5,
6994                ..
6995            }
6996        ));
6997        assert!(shifted_event.modifiers.shift);
6998    }
6999
7000    #[test]
7001    fn pane_terminal_adapter_keyboard_resize_requires_focus() {
7002        let mut adapter =
7003            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7004        let target = pane_target(SplitAxis::Horizontal);
7005
7006        let _ = adapter.translate(&Event::Focus(false), None);
7007        assert!(!adapter.window_focused());
7008
7009        let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7010        assert!(unfocused.primary_event.is_none());
7011        assert!(matches!(
7012            unfocused.log.outcome,
7013            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
7014        ));
7015
7016        let _ = adapter.translate(&Event::Focus(true), None);
7017        assert!(adapter.window_focused());
7018
7019        let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7020        assert!(focused.primary_event.is_some());
7021    }
7022
7023    #[test]
7024    fn pane_terminal_adapter_drag_updates_are_coalesced() {
7025        let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
7026            drag_update_coalesce_distance: 2,
7027            ..PaneTerminalAdapterConfig::default()
7028        })
7029        .expect("valid adapter");
7030        let target = pane_target(SplitAxis::Horizontal);
7031
7032        let down = Event::Mouse(MouseEvent::new(
7033            MouseEventKind::Down(MouseButton::Left),
7034            10,
7035            4,
7036        ));
7037        let _ = adapter.translate(&down, Some(target));
7038
7039        let drag_start = Event::Mouse(MouseEvent::new(
7040            MouseEventKind::Drag(MouseButton::Left),
7041            14,
7042            4,
7043        ));
7044        let started = adapter.translate(&drag_start, None);
7045        assert!(started.primary_event.is_some());
7046        assert!(matches!(
7047            adapter.machine_state(),
7048            PaneDragResizeState::Dragging { .. }
7049        ));
7050
7051        let coalesced = Event::Mouse(MouseEvent::new(
7052            MouseEventKind::Drag(MouseButton::Left),
7053            15,
7054            4,
7055        ));
7056        let coalesced_dispatch = adapter.translate(&coalesced, None);
7057        assert!(coalesced_dispatch.primary_event.is_none());
7058        assert!(matches!(
7059            coalesced_dispatch.log.outcome,
7060            PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
7061        ));
7062
7063        let forwarded = Event::Mouse(MouseEvent::new(
7064            MouseEventKind::Drag(MouseButton::Left),
7065            16,
7066            4,
7067        ));
7068        let forwarded_dispatch = adapter.translate(&forwarded, None);
7069        let forwarded_event = forwarded_dispatch
7070            .primary_event
7071            .as_ref()
7072            .expect("coalesced movement should flush once threshold reached");
7073        assert!(matches!(
7074            forwarded_event.kind,
7075            PaneSemanticInputEventKind::PointerMove {
7076                delta_x: 2,
7077                delta_y: 0,
7078                ..
7079            }
7080        ));
7081    }
7082
7083    #[test]
7084    fn pane_terminal_adapter_motion_tracks_direction_changes() {
7085        let mut adapter =
7086            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7087        let target = pane_target(SplitAxis::Horizontal);
7088
7089        let down = Event::Mouse(MouseEvent::new(
7090            MouseEventKind::Down(MouseButton::Left),
7091            10,
7092            4,
7093        ));
7094        let _ = adapter.translate(&down, Some(target));
7095
7096        let drag_forward = Event::Mouse(MouseEvent::new(
7097            MouseEventKind::Drag(MouseButton::Left),
7098            14,
7099            4,
7100        ));
7101        let forward_dispatch = adapter.translate(&drag_forward, None);
7102        let forward_motion = forward_dispatch
7103            .motion
7104            .expect("forward drag should emit motion metadata");
7105        assert_eq!(forward_motion.direction_changes, 0);
7106
7107        let drag_reverse = Event::Mouse(MouseEvent::new(
7108            MouseEventKind::Drag(MouseButton::Left),
7109            12,
7110            4,
7111        ));
7112        let reverse_dispatch = adapter.translate(&drag_reverse, None);
7113        let reverse_motion = reverse_dispatch
7114            .motion
7115            .expect("reverse drag should emit motion metadata");
7116        assert_eq!(reverse_motion.direction_changes, 1);
7117
7118        let up = Event::Mouse(MouseEvent::new(
7119            MouseEventKind::Up(MouseButton::Left),
7120            12,
7121            4,
7122        ));
7123        let up_dispatch = adapter.translate(&up, None);
7124        let up_motion = up_dispatch
7125            .motion
7126            .expect("release should include cumulative motion metadata");
7127        assert_eq!(up_motion.direction_changes, 1);
7128    }
7129
7130    #[test]
7131    fn pane_terminal_adapter_translate_with_handles_resolves_target() {
7132        let tree = nested_pane_tree();
7133        let layout = tree
7134            .solve_layout(Rect::new(0, 0, 50, 20))
7135            .expect("layout should solve");
7136        let handles =
7137            pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
7138        let mut adapter =
7139            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7140
7141        let down = Event::Mouse(MouseEvent::new(
7142            MouseEventKind::Down(MouseButton::Left),
7143            25,
7144            10,
7145        ));
7146        let dispatch = adapter.translate_with_handles(&down, &handles);
7147        let event = dispatch
7148            .primary_event
7149            .as_ref()
7150            .expect("pointer down should be routed from handles");
7151        assert!(matches!(
7152            event.kind,
7153            PaneSemanticInputEventKind::PointerDown {
7154                target:
7155                    PaneResizeTarget {
7156                        split_id,
7157                        axis: SplitAxis::Horizontal
7158                    },
7159                ..
7160            } if split_id == pane_id(1)
7161        ));
7162    }
7163
7164    #[test]
7165    fn model_update() {
7166        let mut model = TestModel { value: 0 };
7167        model.update(TestMsg::Increment);
7168        assert_eq!(model.value, 1);
7169        model.update(TestMsg::Decrement);
7170        assert_eq!(model.value, 0);
7171        assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
7172    }
7173
7174    #[test]
7175    fn model_init_default() {
7176        let mut model = TestModel { value: 0 };
7177        let cmd = model.init();
7178        assert!(matches!(cmd, Cmd::None));
7179    }
7180
7181    // Resize coalescer behavior is covered by resize_coalescer.rs tests.
7182
7183    // =========================================================================
7184    // DETERMINISM TESTS - Program loop determinism (bd-2nu8.10.1)
7185    // =========================================================================
7186
7187    #[test]
7188    fn cmd_sequence_executes_in_order() {
7189        // Verify that Cmd::Sequence executes commands in declared order
7190        use crate::simulator::ProgramSimulator;
7191
7192        struct SeqModel {
7193            trace: Vec<i32>,
7194        }
7195
7196        #[derive(Debug)]
7197        enum SeqMsg {
7198            Append(i32),
7199            TriggerSequence,
7200        }
7201
7202        impl From<Event> for SeqMsg {
7203            fn from(_: Event) -> Self {
7204                SeqMsg::Append(0)
7205            }
7206        }
7207
7208        impl Model for SeqModel {
7209            type Message = SeqMsg;
7210
7211            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7212                match msg {
7213                    SeqMsg::Append(n) => {
7214                        self.trace.push(n);
7215                        Cmd::none()
7216                    }
7217                    SeqMsg::TriggerSequence => Cmd::sequence(vec![
7218                        Cmd::msg(SeqMsg::Append(1)),
7219                        Cmd::msg(SeqMsg::Append(2)),
7220                        Cmd::msg(SeqMsg::Append(3)),
7221                    ]),
7222                }
7223            }
7224
7225            fn view(&self, _frame: &mut Frame) {}
7226        }
7227
7228        let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
7229        sim.init();
7230        sim.send(SeqMsg::TriggerSequence);
7231
7232        assert_eq!(sim.model().trace, vec![1, 2, 3]);
7233    }
7234
7235    #[test]
7236    fn cmd_batch_executes_all_regardless_of_order() {
7237        // Verify that Cmd::Batch executes all commands
7238        use crate::simulator::ProgramSimulator;
7239
7240        struct BatchModel {
7241            values: Vec<i32>,
7242        }
7243
7244        #[derive(Debug)]
7245        enum BatchMsg {
7246            Add(i32),
7247            TriggerBatch,
7248        }
7249
7250        impl From<Event> for BatchMsg {
7251            fn from(_: Event) -> Self {
7252                BatchMsg::Add(0)
7253            }
7254        }
7255
7256        impl Model for BatchModel {
7257            type Message = BatchMsg;
7258
7259            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7260                match msg {
7261                    BatchMsg::Add(n) => {
7262                        self.values.push(n);
7263                        Cmd::none()
7264                    }
7265                    BatchMsg::TriggerBatch => Cmd::batch(vec![
7266                        Cmd::msg(BatchMsg::Add(10)),
7267                        Cmd::msg(BatchMsg::Add(20)),
7268                        Cmd::msg(BatchMsg::Add(30)),
7269                    ]),
7270                }
7271            }
7272
7273            fn view(&self, _frame: &mut Frame) {}
7274        }
7275
7276        let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
7277        sim.init();
7278        sim.send(BatchMsg::TriggerBatch);
7279
7280        // All values should be present
7281        assert_eq!(sim.model().values.len(), 3);
7282        assert!(sim.model().values.contains(&10));
7283        assert!(sim.model().values.contains(&20));
7284        assert!(sim.model().values.contains(&30));
7285    }
7286
7287    #[test]
7288    fn cmd_sequence_stops_on_quit() {
7289        // Verify that Cmd::Sequence stops processing after Quit
7290        use crate::simulator::ProgramSimulator;
7291
7292        struct SeqQuitModel {
7293            trace: Vec<i32>,
7294        }
7295
7296        #[derive(Debug)]
7297        enum SeqQuitMsg {
7298            Append(i32),
7299            TriggerSequenceWithQuit,
7300        }
7301
7302        impl From<Event> for SeqQuitMsg {
7303            fn from(_: Event) -> Self {
7304                SeqQuitMsg::Append(0)
7305            }
7306        }
7307
7308        impl Model for SeqQuitModel {
7309            type Message = SeqQuitMsg;
7310
7311            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7312                match msg {
7313                    SeqQuitMsg::Append(n) => {
7314                        self.trace.push(n);
7315                        Cmd::none()
7316                    }
7317                    SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
7318                        Cmd::msg(SeqQuitMsg::Append(1)),
7319                        Cmd::quit(),
7320                        Cmd::msg(SeqQuitMsg::Append(2)), // Should not execute
7321                    ]),
7322                }
7323            }
7324
7325            fn view(&self, _frame: &mut Frame) {}
7326        }
7327
7328        let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
7329        sim.init();
7330        sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
7331
7332        assert_eq!(sim.model().trace, vec![1]);
7333        assert!(!sim.is_running());
7334    }
7335
7336    #[test]
7337    fn identical_input_produces_identical_state() {
7338        // Verify deterministic state transitions
7339        use crate::simulator::ProgramSimulator;
7340
7341        fn run_scenario() -> Vec<i32> {
7342            struct DetModel {
7343                values: Vec<i32>,
7344            }
7345
7346            #[derive(Debug, Clone)]
7347            enum DetMsg {
7348                Add(i32),
7349                Double,
7350            }
7351
7352            impl From<Event> for DetMsg {
7353                fn from(_: Event) -> Self {
7354                    DetMsg::Add(1)
7355                }
7356            }
7357
7358            impl Model for DetModel {
7359                type Message = DetMsg;
7360
7361                fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7362                    match msg {
7363                        DetMsg::Add(n) => {
7364                            self.values.push(n);
7365                            Cmd::none()
7366                        }
7367                        DetMsg::Double => {
7368                            if let Some(&last) = self.values.last() {
7369                                self.values.push(last * 2);
7370                            }
7371                            Cmd::none()
7372                        }
7373                    }
7374                }
7375
7376                fn view(&self, _frame: &mut Frame) {}
7377            }
7378
7379            let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
7380            sim.init();
7381            sim.send(DetMsg::Add(5));
7382            sim.send(DetMsg::Double);
7383            sim.send(DetMsg::Add(3));
7384            sim.send(DetMsg::Double);
7385
7386            sim.model().values.clone()
7387        }
7388
7389        // Run the same scenario multiple times
7390        let run1 = run_scenario();
7391        let run2 = run_scenario();
7392        let run3 = run_scenario();
7393
7394        assert_eq!(run1, run2);
7395        assert_eq!(run2, run3);
7396        assert_eq!(run1, vec![5, 10, 3, 6]);
7397    }
7398
7399    #[test]
7400    fn identical_state_produces_identical_render() {
7401        // Verify consistent render outputs for identical inputs
7402        use crate::simulator::ProgramSimulator;
7403
7404        struct RenderModel {
7405            counter: i32,
7406        }
7407
7408        #[derive(Debug)]
7409        enum RenderMsg {
7410            Set(i32),
7411        }
7412
7413        impl From<Event> for RenderMsg {
7414            fn from(_: Event) -> Self {
7415                RenderMsg::Set(0)
7416            }
7417        }
7418
7419        impl Model for RenderModel {
7420            type Message = RenderMsg;
7421
7422            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7423                match msg {
7424                    RenderMsg::Set(n) => {
7425                        self.counter = n;
7426                        Cmd::none()
7427                    }
7428                }
7429            }
7430
7431            fn view(&self, frame: &mut Frame) {
7432                let text = format!("Value: {}", self.counter);
7433                for (i, c) in text.chars().enumerate() {
7434                    if (i as u16) < frame.width() {
7435                        use ftui_render::cell::Cell;
7436                        frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
7437                    }
7438                }
7439            }
7440        }
7441
7442        // Create two simulators with the same state
7443        let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
7444        let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
7445
7446        let buf1 = sim1.capture_frame(80, 24);
7447        let buf2 = sim2.capture_frame(80, 24);
7448
7449        // Compare buffer contents
7450        for y in 0..24 {
7451            for x in 0..80 {
7452                let cell1 = buf1.get(x, y).unwrap();
7453                let cell2 = buf2.get(x, y).unwrap();
7454                assert_eq!(
7455                    cell1.content.as_char(),
7456                    cell2.content.as_char(),
7457                    "Mismatch at ({}, {})",
7458                    x,
7459                    y
7460                );
7461            }
7462        }
7463    }
7464
7465    // Resize coalescer timing invariants are covered in resize_coalescer.rs tests.
7466
7467    #[test]
7468    fn cmd_log_creates_log_command() {
7469        let cmd: Cmd<TestMsg> = Cmd::log("test message");
7470        assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
7471    }
7472
7473    #[test]
7474    fn cmd_log_from_string() {
7475        let msg = String::from("dynamic message");
7476        let cmd: Cmd<TestMsg> = Cmd::log(msg);
7477        assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
7478    }
7479
7480    #[test]
7481    fn program_simulator_logs_jsonl_with_seed_and_run_id() {
7482        // Ensure ProgramSimulator captures JSONL log lines with run_id/seed.
7483        use crate::simulator::ProgramSimulator;
7484
7485        struct LogModel {
7486            run_id: &'static str,
7487            seed: u64,
7488        }
7489
7490        #[derive(Debug)]
7491        enum LogMsg {
7492            Emit,
7493        }
7494
7495        impl From<Event> for LogMsg {
7496            fn from(_: Event) -> Self {
7497                LogMsg::Emit
7498            }
7499        }
7500
7501        impl Model for LogModel {
7502            type Message = LogMsg;
7503
7504            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7505                let line = format!(
7506                    r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
7507                    self.run_id, self.seed
7508                );
7509                Cmd::log(line)
7510            }
7511
7512            fn view(&self, _frame: &mut Frame) {}
7513        }
7514
7515        let mut sim = ProgramSimulator::new(LogModel {
7516            run_id: "test-run-001",
7517            seed: 4242,
7518        });
7519        sim.init();
7520        sim.send(LogMsg::Emit);
7521
7522        let logs = sim.logs();
7523        assert_eq!(logs.len(), 1);
7524        assert!(logs[0].contains(r#""run_id":"test-run-001""#));
7525        assert!(logs[0].contains(r#""seed":4242"#));
7526    }
7527
7528    #[test]
7529    fn cmd_sequence_single_unwraps() {
7530        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
7531        // Single element sequence should unwrap to the inner command
7532        assert!(matches!(cmd, Cmd::Quit));
7533    }
7534
7535    #[test]
7536    fn cmd_sequence_multiple() {
7537        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
7538        assert!(matches!(cmd, Cmd::Sequence(_)));
7539    }
7540
7541    #[test]
7542    fn cmd_default_is_none() {
7543        let cmd: Cmd<TestMsg> = Cmd::default();
7544        assert!(matches!(cmd, Cmd::None));
7545    }
7546
7547    #[test]
7548    fn cmd_debug_all_variants() {
7549        // Test Debug impl for all variants
7550        let none: Cmd<TestMsg> = Cmd::none();
7551        assert_eq!(format!("{none:?}"), "None");
7552
7553        let quit: Cmd<TestMsg> = Cmd::quit();
7554        assert_eq!(format!("{quit:?}"), "Quit");
7555
7556        let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7557        assert!(format!("{msg:?}").starts_with("Msg("));
7558
7559        let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
7560        assert!(format!("{batch:?}").starts_with("Batch("));
7561
7562        let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
7563        assert!(format!("{seq:?}").starts_with("Sequence("));
7564
7565        let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
7566        assert!(format!("{tick:?}").starts_with("Tick("));
7567
7568        let log: Cmd<TestMsg> = Cmd::log("test");
7569        assert!(format!("{log:?}").starts_with("Log("));
7570    }
7571
7572    #[test]
7573    fn program_config_with_budget() {
7574        let budget = FrameBudgetConfig {
7575            total: Duration::from_millis(50),
7576            ..Default::default()
7577        };
7578        let config = ProgramConfig::default().with_budget(budget);
7579        assert_eq!(config.budget.total, Duration::from_millis(50));
7580    }
7581
7582    #[test]
7583    fn program_config_with_conformal() {
7584        let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
7585            alpha: 0.2,
7586            ..Default::default()
7587        });
7588        assert!(config.conformal_config.is_some());
7589        assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
7590    }
7591
7592    #[test]
7593    fn program_config_forced_size_clamps_minimums() {
7594        let config = ProgramConfig::default().with_forced_size(0, 0);
7595        assert_eq!(config.forced_size, Some((1, 1)));
7596
7597        let cleared = config.without_forced_size();
7598        assert!(cleared.forced_size.is_none());
7599    }
7600
7601    #[test]
7602    fn effect_queue_config_defaults_are_safe() {
7603        let config = EffectQueueConfig::default();
7604        assert!(!config.enabled);
7605        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
7606        assert!(config.scheduler.smith_enabled);
7607        assert!(!config.scheduler.preemptive);
7608        assert_eq!(config.scheduler.aging_factor, 0.0);
7609        assert_eq!(config.scheduler.wait_starve_ms, 0.0);
7610    }
7611
7612    #[test]
7613    fn handle_effect_command_enqueues_or_executes_inline() {
7614        let (result_tx, result_rx) = mpsc::channel::<u32>();
7615        let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
7616        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7617
7618        let ran = Arc::new(AtomicUsize::new(0));
7619        let ran_task = ran.clone();
7620        let cmd = EffectCommand::Enqueue(
7621            TaskSpec::default(),
7622            Box::new(move || {
7623                ran_task.fetch_add(1, Ordering::SeqCst);
7624                7
7625            }),
7626        );
7627
7628        let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
7629        assert_eq!(shutdown, EffectLoopControl::Continue);
7630        assert_eq!(ran.load(Ordering::SeqCst), 0);
7631        assert_eq!(tasks.len(), 1);
7632        assert!(result_rx.try_recv().is_err());
7633
7634        let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
7635            max_queue_size: 0,
7636            ..Default::default()
7637        });
7638        let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7639        let ran_full = Arc::new(AtomicUsize::new(0));
7640        let ran_full_task = ran_full.clone();
7641        let cmd_full = EffectCommand::Enqueue(
7642            TaskSpec::default(),
7643            Box::new(move || {
7644                ran_full_task.fetch_add(1, Ordering::SeqCst);
7645                42
7646            }),
7647        );
7648
7649        let shutdown_full = handle_effect_command(
7650            cmd_full,
7651            &mut full_scheduler,
7652            &mut full_tasks,
7653            &result_tx,
7654            None,
7655            0,
7656        );
7657        assert_eq!(shutdown_full, EffectLoopControl::Continue);
7658        assert!(full_tasks.is_empty());
7659        assert_eq!(ran_full.load(Ordering::SeqCst), 1);
7660        assert_eq!(
7661            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
7662            42
7663        );
7664
7665        let shutdown = handle_effect_command(
7666            EffectCommand::Shutdown,
7667            &mut full_scheduler,
7668            &mut full_tasks,
7669            &result_tx,
7670            None,
7671            0,
7672        );
7673        assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
7674    }
7675
7676    #[test]
7677    fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
7678        let evidence_path = temp_evidence_path("task_executor_backpressure");
7679        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
7680        let sink = EvidenceSink::from_config(&sink_config)
7681            .expect("evidence sink config")
7682            .expect("evidence sink enabled");
7683        let (result_tx, result_rx) = mpsc::channel::<u32>();
7684        let mut scheduler = QueueingScheduler::new(SchedulerConfig {
7685            max_queue_size: 0,
7686            ..Default::default()
7687        });
7688        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7689
7690        let shutdown = handle_effect_command(
7691            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
7692            &mut scheduler,
7693            &mut tasks,
7694            &result_tx,
7695            Some(&sink),
7696            0,
7697        );
7698
7699        assert_eq!(shutdown, EffectLoopControl::Continue);
7700        assert!(tasks.is_empty());
7701        assert_eq!(
7702            result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
7703            7
7704        );
7705
7706        let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
7707        assert_eq!(backpressure_line["backend"], "queued");
7708        assert_eq!(backpressure_line["action"], "inline_fallback");
7709        assert_eq!(backpressure_line["max_queue_size"], 0);
7710        assert_eq!(backpressure_line["total_rejected"], 1);
7711
7712        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
7713        assert_eq!(completion_line["backend"], "queued-inline-fallback");
7714        assert!(completion_line["duration_us"].is_number());
7715    }
7716
7717    #[test]
7718    fn effect_queue_loop_executes_tasks_and_shutdowns() {
7719        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7720        let (result_tx, result_rx) = mpsc::channel::<u32>();
7721        let config = EffectQueueConfig {
7722            enabled: true,
7723            backend: TaskExecutorBackend::EffectQueue,
7724            scheduler: SchedulerConfig {
7725                preemptive: false,
7726                ..Default::default()
7727            },
7728            explicit_backend: true,
7729            ..Default::default()
7730        };
7731
7732        let handle = std::thread::spawn(move || {
7733            effect_queue_loop(config, cmd_rx, result_tx, None);
7734        });
7735
7736        cmd_tx
7737            .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
7738            .unwrap();
7739        cmd_tx
7740            .send(EffectCommand::Enqueue(
7741                TaskSpec::new(2.0, 5.0).with_name("second"),
7742                Box::new(|| 20),
7743            ))
7744            .unwrap();
7745
7746        let mut results = vec![
7747            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7748            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7749        ];
7750        results.sort_unstable();
7751        assert_eq!(results, vec![10, 20]);
7752
7753        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7754        let _ = handle.join();
7755    }
7756
7757    #[test]
7758    fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
7759        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7760        let (result_tx, result_rx) = mpsc::channel::<u32>();
7761        let config = EffectQueueConfig {
7762            enabled: true,
7763            backend: TaskExecutorBackend::EffectQueue,
7764            scheduler: SchedulerConfig {
7765                preemptive: false,
7766                ..Default::default()
7767            },
7768            explicit_backend: true,
7769            ..Default::default()
7770        };
7771
7772        let handle = std::thread::spawn(move || {
7773            effect_queue_loop(config, cmd_rx, result_tx, None);
7774        });
7775
7776        cmd_tx
7777            .send(EffectCommand::Enqueue(
7778                TaskSpec::default().with_name("slow"),
7779                Box::new(|| {
7780                    std::thread::sleep(Duration::from_millis(20));
7781                    10
7782                }),
7783            ))
7784            .unwrap();
7785        cmd_tx
7786            .send(EffectCommand::Enqueue(
7787                TaskSpec::new(2.0, 5.0).with_name("fast"),
7788                Box::new(|| 20),
7789            ))
7790            .unwrap();
7791        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7792
7793        let mut results = vec![
7794            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7795            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7796        ];
7797        results.sort_unstable();
7798        assert_eq!(results, vec![10, 20]);
7799
7800        handle
7801            .join()
7802            .expect("effect queue thread joins after draining");
7803    }
7804
7805    #[test]
7806    fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
7807        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7808        let (result_tx, result_rx) = mpsc::channel::<u32>();
7809        let config = EffectQueueConfig {
7810            enabled: true,
7811            backend: TaskExecutorBackend::EffectQueue,
7812            scheduler: SchedulerConfig {
7813                preemptive: false,
7814                ..Default::default()
7815            },
7816            explicit_backend: true,
7817            ..Default::default()
7818        };
7819
7820        let handle = std::thread::spawn(move || {
7821            effect_queue_loop(config, cmd_rx, result_tx, None);
7822        });
7823
7824        cmd_tx
7825            .send(EffectCommand::Enqueue(
7826                TaskSpec::new(3.0, 1.0).with_name("panic"),
7827                Box::new(|| panic!("queued panic")),
7828            ))
7829            .unwrap();
7830        cmd_tx
7831            .send(EffectCommand::Enqueue(
7832                TaskSpec::new(1.0, 5.0).with_name("after"),
7833                Box::new(|| 99),
7834            ))
7835            .unwrap();
7836
7837        assert_eq!(
7838            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7839            99
7840        );
7841
7842        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7843        handle
7844            .join()
7845            .expect("effect queue thread survives task panic");
7846    }
7847
7848    #[test]
7849    fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
7850        let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
7851        let (result_tx, result_rx) = mpsc::channel::<u32>();
7852        let config = EffectQueueConfig {
7853            enabled: true,
7854            backend: TaskExecutorBackend::EffectQueue,
7855            scheduler: SchedulerConfig {
7856                preemptive: false,
7857                ..Default::default()
7858            },
7859            explicit_backend: true,
7860            ..Default::default()
7861        };
7862
7863        let handle = std::thread::spawn(move || {
7864            effect_queue_loop(config, cmd_rx, result_tx, None);
7865        });
7866
7867        cmd_tx
7868            .send(EffectCommand::Enqueue(
7869                TaskSpec::default().with_name("slow"),
7870                Box::new(|| {
7871                    std::thread::sleep(Duration::from_millis(20));
7872                    10
7873                }),
7874            ))
7875            .unwrap();
7876        cmd_tx.send(EffectCommand::Shutdown).unwrap();
7877        cmd_tx
7878            .send(EffectCommand::Enqueue(
7879                TaskSpec::new(1.0, 1.0).with_name("late"),
7880                Box::new(|| 99),
7881            ))
7882            .unwrap();
7883
7884        assert_eq!(
7885            result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
7886            10
7887        );
7888        assert!(
7889            result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
7890            "post-shutdown enqueue should not execute"
7891        );
7892
7893        handle
7894            .join()
7895            .expect("effect queue thread joins after rejecting post-shutdown work");
7896    }
7897
7898    #[test]
7899    fn effect_queue_enqueue_after_shutdown_records_drop() {
7900        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
7901        drop(rx);
7902
7903        let queue = EffectQueue {
7904            sender: tx,
7905            handle: None,
7906            closed: true,
7907        };
7908        let runs = Arc::new(AtomicUsize::new(0));
7909        let before = crate::effect_system::effects_queue_dropped();
7910
7911        queue.enqueue(
7912            TaskSpec::default(),
7913            Box::new({
7914                let runs = Arc::clone(&runs);
7915                move || {
7916                    runs.fetch_add(1, Ordering::SeqCst);
7917                    7
7918                }
7919            }),
7920        );
7921
7922        let after = crate::effect_system::effects_queue_dropped();
7923        assert_eq!(runs.load(Ordering::SeqCst), 0);
7924        assert!(
7925            after > before,
7926            "enqueue after shutdown should increment dropped counter"
7927        );
7928    }
7929
7930    #[test]
7931    fn effect_queue_enqueue_with_closed_channel_records_drop() {
7932        let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
7933        drop(rx);
7934
7935        let queue = EffectQueue {
7936            sender: tx,
7937            handle: None,
7938            closed: false,
7939        };
7940        let runs = Arc::new(AtomicUsize::new(0));
7941        let before = crate::effect_system::effects_queue_dropped();
7942
7943        queue.enqueue(
7944            TaskSpec::default(),
7945            Box::new({
7946                let runs = Arc::clone(&runs);
7947                move || {
7948                    runs.fetch_add(1, Ordering::SeqCst);
7949                    9
7950                }
7951            }),
7952        );
7953
7954        let after = crate::effect_system::effects_queue_dropped();
7955        assert_eq!(runs.load(Ordering::SeqCst), 0);
7956        assert!(
7957            after > before,
7958            "enqueue into a closed queue channel should increment dropped counter"
7959        );
7960    }
7961
7962    // =========================================================================
7963    // Backpressure tests (bd-2zd0a)
7964    // =========================================================================
7965
7966    #[test]
7967    fn backpressure_drops_tasks_beyond_max_depth() {
7968        let (result_tx, _result_rx) = mpsc::channel::<u32>();
7969        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
7970        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
7971
7972        // Enqueue 2 tasks with max_depth=2 — should succeed
7973        let r1 = handle_effect_command(
7974            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
7975            &mut scheduler,
7976            &mut tasks,
7977            &result_tx,
7978            None,
7979            2,
7980        );
7981        assert_eq!(r1, EffectLoopControl::Continue);
7982        assert_eq!(tasks.len(), 1);
7983
7984        let r2 = handle_effect_command(
7985            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
7986            &mut scheduler,
7987            &mut tasks,
7988            &result_tx,
7989            None,
7990            2,
7991        );
7992        assert_eq!(r2, EffectLoopControl::Continue);
7993        assert_eq!(tasks.len(), 2);
7994
7995        // 3rd task should be dropped (depth=2 >= max_depth=2)
7996        let dropped_before = crate::effect_system::effects_queue_dropped();
7997        let r3 = handle_effect_command(
7998            EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
7999            &mut scheduler,
8000            &mut tasks,
8001            &result_tx,
8002            None,
8003            2,
8004        );
8005        assert_eq!(r3, EffectLoopControl::Continue);
8006        assert_eq!(
8007            tasks.len(),
8008            2,
8009            "task should have been dropped, not enqueued"
8010        );
8011        assert!(
8012            crate::effect_system::effects_queue_dropped() > dropped_before,
8013            "dropped counter should increment"
8014        );
8015    }
8016
8017    #[test]
8018    fn backpressure_zero_depth_means_unbounded() {
8019        let (result_tx, _result_rx) = mpsc::channel::<u32>();
8020        let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
8021        let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8022
8023        // With max_depth=0, can enqueue many tasks
8024        for i in 0..20 {
8025            let r = handle_effect_command(
8026                EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
8027                &mut scheduler,
8028                &mut tasks,
8029                &result_tx,
8030                None,
8031                0,
8032            );
8033            assert_eq!(r, EffectLoopControl::Continue);
8034        }
8035        // All should be enqueued (some may have been inlined by scheduler, but none dropped)
8036    }
8037
8038    #[test]
8039    fn inline_auto_remeasure_reset_clears_decision() {
8040        let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
8041        state.sampler.decide(Instant::now());
8042        assert!(state.sampler.last_decision().is_some());
8043
8044        state.reset();
8045        assert!(state.sampler.last_decision().is_none());
8046    }
8047
8048    #[test]
8049    fn budget_decision_jsonl_contains_required_fields() {
8050        let evidence = BudgetDecisionEvidence {
8051            frame_idx: 7,
8052            decision: BudgetDecision::Degrade,
8053            controller_decision: BudgetDecision::Hold,
8054            degradation_before: DegradationLevel::Full,
8055            degradation_after: DegradationLevel::NoStyling,
8056            frame_time_us: 12_345.678,
8057            budget_us: 16_000.0,
8058            pid_output: 1.25,
8059            pid_p: 0.5,
8060            pid_i: 0.25,
8061            pid_d: 0.5,
8062            e_value: 2.0,
8063            frames_observed: 42,
8064            frames_since_change: 3,
8065            in_warmup: false,
8066            conformal: Some(ConformalEvidence {
8067                bucket_key: "inline:dirty:10".to_string(),
8068                n_b: 32,
8069                alpha: 0.05,
8070                q_b: 1000.0,
8071                y_hat: 12_000.0,
8072                upper_us: 13_000.0,
8073                risk: true,
8074                fallback_level: 1,
8075                window_size: 256,
8076                reset_count: 2,
8077            }),
8078        };
8079
8080        let jsonl = evidence.to_jsonl();
8081        assert!(jsonl.contains("\"event\":\"budget_decision\""));
8082        assert!(jsonl.contains("\"decision\":\"degrade\""));
8083        assert!(jsonl.contains("\"decision_controller\":\"stay\""));
8084        assert!(jsonl.contains("\"degradation_before\":\"Full\""));
8085        assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
8086        assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
8087        assert!(jsonl.contains("\"budget_us\":16000.000000"));
8088        assert!(jsonl.contains("\"pid_output\":1.250000"));
8089        assert!(jsonl.contains("\"e_value\":2.000000"));
8090        assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
8091        assert!(jsonl.contains("\"n_b\":32"));
8092        assert!(jsonl.contains("\"alpha\":0.050000"));
8093        assert!(jsonl.contains("\"q_b\":1000.000000"));
8094        assert!(jsonl.contains("\"y_hat\":12000.000000"));
8095        assert!(jsonl.contains("\"upper_us\":13000.000000"));
8096        assert!(jsonl.contains("\"risk\":true"));
8097        assert!(jsonl.contains("\"fallback_level\":1"));
8098        assert!(jsonl.contains("\"window_size\":256"));
8099        assert!(jsonl.contains("\"reset_count\":2"));
8100    }
8101
8102    fn make_signal(
8103        widget_id: u64,
8104        essential: bool,
8105        priority: f32,
8106        staleness_ms: u64,
8107        cost_us: f32,
8108    ) -> WidgetSignal {
8109        WidgetSignal {
8110            widget_id,
8111            essential,
8112            priority,
8113            staleness_ms,
8114            focus_boost: 0.0,
8115            interaction_boost: 0.0,
8116            area_cells: 1,
8117            cost_estimate_us: cost_us,
8118            recent_cost_us: 0.0,
8119            estimate_source: CostEstimateSource::FixedDefault,
8120        }
8121    }
8122
8123    fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
8124        let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
8125        let staleness_window = config.staleness_window_ms.max(1) as f32;
8126        let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
8127        let mut value = config.weight_priority * signal.priority
8128            + config.weight_staleness * staleness_score
8129            + config.weight_focus * signal.focus_boost
8130            + config.weight_interaction * signal.interaction_boost;
8131        if starved {
8132            value += config.starve_boost;
8133        }
8134        let raw_cost = if signal.recent_cost_us > 0.0 {
8135            signal.recent_cost_us
8136        } else {
8137            signal.cost_estimate_us
8138        };
8139        let cost_us = raw_cost.max(config.min_cost_us);
8140        (value, cost_us, starved)
8141    }
8142
8143    fn fifo_select(
8144        signals: &[WidgetSignal],
8145        budget_us: f64,
8146        config: &WidgetRefreshConfig,
8147    ) -> (Vec<u64>, f64, usize) {
8148        let mut selected = Vec::new();
8149        let mut total_value = 0.0f64;
8150        let mut starved_selected = 0usize;
8151        let mut remaining = budget_us;
8152
8153        for signal in signals {
8154            if !signal.essential {
8155                continue;
8156            }
8157            let (value, cost_us, starved) = signal_value_cost(signal, config);
8158            remaining -= cost_us as f64;
8159            total_value += value as f64;
8160            if starved {
8161                starved_selected = starved_selected.saturating_add(1);
8162            }
8163            selected.push(signal.widget_id);
8164        }
8165        for signal in signals {
8166            if signal.essential {
8167                continue;
8168            }
8169            let (value, cost_us, starved) = signal_value_cost(signal, config);
8170            if remaining >= cost_us as f64 {
8171                remaining -= cost_us as f64;
8172                total_value += value as f64;
8173                if starved {
8174                    starved_selected = starved_selected.saturating_add(1);
8175                }
8176                selected.push(signal.widget_id);
8177            }
8178        }
8179
8180        (selected, total_value, starved_selected)
8181    }
8182
8183    fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
8184        if signals.is_empty() {
8185            return Vec::new();
8186        }
8187        let mut rotated = Vec::with_capacity(signals.len());
8188        for idx in 0..signals.len() {
8189            rotated.push(signals[(idx + offset) % signals.len()].clone());
8190        }
8191        rotated
8192    }
8193
8194    #[test]
8195    fn widget_refresh_selects_essentials_first() {
8196        let signals = vec![
8197            make_signal(1, true, 0.6, 0, 5.0),
8198            make_signal(2, false, 0.9, 0, 4.0),
8199        ];
8200        let mut plan = WidgetRefreshPlan::new();
8201        let config = WidgetRefreshConfig::default();
8202        plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
8203        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8204        assert_eq!(selected, vec![1]);
8205        assert!(!plan.over_budget);
8206    }
8207
8208    #[test]
8209    fn widget_refresh_degradation_essential_only_skips_nonessential() {
8210        let signals = vec![
8211            make_signal(1, true, 0.5, 0, 2.0),
8212            make_signal(2, false, 1.0, 0, 1.0),
8213        ];
8214        let mut plan = WidgetRefreshPlan::new();
8215        let config = WidgetRefreshConfig::default();
8216        plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
8217        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8218        assert_eq!(selected, vec![1]);
8219        assert_eq!(plan.skipped_count, 1);
8220    }
8221
8222    #[test]
8223    fn widget_refresh_starvation_guard_forces_one_starved() {
8224        let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
8225        let mut plan = WidgetRefreshPlan::new();
8226        let config = WidgetRefreshConfig {
8227            starve_ms: 1_000,
8228            max_starved_per_frame: 1,
8229            ..Default::default()
8230        };
8231        plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
8232        assert_eq!(plan.selected.len(), 1);
8233        assert!(plan.selected[0].starved);
8234        assert!(plan.over_budget);
8235    }
8236
8237    #[test]
8238    fn widget_refresh_budget_blocks_when_no_selection() {
8239        let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
8240        let mut plan = WidgetRefreshPlan::new();
8241        let config = WidgetRefreshConfig {
8242            starve_ms: 0,
8243            max_starved_per_frame: 0,
8244            ..Default::default()
8245        };
8246        plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
8247        let budget = plan.as_budget();
8248        assert!(!budget.allows(42, false));
8249    }
8250
8251    #[test]
8252    fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
8253        let signals = vec![
8254            make_signal(1, false, 0.4, 0, 10.0),
8255            make_signal(2, false, 0.4, 0, 10.0),
8256            make_signal(3, false, 0.4, 0, 10.0),
8257            make_signal(4, false, 0.4, 0, 10.0),
8258        ];
8259        let mut plan = WidgetRefreshPlan::new();
8260        let config = WidgetRefreshConfig {
8261            starve_ms: 0,
8262            max_starved_per_frame: 0,
8263            max_drop_fraction: 0.5,
8264            ..Default::default()
8265        };
8266        plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
8267        let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8268        assert_eq!(selected, vec![1, 2]);
8269    }
8270
8271    #[test]
8272    fn widget_refresh_greedy_beats_fifo_and_round_robin() {
8273        let signals = vec![
8274            make_signal(1, false, 0.1, 0, 6.0),
8275            make_signal(2, false, 0.2, 0, 6.0),
8276            make_signal(3, false, 1.0, 0, 4.0),
8277            make_signal(4, false, 0.9, 0, 3.0),
8278            make_signal(5, false, 0.8, 0, 3.0),
8279            make_signal(6, false, 0.1, 4_000, 2.0),
8280        ];
8281        let budget_us = 10.0;
8282        let config = WidgetRefreshConfig::default();
8283
8284        let mut plan = WidgetRefreshPlan::new();
8285        plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
8286        let greedy_value = plan.selected_value;
8287        let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
8288
8289        let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
8290        let rotated = rotate_signals(&signals, 2);
8291        let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
8292
8293        assert!(
8294            greedy_value > fifo_value,
8295            "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
8296            greedy_selected,
8297            fifo_selected
8298        );
8299        assert!(
8300            greedy_value > rr_value,
8301            "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
8302            greedy_selected,
8303            rr_selected
8304        );
8305        assert!(
8306            plan.starved_selected > 0,
8307            "greedy did not select starved widget; greedy={:?}",
8308            greedy_selected
8309        );
8310    }
8311
8312    #[test]
8313    fn widget_refresh_jsonl_contains_required_fields() {
8314        let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
8315        let mut plan = WidgetRefreshPlan::new();
8316        let config = WidgetRefreshConfig::default();
8317        plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
8318        let jsonl = plan.to_jsonl();
8319        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
8320        assert!(jsonl.contains("\"frame_idx\":9"));
8321        assert!(jsonl.contains("\"selected_count\":1"));
8322        assert!(jsonl.contains("\"id\":7"));
8323    }
8324
8325    #[test]
8326    fn program_config_with_resize_coalescer() {
8327        let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
8328            steady_delay_ms: 8,
8329            burst_delay_ms: 20,
8330            hard_deadline_ms: 80,
8331            burst_enter_rate: 12.0,
8332            burst_exit_rate: 6.0,
8333            cooldown_frames: 2,
8334            rate_window_size: 6,
8335            enable_logging: true,
8336            enable_bocpd: false,
8337            bocpd_config: None,
8338        });
8339        assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
8340        assert!(config.resize_coalescer.enable_logging);
8341    }
8342
8343    #[test]
8344    fn program_config_with_resize_behavior() {
8345        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
8346        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
8347    }
8348
8349    #[test]
8350    fn program_config_with_legacy_resize_enabled() {
8351        let config = ProgramConfig::default().with_legacy_resize(true);
8352        assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
8353    }
8354
8355    #[test]
8356    fn program_config_with_legacy_resize_disabled_keeps_default() {
8357        let config = ProgramConfig::default().with_legacy_resize(false);
8358        assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
8359    }
8360
8361    fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
8362        let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
8363        let mut writer = TerminalWriter::with_diff_config(
8364            Vec::<u8>::new(),
8365            ScreenMode::AltScreen,
8366            UiAnchor::Bottom,
8367            TerminalCapabilities::basic(),
8368            config,
8369        );
8370        writer.set_size(8, 4);
8371
8372        let mut buffer = Buffer::new(8, 4);
8373        let mut trace = Vec::new();
8374
8375        writer.present_ui(&buffer, None, false).unwrap();
8376        trace.push(
8377            writer
8378                .last_diff_strategy()
8379                .unwrap_or(DiffStrategy::FullRedraw),
8380        );
8381
8382        buffer.set_raw(0, 0, Cell::from_char('A'));
8383        writer.present_ui(&buffer, None, false).unwrap();
8384        trace.push(
8385            writer
8386                .last_diff_strategy()
8387                .unwrap_or(DiffStrategy::FullRedraw),
8388        );
8389
8390        buffer.set_raw(1, 1, Cell::from_char('B'));
8391        writer.present_ui(&buffer, None, false).unwrap();
8392        trace.push(
8393            writer
8394                .last_diff_strategy()
8395                .unwrap_or(DiffStrategy::FullRedraw),
8396        );
8397
8398        trace
8399    }
8400
8401    fn coalescer_checksum(enable_bocpd: bool) -> String {
8402        let mut config = CoalescerConfig::default().with_logging(true);
8403        if enable_bocpd {
8404            config = config.with_bocpd();
8405        }
8406
8407        let base = Instant::now();
8408        let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
8409
8410        let events = [
8411            (0_u64, (82_u16, 24_u16)),
8412            (10, (83, 25)),
8413            (20, (84, 26)),
8414            (35, (90, 28)),
8415            (55, (92, 30)),
8416        ];
8417
8418        let mut idx = 0usize;
8419        for t_ms in (0_u64..=160).step_by(8) {
8420            let now = base + Duration::from_millis(t_ms);
8421            while idx < events.len() && events[idx].0 == t_ms {
8422                let (w, h) = events[idx].1;
8423                coalescer.handle_resize_at(w, h, now);
8424                idx += 1;
8425            }
8426            coalescer.tick_at(now);
8427        }
8428
8429        coalescer.decision_checksum_hex()
8430    }
8431
8432    fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
8433        if !enabled {
8434            return Vec::new();
8435        }
8436
8437        let mut predictor = ConformalPredictor::new(ConformalConfig::default());
8438        let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
8439        let mut trace = Vec::new();
8440
8441        for i in 0..30 {
8442            let y_hat = 16_000.0 + (i as f64) * 15.0;
8443            let observed = y_hat + (i % 7) as f64 * 120.0;
8444            predictor.observe(key, y_hat, observed);
8445            let prediction = predictor.predict(key, y_hat, 20_000.0);
8446            trace.push((prediction.upper_us, prediction.risk));
8447        }
8448
8449        trace
8450    }
8451
8452    #[test]
8453    fn policy_toggle_matrix_determinism() {
8454        for &bayesian in &[false, true] {
8455            for &bocpd in &[false, true] {
8456                for &conformal in &[false, true] {
8457                    let diff_a = diff_strategy_trace(bayesian);
8458                    let diff_b = diff_strategy_trace(bayesian);
8459                    assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
8460
8461                    let checksum_a = coalescer_checksum(bocpd);
8462                    let checksum_b = coalescer_checksum(bocpd);
8463                    assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
8464
8465                    let conf_a = conformal_trace(conformal);
8466                    let conf_b = conformal_trace(conformal);
8467                    assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
8468
8469                    if conformal {
8470                        assert!(!conf_a.is_empty(), "conformal trace should be populated");
8471                    } else {
8472                        assert!(conf_a.is_empty(), "conformal trace should be empty");
8473                    }
8474                }
8475            }
8476        }
8477    }
8478
8479    #[test]
8480    fn resize_behavior_uses_coalescer_flag() {
8481        assert!(ResizeBehavior::Throttled.uses_coalescer());
8482        assert!(!ResizeBehavior::Immediate.uses_coalescer());
8483    }
8484
8485    #[test]
8486    fn nested_cmd_msg_executes_recursively() {
8487        // Verify that Cmd::Msg triggers recursive update
8488        use crate::simulator::ProgramSimulator;
8489
8490        struct NestedModel {
8491            depth: usize,
8492        }
8493
8494        #[derive(Debug)]
8495        enum NestedMsg {
8496            Nest(usize),
8497        }
8498
8499        impl From<Event> for NestedMsg {
8500            fn from(_: Event) -> Self {
8501                NestedMsg::Nest(0)
8502            }
8503        }
8504
8505        impl Model for NestedModel {
8506            type Message = NestedMsg;
8507
8508            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8509                match msg {
8510                    NestedMsg::Nest(n) => {
8511                        self.depth += 1;
8512                        if n > 0 {
8513                            Cmd::msg(NestedMsg::Nest(n - 1))
8514                        } else {
8515                            Cmd::none()
8516                        }
8517                    }
8518                }
8519            }
8520
8521            fn view(&self, _frame: &mut Frame) {}
8522        }
8523
8524        let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
8525        sim.init();
8526        sim.send(NestedMsg::Nest(3));
8527
8528        // Should have recursed 4 times (3, 2, 1, 0)
8529        assert_eq!(sim.model().depth, 4);
8530    }
8531
8532    #[test]
8533    fn task_executes_synchronously_in_simulator() {
8534        // In simulator, tasks execute synchronously
8535        use crate::simulator::ProgramSimulator;
8536
8537        struct TaskModel {
8538            completed: bool,
8539        }
8540
8541        #[derive(Debug)]
8542        enum TaskMsg {
8543            Complete,
8544            SpawnTask,
8545        }
8546
8547        impl From<Event> for TaskMsg {
8548            fn from(_: Event) -> Self {
8549                TaskMsg::Complete
8550            }
8551        }
8552
8553        impl Model for TaskModel {
8554            type Message = TaskMsg;
8555
8556            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8557                match msg {
8558                    TaskMsg::Complete => {
8559                        self.completed = true;
8560                        Cmd::none()
8561                    }
8562                    TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
8563                }
8564            }
8565
8566            fn view(&self, _frame: &mut Frame) {}
8567        }
8568
8569        let mut sim = ProgramSimulator::new(TaskModel { completed: false });
8570        sim.init();
8571        sim.send(TaskMsg::SpawnTask);
8572
8573        // Task should have completed synchronously
8574        assert!(sim.model().completed);
8575    }
8576
8577    #[test]
8578    fn multiple_updates_accumulate_correctly() {
8579        // Verify state accumulates correctly across multiple updates
8580        use crate::simulator::ProgramSimulator;
8581
8582        struct AccumModel {
8583            sum: i32,
8584        }
8585
8586        #[derive(Debug)]
8587        enum AccumMsg {
8588            Add(i32),
8589            Multiply(i32),
8590        }
8591
8592        impl From<Event> for AccumMsg {
8593            fn from(_: Event) -> Self {
8594                AccumMsg::Add(1)
8595            }
8596        }
8597
8598        impl Model for AccumModel {
8599            type Message = AccumMsg;
8600
8601            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8602                match msg {
8603                    AccumMsg::Add(n) => {
8604                        self.sum += n;
8605                        Cmd::none()
8606                    }
8607                    AccumMsg::Multiply(n) => {
8608                        self.sum *= n;
8609                        Cmd::none()
8610                    }
8611                }
8612            }
8613
8614            fn view(&self, _frame: &mut Frame) {}
8615        }
8616
8617        let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
8618        sim.init();
8619
8620        // (0 + 5) * 2 + 3 = 13
8621        sim.send(AccumMsg::Add(5));
8622        sim.send(AccumMsg::Multiply(2));
8623        sim.send(AccumMsg::Add(3));
8624
8625        assert_eq!(sim.model().sum, 13);
8626    }
8627
8628    #[test]
8629    fn init_command_executes_before_first_update() {
8630        // Verify init() command executes before any update
8631        use crate::simulator::ProgramSimulator;
8632
8633        struct InitModel {
8634            initialized: bool,
8635            updates: usize,
8636        }
8637
8638        #[derive(Debug)]
8639        enum InitMsg {
8640            Update,
8641            MarkInit,
8642        }
8643
8644        impl From<Event> for InitMsg {
8645            fn from(_: Event) -> Self {
8646                InitMsg::Update
8647            }
8648        }
8649
8650        impl Model for InitModel {
8651            type Message = InitMsg;
8652
8653            fn init(&mut self) -> Cmd<Self::Message> {
8654                Cmd::msg(InitMsg::MarkInit)
8655            }
8656
8657            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8658                match msg {
8659                    InitMsg::MarkInit => {
8660                        self.initialized = true;
8661                        Cmd::none()
8662                    }
8663                    InitMsg::Update => {
8664                        self.updates += 1;
8665                        Cmd::none()
8666                    }
8667                }
8668            }
8669
8670            fn view(&self, _frame: &mut Frame) {}
8671        }
8672
8673        let mut sim = ProgramSimulator::new(InitModel {
8674            initialized: false,
8675            updates: 0,
8676        });
8677        sim.init();
8678
8679        assert!(sim.model().initialized);
8680        sim.send(InitMsg::Update);
8681        assert_eq!(sim.model().updates, 1);
8682    }
8683
8684    // =========================================================================
8685    // INLINE MODE FRAME SIZING TESTS (bd-20vg)
8686    // =========================================================================
8687
8688    #[test]
8689    fn ui_height_returns_correct_value_inline_mode() {
8690        // Verify TerminalWriter.ui_height() returns ui_height in inline mode
8691        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8692        use ftui_core::terminal_capabilities::TerminalCapabilities;
8693
8694        let output = Vec::new();
8695        let writer = TerminalWriter::new(
8696            output,
8697            ScreenMode::Inline { ui_height: 10 },
8698            UiAnchor::Bottom,
8699            TerminalCapabilities::basic(),
8700        );
8701        assert_eq!(writer.ui_height(), 10);
8702    }
8703
8704    #[test]
8705    fn ui_height_returns_term_height_altscreen_mode() {
8706        // Verify TerminalWriter.ui_height() returns full terminal height in alt-screen mode
8707        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8708        use ftui_core::terminal_capabilities::TerminalCapabilities;
8709
8710        let output = Vec::new();
8711        let mut writer = TerminalWriter::new(
8712            output,
8713            ScreenMode::AltScreen,
8714            UiAnchor::Bottom,
8715            TerminalCapabilities::basic(),
8716        );
8717        writer.set_size(80, 24);
8718        assert_eq!(writer.ui_height(), 24);
8719    }
8720
8721    #[test]
8722    fn inline_mode_frame_uses_ui_height_not_terminal_height() {
8723        // Verify that in inline mode, the model receives a frame with ui_height,
8724        // not the full terminal height. This is the core fix for bd-20vg.
8725        use crate::simulator::ProgramSimulator;
8726        use std::cell::Cell as StdCell;
8727
8728        thread_local! {
8729            static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
8730        }
8731
8732        struct FrameSizeTracker;
8733
8734        #[derive(Debug)]
8735        enum SizeMsg {
8736            Check,
8737        }
8738
8739        impl From<Event> for SizeMsg {
8740            fn from(_: Event) -> Self {
8741                SizeMsg::Check
8742            }
8743        }
8744
8745        impl Model for FrameSizeTracker {
8746            type Message = SizeMsg;
8747
8748            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
8749                Cmd::none()
8750            }
8751
8752            fn view(&self, frame: &mut Frame) {
8753                // Capture the frame height we receive
8754                CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
8755            }
8756        }
8757
8758        // Use simulator to verify frame dimension handling
8759        let mut sim = ProgramSimulator::new(FrameSizeTracker);
8760        sim.init();
8761
8762        // Capture with specific dimensions (simulates inline mode ui_height=10)
8763        let buf = sim.capture_frame(80, 10);
8764        assert_eq!(buf.height(), 10);
8765        assert_eq!(buf.width(), 80);
8766
8767        // Verify the frame has the correct dimensions
8768        // In inline mode with ui_height=10, the frame should be 10 rows tall,
8769        // NOT the full terminal height (e.g., 24).
8770    }
8771
8772    #[test]
8773    fn altscreen_frame_uses_full_terminal_height() {
8774        // Regression test: in alt-screen mode, frame should use full terminal height.
8775        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8776        use ftui_core::terminal_capabilities::TerminalCapabilities;
8777
8778        let output = Vec::new();
8779        let mut writer = TerminalWriter::new(
8780            output,
8781            ScreenMode::AltScreen,
8782            UiAnchor::Bottom,
8783            TerminalCapabilities::basic(),
8784        );
8785        writer.set_size(80, 40);
8786
8787        // In alt-screen, ui_height equals terminal height
8788        assert_eq!(writer.ui_height(), 40);
8789    }
8790
8791    #[test]
8792    fn ui_height_clamped_to_terminal_height() {
8793        // Verify ui_height doesn't exceed terminal height
8794        // (This is handled in present_inline, but ui_height() returns the configured value)
8795        use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
8796        use ftui_core::terminal_capabilities::TerminalCapabilities;
8797
8798        let output = Vec::new();
8799        let mut writer = TerminalWriter::new(
8800            output,
8801            ScreenMode::Inline { ui_height: 100 },
8802            UiAnchor::Bottom,
8803            TerminalCapabilities::basic(),
8804        );
8805        writer.set_size(80, 10);
8806
8807        // ui_height() returns configured value, but present_inline clamps
8808        // The Frame should be created with ui_height (100), which is later
8809        // clamped during presentation. For safety, we should use the min.
8810        // Note: This documents current behavior. A stricter fix might
8811        // have ui_height() return min(ui_height, term_height).
8812        assert_eq!(writer.ui_height(), 100);
8813    }
8814
8815    // =========================================================================
8816    // TICK DELIVERY TESTS (bd-3ufh)
8817    // =========================================================================
8818
8819    #[test]
8820    fn tick_event_delivered_to_model_update() {
8821        // Verify that Event::Tick is delivered to model.update()
8822        // This is the core fix: ticks now flow through the update pipeline.
8823        use crate::simulator::ProgramSimulator;
8824
8825        struct TickTracker {
8826            tick_count: usize,
8827        }
8828
8829        #[derive(Debug)]
8830        enum TickMsg {
8831            Tick,
8832            Other,
8833        }
8834
8835        impl From<Event> for TickMsg {
8836            fn from(event: Event) -> Self {
8837                match event {
8838                    Event::Tick => TickMsg::Tick,
8839                    _ => TickMsg::Other,
8840                }
8841            }
8842        }
8843
8844        impl Model for TickTracker {
8845            type Message = TickMsg;
8846
8847            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8848                match msg {
8849                    TickMsg::Tick => {
8850                        self.tick_count += 1;
8851                        Cmd::none()
8852                    }
8853                    TickMsg::Other => Cmd::none(),
8854                }
8855            }
8856
8857            fn view(&self, _frame: &mut Frame) {}
8858        }
8859
8860        let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
8861        sim.init();
8862
8863        // Manually inject tick event to simulate what the runtime does
8864        sim.inject_event(Event::Tick);
8865        assert_eq!(sim.model().tick_count, 1);
8866
8867        sim.inject_event(Event::Tick);
8868        sim.inject_event(Event::Tick);
8869        assert_eq!(sim.model().tick_count, 3);
8870    }
8871
8872    #[test]
8873    fn tick_command_sets_tick_rate() {
8874        // Verify Cmd::tick() sets the tick rate in the simulator
8875        use crate::simulator::{CmdRecord, ProgramSimulator};
8876
8877        struct TickModel;
8878
8879        #[derive(Debug)]
8880        enum Msg {
8881            SetTick,
8882            Noop,
8883        }
8884
8885        impl From<Event> for Msg {
8886            fn from(_: Event) -> Self {
8887                Msg::Noop
8888            }
8889        }
8890
8891        impl Model for TickModel {
8892            type Message = Msg;
8893
8894            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8895                match msg {
8896                    Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
8897                    Msg::Noop => Cmd::none(),
8898                }
8899            }
8900
8901            fn view(&self, _frame: &mut Frame) {}
8902        }
8903
8904        let mut sim = ProgramSimulator::new(TickModel);
8905        sim.init();
8906        sim.send(Msg::SetTick);
8907
8908        // Check that tick was recorded
8909        let commands = sim.command_log();
8910        assert!(
8911            commands
8912                .iter()
8913                .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
8914        );
8915    }
8916
8917    #[test]
8918    fn tick_can_trigger_further_commands() {
8919        // Verify that tick handling can return commands that are executed
8920        use crate::simulator::ProgramSimulator;
8921
8922        struct ChainModel {
8923            stage: usize,
8924        }
8925
8926        #[derive(Debug)]
8927        enum ChainMsg {
8928            Tick,
8929            Advance,
8930            Noop,
8931        }
8932
8933        impl From<Event> for ChainMsg {
8934            fn from(event: Event) -> Self {
8935                match event {
8936                    Event::Tick => ChainMsg::Tick,
8937                    _ => ChainMsg::Noop,
8938                }
8939            }
8940        }
8941
8942        impl Model for ChainModel {
8943            type Message = ChainMsg;
8944
8945            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8946                match msg {
8947                    ChainMsg::Tick => {
8948                        self.stage += 1;
8949                        // Return another message to be processed
8950                        Cmd::msg(ChainMsg::Advance)
8951                    }
8952                    ChainMsg::Advance => {
8953                        self.stage += 10;
8954                        Cmd::none()
8955                    }
8956                    ChainMsg::Noop => Cmd::none(),
8957                }
8958            }
8959
8960            fn view(&self, _frame: &mut Frame) {}
8961        }
8962
8963        let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
8964        sim.init();
8965        sim.inject_event(Event::Tick);
8966
8967        // Tick increments by 1, then Advance increments by 10
8968        assert_eq!(sim.model().stage, 11);
8969    }
8970
8971    #[test]
8972    fn tick_disabled_with_zero_duration() {
8973        // Verify that Duration::ZERO disables ticks (no busy loop)
8974        use crate::simulator::ProgramSimulator;
8975
8976        struct ZeroTickModel {
8977            disabled: bool,
8978        }
8979
8980        #[derive(Debug)]
8981        enum ZeroMsg {
8982            DisableTick,
8983            Noop,
8984        }
8985
8986        impl From<Event> for ZeroMsg {
8987            fn from(_: Event) -> Self {
8988                ZeroMsg::Noop
8989            }
8990        }
8991
8992        impl Model for ZeroTickModel {
8993            type Message = ZeroMsg;
8994
8995            fn init(&mut self) -> Cmd<Self::Message> {
8996                // Start with a tick enabled
8997                Cmd::tick(Duration::from_millis(100))
8998            }
8999
9000            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9001                match msg {
9002                    ZeroMsg::DisableTick => {
9003                        self.disabled = true;
9004                        // Setting tick to ZERO should effectively disable
9005                        Cmd::tick(Duration::ZERO)
9006                    }
9007                    ZeroMsg::Noop => Cmd::none(),
9008                }
9009            }
9010
9011            fn view(&self, _frame: &mut Frame) {}
9012        }
9013
9014        let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
9015        sim.init();
9016
9017        // Verify initial tick rate is set
9018        assert!(sim.tick_rate().is_some());
9019        assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
9020
9021        // Disable ticks
9022        sim.send(ZeroMsg::DisableTick);
9023        assert!(sim.model().disabled);
9024
9025        // Note: The simulator still records the ZERO tick, but the runtime's
9026        // should_tick() handles ZERO duration appropriately
9027        assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
9028    }
9029
9030    #[test]
9031    fn tick_event_distinguishable_from_other_events() {
9032        // Verify Event::Tick can be distinguished in pattern matching
9033        let tick = Event::Tick;
9034        let key = Event::Key(ftui_core::event::KeyEvent::new(
9035            ftui_core::event::KeyCode::Char('a'),
9036        ));
9037
9038        assert!(matches!(tick, Event::Tick));
9039        assert!(!matches!(key, Event::Tick));
9040    }
9041
9042    #[test]
9043    fn tick_event_clone_and_eq() {
9044        // Verify Event::Tick implements Clone and Eq correctly
9045        let tick1 = Event::Tick;
9046        let tick2 = tick1.clone();
9047        assert_eq!(tick1, tick2);
9048    }
9049
9050    #[test]
9051    fn model_receives_tick_and_input_events() {
9052        // Verify model can handle both tick and input events correctly
9053        use crate::simulator::ProgramSimulator;
9054
9055        struct MixedModel {
9056            ticks: usize,
9057            keys: usize,
9058        }
9059
9060        #[derive(Debug)]
9061        enum MixedMsg {
9062            Tick,
9063            Key,
9064        }
9065
9066        impl From<Event> for MixedMsg {
9067            fn from(event: Event) -> Self {
9068                match event {
9069                    Event::Tick => MixedMsg::Tick,
9070                    _ => MixedMsg::Key,
9071                }
9072            }
9073        }
9074
9075        impl Model for MixedModel {
9076            type Message = MixedMsg;
9077
9078            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9079                match msg {
9080                    MixedMsg::Tick => {
9081                        self.ticks += 1;
9082                        Cmd::none()
9083                    }
9084                    MixedMsg::Key => {
9085                        self.keys += 1;
9086                        Cmd::none()
9087                    }
9088                }
9089            }
9090
9091            fn view(&self, _frame: &mut Frame) {}
9092        }
9093
9094        let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
9095        sim.init();
9096
9097        // Interleave tick and input events
9098        sim.inject_event(Event::Tick);
9099        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
9100            ftui_core::event::KeyCode::Char('a'),
9101        )));
9102        sim.inject_event(Event::Tick);
9103        sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
9104            ftui_core::event::KeyCode::Char('b'),
9105        )));
9106        sim.inject_event(Event::Tick);
9107
9108        assert_eq!(sim.model().ticks, 3);
9109        assert_eq!(sim.model().keys, 2);
9110    }
9111
9112    // =========================================================================
9113    // HEADLESS PROGRAM TESTS (bd-1av4o.2)
9114    // =========================================================================
9115
9116    fn headless_program_with_resolved_config<M: Model>(
9117        model: M,
9118        config: ProgramConfig,
9119    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9120    where
9121        M::Message: Send + 'static,
9122    {
9123        clear_termination_signal();
9124        let effect_queue_config = config.resolved_effect_queue_config();
9125        let capabilities = TerminalCapabilities::basic();
9126        let mut writer = TerminalWriter::with_diff_config(
9127            Vec::new(),
9128            config.screen_mode,
9129            config.ui_anchor,
9130            capabilities,
9131            config.diff_config.clone(),
9132        );
9133        let frame_timing = config.frame_timing.clone();
9134        writer.set_timing_enabled(frame_timing.is_some());
9135
9136        let (width, height) = config.forced_size.unwrap_or((80, 24));
9137        let width = width.max(1);
9138        let height = height.max(1);
9139        writer.set_size(width, height);
9140
9141        let mouse_capture = config.resolved_mouse_capture();
9142        let initial_features = BackendFeatures {
9143            mouse_capture,
9144            bracketed_paste: config.bracketed_paste,
9145            focus_events: config.focus_reporting,
9146            kitty_keyboard: config.kitty_keyboard,
9147        };
9148        let events = HeadlessEventSource::new(width, height, initial_features);
9149        let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
9150            .expect("headless evidence sink config");
9151
9152        let budget = RenderBudget::from_config(&config.budget);
9153        let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
9154        let locale_context = config.locale_context.clone();
9155        let locale_version = locale_context.version();
9156        let mut resize_coalescer =
9157            ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
9158        if let Some(ref sink) = evidence_sink {
9159            resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
9160        }
9161        let subscriptions = SubscriptionManager::new();
9162        let (task_sender, task_receiver) = std::sync::mpsc::channel();
9163        let inline_auto_remeasure = config
9164            .inline_auto_remeasure
9165            .clone()
9166            .map(InlineAutoRemeasureState::new);
9167        let guardrails = FrameGuardrails::new(config.guardrails);
9168        let task_executor = TaskExecutor::new(
9169            &effect_queue_config,
9170            task_sender.clone(),
9171            evidence_sink.clone(),
9172        )
9173        .expect("task executor");
9174
9175        Program {
9176            model,
9177            writer,
9178            events,
9179            backend_features: initial_features,
9180            running: true,
9181            tick_rate: None,
9182            executed_cmd_count: 0,
9183            last_tick: Instant::now(),
9184            dirty: true,
9185            frame_idx: 0,
9186            tick_count: 0,
9187            widget_signals: Vec::new(),
9188            widget_refresh_config: config.widget_refresh,
9189            widget_refresh_plan: WidgetRefreshPlan::new(),
9190            width,
9191            height,
9192            forced_size: config.forced_size,
9193            poll_timeout: config.poll_timeout,
9194            intercept_signals: config.intercept_signals,
9195            immediate_drain_config: config.immediate_drain,
9196            immediate_drain_stats: ImmediateDrainStats::default(),
9197            budget,
9198            conformal_predictor,
9199            last_frame_time_us: None,
9200            last_update_us: None,
9201            frame_timing,
9202            locale_context,
9203            locale_version,
9204            resize_coalescer,
9205            evidence_sink,
9206            fairness_config_logged: false,
9207            resize_behavior: config.resize_behavior,
9208            fairness_guard: InputFairnessGuard::new(),
9209            event_recorder: None,
9210            subscriptions,
9211            #[cfg(test)]
9212            task_sender,
9213            task_receiver,
9214            task_executor,
9215            state_registry: config.persistence.registry.clone(),
9216            persistence_config: config.persistence,
9217            last_checkpoint: Instant::now(),
9218            inline_auto_remeasure,
9219            frame_arena: FrameArena::default(),
9220            guardrails,
9221            tick_strategy: config
9222                .tick_strategy
9223                .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
9224            last_active_screen_for_strategy: None,
9225        }
9226    }
9227
9228    fn headless_program_with_config<M: Model>(
9229        model: M,
9230        config: ProgramConfig,
9231    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9232    where
9233        M::Message: Send + 'static,
9234    {
9235        // Headless unit tests should not observe process-global shutdown state
9236        // unless they explicitly opt into signal interception.
9237        headless_program_with_resolved_config(model, config.with_signal_interception(false))
9238    }
9239
9240    fn headless_signal_program_with_config<M: Model>(
9241        model: M,
9242        config: ProgramConfig,
9243    ) -> Program<M, HeadlessEventSource, Vec<u8>>
9244    where
9245        M::Message: Send + 'static,
9246    {
9247        headless_program_with_resolved_config(model, config)
9248    }
9249
9250    fn temp_evidence_path(label: &str) -> PathBuf {
9251        static COUNTER: AtomicUsize = AtomicUsize::new(0);
9252        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
9253        let pid = std::process::id();
9254        let mut path = std::env::temp_dir();
9255        path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
9256        path
9257    }
9258
9259    fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
9260        let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
9261        let needle = format!("\"event\":\"{event}\"");
9262        let missing_msg = format!("missing {event} line");
9263        let line = jsonl
9264            .lines()
9265            .find(|line| line.contains(&needle))
9266            .expect(&missing_msg);
9267        serde_json::from_str(line).expect("valid evidence json")
9268    }
9269
9270    #[test]
9271    fn headless_apply_resize_updates_model_and_dimensions() {
9272        struct ResizeModel {
9273            last_size: Option<(u16, u16)>,
9274        }
9275
9276        #[derive(Debug)]
9277        enum ResizeMsg {
9278            Resize(u16, u16),
9279            Other,
9280        }
9281
9282        impl From<Event> for ResizeMsg {
9283            fn from(event: Event) -> Self {
9284                match event {
9285                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
9286                    _ => ResizeMsg::Other,
9287                }
9288            }
9289        }
9290
9291        impl Model for ResizeModel {
9292            type Message = ResizeMsg;
9293
9294            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9295                if let ResizeMsg::Resize(w, h) = msg {
9296                    self.last_size = Some((w, h));
9297                }
9298                Cmd::none()
9299            }
9300
9301            fn view(&self, _frame: &mut Frame) {}
9302        }
9303
9304        let mut program =
9305            headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
9306        program.dirty = false;
9307
9308        program
9309            .apply_resize(0, 0, Duration::ZERO, false)
9310            .expect("resize");
9311
9312        assert_eq!(program.width, 1);
9313        assert_eq!(program.height, 1);
9314        assert_eq!(program.model().last_size, Some((1, 1)));
9315        assert!(program.dirty);
9316    }
9317
9318    #[test]
9319    fn headless_apply_resize_reconciles_subscriptions() {
9320        use crate::subscription::{StopSignal, SubId, Subscription};
9321
9322        struct ResizeSubModel {
9323            subscribed: bool,
9324        }
9325
9326        #[derive(Debug)]
9327        enum ResizeSubMsg {
9328            Resize,
9329            Other,
9330        }
9331
9332        impl From<Event> for ResizeSubMsg {
9333            fn from(event: Event) -> Self {
9334                match event {
9335                    Event::Resize { .. } => Self::Resize,
9336                    _ => Self::Other,
9337                }
9338            }
9339        }
9340
9341        impl Model for ResizeSubModel {
9342            type Message = ResizeSubMsg;
9343
9344            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9345                if matches!(msg, ResizeSubMsg::Resize) {
9346                    self.subscribed = true;
9347                }
9348                Cmd::none()
9349            }
9350
9351            fn view(&self, _frame: &mut Frame) {}
9352
9353            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
9354                if self.subscribed {
9355                    vec![Box::new(ResizeSubscription)]
9356                } else {
9357                    vec![]
9358                }
9359            }
9360        }
9361
9362        struct ResizeSubscription;
9363
9364        impl Subscription<ResizeSubMsg> for ResizeSubscription {
9365            fn id(&self) -> SubId {
9366                1
9367            }
9368
9369            fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
9370        }
9371
9372        let mut program = headless_program_with_config(
9373            ResizeSubModel { subscribed: false },
9374            ProgramConfig::default(),
9375        );
9376
9377        assert_eq!(program.subscriptions.active_count(), 0);
9378        program
9379            .apply_resize(120, 40, Duration::ZERO, false)
9380            .expect("resize");
9381
9382        assert!(program.model().subscribed);
9383        assert_eq!(program.subscriptions.active_count(), 1);
9384    }
9385
9386    #[test]
9387    fn headless_execute_cmd_log_writes_output() {
9388        let mut program =
9389            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9390        program.execute_cmd(Cmd::log("hello world")).expect("log");
9391
9392        let bytes = program.writer.into_inner().expect("writer output");
9393        let output = String::from_utf8_lossy(&bytes);
9394        assert!(output.contains("hello world"));
9395    }
9396
9397    #[test]
9398    fn headless_process_task_results_updates_model() {
9399        struct TaskModel {
9400            updates: usize,
9401        }
9402
9403        #[derive(Debug)]
9404        enum TaskMsg {
9405            Done,
9406        }
9407
9408        impl From<Event> for TaskMsg {
9409            fn from(_: Event) -> Self {
9410                TaskMsg::Done
9411            }
9412        }
9413
9414        impl Model for TaskModel {
9415            type Message = TaskMsg;
9416
9417            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9418                self.updates += 1;
9419                Cmd::none()
9420            }
9421
9422            fn view(&self, _frame: &mut Frame) {}
9423        }
9424
9425        let mut program =
9426            headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
9427        program.dirty = false;
9428        program.task_sender.send(TaskMsg::Done).unwrap();
9429
9430        program
9431            .process_task_results()
9432            .expect("process task results");
9433        assert_eq!(program.model().updates, 1);
9434        assert!(program.dirty);
9435    }
9436
9437    #[test]
9438    fn run_invokes_on_shutdown_after_quit() {
9439        use std::sync::{
9440            Arc,
9441            atomic::{AtomicUsize, Ordering},
9442        };
9443
9444        struct ShutdownModel {
9445            shutdowns: Arc<AtomicUsize>,
9446        }
9447
9448        #[derive(Debug, Clone, Copy)]
9449        enum ShutdownMsg {
9450            Quit,
9451            ShutdownRan,
9452        }
9453
9454        impl From<Event> for ShutdownMsg {
9455            fn from(_: Event) -> Self {
9456                ShutdownMsg::Quit
9457            }
9458        }
9459
9460        impl Model for ShutdownModel {
9461            type Message = ShutdownMsg;
9462
9463            fn init(&mut self) -> Cmd<Self::Message> {
9464                Cmd::quit()
9465            }
9466
9467            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9468                match msg {
9469                    ShutdownMsg::Quit => Cmd::quit(),
9470                    ShutdownMsg::ShutdownRan => {
9471                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9472                        Cmd::none()
9473                    }
9474                }
9475            }
9476
9477            fn view(&self, _frame: &mut Frame) {}
9478
9479            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9480                Cmd::msg(ShutdownMsg::ShutdownRan)
9481            }
9482        }
9483
9484        let shutdowns = Arc::new(AtomicUsize::new(0));
9485        let mut program = headless_program_with_config(
9486            ShutdownModel {
9487                shutdowns: Arc::clone(&shutdowns),
9488            },
9489            ProgramConfig::default(),
9490        );
9491
9492        program.run().expect("program run");
9493
9494        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9495    }
9496
9497    #[test]
9498    fn run_processes_shutdown_task_results_before_exit() {
9499        use std::sync::{
9500            Arc,
9501            atomic::{AtomicUsize, Ordering},
9502        };
9503
9504        struct ShutdownTaskModel {
9505            shutdowns: Arc<AtomicUsize>,
9506        }
9507
9508        #[derive(Debug, Clone, Copy)]
9509        enum ShutdownTaskMsg {
9510            Quit,
9511            ShutdownRan,
9512        }
9513
9514        impl From<Event> for ShutdownTaskMsg {
9515            fn from(_: Event) -> Self {
9516                ShutdownTaskMsg::Quit
9517            }
9518        }
9519
9520        impl Model for ShutdownTaskModel {
9521            type Message = ShutdownTaskMsg;
9522
9523            fn init(&mut self) -> Cmd<Self::Message> {
9524                Cmd::quit()
9525            }
9526
9527            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9528                match msg {
9529                    ShutdownTaskMsg::Quit => Cmd::quit(),
9530                    ShutdownTaskMsg::ShutdownRan => {
9531                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9532                        Cmd::none()
9533                    }
9534                }
9535            }
9536
9537            fn view(&self, _frame: &mut Frame) {}
9538
9539            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9540                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9541            }
9542        }
9543
9544        let shutdowns = Arc::new(AtomicUsize::new(0));
9545        let mut program = headless_program_with_config(
9546            ShutdownTaskModel {
9547                shutdowns: Arc::clone(&shutdowns),
9548            },
9549            ProgramConfig::default(),
9550        );
9551
9552        program.run().expect("program run");
9553
9554        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9555    }
9556
9557    #[test]
9558    fn run_processes_shutdown_task_results_with_effect_queue_backend() {
9559        use std::sync::{
9560            Arc,
9561            atomic::{AtomicUsize, Ordering},
9562        };
9563
9564        struct ShutdownTaskModel {
9565            shutdowns: Arc<AtomicUsize>,
9566        }
9567
9568        #[derive(Debug, Clone, Copy)]
9569        enum ShutdownTaskMsg {
9570            Quit,
9571            ShutdownRan,
9572        }
9573
9574        impl From<Event> for ShutdownTaskMsg {
9575            fn from(_: Event) -> Self {
9576                ShutdownTaskMsg::Quit
9577            }
9578        }
9579
9580        impl Model for ShutdownTaskModel {
9581            type Message = ShutdownTaskMsg;
9582
9583            fn init(&mut self) -> Cmd<Self::Message> {
9584                Cmd::quit()
9585            }
9586
9587            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9588                match msg {
9589                    ShutdownTaskMsg::Quit => Cmd::quit(),
9590                    ShutdownTaskMsg::ShutdownRan => {
9591                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9592                        Cmd::none()
9593                    }
9594                }
9595            }
9596
9597            fn view(&self, _frame: &mut Frame) {}
9598
9599            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9600                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9601            }
9602        }
9603
9604        let shutdowns = Arc::new(AtomicUsize::new(0));
9605        let mut program = headless_program_with_config(
9606            ShutdownTaskModel {
9607                shutdowns: Arc::clone(&shutdowns),
9608            },
9609            ProgramConfig::default().with_effect_queue(
9610                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
9611            ),
9612        );
9613
9614        program.run().expect("program run");
9615
9616        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9617    }
9618
9619    #[test]
9620    fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
9621        use std::sync::{
9622            Arc,
9623            atomic::{AtomicUsize, Ordering},
9624        };
9625
9626        struct ShutdownTaskModel {
9627            shutdowns: Arc<AtomicUsize>,
9628            follow_up_runs: Arc<AtomicUsize>,
9629        }
9630
9631        #[derive(Debug, Clone, Copy)]
9632        enum ShutdownTaskMsg {
9633            Quit,
9634            ShutdownRan,
9635            FollowUp,
9636        }
9637
9638        impl From<Event> for ShutdownTaskMsg {
9639            fn from(_: Event) -> Self {
9640                ShutdownTaskMsg::Quit
9641            }
9642        }
9643
9644        impl Model for ShutdownTaskModel {
9645            type Message = ShutdownTaskMsg;
9646
9647            fn init(&mut self) -> Cmd<Self::Message> {
9648                Cmd::quit()
9649            }
9650
9651            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9652                match msg {
9653                    ShutdownTaskMsg::Quit => Cmd::quit(),
9654                    ShutdownTaskMsg::ShutdownRan => {
9655                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9656                        let follow_up_runs = Arc::clone(&self.follow_up_runs);
9657                        Cmd::task(move || {
9658                            follow_up_runs.fetch_add(1, Ordering::SeqCst);
9659                            ShutdownTaskMsg::FollowUp
9660                        })
9661                    }
9662                    ShutdownTaskMsg::FollowUp => {
9663                        self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
9664                        Cmd::none()
9665                    }
9666                }
9667            }
9668
9669            fn view(&self, _frame: &mut Frame) {}
9670
9671            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9672                Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
9673            }
9674        }
9675
9676        let shutdowns = Arc::new(AtomicUsize::new(0));
9677        let follow_up_runs = Arc::new(AtomicUsize::new(0));
9678        let mut program = headless_program_with_config(
9679            ShutdownTaskModel {
9680                shutdowns: Arc::clone(&shutdowns),
9681                follow_up_runs: Arc::clone(&follow_up_runs),
9682            },
9683            ProgramConfig::default(),
9684        );
9685
9686        program.run().expect("program run");
9687
9688        assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9689        assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
9690    }
9691
9692    #[test]
9693    fn run_quit_from_init_skips_initial_render_and_subscription_start() {
9694        use crate::subscription::{StopSignal, SubId, Subscription};
9695
9696        struct InitQuitModel {
9697            render_calls: Arc<AtomicUsize>,
9698            subscription_starts: Arc<AtomicUsize>,
9699        }
9700
9701        #[derive(Debug, Clone, Copy)]
9702        enum InitQuitMsg {
9703            Noop,
9704        }
9705
9706        impl From<Event> for InitQuitMsg {
9707            fn from(_: Event) -> Self {
9708                Self::Noop
9709            }
9710        }
9711
9712        impl Model for InitQuitModel {
9713            type Message = InitQuitMsg;
9714
9715            fn init(&mut self) -> Cmd<Self::Message> {
9716                Cmd::quit()
9717            }
9718
9719            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
9720                Cmd::none()
9721            }
9722
9723            fn view(&self, _frame: &mut Frame) {
9724                self.render_calls.fetch_add(1, Ordering::SeqCst);
9725            }
9726
9727            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
9728                vec![Box::new(InitQuitSubscription {
9729                    starts: Arc::clone(&self.subscription_starts),
9730                })]
9731            }
9732        }
9733
9734        struct InitQuitSubscription {
9735            starts: Arc<AtomicUsize>,
9736        }
9737
9738        impl Subscription<InitQuitMsg> for InitQuitSubscription {
9739            fn id(&self) -> SubId {
9740                1
9741            }
9742
9743            fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
9744                self.starts.fetch_add(1, Ordering::SeqCst);
9745                let _ = stop.wait_timeout(Duration::from_millis(10));
9746            }
9747        }
9748
9749        let render_calls = Arc::new(AtomicUsize::new(0));
9750        let subscription_starts = Arc::new(AtomicUsize::new(0));
9751        let mut program = headless_program_with_config(
9752            InitQuitModel {
9753                render_calls: Arc::clone(&render_calls),
9754                subscription_starts: Arc::clone(&subscription_starts),
9755            },
9756            ProgramConfig::default(),
9757        );
9758
9759        program.run().expect("program run");
9760
9761        assert_eq!(render_calls.load(Ordering::SeqCst), 0);
9762        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
9763    }
9764
9765    #[test]
9766    fn run_invokes_on_shutdown_before_returning_signal_error() {
9767        use std::sync::{
9768            Arc,
9769            atomic::{AtomicUsize, Ordering},
9770        };
9771
9772        struct ShutdownModel {
9773            shutdowns: Arc<AtomicUsize>,
9774        }
9775
9776        #[derive(Debug, Clone, Copy)]
9777        enum ShutdownMsg {
9778            Noop,
9779            ShutdownRan,
9780        }
9781
9782        impl From<Event> for ShutdownMsg {
9783            fn from(_: Event) -> Self {
9784                ShutdownMsg::Noop
9785            }
9786        }
9787
9788        impl Model for ShutdownModel {
9789            type Message = ShutdownMsg;
9790
9791            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9792                match msg {
9793                    ShutdownMsg::Noop => Cmd::none(),
9794                    ShutdownMsg::ShutdownRan => {
9795                        self.shutdowns.fetch_add(1, Ordering::SeqCst);
9796                        Cmd::none()
9797                    }
9798                }
9799            }
9800
9801            fn view(&self, _frame: &mut Frame) {}
9802
9803            fn on_shutdown(&mut self) -> Cmd<Self::Message> {
9804                Cmd::msg(ShutdownMsg::ShutdownRan)
9805            }
9806        }
9807
9808        let shutdowns = Arc::new(AtomicUsize::new(0));
9809        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
9810            let mut program = headless_signal_program_with_config(
9811                ShutdownModel {
9812                    shutdowns: Arc::clone(&shutdowns),
9813                },
9814                ProgramConfig::default().with_signal_interception(true),
9815            );
9816
9817            ftui_core::shutdown_signal::record_pending_termination_signal(2);
9818            let err = program.run().expect_err("signal should stop runtime");
9819
9820            assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
9821            assert_eq!(signal_termination_from_error(&err), Some(2));
9822            assert_eq!(check_termination_signal(), None);
9823        });
9824    }
9825
9826    #[test]
9827    fn run_pending_signal_skips_initial_render_and_subscription_start() {
9828        use crate::subscription::{StopSignal, SubId, Subscription};
9829
9830        struct SignalStopModel {
9831            render_calls: Arc<AtomicUsize>,
9832            subscription_starts: Arc<AtomicUsize>,
9833        }
9834
9835        #[derive(Debug, Clone, Copy)]
9836        enum SignalStopMsg {
9837            Noop,
9838        }
9839
9840        impl From<Event> for SignalStopMsg {
9841            fn from(_: Event) -> Self {
9842                Self::Noop
9843            }
9844        }
9845
9846        impl Model for SignalStopModel {
9847            type Message = SignalStopMsg;
9848
9849            fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
9850                Cmd::none()
9851            }
9852
9853            fn view(&self, _frame: &mut Frame) {
9854                self.render_calls.fetch_add(1, Ordering::SeqCst);
9855            }
9856
9857            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
9858                vec![Box::new(SignalStopSubscription {
9859                    starts: Arc::clone(&self.subscription_starts),
9860                })]
9861            }
9862        }
9863
9864        struct SignalStopSubscription {
9865            starts: Arc<AtomicUsize>,
9866        }
9867
9868        impl Subscription<SignalStopMsg> for SignalStopSubscription {
9869            fn id(&self) -> SubId {
9870                11
9871            }
9872
9873            fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
9874                self.starts.fetch_add(1, Ordering::SeqCst);
9875                let _ = stop.wait_timeout(Duration::from_millis(10));
9876            }
9877        }
9878
9879        let render_calls = Arc::new(AtomicUsize::new(0));
9880        let subscription_starts = Arc::new(AtomicUsize::new(0));
9881        ftui_core::shutdown_signal::with_test_signal_serialization(|| {
9882            let mut program = headless_signal_program_with_config(
9883                SignalStopModel {
9884                    render_calls: Arc::clone(&render_calls),
9885                    subscription_starts: Arc::clone(&subscription_starts),
9886                },
9887                ProgramConfig::default().with_signal_interception(true),
9888            );
9889
9890            ftui_core::shutdown_signal::record_pending_termination_signal(15);
9891            let err = program.run().expect_err("signal should stop runtime");
9892
9893            assert_eq!(signal_termination_from_error(&err), Some(15));
9894            assert_eq!(render_calls.load(Ordering::SeqCst), 0);
9895            assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
9896            assert_eq!(check_termination_signal(), None);
9897        });
9898    }
9899
9900    #[test]
9901    fn headless_should_tick_and_timeout_behaviors() {
9902        let mut program =
9903            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9904        program.tick_rate = Some(Duration::from_millis(5));
9905        program.last_tick = Instant::now() - Duration::from_millis(10);
9906
9907        assert!(program.should_tick());
9908        assert!(!program.should_tick());
9909
9910        let timeout = program.effective_timeout();
9911        assert!(timeout <= Duration::from_millis(5));
9912
9913        program.tick_rate = None;
9914        program.poll_timeout = Duration::from_millis(33);
9915        assert_eq!(program.effective_timeout(), Duration::from_millis(33));
9916    }
9917
9918    #[test]
9919    fn headless_effective_timeout_respects_resize_coalescer() {
9920        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
9921        config.resize_coalescer.steady_delay_ms = 0;
9922        config.resize_coalescer.burst_delay_ms = 0;
9923
9924        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
9925        program.tick_rate = Some(Duration::from_millis(50));
9926
9927        program.resize_coalescer.handle_resize(120, 40);
9928        assert!(program.resize_coalescer.has_pending());
9929
9930        let timeout = program.effective_timeout();
9931        assert_eq!(timeout, Duration::ZERO);
9932    }
9933
9934    #[test]
9935    fn headless_ui_height_remeasure_clears_auto_height() {
9936        let mut config = ProgramConfig::inline_auto(2, 6);
9937        config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
9938
9939        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
9940        program.dirty = false;
9941        program.writer.set_auto_ui_height(5);
9942
9943        assert_eq!(program.writer.auto_ui_height(), Some(5));
9944        program.request_ui_height_remeasure();
9945
9946        assert_eq!(program.writer.auto_ui_height(), None);
9947        assert!(program.dirty);
9948    }
9949
9950    #[test]
9951    fn headless_recording_lifecycle_and_locale_change() {
9952        let mut program =
9953            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9954        program.dirty = false;
9955
9956        program.start_recording("demo");
9957        assert!(program.is_recording());
9958        let recorded = program.stop_recording();
9959        assert!(recorded.is_some());
9960        assert!(!program.is_recording());
9961
9962        let prev_dirty = program.dirty;
9963        program.locale_context.set_locale("fr");
9964        program.check_locale_change();
9965        assert!(program.dirty || prev_dirty);
9966    }
9967
9968    #[test]
9969    fn headless_render_frame_marks_clean_and_sets_diff() {
9970        struct RenderModel;
9971
9972        #[derive(Debug)]
9973        enum RenderMsg {
9974            Noop,
9975        }
9976
9977        impl From<Event> for RenderMsg {
9978            fn from(_: Event) -> Self {
9979                RenderMsg::Noop
9980            }
9981        }
9982
9983        impl Model for RenderModel {
9984            type Message = RenderMsg;
9985
9986            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9987                Cmd::none()
9988            }
9989
9990            fn view(&self, frame: &mut Frame) {
9991                frame.buffer.set_raw(0, 0, Cell::from_char('X'));
9992            }
9993        }
9994
9995        let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
9996        program.render_frame().expect("render frame");
9997
9998        assert!(!program.dirty);
9999        assert!(program.writer.last_diff_strategy().is_some());
10000        assert_eq!(program.frame_idx, 1);
10001    }
10002
10003    #[test]
10004    fn headless_render_frame_skips_when_budget_exhausted() {
10005        let config = ProgramConfig {
10006            budget: FrameBudgetConfig::with_total(Duration::ZERO),
10007            ..Default::default()
10008        };
10009
10010        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10011        program.dirty = true;
10012        program.render_frame().expect("render frame");
10013
10014        // Dirty state is preserved when frame is skipped — the UI update
10015        // was never presented and must be retried.
10016        assert!(program.dirty);
10017        assert_eq!(program.frame_idx, 1);
10018    }
10019
10020    #[test]
10021    fn headless_render_frame_emits_budget_evidence_with_controller() {
10022        use ftui_render::budget::BudgetControllerConfig;
10023
10024        struct RenderModel;
10025
10026        #[derive(Debug)]
10027        enum RenderMsg {
10028            Noop,
10029        }
10030
10031        impl From<Event> for RenderMsg {
10032            fn from(_: Event) -> Self {
10033                RenderMsg::Noop
10034            }
10035        }
10036
10037        impl Model for RenderModel {
10038            type Message = RenderMsg;
10039
10040            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10041                Cmd::none()
10042            }
10043
10044            fn view(&self, frame: &mut Frame) {
10045                frame.buffer.set_raw(0, 0, Cell::from_char('E'));
10046            }
10047        }
10048
10049        let config =
10050            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
10051        let mut program = headless_program_with_config(RenderModel, config);
10052        program.budget = program
10053            .budget
10054            .with_controller(BudgetControllerConfig::default());
10055
10056        program.render_frame().expect("render frame");
10057        assert!(program.budget.telemetry().is_some());
10058        assert_eq!(program.frame_idx, 1);
10059    }
10060
10061    #[test]
10062    fn headless_handle_event_updates_model() {
10063        struct EventModel {
10064            events: usize,
10065            last_resize: Option<(u16, u16)>,
10066        }
10067
10068        #[derive(Debug)]
10069        enum EventMsg {
10070            Resize(u16, u16),
10071            Other,
10072        }
10073
10074        impl From<Event> for EventMsg {
10075            fn from(event: Event) -> Self {
10076                match event {
10077                    Event::Resize { width, height } => EventMsg::Resize(width, height),
10078                    _ => EventMsg::Other,
10079                }
10080            }
10081        }
10082
10083        impl Model for EventModel {
10084            type Message = EventMsg;
10085
10086            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10087                self.events += 1;
10088                if let EventMsg::Resize(w, h) = msg {
10089                    self.last_resize = Some((w, h));
10090                }
10091                Cmd::none()
10092            }
10093
10094            fn view(&self, _frame: &mut Frame) {}
10095        }
10096
10097        let mut program = headless_program_with_config(
10098            EventModel {
10099                events: 0,
10100                last_resize: None,
10101            },
10102            ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
10103        );
10104
10105        program
10106            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
10107                ftui_core::event::KeyCode::Char('x'),
10108            )))
10109            .expect("handle key");
10110        assert_eq!(program.model().events, 1);
10111
10112        program
10113            .handle_event(Event::Resize {
10114                width: 10,
10115                height: 5,
10116            })
10117            .expect("handle resize");
10118        assert_eq!(program.model().events, 2);
10119        assert_eq!(program.model().last_resize, Some((10, 5)));
10120        assert_eq!(program.width, 10);
10121        assert_eq!(program.height, 5);
10122    }
10123
10124    #[test]
10125    fn headless_handle_event_quit_skips_subscription_reconcile() {
10126        use crate::subscription::{StopSignal, SubId, Subscription};
10127
10128        struct QuitSubModel {
10129            quitting: bool,
10130            subscription_starts: Arc<AtomicUsize>,
10131        }
10132
10133        #[derive(Debug)]
10134        enum QuitSubMsg {
10135            Quit,
10136            Other,
10137        }
10138
10139        impl From<Event> for QuitSubMsg {
10140            fn from(event: Event) -> Self {
10141                match event {
10142                    Event::Key(_) => Self::Quit,
10143                    _ => Self::Other,
10144                }
10145            }
10146        }
10147
10148        impl Model for QuitSubModel {
10149            type Message = QuitSubMsg;
10150
10151            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10152                match msg {
10153                    QuitSubMsg::Quit => {
10154                        self.quitting = true;
10155                        Cmd::quit()
10156                    }
10157                    QuitSubMsg::Other => Cmd::none(),
10158                }
10159            }
10160
10161            fn view(&self, _frame: &mut Frame) {}
10162
10163            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10164                if self.quitting {
10165                    vec![Box::new(QuitSubSubscription {
10166                        starts: Arc::clone(&self.subscription_starts),
10167                    })]
10168                } else {
10169                    vec![]
10170                }
10171            }
10172        }
10173
10174        struct QuitSubSubscription {
10175            starts: Arc<AtomicUsize>,
10176        }
10177
10178        impl Subscription<QuitSubMsg> for QuitSubSubscription {
10179            fn id(&self) -> SubId {
10180                7
10181            }
10182
10183            fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
10184                self.starts.fetch_add(1, Ordering::SeqCst);
10185                let _ = stop.wait_timeout(Duration::from_millis(10));
10186            }
10187        }
10188
10189        let subscription_starts = Arc::new(AtomicUsize::new(0));
10190        let mut program = headless_program_with_config(
10191            QuitSubModel {
10192                quitting: false,
10193                subscription_starts: Arc::clone(&subscription_starts),
10194            },
10195            ProgramConfig::default(),
10196        );
10197
10198        program
10199            .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
10200                ftui_core::event::KeyCode::Char('q'),
10201            )))
10202            .expect("handle event");
10203
10204        assert!(!program.is_running());
10205        assert_eq!(program.subscriptions.active_count(), 0);
10206        assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10207    }
10208
10209    #[test]
10210    fn headless_handle_resize_ignored_when_forced_size() {
10211        struct ResizeModel {
10212            resized: bool,
10213        }
10214
10215        #[derive(Debug)]
10216        enum ResizeMsg {
10217            Resize,
10218            Other,
10219        }
10220
10221        impl From<Event> for ResizeMsg {
10222            fn from(event: Event) -> Self {
10223                match event {
10224                    Event::Resize { .. } => ResizeMsg::Resize,
10225                    _ => ResizeMsg::Other,
10226                }
10227            }
10228        }
10229
10230        impl Model for ResizeModel {
10231            type Message = ResizeMsg;
10232
10233            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10234                if matches!(msg, ResizeMsg::Resize) {
10235                    self.resized = true;
10236                }
10237                Cmd::none()
10238            }
10239
10240            fn view(&self, _frame: &mut Frame) {}
10241        }
10242
10243        let config = ProgramConfig::default().with_forced_size(80, 24);
10244        let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
10245
10246        program
10247            .handle_event(Event::Resize {
10248                width: 120,
10249                height: 40,
10250            })
10251            .expect("handle resize");
10252
10253        assert_eq!(program.width, 80);
10254        assert_eq!(program.height, 24);
10255        assert!(!program.model().resized);
10256    }
10257
10258    #[test]
10259    fn headless_execute_cmd_batch_sequence_and_quit() {
10260        struct BatchModel {
10261            count: usize,
10262        }
10263
10264        #[derive(Debug)]
10265        enum BatchMsg {
10266            Inc,
10267        }
10268
10269        impl From<Event> for BatchMsg {
10270            fn from(_: Event) -> Self {
10271                BatchMsg::Inc
10272            }
10273        }
10274
10275        impl Model for BatchModel {
10276            type Message = BatchMsg;
10277
10278            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10279                match msg {
10280                    BatchMsg::Inc => {
10281                        self.count += 1;
10282                        Cmd::none()
10283                    }
10284                }
10285            }
10286
10287            fn view(&self, _frame: &mut Frame) {}
10288        }
10289
10290        let mut program =
10291            headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
10292
10293        program
10294            .execute_cmd(Cmd::Batch(vec![
10295                Cmd::msg(BatchMsg::Inc),
10296                Cmd::Sequence(vec![
10297                    Cmd::msg(BatchMsg::Inc),
10298                    Cmd::quit(),
10299                    Cmd::msg(BatchMsg::Inc),
10300                ]),
10301            ]))
10302            .expect("batch cmd");
10303
10304        assert_eq!(program.model().count, 2);
10305        assert!(!program.running);
10306    }
10307
10308    #[test]
10309    fn headless_process_subscription_messages_updates_model() {
10310        use crate::subscription::{StopSignal, SubId, Subscription};
10311
10312        struct SubModel {
10313            pings: usize,
10314            ready_tx: mpsc::Sender<()>,
10315        }
10316
10317        #[derive(Debug)]
10318        enum SubMsg {
10319            Ping,
10320            Other,
10321        }
10322
10323        impl From<Event> for SubMsg {
10324            fn from(_: Event) -> Self {
10325                SubMsg::Other
10326            }
10327        }
10328
10329        impl Model for SubModel {
10330            type Message = SubMsg;
10331
10332            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10333                if let SubMsg::Ping = msg {
10334                    self.pings += 1;
10335                }
10336                Cmd::none()
10337            }
10338
10339            fn view(&self, _frame: &mut Frame) {}
10340
10341            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10342                vec![Box::new(TestSubscription {
10343                    ready_tx: self.ready_tx.clone(),
10344                })]
10345            }
10346        }
10347
10348        struct TestSubscription {
10349            ready_tx: mpsc::Sender<()>,
10350        }
10351
10352        impl Subscription<SubMsg> for TestSubscription {
10353            fn id(&self) -> SubId {
10354                1
10355            }
10356
10357            fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
10358                let _ = sender.send(SubMsg::Ping);
10359                let _ = self.ready_tx.send(());
10360            }
10361        }
10362
10363        let (ready_tx, ready_rx) = mpsc::channel();
10364        let mut program =
10365            headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
10366
10367        program.reconcile_subscriptions();
10368        ready_rx
10369            .recv_timeout(Duration::from_millis(200))
10370            .expect("subscription started");
10371        program
10372            .process_subscription_messages()
10373            .expect("process subscriptions");
10374
10375        assert_eq!(program.model().pings, 1);
10376    }
10377
10378    #[test]
10379    fn headless_execute_cmd_task_spawns_and_reaps() {
10380        struct TaskModel {
10381            done: bool,
10382        }
10383
10384        #[derive(Debug)]
10385        enum TaskMsg {
10386            Done,
10387        }
10388
10389        impl From<Event> for TaskMsg {
10390            fn from(_: Event) -> Self {
10391                TaskMsg::Done
10392            }
10393        }
10394
10395        impl Model for TaskModel {
10396            type Message = TaskMsg;
10397
10398            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10399                match msg {
10400                    TaskMsg::Done => {
10401                        self.done = true;
10402                        Cmd::none()
10403                    }
10404                }
10405            }
10406
10407            fn view(&self, _frame: &mut Frame) {}
10408        }
10409
10410        let mut program =
10411            headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
10412        program
10413            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10414            .expect("task cmd");
10415
10416        let deadline = Instant::now() + Duration::from_millis(200);
10417        while !program.model().done && Instant::now() <= deadline {
10418            program
10419                .process_task_results()
10420                .expect("process task results");
10421            program.reap_finished_tasks();
10422        }
10423
10424        assert!(program.model().done, "task result did not arrive in time");
10425    }
10426
10427    #[test]
10428    fn headless_default_task_executor_is_queued_for_structured_lane() {
10429        let program =
10430            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10431        assert_eq!(program.task_executor.kind_name(), "queued");
10432    }
10433
10434    #[test]
10435    fn headless_structured_lane_task_executor_writes_queued_backend_evidence() {
10436        let evidence_path = temp_evidence_path("task_executor_queued_backend");
10437        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10438        let config = ProgramConfig::default().with_evidence_sink(sink_config);
10439        let _program = headless_program_with_config(TestModel { value: 0 }, config);
10440
10441        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
10442        assert_eq!(backend_line["backend"], "queued");
10443    }
10444
10445    #[test]
10446    fn headless_legacy_lane_task_executor_is_spawned() {
10447        let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
10448        let program = headless_program_with_config(TestModel { value: 0 }, config);
10449        assert_eq!(program.task_executor.kind_name(), "spawned");
10450    }
10451
10452    #[test]
10453    fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
10454        let config = ProgramConfig::default().with_effect_queue(
10455            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
10456        );
10457        let program = headless_program_with_config(TestModel { value: 0 }, config);
10458        assert_eq!(program.task_executor.kind_name(), "spawned");
10459    }
10460
10461    #[cfg(feature = "asupersync-executor")]
10462    #[test]
10463    fn headless_asupersync_task_executor_is_selected() {
10464        let config = ProgramConfig::default().with_effect_queue(
10465            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
10466        );
10467        let program = headless_program_with_config(TestModel { value: 0 }, config);
10468        assert_eq!(program.task_executor.kind_name(), "asupersync");
10469    }
10470
10471    #[test]
10472    fn headless_persistence_commands_with_registry() {
10473        use crate::state_persistence::{MemoryStorage, StateRegistry};
10474        use std::sync::Arc;
10475
10476        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
10477        let config = ProgramConfig::default().with_registry(registry.clone());
10478        let mut program = headless_program_with_config(TestModel { value: 0 }, config);
10479
10480        assert!(program.has_persistence());
10481        assert!(program.state_registry().is_some());
10482
10483        program.execute_cmd(Cmd::save_state()).expect("save");
10484        program.execute_cmd(Cmd::restore_state()).expect("restore");
10485
10486        let saved = program.trigger_save().expect("trigger save");
10487        let loaded = program.trigger_load().expect("trigger load");
10488        assert!(!saved);
10489        assert_eq!(loaded, 0);
10490    }
10491
10492    #[test]
10493    fn headless_process_resize_coalescer_applies_pending_resize() {
10494        struct ResizeModel {
10495            last_size: Option<(u16, u16)>,
10496        }
10497
10498        #[derive(Debug)]
10499        enum ResizeMsg {
10500            Resize(u16, u16),
10501            Other,
10502        }
10503
10504        impl From<Event> for ResizeMsg {
10505            fn from(event: Event) -> Self {
10506                match event {
10507                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10508                    _ => ResizeMsg::Other,
10509                }
10510            }
10511        }
10512
10513        impl Model for ResizeModel {
10514            type Message = ResizeMsg;
10515
10516            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10517                if let ResizeMsg::Resize(w, h) = msg {
10518                    self.last_size = Some((w, h));
10519                }
10520                Cmd::none()
10521            }
10522
10523            fn view(&self, _frame: &mut Frame) {}
10524        }
10525
10526        let evidence_path = temp_evidence_path("fairness_allow");
10527        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10528        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
10529        config.resize_coalescer.steady_delay_ms = 0;
10530        config.resize_coalescer.burst_delay_ms = 0;
10531        config.resize_coalescer.hard_deadline_ms = 1_000;
10532        config.evidence_sink = sink_config.clone();
10533
10534        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
10535        let sink = EvidenceSink::from_config(&sink_config)
10536            .expect("evidence sink config")
10537            .expect("evidence sink enabled");
10538        program.evidence_sink = Some(sink);
10539
10540        program.resize_coalescer.handle_resize(120, 40);
10541        assert!(program.resize_coalescer.has_pending());
10542
10543        program
10544            .process_resize_coalescer()
10545            .expect("process resize coalescer");
10546
10547        assert_eq!(program.width, 120);
10548        assert_eq!(program.height, 40);
10549        assert_eq!(program.model().last_size, Some((120, 40)));
10550
10551        let config_line = read_evidence_event(&evidence_path, "fairness_config");
10552        assert_eq!(config_line["event"], "fairness_config");
10553        assert!(config_line["enabled"].is_boolean());
10554        assert!(config_line["input_priority_threshold_ms"].is_number());
10555        assert!(config_line["dominance_threshold"].is_number());
10556        assert!(config_line["fairness_threshold"].is_number());
10557
10558        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
10559        assert_eq!(decision_line["event"], "fairness_decision");
10560        assert_eq!(decision_line["decision"], "allow");
10561        assert_eq!(decision_line["reason"], "none");
10562        assert!(decision_line["pending_input_latency_ms"].is_null());
10563        assert!(decision_line["jain_index"].is_number());
10564        assert!(decision_line["resize_dominance_count"].is_number());
10565        assert!(decision_line["dominance_threshold"].is_number());
10566        assert!(decision_line["fairness_threshold"].is_number());
10567        assert!(decision_line["input_priority_threshold_ms"].is_number());
10568    }
10569
10570    #[test]
10571    fn headless_process_resize_coalescer_yields_to_input() {
10572        struct ResizeModel {
10573            last_size: Option<(u16, u16)>,
10574        }
10575
10576        #[derive(Debug)]
10577        enum ResizeMsg {
10578            Resize(u16, u16),
10579            Other,
10580        }
10581
10582        impl From<Event> for ResizeMsg {
10583            fn from(event: Event) -> Self {
10584                match event {
10585                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10586                    _ => ResizeMsg::Other,
10587                }
10588            }
10589        }
10590
10591        impl Model for ResizeModel {
10592            type Message = ResizeMsg;
10593
10594            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10595                if let ResizeMsg::Resize(w, h) = msg {
10596                    self.last_size = Some((w, h));
10597                }
10598                Cmd::none()
10599            }
10600
10601            fn view(&self, _frame: &mut Frame) {}
10602        }
10603
10604        let evidence_path = temp_evidence_path("fairness_yield");
10605        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10606        let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
10607        config.resize_coalescer.steady_delay_ms = 0;
10608        config.resize_coalescer.burst_delay_ms = 0;
10609        // Use a large hard deadline so elapsed wall-clock time between coalescer
10610        // construction and `handle_resize` never triggers an immediate apply.
10611        config.resize_coalescer.hard_deadline_ms = 10_000;
10612        config.evidence_sink = sink_config.clone();
10613
10614        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
10615        let sink = EvidenceSink::from_config(&sink_config)
10616            .expect("evidence sink config")
10617            .expect("evidence sink enabled");
10618        program.evidence_sink = Some(sink);
10619
10620        program.fairness_guard = InputFairnessGuard::with_config(
10621            crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
10622        );
10623        program
10624            .fairness_guard
10625            .input_arrived(Instant::now() - Duration::from_millis(1));
10626
10627        program.resize_coalescer.handle_resize(120, 40);
10628        assert!(program.resize_coalescer.has_pending());
10629
10630        program
10631            .process_resize_coalescer()
10632            .expect("process resize coalescer");
10633
10634        assert_eq!(program.width, 80);
10635        assert_eq!(program.height, 24);
10636        assert_eq!(program.model().last_size, None);
10637        assert!(program.resize_coalescer.has_pending());
10638
10639        let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
10640        assert_eq!(decision_line["event"], "fairness_decision");
10641        assert_eq!(decision_line["decision"], "yield");
10642        assert_eq!(decision_line["reason"], "input_latency");
10643        assert!(decision_line["pending_input_latency_ms"].is_number());
10644        assert!(decision_line["jain_index"].is_number());
10645        assert!(decision_line["resize_dominance_count"].is_number());
10646        assert!(decision_line["dominance_threshold"].is_number());
10647        assert!(decision_line["fairness_threshold"].is_number());
10648        assert!(decision_line["input_priority_threshold_ms"].is_number());
10649    }
10650
10651    #[test]
10652    fn headless_execute_cmd_task_with_effect_queue() {
10653        struct TaskModel {
10654            done: bool,
10655        }
10656
10657        #[derive(Debug)]
10658        enum TaskMsg {
10659            Done,
10660        }
10661
10662        impl From<Event> for TaskMsg {
10663            fn from(_: Event) -> Self {
10664                TaskMsg::Done
10665            }
10666        }
10667
10668        impl Model for TaskModel {
10669            type Message = TaskMsg;
10670
10671            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10672                match msg {
10673                    TaskMsg::Done => {
10674                        self.done = true;
10675                        Cmd::none()
10676                    }
10677                }
10678            }
10679
10680            fn view(&self, _frame: &mut Frame) {}
10681        }
10682
10683        let effect_queue = EffectQueueConfig {
10684            enabled: true,
10685            backend: TaskExecutorBackend::EffectQueue,
10686            scheduler: SchedulerConfig {
10687                max_queue_size: 0,
10688                ..Default::default()
10689            },
10690            explicit_backend: true,
10691            ..Default::default()
10692        };
10693        let config = ProgramConfig::default().with_effect_queue(effect_queue);
10694        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10695
10696        program
10697            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10698            .expect("task cmd");
10699
10700        let deadline = Instant::now() + Duration::from_millis(200);
10701        while !program.model().done && Instant::now() <= deadline {
10702            program
10703                .process_task_results()
10704                .expect("process task results");
10705        }
10706
10707        assert!(
10708            program.model().done,
10709            "effect queue task result did not arrive in time"
10710        );
10711        assert_eq!(program.task_executor.kind_name(), "queued");
10712    }
10713
10714    #[test]
10715    fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
10716        struct TaskModel {
10717            done: bool,
10718        }
10719
10720        #[derive(Debug)]
10721        enum TaskMsg {
10722            Done,
10723        }
10724
10725        impl From<Event> for TaskMsg {
10726            fn from(_: Event) -> Self {
10727                TaskMsg::Done
10728            }
10729        }
10730
10731        impl Model for TaskModel {
10732            type Message = TaskMsg;
10733
10734            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10735                match msg {
10736                    TaskMsg::Done => {
10737                        self.done = true;
10738                        Cmd::none()
10739                    }
10740                }
10741            }
10742
10743            fn view(&self, _frame: &mut Frame) {}
10744        }
10745
10746        let evidence_path = temp_evidence_path("task_executor_spawned_complete");
10747        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10748        let config = ProgramConfig::default()
10749            .with_lane(RuntimeLane::Legacy)
10750            .with_evidence_sink(sink_config);
10751        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10752
10753        program
10754            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10755            .expect("task cmd");
10756
10757        let deadline = Instant::now() + Duration::from_millis(200);
10758        while !program.model().done && Instant::now() <= deadline {
10759            program
10760                .process_task_results()
10761                .expect("process task results");
10762            program.reap_finished_tasks();
10763        }
10764
10765        assert!(
10766            program.model().done,
10767            "spawned task result did not arrive in time"
10768        );
10769
10770        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
10771        assert_eq!(completion_line["backend"], "spawned");
10772        assert!(completion_line["duration_us"].is_number());
10773    }
10774
10775    #[test]
10776    fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
10777        struct TaskModel {
10778            done: bool,
10779        }
10780
10781        #[derive(Debug)]
10782        enum TaskMsg {
10783            Done,
10784        }
10785
10786        impl From<Event> for TaskMsg {
10787            fn from(_: Event) -> Self {
10788                TaskMsg::Done
10789            }
10790        }
10791
10792        impl Model for TaskModel {
10793            type Message = TaskMsg;
10794
10795            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10796                match msg {
10797                    TaskMsg::Done => {
10798                        self.done = true;
10799                        Cmd::none()
10800                    }
10801                }
10802            }
10803
10804            fn view(&self, _frame: &mut Frame) {}
10805        }
10806
10807        let evidence_path = temp_evidence_path("task_executor_queued_panic");
10808        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10809        let config = ProgramConfig::default()
10810            .with_evidence_sink(sink_config)
10811            .with_effect_queue(
10812                EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
10813            );
10814        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10815
10816        program
10817            .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
10818            .expect("panic task cmd");
10819        program
10820            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10821            .expect("follow-up task cmd");
10822
10823        let deadline = Instant::now() + Duration::from_millis(500);
10824        while !program.model().done && Instant::now() <= deadline {
10825            program
10826                .process_task_results()
10827                .expect("process task results");
10828        }
10829
10830        assert!(
10831            program.model().done,
10832            "effect queue should continue after a panicking task"
10833        );
10834
10835        let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
10836        assert_eq!(panic_line["backend"], "queued");
10837        assert_eq!(panic_line["panic_msg"], "queued panic evidence");
10838    }
10839
10840    #[cfg(feature = "asupersync-executor")]
10841    #[test]
10842    fn headless_execute_cmd_task_with_asupersync_backend() {
10843        struct TaskModel {
10844            done: bool,
10845        }
10846
10847        #[derive(Debug)]
10848        enum TaskMsg {
10849            Done,
10850        }
10851
10852        impl From<Event> for TaskMsg {
10853            fn from(_: Event) -> Self {
10854                TaskMsg::Done
10855            }
10856        }
10857
10858        impl Model for TaskModel {
10859            type Message = TaskMsg;
10860
10861            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10862                match msg {
10863                    TaskMsg::Done => {
10864                        self.done = true;
10865                        Cmd::none()
10866                    }
10867                }
10868            }
10869
10870            fn view(&self, _frame: &mut Frame) {}
10871        }
10872
10873        let config = ProgramConfig::default().with_effect_queue(
10874            EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
10875        );
10876        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10877
10878        program
10879            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10880            .expect("task cmd");
10881
10882        let deadline = Instant::now() + Duration::from_millis(200);
10883        while !program.model().done && Instant::now() <= deadline {
10884            program
10885                .process_task_results()
10886                .expect("process task results");
10887            program.reap_finished_tasks();
10888        }
10889
10890        assert!(
10891            program.model().done,
10892            "asupersync task result did not arrive in time"
10893        );
10894        assert_eq!(program.task_executor.kind_name(), "asupersync");
10895    }
10896
10897    #[cfg(feature = "asupersync-executor")]
10898    #[test]
10899    fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
10900        struct TaskModel {
10901            done: bool,
10902        }
10903
10904        #[derive(Debug)]
10905        enum TaskMsg {
10906            Done,
10907        }
10908
10909        impl From<Event> for TaskMsg {
10910            fn from(_: Event) -> Self {
10911                TaskMsg::Done
10912            }
10913        }
10914
10915        impl Model for TaskModel {
10916            type Message = TaskMsg;
10917
10918            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10919                match msg {
10920                    TaskMsg::Done => {
10921                        self.done = true;
10922                        Cmd::none()
10923                    }
10924                }
10925            }
10926
10927            fn view(&self, _frame: &mut Frame) {}
10928        }
10929
10930        let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
10931        let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
10932        let config = ProgramConfig::default()
10933            .with_evidence_sink(sink_config)
10934            .with_effect_queue(
10935                EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
10936            );
10937        let mut program = headless_program_with_config(TaskModel { done: false }, config);
10938
10939        let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
10940        assert_eq!(backend_line["backend"], "asupersync");
10941
10942        program
10943            .execute_cmd(Cmd::task(|| TaskMsg::Done))
10944            .expect("task cmd");
10945
10946        let deadline = Instant::now() + Duration::from_millis(200);
10947        while !program.model().done && Instant::now() <= deadline {
10948            program
10949                .process_task_results()
10950                .expect("process task results");
10951            program.reap_finished_tasks();
10952        }
10953
10954        assert!(
10955            program.model().done,
10956            "asupersync task result did not arrive in time"
10957        );
10958
10959        let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
10960        assert_eq!(completion_line["backend"], "asupersync");
10961        assert!(completion_line["duration_us"].is_number());
10962    }
10963
10964    // =========================================================================
10965    // BatchController Tests (bd-4kq0.8.1)
10966    // =========================================================================
10967
10968    #[test]
10969    fn unit_tau_monotone() {
10970        // τ should decrease (or stay constant) as service time decreases,
10971        // since τ = E[S] × headroom.
10972        let mut bc = BatchController::new();
10973
10974        // High service time → high τ
10975        bc.observe_service(Duration::from_millis(20));
10976        bc.observe_service(Duration::from_millis(20));
10977        bc.observe_service(Duration::from_millis(20));
10978        let tau_high = bc.tau_s();
10979
10980        // Low service time → lower τ
10981        for _ in 0..20 {
10982            bc.observe_service(Duration::from_millis(1));
10983        }
10984        let tau_low = bc.tau_s();
10985
10986        assert!(
10987            tau_low <= tau_high,
10988            "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
10989        );
10990    }
10991
10992    #[test]
10993    fn unit_tau_monotone_lambda() {
10994        // As arrival rate λ decreases (longer inter-arrival times),
10995        // τ should not increase (it's based on service time, not λ).
10996        // But ρ should decrease.
10997        let mut bc = BatchController::new();
10998        let base = Instant::now();
10999
11000        // Fast arrivals (λ high)
11001        for i in 0..10 {
11002            bc.observe_arrival(base + Duration::from_millis(i * 10));
11003        }
11004        let rho_fast = bc.rho_est();
11005
11006        // Slow arrivals (λ low)
11007        for i in 10..20 {
11008            bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
11009        }
11010        let rho_slow = bc.rho_est();
11011
11012        assert!(
11013            rho_slow < rho_fast,
11014            "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
11015        );
11016    }
11017
11018    #[test]
11019    fn unit_stability() {
11020        // With reasonable service times, the controller should keep ρ < 1.
11021        let mut bc = BatchController::new();
11022        let base = Instant::now();
11023
11024        // Moderate arrival rate: 30 events/sec
11025        for i in 0..30 {
11026            bc.observe_arrival(base + Duration::from_millis(i * 33));
11027            bc.observe_service(Duration::from_millis(5)); // 5ms render
11028        }
11029
11030        assert!(
11031            bc.is_stable(),
11032            "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
11033            bc.rho_est()
11034        );
11035        assert!(
11036            bc.rho_est() < 1.0,
11037            "utilization should be < 1: ρ={:.4}",
11038            bc.rho_est()
11039        );
11040
11041        // τ must be > E[S] (stability requirement)
11042        assert!(
11043            bc.tau_s() > bc.service_est_s(),
11044            "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
11045            bc.tau_s(),
11046            bc.service_est_s()
11047        );
11048    }
11049
11050    #[test]
11051    fn unit_stability_high_load() {
11052        // Even under high load, τ keeps the system stable.
11053        let mut bc = BatchController::new();
11054        let base = Instant::now();
11055
11056        // 100 events/sec with 8ms render
11057        for i in 0..50 {
11058            bc.observe_arrival(base + Duration::from_millis(i * 10));
11059            bc.observe_service(Duration::from_millis(8));
11060        }
11061
11062        // τ × ρ_eff = E[S]/τ should be < 1
11063        let tau = bc.tau_s();
11064        let rho_eff = bc.service_est_s() / tau;
11065        assert!(
11066            rho_eff < 1.0,
11067            "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
11068            bc.service_est_s()
11069        );
11070    }
11071
11072    #[test]
11073    fn batch_controller_defaults() {
11074        let bc = BatchController::new();
11075        assert!(bc.tau_s() >= bc.tau_min_s);
11076        assert!(bc.tau_s() <= bc.tau_max_s);
11077        assert_eq!(bc.observations(), 0);
11078        assert!(bc.is_stable());
11079    }
11080
11081    #[test]
11082    fn batch_controller_tau_clamped() {
11083        let mut bc = BatchController::new();
11084
11085        // Very fast service → τ clamped to tau_min
11086        for _ in 0..20 {
11087            bc.observe_service(Duration::from_micros(10));
11088        }
11089        assert!(
11090            bc.tau_s() >= bc.tau_min_s,
11091            "τ should be >= tau_min: τ={:.6}, min={:.6}",
11092            bc.tau_s(),
11093            bc.tau_min_s
11094        );
11095
11096        // Very slow service → τ clamped to tau_max
11097        for _ in 0..20 {
11098            bc.observe_service(Duration::from_millis(100));
11099        }
11100        assert!(
11101            bc.tau_s() <= bc.tau_max_s,
11102            "τ should be <= tau_max: τ={:.6}, max={:.6}",
11103            bc.tau_s(),
11104            bc.tau_max_s
11105        );
11106    }
11107
11108    #[test]
11109    fn batch_controller_duration_conversion() {
11110        let bc = BatchController::new();
11111        let tau = bc.tau();
11112        let tau_s = bc.tau_s();
11113        // Duration should match f64 representation
11114        let diff = (tau.as_secs_f64() - tau_s).abs();
11115        assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
11116    }
11117
11118    #[test]
11119    fn batch_controller_lambda_estimation() {
11120        let mut bc = BatchController::new();
11121        let base = Instant::now();
11122
11123        // 50 events/sec (20ms apart)
11124        for i in 0..20 {
11125            bc.observe_arrival(base + Duration::from_millis(i * 20));
11126        }
11127
11128        // λ should converge near 50
11129        let lambda = bc.lambda_est();
11130        assert!(
11131            lambda > 20.0 && lambda < 100.0,
11132            "λ should be near 50: got {lambda:.1}"
11133        );
11134    }
11135
11136    // ─────────────────────────────────────────────────────────────────────────────
11137    // Persistence Config Tests
11138    // ─────────────────────────────────────────────────────────────────────────────
11139
11140    #[test]
11141    fn cmd_save_state() {
11142        let cmd: Cmd<TestMsg> = Cmd::save_state();
11143        assert!(matches!(cmd, Cmd::SaveState));
11144    }
11145
11146    #[test]
11147    fn cmd_restore_state() {
11148        let cmd: Cmd<TestMsg> = Cmd::restore_state();
11149        assert!(matches!(cmd, Cmd::RestoreState));
11150    }
11151
11152    #[test]
11153    fn persistence_config_default() {
11154        let config = PersistenceConfig::default();
11155        assert!(config.registry.is_none());
11156        assert!(config.checkpoint_interval.is_none());
11157        assert!(config.auto_load);
11158        assert!(config.auto_save);
11159    }
11160
11161    #[test]
11162    fn persistence_config_disabled() {
11163        let config = PersistenceConfig::disabled();
11164        assert!(config.registry.is_none());
11165    }
11166
11167    #[test]
11168    fn persistence_config_with_registry() {
11169        use crate::state_persistence::{MemoryStorage, StateRegistry};
11170        use std::sync::Arc;
11171
11172        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11173        let config = PersistenceConfig::with_registry(registry.clone());
11174
11175        assert!(config.registry.is_some());
11176        assert!(config.auto_load);
11177        assert!(config.auto_save);
11178    }
11179
11180    #[test]
11181    fn persistence_config_checkpoint_interval() {
11182        use crate::state_persistence::{MemoryStorage, StateRegistry};
11183        use std::sync::Arc;
11184
11185        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11186        let config = PersistenceConfig::with_registry(registry)
11187            .checkpoint_every(Duration::from_secs(30))
11188            .auto_load(false)
11189            .auto_save(true);
11190
11191        assert!(config.checkpoint_interval.is_some());
11192        assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
11193        assert!(!config.auto_load);
11194        assert!(config.auto_save);
11195    }
11196
11197    #[test]
11198    fn program_config_with_persistence() {
11199        use crate::state_persistence::{MemoryStorage, StateRegistry};
11200        use std::sync::Arc;
11201
11202        let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11203        let config = ProgramConfig::default().with_registry(registry);
11204
11205        assert!(config.persistence.registry.is_some());
11206    }
11207
11208    // =========================================================================
11209    // TaskSpec tests (bd-2yjus)
11210    // =========================================================================
11211
11212    #[test]
11213    fn task_spec_default() {
11214        let spec = TaskSpec::default();
11215        assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
11216        assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
11217        assert!(spec.name.is_none());
11218    }
11219
11220    #[test]
11221    fn task_spec_new() {
11222        let spec = TaskSpec::new(5.0, 20.0);
11223        assert_eq!(spec.weight, 5.0);
11224        assert_eq!(spec.estimate_ms, 20.0);
11225        assert!(spec.name.is_none());
11226    }
11227
11228    #[test]
11229    fn task_spec_with_name() {
11230        let spec = TaskSpec::default().with_name("fetch_data");
11231        assert_eq!(spec.name.as_deref(), Some("fetch_data"));
11232    }
11233
11234    #[test]
11235    fn task_spec_debug() {
11236        let spec = TaskSpec::new(2.0, 15.0).with_name("test");
11237        let debug = format!("{spec:?}");
11238        assert!(debug.contains("2.0"));
11239        assert!(debug.contains("15.0"));
11240        assert!(debug.contains("test"));
11241    }
11242
11243    // =========================================================================
11244    // Cmd::count() tests (bd-2yjus)
11245    // =========================================================================
11246
11247    #[test]
11248    fn cmd_count_none() {
11249        let cmd: Cmd<TestMsg> = Cmd::none();
11250        assert_eq!(cmd.count(), 0);
11251    }
11252
11253    #[test]
11254    fn cmd_count_atomic() {
11255        assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
11256        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
11257        assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
11258        assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
11259        assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
11260        assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
11261        assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
11262    }
11263
11264    #[test]
11265    fn cmd_count_batch() {
11266        let cmd: Cmd<TestMsg> =
11267            Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
11268        assert_eq!(cmd.count(), 2); // quit + msg, none counts 0
11269    }
11270
11271    #[test]
11272    fn cmd_count_nested() {
11273        let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
11274            Cmd::msg(TestMsg::Increment),
11275            Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
11276        ]);
11277        assert_eq!(cmd.count(), 3);
11278    }
11279
11280    // =========================================================================
11281    // Cmd::type_name() tests (bd-2yjus)
11282    // =========================================================================
11283
11284    #[test]
11285    fn cmd_type_name_all_variants() {
11286        assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
11287        assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
11288        assert_eq!(
11289            Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
11290            "Batch"
11291        );
11292        assert_eq!(
11293            Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
11294            "Sequence"
11295        );
11296        assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
11297        assert_eq!(
11298            Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
11299            "Tick"
11300        );
11301        assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
11302        assert_eq!(
11303            Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
11304            "Task"
11305        );
11306        assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
11307        assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
11308        assert_eq!(
11309            Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
11310            "SetMouseCapture"
11311        );
11312    }
11313
11314    // =========================================================================
11315    // Cmd::batch() / Cmd::sequence() edge-case tests (bd-2yjus)
11316    // =========================================================================
11317
11318    #[test]
11319    fn cmd_batch_empty_returns_none() {
11320        let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
11321        assert!(matches!(cmd, Cmd::None));
11322    }
11323
11324    #[test]
11325    fn cmd_batch_single_unwraps() {
11326        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
11327        assert!(matches!(cmd, Cmd::Quit));
11328    }
11329
11330    #[test]
11331    fn cmd_batch_multiple_stays_batch() {
11332        let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
11333        assert!(matches!(cmd, Cmd::Batch(_)));
11334    }
11335
11336    #[test]
11337    fn cmd_sequence_empty_returns_none() {
11338        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
11339        assert!(matches!(cmd, Cmd::None));
11340    }
11341
11342    #[test]
11343    fn cmd_sequence_single_unwraps_to_inner() {
11344        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
11345        assert!(matches!(cmd, Cmd::Quit));
11346    }
11347
11348    #[test]
11349    fn cmd_sequence_multiple_stays_sequence() {
11350        let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
11351        assert!(matches!(cmd, Cmd::Sequence(_)));
11352    }
11353
11354    // =========================================================================
11355    // Cmd task constructor variants (bd-2yjus)
11356    // =========================================================================
11357
11358    #[test]
11359    fn cmd_task_with_spec() {
11360        let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
11361        let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
11362        match cmd {
11363            Cmd::Task(s, _) => {
11364                assert_eq!(s.weight, 3.0);
11365                assert_eq!(s.estimate_ms, 25.0);
11366                assert_eq!(s.name.as_deref(), Some("my_task"));
11367            }
11368            _ => panic!("expected Task variant"),
11369        }
11370    }
11371
11372    #[test]
11373    fn cmd_task_weighted() {
11374        let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
11375        match cmd {
11376            Cmd::Task(s, _) => {
11377                assert_eq!(s.weight, 2.0);
11378                assert_eq!(s.estimate_ms, 50.0);
11379                assert!(s.name.is_none());
11380            }
11381            _ => panic!("expected Task variant"),
11382        }
11383    }
11384
11385    #[test]
11386    fn cmd_task_named() {
11387        let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
11388        match cmd {
11389            Cmd::Task(s, _) => {
11390                assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
11391                assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
11392                assert_eq!(s.name.as_deref(), Some("background_fetch"));
11393            }
11394            _ => panic!("expected Task variant"),
11395        }
11396    }
11397
11398    // =========================================================================
11399    // Cmd Debug formatting (bd-2yjus)
11400    // =========================================================================
11401
11402    #[test]
11403    fn cmd_debug_all_variant_strings() {
11404        assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
11405        assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
11406        assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
11407        assert!(
11408            format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
11409        );
11410        assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
11411        assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
11412        assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
11413        assert_eq!(
11414            format!("{:?}", Cmd::<TestMsg>::restore_state()),
11415            "RestoreState"
11416        );
11417        assert_eq!(
11418            format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
11419            "SetMouseCapture(true)"
11420        );
11421    }
11422
11423    // =========================================================================
11424    // Cmd::set_mouse_capture headless execution (bd-2yjus)
11425    // =========================================================================
11426
11427    #[test]
11428    fn headless_execute_cmd_set_mouse_capture() {
11429        let mut program =
11430            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11431        assert!(!program.backend_features.mouse_capture);
11432
11433        program
11434            .execute_cmd(Cmd::set_mouse_capture(true))
11435            .expect("set mouse capture true");
11436        assert!(program.backend_features.mouse_capture);
11437
11438        program
11439            .execute_cmd(Cmd::set_mouse_capture(false))
11440            .expect("set mouse capture false");
11441        assert!(!program.backend_features.mouse_capture);
11442    }
11443
11444    // =========================================================================
11445    // ResizeBehavior tests (bd-2yjus)
11446    // =========================================================================
11447
11448    #[test]
11449    fn resize_behavior_uses_coalescer() {
11450        assert!(ResizeBehavior::Throttled.uses_coalescer());
11451        assert!(!ResizeBehavior::Immediate.uses_coalescer());
11452    }
11453
11454    #[test]
11455    fn resize_behavior_eq_and_debug() {
11456        assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
11457        assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
11458        let debug = format!("{:?}", ResizeBehavior::Throttled);
11459        assert_eq!(debug, "Throttled");
11460    }
11461
11462    // =========================================================================
11463    // WidgetRefreshConfig default values (bd-2yjus)
11464    // =========================================================================
11465
11466    #[test]
11467    fn widget_refresh_config_defaults() {
11468        let config = WidgetRefreshConfig::default();
11469        assert!(config.enabled);
11470        assert_eq!(config.staleness_window_ms, 1_000);
11471        assert_eq!(config.starve_ms, 3_000);
11472        assert_eq!(config.max_starved_per_frame, 2);
11473        assert_eq!(config.max_drop_fraction, 1.0);
11474        assert_eq!(config.weight_priority, 1.0);
11475        assert_eq!(config.weight_staleness, 0.5);
11476        assert_eq!(config.weight_focus, 0.75);
11477        assert_eq!(config.weight_interaction, 0.5);
11478        assert_eq!(config.starve_boost, 1.5);
11479        assert_eq!(config.min_cost_us, 1.0);
11480    }
11481
11482    // =========================================================================
11483    // EffectQueueConfig tests (bd-2yjus)
11484    // =========================================================================
11485
11486    #[test]
11487    fn effect_queue_config_default() {
11488        let config = EffectQueueConfig::default();
11489        assert!(!config.enabled);
11490        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
11491        assert!(!config.explicit_backend);
11492        assert!(config.scheduler.smith_enabled);
11493        assert!(!config.scheduler.force_fifo);
11494        assert!(!config.scheduler.preemptive);
11495    }
11496
11497    #[test]
11498    fn effect_queue_config_with_enabled() {
11499        let config = EffectQueueConfig::default().with_enabled(true);
11500        assert!(config.enabled);
11501        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
11502        assert!(config.explicit_backend);
11503    }
11504
11505    #[test]
11506    fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
11507        let config = EffectQueueConfig::default().with_enabled(false);
11508        assert!(!config.enabled);
11509        assert_eq!(config.backend, TaskExecutorBackend::Spawned);
11510        assert!(config.explicit_backend);
11511    }
11512
11513    #[test]
11514    fn effect_queue_config_with_backend() {
11515        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
11516        assert!(config.enabled);
11517        assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
11518        assert!(config.explicit_backend);
11519    }
11520
11521    #[cfg(feature = "asupersync-executor")]
11522    #[test]
11523    fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
11524        let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
11525        assert!(!config.enabled);
11526        assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
11527    }
11528
11529    #[test]
11530    fn effect_queue_config_with_scheduler() {
11531        let sched = SchedulerConfig {
11532            force_fifo: true,
11533            ..Default::default()
11534        };
11535        let config = EffectQueueConfig::default().with_scheduler(sched);
11536        assert!(config.scheduler.force_fifo);
11537    }
11538
11539    // =========================================================================
11540    // InlineAutoRemeasureConfig defaults (bd-2yjus)
11541    // =========================================================================
11542
11543    #[test]
11544    fn inline_auto_remeasure_config_defaults() {
11545        let config = InlineAutoRemeasureConfig::default();
11546        assert_eq!(config.change_threshold_rows, 1);
11547        assert_eq!(config.voi.prior_alpha, 1.0);
11548        assert_eq!(config.voi.prior_beta, 9.0);
11549        assert_eq!(config.voi.max_interval_ms, 1000);
11550        assert_eq!(config.voi.min_interval_ms, 100);
11551        assert_eq!(config.voi.sample_cost, 0.08);
11552    }
11553
11554    // =========================================================================
11555    // HeadlessEventSource direct tests (bd-2yjus)
11556    // =========================================================================
11557
11558    #[test]
11559    fn headless_event_source_size() {
11560        let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
11561        assert_eq!(source.size().unwrap(), (120, 40));
11562    }
11563
11564    #[test]
11565    fn headless_event_source_poll_always_false() {
11566        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11567        assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
11568    }
11569
11570    #[test]
11571    fn headless_event_source_read_always_none() {
11572        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11573        assert!(source.read_event().unwrap().is_none());
11574    }
11575
11576    #[test]
11577    fn headless_event_source_set_features() {
11578        let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
11579        let features = BackendFeatures {
11580            mouse_capture: true,
11581            bracketed_paste: true,
11582            focus_events: true,
11583            kitty_keyboard: true,
11584        };
11585        source.set_features(features).unwrap();
11586        assert_eq!(source.features, features);
11587    }
11588
11589    #[test]
11590    fn immediate_drain_budget_adds_backoff_poll_under_burst() {
11591        use ftui_core::event::{KeyCode, KeyEvent};
11592
11593        struct DrainBurstModel {
11594            processed: usize,
11595            quit_after: usize,
11596        }
11597
11598        #[derive(Debug)]
11599        #[allow(dead_code)]
11600        enum DrainBurstMsg {
11601            Event(Event),
11602        }
11603
11604        impl From<Event> for DrainBurstMsg {
11605            fn from(event: Event) -> Self {
11606                DrainBurstMsg::Event(event)
11607            }
11608        }
11609
11610        impl Model for DrainBurstModel {
11611            type Message = DrainBurstMsg;
11612
11613            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11614                match msg {
11615                    DrainBurstMsg::Event(_) => {
11616                        self.processed = self.processed.saturating_add(1);
11617                        if self.processed >= self.quit_after {
11618                            Cmd::quit()
11619                        } else {
11620                            Cmd::none()
11621                        }
11622                    }
11623                }
11624            }
11625
11626            fn view(&self, _frame: &mut Frame) {}
11627        }
11628
11629        struct DrainBurstEventSource {
11630            queue: VecDeque<Event>,
11631            poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
11632            size: (u16, u16),
11633        }
11634
11635        impl BackendEventSource for DrainBurstEventSource {
11636            type Error = io::Error;
11637
11638            fn size(&self) -> Result<(u16, u16), Self::Error> {
11639                Ok(self.size)
11640            }
11641
11642            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
11643                Ok(())
11644            }
11645
11646            fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
11647                self.poll_timeouts.lock().unwrap().push(timeout);
11648                Ok(!self.queue.is_empty())
11649            }
11650
11651            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
11652                Ok(self.queue.pop_front())
11653            }
11654        }
11655
11656        let burst_events = 24usize;
11657        let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
11658        let mut queue = VecDeque::new();
11659        for _ in 0..burst_events {
11660            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
11661        }
11662
11663        let events = DrainBurstEventSource {
11664            queue,
11665            poll_timeouts: poll_timeouts.clone(),
11666            size: (80, 24),
11667        };
11668        let writer = TerminalWriter::new(
11669            Vec::<u8>::new(),
11670            ScreenMode::AltScreen,
11671            UiAnchor::Bottom,
11672            TerminalCapabilities::dumb(),
11673        );
11674        let config = ProgramConfig::default()
11675            .with_forced_size(80, 24)
11676            .with_signal_interception(false)
11677            .with_immediate_drain(ImmediateDrainConfig {
11678                max_zero_timeout_polls_per_burst: 3,
11679                max_burst_duration: Duration::from_secs(1),
11680                backoff_timeout: Duration::from_millis(1),
11681            });
11682
11683        let model = DrainBurstModel {
11684            processed: 0,
11685            quit_after: burst_events,
11686        };
11687        let mut program =
11688            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
11689                .expect("program creation");
11690        program.run().expect("run burst");
11691
11692        assert_eq!(program.model().processed, burst_events);
11693
11694        let stats = program.immediate_drain_stats();
11695        assert_eq!(stats.bursts, 1);
11696        assert!(stats.capped_bursts >= 1);
11697        assert!(stats.backoff_polls >= 1);
11698        assert!(stats.zero_timeout_polls >= 1);
11699        assert!(stats.max_zero_timeout_polls_in_burst <= 3);
11700
11701        let timeouts = poll_timeouts.lock().unwrap();
11702        assert!(timeouts.contains(&Duration::ZERO));
11703        assert!(timeouts.contains(&Duration::from_millis(1)));
11704    }
11705
11706    #[test]
11707    fn immediate_drain_zero_poll_limit_is_clamped() {
11708        use ftui_core::event::{KeyCode, KeyEvent};
11709
11710        struct ClampModel {
11711            processed: usize,
11712            quit_after: usize,
11713        }
11714
11715        #[derive(Debug)]
11716        #[allow(dead_code)]
11717        enum ClampMsg {
11718            Event(Event),
11719        }
11720
11721        impl From<Event> for ClampMsg {
11722            fn from(event: Event) -> Self {
11723                ClampMsg::Event(event)
11724            }
11725        }
11726
11727        impl Model for ClampModel {
11728            type Message = ClampMsg;
11729
11730            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11731                match msg {
11732                    ClampMsg::Event(_) => {
11733                        self.processed = self.processed.saturating_add(1);
11734                        if self.processed >= self.quit_after {
11735                            Cmd::quit()
11736                        } else {
11737                            Cmd::none()
11738                        }
11739                    }
11740                }
11741            }
11742
11743            fn view(&self, _frame: &mut Frame) {}
11744        }
11745
11746        struct ClampSource {
11747            queue: VecDeque<Event>,
11748        }
11749
11750        impl BackendEventSource for ClampSource {
11751            type Error = io::Error;
11752
11753            fn size(&self) -> Result<(u16, u16), Self::Error> {
11754                Ok((80, 24))
11755            }
11756
11757            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
11758                Ok(())
11759            }
11760
11761            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
11762                Ok(!self.queue.is_empty())
11763            }
11764
11765            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
11766                Ok(self.queue.pop_front())
11767            }
11768        }
11769
11770        let burst_events = 8usize;
11771        let mut queue = VecDeque::new();
11772        for _ in 0..burst_events {
11773            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
11774        }
11775        let events = ClampSource { queue };
11776
11777        let writer = TerminalWriter::new(
11778            Vec::<u8>::new(),
11779            ScreenMode::AltScreen,
11780            UiAnchor::Bottom,
11781            TerminalCapabilities::dumb(),
11782        );
11783        let config = ProgramConfig::default()
11784            .with_forced_size(80, 24)
11785            .with_signal_interception(false)
11786            .with_immediate_drain(ImmediateDrainConfig {
11787                max_zero_timeout_polls_per_burst: 0,
11788                max_burst_duration: Duration::from_secs(1),
11789                backoff_timeout: Duration::from_millis(1),
11790            });
11791        let model = ClampModel {
11792            processed: 0,
11793            quit_after: burst_events,
11794        };
11795
11796        let mut program =
11797            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
11798                .expect("program creation");
11799        program.run().expect("run clamp");
11800
11801        let stats = program.immediate_drain_stats();
11802        assert!(stats.max_zero_timeout_polls_in_burst <= 1);
11803    }
11804
11805    #[test]
11806    fn quit_stops_draining_remaining_burst_events() {
11807        use ftui_core::event::{KeyCode, KeyEvent};
11808
11809        struct QuitBurstModel {
11810            processed: usize,
11811            quit_after: usize,
11812        }
11813
11814        #[derive(Debug)]
11815        #[allow(dead_code)]
11816        enum QuitBurstMsg {
11817            Event(Event),
11818        }
11819
11820        impl From<Event> for QuitBurstMsg {
11821            fn from(event: Event) -> Self {
11822                Self::Event(event)
11823            }
11824        }
11825
11826        impl Model for QuitBurstModel {
11827            type Message = QuitBurstMsg;
11828
11829            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11830                match msg {
11831                    QuitBurstMsg::Event(_) => {
11832                        self.processed = self.processed.saturating_add(1);
11833                        if self.processed >= self.quit_after {
11834                            Cmd::quit()
11835                        } else {
11836                            Cmd::none()
11837                        }
11838                    }
11839                }
11840            }
11841
11842            fn view(&self, _frame: &mut Frame) {}
11843        }
11844
11845        struct QuitBurstSource {
11846            queue: VecDeque<Event>,
11847        }
11848
11849        impl BackendEventSource for QuitBurstSource {
11850            type Error = io::Error;
11851
11852            fn size(&self) -> Result<(u16, u16), Self::Error> {
11853                Ok((80, 24))
11854            }
11855
11856            fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
11857                Ok(())
11858            }
11859
11860            fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
11861                Ok(!self.queue.is_empty())
11862            }
11863
11864            fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
11865                Ok(self.queue.pop_front())
11866            }
11867        }
11868
11869        let total_events = 8usize;
11870        let quit_after = 3usize;
11871        let mut queue = VecDeque::new();
11872        for _ in 0..total_events {
11873            queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
11874        }
11875
11876        let writer = TerminalWriter::new(
11877            Vec::<u8>::new(),
11878            ScreenMode::AltScreen,
11879            UiAnchor::Bottom,
11880            TerminalCapabilities::dumb(),
11881        );
11882        let config = ProgramConfig::default()
11883            .with_forced_size(80, 24)
11884            .with_signal_interception(false)
11885            .with_immediate_drain(ImmediateDrainConfig {
11886                max_zero_timeout_polls_per_burst: 64,
11887                max_burst_duration: Duration::from_secs(1),
11888                backoff_timeout: Duration::from_millis(1),
11889            });
11890        let model = QuitBurstModel {
11891            processed: 0,
11892            quit_after,
11893        };
11894        let events = QuitBurstSource { queue };
11895
11896        let mut program =
11897            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
11898                .expect("program creation");
11899        program.run().expect("run burst quit");
11900
11901        assert_eq!(program.model().processed, quit_after);
11902    }
11903
11904    // =========================================================================
11905    // Program helper methods (bd-2yjus)
11906    // =========================================================================
11907
11908    #[test]
11909    fn headless_program_quit_and_is_running() {
11910        let mut program =
11911            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11912        assert!(program.is_running());
11913
11914        program.quit();
11915        assert!(!program.is_running());
11916    }
11917
11918    #[test]
11919    fn headless_program_model_mut() {
11920        let mut program =
11921            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11922        assert_eq!(program.model().value, 0);
11923
11924        program.model_mut().value = 42;
11925        assert_eq!(program.model().value, 42);
11926    }
11927
11928    #[test]
11929    fn headless_program_request_redraw() {
11930        let mut program =
11931            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11932        program.dirty = false;
11933
11934        program.request_redraw();
11935        assert!(program.dirty);
11936    }
11937
11938    #[test]
11939    fn headless_program_last_widget_signals_initially_empty() {
11940        let program =
11941            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11942        assert!(program.last_widget_signals().is_empty());
11943    }
11944
11945    #[test]
11946    fn headless_program_no_persistence_by_default() {
11947        let program =
11948            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11949        assert!(!program.has_persistence());
11950        assert!(program.state_registry().is_none());
11951    }
11952
11953    // =========================================================================
11954    // classify_event_for_fairness (bd-2yjus)
11955    // =========================================================================
11956
11957    #[test]
11958    fn classify_event_fairness_key_is_input() {
11959        let event = Event::Key(ftui_core::event::KeyEvent::new(
11960            ftui_core::event::KeyCode::Char('a'),
11961        ));
11962        let classification =
11963            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
11964        assert_eq!(classification, FairnessEventType::Input);
11965    }
11966
11967    #[test]
11968    fn classify_event_fairness_resize_is_resize() {
11969        let event = Event::Resize {
11970            width: 80,
11971            height: 24,
11972        };
11973        let classification =
11974            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
11975        assert_eq!(classification, FairnessEventType::Resize);
11976    }
11977
11978    #[test]
11979    fn classify_event_fairness_tick_is_tick() {
11980        let event = Event::Tick;
11981        let classification =
11982            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
11983        assert_eq!(classification, FairnessEventType::Tick);
11984    }
11985
11986    #[test]
11987    fn classify_event_fairness_paste_is_input() {
11988        let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
11989        let classification =
11990            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
11991        assert_eq!(classification, FairnessEventType::Input);
11992    }
11993
11994    #[test]
11995    fn classify_event_fairness_focus_is_input() {
11996        let event = Event::Focus(true);
11997        let classification =
11998            Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
11999        assert_eq!(classification, FairnessEventType::Input);
12000    }
12001
12002    // =========================================================================
12003    // ProgramConfig builder methods (bd-2yjus)
12004    // =========================================================================
12005
12006    #[test]
12007    fn program_config_with_diff_config() {
12008        let diff = RuntimeDiffConfig::default();
12009        let config = ProgramConfig::default().with_diff_config(diff.clone());
12010        // Just verify it doesn't panic and the field is set
12011        let _ = format!("{:?}", config);
12012    }
12013
12014    #[test]
12015    fn program_config_with_evidence_sink() {
12016        let config =
12017            ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
12018        let _ = format!("{:?}", config);
12019    }
12020
12021    #[test]
12022    fn program_config_with_render_trace() {
12023        let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
12024        let _ = format!("{:?}", config);
12025    }
12026
12027    #[test]
12028    fn program_config_with_locale() {
12029        let config = ProgramConfig::default().with_locale("fr");
12030        let _ = format!("{:?}", config);
12031    }
12032
12033    #[test]
12034    fn program_config_with_locale_context() {
12035        let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
12036        let _ = format!("{:?}", config);
12037    }
12038
12039    #[test]
12040    fn program_config_without_forced_size() {
12041        let config = ProgramConfig::default()
12042            .with_forced_size(80, 24)
12043            .without_forced_size();
12044        assert!(config.forced_size.is_none());
12045    }
12046
12047    #[test]
12048    fn program_config_forced_size_clamps_min() {
12049        let config = ProgramConfig::default().with_forced_size(0, 0);
12050        assert_eq!(config.forced_size, Some((1, 1)));
12051    }
12052
12053    #[test]
12054    fn program_config_with_widget_refresh() {
12055        let wrc = WidgetRefreshConfig {
12056            enabled: false,
12057            ..Default::default()
12058        };
12059        let config = ProgramConfig::default().with_widget_refresh(wrc);
12060        assert!(!config.widget_refresh.enabled);
12061    }
12062
12063    #[test]
12064    fn program_config_with_effect_queue() {
12065        let eqc = EffectQueueConfig::default().with_enabled(true);
12066        let config = ProgramConfig::default().with_effect_queue(eqc);
12067        assert!(config.effect_queue.enabled);
12068        assert_eq!(
12069            config.effect_queue.backend,
12070            TaskExecutorBackend::EffectQueue
12071        );
12072    }
12073
12074    #[test]
12075    fn program_config_with_resize_coalescer_custom() {
12076        let cc = CoalescerConfig {
12077            steady_delay_ms: 42,
12078            ..Default::default()
12079        };
12080        let config = ProgramConfig::default().with_resize_coalescer(cc);
12081        assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
12082    }
12083
12084    #[test]
12085    fn program_config_with_inline_auto_remeasure() {
12086        let config = ProgramConfig::default()
12087            .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
12088        assert!(config.inline_auto_remeasure.is_some());
12089
12090        let config = config.without_inline_auto_remeasure();
12091        assert!(config.inline_auto_remeasure.is_none());
12092    }
12093
12094    #[test]
12095    fn program_config_with_persistence_full() {
12096        let pc = PersistenceConfig::disabled();
12097        let config = ProgramConfig::default().with_persistence(pc);
12098        assert!(config.persistence.registry.is_none());
12099    }
12100
12101    #[test]
12102    fn program_config_with_conformal_config() {
12103        let config = ProgramConfig::default()
12104            .with_conformal_config(ConformalConfig::default())
12105            .without_conformal();
12106        assert!(config.conformal_config.is_none());
12107    }
12108
12109    // =========================================================================
12110    // Rollout config builder methods (bd-2crbt)
12111    // =========================================================================
12112
12113    #[test]
12114    fn program_config_with_lane() {
12115        let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
12116        assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
12117    }
12118
12119    #[test]
12120    fn program_config_default_lane_resolves_to_effect_queue_backend() {
12121        let resolved = ProgramConfig::default().resolved_effect_queue_config();
12122        assert!(resolved.enabled);
12123        assert_eq!(resolved.backend, TaskExecutorBackend::EffectQueue);
12124    }
12125
12126    #[test]
12127    fn program_config_legacy_lane_resolves_to_spawned_backend() {
12128        let resolved = ProgramConfig::default()
12129            .with_lane(RuntimeLane::Legacy)
12130            .resolved_effect_queue_config();
12131        assert!(!resolved.enabled);
12132        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
12133    }
12134
12135    #[test]
12136    fn program_config_explicit_spawned_backend_is_preserved() {
12137        let resolved = ProgramConfig::default()
12138            .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
12139            .resolved_effect_queue_config();
12140        assert!(!resolved.enabled);
12141        assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
12142    }
12143
12144    #[test]
12145    fn program_config_with_rollout_policy() {
12146        let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
12147        assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
12148    }
12149
12150    #[test]
12151    fn rollout_policy_labels() {
12152        assert_eq!(RolloutPolicy::Off.label(), "off");
12153        assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
12154        assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
12155        assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
12156    }
12157
12158    #[test]
12159    fn rollout_policy_is_shadow() {
12160        assert!(!RolloutPolicy::Off.is_shadow());
12161        assert!(RolloutPolicy::Shadow.is_shadow());
12162        assert!(!RolloutPolicy::Enabled.is_shadow());
12163    }
12164
12165    #[test]
12166    fn rollout_policy_default_is_off() {
12167        assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
12168    }
12169
12170    #[test]
12171    fn runtime_lane_parse_legacy() {
12172        assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
12173    }
12174
12175    #[test]
12176    fn runtime_lane_parse_structured_case_insensitive() {
12177        assert_eq!(
12178            RuntimeLane::parse("Structured"),
12179            Some(RuntimeLane::Structured)
12180        );
12181    }
12182
12183    #[test]
12184    fn runtime_lane_parse_asupersync_uppercase() {
12185        assert_eq!(
12186            RuntimeLane::parse("ASUPERSYNC"),
12187            Some(RuntimeLane::Asupersync)
12188        );
12189    }
12190
12191    #[test]
12192    fn runtime_lane_parse_unrecognized() {
12193        assert_eq!(RuntimeLane::parse("bogus"), None);
12194    }
12195
12196    #[test]
12197    fn rollout_policy_parse_shadow() {
12198        assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
12199    }
12200
12201    #[test]
12202    fn rollout_policy_parse_enabled() {
12203        assert_eq!(
12204            RolloutPolicy::parse("enabled"),
12205            Some(RolloutPolicy::Enabled)
12206        );
12207    }
12208
12209    #[test]
12210    fn rollout_policy_parse_off() {
12211        assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
12212    }
12213
12214    #[test]
12215    fn rollout_policy_parse_unrecognized() {
12216        assert_eq!(RolloutPolicy::parse("bogus"), None);
12217    }
12218
12219    // =========================================================================
12220    // PersistenceConfig Debug (bd-2yjus)
12221    // =========================================================================
12222
12223    #[test]
12224    fn persistence_config_debug() {
12225        let config = PersistenceConfig::default();
12226        let debug = format!("{config:?}");
12227        assert!(debug.contains("PersistenceConfig"));
12228        assert!(debug.contains("auto_load"));
12229        assert!(debug.contains("auto_save"));
12230    }
12231
12232    // =========================================================================
12233    // FrameTimingConfig (bd-2yjus)
12234    // =========================================================================
12235
12236    #[test]
12237    fn frame_timing_config_debug() {
12238        use std::sync::Arc;
12239
12240        struct DummySink;
12241        impl FrameTimingSink for DummySink {
12242            fn record_frame(&self, _timing: &FrameTiming) {}
12243        }
12244
12245        let config = FrameTimingConfig::new(Arc::new(DummySink));
12246        let debug = format!("{config:?}");
12247        assert!(debug.contains("FrameTimingConfig"));
12248    }
12249
12250    #[test]
12251    fn program_config_with_frame_timing() {
12252        use std::sync::Arc;
12253
12254        struct DummySink;
12255        impl FrameTimingSink for DummySink {
12256            fn record_frame(&self, _timing: &FrameTiming) {}
12257        }
12258
12259        let config =
12260            ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
12261        assert!(config.frame_timing.is_some());
12262    }
12263
12264    // =========================================================================
12265    // BudgetDecisionEvidence helper functions (bd-2yjus)
12266    // =========================================================================
12267
12268    #[test]
12269    fn budget_decision_evidence_decision_from_levels() {
12270        use ftui_render::budget::DegradationLevel;
12271        // Degrade: after > before
12272        assert_eq!(
12273            BudgetDecisionEvidence::decision_from_levels(
12274                DegradationLevel::Full,
12275                DegradationLevel::SimpleBorders
12276            ),
12277            BudgetDecision::Degrade
12278        );
12279        // Upgrade: after < before
12280        assert_eq!(
12281            BudgetDecisionEvidence::decision_from_levels(
12282                DegradationLevel::SimpleBorders,
12283                DegradationLevel::Full
12284            ),
12285            BudgetDecision::Upgrade
12286        );
12287        // Hold: same
12288        assert_eq!(
12289            BudgetDecisionEvidence::decision_from_levels(
12290                DegradationLevel::Full,
12291                DegradationLevel::Full
12292            ),
12293            BudgetDecision::Hold
12294        );
12295    }
12296
12297    // =========================================================================
12298    // WidgetRefreshPlan (bd-2yjus)
12299    // =========================================================================
12300
12301    #[test]
12302    fn widget_refresh_plan_clear() {
12303        let mut plan = WidgetRefreshPlan::new();
12304        plan.frame_idx = 5;
12305        plan.budget_us = 100.0;
12306        plan.signal_count = 3;
12307        plan.over_budget = true;
12308        plan.clear();
12309        assert_eq!(plan.frame_idx, 0);
12310        assert_eq!(plan.budget_us, 0.0);
12311        assert_eq!(plan.signal_count, 0);
12312        assert!(!plan.over_budget);
12313    }
12314
12315    #[test]
12316    fn widget_refresh_plan_as_budget_empty_signals() {
12317        let plan = WidgetRefreshPlan::new();
12318        let budget = plan.as_budget();
12319        // With signal_count == 0, should be allow_all (allows any widget)
12320        assert!(budget.allows(0, false));
12321        assert!(budget.allows(999, false));
12322    }
12323
12324    #[test]
12325    fn widget_refresh_plan_to_jsonl_structure() {
12326        let plan = WidgetRefreshPlan::new();
12327        let jsonl = plan.to_jsonl();
12328        assert!(jsonl.contains("\"event\":\"widget_refresh\""));
12329        assert!(jsonl.contains("\"frame_idx\":0"));
12330        assert!(jsonl.contains("\"selected\":[]"));
12331    }
12332
12333    // =========================================================================
12334    // BatchController Default trait (bd-2yjus)
12335    // =========================================================================
12336
12337    #[test]
12338    fn batch_controller_default_trait() {
12339        let bc = BatchController::default();
12340        let bc2 = BatchController::new();
12341        // Should be equivalent
12342        assert_eq!(bc.tau_s(), bc2.tau_s());
12343        assert_eq!(bc.observations(), bc2.observations());
12344    }
12345
12346    #[test]
12347    fn batch_controller_observe_arrival_stale_gap_ignored() {
12348        let mut bc = BatchController::new();
12349        let base = Instant::now();
12350        // First arrival
12351        bc.observe_arrival(base);
12352        // Stale gap > 10s should be ignored
12353        bc.observe_arrival(base + Duration::from_secs(15));
12354        assert_eq!(bc.observations(), 0);
12355    }
12356
12357    #[test]
12358    fn batch_controller_observe_service_out_of_range() {
12359        let mut bc = BatchController::new();
12360        let original_service = bc.service_est_s();
12361        // Out-of-range (>= 10s) should be ignored
12362        bc.observe_service(Duration::from_secs(15));
12363        assert_eq!(bc.service_est_s(), original_service);
12364    }
12365
12366    #[test]
12367    fn batch_controller_lambda_zero_inter_arrival() {
12368        // When ema_inter_arrival_s is effectively 0, lambda should be 0
12369        let bc = BatchController {
12370            ema_inter_arrival_s: 0.0,
12371            ..BatchController::new()
12372        };
12373        assert_eq!(bc.lambda_est(), 0.0);
12374    }
12375
12376    // =========================================================================
12377    // Headless program: Cmd::Log with and without trailing newline (bd-2yjus)
12378    // =========================================================================
12379
12380    #[test]
12381    fn headless_execute_cmd_log_appends_newline_if_missing() {
12382        let mut program =
12383            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12384        program.execute_cmd(Cmd::log("no newline")).expect("log");
12385
12386        let bytes = program.writer.into_inner().expect("writer output");
12387        let output = String::from_utf8_lossy(&bytes);
12388        // The sanitized output should end with a newline
12389        assert!(output.contains("no newline"));
12390    }
12391
12392    #[test]
12393    fn headless_execute_cmd_log_preserves_trailing_newline() {
12394        let mut program =
12395            headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12396        program
12397            .execute_cmd(Cmd::log("with newline\n"))
12398            .expect("log");
12399
12400        let bytes = program.writer.into_inner().expect("writer output");
12401        let output = String::from_utf8_lossy(&bytes);
12402        assert!(output.contains("with newline"));
12403    }
12404
12405    // =========================================================================
12406    // Headless program: immediate resize behavior (bd-2yjus)
12407    // =========================================================================
12408
12409    #[test]
12410    fn headless_handle_event_immediate_resize() {
12411        struct ResizeModel {
12412            last_size: Option<(u16, u16)>,
12413        }
12414
12415        #[derive(Debug)]
12416        enum ResizeMsg {
12417            Resize(u16, u16),
12418            Other,
12419        }
12420
12421        impl From<Event> for ResizeMsg {
12422            fn from(event: Event) -> Self {
12423                match event {
12424                    Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12425                    _ => ResizeMsg::Other,
12426                }
12427            }
12428        }
12429
12430        impl Model for ResizeModel {
12431            type Message = ResizeMsg;
12432
12433            fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12434                if let ResizeMsg::Resize(w, h) = msg {
12435                    self.last_size = Some((w, h));
12436                }
12437                Cmd::none()
12438            }
12439
12440            fn view(&self, _frame: &mut Frame) {}
12441        }
12442
12443        let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
12444        let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12445
12446        program
12447            .handle_event(Event::Resize {
12448                width: 120,
12449                height: 40,
12450            })
12451            .expect("handle resize");
12452
12453        assert_eq!(program.width, 120);
12454        assert_eq!(program.height, 40);
12455        assert_eq!(program.model().last_size, Some((120, 40)));
12456    }
12457
12458    // =========================================================================
12459    // Headless program: resize clamps zero dimensions (bd-2yjus)
12460    // =========================================================================
12461
12462    #[test]
12463    fn headless_apply_resize_clamps_zero_to_one() {
12464        struct SimpleModel;
12465
12466        #[derive(Debug)]
12467        enum SimpleMsg {
12468            Noop,
12469        }
12470
12471        impl From<Event> for SimpleMsg {
12472            fn from(_: Event) -> Self {
12473                SimpleMsg::Noop
12474            }
12475        }
12476
12477        impl Model for SimpleModel {
12478            type Message = SimpleMsg;
12479
12480            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12481                Cmd::none()
12482            }
12483
12484            fn view(&self, _frame: &mut Frame) {}
12485        }
12486
12487        let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
12488        program
12489            .apply_resize(0, 0, Duration::ZERO, false)
12490            .expect("resize");
12491
12492        // Zero dimensions should be clamped to 1
12493        assert_eq!(program.width, 1);
12494        assert_eq!(program.height, 1);
12495    }
12496
12497    // =========================================================================
12498    // PaneTerminalAdapter::force_cancel_all (bd-24v9m)
12499    // =========================================================================
12500
12501    #[test]
12502    fn force_cancel_all_idle_returns_none() {
12503        let mut adapter =
12504            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12505        assert!(adapter.force_cancel_all().is_none());
12506    }
12507
12508    #[test]
12509    fn force_cancel_all_after_pointer_down_returns_diagnostics() {
12510        let mut adapter =
12511            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12512        let target = pane_target(SplitAxis::Horizontal);
12513
12514        let down = Event::Mouse(MouseEvent::new(
12515            MouseEventKind::Down(MouseButton::Left),
12516            5,
12517            5,
12518        ));
12519        let _ = adapter.translate(&down, Some(target));
12520        assert!(adapter.active_pointer_id().is_some());
12521
12522        let diag = adapter
12523            .force_cancel_all()
12524            .expect("should produce diagnostics");
12525        assert!(diag.had_active_pointer);
12526        assert_eq!(diag.active_pointer_id, Some(1));
12527        assert!(diag.machine_transition.is_some());
12528
12529        // Adapter should be fully idle afterwards
12530        assert_eq!(adapter.active_pointer_id(), None);
12531        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
12532    }
12533
12534    #[test]
12535    fn force_cancel_all_during_drag_returns_diagnostics() {
12536        let mut adapter =
12537            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12538        let target = pane_target(SplitAxis::Vertical);
12539
12540        // Down → arm
12541        let down = Event::Mouse(MouseEvent::new(
12542            MouseEventKind::Down(MouseButton::Left),
12543            3,
12544            3,
12545        ));
12546        let _ = adapter.translate(&down, Some(target));
12547
12548        // Drag → transition to Dragging
12549        let drag = Event::Mouse(MouseEvent::new(
12550            MouseEventKind::Drag(MouseButton::Left),
12551            8,
12552            3,
12553        ));
12554        let _ = adapter.translate(&drag, None);
12555
12556        let diag = adapter
12557            .force_cancel_all()
12558            .expect("should produce diagnostics");
12559        assert!(diag.had_active_pointer);
12560        assert!(diag.machine_transition.is_some());
12561        let transition = diag.machine_transition.unwrap();
12562        assert!(matches!(
12563            transition.effect,
12564            PaneDragResizeEffect::Canceled {
12565                reason: PaneCancelReason::Programmatic,
12566                ..
12567            }
12568        ));
12569    }
12570
12571    #[test]
12572    fn force_cancel_all_is_idempotent() {
12573        let mut adapter =
12574            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12575        let target = pane_target(SplitAxis::Horizontal);
12576
12577        let down = Event::Mouse(MouseEvent::new(
12578            MouseEventKind::Down(MouseButton::Left),
12579            5,
12580            5,
12581        ));
12582        let _ = adapter.translate(&down, Some(target));
12583
12584        let first = adapter.force_cancel_all();
12585        assert!(first.is_some());
12586
12587        let second = adapter.force_cancel_all();
12588        assert!(second.is_none());
12589    }
12590
12591    // =========================================================================
12592    // PaneInteractionGuard (bd-24v9m)
12593    // =========================================================================
12594
12595    #[test]
12596    fn pane_interaction_guard_finish_when_idle() {
12597        let mut adapter =
12598            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12599        let guard = PaneInteractionGuard::new(&mut adapter);
12600        let diag = guard.finish();
12601        assert!(diag.is_none());
12602    }
12603
12604    #[test]
12605    fn pane_interaction_guard_finish_returns_diagnostics() {
12606        let mut adapter =
12607            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12608        let target = pane_target(SplitAxis::Horizontal);
12609
12610        // Start a drag interaction through the adapter directly
12611        let down = Event::Mouse(MouseEvent::new(
12612            MouseEventKind::Down(MouseButton::Left),
12613            5,
12614            5,
12615        ));
12616        let _ = adapter.translate(&down, Some(target));
12617
12618        let guard = PaneInteractionGuard::new(&mut adapter);
12619        let diag = guard.finish().expect("should produce diagnostics");
12620        assert!(diag.had_active_pointer);
12621        assert_eq!(diag.active_pointer_id, Some(1));
12622    }
12623
12624    #[test]
12625    fn pane_interaction_guard_drop_cancels_active_interaction() {
12626        let mut adapter =
12627            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12628        let target = pane_target(SplitAxis::Vertical);
12629
12630        let down = Event::Mouse(MouseEvent::new(
12631            MouseEventKind::Down(MouseButton::Left),
12632            7,
12633            7,
12634        ));
12635        let _ = adapter.translate(&down, Some(target));
12636        assert!(adapter.active_pointer_id().is_some());
12637
12638        {
12639            let _guard = PaneInteractionGuard::new(&mut adapter);
12640            // guard drops here without finish()
12641        }
12642
12643        // After guard drop, adapter should be idle
12644        assert_eq!(adapter.active_pointer_id(), None);
12645        assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
12646    }
12647
12648    #[test]
12649    fn pane_interaction_guard_adapter_access_works() {
12650        let mut adapter =
12651            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12652        let target = pane_target(SplitAxis::Horizontal);
12653
12654        let mut guard = PaneInteractionGuard::new(&mut adapter);
12655
12656        // Use the adapter through the guard
12657        let down = Event::Mouse(MouseEvent::new(
12658            MouseEventKind::Down(MouseButton::Left),
12659            5,
12660            5,
12661        ));
12662        let dispatch = guard.adapter().translate(&down, Some(target));
12663        assert!(dispatch.primary_event.is_some());
12664
12665        // finish should clean up the interaction started through the guard
12666        let diag = guard.finish().expect("should produce diagnostics");
12667        assert!(diag.had_active_pointer);
12668    }
12669
12670    #[test]
12671    fn pane_interaction_guard_finish_then_drop_is_safe() {
12672        let mut adapter =
12673            PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
12674        let target = pane_target(SplitAxis::Horizontal);
12675
12676        let down = Event::Mouse(MouseEvent::new(
12677            MouseEventKind::Down(MouseButton::Left),
12678            5,
12679            5,
12680        ));
12681        let _ = adapter.translate(&down, Some(target));
12682
12683        let guard = PaneInteractionGuard::new(&mut adapter);
12684        let _diag = guard.finish();
12685        // guard is consumed by finish(), so drop doesn't double-cancel
12686        // This test proves the API is safe: finish() takes `self` not `&mut self`
12687        assert_eq!(adapter.active_pointer_id(), None);
12688    }
12689
12690    // =========================================================================
12691    // PaneCapabilityMatrix (bd-6u66i)
12692    // =========================================================================
12693
12694    fn caps_modern() -> TerminalCapabilities {
12695        TerminalCapabilities::modern()
12696    }
12697
12698    fn caps_with_mux(
12699        mux: PaneMuxEnvironment,
12700    ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
12701        let mut caps = TerminalCapabilities::modern();
12702        match mux {
12703            PaneMuxEnvironment::Tmux => caps.in_tmux = true,
12704            PaneMuxEnvironment::Screen => caps.in_screen = true,
12705            PaneMuxEnvironment::Zellij => caps.in_zellij = true,
12706            PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
12707            PaneMuxEnvironment::None => {}
12708        }
12709        caps
12710    }
12711
12712    #[test]
12713    fn capability_matrix_bare_terminal_modern() {
12714        let caps = caps_modern();
12715        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12716
12717        assert_eq!(mat.mux, PaneMuxEnvironment::None);
12718        assert!(mat.mouse_sgr);
12719        assert!(mat.mouse_drag_reliable);
12720        assert!(mat.mouse_button_discrimination);
12721        assert!(mat.focus_events);
12722        assert!(mat.unicode_box_drawing);
12723        assert!(mat.true_color);
12724        assert!(!mat.degraded);
12725        assert!(mat.drag_enabled());
12726        assert!(mat.focus_cancel_effective());
12727        assert!(mat.limitations().is_empty());
12728    }
12729
12730    #[test]
12731    fn capability_matrix_tmux() {
12732        let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
12733        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12734
12735        assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
12736        // Focus cancel path is conservatively disabled in all muxes.
12737        assert!(mat.mouse_drag_reliable);
12738        assert!(!mat.focus_events);
12739        assert!(mat.drag_enabled());
12740        assert!(!mat.focus_cancel_effective());
12741        assert!(mat.degraded);
12742    }
12743
12744    #[test]
12745    fn capability_matrix_screen_degrades_drag() {
12746        let caps = caps_with_mux(PaneMuxEnvironment::Screen);
12747        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12748
12749        assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
12750        assert!(!mat.mouse_drag_reliable);
12751        assert!(!mat.focus_events);
12752        assert!(!mat.drag_enabled());
12753        assert!(!mat.focus_cancel_effective());
12754        assert!(mat.degraded);
12755
12756        let lims = mat.limitations();
12757        assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
12758        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
12759    }
12760
12761    #[test]
12762    fn capability_matrix_zellij() {
12763        let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
12764        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12765
12766        assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
12767        assert!(mat.mouse_drag_reliable);
12768        assert!(!mat.focus_events);
12769        assert!(mat.drag_enabled());
12770        assert!(!mat.focus_cancel_effective());
12771        assert!(mat.degraded);
12772    }
12773
12774    #[test]
12775    fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
12776        let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
12777        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12778
12779        assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
12780        assert!(mat.mouse_drag_reliable);
12781        assert!(!mat.focus_events);
12782        assert!(mat.drag_enabled());
12783        assert!(!mat.focus_cancel_effective());
12784        assert!(mat.degraded);
12785    }
12786
12787    #[test]
12788    fn capability_matrix_no_sgr_mouse() {
12789        let mut caps = caps_modern();
12790        caps.mouse_sgr = false;
12791        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12792
12793        assert!(!mat.mouse_sgr);
12794        assert!(!mat.mouse_button_discrimination);
12795        assert!(mat.degraded);
12796
12797        let lims = mat.limitations();
12798        assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
12799        assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
12800    }
12801
12802    #[test]
12803    fn capability_matrix_no_focus_events() {
12804        let mut caps = caps_modern();
12805        caps.focus_events = false;
12806        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12807
12808        assert!(!mat.focus_events);
12809        assert!(!mat.focus_cancel_effective());
12810        assert!(mat.degraded);
12811
12812        let lims = mat.limitations();
12813        assert!(lims.iter().any(|l| l.id == "no_focus_events"));
12814    }
12815
12816    #[test]
12817    fn capability_matrix_dumb_terminal() {
12818        let caps = TerminalCapabilities::dumb();
12819        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12820
12821        assert_eq!(mat.mux, PaneMuxEnvironment::None);
12822        assert!(!mat.mouse_sgr);
12823        assert!(!mat.focus_events);
12824        assert!(!mat.unicode_box_drawing);
12825        assert!(!mat.true_color);
12826        assert!(mat.degraded);
12827        assert!(mat.limitations().len() >= 3);
12828    }
12829
12830    #[test]
12831    fn capability_matrix_limitations_have_fallbacks() {
12832        let caps = TerminalCapabilities::dumb();
12833        let mat = PaneCapabilityMatrix::from_capabilities(&caps);
12834
12835        for lim in mat.limitations() {
12836            assert!(!lim.id.is_empty());
12837            assert!(!lim.description.is_empty());
12838            assert!(!lim.fallback.is_empty());
12839        }
12840    }
12841
12842    // ========================================================================
12843    // Screen transition detection tests (A.2 + D.3)
12844    // ========================================================================
12845
12846    /// A multi-screen model that implements ScreenTickDispatch, for testing
12847    /// the `check_screen_transition` logic.
12848    struct MultiScreenModel {
12849        active: String,
12850        screens: Vec<String>,
12851        ticked_screens: Vec<(String, u64)>,
12852    }
12853
12854    #[derive(Debug)]
12855    enum MultiScreenMsg {
12856        #[expect(dead_code)]
12857        Event(Event),
12858    }
12859
12860    impl From<Event> for MultiScreenMsg {
12861        fn from(event: Event) -> Self {
12862            MultiScreenMsg::Event(event)
12863        }
12864    }
12865
12866    impl Model for MultiScreenModel {
12867        type Message = MultiScreenMsg;
12868
12869        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12870            match msg {
12871                MultiScreenMsg::Event(_) => Cmd::none(),
12872            }
12873        }
12874
12875        fn view(&self, _frame: &mut Frame) {}
12876
12877        fn as_screen_tick_dispatch(
12878            &mut self,
12879        ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
12880            Some(self)
12881        }
12882    }
12883
12884    impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
12885        fn screen_ids(&self) -> Vec<String> {
12886            self.screens.clone()
12887        }
12888
12889        fn active_screen_id(&self) -> String {
12890            self.active.clone()
12891        }
12892
12893        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
12894            self.ticked_screens.push((screen_id.to_owned(), tick_count));
12895        }
12896    }
12897
12898    /// Shared log for recording strategy transitions (inspectable after test).
12899    type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
12900
12901    /// A recording tick strategy that logs `on_screen_transition` calls
12902    /// to a shared log that can be inspected from test assertions.
12903    struct RecordingStrategy {
12904        log: TransitionLog,
12905    }
12906
12907    impl RecordingStrategy {
12908        fn new(log: TransitionLog) -> Self {
12909            Self { log }
12910        }
12911    }
12912
12913    impl crate::tick_strategy::TickStrategy for RecordingStrategy {
12914        fn should_tick(
12915            &mut self,
12916            _screen_id: &str,
12917            _tick_count: u64,
12918            _active_screen: &str,
12919        ) -> crate::tick_strategy::TickDecision {
12920            crate::tick_strategy::TickDecision::Skip
12921        }
12922
12923        fn on_screen_transition(&mut self, from: &str, to: &str) {
12924            self.log
12925                .lock()
12926                .unwrap()
12927                .push((from.to_owned(), to.to_owned()));
12928        }
12929
12930        fn name(&self) -> &str {
12931            "Recording"
12932        }
12933
12934        fn debug_stats(&self) -> Vec<(String, String)> {
12935            vec![("strategy".into(), "Recording".into())]
12936        }
12937    }
12938
12939    /// Helper to create a headless Program with a multi-screen model and
12940    /// a recording tick strategy. Returns the program and a shared log of
12941    /// `on_screen_transition` calls for assertions.
12942    fn headless_multi_screen_program(
12943        active: &str,
12944        screens: &[&str],
12945    ) -> (
12946        Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
12947        TransitionLog,
12948    ) {
12949        let model = MultiScreenModel {
12950            active: active.to_owned(),
12951            screens: screens.iter().map(|s| (*s).to_owned()).collect(),
12952            ticked_screens: Vec::new(),
12953        };
12954        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12955        let writer = TerminalWriter::new(
12956            Vec::<u8>::new(),
12957            ScreenMode::AltScreen,
12958            UiAnchor::Bottom,
12959            TerminalCapabilities::dumb(),
12960        );
12961        let config = ProgramConfig {
12962            forced_size: Some((80, 24)),
12963            tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
12964            ..ProgramConfig::default()
12965        };
12966        let mut prog =
12967            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12968                .expect("headless program creation failed");
12969
12970        // Replace the default strategy with our recording strategy.
12971        let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
12972        prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
12973
12974        (prog, log)
12975    }
12976
12977    #[test]
12978    fn check_screen_transition_first_call_records_active() {
12979        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
12980
12981        assert!(prog.last_active_screen_for_strategy.is_none());
12982        prog.check_screen_transition();
12983        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
12984
12985        // First observation: no transition event, no force-tick.
12986        assert!(prog.model.ticked_screens.is_empty());
12987        assert!(log.lock().unwrap().is_empty());
12988    }
12989
12990    #[test]
12991    fn check_screen_transition_no_change_is_noop() {
12992        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
12993
12994        // First call: records.
12995        prog.check_screen_transition();
12996
12997        // Second call with same active screen: no-op.
12998        prog.check_screen_transition();
12999        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
13000
13001        // No force-tick, no transition notification.
13002        assert!(prog.model.ticked_screens.is_empty());
13003        assert!(log.lock().unwrap().is_empty());
13004    }
13005
13006    #[test]
13007    fn check_screen_transition_detects_switch_and_force_ticks() {
13008        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13009
13010        prog.check_screen_transition(); // records "A"
13011
13012        // Simulate model switching to screen "B".
13013        prog.model.active = "B".to_owned();
13014        prog.check_screen_transition();
13015
13016        // D.3: force-tick should have been dispatched for "B".
13017        assert_eq!(prog.model.ticked_screens.len(), 1);
13018        assert_eq!(prog.model.ticked_screens[0].0, "B");
13019
13020        // A.2: strategy should have been notified of A → B.
13021        let transitions = log.lock().unwrap();
13022        assert_eq!(transitions.len(), 1);
13023        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
13024
13025        // last_active should now be "B".
13026        assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
13027    }
13028
13029    #[test]
13030    fn check_screen_transition_marks_dirty_on_change() {
13031        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13032
13033        prog.check_screen_transition();
13034        prog.dirty = false;
13035
13036        prog.model.active = "B".to_owned();
13037        prog.check_screen_transition();
13038
13039        assert!(prog.dirty);
13040    }
13041
13042    #[test]
13043    fn check_screen_transition_not_dirty_when_unchanged() {
13044        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13045
13046        prog.check_screen_transition();
13047        prog.dirty = false;
13048
13049        prog.check_screen_transition();
13050
13051        assert!(!prog.dirty);
13052    }
13053
13054    #[test]
13055    fn check_screen_transition_noop_without_strategy() {
13056        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13057
13058        // Remove the tick strategy.
13059        prog.tick_strategy = None;
13060
13061        prog.check_screen_transition();
13062        assert!(prog.last_active_screen_for_strategy.is_none());
13063    }
13064
13065    #[test]
13066    fn check_screen_transition_multiple_switches_notifies_strategy() {
13067        let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
13068
13069        prog.check_screen_transition(); // records "A"
13070
13071        // A → B
13072        prog.model.active = "B".to_owned();
13073        prog.check_screen_transition();
13074        assert_eq!(prog.model.ticked_screens.len(), 1);
13075        assert_eq!(prog.model.ticked_screens[0].0, "B");
13076
13077        // B → C
13078        prog.model.active = "C".to_owned();
13079        prog.check_screen_transition();
13080        assert_eq!(prog.model.ticked_screens.len(), 2);
13081        assert_eq!(prog.model.ticked_screens[1].0, "C");
13082
13083        // C → A
13084        prog.model.active = "A".to_owned();
13085        prog.check_screen_transition();
13086        assert_eq!(prog.model.ticked_screens.len(), 3);
13087        assert_eq!(prog.model.ticked_screens[2].0, "A");
13088
13089        // A.2: strategy should have all three transitions.
13090        let transitions = log.lock().unwrap();
13091        assert_eq!(transitions.len(), 3);
13092        assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
13093        assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
13094        assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
13095    }
13096
13097    #[test]
13098    fn check_screen_transition_uses_current_tick_count() {
13099        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13100        prog.tick_count = 42;
13101
13102        prog.check_screen_transition(); // records "A"
13103
13104        prog.model.active = "B".to_owned();
13105        prog.check_screen_transition();
13106
13107        // Force-tick should use the current tick_count.
13108        assert_eq!(prog.model.ticked_screens[0].1, 42);
13109    }
13110
13111    #[test]
13112    fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
13113        use crate::subscription::{StopSignal, SubId, Subscription};
13114
13115        struct TransitionSubModel {
13116            active: String,
13117            screens: Vec<String>,
13118            subscribed: bool,
13119        }
13120
13121        #[derive(Debug)]
13122        #[allow(dead_code)]
13123        enum TransitionSubMsg {
13124            Event(Event),
13125        }
13126
13127        impl From<Event> for TransitionSubMsg {
13128            fn from(event: Event) -> Self {
13129                Self::Event(event)
13130            }
13131        }
13132
13133        impl Model for TransitionSubModel {
13134            type Message = TransitionSubMsg;
13135
13136            fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
13137                Cmd::none()
13138            }
13139
13140            fn view(&self, _frame: &mut Frame) {}
13141
13142            fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
13143                if self.subscribed {
13144                    vec![Box::new(TransitionSubscription)]
13145                } else {
13146                    vec![]
13147                }
13148            }
13149
13150            fn as_screen_tick_dispatch(
13151                &mut self,
13152            ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
13153                Some(self)
13154            }
13155        }
13156
13157        impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
13158            fn screen_ids(&self) -> Vec<String> {
13159                self.screens.clone()
13160            }
13161
13162            fn active_screen_id(&self) -> String {
13163                self.active.clone()
13164            }
13165
13166            fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
13167                if screen_id == self.active {
13168                    self.subscribed = true;
13169                }
13170            }
13171        }
13172
13173        struct TransitionSubscription;
13174
13175        impl Subscription<TransitionSubMsg> for TransitionSubscription {
13176            fn id(&self) -> SubId {
13177                1
13178            }
13179
13180            fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
13181        }
13182
13183        struct TransitionStrategy;
13184
13185        impl crate::tick_strategy::TickStrategy for TransitionStrategy {
13186            fn should_tick(
13187                &mut self,
13188                _screen_id: &str,
13189                _tick_count: u64,
13190                _active_screen: &str,
13191            ) -> crate::tick_strategy::TickDecision {
13192                crate::tick_strategy::TickDecision::Skip
13193            }
13194
13195            fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
13196
13197            fn name(&self) -> &str {
13198                "TransitionStrategy"
13199            }
13200
13201            fn debug_stats(&self) -> Vec<(String, String)> {
13202                vec![]
13203            }
13204        }
13205
13206        let model = TransitionSubModel {
13207            active: "A".to_owned(),
13208            screens: vec!["A".to_owned(), "B".to_owned()],
13209            subscribed: false,
13210        };
13211        let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13212        let writer = TerminalWriter::new(
13213            Vec::<u8>::new(),
13214            ScreenMode::AltScreen,
13215            UiAnchor::Bottom,
13216            TerminalCapabilities::dumb(),
13217        );
13218        let config = ProgramConfig::default().with_forced_size(80, 24);
13219
13220        let mut program =
13221            Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13222                .expect("program creation");
13223        program.tick_strategy = Some(Box::new(TransitionStrategy));
13224
13225        program.check_screen_transition();
13226        assert_eq!(program.subscriptions.active_count(), 0);
13227
13228        program.model.active = "B".to_owned();
13229        program.check_screen_transition();
13230
13231        assert!(program.model().subscribed);
13232        assert_eq!(program.subscriptions.active_count(), 1);
13233    }
13234
13235    #[test]
13236    fn tick_strategy_stats_returns_empty_without_strategy() {
13237        let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13238        prog.tick_strategy = None;
13239        assert!(prog.tick_strategy_stats().is_empty());
13240    }
13241
13242    #[test]
13243    fn tick_strategy_stats_returns_strategy_fields() {
13244        let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
13245        let stats = prog.tick_strategy_stats();
13246        // RecordingStrategy returns [("strategy", "Recording")]
13247        assert!(
13248            !stats.is_empty(),
13249            "stats should not be empty when strategy is configured"
13250        );
13251    }
13252}