1#![forbid(unsafe_code)]
2
3use 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::{
91 BudgetControllerConfig, BudgetDecision, BudgetDecisionReason, DegradationLevel,
92 FrameBudgetConfig, RenderBudget,
93};
94use ftui_render::buffer::Buffer;
95use ftui_render::diff_strategy::DiffStrategy;
96use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
97use ftui_render::frame_guardrails::{FrameGuardrails, GuardrailsConfig};
98use ftui_render::sanitize::sanitize;
99use std::any::Any;
100use std::collections::HashMap;
101use std::io::{self, Stdout, Write};
102use std::panic::{self, AssertUnwindSafe};
103use std::sync::Arc;
104
105#[inline]
108fn check_termination_signal() -> Option<i32> {
109 ftui_core::shutdown_signal::pending_termination_signal()
110}
111
112#[inline]
114fn clear_termination_signal() {
115 ftui_core::shutdown_signal::clear_pending_termination_signal();
116}
117use std::sync::mpsc;
118use std::thread::{self, JoinHandle};
119use tracing::{debug, debug_span, info, info_span, trace};
120use web_time::{Duration, Instant};
121
122pub trait Model: Sized {
127 type Message: From<Event> + Send + 'static;
132
133 fn init(&mut self) -> Cmd<Self::Message> {
138 Cmd::none()
139 }
140
141 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
146
147 fn view(&self, frame: &mut Frame);
151
152 fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
162 vec![]
163 }
164
165 fn as_screen_tick_dispatch(
174 &mut self,
175 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
176 None
177 }
178
179 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
189 Cmd::none()
190 }
191
192 fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
202 Cmd::none()
203 }
204}
205
206const DEFAULT_TASK_WEIGHT: f64 = 1.0;
208
209const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
211
212#[derive(Debug, Clone)]
214pub struct TaskSpec {
215 pub weight: f64,
217 pub estimate_ms: f64,
219 pub name: Option<String>,
221}
222
223impl Default for TaskSpec {
224 fn default() -> Self {
225 Self {
226 weight: DEFAULT_TASK_WEIGHT,
227 estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
228 name: None,
229 }
230 }
231}
232
233impl TaskSpec {
234 #[must_use]
236 pub fn new(weight: f64, estimate_ms: f64) -> Self {
237 Self {
238 weight,
239 estimate_ms,
240 name: None,
241 }
242 }
243
244 #[must_use]
246 pub fn with_name(mut self, name: impl Into<String>) -> Self {
247 self.name = Some(name.into());
248 self
249 }
250}
251
252#[derive(Debug, Clone, Copy)]
254pub struct FrameTiming {
255 pub frame_idx: u64,
256 pub update_us: u64,
257 pub render_us: u64,
258 pub diff_us: u64,
259 pub present_us: u64,
260 pub total_us: u64,
261}
262
263#[derive(Debug)]
264struct SignalTerminationError {
265 signal: i32,
266}
267
268impl std::fmt::Display for SignalTerminationError {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 write!(f, "terminated by signal {}", self.signal)
271 }
272}
273
274impl std::error::Error for SignalTerminationError {}
275
276fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
277 err.get_ref()
278 .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
279 .map(|inner| inner.signal)
280}
281
282pub trait FrameTimingSink: Send + Sync {
284 fn record_frame(&self, timing: &FrameTiming);
285}
286
287#[derive(Clone)]
289pub struct FrameTimingConfig {
290 pub sink: Arc<dyn FrameTimingSink>,
291}
292
293impl FrameTimingConfig {
294 #[must_use]
295 pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
296 Self { sink }
297 }
298}
299
300impl std::fmt::Debug for FrameTimingConfig {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 f.debug_struct("FrameTimingConfig")
303 .field("sink", &"<dyn FrameTimingSink>")
304 .finish()
305 }
306}
307
308#[derive(Default)]
313pub enum Cmd<M> {
314 #[default]
316 None,
317 Quit,
319 Batch(Vec<Cmd<M>>),
321 Sequence(Vec<Cmd<M>>),
323 Msg(M),
325 Tick(Duration),
327 Log(String),
332 Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
339 SaveState,
344 RestoreState,
350 SetMouseCapture(bool),
355 SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
361}
362
363impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365 match self {
366 Self::None => write!(f, "None"),
367 Self::Quit => write!(f, "Quit"),
368 Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
369 Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
370 Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
371 Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
372 Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
373 Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
374 Self::SaveState => write!(f, "SaveState"),
375 Self::RestoreState => write!(f, "RestoreState"),
376 Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
377 Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
378 }
379 }
380}
381
382impl<M> Cmd<M> {
383 #[inline]
385 pub fn none() -> Self {
386 Self::None
387 }
388
389 #[inline]
391 pub fn quit() -> Self {
392 Self::Quit
393 }
394
395 #[inline]
397 pub fn msg(m: M) -> Self {
398 Self::Msg(m)
399 }
400
401 #[inline]
406 pub fn log(msg: impl Into<String>) -> Self {
407 Self::Log(msg.into())
408 }
409
410 pub fn batch(cmds: Vec<Self>) -> Self {
412 if cmds.is_empty() {
413 Self::None
414 } else if cmds.len() == 1 {
415 cmds.into_iter().next().unwrap_or(Self::None)
416 } else {
417 Self::Batch(cmds)
418 }
419 }
420
421 pub fn sequence(cmds: Vec<Self>) -> Self {
423 if cmds.is_empty() {
424 Self::None
425 } else if cmds.len() == 1 {
426 cmds.into_iter().next().unwrap_or(Self::None)
427 } else {
428 Self::Sequence(cmds)
429 }
430 }
431
432 #[inline]
434 pub fn type_name(&self) -> &'static str {
435 match self {
436 Self::None => "None",
437 Self::Quit => "Quit",
438 Self::Batch(_) => "Batch",
439 Self::Sequence(_) => "Sequence",
440 Self::Msg(_) => "Msg",
441 Self::Tick(_) => "Tick",
442 Self::Log(_) => "Log",
443 Self::Task(..) => "Task",
444 Self::SaveState => "SaveState",
445 Self::RestoreState => "RestoreState",
446 Self::SetMouseCapture(_) => "SetMouseCapture",
447 Self::SetTickStrategy(_) => "SetTickStrategy",
448 }
449 }
450
451 #[inline]
453 pub fn tick(duration: Duration) -> Self {
454 Self::Tick(duration)
455 }
456
457 pub fn task<F>(f: F) -> Self
463 where
464 F: FnOnce() -> M + Send + 'static,
465 {
466 Self::Task(TaskSpec::default(), Box::new(f))
467 }
468
469 pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
471 where
472 F: FnOnce() -> M + Send + 'static,
473 {
474 Self::Task(spec, Box::new(f))
475 }
476
477 pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
479 where
480 F: FnOnce() -> M + Send + 'static,
481 {
482 Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
483 }
484
485 pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
487 where
488 F: FnOnce() -> M + Send + 'static,
489 {
490 Self::Task(TaskSpec::default().with_name(name), Box::new(f))
491 }
492
493 pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
498 Self::SetTickStrategy(Box::new(strategy))
499 }
500
501 #[inline]
506 pub fn save_state() -> Self {
507 Self::SaveState
508 }
509
510 #[inline]
515 pub fn restore_state() -> Self {
516 Self::RestoreState
517 }
518
519 #[inline]
524 pub fn set_mouse_capture(enabled: bool) -> Self {
525 Self::SetMouseCapture(enabled)
526 }
527
528 pub fn count(&self) -> usize {
532 match self {
533 Self::None => 0,
534 Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
535 _ => 1,
536 }
537 }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum ResizeBehavior {
543 Immediate,
545 Throttled,
547}
548
549impl ResizeBehavior {
550 const fn uses_coalescer(self) -> bool {
551 matches!(self, ResizeBehavior::Throttled)
552 }
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum MouseCapturePolicy {
562 #[default]
564 Auto,
565 On,
567 Off,
569}
570
571impl MouseCapturePolicy {
572 #[must_use]
574 pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
575 match self {
576 Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
577 Self::On => true,
578 Self::Off => false,
579 }
580 }
581}
582
583const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
584const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub struct PaneTerminalSplitterHandle {
589 pub target: PaneResizeTarget,
591 pub rect: Rect,
593 pub boundary: i32,
595}
596
597#[must_use]
601pub fn pane_terminal_splitter_handles(
602 tree: &PaneTree,
603 layout: &PaneLayout,
604 hit_thickness: u16,
605) -> Vec<PaneTerminalSplitterHandle> {
606 let thickness = if hit_thickness == 0 {
607 PANE_TERMINAL_DEFAULT_HIT_THICKNESS
608 } else {
609 hit_thickness
610 };
611 let mut handles = Vec::new();
612 for node in tree.nodes() {
613 let PaneNodeKind::Split(split) = &node.kind else {
614 continue;
615 };
616 let Some(split_rect) = layout.rect(node.id) else {
617 continue;
618 };
619 if split_rect.is_empty() {
620 continue;
621 }
622 let Some(first_rect) = layout.rect(split.first) else {
623 continue;
624 };
625 let Some(second_rect) = layout.rect(split.second) else {
626 continue;
627 };
628
629 let boundary_u16 = match split.axis {
630 SplitAxis::Horizontal => {
631 if second_rect.x == split_rect.x {
633 first_rect.right()
634 } else {
635 second_rect.x
636 }
637 }
638 SplitAxis::Vertical => {
639 if second_rect.y == split_rect.y {
641 first_rect.bottom()
642 } else {
643 second_rect.y
644 }
645 }
646 };
647 let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
648 continue;
649 };
650 handles.push(PaneTerminalSplitterHandle {
651 target: PaneResizeTarget {
652 split_id: node.id,
653 axis: split.axis,
654 },
655 rect,
656 boundary: i32::from(boundary_u16),
657 });
658 }
659 handles
660}
661
662#[must_use]
669pub fn pane_terminal_resolve_splitter_target(
670 handles: &[PaneTerminalSplitterHandle],
671 x: u16,
672 y: u16,
673) -> Option<PaneResizeTarget> {
674 let px = i32::from(x);
675 let py = i32::from(y);
676 let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
677
678 for handle in handles {
679 if !rect_contains_cell(handle.rect, x, y) {
680 continue;
681 }
682 let distance = match handle.target.axis {
683 SplitAxis::Horizontal => px.abs_diff(handle.boundary),
684 SplitAxis::Vertical => py.abs_diff(handle.boundary),
685 };
686 let axis_rank = match handle.target.axis {
687 SplitAxis::Horizontal => 0,
688 SplitAxis::Vertical => 1,
689 };
690 let key = (distance, handle.target.split_id.get(), axis_rank);
691 if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
692 best = Some((key, handle.target));
693 }
694 }
695
696 best.map(|(_, target)| target)
697}
698
699pub fn register_pane_terminal_splitter_hits(
704 frame: &mut Frame,
705 handles: &[PaneTerminalSplitterHandle],
706 hit_id_base: u32,
707) -> usize {
708 let mut registered = 0usize;
709 for (idx, handle) in handles.iter().enumerate() {
710 let Ok(offset) = u32::try_from(idx) else {
711 break;
712 };
713 let hit_id = HitId::new(hit_id_base.saturating_add(offset));
714 if frame.register_hit(
715 handle.rect,
716 hit_id,
717 HitRegion::Handle,
718 encode_pane_resize_target(handle.target),
719 ) {
720 registered = registered.saturating_add(1);
721 }
722 }
723 registered
724}
725
726#[must_use]
728pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
729 let (_, region, data) = hit;
730 if region != HitRegion::Handle {
731 return None;
732 }
733 decode_pane_resize_target(data)
734}
735
736fn splitter_hit_rect(
737 axis: SplitAxis,
738 split_rect: Rect,
739 boundary: u16,
740 thickness: u16,
741) -> Option<Rect> {
742 let half = thickness.saturating_sub(1) / 2;
743 match axis {
744 SplitAxis::Horizontal => {
745 let start = boundary.saturating_sub(half).max(split_rect.x);
746 let end = boundary
747 .saturating_add(thickness.saturating_sub(half))
748 .min(split_rect.right());
749 let width = end.saturating_sub(start);
750 (width > 0 && split_rect.height > 0).then_some(Rect::new(
751 start,
752 split_rect.y,
753 width,
754 split_rect.height,
755 ))
756 }
757 SplitAxis::Vertical => {
758 let start = boundary.saturating_sub(half).max(split_rect.y);
759 let end = boundary
760 .saturating_add(thickness.saturating_sub(half))
761 .min(split_rect.bottom());
762 let height = end.saturating_sub(start);
763 (height > 0 && split_rect.width > 0).then_some(Rect::new(
764 split_rect.x,
765 start,
766 split_rect.width,
767 height,
768 ))
769 }
770 }
771}
772
773fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
774 x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
775}
776
777fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
778 let axis = match target.axis {
779 SplitAxis::Horizontal => 0_u64,
780 SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
781 };
782 (target.split_id.get() << 1) | axis
783}
784
785fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
786 let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
787 SplitAxis::Horizontal
788 } else {
789 SplitAxis::Vertical
790 };
791 let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
792 Some(PaneResizeTarget { split_id, axis })
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
801pub enum PaneMuxEnvironment {
802 None,
804 Tmux,
806 Screen,
808 Zellij,
810 WeztermMux,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub struct PaneCapabilityMatrix {
822 pub mux: PaneMuxEnvironment,
824
825 pub mouse_sgr: bool,
829 pub mouse_drag_reliable: bool,
832 pub mouse_button_discrimination: bool,
835
836 pub focus_events: bool,
839 pub bracketed_paste: bool,
841
842 pub unicode_box_drawing: bool,
845 pub true_color: bool,
847
848 pub degraded: bool,
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct PaneCapabilityLimitation {
856 pub id: &'static str,
858 pub description: &'static str,
860 pub fallback: &'static str,
862}
863
864impl PaneCapabilityMatrix {
865 #[must_use]
870 pub fn from_capabilities(
871 caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
872 ) -> Self {
873 let mux = if caps.in_tmux {
874 PaneMuxEnvironment::Tmux
875 } else if caps.in_screen {
876 PaneMuxEnvironment::Screen
877 } else if caps.in_zellij {
878 PaneMuxEnvironment::Zellij
879 } else if caps.in_wezterm_mux {
880 PaneMuxEnvironment::WeztermMux
881 } else {
882 PaneMuxEnvironment::None
883 };
884
885 let mouse_sgr = caps.mouse_sgr;
886
887 let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
890
891 let mouse_button_discrimination = mouse_sgr;
894
895 let focus_events = caps.focus_events && !caps.in_any_mux();
897
898 let bracketed_paste = caps.bracketed_paste;
899 let unicode_box_drawing = caps.unicode_box_drawing;
900 let true_color = caps.true_color;
901
902 let degraded =
903 !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
904
905 Self {
906 mux,
907 mouse_sgr,
908 mouse_drag_reliable,
909 mouse_button_discrimination,
910 focus_events,
911 bracketed_paste,
912 unicode_box_drawing,
913 true_color,
914 degraded,
915 }
916 }
917
918 #[must_use]
924 pub const fn drag_enabled(&self) -> bool {
925 self.mouse_drag_reliable
926 }
927
928 #[must_use]
934 pub const fn focus_cancel_effective(&self) -> bool {
935 self.focus_events
936 }
937
938 #[must_use]
940 pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
941 let mut out = Vec::new();
942
943 if !self.mouse_sgr {
944 out.push(PaneCapabilityLimitation {
945 id: "no_sgr_mouse",
946 description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
947 fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
948 });
949 }
950
951 if !self.mouse_drag_reliable {
952 out.push(PaneCapabilityLimitation {
953 id: "mouse_drag_unreliable",
954 description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
955 fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
956 });
957 }
958
959 if !self.mouse_button_discrimination {
960 out.push(PaneCapabilityLimitation {
961 id: "no_button_discrimination",
962 description: "Mouse release events do not identify which button was released",
963 fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
964 });
965 }
966
967 if !self.focus_events {
968 out.push(PaneCapabilityLimitation {
969 id: "no_focus_events",
970 description: "Terminal does not deliver focus-in/focus-out events",
971 fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
972 });
973 }
974
975 out
976 }
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
984pub struct PaneTerminalAdapterConfig {
985 pub drag_threshold: u16,
987 pub update_hysteresis: u16,
989 pub activation_button: PanePointerButton,
991 pub drag_update_coalesce_distance: u16,
994 pub cancel_on_focus_lost: bool,
996 pub cancel_on_resize: bool,
998}
999
1000impl Default for PaneTerminalAdapterConfig {
1001 fn default() -> Self {
1002 Self {
1003 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1004 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1005 activation_button: PanePointerButton::Primary,
1006 drag_update_coalesce_distance: 2,
1007 cancel_on_focus_lost: true,
1008 cancel_on_resize: true,
1009 }
1010 }
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014struct PaneTerminalActivePointer {
1015 pointer_id: u32,
1016 target: PaneResizeTarget,
1017 button: PanePointerButton,
1018 last_position: PanePointerPosition,
1019 cumulative_delta_x: i32,
1020 cumulative_delta_y: i32,
1021 direction_changes: u16,
1022 sample_count: u32,
1023 previous_step_delta_x: i32,
1024 previous_step_delta_y: i32,
1025 start_time: Instant,
1026}
1027
1028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum PaneTerminalLifecyclePhase {
1031 MouseDown,
1032 MouseDrag,
1033 MouseMove,
1034 MouseUp,
1035 MouseScroll,
1036 KeyResize,
1037 KeyCancel,
1038 FocusLoss,
1039 ResizeInterrupt,
1040 Other,
1041}
1042
1043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub enum PaneTerminalIgnoredReason {
1046 MissingTarget,
1047 NoActivePointer,
1048 PointerButtonMismatch,
1049 ActivationButtonRequired,
1050 WindowNotFocused,
1051 UnsupportedKey,
1052 FocusGainNoop,
1053 ResizeNoop,
1054 DragCoalesced,
1055 NonSemanticEvent,
1056 MachineRejectedEvent,
1057}
1058
1059#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1061pub enum PaneTerminalLogOutcome {
1062 SemanticForwarded,
1063 SemanticForwardedAfterRecovery,
1064 Ignored(PaneTerminalIgnoredReason),
1065}
1066
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069pub struct PaneTerminalLogEntry {
1070 pub phase: PaneTerminalLifecyclePhase,
1071 pub sequence: Option<u64>,
1072 pub pointer_id: Option<u32>,
1073 pub target: Option<PaneResizeTarget>,
1074 pub recovery_cancel_sequence: Option<u64>,
1075 pub outcome: PaneTerminalLogOutcome,
1076}
1077
1078#[derive(Debug, Clone, PartialEq)]
1084pub struct PaneTerminalDispatch {
1085 pub primary_event: Option<PaneSemanticInputEvent>,
1086 pub primary_transition: Option<PaneDragResizeTransition>,
1087 pub motion: Option<PaneMotionVector>,
1088 pub inertial_throw: Option<PaneInertialThrow>,
1089 pub projected_position: Option<PanePointerPosition>,
1090 pub recovery_event: Option<PaneSemanticInputEvent>,
1091 pub recovery_transition: Option<PaneDragResizeTransition>,
1092 pub log: PaneTerminalLogEntry,
1093}
1094
1095impl PaneTerminalDispatch {
1096 fn ignored(
1097 phase: PaneTerminalLifecyclePhase,
1098 reason: PaneTerminalIgnoredReason,
1099 pointer_id: Option<u32>,
1100 target: Option<PaneResizeTarget>,
1101 ) -> Self {
1102 Self {
1103 primary_event: None,
1104 primary_transition: None,
1105 motion: None,
1106 inertial_throw: None,
1107 projected_position: None,
1108 recovery_event: None,
1109 recovery_transition: None,
1110 log: PaneTerminalLogEntry {
1111 phase,
1112 sequence: None,
1113 pointer_id,
1114 target,
1115 recovery_cancel_sequence: None,
1116 outcome: PaneTerminalLogOutcome::Ignored(reason),
1117 },
1118 }
1119 }
1120
1121 fn forwarded(
1122 phase: PaneTerminalLifecyclePhase,
1123 pointer_id: Option<u32>,
1124 target: Option<PaneResizeTarget>,
1125 event: PaneSemanticInputEvent,
1126 transition: PaneDragResizeTransition,
1127 ) -> Self {
1128 let sequence = Some(event.sequence);
1129 Self {
1130 primary_event: Some(event),
1131 primary_transition: Some(transition),
1132 motion: None,
1133 inertial_throw: None,
1134 projected_position: None,
1135 recovery_event: None,
1136 recovery_transition: None,
1137 log: PaneTerminalLogEntry {
1138 phase,
1139 sequence,
1140 pointer_id,
1141 target,
1142 recovery_cancel_sequence: None,
1143 outcome: PaneTerminalLogOutcome::SemanticForwarded,
1144 },
1145 }
1146 }
1147
1148 #[must_use]
1150 pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1151 self.motion.map(PanePressureSnapProfile::from_motion)
1152 }
1153}
1154
1155#[derive(Debug, Clone)]
1158pub struct PaneTerminalAdapter {
1159 machine: PaneDragResizeMachine,
1160 config: PaneTerminalAdapterConfig,
1161 active: Option<PaneTerminalActivePointer>,
1162 window_focused: bool,
1163 next_sequence: u64,
1164}
1165
1166impl PaneTerminalAdapter {
1167 pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1169 let config = PaneTerminalAdapterConfig {
1170 drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1171 ..config
1172 };
1173 let machine = PaneDragResizeMachine::new_with_hysteresis(
1174 config.drag_threshold,
1175 config.update_hysteresis,
1176 )?;
1177 Ok(Self {
1178 machine,
1179 config,
1180 active: None,
1181 window_focused: true,
1182 next_sequence: 1,
1183 })
1184 }
1185
1186 #[must_use]
1188 pub const fn config(&self) -> PaneTerminalAdapterConfig {
1189 self.config
1190 }
1191
1192 #[must_use]
1194 pub fn active_pointer_id(&self) -> Option<u32> {
1195 self.active.map(|active| active.pointer_id)
1196 }
1197
1198 #[must_use]
1200 pub const fn window_focused(&self) -> bool {
1201 self.window_focused
1202 }
1203
1204 #[must_use]
1206 pub const fn machine_state(&self) -> PaneDragResizeState {
1207 self.machine.state()
1208 }
1209
1210 pub fn translate(
1215 &mut self,
1216 event: &Event,
1217 target_hint: Option<PaneResizeTarget>,
1218 ) -> PaneTerminalDispatch {
1219 match event {
1220 Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1221 Event::Key(key) => self.translate_key(*key, target_hint),
1222 Event::Focus(focused) => self.translate_focus(*focused),
1223 Event::Resize { .. } => self.translate_resize(),
1224 _ => PaneTerminalDispatch::ignored(
1225 PaneTerminalLifecyclePhase::Other,
1226 PaneTerminalIgnoredReason::NonSemanticEvent,
1227 None,
1228 target_hint,
1229 ),
1230 }
1231 }
1232
1233 pub fn translate_with_handles(
1239 &mut self,
1240 event: &Event,
1241 handles: &[PaneTerminalSplitterHandle],
1242 ) -> PaneTerminalDispatch {
1243 let active_target = self.active.map(|active| active.target);
1244 let target_hint = match event {
1245 Event::Mouse(mouse) => {
1246 let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1247 match mouse.kind {
1248 MouseEventKind::Down(_)
1249 | MouseEventKind::ScrollUp
1250 | MouseEventKind::ScrollDown
1251 | MouseEventKind::ScrollLeft
1252 | MouseEventKind::ScrollRight => resolved,
1253 MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1254 resolved.or(active_target)
1255 }
1256 }
1257 }
1258 Event::Key(_) => active_target,
1259 _ => None,
1260 };
1261 self.translate(event, target_hint)
1262 }
1263
1264 fn translate_mouse(
1265 &mut self,
1266 mouse: MouseEvent,
1267 target_hint: Option<PaneResizeTarget>,
1268 ) -> PaneTerminalDispatch {
1269 let position = mouse_position(mouse);
1270 let modifiers = pane_modifiers(mouse.modifiers);
1271 match mouse.kind {
1272 MouseEventKind::Down(button) => {
1273 let pane_button = pane_button(button);
1274 if pane_button != self.config.activation_button {
1275 return PaneTerminalDispatch::ignored(
1276 PaneTerminalLifecyclePhase::MouseDown,
1277 PaneTerminalIgnoredReason::ActivationButtonRequired,
1278 Some(pointer_id_for_button(pane_button)),
1279 target_hint,
1280 );
1281 }
1282 let Some(target) = target_hint else {
1283 return PaneTerminalDispatch::ignored(
1284 PaneTerminalLifecyclePhase::MouseDown,
1285 PaneTerminalIgnoredReason::MissingTarget,
1286 Some(pointer_id_for_button(pane_button)),
1287 None,
1288 );
1289 };
1290
1291 let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1292 let pointer_id = pointer_id_for_button(pane_button);
1293 let kind = PaneSemanticInputEventKind::PointerDown {
1294 target,
1295 pointer_id,
1296 button: pane_button,
1297 position,
1298 };
1299 let mut dispatch = self.forward_semantic(
1300 PaneTerminalLifecyclePhase::MouseDown,
1301 Some(pointer_id),
1302 Some(target),
1303 kind,
1304 modifiers,
1305 );
1306 if dispatch.primary_transition.is_some() {
1307 self.active = Some(PaneTerminalActivePointer {
1308 pointer_id,
1309 target,
1310 button: pane_button,
1311 last_position: position,
1312 cumulative_delta_x: 0,
1313 cumulative_delta_y: 0,
1314 direction_changes: 0,
1315 sample_count: 0,
1316 previous_step_delta_x: 0,
1317 previous_step_delta_y: 0,
1318 start_time: Instant::now(),
1319 });
1320 }
1321 if let Some((cancel_event, cancel_transition)) = recovery {
1322 dispatch.recovery_event = Some(cancel_event);
1323 dispatch.recovery_transition = Some(cancel_transition);
1324 dispatch.log.recovery_cancel_sequence =
1325 dispatch.recovery_event.as_ref().map(|event| event.sequence);
1326 if matches!(
1327 dispatch.log.outcome,
1328 PaneTerminalLogOutcome::SemanticForwarded
1329 ) {
1330 dispatch.log.outcome =
1331 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1332 }
1333 }
1334 dispatch
1335 }
1336 MouseEventKind::Drag(button) => {
1337 let pane_button = pane_button(button);
1338 let Some(mut active) = self.active else {
1339 return PaneTerminalDispatch::ignored(
1340 PaneTerminalLifecyclePhase::MouseDrag,
1341 PaneTerminalIgnoredReason::NoActivePointer,
1342 Some(pointer_id_for_button(pane_button)),
1343 target_hint,
1344 );
1345 };
1346 if active.button != pane_button {
1347 return PaneTerminalDispatch::ignored(
1348 PaneTerminalLifecyclePhase::MouseDrag,
1349 PaneTerminalIgnoredReason::PointerButtonMismatch,
1350 Some(pointer_id_for_button(pane_button)),
1351 Some(active.target),
1352 );
1353 }
1354 let delta_x = position.x.saturating_sub(active.last_position.x);
1355 let delta_y = position.y.saturating_sub(active.last_position.y);
1356 if self.should_coalesce_drag(delta_x, delta_y) {
1357 return PaneTerminalDispatch::ignored(
1358 PaneTerminalLifecyclePhase::MouseDrag,
1359 PaneTerminalIgnoredReason::DragCoalesced,
1360 Some(active.pointer_id),
1361 Some(active.target),
1362 );
1363 }
1364 if active.sample_count > 0 {
1365 let flipped_x = delta_x.signum() != 0
1366 && active.previous_step_delta_x.signum() != 0
1367 && delta_x.signum() != active.previous_step_delta_x.signum();
1368 let flipped_y = delta_y.signum() != 0
1369 && active.previous_step_delta_y.signum() != 0
1370 && delta_y.signum() != active.previous_step_delta_y.signum();
1371 if flipped_x || flipped_y {
1372 active.direction_changes = active.direction_changes.saturating_add(1);
1373 }
1374 }
1375 active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1376 active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1377 active.sample_count = active.sample_count.saturating_add(1);
1378 active.previous_step_delta_x = delta_x;
1379 active.previous_step_delta_y = delta_y;
1380 let kind = PaneSemanticInputEventKind::PointerMove {
1381 target: active.target,
1382 pointer_id: active.pointer_id,
1383 position,
1384 delta_x,
1385 delta_y,
1386 };
1387 let mut dispatch = self.forward_semantic(
1388 PaneTerminalLifecyclePhase::MouseDrag,
1389 Some(active.pointer_id),
1390 Some(active.target),
1391 kind,
1392 modifiers,
1393 );
1394 if dispatch.primary_transition.is_some() {
1395 active.last_position = position;
1396 self.active = Some(active);
1397 let duration = active.start_time.elapsed().as_millis() as u32;
1398 dispatch.motion = Some(PaneMotionVector::from_delta(
1399 active.cumulative_delta_x,
1400 active.cumulative_delta_y,
1401 duration,
1402 active.direction_changes,
1403 ));
1404 }
1405 dispatch
1406 }
1407 MouseEventKind::Moved => {
1408 let Some(mut active) = self.active else {
1409 return PaneTerminalDispatch::ignored(
1410 PaneTerminalLifecyclePhase::MouseMove,
1411 PaneTerminalIgnoredReason::NoActivePointer,
1412 None,
1413 target_hint,
1414 );
1415 };
1416 let delta_x = position.x.saturating_sub(active.last_position.x);
1417 let delta_y = position.y.saturating_sub(active.last_position.y);
1418 if self.should_coalesce_drag(delta_x, delta_y) {
1419 return PaneTerminalDispatch::ignored(
1420 PaneTerminalLifecyclePhase::MouseMove,
1421 PaneTerminalIgnoredReason::DragCoalesced,
1422 Some(active.pointer_id),
1423 Some(active.target),
1424 );
1425 }
1426 if active.sample_count > 0 {
1427 let flipped_x = delta_x.signum() != 0
1428 && active.previous_step_delta_x.signum() != 0
1429 && delta_x.signum() != active.previous_step_delta_x.signum();
1430 let flipped_y = delta_y.signum() != 0
1431 && active.previous_step_delta_y.signum() != 0
1432 && delta_y.signum() != active.previous_step_delta_y.signum();
1433 if flipped_x || flipped_y {
1434 active.direction_changes = active.direction_changes.saturating_add(1);
1435 }
1436 }
1437 active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1438 active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1439 active.sample_count = active.sample_count.saturating_add(1);
1440 active.previous_step_delta_x = delta_x;
1441 active.previous_step_delta_y = delta_y;
1442 let kind = PaneSemanticInputEventKind::PointerMove {
1443 target: active.target,
1444 pointer_id: active.pointer_id,
1445 position,
1446 delta_x,
1447 delta_y,
1448 };
1449 let mut dispatch = self.forward_semantic(
1450 PaneTerminalLifecyclePhase::MouseMove,
1451 Some(active.pointer_id),
1452 Some(active.target),
1453 kind,
1454 modifiers,
1455 );
1456 if dispatch.primary_transition.is_some() {
1457 active.last_position = position;
1458 self.active = Some(active);
1459 let duration = active.start_time.elapsed().as_millis() as u32;
1460 dispatch.motion = Some(PaneMotionVector::from_delta(
1461 active.cumulative_delta_x,
1462 active.cumulative_delta_y,
1463 duration,
1464 active.direction_changes,
1465 ));
1466 }
1467 dispatch
1468 }
1469 MouseEventKind::Up(button) => {
1470 let pane_button = pane_button(button);
1471 let Some(active) = self.active else {
1472 return PaneTerminalDispatch::ignored(
1473 PaneTerminalLifecyclePhase::MouseUp,
1474 PaneTerminalIgnoredReason::NoActivePointer,
1475 Some(pointer_id_for_button(pane_button)),
1476 target_hint,
1477 );
1478 };
1479 if active.button != pane_button {
1480 return PaneTerminalDispatch::ignored(
1481 PaneTerminalLifecyclePhase::MouseUp,
1482 PaneTerminalIgnoredReason::PointerButtonMismatch,
1483 Some(pointer_id_for_button(pane_button)),
1484 Some(active.target),
1485 );
1486 }
1487 let kind = PaneSemanticInputEventKind::PointerUp {
1488 target: active.target,
1489 pointer_id: active.pointer_id,
1490 button: active.button,
1491 position,
1492 };
1493 let mut dispatch = self.forward_semantic(
1494 PaneTerminalLifecyclePhase::MouseUp,
1495 Some(active.pointer_id),
1496 Some(active.target),
1497 kind,
1498 modifiers,
1499 );
1500 if dispatch.primary_transition.is_some() {
1501 let duration = active.start_time.elapsed().as_millis() as u32;
1502 let motion = PaneMotionVector::from_delta(
1503 active.cumulative_delta_x,
1504 active.cumulative_delta_y,
1505 duration,
1506 active.direction_changes,
1507 );
1508 let inertial_throw = PaneInertialThrow::from_motion(motion);
1509 dispatch.motion = Some(motion);
1510 dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1511 dispatch.inertial_throw = Some(inertial_throw);
1512 self.active = None;
1513 }
1514 dispatch
1515 }
1516 MouseEventKind::ScrollUp
1517 | MouseEventKind::ScrollDown
1518 | MouseEventKind::ScrollLeft
1519 | MouseEventKind::ScrollRight => {
1520 let target = target_hint.or(self.active.map(|active| active.target));
1521 let Some(target) = target else {
1522 return PaneTerminalDispatch::ignored(
1523 PaneTerminalLifecyclePhase::MouseScroll,
1524 PaneTerminalIgnoredReason::MissingTarget,
1525 None,
1526 None,
1527 );
1528 };
1529 let lines = match mouse.kind {
1530 MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1531 MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1532 _ => unreachable!("handled by outer match"),
1533 };
1534 let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1535 self.forward_semantic(
1536 PaneTerminalLifecyclePhase::MouseScroll,
1537 None,
1538 Some(target),
1539 kind,
1540 modifiers,
1541 )
1542 }
1543 }
1544 }
1545
1546 fn translate_key(
1547 &mut self,
1548 key: KeyEvent,
1549 target_hint: Option<PaneResizeTarget>,
1550 ) -> PaneTerminalDispatch {
1551 if !self.window_focused {
1552 return PaneTerminalDispatch::ignored(
1553 PaneTerminalLifecyclePhase::KeyResize,
1554 PaneTerminalIgnoredReason::WindowNotFocused,
1555 self.active_pointer_id(),
1556 target_hint.or(self.active.map(|active| active.target)),
1557 );
1558 }
1559 if key.kind == KeyEventKind::Release {
1560 return PaneTerminalDispatch::ignored(
1561 PaneTerminalLifecyclePhase::Other,
1562 PaneTerminalIgnoredReason::UnsupportedKey,
1563 None,
1564 target_hint,
1565 );
1566 }
1567 if matches!(key.code, KeyCode::Escape) {
1568 return self.cancel_active_dispatch(
1569 PaneTerminalLifecyclePhase::KeyCancel,
1570 PaneCancelReason::EscapeKey,
1571 PaneTerminalIgnoredReason::NoActivePointer,
1572 );
1573 }
1574 let target = target_hint.or(self.active.map(|active| active.target));
1575 let Some(target) = target else {
1576 return PaneTerminalDispatch::ignored(
1577 PaneTerminalLifecyclePhase::KeyResize,
1578 PaneTerminalIgnoredReason::MissingTarget,
1579 None,
1580 None,
1581 );
1582 };
1583 let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1584 return PaneTerminalDispatch::ignored(
1585 PaneTerminalLifecyclePhase::KeyResize,
1586 PaneTerminalIgnoredReason::UnsupportedKey,
1587 None,
1588 Some(target),
1589 );
1590 };
1591 let units = keyboard_resize_units(key.modifiers);
1592 let kind = PaneSemanticInputEventKind::KeyboardResize {
1593 target,
1594 direction,
1595 units,
1596 };
1597 self.forward_semantic(
1598 PaneTerminalLifecyclePhase::KeyResize,
1599 self.active_pointer_id(),
1600 Some(target),
1601 kind,
1602 pane_modifiers(key.modifiers),
1603 )
1604 }
1605
1606 fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1607 if focused {
1608 self.window_focused = true;
1609 return PaneTerminalDispatch::ignored(
1610 PaneTerminalLifecyclePhase::Other,
1611 PaneTerminalIgnoredReason::FocusGainNoop,
1612 self.active_pointer_id(),
1613 self.active.map(|active| active.target),
1614 );
1615 }
1616 self.window_focused = false;
1617 if !self.config.cancel_on_focus_lost {
1618 return PaneTerminalDispatch::ignored(
1619 PaneTerminalLifecyclePhase::FocusLoss,
1620 PaneTerminalIgnoredReason::ResizeNoop,
1621 self.active_pointer_id(),
1622 self.active.map(|active| active.target),
1623 );
1624 }
1625 self.cancel_active_dispatch(
1626 PaneTerminalLifecyclePhase::FocusLoss,
1627 PaneCancelReason::FocusLost,
1628 PaneTerminalIgnoredReason::NoActivePointer,
1629 )
1630 }
1631
1632 fn translate_resize(&mut self) -> PaneTerminalDispatch {
1633 if !self.config.cancel_on_resize {
1634 return PaneTerminalDispatch::ignored(
1635 PaneTerminalLifecyclePhase::ResizeInterrupt,
1636 PaneTerminalIgnoredReason::ResizeNoop,
1637 self.active_pointer_id(),
1638 self.active.map(|active| active.target),
1639 );
1640 }
1641 self.cancel_active_dispatch(
1642 PaneTerminalLifecyclePhase::ResizeInterrupt,
1643 PaneCancelReason::Programmatic,
1644 PaneTerminalIgnoredReason::ResizeNoop,
1645 )
1646 }
1647
1648 fn cancel_active_dispatch(
1649 &mut self,
1650 phase: PaneTerminalLifecyclePhase,
1651 reason: PaneCancelReason,
1652 no_active_reason: PaneTerminalIgnoredReason,
1653 ) -> PaneTerminalDispatch {
1654 let Some(active) = self.active else {
1655 return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1656 };
1657 let kind = PaneSemanticInputEventKind::Cancel {
1658 target: Some(active.target),
1659 reason,
1660 };
1661 let dispatch = self.forward_semantic(
1662 phase,
1663 Some(active.pointer_id),
1664 Some(active.target),
1665 kind,
1666 PaneModifierSnapshot::default(),
1667 );
1668 if dispatch.primary_transition.is_some() {
1669 self.active = None;
1670 }
1671 dispatch
1672 }
1673
1674 fn cancel_active_internal(
1675 &mut self,
1676 reason: PaneCancelReason,
1677 ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1678 let active = self.active?;
1679 let kind = PaneSemanticInputEventKind::Cancel {
1680 target: Some(active.target),
1681 reason,
1682 };
1683 let result = self
1684 .apply_semantic(kind, PaneModifierSnapshot::default())
1685 .ok();
1686 if result.is_some() {
1687 self.active = None;
1688 }
1689 result
1690 }
1691
1692 fn forward_semantic(
1693 &mut self,
1694 phase: PaneTerminalLifecyclePhase,
1695 pointer_id: Option<u32>,
1696 target: Option<PaneResizeTarget>,
1697 kind: PaneSemanticInputEventKind,
1698 modifiers: PaneModifierSnapshot,
1699 ) -> PaneTerminalDispatch {
1700 match self.apply_semantic(kind, modifiers) {
1701 Ok((event, transition)) => {
1702 PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1703 }
1704 Err(_) => PaneTerminalDispatch::ignored(
1705 phase,
1706 PaneTerminalIgnoredReason::MachineRejectedEvent,
1707 pointer_id,
1708 target,
1709 ),
1710 }
1711 }
1712
1713 fn apply_semantic(
1714 &mut self,
1715 kind: PaneSemanticInputEventKind,
1716 modifiers: PaneModifierSnapshot,
1717 ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1718 {
1719 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1720 event.modifiers = modifiers;
1721 let transition = self.machine.apply_event(&event)?;
1722 Ok((event, transition))
1723 }
1724
1725 fn next_sequence(&mut self) -> u64 {
1726 let sequence = self.next_sequence;
1727 self.next_sequence = self.next_sequence.saturating_add(1);
1728 sequence
1729 }
1730
1731 fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1732 if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1733 return false;
1734 }
1735 let movement = delta_x
1736 .unsigned_abs()
1737 .saturating_add(delta_y.unsigned_abs());
1738 movement < u32::from(self.config.drag_update_coalesce_distance)
1739 }
1740
1741 pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1750 let was_active = self.active.is_some();
1751 let machine_state_before = self.machine.state();
1752 let machine_transition = self.machine.force_cancel();
1753 let active_pointer = self.active.take();
1754 if !was_active && machine_transition.is_none() {
1755 return None;
1756 }
1757 Some(PaneCleanupDiagnostics {
1758 had_active_pointer: was_active,
1759 active_pointer_id: active_pointer.map(|a| a.pointer_id),
1760 machine_state_before,
1761 machine_transition,
1762 })
1763 }
1764}
1765
1766#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct PaneCleanupDiagnostics {
1772 pub had_active_pointer: bool,
1774 pub active_pointer_id: Option<u32>,
1776 pub machine_state_before: PaneDragResizeState,
1778 pub machine_transition: Option<PaneDragResizeTransition>,
1781}
1782
1783pub struct PaneInteractionGuard<'a> {
1798 adapter: &'a mut PaneTerminalAdapter,
1799 finished: bool,
1800 diagnostics: Option<PaneCleanupDiagnostics>,
1801}
1802
1803impl<'a> PaneInteractionGuard<'a> {
1804 pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1806 Self {
1807 adapter,
1808 finished: false,
1809 diagnostics: None,
1810 }
1811 }
1812
1813 pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1815 self.adapter
1816 }
1817
1818 pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1823 self.finished = true;
1824 let diagnostics = self.adapter.force_cancel_all();
1825 self.diagnostics = diagnostics.clone();
1826 diagnostics
1827 }
1828}
1829
1830impl Drop for PaneInteractionGuard<'_> {
1831 fn drop(&mut self) {
1832 if !self.finished {
1833 self.diagnostics = self.adapter.force_cancel_all();
1834 }
1835 }
1836}
1837
1838fn pane_button(button: MouseButton) -> PanePointerButton {
1839 match button {
1840 MouseButton::Left => PanePointerButton::Primary,
1841 MouseButton::Right => PanePointerButton::Secondary,
1842 MouseButton::Middle => PanePointerButton::Middle,
1843 }
1844}
1845
1846fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1847 match button {
1848 PanePointerButton::Primary => 1,
1849 PanePointerButton::Secondary => 2,
1850 PanePointerButton::Middle => 3,
1851 }
1852}
1853
1854fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1855 PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1856}
1857
1858fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1859 PaneModifierSnapshot {
1860 shift: modifiers.contains(Modifiers::SHIFT),
1861 alt: modifiers.contains(Modifiers::ALT),
1862 ctrl: modifiers.contains(Modifiers::CTRL),
1863 meta: modifiers.contains(Modifiers::SUPER),
1864 }
1865}
1866
1867fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1868 match (axis, code) {
1869 (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1870 (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1871 (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1872 (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1873 (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1874 (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1875 _ => None,
1876 }
1877}
1878
1879fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1880 if modifiers.contains(Modifiers::SHIFT) {
1881 5
1882 } else {
1883 1
1884 }
1885}
1886
1887#[derive(Clone)]
1891pub struct PersistenceConfig {
1892 pub registry: Option<std::sync::Arc<StateRegistry>>,
1894 pub checkpoint_interval: Option<Duration>,
1896 pub auto_load: bool,
1898 pub auto_save: bool,
1900}
1901
1902impl Default for PersistenceConfig {
1903 fn default() -> Self {
1904 Self {
1905 registry: None,
1906 checkpoint_interval: None,
1907 auto_load: true,
1908 auto_save: true,
1909 }
1910 }
1911}
1912
1913impl std::fmt::Debug for PersistenceConfig {
1914 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1915 f.debug_struct("PersistenceConfig")
1916 .field(
1917 "registry",
1918 &self.registry.as_ref().map(|r| r.backend_name()),
1919 )
1920 .field("checkpoint_interval", &self.checkpoint_interval)
1921 .field("auto_load", &self.auto_load)
1922 .field("auto_save", &self.auto_save)
1923 .finish()
1924 }
1925}
1926
1927impl PersistenceConfig {
1928 #[must_use]
1930 pub fn disabled() -> Self {
1931 Self::default()
1932 }
1933
1934 #[must_use]
1936 pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1937 Self {
1938 registry: Some(registry),
1939 ..Default::default()
1940 }
1941 }
1942
1943 #[must_use]
1945 pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1946 self.checkpoint_interval = Some(interval);
1947 self
1948 }
1949
1950 #[must_use]
1952 pub fn auto_load(mut self, enabled: bool) -> Self {
1953 self.auto_load = enabled;
1954 self
1955 }
1956
1957 #[must_use]
1959 pub fn auto_save(mut self, enabled: bool) -> Self {
1960 self.auto_save = enabled;
1961 self
1962 }
1963}
1964
1965#[derive(Debug, Clone)]
1977pub struct WidgetRefreshConfig {
1978 pub enabled: bool,
1980 pub staleness_window_ms: u64,
1982 pub starve_ms: u64,
1984 pub max_starved_per_frame: usize,
1986 pub max_drop_fraction: f32,
1989 pub weight_priority: f32,
1991 pub weight_staleness: f32,
1993 pub weight_focus: f32,
1995 pub weight_interaction: f32,
1997 pub starve_boost: f32,
1999 pub min_cost_us: f32,
2001}
2002
2003impl Default for WidgetRefreshConfig {
2004 fn default() -> Self {
2005 Self {
2006 enabled: true,
2007 staleness_window_ms: 1_000,
2008 starve_ms: 3_000,
2009 max_starved_per_frame: 2,
2010 max_drop_fraction: 1.0,
2011 weight_priority: 1.0,
2012 weight_staleness: 0.5,
2013 weight_focus: 0.75,
2014 weight_interaction: 0.5,
2015 starve_boost: 1.5,
2016 min_cost_us: 1.0,
2017 }
2018 }
2019}
2020
2021#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2023pub enum TaskExecutorBackend {
2024 #[default]
2026 Spawned,
2027 EffectQueue,
2029 #[cfg(feature = "asupersync-executor")]
2031 Asupersync,
2032}
2033
2034#[derive(Debug, Clone)]
2035pub struct EffectQueueConfig {
2036 pub enabled: bool,
2041 pub backend: TaskExecutorBackend,
2043 pub scheduler: SchedulerConfig,
2045 pub max_queue_depth: usize,
2051 explicit_backend: bool,
2053}
2054
2055impl Default for EffectQueueConfig {
2056 fn default() -> Self {
2057 let scheduler = SchedulerConfig {
2058 smith_enabled: true,
2059 force_fifo: false,
2060 preemptive: false,
2061 aging_factor: 0.0,
2062 wait_starve_ms: 0.0,
2063 enable_logging: false,
2064 ..Default::default()
2065 };
2066 Self {
2067 enabled: false,
2068 backend: TaskExecutorBackend::Spawned,
2069 scheduler,
2070 max_queue_depth: 0,
2071 explicit_backend: false,
2072 }
2073 }
2074}
2075
2076impl EffectQueueConfig {
2077 #[must_use]
2079 pub fn with_enabled(mut self, enabled: bool) -> Self {
2080 self.enabled = enabled;
2081 self.backend = if enabled {
2082 TaskExecutorBackend::EffectQueue
2083 } else {
2084 TaskExecutorBackend::Spawned
2085 };
2086 self.explicit_backend = true;
2087 self
2088 }
2089
2090 #[must_use]
2092 pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2093 self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2094 self.backend = backend;
2095 self.explicit_backend = true;
2096 self
2097 }
2098
2099 #[must_use]
2101 pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2102 self.scheduler = scheduler;
2103 self
2104 }
2105
2106 #[must_use]
2111 pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2112 self.max_queue_depth = depth;
2113 self
2114 }
2115
2116 #[must_use]
2117 fn uses_legacy_default_backend(&self) -> bool {
2118 !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2119 }
2120}
2121
2122#[derive(Debug, Clone)]
2129pub struct ImmediateDrainConfig {
2130 pub max_zero_timeout_polls_per_burst: usize,
2132 pub max_burst_duration: Duration,
2134 pub backoff_timeout: Duration,
2136}
2137
2138impl Default for ImmediateDrainConfig {
2139 fn default() -> Self {
2140 Self {
2141 max_zero_timeout_polls_per_burst: 64,
2142 max_burst_duration: Duration::from_millis(2),
2143 backoff_timeout: Duration::from_millis(1),
2144 }
2145 }
2146}
2147
2148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2150pub struct ImmediateDrainStats {
2151 pub bursts: u64,
2153 pub zero_timeout_polls: u64,
2155 pub backoff_polls: u64,
2157 pub capped_bursts: u64,
2159 pub max_zero_timeout_polls_in_burst: u64,
2161}
2162
2163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2177pub enum RuntimeLoadMode {
2178 Healthy,
2180 Stressed,
2182 Degraded,
2184 Recovered,
2186 Unsafe,
2188}
2189
2190impl RuntimeLoadMode {
2191 #[inline]
2193 #[must_use]
2194 pub const fn as_str(self) -> &'static str {
2195 match self {
2196 Self::Healthy => "healthy",
2197 Self::Stressed => "stressed",
2198 Self::Degraded => "degraded",
2199 Self::Recovered => "recovered",
2200 Self::Unsafe => "unsafe",
2201 }
2202 }
2203}
2204
2205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2207pub enum RuntimePressureClass {
2208 SteadyState,
2210 SoftOverload,
2212 HardOverload,
2214 Unsafe,
2216}
2217
2218impl RuntimePressureClass {
2219 #[inline]
2221 #[must_use]
2222 pub const fn as_str(self) -> &'static str {
2223 match self {
2224 Self::SteadyState => "steady_state",
2225 Self::SoftOverload => "soft_overload",
2226 Self::HardOverload => "hard_overload",
2227 Self::Unsafe => "unsafe",
2228 }
2229 }
2230}
2231
2232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2234pub enum RuntimeWorkDisposition {
2235 AdmitAll,
2237 CoalesceVisibleDeferBackground,
2239 DeferBackgroundDropBestEffort,
2241 ReadmitAfterHysteresis,
2243 FailFastStrictGuarantee,
2245}
2246
2247impl RuntimeWorkDisposition {
2248 #[inline]
2250 #[must_use]
2251 pub const fn as_str(self) -> &'static str {
2252 match self {
2253 Self::AdmitAll => "admit_all",
2254 Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2255 Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2256 Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2257 Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2258 }
2259 }
2260}
2261
2262#[derive(Debug, Clone, Copy, PartialEq)]
2269pub struct LoadGovernorPolicy {
2270 pub stressed_queue_watermark: f64,
2272 pub degraded_queue_watermark: f64,
2274 pub recovery_queue_watermark: f64,
2276 pub recovery_intervals: u8,
2278 pub budget_overrun_soft_ratio: f64,
2280}
2281
2282impl Default for LoadGovernorPolicy {
2283 fn default() -> Self {
2284 Self {
2285 stressed_queue_watermark: 0.5,
2286 degraded_queue_watermark: 0.8,
2287 recovery_queue_watermark: 0.25,
2288 recovery_intervals: 3,
2289 budget_overrun_soft_ratio: 1.0,
2290 }
2291 }
2292}
2293
2294impl LoadGovernorPolicy {
2295 #[must_use]
2296 fn normalized(self) -> Self {
2297 let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2298 let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2299 let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2300 Self {
2301 recovery_queue_watermark: recovery,
2302 stressed_queue_watermark: stressed,
2303 degraded_queue_watermark: degraded,
2304 recovery_intervals: self.recovery_intervals.max(1),
2305 budget_overrun_soft_ratio: normalize_positive_ratio(
2306 self.budget_overrun_soft_ratio,
2307 1.0,
2308 ),
2309 }
2310 }
2311}
2312
2313#[inline]
2314fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2315 if value.is_finite() {
2316 value.clamp(0.0, 1.0)
2317 } else {
2318 fallback
2319 }
2320}
2321
2322#[inline]
2323fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2324 if value.is_finite() && value > 0.0 {
2325 value
2326 } else {
2327 fallback
2328 }
2329}
2330
2331#[derive(Debug, Clone, PartialEq)]
2339pub struct LoadGovernorConfig {
2340 pub enabled: bool,
2342 pub budget_controller: BudgetControllerConfig,
2344 pub policy: LoadGovernorPolicy,
2346}
2347
2348impl Default for LoadGovernorConfig {
2349 fn default() -> Self {
2350 Self {
2351 enabled: true,
2352 budget_controller: BudgetControllerConfig::default(),
2353 policy: LoadGovernorPolicy::default(),
2354 }
2355 }
2356}
2357
2358impl LoadGovernorConfig {
2359 #[must_use]
2361 pub fn enabled() -> Self {
2362 Self::default()
2363 }
2364
2365 #[must_use]
2367 pub fn disabled() -> Self {
2368 Self {
2369 enabled: false,
2370 budget_controller: BudgetControllerConfig::default(),
2371 policy: LoadGovernorPolicy::default(),
2372 }
2373 }
2374
2375 #[must_use]
2377 pub fn with_enabled(mut self, enabled: bool) -> Self {
2378 self.enabled = enabled;
2379 self
2380 }
2381
2382 #[must_use]
2384 pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2385 self.budget_controller = config;
2386 self
2387 }
2388
2389 #[must_use]
2391 pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2392 self.policy = policy.normalized();
2393 self
2394 }
2395}
2396
2397#[derive(Debug, Clone, Copy)]
2398struct LoadGovernorObservation {
2399 frame_time_us: f64,
2400 budget_us: f64,
2401 degradation: DegradationLevel,
2402 queue: crate::effect_system::QueueTelemetry,
2403 resize_coalescing_active: bool,
2404 strict_semantics_violation: bool,
2405}
2406
2407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2408struct LoadGovernorSnapshot {
2409 mode: RuntimeLoadMode,
2410 mode_before: RuntimeLoadMode,
2411 pressure_class: RuntimePressureClass,
2412 disposition: RuntimeWorkDisposition,
2413 reason_code: &'static str,
2414 transition: bool,
2415 strict_semantics_preserved: bool,
2416 queue_in_flight: u64,
2417 queue_max_depth: Option<usize>,
2418 queue_dropped_delta: u64,
2419 resize_coalescing_active: bool,
2420 recovery_intervals_observed: u8,
2421 recovery_intervals_required: u8,
2422 deferred_work_total: u64,
2423 coalesced_work_total: u64,
2424 dropped_work_total: u64,
2425}
2426
2427#[derive(Debug, Clone)]
2428struct LoadGovernorState {
2429 enabled: bool,
2430 policy: LoadGovernorPolicy,
2431 max_queue_depth: usize,
2432 mode: RuntimeLoadMode,
2433 recovery_intervals_observed: u8,
2434 last_queue_dropped: u64,
2435 queue_baseline_initialized: bool,
2436 deferred_work_total: u64,
2437 coalesced_work_total: u64,
2438 dropped_work_total: u64,
2439}
2440
2441impl LoadGovernorState {
2442 fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2443 Self {
2444 enabled: config.enabled,
2445 policy: config.policy.normalized(),
2446 max_queue_depth,
2447 mode: RuntimeLoadMode::Healthy,
2448 recovery_intervals_observed: 0,
2449 last_queue_dropped: 0,
2450 queue_baseline_initialized: false,
2451 deferred_work_total: 0,
2452 coalesced_work_total: 0,
2453 dropped_work_total: 0,
2454 }
2455 }
2456
2457 fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2458 if !self.enabled {
2459 return self.snapshot(
2460 RuntimeLoadMode::Healthy,
2461 RuntimeLoadMode::Healthy,
2462 RuntimePressureClass::SteadyState,
2463 RuntimeWorkDisposition::AdmitAll,
2464 "governor_disabled",
2465 false,
2466 true,
2467 observation,
2468 0,
2469 );
2470 }
2471
2472 let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2473 let pressure = self.classify_pressure(observation, dropped_delta);
2474 let mode_before = self.mode;
2475 let reason_code = self.reason_code(observation, pressure, dropped_delta);
2476
2477 match pressure {
2478 RuntimePressureClass::Unsafe => {
2479 self.mode = RuntimeLoadMode::Unsafe;
2480 self.recovery_intervals_observed = 0;
2481 }
2482 _ if self.mode == RuntimeLoadMode::Unsafe => {
2490 self.recovery_intervals_observed = 0;
2491 }
2492 RuntimePressureClass::HardOverload => {
2493 self.mode = RuntimeLoadMode::Degraded;
2494 self.recovery_intervals_observed = 0;
2495 }
2496 RuntimePressureClass::SoftOverload => {
2497 if self.mode != RuntimeLoadMode::Degraded {
2498 self.mode = RuntimeLoadMode::Stressed;
2499 }
2500 self.recovery_intervals_observed = 0;
2501 }
2502 RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2503 }
2504
2505 self.record_work_disposition(observation, dropped_delta);
2506 let disposition = Self::disposition_for_mode(self.mode);
2507 self.snapshot(
2508 self.mode,
2509 mode_before,
2510 pressure,
2511 disposition,
2512 if self.mode == RuntimeLoadMode::Unsafe {
2513 "strict_semantics_violation"
2516 } else if self.mode == RuntimeLoadMode::Recovered {
2517 "recovery_hysteresis_satisfied"
2518 } else if mode_before == RuntimeLoadMode::Recovered
2519 && self.mode == RuntimeLoadMode::Healthy
2520 {
2521 "recovered_interval_closed"
2522 } else {
2523 reason_code
2524 },
2525 mode_before != self.mode,
2526 self.mode != RuntimeLoadMode::Unsafe,
2530 observation,
2531 dropped_delta,
2532 )
2533 }
2534
2535 fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2536 if !self.queue_baseline_initialized {
2537 self.queue_baseline_initialized = true;
2538 self.last_queue_dropped = dropped_total;
2539 return 0;
2540 }
2541 let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2542 self.last_queue_dropped = dropped_total;
2543 delta
2544 }
2545
2546 fn classify_pressure(
2547 &self,
2548 observation: LoadGovernorObservation,
2549 dropped_delta: u64,
2550 ) -> RuntimePressureClass {
2551 if observation.strict_semantics_violation {
2552 return RuntimePressureClass::Unsafe;
2553 }
2554 if dropped_delta > 0
2555 || self
2556 .queue_ratio(observation.queue.in_flight)
2557 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2558 || observation.degradation > DegradationLevel::Full
2559 {
2560 return RuntimePressureClass::HardOverload;
2561 }
2562 if self
2563 .queue_ratio(observation.queue.in_flight)
2564 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2565 || observation.resize_coalescing_active
2566 || observation.frame_time_us
2567 > observation.budget_us * self.policy.budget_overrun_soft_ratio
2568 {
2569 return RuntimePressureClass::SoftOverload;
2570 }
2571 RuntimePressureClass::SteadyState
2572 }
2573
2574 fn observe_steady_interval(&mut self) {
2575 match self.mode {
2576 RuntimeLoadMode::Degraded => {
2577 self.recovery_intervals_observed = self
2578 .recovery_intervals_observed
2579 .saturating_add(1)
2580 .min(self.policy.recovery_intervals);
2581 if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2582 self.mode = RuntimeLoadMode::Recovered;
2583 }
2584 }
2585 RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2586 self.mode = RuntimeLoadMode::Healthy;
2587 self.recovery_intervals_observed = 0;
2588 }
2589 RuntimeLoadMode::Healthy => {
2590 self.recovery_intervals_observed = 0;
2591 }
2592 RuntimeLoadMode::Unsafe => {}
2593 }
2594 }
2595
2596 fn record_work_disposition(
2597 &mut self,
2598 observation: LoadGovernorObservation,
2599 dropped_delta: u64,
2600 ) {
2601 if dropped_delta > 0 {
2602 self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2603 }
2604 match self.mode {
2605 RuntimeLoadMode::Stressed => {
2606 if observation.resize_coalescing_active {
2607 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2608 }
2609 }
2610 RuntimeLoadMode::Degraded => {
2611 self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2612 if observation.resize_coalescing_active {
2613 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2614 }
2615 }
2616 RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2617 }
2618 }
2619
2620 fn reason_code(
2621 &self,
2622 observation: LoadGovernorObservation,
2623 pressure: RuntimePressureClass,
2624 dropped_delta: u64,
2625 ) -> &'static str {
2626 match pressure {
2627 RuntimePressureClass::Unsafe => "strict_semantics_violation",
2628 RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2629 RuntimePressureClass::HardOverload
2630 if self
2631 .queue_ratio(observation.queue.in_flight)
2632 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2633 {
2634 "queue_degraded_watermark"
2635 }
2636 RuntimePressureClass::HardOverload => "budget_degradation_active",
2637 RuntimePressureClass::SoftOverload
2638 if self
2639 .queue_ratio(observation.queue.in_flight)
2640 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2641 {
2642 "queue_stressed_watermark"
2643 }
2644 RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2645 "resize_coalescing_active"
2646 }
2647 RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2648 RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2649 "recovery_hysteresis_pending"
2650 }
2651 RuntimePressureClass::SteadyState => "steady_state",
2652 }
2653 }
2654
2655 fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2656 (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2657 }
2658
2659 const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2660 match mode {
2661 RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2662 RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2663 RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2664 RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2665 RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2666 }
2667 }
2668
2669 #[allow(clippy::too_many_arguments)]
2673 fn snapshot(
2674 &self,
2675 mode: RuntimeLoadMode,
2676 mode_before: RuntimeLoadMode,
2677 pressure_class: RuntimePressureClass,
2678 disposition: RuntimeWorkDisposition,
2679 reason_code: &'static str,
2680 transition: bool,
2681 strict_semantics_preserved: bool,
2682 observation: LoadGovernorObservation,
2683 dropped_delta: u64,
2684 ) -> LoadGovernorSnapshot {
2685 LoadGovernorSnapshot {
2686 mode,
2687 mode_before,
2688 pressure_class,
2689 disposition,
2690 reason_code,
2691 transition,
2692 strict_semantics_preserved,
2693 queue_in_flight: observation.queue.in_flight,
2694 queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2695 queue_dropped_delta: dropped_delta,
2696 resize_coalescing_active: observation.resize_coalescing_active,
2697 recovery_intervals_observed: self.recovery_intervals_observed,
2698 recovery_intervals_required: self.policy.recovery_intervals,
2699 deferred_work_total: self.deferred_work_total,
2700 coalesced_work_total: self.coalesced_work_total,
2701 dropped_work_total: self.dropped_work_total,
2702 }
2703 }
2704}
2705
2706#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2720pub enum RuntimeLane {
2721 Legacy,
2724 #[default]
2727 Structured,
2728 Asupersync,
2731}
2732
2733impl RuntimeLane {
2734 #[must_use]
2739 pub fn resolve(self) -> Self {
2740 match self {
2741 Self::Asupersync => {
2742 tracing::info!(
2743 target: "ftui.runtime",
2744 requested = "asupersync",
2745 resolved = "structured",
2746 "Asupersync lane not yet available; falling back to structured cancellation"
2747 );
2748 Self::Structured
2749 }
2750 other => other,
2751 }
2752 }
2753
2754 #[must_use]
2756 pub fn label(self) -> &'static str {
2757 match self {
2758 Self::Legacy => "legacy",
2759 Self::Structured => "structured",
2760 Self::Asupersync => "asupersync",
2761 }
2762 }
2763
2764 #[must_use]
2766 pub fn uses_structured_cancellation(self) -> bool {
2767 matches!(self, Self::Structured | Self::Asupersync)
2768 }
2769
2770 #[must_use]
2792 fn task_executor_backend(self) -> TaskExecutorBackend {
2793 match self {
2794 Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2799 Self::Asupersync => {
2800 #[cfg(feature = "asupersync-executor")]
2801 {
2802 TaskExecutorBackend::Asupersync
2803 }
2804 #[cfg(not(feature = "asupersync-executor"))]
2805 {
2806 TaskExecutorBackend::EffectQueue
2807 }
2808 }
2809 }
2810 }
2811
2812 #[must_use]
2817 pub fn from_env() -> Option<Self> {
2818 let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2819 Self::parse(&val)
2820 }
2821
2822 #[must_use]
2826 pub fn parse(s: &str) -> Option<Self> {
2827 match s.to_ascii_lowercase().as_str() {
2828 "legacy" => Some(Self::Legacy),
2829 "structured" => Some(Self::Structured),
2830 "asupersync" => Some(Self::Asupersync),
2831 _ => {
2832 tracing::warn!(
2833 target: "ftui.runtime",
2834 value = s,
2835 "RuntimeLane::parse: unrecognized value"
2836 );
2837 None
2838 }
2839 }
2840 }
2841}
2842
2843#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2857pub enum RolloutPolicy {
2858 #[default]
2860 Off,
2861 Shadow,
2863 Enabled,
2865}
2866
2867impl RolloutPolicy {
2868 #[must_use]
2873 pub fn from_env() -> Option<Self> {
2874 let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2875 Self::parse(&val)
2876 }
2877
2878 #[must_use]
2882 pub fn parse(s: &str) -> Option<Self> {
2883 match s.to_ascii_lowercase().as_str() {
2884 "off" => Some(Self::Off),
2885 "shadow" => Some(Self::Shadow),
2886 "enabled" => Some(Self::Enabled),
2887 _ => {
2888 tracing::warn!(
2889 target: "ftui.runtime",
2890 value = s,
2891 "RolloutPolicy::parse: unrecognized value"
2892 );
2893 None
2894 }
2895 }
2896 }
2897
2898 #[must_use]
2900 pub fn label(self) -> &'static str {
2901 match self {
2902 Self::Off => "off",
2903 Self::Shadow => "shadow",
2904 Self::Enabled => "enabled",
2905 }
2906 }
2907
2908 #[must_use]
2910 pub fn is_shadow(self) -> bool {
2911 matches!(self, Self::Shadow)
2912 }
2913}
2914
2915impl std::fmt::Display for RolloutPolicy {
2916 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2917 f.write_str(self.label())
2918 }
2919}
2920
2921impl std::fmt::Display for RuntimeLane {
2922 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2923 f.write_str(self.label())
2924 }
2925}
2926
2927#[derive(Debug, Clone)]
2929pub struct ProgramConfig {
2930 pub screen_mode: ScreenMode,
2932 pub ui_anchor: UiAnchor,
2934 pub budget: FrameBudgetConfig,
2936 pub load_governor: LoadGovernorConfig,
2938 pub diff_config: RuntimeDiffConfig,
2940 pub evidence_sink: EvidenceSinkConfig,
2942 pub render_trace: RenderTraceConfig,
2944 pub frame_timing: Option<FrameTimingConfig>,
2946 pub conformal_config: Option<ConformalConfig>,
2948 pub locale_context: LocaleContext,
2950 pub poll_timeout: Duration,
2952 pub immediate_drain: ImmediateDrainConfig,
2954 pub resize_coalescer: CoalescerConfig,
2956 pub resize_behavior: ResizeBehavior,
2958 pub forced_size: Option<(u16, u16)>,
2960 pub mouse_capture_policy: MouseCapturePolicy,
2964 pub bracketed_paste: bool,
2966 pub focus_reporting: bool,
2968 pub kitty_keyboard: bool,
2970 pub persistence: PersistenceConfig,
2972 pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2974 pub widget_refresh: WidgetRefreshConfig,
2976 pub effect_queue: EffectQueueConfig,
2978 pub guardrails: GuardrailsConfig,
2980 pub intercept_signals: bool,
2985 pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2990 pub runtime_lane: RuntimeLane,
2996 pub rollout_policy: RolloutPolicy,
3002}
3003
3004impl Default for ProgramConfig {
3005 fn default() -> Self {
3006 Self {
3007 screen_mode: ScreenMode::Inline { ui_height: 4 },
3008 ui_anchor: UiAnchor::Bottom,
3009 budget: FrameBudgetConfig::default(),
3010 load_governor: LoadGovernorConfig::default(),
3011 diff_config: RuntimeDiffConfig::default(),
3012 evidence_sink: EvidenceSinkConfig::default(),
3013 render_trace: RenderTraceConfig::default(),
3014 frame_timing: None,
3015 conformal_config: None,
3016 locale_context: LocaleContext::global(),
3017 poll_timeout: Duration::from_millis(100),
3018 immediate_drain: ImmediateDrainConfig::default(),
3019 resize_coalescer: CoalescerConfig::default(),
3020 resize_behavior: ResizeBehavior::Throttled,
3021 forced_size: None,
3022 mouse_capture_policy: MouseCapturePolicy::Auto,
3023 bracketed_paste: true,
3024 focus_reporting: false,
3025 kitty_keyboard: false,
3026 persistence: PersistenceConfig::default(),
3027 inline_auto_remeasure: None,
3028 widget_refresh: WidgetRefreshConfig::default(),
3029 effect_queue: EffectQueueConfig::default(),
3030 guardrails: GuardrailsConfig::default(),
3031 intercept_signals: true,
3032 tick_strategy: None,
3033 runtime_lane: RuntimeLane::default(),
3034 rollout_policy: RolloutPolicy::default(),
3035 }
3036 }
3037}
3038
3039impl ProgramConfig {
3040 pub fn fullscreen() -> Self {
3042 Self {
3043 screen_mode: ScreenMode::AltScreen,
3044 ..Default::default()
3045 }
3046 }
3047
3048 pub fn inline(height: u16) -> Self {
3050 Self {
3051 screen_mode: ScreenMode::Inline { ui_height: height },
3052 ..Default::default()
3053 }
3054 }
3055
3056 pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3058 Self {
3059 screen_mode: ScreenMode::InlineAuto {
3060 min_height,
3061 max_height,
3062 },
3063 inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3064 ..Default::default()
3065 }
3066 }
3067
3068 #[must_use]
3070 pub fn with_mouse(mut self) -> Self {
3071 self.mouse_capture_policy = MouseCapturePolicy::On;
3072 self
3073 }
3074
3075 #[must_use]
3077 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3078 self.mouse_capture_policy = policy;
3079 self
3080 }
3081
3082 #[must_use]
3084 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3085 self.mouse_capture_policy = if enabled {
3086 MouseCapturePolicy::On
3087 } else {
3088 MouseCapturePolicy::Off
3089 };
3090 self
3091 }
3092
3093 #[must_use]
3095 pub const fn resolved_mouse_capture(&self) -> bool {
3096 self.mouse_capture_policy.resolve(self.screen_mode)
3097 }
3098
3099 #[must_use]
3101 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3102 self.budget = budget;
3103 self
3104 }
3105
3106 #[must_use]
3108 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3109 self.load_governor = config;
3110 self
3111 }
3112
3113 #[must_use]
3115 pub fn without_load_governor(mut self) -> Self {
3116 self.load_governor = LoadGovernorConfig::disabled();
3117 self
3118 }
3119
3120 #[must_use]
3122 pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3123 self.diff_config = diff_config;
3124 self
3125 }
3126
3127 #[must_use]
3129 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3130 self.evidence_sink = config;
3131 self
3132 }
3133
3134 #[must_use]
3136 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3137 self.render_trace = config;
3138 self
3139 }
3140
3141 #[must_use]
3143 pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3144 self.frame_timing = Some(config);
3145 self
3146 }
3147
3148 #[must_use]
3150 pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3151 self.conformal_config = Some(config);
3152 self
3153 }
3154
3155 #[must_use]
3157 pub fn without_conformal(mut self) -> Self {
3158 self.conformal_config = None;
3159 self
3160 }
3161
3162 #[must_use]
3164 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3165 self.locale_context = locale_context;
3166 self
3167 }
3168
3169 #[must_use]
3171 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3172 self.locale_context = LocaleContext::new(locale);
3173 self
3174 }
3175
3176 #[must_use]
3178 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3179 self.widget_refresh = config;
3180 self
3181 }
3182
3183 #[must_use]
3185 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3186 self.effect_queue = config;
3187 self
3188 }
3189
3190 #[must_use]
3192 pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3193 self.resize_coalescer = config;
3194 self
3195 }
3196
3197 #[must_use]
3199 pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3200 self.resize_behavior = behavior;
3201 self
3202 }
3203
3204 #[must_use]
3206 pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3207 let width = width.max(1);
3208 let height = height.max(1);
3209 self.forced_size = Some((width, height));
3210 self
3211 }
3212
3213 #[must_use]
3215 pub fn without_forced_size(mut self) -> Self {
3216 self.forced_size = None;
3217 self
3218 }
3219
3220 #[must_use]
3222 pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3223 if enabled {
3224 self.resize_behavior = ResizeBehavior::Immediate;
3225 }
3226 self
3227 }
3228
3229 #[must_use]
3231 pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3232 self.persistence = persistence;
3233 self
3234 }
3235
3236 #[must_use]
3238 pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3239 self.persistence = PersistenceConfig::with_registry(registry);
3240 self
3241 }
3242
3243 #[must_use]
3245 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3246 self.inline_auto_remeasure = Some(config);
3247 self
3248 }
3249
3250 #[must_use]
3252 pub fn without_inline_auto_remeasure(mut self) -> Self {
3253 self.inline_auto_remeasure = None;
3254 self
3255 }
3256
3257 #[must_use]
3259 pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3260 self.intercept_signals = enabled;
3261 self
3262 }
3263
3264 #[must_use]
3266 pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3267 self.guardrails = config;
3268 self
3269 }
3270
3271 #[must_use]
3273 pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3274 self.immediate_drain = config;
3275 self
3276 }
3277
3278 #[must_use]
3289 pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3290 self.tick_strategy = Some(strategy);
3291 self
3292 }
3293
3294 #[must_use]
3296 pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3297 self.runtime_lane = lane;
3298 self
3299 }
3300
3301 #[must_use]
3303 pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3304 self.rollout_policy = policy;
3305 self
3306 }
3307
3308 #[must_use]
3314 pub fn with_env_overrides(mut self) -> Self {
3315 if let Some(lane) = RuntimeLane::from_env() {
3316 self.runtime_lane = lane;
3317 }
3318 if let Some(policy) = RolloutPolicy::from_env() {
3319 self.rollout_policy = policy;
3320 }
3321 self
3322 }
3323
3324 #[must_use]
3325 fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3326 if !self.effect_queue.uses_legacy_default_backend() {
3327 return self.effect_queue.clone();
3328 }
3329
3330 self.effect_queue
3331 .clone()
3332 .with_backend(self.runtime_lane.resolve().task_executor_backend())
3333 }
3334}
3335
3336fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3337 let budget = RenderBudget::from_config(&config.budget);
3338 if config.load_governor.enabled {
3339 let mut controller = config.load_governor.budget_controller.clone();
3340 controller.target = config.budget.total;
3341 budget.with_controller(controller)
3342 } else {
3343 budget
3344 }
3345}
3346
3347enum EffectCommand<M> {
3348 Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3349 Shutdown,
3350}
3351
3352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3353enum EffectLoopControl {
3354 Continue,
3355 ShutdownRequested,
3356}
3357
3358struct EffectQueue<M: Send + 'static> {
3359 sender: mpsc::Sender<EffectCommand<M>>,
3360 handle: Option<JoinHandle<()>>,
3361 closed: bool,
3362}
3363
3364impl<M: Send + 'static> EffectQueue<M> {
3365 fn start(
3366 config: EffectQueueConfig,
3367 result_sender: mpsc::Sender<M>,
3368 evidence_sink: Option<EvidenceSink>,
3369 ) -> io::Result<Self> {
3370 let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3371 let handle = thread::Builder::new()
3372 .name("ftui-effects".into())
3373 .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3374
3375 Ok(Self {
3376 sender: tx,
3377 handle: Some(handle),
3378 closed: false,
3379 })
3380 }
3381
3382 fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3383 if self.closed {
3384 crate::effect_system::record_queue_drop("post_shutdown");
3385 tracing::debug!("rejecting task enqueue after effect queue shutdown");
3386 return;
3387 }
3388 if self
3389 .sender
3390 .send(EffectCommand::Enqueue(spec, task))
3391 .is_err()
3392 {
3393 crate::effect_system::record_queue_drop("channel_closed");
3394 }
3395 }
3396
3397 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3399 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3405
3406 fn shutdown(&mut self) {
3407 self.closed = true;
3408 let _ = self.sender.send(EffectCommand::Shutdown);
3409 if let Some(handle) = self.handle.take() {
3410 let start = Instant::now();
3411 if handle.is_finished() {
3414 let _ = handle.join();
3415 let elapsed_us = start.elapsed().as_micros() as u64;
3416 tracing::debug!(
3417 target: "ftui.runtime",
3418 elapsed_us,
3419 "effect-queue shutdown (fast path)"
3420 );
3421 return;
3422 }
3423 while !handle.is_finished() {
3425 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3426 tracing::warn!(
3427 target: "ftui.runtime",
3428 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3429 "effect-queue thread did not stop within timeout; detaching"
3430 );
3431 return;
3432 }
3433 thread::sleep(Self::SHUTDOWN_POLL);
3434 }
3435 let _ = handle.join();
3436 let elapsed_us = start.elapsed().as_micros() as u64;
3437 tracing::debug!(
3438 target: "ftui.runtime",
3439 elapsed_us,
3440 "effect-queue shutdown (slow path)"
3441 );
3442 }
3443 }
3444}
3445
3446impl<M: Send + 'static> Drop for EffectQueue<M> {
3447 fn drop(&mut self) {
3448 self.shutdown();
3449 }
3450}
3451
3452struct SpawnTaskExecutor<M: Send + 'static> {
3453 result_sender: mpsc::Sender<M>,
3454 evidence_sink: Option<EvidenceSink>,
3455 handles: Vec<JoinHandle<()>>,
3456 closed: bool,
3457}
3458
3459impl<M: Send + 'static> SpawnTaskExecutor<M> {
3460 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3461 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3467
3468 fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
3469 Self {
3470 result_sender,
3471 evidence_sink,
3472 handles: Vec::new(),
3473 closed: false,
3474 }
3475 }
3476
3477 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3478 if self.closed {
3479 tracing::debug!("rejecting spawned task submit after shutdown");
3480 return;
3481 }
3482 let sender = self.result_sender.clone();
3483 let evidence_sink = self.evidence_sink.clone();
3484 let handle = thread::spawn(move || {
3485 let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3486 });
3487 self.handles.push(handle);
3488 }
3489
3490 fn reap_finished(&mut self) {
3491 if self.handles.is_empty() {
3492 return;
3493 }
3494
3495 let mut i = 0;
3496 while i < self.handles.len() {
3497 if self.handles[i].is_finished() {
3498 let handle = self.handles.swap_remove(i);
3499 let _ = handle.join();
3500 } else {
3501 i += 1;
3502 }
3503 }
3504 }
3505
3506 fn shutdown(&mut self) {
3507 self.closed = true;
3508 let start = Instant::now();
3509 self.reap_finished();
3511 if self.handles.is_empty() {
3512 let elapsed_us = start.elapsed().as_micros() as u64;
3513 tracing::debug!(
3514 target: "ftui.runtime",
3515 elapsed_us,
3516 "spawn-executor shutdown (fast path, all tasks already finished)"
3517 );
3518 return;
3519 }
3520 let pending_at_start = self.handles.len();
3522 while self.handles.iter().any(|handle| !handle.is_finished()) {
3523 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3524 let still_pending = self
3525 .handles
3526 .iter()
3527 .filter(|handle| !handle.is_finished())
3528 .count();
3529 tracing::warn!(
3530 target: "ftui.runtime",
3531 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3532 pending_handles = still_pending,
3533 "background task threads did not stop within timeout; detaching"
3534 );
3535 self.handles.clear();
3536 return;
3537 }
3538 thread::sleep(Self::SHUTDOWN_POLL);
3539 }
3540 self.reap_finished();
3541 let elapsed_us = start.elapsed().as_micros() as u64;
3542 tracing::debug!(
3543 target: "ftui.runtime",
3544 elapsed_us,
3545 pending_at_start,
3546 "spawn-executor shutdown (slow path)"
3547 );
3548 }
3549}
3550
3551#[cfg(feature = "asupersync-executor")]
3552struct AsupersyncTaskExecutor<M: Send + 'static> {
3553 result_sender: mpsc::Sender<M>,
3554 evidence_sink: Option<EvidenceSink>,
3555 runtime: AsupersyncRuntime,
3556 handles: Vec<BlockingTaskHandle>,
3557 closed: bool,
3558}
3559
3560#[cfg(feature = "asupersync-executor")]
3561impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3562 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3563
3564 fn new(
3565 result_sender: mpsc::Sender<M>,
3566 evidence_sink: Option<EvidenceSink>,
3567 ) -> io::Result<Self> {
3568 let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3569 let runtime = RuntimeBuilder::new()
3570 .blocking_threads(1, max_threads)
3571 .thread_name_prefix("ftui-asupersync-task")
3572 .build()
3573 .map_err(|error| {
3574 io::Error::other(format!("asupersync runtime init failed: {error}"))
3575 })?;
3576
3577 Ok(Self {
3578 result_sender,
3579 evidence_sink,
3580 runtime,
3581 handles: Vec::new(),
3582 closed: false,
3583 })
3584 }
3585
3586 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3587 if self.closed {
3588 tracing::debug!("rejecting asupersync task submit after shutdown");
3589 return;
3590 }
3591 let sender = self.result_sender.clone();
3592 let evidence_sink = self.evidence_sink.clone();
3593 let handle = self
3594 .runtime
3595 .spawn_blocking(move || {
3596 let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3597 })
3598 .expect("asupersync blocking pool must be configured");
3599 self.handles.push(handle);
3600 }
3601
3602 fn reap_finished(&mut self) {
3603 self.handles.retain(|handle| !handle.is_done());
3604 }
3605
3606 fn shutdown(&mut self) {
3607 self.closed = true;
3608 let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3609 for handle in &self.handles {
3610 let remaining = deadline.saturating_duration_since(Instant::now());
3611 if remaining.is_zero() || !handle.wait_timeout(remaining) {
3612 tracing::warn!(
3613 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3614 pending_handles = self
3615 .handles
3616 .iter()
3617 .filter(|pending| !pending.is_done())
3618 .count(),
3619 "Asupersync blocking tasks did not stop within timeout; detaching"
3620 );
3621 self.handles.clear();
3622 return;
3623 }
3624 }
3625 self.handles.clear();
3626 }
3627}
3628
3629enum TaskExecutor<M: Send + 'static> {
3630 Spawned(SpawnTaskExecutor<M>),
3631 Queued(EffectQueue<M>),
3632 #[cfg(feature = "asupersync-executor")]
3633 Asupersync(AsupersyncTaskExecutor<M>),
3634}
3635
3636impl<M: Send + 'static> TaskExecutor<M> {
3637 fn new(
3638 config: &EffectQueueConfig,
3639 result_sender: mpsc::Sender<M>,
3640 evidence_sink: Option<EvidenceSink>,
3641 ) -> io::Result<Self> {
3642 let executor = match config.backend {
3643 TaskExecutorBackend::Spawned => {
3644 Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3645 }
3646 TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3647 config.clone(),
3648 result_sender,
3649 evidence_sink.clone(),
3650 )?),
3651 #[cfg(feature = "asupersync-executor")]
3652 TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3653 result_sender,
3654 evidence_sink.clone(),
3655 )?),
3656 };
3657
3658 emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3659 Ok(executor)
3660 }
3661
3662 fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3663 match self {
3664 Self::Spawned(executor) => executor.submit(task),
3665 Self::Queued(queue) => queue.enqueue(spec, task),
3666 #[cfg(feature = "asupersync-executor")]
3667 Self::Asupersync(executor) => executor.submit(task),
3668 }
3669 }
3670
3671 fn reap_finished(&mut self) {
3672 match self {
3673 Self::Spawned(executor) => executor.reap_finished(),
3674 #[cfg(feature = "asupersync-executor")]
3675 Self::Asupersync(executor) => executor.reap_finished(),
3676 Self::Queued(_) => {}
3677 }
3678 }
3679
3680 fn shutdown(&mut self) {
3681 match self {
3682 Self::Spawned(executor) => executor.shutdown(),
3683 Self::Queued(queue) => queue.shutdown(),
3684 #[cfg(feature = "asupersync-executor")]
3685 Self::Asupersync(executor) => executor.shutdown(),
3686 }
3687 }
3688
3689 #[cfg(test)]
3690 fn kind_name(&self) -> &'static str {
3691 self.kind_name_for_logs()
3692 }
3693
3694 fn kind_name_for_logs(&self) -> &'static str {
3695 match self {
3696 Self::Spawned(_) => "spawned",
3697 Self::Queued(_) => "queued",
3698 #[cfg(feature = "asupersync-executor")]
3699 Self::Asupersync(_) => "asupersync",
3700 }
3701 }
3702}
3703
3704fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3705 let Some(sink) = sink else {
3706 return;
3707 };
3708 let _ = sink.write_jsonl(&format!(
3709 r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3710 ));
3711}
3712
3713fn emit_task_executor_completion_evidence(
3714 sink: Option<&EvidenceSink>,
3715 backend: &str,
3716 duration_us: u64,
3717) {
3718 let Some(sink) = sink else {
3719 return;
3720 };
3721 let _ = sink.write_jsonl(&format!(
3722 r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3723 ));
3724}
3725
3726fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3727 let Some(sink) = sink else {
3728 return;
3729 };
3730 let escaped = panic_msg
3731 .replace('\\', "\\\\")
3732 .replace('"', "\\\"")
3733 .replace('\n', "\\n")
3734 .replace('\r', "\\r")
3735 .replace('\t', "\\t");
3736 let _ = sink.write_jsonl(&format!(
3737 r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3738 ));
3739}
3740
3741fn emit_task_executor_backpressure_evidence(
3742 sink: Option<&EvidenceSink>,
3743 backend: &str,
3744 action: &str,
3745 queue_length: usize,
3746 max_queue_size: usize,
3747 total_rejected: u64,
3748) {
3749 let Some(sink) = sink else {
3750 return;
3751 };
3752 let _ = sink.write_jsonl(&format!(
3753 r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3754 ));
3755}
3756
3757fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3758 if let Some(s) = payload.downcast_ref::<&str>() {
3759 (*s).to_owned()
3760 } else if let Some(s) = payload.downcast_ref::<String>() {
3761 s.clone()
3762 } else {
3763 "unknown panic payload".to_owned()
3764 }
3765}
3766
3767fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3768 #[cfg(feature = "tracing")]
3769 tracing::error!(
3770 executor_backend = backend,
3771 panic_msg,
3772 "task executor task panicked"
3773 );
3774 #[cfg(not(feature = "tracing"))]
3775 eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3776}
3777
3778fn run_task_closure<M: Send + 'static>(
3779 task: Box<dyn FnOnce() -> M + Send>,
3780 backend: &str,
3781 evidence_sink: Option<&EvidenceSink>,
3782 result_sender: &mpsc::Sender<M>,
3783) -> bool {
3784 let start = Instant::now();
3785 match panic::catch_unwind(AssertUnwindSafe(task)) {
3786 Ok(msg) => {
3787 let duration_us = start.elapsed().as_micros() as u64;
3788 tracing::debug!(
3789 target: "ftui.effect",
3790 command_type = "task",
3791 executor_backend = backend,
3792 duration_us = duration_us,
3793 effect_duration_us = duration_us,
3794 "task effect completed"
3795 );
3796 emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3797 let _ = result_sender.send(msg);
3798 true
3799 }
3800 Err(payload) => {
3801 let panic_msg = panic_payload_message(payload);
3802 log_task_executor_panic(backend, &panic_msg);
3803 emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3804 false
3805 }
3806 }
3807}
3808
3809fn effect_queue_loop<M: Send + 'static>(
3810 config: EffectQueueConfig,
3811 rx: mpsc::Receiver<EffectCommand<M>>,
3812 result_sender: mpsc::Sender<M>,
3813 evidence_sink: Option<EvidenceSink>,
3814) {
3815 let mut scheduler = QueueingScheduler::new(config.scheduler);
3816 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3817 let mut shutdown_requested = false;
3818 let max_depth = config.max_queue_depth;
3819
3820 loop {
3821 if tasks.is_empty() {
3822 if shutdown_requested {
3823 return;
3824 }
3825 match rx.recv() {
3826 Ok(cmd) => {
3827 if matches!(
3828 handle_effect_command(
3829 cmd,
3830 &mut scheduler,
3831 &mut tasks,
3832 &result_sender,
3833 evidence_sink.as_ref(),
3834 max_depth,
3835 ),
3836 EffectLoopControl::ShutdownRequested
3837 ) {
3838 shutdown_requested = true;
3839 }
3840 }
3841 Err(_) => return,
3842 }
3843 }
3844
3845 while let Ok(cmd) = rx.try_recv() {
3846 if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3847 crate::effect_system::record_queue_drop("post_shutdown");
3848 continue;
3849 }
3850 if matches!(
3851 handle_effect_command(
3852 cmd,
3853 &mut scheduler,
3854 &mut tasks,
3855 &result_sender,
3856 evidence_sink.as_ref(),
3857 max_depth,
3858 ),
3859 EffectLoopControl::ShutdownRequested
3860 ) {
3861 shutdown_requested = true;
3862 }
3863 }
3864
3865 if tasks.is_empty() {
3866 if shutdown_requested {
3867 return;
3868 }
3869 continue;
3870 }
3871
3872 let Some(job) = scheduler.peek_next().cloned() else {
3873 continue;
3874 };
3875
3876 if let Some(ref sink) = evidence_sink {
3877 let evidence = scheduler.evidence();
3878 let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3879 }
3880
3881 let completed = scheduler.tick(job.remaining_time);
3882 for job_id in completed {
3883 if let Some(task) = tasks.remove(&job_id) {
3884 let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3885 crate::effect_system::record_queue_processed();
3886 }
3887 }
3888 }
3889}
3890
3891fn handle_effect_command<M: Send + 'static>(
3892 cmd: EffectCommand<M>,
3893 scheduler: &mut QueueingScheduler,
3894 tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3895 result_sender: &mpsc::Sender<M>,
3896 evidence_sink: Option<&EvidenceSink>,
3897 max_depth: usize,
3898) -> EffectLoopControl {
3899 match cmd {
3900 EffectCommand::Enqueue(spec, task) => {
3901 if max_depth > 0 && tasks.len() >= max_depth {
3903 crate::effect_system::record_queue_drop("backpressure");
3904 return EffectLoopControl::Continue;
3905 }
3906 let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3907 WeightSource::Default
3908 } else {
3909 WeightSource::Explicit
3910 };
3911 let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3912 EstimateSource::Default
3913 } else {
3914 EstimateSource::Explicit
3915 };
3916 let id = scheduler.submit_with_sources(
3917 spec.weight,
3918 spec.estimate_ms,
3919 weight_source,
3920 estimate_source,
3921 spec.name,
3922 );
3923 if let Some(id) = id {
3924 tasks.insert(id, task);
3925 crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3926 } else {
3927 let stats = scheduler.stats();
3928 emit_task_executor_backpressure_evidence(
3929 evidence_sink,
3930 "queued",
3931 "inline_fallback",
3932 stats.queue_length,
3933 scheduler.max_queue_size(),
3934 stats.total_rejected,
3935 );
3936 let _ =
3937 run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3938 }
3939 EffectLoopControl::Continue
3940 }
3941 EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3942 }
3943}
3944
3945#[derive(Debug, Clone)]
3953pub struct InlineAutoRemeasureConfig {
3954 pub voi: VoiConfig,
3956 pub change_threshold_rows: u16,
3958}
3959
3960impl Default for InlineAutoRemeasureConfig {
3961 fn default() -> Self {
3962 Self {
3963 voi: VoiConfig {
3964 prior_alpha: 1.0,
3966 prior_beta: 9.0,
3967 max_interval_ms: 1000,
3969 min_interval_ms: 100,
3971 max_interval_events: 0,
3973 min_interval_events: 0,
3974 sample_cost: 0.08,
3976 ..VoiConfig::default()
3977 },
3978 change_threshold_rows: 1,
3979 }
3980 }
3981}
3982
3983#[derive(Debug)]
3984struct InlineAutoRemeasureState {
3985 config: InlineAutoRemeasureConfig,
3986 sampler: VoiSampler,
3987}
3988
3989impl InlineAutoRemeasureState {
3990 fn new(config: InlineAutoRemeasureConfig) -> Self {
3991 let sampler = VoiSampler::new(config.voi.clone());
3992 Self { config, sampler }
3993 }
3994
3995 fn reset(&mut self) {
3996 self.sampler = VoiSampler::new(self.config.voi.clone());
3997 }
3998}
3999
4000#[derive(Debug, Clone)]
4001struct ConformalEvidence {
4002 bucket_key: String,
4003 n_b: usize,
4004 alpha: f64,
4005 q_b: f64,
4006 y_hat: f64,
4007 upper_us: f64,
4008 risk: bool,
4009 fallback_level: u8,
4010 window_size: usize,
4011 reset_count: u64,
4012}
4013
4014impl ConformalEvidence {
4015 fn from_prediction(prediction: &ConformalPrediction) -> Self {
4016 let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4017 Self {
4018 bucket_key: prediction.bucket.to_string(),
4019 n_b: prediction.sample_count,
4020 alpha,
4021 q_b: prediction.quantile,
4022 y_hat: prediction.y_hat,
4023 upper_us: prediction.upper_us,
4024 risk: prediction.risk,
4025 fallback_level: prediction.fallback_level,
4026 window_size: prediction.window_size,
4027 reset_count: prediction.reset_count,
4028 }
4029 }
4030}
4031
4032#[derive(Debug, Clone)]
4033struct BudgetDecisionEvidence {
4034 frame_idx: u64,
4035 decision: BudgetDecision,
4036 controller_decision: BudgetDecision,
4037 degradation_before: DegradationLevel,
4038 degradation_after: DegradationLevel,
4039 frame_time_us: f64,
4040 budget_us: f64,
4041 pid_output: f64,
4042 pid_p: f64,
4043 pid_i: f64,
4044 pid_d: f64,
4045 e_value: f64,
4046 frames_observed: u32,
4047 frames_since_change: u32,
4048 in_warmup: bool,
4049 controller_reason: BudgetDecisionReason,
4050 load_governor: LoadGovernorSnapshot,
4051 conformal: Option<ConformalEvidence>,
4052}
4053
4054impl BudgetDecisionEvidence {
4055 fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4056 if after > before {
4057 BudgetDecision::Degrade
4058 } else if after < before {
4059 BudgetDecision::Upgrade
4060 } else {
4061 BudgetDecision::Hold
4062 }
4063 }
4064
4065 #[must_use]
4066 fn to_jsonl(&self) -> String {
4067 let conformal = self.conformal.as_ref();
4068 let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4069 let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4070 let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4071 let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4072 let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4073 let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4074 let risk = Self::opt_bool(conformal.map(|c| c.risk));
4075 let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4076 let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4077 let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4078 let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4079
4080 format!(
4081 r#"{{"event":"budget_decision","frame_idx":{},"decision":"{}","decision_controller":"{}","decision_controller_reason":"{}","degradation_before":"{}","degradation_after":"{}","frame_time_us":{:.6},"budget_us":{:.6},"pid_output":{:.6},"pid_p":{:.6},"pid_i":{:.6},"pid_d":{:.6},"e_value":{:.6},"frames_observed":{},"frames_since_change":{},"in_warmup":{},"runtime_mode":"{}","runtime_mode_before":"{}","pressure_class":"{}","work_disposition":"{}","governor_reason":"{}","governor_transition":{},"strict_semantics_preserved":{},"queue_in_flight":{},"queue_max_depth":{},"queue_dropped_delta":{},"resize_coalescing_active":{},"recovery_intervals_observed":{},"recovery_intervals_required":{},"deferred_work_total":{},"coalesced_work_total":{},"dropped_work_total":{},"bucket_key":{},"n_b":{},"alpha":{},"q_b":{},"y_hat":{},"upper_us":{},"risk":{},"fallback_level":{},"window_size":{},"reset_count":{}}}"#,
4082 self.frame_idx,
4083 self.decision.as_str(),
4084 self.controller_decision.as_str(),
4085 self.controller_reason.as_str(),
4086 self.degradation_before.as_str(),
4087 self.degradation_after.as_str(),
4088 self.frame_time_us,
4089 self.budget_us,
4090 self.pid_output,
4091 self.pid_p,
4092 self.pid_i,
4093 self.pid_d,
4094 self.e_value,
4095 self.frames_observed,
4096 self.frames_since_change,
4097 self.in_warmup,
4098 self.load_governor.mode.as_str(),
4099 self.load_governor.mode_before.as_str(),
4100 self.load_governor.pressure_class.as_str(),
4101 self.load_governor.disposition.as_str(),
4102 self.load_governor.reason_code,
4103 self.load_governor.transition,
4104 self.load_governor.strict_semantics_preserved,
4105 self.load_governor.queue_in_flight,
4106 queue_max_depth,
4107 self.load_governor.queue_dropped_delta,
4108 self.load_governor.resize_coalescing_active,
4109 self.load_governor.recovery_intervals_observed,
4110 self.load_governor.recovery_intervals_required,
4111 self.load_governor.deferred_work_total,
4112 self.load_governor.coalesced_work_total,
4113 self.load_governor.dropped_work_total,
4114 bucket_key,
4115 n_b,
4116 alpha,
4117 q_b,
4118 y_hat,
4119 upper_us,
4120 risk,
4121 fallback_level,
4122 window_size,
4123 reset_count
4124 )
4125 }
4126
4127 fn opt_f64(value: Option<f64>) -> String {
4128 value
4129 .map(|v| format!("{v:.6}"))
4130 .unwrap_or_else(|| "null".to_string())
4131 }
4132
4133 fn opt_u64(value: Option<u64>) -> String {
4134 value
4135 .map(|v| v.to_string())
4136 .unwrap_or_else(|| "null".to_string())
4137 }
4138
4139 fn opt_u8(value: Option<u8>) -> String {
4140 value
4141 .map(|v| v.to_string())
4142 .unwrap_or_else(|| "null".to_string())
4143 }
4144
4145 fn opt_usize(value: Option<usize>) -> String {
4146 value
4147 .map(|v| v.to_string())
4148 .unwrap_or_else(|| "null".to_string())
4149 }
4150
4151 fn opt_bool(value: Option<bool>) -> String {
4152 value
4153 .map(|v| v.to_string())
4154 .unwrap_or_else(|| "null".to_string())
4155 }
4156
4157 fn opt_str(value: Option<&str>) -> String {
4158 value
4159 .map(|v| {
4160 format!(
4161 "\"{}\"",
4162 v.replace('\\', "\\\\")
4163 .replace('"', "\\\"")
4164 .replace('\n', "\\n")
4165 .replace('\r', "\\r")
4166 .replace('\t', "\\t")
4167 )
4168 })
4169 .unwrap_or_else(|| "null".to_string())
4170 }
4171}
4172
4173#[derive(Debug, Clone)]
4174struct FairnessConfigEvidence {
4175 enabled: bool,
4176 input_priority_threshold_ms: u64,
4177 dominance_threshold: u32,
4178 fairness_threshold: f64,
4179}
4180
4181impl FairnessConfigEvidence {
4182 #[must_use]
4183 fn to_jsonl(&self) -> String {
4184 format!(
4185 r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4186 self.enabled,
4187 self.input_priority_threshold_ms,
4188 self.dominance_threshold,
4189 self.fairness_threshold
4190 )
4191 }
4192}
4193
4194#[derive(Debug, Clone)]
4195struct FairnessDecisionEvidence {
4196 frame_idx: u64,
4197 decision: &'static str,
4198 reason: &'static str,
4199 pending_input_latency_ms: Option<u64>,
4200 jain_index: f64,
4201 resize_dominance_count: u32,
4202 dominance_threshold: u32,
4203 fairness_threshold: f64,
4204 input_priority_threshold_ms: u64,
4205}
4206
4207impl FairnessDecisionEvidence {
4208 #[must_use]
4209 fn to_jsonl(&self) -> String {
4210 let pending_latency = self
4211 .pending_input_latency_ms
4212 .map(|v| v.to_string())
4213 .unwrap_or_else(|| "null".to_string());
4214 format!(
4215 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":{}}}"#,
4216 self.frame_idx,
4217 self.decision,
4218 self.reason,
4219 pending_latency,
4220 self.jain_index,
4221 self.resize_dominance_count,
4222 self.dominance_threshold,
4223 self.fairness_threshold,
4224 self.input_priority_threshold_ms
4225 )
4226 }
4227}
4228
4229#[derive(Debug, Clone)]
4230struct WidgetRefreshEntry {
4231 widget_id: u64,
4232 essential: bool,
4233 starved: bool,
4234 value: f32,
4235 cost_us: f32,
4236 score: f32,
4237 staleness_ms: u64,
4238}
4239
4240impl WidgetRefreshEntry {
4241 fn to_json(&self) -> String {
4242 format!(
4243 r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4244 self.widget_id,
4245 self.cost_us,
4246 self.value,
4247 self.score,
4248 self.essential,
4249 self.starved,
4250 self.staleness_ms
4251 )
4252 }
4253}
4254
4255#[derive(Debug, Clone)]
4256struct WidgetRefreshPlan {
4257 frame_idx: u64,
4258 budget_us: f64,
4259 degradation: DegradationLevel,
4260 essentials_cost_us: f64,
4261 selected_cost_us: f64,
4262 selected_value: f64,
4263 signal_count: usize,
4264 selected: Vec<WidgetRefreshEntry>,
4265 skipped_count: usize,
4266 skipped_starved: usize,
4267 starved_selected: usize,
4268 over_budget: bool,
4269}
4270
4271impl WidgetRefreshPlan {
4272 fn new() -> Self {
4273 Self {
4274 frame_idx: 0,
4275 budget_us: 0.0,
4276 degradation: DegradationLevel::Full,
4277 essentials_cost_us: 0.0,
4278 selected_cost_us: 0.0,
4279 selected_value: 0.0,
4280 signal_count: 0,
4281 selected: Vec::new(),
4282 skipped_count: 0,
4283 skipped_starved: 0,
4284 starved_selected: 0,
4285 over_budget: false,
4286 }
4287 }
4288
4289 fn clear(&mut self) {
4290 self.frame_idx = 0;
4291 self.budget_us = 0.0;
4292 self.degradation = DegradationLevel::Full;
4293 self.essentials_cost_us = 0.0;
4294 self.selected_cost_us = 0.0;
4295 self.selected_value = 0.0;
4296 self.signal_count = 0;
4297 self.selected.clear();
4298 self.skipped_count = 0;
4299 self.skipped_starved = 0;
4300 self.starved_selected = 0;
4301 self.over_budget = false;
4302 }
4303
4304 fn as_budget(&self) -> WidgetBudget {
4305 if self.signal_count == 0 {
4306 return WidgetBudget::allow_all();
4307 }
4308 let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4309 WidgetBudget::allow_only(ids)
4310 }
4311
4312 fn recompute(
4313 &mut self,
4314 frame_idx: u64,
4315 budget_us: f64,
4316 degradation: DegradationLevel,
4317 signals: &[WidgetSignal],
4318 config: &WidgetRefreshConfig,
4319 ) {
4320 self.clear();
4321 self.frame_idx = frame_idx;
4322 self.budget_us = budget_us;
4323 self.degradation = degradation;
4324
4325 if !config.enabled || signals.is_empty() {
4326 return;
4327 }
4328
4329 self.signal_count = signals.len();
4330 let mut essentials_cost = 0.0f64;
4331 let mut selected_cost = 0.0f64;
4332 let mut selected_value = 0.0f64;
4333
4334 let staleness_window = config.staleness_window_ms.max(1) as f32;
4335 let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4336
4337 for signal in signals {
4338 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4339 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4340 let mut value = config.weight_priority * signal.priority
4341 + config.weight_staleness * staleness_score
4342 + config.weight_focus * signal.focus_boost
4343 + config.weight_interaction * signal.interaction_boost;
4344 if starved {
4345 value += config.starve_boost;
4346 }
4347 let raw_cost = if signal.recent_cost_us > 0.0 {
4348 signal.recent_cost_us
4349 } else {
4350 signal.cost_estimate_us
4351 };
4352 let cost_us = raw_cost.max(config.min_cost_us);
4353 let score = if cost_us > 0.0 {
4354 value / cost_us
4355 } else {
4356 value
4357 };
4358
4359 let entry = WidgetRefreshEntry {
4360 widget_id: signal.widget_id,
4361 essential: signal.essential,
4362 starved,
4363 value,
4364 cost_us,
4365 score,
4366 staleness_ms: signal.staleness_ms,
4367 };
4368
4369 if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4370 self.skipped_count += 1;
4371 if starved {
4372 self.skipped_starved = self.skipped_starved.saturating_add(1);
4373 }
4374 continue;
4375 }
4376
4377 if signal.essential {
4378 essentials_cost += cost_us as f64;
4379 selected_cost += cost_us as f64;
4380 selected_value += value as f64;
4381 if starved {
4382 self.starved_selected = self.starved_selected.saturating_add(1);
4383 }
4384 self.selected.push(entry);
4385 } else {
4386 candidates.push(entry);
4387 }
4388 }
4389
4390 let mut remaining = budget_us - selected_cost;
4391
4392 if degradation < DegradationLevel::EssentialOnly {
4393 let nonessential_total = candidates.len();
4394 let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4395 let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4396 let min_nonessential_selected = if enforce_drop_rate {
4397 let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4398 ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4399 } else {
4400 0
4401 };
4402
4403 candidates.sort_by(|a, b| {
4404 b.starved
4405 .cmp(&a.starved)
4406 .then_with(|| b.score.total_cmp(&a.score))
4407 .then_with(|| b.value.total_cmp(&a.value))
4408 .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4409 .then_with(|| a.widget_id.cmp(&b.widget_id))
4410 });
4411
4412 let mut forced_starved = 0usize;
4413 let mut nonessential_selected = 0usize;
4414 let mut skipped_candidates = if enforce_drop_rate {
4415 Vec::with_capacity(candidates.len())
4416 } else {
4417 Vec::new()
4418 };
4419
4420 for entry in candidates.into_iter() {
4421 if entry.starved && forced_starved >= config.max_starved_per_frame {
4422 self.skipped_count += 1;
4423 self.skipped_starved = self.skipped_starved.saturating_add(1);
4424 if enforce_drop_rate {
4425 skipped_candidates.push(entry);
4426 }
4427 continue;
4428 }
4429
4430 if remaining >= entry.cost_us as f64 {
4431 remaining -= entry.cost_us as f64;
4432 selected_cost += entry.cost_us as f64;
4433 selected_value += entry.value as f64;
4434 if entry.starved {
4435 self.starved_selected = self.starved_selected.saturating_add(1);
4436 forced_starved += 1;
4437 }
4438 nonessential_selected += 1;
4439 self.selected.push(entry);
4440 } else if entry.starved
4441 && forced_starved < config.max_starved_per_frame
4442 && nonessential_selected == 0
4443 {
4444 selected_cost += entry.cost_us as f64;
4446 selected_value += entry.value as f64;
4447 self.starved_selected = self.starved_selected.saturating_add(1);
4448 forced_starved += 1;
4449 nonessential_selected += 1;
4450 self.selected.push(entry);
4451 } else {
4452 self.skipped_count += 1;
4453 if entry.starved {
4454 self.skipped_starved = self.skipped_starved.saturating_add(1);
4455 }
4456 if enforce_drop_rate {
4457 skipped_candidates.push(entry);
4458 }
4459 }
4460 }
4461
4462 if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4463 for entry in skipped_candidates.into_iter() {
4464 if nonessential_selected >= min_nonessential_selected {
4465 break;
4466 }
4467 if entry.starved && forced_starved >= config.max_starved_per_frame {
4468 continue;
4469 }
4470 selected_cost += entry.cost_us as f64;
4471 selected_value += entry.value as f64;
4472 if entry.starved {
4473 self.starved_selected = self.starved_selected.saturating_add(1);
4474 forced_starved += 1;
4475 self.skipped_starved = self.skipped_starved.saturating_sub(1);
4476 }
4477 self.skipped_count = self.skipped_count.saturating_sub(1);
4478 nonessential_selected += 1;
4479 self.selected.push(entry);
4480 }
4481 }
4482 }
4483
4484 self.essentials_cost_us = essentials_cost;
4485 self.selected_cost_us = selected_cost;
4486 self.selected_value = selected_value;
4487 self.over_budget = selected_cost > budget_us;
4488 }
4489
4490 #[must_use]
4491 fn to_jsonl(&self) -> String {
4492 let mut out = String::with_capacity(256 + self.selected.len() * 96);
4493 out.push_str(r#"{"event":"widget_refresh""#);
4494 out.push_str(&format!(
4495 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":{}"#,
4496 self.frame_idx,
4497 self.budget_us,
4498 self.degradation.as_str(),
4499 self.essentials_cost_us,
4500 self.selected_cost_us,
4501 self.selected_value,
4502 self.selected.len(),
4503 self.skipped_count,
4504 self.starved_selected,
4505 self.skipped_starved,
4506 self.over_budget
4507 ));
4508 out.push_str(r#","selected":["#);
4509 for (i, entry) in self.selected.iter().enumerate() {
4510 if i > 0 {
4511 out.push(',');
4512 }
4513 out.push_str(&entry.to_json());
4514 }
4515 out.push_str("]}");
4516 out
4517 }
4518}
4519
4520#[cfg(feature = "crossterm-compat")]
4525pub struct CrosstermEventSource {
4531 session: TerminalSession,
4532 features: BackendFeatures,
4533}
4534
4535#[cfg(feature = "crossterm-compat")]
4536impl CrosstermEventSource {
4537 pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4539 Self {
4540 session,
4541 features: initial_features,
4542 }
4543 }
4544}
4545
4546#[cfg(feature = "crossterm-compat")]
4547impl BackendEventSource for CrosstermEventSource {
4548 type Error = io::Error;
4549
4550 fn size(&self) -> Result<(u16, u16), io::Error> {
4551 self.session.size()
4552 }
4553
4554 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4555 if features.mouse_capture != self.features.mouse_capture {
4556 self.session.set_mouse_capture(features.mouse_capture)?;
4557 }
4558 self.features = features;
4562 Ok(())
4563 }
4564
4565 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4566 self.session.poll_event(timeout)
4567 }
4568
4569 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4570 self.session.read_event()
4571 }
4572}
4573
4574pub struct HeadlessEventSource {
4584 width: u16,
4585 height: u16,
4586 features: BackendFeatures,
4587}
4588
4589impl HeadlessEventSource {
4590 pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4592 Self {
4593 width,
4594 height,
4595 features,
4596 }
4597 }
4598}
4599
4600impl BackendEventSource for HeadlessEventSource {
4601 type Error = io::Error;
4602
4603 fn size(&self) -> Result<(u16, u16), io::Error> {
4604 Ok((self.width, self.height))
4605 }
4606
4607 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4608 self.features = features;
4609 Ok(())
4610 }
4611
4612 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4613 Ok(false)
4614 }
4615
4616 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4617 Ok(None)
4618 }
4619}
4620
4621pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4627 model: M,
4629 writer: TerminalWriter<W>,
4631 events: E,
4633 backend_features: BackendFeatures,
4635 running: bool,
4637 tick_rate: Option<Duration>,
4639 executed_cmd_count: usize,
4641 last_tick: Instant,
4643 dirty: bool,
4645 frame_idx: u64,
4647 tick_count: u64,
4649 widget_signals: Vec<WidgetSignal>,
4651 widget_refresh_config: WidgetRefreshConfig,
4653 widget_refresh_plan: WidgetRefreshPlan,
4655 width: u16,
4657 height: u16,
4659 forced_size: Option<(u16, u16)>,
4661 poll_timeout: Duration,
4663 intercept_signals: bool,
4665 immediate_drain_config: ImmediateDrainConfig,
4667 immediate_drain_stats: ImmediateDrainStats,
4669 budget: RenderBudget,
4671 load_governor: LoadGovernorState,
4673 conformal_predictor: Option<ConformalPredictor>,
4675 last_frame_time_us: Option<f64>,
4677 last_update_us: Option<u64>,
4679 frame_timing: Option<FrameTimingConfig>,
4681 locale_context: LocaleContext,
4683 locale_version: u64,
4685 resize_coalescer: ResizeCoalescer,
4687 evidence_sink: Option<EvidenceSink>,
4689 fairness_config_logged: bool,
4691 resize_behavior: ResizeBehavior,
4693 fairness_guard: InputFairnessGuard,
4695 event_recorder: Option<EventRecorder>,
4697 subscriptions: SubscriptionManager<M::Message>,
4699 #[cfg(test)]
4701 task_sender: std::sync::mpsc::Sender<M::Message>,
4702 task_receiver: std::sync::mpsc::Receiver<M::Message>,
4704 task_executor: TaskExecutor<M::Message>,
4706 state_registry: Option<std::sync::Arc<StateRegistry>>,
4708 persistence_config: PersistenceConfig,
4710 last_checkpoint: Instant,
4712 inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4714 frame_arena: FrameArena,
4716 guardrails: FrameGuardrails,
4718 tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4720 last_active_screen_for_strategy: Option<String>,
4722}
4723
4724#[cfg(feature = "crossterm-compat")]
4725impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4726 pub fn new(model: M) -> io::Result<Self>
4728 where
4729 M::Message: Send + 'static,
4730 {
4731 Self::with_config(model, ProgramConfig::default())
4732 }
4733
4734 pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4736 where
4737 M::Message: Send + 'static,
4738 {
4739 let resolved_lane = config.runtime_lane.resolve();
4740 let effect_queue_config = config.resolved_effect_queue_config();
4741 let capabilities = TerminalCapabilities::with_overrides();
4742 let mouse_capture = config.resolved_mouse_capture();
4743 let requested_features = BackendFeatures {
4744 mouse_capture,
4745 bracketed_paste: config.bracketed_paste,
4746 focus_events: config.focus_reporting,
4747 kitty_keyboard: config.kitty_keyboard,
4748 };
4749 let initial_features =
4750 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4751 let session = TerminalSession::new(SessionOptions {
4752 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4753 mouse_capture: initial_features.mouse_capture,
4754 bracketed_paste: initial_features.bracketed_paste,
4755 focus_events: initial_features.focus_events,
4756 kitty_keyboard: initial_features.kitty_keyboard,
4757 intercept_signals: config.intercept_signals,
4758 })?;
4759 let events = CrosstermEventSource::new(session, initial_features);
4760
4761 let mut writer = TerminalWriter::with_diff_config(
4762 io::stdout(),
4763 config.screen_mode,
4764 config.ui_anchor,
4765 capabilities,
4766 config.diff_config.clone(),
4767 );
4768
4769 let frame_timing = config.frame_timing.clone();
4770 writer.set_timing_enabled(frame_timing.is_some());
4771
4772 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4773 if let Some(ref sink) = evidence_sink {
4774 writer = writer.with_evidence_sink(sink.clone());
4775 }
4776
4777 let render_trace = crate::RenderTraceRecorder::from_config(
4778 &config.render_trace,
4779 crate::RenderTraceContext {
4780 capabilities: writer.capabilities(),
4781 diff_config: config.diff_config.clone(),
4782 resize_config: config.resize_coalescer.clone(),
4783 conformal_config: config.conformal_config.clone(),
4784 },
4785 )?;
4786 if let Some(recorder) = render_trace {
4787 writer = writer.with_render_trace(recorder);
4788 }
4789
4790 let (w, h) = config
4792 .forced_size
4793 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4794 let width = w.max(1);
4795 let height = h.max(1);
4796 writer.set_size(width, height);
4797
4798 let budget = render_budget_from_program_config(&config);
4799 let load_governor = LoadGovernorState::new(
4800 config.load_governor.clone(),
4801 effect_queue_config.max_queue_depth,
4802 );
4803 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4804 let locale_context = config.locale_context.clone();
4805 let locale_version = locale_context.version();
4806 let mut resize_coalescer =
4807 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4808 .with_screen_mode(config.screen_mode);
4809 if let Some(ref sink) = evidence_sink {
4810 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4811 }
4812 let subscriptions = SubscriptionManager::new();
4813 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4814 let inline_auto_remeasure = config
4815 .inline_auto_remeasure
4816 .clone()
4817 .map(InlineAutoRemeasureState::new);
4818 let task_executor = TaskExecutor::new(
4819 &effect_queue_config,
4820 task_sender.clone(),
4821 evidence_sink.clone(),
4822 )?;
4823 let guardrails = FrameGuardrails::new(config.guardrails);
4824
4825 tracing::info!(
4827 target: "ftui.runtime",
4828 requested_lane = config.runtime_lane.label(),
4829 resolved_lane = resolved_lane.label(),
4830 rollout_policy = config.rollout_policy.label(),
4831 "runtime startup: lane={}, rollout={}",
4832 resolved_lane.label(),
4833 config.rollout_policy.label(),
4834 );
4835
4836 Ok(Self {
4837 model,
4838 writer,
4839 events,
4840 backend_features: initial_features,
4841 running: true,
4842 tick_rate: None,
4843 executed_cmd_count: 0,
4844 last_tick: Instant::now(),
4845 dirty: true,
4846 frame_idx: 0,
4847 tick_count: 0,
4848 widget_signals: Vec::new(),
4849 widget_refresh_config: config.widget_refresh,
4850 widget_refresh_plan: WidgetRefreshPlan::new(),
4851 width,
4852 height,
4853 forced_size: config.forced_size,
4854 poll_timeout: config.poll_timeout,
4855 intercept_signals: config.intercept_signals,
4856 immediate_drain_config: config.immediate_drain,
4857 immediate_drain_stats: ImmediateDrainStats::default(),
4858 budget,
4859 load_governor,
4860 conformal_predictor,
4861 last_frame_time_us: None,
4862 last_update_us: None,
4863 frame_timing,
4864 locale_context,
4865 locale_version,
4866 resize_coalescer,
4867 evidence_sink,
4868 fairness_config_logged: false,
4869 resize_behavior: config.resize_behavior,
4870 fairness_guard: InputFairnessGuard::new(),
4871 event_recorder: None,
4872 subscriptions,
4873 #[cfg(test)]
4874 task_sender,
4875 task_receiver,
4876 task_executor,
4877 state_registry: config.persistence.registry.clone(),
4878 persistence_config: config.persistence,
4879 last_checkpoint: Instant::now(),
4880 inline_auto_remeasure,
4881 frame_arena: FrameArena::default(),
4882 guardrails,
4883 tick_strategy: config
4884 .tick_strategy
4885 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4886 last_active_screen_for_strategy: None,
4887 })
4888 }
4889}
4890
4891impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4892 pub fn with_event_source(
4899 model: M,
4900 events: E,
4901 backend_features: BackendFeatures,
4902 writer: TerminalWriter<W>,
4903 config: ProgramConfig,
4904 ) -> io::Result<Self>
4905 where
4906 M::Message: Send + 'static,
4907 {
4908 let effect_queue_config = config.resolved_effect_queue_config();
4909 let (width, height) = config
4910 .forced_size
4911 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4912 let width = width.max(1);
4913 let height = height.max(1);
4914
4915 let mut writer = writer;
4916 writer.set_size(width, height);
4917
4918 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4919 if let Some(ref sink) = evidence_sink {
4920 writer = writer.with_evidence_sink(sink.clone());
4921 }
4922
4923 let render_trace = crate::RenderTraceRecorder::from_config(
4924 &config.render_trace,
4925 crate::RenderTraceContext {
4926 capabilities: writer.capabilities(),
4927 diff_config: config.diff_config.clone(),
4928 resize_config: config.resize_coalescer.clone(),
4929 conformal_config: config.conformal_config.clone(),
4930 },
4931 )?;
4932 if let Some(recorder) = render_trace {
4933 writer = writer.with_render_trace(recorder);
4934 }
4935
4936 let frame_timing = config.frame_timing.clone();
4937 writer.set_timing_enabled(frame_timing.is_some());
4938
4939 let budget = render_budget_from_program_config(&config);
4940 let load_governor = LoadGovernorState::new(
4941 config.load_governor.clone(),
4942 effect_queue_config.max_queue_depth,
4943 );
4944 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4945 let locale_context = config.locale_context.clone();
4946 let locale_version = locale_context.version();
4947 let mut resize_coalescer =
4948 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4949 .with_screen_mode(config.screen_mode);
4950 if let Some(ref sink) = evidence_sink {
4951 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4952 }
4953 let subscriptions = SubscriptionManager::new();
4954 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4955 let inline_auto_remeasure = config
4956 .inline_auto_remeasure
4957 .clone()
4958 .map(InlineAutoRemeasureState::new);
4959 let task_executor = TaskExecutor::new(
4960 &effect_queue_config,
4961 task_sender.clone(),
4962 evidence_sink.clone(),
4963 )?;
4964
4965 let guardrails = FrameGuardrails::new(config.guardrails);
4966
4967 Ok(Self {
4968 model,
4969 writer,
4970 events,
4971 backend_features,
4972 running: true,
4973 tick_rate: None,
4974 executed_cmd_count: 0,
4975 last_tick: Instant::now(),
4976 dirty: true,
4977 frame_idx: 0,
4978 tick_count: 0,
4979 widget_signals: Vec::new(),
4980 widget_refresh_config: config.widget_refresh,
4981 widget_refresh_plan: WidgetRefreshPlan::new(),
4982 width,
4983 height,
4984 forced_size: config.forced_size,
4985 poll_timeout: config.poll_timeout,
4986 intercept_signals: config.intercept_signals,
4987 immediate_drain_config: config.immediate_drain,
4988 immediate_drain_stats: ImmediateDrainStats::default(),
4989 budget,
4990 load_governor,
4991 conformal_predictor,
4992 last_frame_time_us: None,
4993 last_update_us: None,
4994 frame_timing,
4995 locale_context,
4996 locale_version,
4997 resize_coalescer,
4998 evidence_sink,
4999 fairness_config_logged: false,
5000 resize_behavior: config.resize_behavior,
5001 fairness_guard: InputFairnessGuard::new(),
5002 event_recorder: None,
5003 subscriptions,
5004 #[cfg(test)]
5005 task_sender,
5006 task_receiver,
5007 task_executor,
5008 state_registry: config.persistence.registry.clone(),
5009 persistence_config: config.persistence,
5010 last_checkpoint: Instant::now(),
5011 inline_auto_remeasure,
5012 frame_arena: FrameArena::default(),
5013 guardrails,
5014 tick_strategy: config
5015 .tick_strategy
5016 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5017 last_active_screen_for_strategy: None,
5018 })
5019 }
5020}
5021
5022#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5027#[inline]
5028const fn sanitize_backend_features_for_capabilities(
5029 requested: BackendFeatures,
5030 capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5031) -> BackendFeatures {
5032 let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5033 let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5034
5035 BackendFeatures {
5036 mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5037 bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5038 focus_events: requested.focus_events && focus_events_supported,
5039 kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5040 }
5041}
5042
5043#[cfg(feature = "native-backend")]
5044impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5045 #[cfg(unix)]
5057 pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5058 where
5059 M::Message: Send + 'static,
5060 {
5061 let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5062 let mouse_capture = config.resolved_mouse_capture();
5063 let requested_features = BackendFeatures {
5064 mouse_capture,
5065 bracketed_paste: config.bracketed_paste,
5066 focus_events: config.focus_reporting,
5067 kitty_keyboard: config.kitty_keyboard,
5068 };
5069 let features =
5070 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5071 let options = ftui_tty::TtySessionOptions {
5072 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5073 features,
5074 intercept_signals: config.intercept_signals,
5075 };
5076 let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5077
5078 let writer = TerminalWriter::with_diff_config(
5079 io::stdout(),
5080 config.screen_mode,
5081 config.ui_anchor,
5082 capabilities,
5083 config.diff_config.clone(),
5084 );
5085
5086 Self::with_event_source(model, backend, features, writer, config)
5087 }
5088}
5089
5090impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5091 pub fn run(&mut self) -> io::Result<()> {
5099 self.run_event_loop()
5100 }
5101
5102 #[inline]
5103 fn observed_termination_signal(&self) -> Option<i32> {
5104 if self.intercept_signals {
5105 check_termination_signal()
5106 } else {
5107 None
5108 }
5109 }
5110
5111 #[inline]
5113 pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5114 &self.widget_signals
5115 }
5116
5117 #[inline]
5119 pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5120 self.immediate_drain_stats
5121 }
5122
5123 fn run_event_loop(&mut self) -> io::Result<()> {
5125 if self.persistence_config.auto_load {
5127 self.load_state();
5128 }
5129
5130 let cmd = {
5132 let _span = info_span!("ftui.program.init").entered();
5133 self.model.init()
5134 };
5135 self.execute_cmd(cmd)?;
5136
5137 let mut termination_signal = self.observed_termination_signal();
5138 if self.running && termination_signal.is_none() {
5139 self.reconcile_subscriptions();
5141
5142 self.render_frame()?;
5144 }
5145
5146 let mut loop_count: u64 = 0;
5148 while self.running {
5149 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5150 if termination_signal.is_some() {
5151 self.running = false;
5152 break;
5153 }
5154
5155 loop_count += 1;
5156 if loop_count.is_multiple_of(100) {
5158 crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5159 }
5160
5161 let timeout = self.effective_timeout();
5163
5164 let poll_result = self.events.poll_event(timeout)?;
5166 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5167 if termination_signal.is_some() {
5168 self.running = false;
5169 break;
5170 }
5171 if poll_result {
5172 self.drain_ready_events()?;
5173 }
5174 if !self.running {
5175 break;
5176 }
5177 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5178 if termination_signal.is_some() {
5179 self.running = false;
5180 break;
5181 }
5182
5183 self.process_subscription_messages()?;
5185 if !self.running {
5186 break;
5187 }
5188
5189 self.process_task_results()?;
5191 self.reap_finished_tasks();
5192 if !self.running {
5193 break;
5194 }
5195
5196 self.process_resize_coalescer()?;
5197 if !self.running {
5198 break;
5199 }
5200 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5201 if termination_signal.is_some() {
5202 self.running = false;
5203 break;
5204 }
5205
5206 self.check_screen_transition();
5210
5211 if self.should_tick() {
5213 self.tick_count = self.tick_count.wrapping_add(1);
5214 let tick_count = self.tick_count;
5215
5216 let mut used_screen_dispatch = false;
5217
5218 if let Some(strategy) = self.tick_strategy.as_mut() {
5223 let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5226 let active = dispatch.active_screen_id();
5227 let all_screens = dispatch.screen_ids();
5228 (active, all_screens)
5229 });
5230
5231 if let Some((active, all_screens)) = dispatch_snapshot {
5232 used_screen_dispatch = true;
5233
5234 if let Some(previous_active) =
5237 self.last_active_screen_for_strategy.as_deref()
5238 && previous_active != active
5239 {
5240 strategy.on_screen_transition(previous_active, &active);
5241 }
5242 self.last_active_screen_for_strategy = Some(active.clone());
5243
5244 let all_screens_count = all_screens.len();
5245 let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5246 tick_targets.push(active.clone());
5248
5249 for screen_id in all_screens {
5251 if screen_id != active
5252 && strategy.should_tick(&screen_id, tick_count, &active)
5253 == crate::tick_strategy::TickDecision::Tick
5254 {
5255 tick_targets.push(screen_id);
5256 }
5257 }
5258
5259 let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5261
5262 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5263 for screen_id in &tick_targets {
5264 dispatch.tick_screen(screen_id, tick_count);
5265 }
5266 }
5267
5268 trace!(
5269 tick = tick_count,
5270 active = %active,
5271 ticked = tick_targets.len(),
5272 skipped = skipped_count,
5273 "tick_strategy.frame"
5274 );
5275
5276 strategy.maintenance_tick(tick_count);
5278 self.mark_dirty();
5279 }
5280 }
5281
5282 if used_screen_dispatch && self.running {
5283 self.reconcile_subscriptions();
5284 }
5285
5286 if !used_screen_dispatch {
5287 self.last_active_screen_for_strategy = None;
5290 let msg = M::Message::from(Event::Tick);
5291 let cmd = {
5292 let _span = debug_span!(
5293 "ftui.program.update",
5294 msg_type = "Tick",
5295 duration_us = tracing::field::Empty,
5296 cmd_type = tracing::field::Empty
5297 )
5298 .entered();
5299 let start = Instant::now();
5300 let cmd = self.model.update(msg);
5301 tracing::Span::current()
5302 .record("duration_us", start.elapsed().as_micros() as u64);
5303 tracing::Span::current()
5304 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5305 cmd
5306 };
5307 self.mark_dirty();
5308 self.execute_cmd(cmd)?;
5309 if self.running {
5310 self.reconcile_subscriptions();
5311 }
5312 }
5313 }
5314
5315 self.check_checkpoint_save();
5317
5318 self.check_locale_change();
5320 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5321 if termination_signal.is_some() {
5322 self.running = false;
5323 break;
5324 }
5325
5326 if self.dirty {
5328 self.render_frame()?;
5329 }
5330
5331 if loop_count.is_multiple_of(1000) {
5333 self.writer.gc(None);
5334 }
5335 }
5336
5337 let shutdown_cmd = {
5338 let _span = info_span!("ftui.program.shutdown").entered();
5339 self.model.on_shutdown()
5340 };
5341 self.execute_cmd(shutdown_cmd)?;
5342
5343 if self.persistence_config.auto_save {
5345 self.save_state();
5346 }
5347
5348 if let Some(ref mut strategy) = self.tick_strategy {
5350 strategy.shutdown();
5351 }
5352
5353 self.subscriptions.stop_all();
5355 self.task_executor.shutdown();
5356 self.reap_finished_tasks();
5357 self.drain_shutdown_task_results()?;
5358
5359 if let Some(signal) = termination_signal {
5360 clear_termination_signal();
5361 let err = io::Error::new(
5362 io::ErrorKind::Interrupted,
5363 SignalTerminationError { signal },
5364 );
5365 debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
5366 return Err(err);
5367 }
5368
5369 Ok(())
5370 }
5371
5372 fn drain_ready_events(&mut self) -> io::Result<()> {
5378 self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5379
5380 let zero_poll_limit = self
5381 .immediate_drain_config
5382 .max_zero_timeout_polls_per_burst
5383 .max(1);
5384 let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5385 let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5386
5387 let mut burst_start = Instant::now();
5388 let mut zero_polls_in_burst_window: u64 = 0;
5389 let mut capped_this_burst = false;
5390
5391 loop {
5392 if let Some(event) = self.events.read_event()? {
5393 self.handle_event(event)?;
5394 if !self.running {
5395 break;
5396 }
5397 }
5398
5399 let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5400 || burst_start.elapsed() >= max_burst_duration;
5401
5402 if budget_exhausted {
5403 if !capped_this_burst {
5404 capped_this_burst = true;
5405 self.immediate_drain_stats.capped_bursts =
5406 self.immediate_drain_stats.capped_bursts.saturating_add(1);
5407 }
5408
5409 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5410 .immediate_drain_stats
5411 .max_zero_timeout_polls_in_burst
5412 .max(zero_polls_in_burst_window);
5413
5414 std::thread::yield_now();
5415 self.immediate_drain_stats.backoff_polls =
5416 self.immediate_drain_stats.backoff_polls.saturating_add(1);
5417 if !self.events.poll_event(backoff_timeout)? {
5418 break;
5419 }
5420 zero_polls_in_burst_window = 0;
5421 burst_start = Instant::now();
5422 continue;
5423 }
5424
5425 self.immediate_drain_stats.zero_timeout_polls = self
5426 .immediate_drain_stats
5427 .zero_timeout_polls
5428 .saturating_add(1);
5429 zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5430 if !self.events.poll_event(Duration::ZERO)? {
5431 break;
5432 }
5433 }
5434
5435 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5436 .immediate_drain_stats
5437 .max_zero_timeout_polls_in_burst
5438 .max(zero_polls_in_burst_window);
5439
5440 Ok(())
5441 }
5442
5443 fn load_state(&mut self) {
5445 if let Some(registry) = &self.state_registry {
5446 match registry.load() {
5447 Ok(count) => {
5448 info!(count, "loaded widget state from persistence");
5449 }
5450 Err(e) => {
5451 tracing::warn!(error = %e, "failed to load widget state");
5452 }
5453 }
5454 }
5455 }
5456
5457 fn save_state(&mut self) {
5459 if let Some(registry) = &self.state_registry {
5460 match registry.flush() {
5461 Ok(true) => {
5462 debug!("saved widget state to persistence");
5463 }
5464 Ok(false) => {
5465 }
5467 Err(e) => {
5468 tracing::warn!(error = %e, "failed to save widget state");
5469 }
5470 }
5471 }
5472 }
5473
5474 fn check_checkpoint_save(&mut self) {
5476 if let Some(interval) = self.persistence_config.checkpoint_interval
5477 && self.last_checkpoint.elapsed() >= interval
5478 {
5479 self.save_state();
5480 self.last_checkpoint = Instant::now();
5481 }
5482 }
5483
5484 fn handle_event(&mut self, event: Event) -> io::Result<()> {
5485 let event_start = Instant::now();
5487 let fairness_event_type = Self::classify_event_for_fairness(&event);
5488 if fairness_event_type == FairnessEventType::Input {
5489 self.fairness_guard.input_arrived(event_start);
5490 }
5491
5492 if let Some(recorder) = &mut self.event_recorder {
5494 recorder.record(&event);
5495 }
5496
5497 let event = match event {
5498 Event::Resize { width, height } => {
5499 debug!(
5500 width,
5501 height,
5502 behavior = ?self.resize_behavior,
5503 "Resize event received"
5504 );
5505 if let Some((forced_width, forced_height)) = self.forced_size {
5506 debug!(
5507 forced_width,
5508 forced_height, "Resize ignored due to forced size override"
5509 );
5510 self.fairness_guard.event_processed(
5511 fairness_event_type,
5512 event_start.elapsed(),
5513 Instant::now(),
5514 );
5515 return Ok(());
5516 }
5517 let width = width.max(1);
5519 let height = height.max(1);
5520 match self.resize_behavior {
5521 ResizeBehavior::Immediate => {
5522 self.resize_coalescer
5523 .record_external_apply(width, height, Instant::now());
5524 let result = self.apply_resize(width, height, Duration::ZERO, false);
5525 self.fairness_guard.event_processed(
5526 fairness_event_type,
5527 event_start.elapsed(),
5528 Instant::now(),
5529 );
5530 return result;
5531 }
5532 ResizeBehavior::Throttled => {
5533 let action = self.resize_coalescer.handle_resize(width, height);
5534 if let CoalesceAction::ApplyResize {
5535 width,
5536 height,
5537 coalesce_time,
5538 forced_by_deadline,
5539 } = action
5540 {
5541 let result =
5542 self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5543 self.fairness_guard.event_processed(
5544 fairness_event_type,
5545 event_start.elapsed(),
5546 Instant::now(),
5547 );
5548 return result;
5549 }
5550
5551 self.fairness_guard.event_processed(
5552 fairness_event_type,
5553 event_start.elapsed(),
5554 Instant::now(),
5555 );
5556 return Ok(());
5557 }
5558 }
5559 }
5560 other => other,
5561 };
5562
5563 let msg = M::Message::from(event);
5564 let cmd = {
5565 let _span = debug_span!(
5566 "ftui.program.update",
5567 msg_type = "event",
5568 duration_us = tracing::field::Empty,
5569 cmd_type = tracing::field::Empty
5570 )
5571 .entered();
5572 let start = Instant::now();
5573 let cmd = self.model.update(msg);
5574 let elapsed_us = start.elapsed().as_micros() as u64;
5575 self.last_update_us = Some(elapsed_us);
5576 tracing::Span::current().record("duration_us", elapsed_us);
5577 tracing::Span::current()
5578 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5579 cmd
5580 };
5581 self.mark_dirty();
5582 self.execute_cmd(cmd)?;
5583 if self.running {
5584 self.reconcile_subscriptions();
5585 }
5586
5587 self.fairness_guard.event_processed(
5589 fairness_event_type,
5590 event_start.elapsed(),
5591 Instant::now(),
5592 );
5593
5594 Ok(())
5595 }
5596
5597 fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5599 match event {
5600 Event::Key(_)
5601 | Event::Mouse(_)
5602 | Event::Paste(_)
5603 | Event::Ime(_)
5604 | Event::Focus(_)
5605 | Event::Clipboard(_) => FairnessEventType::Input,
5606 Event::Resize { .. } => FairnessEventType::Resize,
5607 Event::Tick => FairnessEventType::Tick,
5608 }
5609 }
5610
5611 fn reconcile_subscriptions(&mut self) {
5613 let _span = debug_span!(
5614 "ftui.program.subscriptions",
5615 active_count = tracing::field::Empty,
5616 started = tracing::field::Empty,
5617 stopped = tracing::field::Empty
5618 )
5619 .entered();
5620 let subs = self.model.subscriptions();
5621 let before_count = self.subscriptions.active_count();
5622 self.subscriptions.reconcile(subs);
5623 let after_count = self.subscriptions.active_count();
5624 let started = after_count.saturating_sub(before_count);
5625 let stopped = before_count.saturating_sub(after_count);
5626 crate::debug_trace!(
5627 "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5628 before_count,
5629 after_count,
5630 started,
5631 stopped
5632 );
5633 if after_count == 0 {
5634 crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5635 }
5636 let current = tracing::Span::current();
5637 current.record("active_count", after_count);
5638 current.record("started", started);
5640 current.record("stopped", stopped);
5641 }
5642
5643 fn process_subscription_messages(&mut self) -> io::Result<()> {
5645 let messages = self.subscriptions.drain_messages();
5646 let msg_count = messages.len();
5647 if msg_count > 0 {
5648 crate::debug_trace!("processing {} subscription message(s)", msg_count);
5649 }
5650 for msg in messages {
5651 let cmd = {
5652 let _span = debug_span!(
5653 "ftui.program.update",
5654 msg_type = "subscription",
5655 duration_us = tracing::field::Empty,
5656 cmd_type = tracing::field::Empty
5657 )
5658 .entered();
5659 let start = Instant::now();
5660 let cmd = self.model.update(msg);
5661 let elapsed_us = start.elapsed().as_micros() as u64;
5662 self.last_update_us = Some(elapsed_us);
5663 tracing::Span::current().record("duration_us", elapsed_us);
5664 tracing::Span::current()
5665 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5666 cmd
5667 };
5668 self.mark_dirty();
5669 self.execute_cmd(cmd)?;
5670 if !self.running {
5671 break;
5672 }
5673 }
5674 if self.running && self.dirty {
5675 self.reconcile_subscriptions();
5676 }
5677 Ok(())
5678 }
5679
5680 fn process_task_results(&mut self) -> io::Result<()> {
5682 while let Ok(msg) = self.task_receiver.try_recv() {
5683 let cmd = {
5684 let _span = debug_span!(
5685 "ftui.program.update",
5686 msg_type = "task",
5687 duration_us = tracing::field::Empty,
5688 cmd_type = tracing::field::Empty
5689 )
5690 .entered();
5691 let start = Instant::now();
5692 let cmd = self.model.update(msg);
5693 let elapsed_us = start.elapsed().as_micros() as u64;
5694 self.last_update_us = Some(elapsed_us);
5695 tracing::Span::current().record("duration_us", elapsed_us);
5696 tracing::Span::current()
5697 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5698 cmd
5699 };
5700 self.mark_dirty();
5701 self.execute_cmd(cmd)?;
5702 if !self.running {
5703 break;
5704 }
5705 }
5706 if self.running && self.dirty {
5707 self.reconcile_subscriptions();
5708 }
5709 Ok(())
5710 }
5711
5712 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5714 self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5715 match cmd {
5716 Cmd::None => {}
5717 Cmd::Quit => self.running = false,
5718 Cmd::Msg(m) => {
5719 let start = Instant::now();
5720 let cmd = self.model.update(m);
5721 let elapsed_us = start.elapsed().as_micros() as u64;
5722 self.last_update_us = Some(elapsed_us);
5723 self.mark_dirty();
5724 self.execute_cmd(cmd)?;
5725 }
5726 Cmd::Batch(cmds) => {
5727 for c in cmds {
5730 self.execute_cmd(c)?;
5731 if !self.running {
5732 break;
5733 }
5734 }
5735 }
5736 Cmd::Sequence(cmds) => {
5737 for c in cmds {
5738 self.execute_cmd(c)?;
5739 if !self.running {
5740 break;
5741 }
5742 }
5743 }
5744 Cmd::Tick(duration) => {
5745 self.tick_rate = Some(duration);
5746 self.last_tick = Instant::now();
5747 }
5748 Cmd::Log(text) => {
5749 let sanitized = sanitize(&text);
5750 let mut text_crlf = if sanitized.contains('\n') {
5751 sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5752 } else {
5753 sanitized.into_owned()
5754 };
5755 if !text_crlf.ends_with("\r\n") {
5756 if text_crlf.ends_with('\n') {
5757 text_crlf.pop();
5758 }
5759 text_crlf.push_str("\r\n");
5760 }
5761 self.writer.write_log(&text_crlf)?;
5762 }
5763 Cmd::Task(spec, f) => {
5764 crate::effect_system::record_command_effect("task", 0);
5765 self.task_executor.submit(spec, f);
5766 }
5767 Cmd::SaveState => {
5768 self.save_state();
5769 }
5770 Cmd::RestoreState => {
5771 self.load_state();
5772 }
5773 Cmd::SetMouseCapture(enabled) => {
5774 self.backend_features.mouse_capture = enabled;
5775 self.events.set_features(self.backend_features)?;
5776 }
5777 Cmd::SetTickStrategy(strategy) => {
5778 let new_name = strategy.name().to_owned();
5779 if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5780 let old_name = previous.name().to_owned();
5781 previous.shutdown();
5782 info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5783 } else {
5784 info!(new = %new_name, "tick strategy changed at runtime");
5785 }
5786 self.last_active_screen_for_strategy = None;
5787 }
5788 }
5789 Ok(())
5790 }
5791
5792 fn check_screen_transition(&mut self) {
5802 if self.tick_strategy.is_none() {
5803 return;
5804 }
5805
5806 let current_active = match self.model.as_screen_tick_dispatch() {
5808 Some(dispatch) => dispatch.active_screen_id(),
5809 None => return,
5810 };
5811
5812 let previous = match self.last_active_screen_for_strategy.take() {
5814 Some(prev) => prev,
5815 None => {
5816 self.last_active_screen_for_strategy = Some(current_active);
5817 return;
5818 }
5819 };
5820
5821 if previous == current_active {
5822 self.last_active_screen_for_strategy = Some(current_active);
5823 return;
5824 }
5825
5826 if let Some(strategy) = self.tick_strategy.as_mut() {
5828 strategy.on_screen_transition(&previous, ¤t_active);
5829 }
5830
5831 let mut force_ticked = false;
5833 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5834 dispatch.tick_screen(¤t_active, self.tick_count);
5835 force_ticked = true;
5836 }
5837 if force_ticked && self.running {
5838 self.reconcile_subscriptions();
5839 }
5840
5841 self.last_active_screen_for_strategy = Some(current_active);
5842 self.mark_dirty();
5843 }
5844
5845 fn reap_finished_tasks(&mut self) {
5846 self.task_executor.reap_finished();
5847 }
5848
5849 fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5850 while let Ok(msg) = self.task_receiver.try_recv() {
5851 let cmd = {
5852 let _span = debug_span!(
5853 "ftui.program.update",
5854 msg_type = "shutdown_task",
5855 duration_us = tracing::field::Empty,
5856 cmd_type = tracing::field::Empty
5857 )
5858 .entered();
5859 let start = Instant::now();
5860 let cmd = self.model.update(msg);
5861 let elapsed_us = start.elapsed().as_micros() as u64;
5862 self.last_update_us = Some(elapsed_us);
5863 tracing::Span::current().record("duration_us", elapsed_us);
5864 tracing::Span::current()
5865 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5866 cmd
5867 };
5868 self.mark_dirty();
5869 self.execute_cmd(cmd)?;
5870 }
5871 Ok(())
5872 }
5873
5874 fn render_frame(&mut self) -> io::Result<()> {
5876 crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5877
5878 self.frame_idx = self.frame_idx.wrapping_add(1);
5879 let frame_idx = self.frame_idx;
5880 let degradation_start = self.budget.degradation();
5881
5882 self.budget.next_frame();
5884
5885 let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5887 let verdict = self.guardrails.check_frame(memory_bytes, 0);
5889
5890 if verdict.should_drop_frame() {
5891 return Ok(());
5893 }
5894
5895 if verdict.should_degrade() {
5896 let current = self.budget.degradation();
5898 if verdict.recommended_level > current {
5899 self.budget.set_degradation(verdict.recommended_level);
5900 }
5901 }
5902
5903 let mut conformal_prediction = None;
5905 if let Some(predictor) = self.conformal_predictor.as_ref() {
5906 let baseline_us = self
5907 .last_frame_time_us
5908 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5909 let diff_strategy = self
5910 .writer
5911 .last_diff_strategy()
5912 .unwrap_or(DiffStrategy::Full);
5913 let frame_height_hint = self.writer.render_height_hint().max(1);
5914 let key = BucketKey::from_context(
5915 self.writer.screen_mode(),
5916 diff_strategy,
5917 self.width,
5918 frame_height_hint,
5919 );
5920 let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5921 let prediction = predictor.predict(key, baseline_us, budget_us);
5922 if prediction.risk {
5923 self.budget.degrade();
5924 info!(
5925 bucket = %prediction.bucket,
5926 upper_us = prediction.upper_us,
5927 budget_us = prediction.budget_us,
5928 fallback_level = prediction.fallback_level,
5929 degradation = self.budget.degradation().as_str(),
5930 "conformal gate triggered strategy downgrade"
5931 );
5932 debug!(
5933 monotonic.counter.conformal_gate_triggers_total = 1_u64,
5934 bucket = %prediction.bucket,
5935 "conformal gate trigger"
5936 );
5937 }
5938 debug!(
5939 bucket = %prediction.bucket,
5940 upper_us = prediction.upper_us,
5941 budget_us = prediction.budget_us,
5942 fallback = prediction.fallback_level,
5943 risk = prediction.risk,
5944 "conformal risk gate"
5945 );
5946 debug!(
5947 monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5948 bucket = %prediction.bucket,
5949 "conformal prediction interval width"
5950 );
5951 conformal_prediction = Some(prediction);
5952 }
5953
5954 if self.budget.exhausted() {
5956 self.budget.record_frame_time(Duration::ZERO);
5957 let load_snapshot =
5958 self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
5959 self.emit_budget_evidence(
5960 frame_idx,
5961 degradation_start,
5962 0.0,
5963 conformal_prediction.as_ref(),
5964 &load_snapshot,
5965 );
5966 crate::debug_trace!(
5967 "frame skipped: budget exhausted (degradation={})",
5968 self.budget.degradation().as_str()
5969 );
5970 debug!(
5971 degradation = self.budget.degradation().as_str(),
5972 "frame skipped: budget exhausted before render"
5973 );
5974 return Ok(());
5977 }
5978
5979 let auto_bounds = self.writer.inline_auto_bounds();
5980 let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
5981 let mut should_measure = needs_measure;
5982 if auto_bounds.is_some()
5983 && let Some(state) = self.inline_auto_remeasure.as_mut()
5984 {
5985 let decision = state.sampler.decide(Instant::now());
5986 if decision.should_sample {
5987 should_measure = true;
5988 }
5989 } else {
5990 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
5991 }
5992
5993 let render_start = Instant::now();
5995 if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
5996 let measure_height = if needs_measure {
5997 self.writer.render_height_hint().max(1)
5998 } else {
5999 max_height.max(1)
6000 };
6001 let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6002 let measured_height = measure_buffer.content_height();
6003 let clamped = measured_height.clamp(min_height, max_height);
6004 let previous_height = self.writer.auto_ui_height();
6005 self.writer.set_auto_ui_height(clamped);
6006 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6007 let threshold = state.config.change_threshold_rows;
6008 let violated = previous_height
6009 .map(|prev| prev.abs_diff(clamped) >= threshold)
6010 .unwrap_or(false);
6011 state.sampler.observe(violated);
6012 }
6013 }
6014 if auto_bounds.is_some()
6015 && let Some(state) = self.inline_auto_remeasure.as_ref()
6016 {
6017 let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6018 crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6019 }
6020
6021 let frame_height = self.writer.render_height_hint().max(1);
6022 let _frame_span = info_span!(
6023 "ftui.render.frame",
6024 width = self.width,
6025 height = frame_height,
6026 duration_us = tracing::field::Empty
6027 )
6028 .entered();
6029 let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6030 self.update_widget_refresh_plan(frame_idx);
6031 let render_elapsed = render_start.elapsed();
6032 let mut present_elapsed = Duration::ZERO;
6033 let mut presented = false;
6034
6035 let render_budget = self.budget.phase_budgets().render;
6037 if render_elapsed > render_budget {
6038 debug!(
6039 render_ms = render_elapsed.as_millis() as u32,
6040 budget_ms = render_budget.as_millis() as u32,
6041 "render phase exceeded budget"
6042 );
6043 if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6047 self.budget.degrade();
6048 }
6049 }
6050
6051 if !self.budget.exhausted() {
6053 let present_start = Instant::now();
6054 {
6055 let _present_span = debug_span!("ftui.render.present").entered();
6056 self.writer
6057 .present_ui_owned(buffer, cursor, cursor_visible)?;
6058 }
6059 presented = true;
6060 present_elapsed = present_start.elapsed();
6061
6062 let present_budget = self.budget.phase_budgets().present;
6063 if present_elapsed > present_budget {
6064 debug!(
6065 present_ms = present_elapsed.as_millis() as u32,
6066 budget_ms = present_budget.as_millis() as u32,
6067 "present phase exceeded budget"
6068 );
6069 }
6070 } else {
6071 debug!(
6072 degradation = self.budget.degradation().as_str(),
6073 elapsed_ms = self.budget.elapsed().as_millis() as u32,
6074 "frame present skipped: budget exhausted after render"
6075 );
6076 }
6077
6078 if let Some(ref frame_timing) = self.frame_timing {
6079 let update_us = self.last_update_us.unwrap_or(0);
6080 let render_us = render_elapsed.as_micros() as u64;
6081 let present_us = present_elapsed.as_micros() as u64;
6082 let diff_us = if presented {
6083 self.writer
6084 .take_last_present_timings()
6085 .map(|timings| timings.diff_us)
6086 .unwrap_or(0)
6087 } else {
6088 let _ = self.writer.take_last_present_timings();
6089 0
6090 };
6091 let total_us = update_us
6092 .saturating_add(render_us)
6093 .saturating_add(present_us);
6094 let timing = FrameTiming {
6095 frame_idx,
6096 update_us,
6097 render_us,
6098 diff_us,
6099 present_us,
6100 total_us,
6101 };
6102 frame_timing.sink.record_frame(&timing);
6103 }
6104
6105 let frame_time = render_elapsed.saturating_add(present_elapsed);
6106 self.budget.record_frame_time(frame_time);
6107 let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6108
6109 if let (Some(predictor), Some(prediction)) = (
6110 self.conformal_predictor.as_mut(),
6111 conformal_prediction.as_ref(),
6112 ) {
6113 let diff_strategy = self
6114 .writer
6115 .last_diff_strategy()
6116 .unwrap_or(DiffStrategy::Full);
6117 let key = BucketKey::from_context(
6118 self.writer.screen_mode(),
6119 diff_strategy,
6120 self.width,
6121 frame_height,
6122 );
6123 predictor.observe(key, prediction.y_hat, frame_time_us);
6124 }
6125 self.last_frame_time_us = Some(frame_time_us);
6126 let load_snapshot = self.update_load_governor_snapshot(
6127 frame_idx,
6128 frame_time_us,
6129 conformal_prediction.as_ref(),
6130 );
6131 self.emit_budget_evidence(
6132 frame_idx,
6133 degradation_start,
6134 frame_time_us,
6135 conformal_prediction.as_ref(),
6136 &load_snapshot,
6137 );
6138
6139 if presented {
6143 self.dirty = false;
6144 }
6145
6146 Ok(())
6147 }
6148
6149 fn update_load_governor_snapshot(
6150 &mut self,
6151 _frame_idx: u64,
6152 frame_time_us: f64,
6153 conformal_prediction: Option<&ConformalPrediction>,
6154 ) -> LoadGovernorSnapshot {
6155 let budget_us = conformal_prediction
6156 .map(|prediction| prediction.budget_us)
6157 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6158 let resize_stats = self.resize_coalescer.stats();
6159 self.load_governor.observe(LoadGovernorObservation {
6160 frame_time_us,
6161 budget_us,
6162 degradation: self.budget.degradation(),
6163 queue: crate::effect_system::queue_telemetry(),
6164 resize_coalescing_active: resize_stats.has_pending
6165 || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady),
6166 strict_semantics_violation: false,
6167 })
6168 }
6169
6170 fn emit_budget_evidence(
6171 &self,
6172 frame_idx: u64,
6173 degradation_start: DegradationLevel,
6174 frame_time_us: f64,
6175 conformal_prediction: Option<&ConformalPrediction>,
6176 load_snapshot: &LoadGovernorSnapshot,
6177 ) {
6178 let Some(telemetry) = self.budget.telemetry() else {
6179 set_budget_snapshot(None);
6180 return;
6181 };
6182
6183 let budget_us = conformal_prediction
6184 .map(|prediction| prediction.budget_us)
6185 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6186 let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6187 let degradation_after = self.budget.degradation();
6188
6189 let evidence = BudgetDecisionEvidence {
6190 frame_idx,
6191 decision: BudgetDecisionEvidence::decision_from_levels(
6192 degradation_start,
6193 degradation_after,
6194 ),
6195 controller_decision: telemetry.last_decision,
6196 degradation_before: degradation_start,
6197 degradation_after,
6198 frame_time_us,
6199 budget_us,
6200 pid_output: telemetry.pid_output,
6201 pid_p: telemetry.pid_p,
6202 pid_i: telemetry.pid_i,
6203 pid_d: telemetry.pid_d,
6204 e_value: telemetry.e_value,
6205 frames_observed: telemetry.frames_observed,
6206 frames_since_change: telemetry.frames_since_change,
6207 in_warmup: telemetry.in_warmup,
6208 controller_reason: telemetry.decision_reason,
6209 load_governor: *load_snapshot,
6210 conformal,
6211 };
6212
6213 let conformal_snapshot = evidence
6214 .conformal
6215 .as_ref()
6216 .map(|snapshot| ConformalSnapshot {
6217 bucket_key: snapshot.bucket_key.clone(),
6218 sample_count: snapshot.n_b,
6219 upper_us: snapshot.upper_us,
6220 risk: snapshot.risk,
6221 });
6222 set_budget_snapshot(Some(BudgetDecisionSnapshot {
6223 frame_idx: evidence.frame_idx,
6224 decision: evidence.decision,
6225 controller_decision: evidence.controller_decision,
6226 degradation_before: evidence.degradation_before,
6227 degradation_after: evidence.degradation_after,
6228 frame_time_us: evidence.frame_time_us,
6229 budget_us: evidence.budget_us,
6230 pid_output: evidence.pid_output,
6231 e_value: evidence.e_value,
6232 frames_observed: evidence.frames_observed,
6233 frames_since_change: evidence.frames_since_change,
6234 in_warmup: evidence.in_warmup,
6235 conformal: conformal_snapshot,
6236 }));
6237
6238 if let Some(ref sink) = self.evidence_sink {
6239 let _ = sink.write_jsonl(&evidence.to_jsonl());
6240 }
6241 }
6242
6243 fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6244 if !self.widget_refresh_config.enabled {
6245 self.widget_refresh_plan.clear();
6246 return;
6247 }
6248
6249 let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6250 let degradation = self.budget.degradation();
6251 self.widget_refresh_plan.recompute(
6252 frame_idx,
6253 budget_us,
6254 degradation,
6255 &self.widget_signals,
6256 &self.widget_refresh_config,
6257 );
6258
6259 if let Some(ref sink) = self.evidence_sink {
6260 let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6261 }
6262 }
6263
6264 fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6265 self.frame_arena.reset();
6267
6268 let buffer = self.writer.take_render_buffer(self.width, frame_height);
6271 let (pool, links) = self.writer.pool_and_links_mut();
6272 let mut frame = Frame::from_buffer(buffer, pool);
6273 frame.set_degradation(self.budget.degradation());
6274 frame.set_links(links);
6275 frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6276 frame.set_arena(&self.frame_arena);
6277
6278 let view_start = Instant::now();
6279 let _view_span = debug_span!(
6280 "ftui.program.view",
6281 duration_us = tracing::field::Empty,
6282 widget_count = tracing::field::Empty
6283 )
6284 .entered();
6285 self.model.view(&mut frame);
6286 self.widget_signals = frame.take_widget_signals();
6287 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6288 (frame.buffer, frame.cursor_position, frame.cursor_visible)
6291 }
6292
6293 fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6294 let Some(ref sink) = self.evidence_sink else {
6295 return;
6296 };
6297
6298 let config = self.fairness_guard.config();
6299 if !self.fairness_config_logged {
6300 let config_entry = FairnessConfigEvidence {
6301 enabled: config.enabled,
6302 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6303 dominance_threshold: config.dominance_threshold,
6304 fairness_threshold: config.fairness_threshold,
6305 };
6306 let _ = sink.write_jsonl(&config_entry.to_jsonl());
6307 self.fairness_config_logged = true;
6308 }
6309
6310 let evidence = FairnessDecisionEvidence {
6311 frame_idx: self.frame_idx,
6312 decision: if decision.should_process {
6313 "allow"
6314 } else {
6315 "yield"
6316 },
6317 reason: decision.reason.as_str(),
6318 pending_input_latency_ms: decision
6319 .pending_input_latency
6320 .map(|latency| latency.as_millis() as u64),
6321 jain_index: decision.jain_index,
6322 resize_dominance_count: dominance_count,
6323 dominance_threshold: config.dominance_threshold,
6324 fairness_threshold: config.fairness_threshold,
6325 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6326 };
6327
6328 let _ = sink.write_jsonl(&evidence.to_jsonl());
6329 }
6330
6331 fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6332 self.frame_arena.reset();
6334
6335 let pool = self.writer.pool_mut();
6336 let mut frame = Frame::new(self.width, frame_height, pool);
6337 frame.set_degradation(self.budget.degradation());
6338 frame.set_arena(&self.frame_arena);
6339
6340 let view_start = Instant::now();
6341 let _view_span = debug_span!(
6342 "ftui.program.view",
6343 duration_us = tracing::field::Empty,
6344 widget_count = tracing::field::Empty
6345 )
6346 .entered();
6347 self.model.view(&mut frame);
6348 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6349
6350 (frame.buffer, frame.cursor_position)
6351 }
6352
6353 fn effective_timeout(&self) -> Duration {
6355 if let Some(tick_rate) = self.tick_rate {
6356 let elapsed = self.last_tick.elapsed();
6357 let mut timeout = tick_rate.saturating_sub(elapsed);
6358 if self.resize_behavior.uses_coalescer()
6359 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6360 {
6361 timeout = timeout.min(resize_timeout);
6362 }
6363 timeout
6364 } else {
6365 let mut timeout = self.poll_timeout;
6366 if self.resize_behavior.uses_coalescer()
6367 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6368 {
6369 timeout = timeout.min(resize_timeout);
6370 }
6371 timeout
6372 }
6373 }
6374
6375 fn should_tick(&mut self) -> bool {
6377 if let Some(tick_rate) = self.tick_rate
6378 && self.last_tick.elapsed() >= tick_rate
6379 {
6380 self.last_tick = Instant::now();
6381 return true;
6382 }
6383 false
6384 }
6385
6386 fn process_resize_coalescer(&mut self) -> io::Result<()> {
6387 if !self.resize_behavior.uses_coalescer() {
6388 return Ok(());
6389 }
6390
6391 let dominance_count = self.fairness_guard.resize_dominance_count();
6394 let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6395 self.emit_fairness_evidence(&fairness_decision, dominance_count);
6396 if !fairness_decision.should_process {
6397 debug!(
6398 reason = ?fairness_decision.reason,
6399 pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6400 "Resize yielding to input for fairness"
6401 );
6402 return Ok(());
6404 }
6405
6406 let action = self.resize_coalescer.tick();
6407 let resize_snapshot =
6408 self.resize_coalescer
6409 .logs()
6410 .last()
6411 .map(|entry| ResizeDecisionSnapshot {
6412 event_idx: entry.event_idx,
6413 action: entry.action,
6414 dt_ms: entry.dt_ms,
6415 event_rate: entry.event_rate,
6416 regime: entry.regime,
6417 pending_size: entry.pending_size,
6418 applied_size: entry.applied_size,
6419 time_since_render_ms: entry.time_since_render_ms,
6420 bocpd: self
6421 .resize_coalescer
6422 .bocpd()
6423 .and_then(|detector| detector.last_evidence().cloned()),
6424 });
6425 set_resize_snapshot(resize_snapshot);
6426
6427 match action {
6428 CoalesceAction::ApplyResize {
6429 width,
6430 height,
6431 coalesce_time,
6432 forced_by_deadline,
6433 } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6434 _ => Ok(()),
6435 }
6436 }
6437
6438 fn apply_resize(
6439 &mut self,
6440 width: u16,
6441 height: u16,
6442 coalesce_time: Duration,
6443 forced_by_deadline: bool,
6444 ) -> io::Result<()> {
6445 let width = width.max(1);
6447 let height = height.max(1);
6448 self.width = width;
6449 self.height = height;
6450 self.writer.set_size(width, height);
6451 info!(
6452 width = width,
6453 height = height,
6454 coalesce_ms = coalesce_time.as_millis() as u64,
6455 forced = forced_by_deadline,
6456 "Resize applied"
6457 );
6458
6459 let msg = M::Message::from(Event::Resize { width, height });
6460 let start = Instant::now();
6461 let cmd = self.model.update(msg);
6462 let elapsed_us = start.elapsed().as_micros() as u64;
6463 self.last_update_us = Some(elapsed_us);
6464 self.mark_dirty();
6465 self.execute_cmd(cmd)?;
6466 if self.running && self.dirty {
6467 self.reconcile_subscriptions();
6468 }
6469 Ok(())
6470 }
6471
6472 pub fn model(&self) -> &M {
6476 &self.model
6477 }
6478
6479 pub fn model_mut(&mut self) -> &mut M {
6481 &mut self.model
6482 }
6483
6484 pub fn is_running(&self) -> bool {
6486 self.running
6487 }
6488
6489 #[must_use]
6491 pub const fn tick_rate(&self) -> Option<Duration> {
6492 self.tick_rate
6493 }
6494
6495 #[must_use]
6497 pub const fn executed_cmd_count(&self) -> usize {
6498 self.executed_cmd_count
6499 }
6500
6501 pub fn quit(&mut self) {
6503 self.running = false;
6504 }
6505
6506 pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6508 self.state_registry.as_ref()
6509 }
6510
6511 pub fn has_persistence(&self) -> bool {
6513 self.state_registry.is_some()
6514 }
6515
6516 #[must_use]
6522 pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6523 self.tick_strategy
6524 .as_ref()
6525 .map(|s| s.debug_stats())
6526 .unwrap_or_default()
6527 }
6528
6529 pub fn trigger_save(&mut self) -> StorageResult<bool> {
6534 if let Some(registry) = &self.state_registry {
6535 registry.flush()
6536 } else {
6537 Ok(false)
6538 }
6539 }
6540
6541 pub fn trigger_load(&mut self) -> StorageResult<usize> {
6546 if let Some(registry) = &self.state_registry {
6547 registry.load()
6548 } else {
6549 Ok(0)
6550 }
6551 }
6552
6553 fn mark_dirty(&mut self) {
6554 self.dirty = true;
6555 }
6556
6557 fn check_locale_change(&mut self) {
6558 let version = self.locale_context.version();
6559 if version != self.locale_version {
6560 self.locale_version = version;
6561 self.mark_dirty();
6562 }
6563 }
6564
6565 pub fn request_redraw(&mut self) {
6567 self.mark_dirty();
6568 }
6569
6570 pub fn request_ui_height_remeasure(&mut self) {
6572 if self.writer.inline_auto_bounds().is_some() {
6573 self.writer.clear_auto_ui_height();
6574 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6575 state.reset();
6576 }
6577 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6578 self.mark_dirty();
6579 }
6580 }
6581
6582 pub fn start_recording(&mut self, name: impl Into<String>) {
6587 let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6588 recorder.start();
6589 self.event_recorder = Some(recorder);
6590 }
6591
6592 pub fn stop_recording(&mut self) -> Option<InputMacro> {
6596 self.event_recorder.take().map(EventRecorder::finish)
6597 }
6598
6599 pub fn is_recording(&self) -> bool {
6601 self.event_recorder
6602 .as_ref()
6603 .is_some_and(EventRecorder::is_recording)
6604 }
6605}
6606
6607pub struct App;
6609
6610impl App {
6611 #[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6614 AppBuilder {
6615 model,
6616 config: ProgramConfig::default(),
6617 }
6618 }
6619
6620 pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6622 AppBuilder {
6623 model,
6624 config: ProgramConfig::fullscreen(),
6625 }
6626 }
6627
6628 pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6630 AppBuilder {
6631 model,
6632 config: ProgramConfig::inline(height),
6633 }
6634 }
6635
6636 pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6638 AppBuilder {
6639 model,
6640 config: ProgramConfig::inline_auto(min_height, max_height),
6641 }
6642 }
6643
6644 pub fn string_model<S: crate::string_model::StringModel>(
6649 model: S,
6650 ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6651 AppBuilder {
6652 model: crate::string_model::StringModelAdapter::new(model),
6653 config: ProgramConfig::fullscreen(),
6654 }
6655 }
6656}
6657
6658#[must_use]
6660pub struct AppBuilder<M: Model> {
6661 model: M,
6662 config: ProgramConfig,
6663}
6664
6665impl<M: Model> AppBuilder<M> {
6666 pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6668 self.config.screen_mode = mode;
6669 self
6670 }
6671
6672 pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6674 self.config.ui_anchor = anchor;
6675 self
6676 }
6677
6678 pub fn with_mouse(mut self) -> Self {
6680 self.config.mouse_capture_policy = MouseCapturePolicy::On;
6681 self
6682 }
6683
6684 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6686 self.config.mouse_capture_policy = policy;
6687 self
6688 }
6689
6690 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6692 self.config.mouse_capture_policy = if enabled {
6693 MouseCapturePolicy::On
6694 } else {
6695 MouseCapturePolicy::Off
6696 };
6697 self
6698 }
6699
6700 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6702 self.config.budget = budget;
6703 self
6704 }
6705
6706 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6708 self.config.load_governor = config;
6709 self
6710 }
6711
6712 pub fn without_load_governor(mut self) -> Self {
6714 self.config.load_governor = LoadGovernorConfig::disabled();
6715 self
6716 }
6717
6718 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6720 self.config.evidence_sink = config;
6721 self
6722 }
6723
6724 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6726 self.config.render_trace = config;
6727 self
6728 }
6729
6730 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6732 self.config.widget_refresh = config;
6733 self
6734 }
6735
6736 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6738 self.config.effect_queue = config;
6739 self
6740 }
6741
6742 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6744 self.config.inline_auto_remeasure = Some(config);
6745 self
6746 }
6747
6748 pub fn without_inline_auto_remeasure(mut self) -> Self {
6750 self.config.inline_auto_remeasure = None;
6751 self
6752 }
6753
6754 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6756 self.config.locale_context = locale_context;
6757 self
6758 }
6759
6760 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6762 self.config.locale_context = LocaleContext::new(locale);
6763 self
6764 }
6765
6766 pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6768 self.config.resize_coalescer = config;
6769 self
6770 }
6771
6772 pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6774 self.config.resize_behavior = behavior;
6775 self
6776 }
6777
6778 pub fn legacy_resize(mut self, enabled: bool) -> Self {
6780 if enabled {
6781 self.config.resize_behavior = ResizeBehavior::Immediate;
6782 }
6783 self
6784 }
6785
6786 pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6788 self.config.tick_strategy = Some(strategy);
6789 self
6790 }
6791
6792 #[cfg(feature = "crossterm-compat")]
6794 pub fn run(self) -> io::Result<()>
6795 where
6796 M::Message: Send + 'static,
6797 {
6798 let mut program = Program::with_config(self.model, self.config)?;
6799 let result = program.run();
6800 if let Err(ref err) = result
6801 && let Some(signal) = signal_termination_from_error(err)
6802 {
6803 drop(program);
6804 std::process::exit(128 + signal);
6805 }
6806 result
6807 }
6808
6809 #[cfg(all(feature = "native-backend", unix))]
6811 pub fn run_native(self) -> io::Result<()>
6812 where
6813 M::Message: Send + 'static,
6814 {
6815 let mut program = Program::with_native_backend(self.model, self.config)?;
6816 let result = program.run();
6817 if let Err(ref err) = result
6818 && let Some(signal) = signal_termination_from_error(err)
6819 {
6820 drop(program);
6821 std::process::exit(128 + signal);
6822 }
6823 result
6824 }
6825
6826 #[cfg(not(feature = "crossterm-compat"))]
6828 pub fn run(self) -> io::Result<()>
6829 where
6830 M::Message: Send + 'static,
6831 {
6832 let _ = (self.model, self.config);
6833 Err(io::Error::new(
6834 io::ErrorKind::Unsupported,
6835 "enable `crossterm-compat` feature to use AppBuilder::run()",
6836 ))
6837 }
6838
6839 #[cfg(any(not(feature = "native-backend"), not(unix)))]
6844 pub fn run_native(self) -> io::Result<()>
6845 where
6846 M::Message: Send + 'static,
6847 {
6848 let _ = (self.model, self.config);
6849 #[cfg(not(unix))]
6855 let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
6856 #[cfg(all(unix, not(feature = "native-backend")))]
6857 let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
6858 Err(io::Error::new(io::ErrorKind::Unsupported, msg))
6859 }
6860}
6861
6862#[derive(Debug, Clone)]
6931pub struct BatchController {
6932 ema_inter_arrival_s: f64,
6934 ema_service_s: f64,
6936 alpha: f64,
6938 tau_min_s: f64,
6940 tau_max_s: f64,
6942 headroom: f64,
6944 last_arrival: Option<Instant>,
6946 observations: u64,
6948}
6949
6950impl BatchController {
6951 pub fn new() -> Self {
6958 Self {
6959 ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
6962 tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
6965 last_arrival: None,
6966 observations: 0,
6967 }
6968 }
6969
6970 pub fn observe_arrival(&mut self, now: Instant) {
6972 if let Some(last) = self.last_arrival {
6973 let dt = now.saturating_duration_since(last).as_secs_f64();
6974 if dt > 0.0 && dt < 10.0 {
6975 self.ema_inter_arrival_s =
6977 self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
6978 self.observations += 1;
6979 }
6980 }
6981 self.last_arrival = Some(now);
6982 }
6983
6984 pub fn observe_service(&mut self, duration: Duration) {
6986 let dt = duration.as_secs_f64();
6987 if (0.0..10.0).contains(&dt) {
6988 self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
6989 }
6990 }
6991
6992 #[inline]
6994 pub fn lambda_est(&self) -> f64 {
6995 if self.ema_inter_arrival_s > 0.0 {
6996 1.0 / self.ema_inter_arrival_s
6997 } else {
6998 0.0
6999 }
7000 }
7001
7002 #[inline]
7004 pub fn service_est_s(&self) -> f64 {
7005 self.ema_service_s
7006 }
7007
7008 #[inline]
7010 pub fn rho_est(&self) -> f64 {
7011 self.lambda_est() * self.ema_service_s
7012 }
7013
7014 pub fn tau_s(&self) -> f64 {
7020 let base = self.ema_service_s * self.headroom;
7021 base.clamp(self.tau_min_s, self.tau_max_s)
7022 }
7023
7024 pub fn tau(&self) -> Duration {
7026 Duration::from_secs_f64(self.tau_s())
7027 }
7028
7029 #[inline]
7031 pub fn is_stable(&self) -> bool {
7032 self.rho_est() < 1.0
7033 }
7034
7035 #[inline]
7037 pub fn observations(&self) -> u64 {
7038 self.observations
7039 }
7040}
7041
7042impl Default for BatchController {
7043 fn default() -> Self {
7044 Self::new()
7045 }
7046}
7047
7048#[cfg(test)]
7049mod tests {
7050 use super::*;
7051 use ftui_core::terminal_capabilities::TerminalCapabilities;
7052 use ftui_layout::PaneDragResizeEffect;
7053 use ftui_render::buffer::Buffer;
7054 use ftui_render::cell::Cell;
7055 use ftui_render::diff_strategy::DiffStrategy;
7056 use ftui_render::frame::CostEstimateSource;
7057 use serde_json::Value;
7058 use std::collections::{HashMap, VecDeque};
7059 use std::path::PathBuf;
7060 use std::sync::mpsc;
7061 use std::sync::{
7062 Arc,
7063 atomic::{AtomicUsize, Ordering},
7064 };
7065
7066 struct TestModel {
7068 value: i32,
7069 }
7070
7071 #[derive(Debug)]
7072 enum TestMsg {
7073 Increment,
7074 Decrement,
7075 Quit,
7076 }
7077
7078 impl From<Event> for TestMsg {
7079 fn from(_event: Event) -> Self {
7080 TestMsg::Increment
7081 }
7082 }
7083
7084 impl Model for TestModel {
7085 type Message = TestMsg;
7086
7087 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7088 match msg {
7089 TestMsg::Increment => {
7090 self.value += 1;
7091 Cmd::none()
7092 }
7093 TestMsg::Decrement => {
7094 self.value -= 1;
7095 Cmd::none()
7096 }
7097 TestMsg::Quit => Cmd::quit(),
7098 }
7099 }
7100
7101 fn view(&self, _frame: &mut Frame) {
7102 }
7104 }
7105
7106 #[test]
7107 fn cmd_none() {
7108 let cmd: Cmd<TestMsg> = Cmd::none();
7109 assert!(matches!(cmd, Cmd::None));
7110 }
7111
7112 #[test]
7113 fn cmd_quit() {
7114 let cmd: Cmd<TestMsg> = Cmd::quit();
7115 assert!(matches!(cmd, Cmd::Quit));
7116 }
7117
7118 #[test]
7119 fn cmd_msg() {
7120 let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7121 assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7122 }
7123
7124 #[test]
7125 fn cmd_batch_empty() {
7126 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7127 assert!(matches!(cmd, Cmd::None));
7128 }
7129
7130 #[test]
7131 fn cmd_batch_single() {
7132 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7133 assert!(matches!(cmd, Cmd::Quit));
7134 }
7135
7136 #[test]
7137 fn cmd_batch_multiple() {
7138 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7139 assert!(matches!(cmd, Cmd::Batch(_)));
7140 }
7141
7142 #[test]
7143 fn cmd_sequence_empty() {
7144 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7145 assert!(matches!(cmd, Cmd::None));
7146 }
7147
7148 #[test]
7149 fn cmd_tick() {
7150 let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7151 assert!(matches!(cmd, Cmd::Tick(_)));
7152 }
7153
7154 #[test]
7155 fn cmd_task() {
7156 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7157 assert!(matches!(cmd, Cmd::Task(..)));
7158 }
7159
7160 #[test]
7161 fn cmd_debug_format() {
7162 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7163 let debug = format!("{cmd:?}");
7164 assert_eq!(
7165 debug,
7166 "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7167 );
7168 }
7169
7170 #[test]
7171 fn model_subscriptions_default_empty() {
7172 let model = TestModel { value: 0 };
7173 let subs = model.subscriptions();
7174 assert!(subs.is_empty());
7175 }
7176
7177 #[test]
7178 fn program_config_default() {
7179 let config = ProgramConfig::default();
7180 assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7181 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7182 assert!(!config.resolved_mouse_capture());
7183 assert!(config.bracketed_paste);
7184 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7185 assert!(config.inline_auto_remeasure.is_none());
7186 assert!(config.conformal_config.is_none());
7187 assert!(config.diff_config.bayesian_enabled);
7188 assert!(config.diff_config.dirty_rows_enabled);
7189 assert!(!config.resize_coalescer.enable_bocpd);
7190 assert!(!config.effect_queue.enabled);
7191 assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7192 assert_eq!(
7193 config.immediate_drain.max_burst_duration,
7194 Duration::from_millis(2)
7195 );
7196 assert_eq!(
7197 config.immediate_drain.backoff_timeout,
7198 Duration::from_millis(1)
7199 );
7200 assert_eq!(
7201 config.resize_coalescer.steady_delay_ms,
7202 CoalescerConfig::default().steady_delay_ms
7203 );
7204 }
7205
7206 #[test]
7207 fn program_config_with_immediate_drain() {
7208 let custom = ImmediateDrainConfig {
7209 max_zero_timeout_polls_per_burst: 7,
7210 max_burst_duration: Duration::from_millis(9),
7211 backoff_timeout: Duration::from_millis(3),
7212 };
7213 let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7214 assert_eq!(
7215 config.immediate_drain.max_zero_timeout_polls_per_burst,
7216 custom.max_zero_timeout_polls_per_burst
7217 );
7218 assert_eq!(
7219 config.immediate_drain.max_burst_duration,
7220 custom.max_burst_duration
7221 );
7222 assert_eq!(
7223 config.immediate_drain.backoff_timeout,
7224 custom.backoff_timeout
7225 );
7226 }
7227
7228 #[test]
7229 fn program_config_fullscreen() {
7230 let config = ProgramConfig::fullscreen();
7231 assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7232 }
7233
7234 #[test]
7235 fn program_config_inline() {
7236 let config = ProgramConfig::inline(10);
7237 assert!(matches!(
7238 config.screen_mode,
7239 ScreenMode::Inline { ui_height: 10 }
7240 ));
7241 }
7242
7243 #[test]
7244 fn program_config_inline_auto() {
7245 let config = ProgramConfig::inline_auto(3, 9);
7246 assert!(matches!(
7247 config.screen_mode,
7248 ScreenMode::InlineAuto {
7249 min_height: 3,
7250 max_height: 9
7251 }
7252 ));
7253 assert!(config.inline_auto_remeasure.is_some());
7254 }
7255
7256 #[test]
7257 fn program_config_with_mouse() {
7258 let config = ProgramConfig::default().with_mouse();
7259 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7260 assert!(config.resolved_mouse_capture());
7261 }
7262
7263 #[cfg(feature = "native-backend")]
7264 #[test]
7265 fn sanitize_backend_features_disables_unsupported_features() {
7266 let requested = BackendFeatures {
7267 mouse_capture: true,
7268 bracketed_paste: true,
7269 focus_events: true,
7270 kitty_keyboard: true,
7271 };
7272 let sanitized =
7273 sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7274 assert_eq!(sanitized, BackendFeatures::default());
7275 }
7276
7277 #[cfg(feature = "native-backend")]
7278 #[test]
7279 fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7280 let requested = BackendFeatures {
7281 mouse_capture: true,
7282 bracketed_paste: true,
7283 focus_events: true,
7284 kitty_keyboard: true,
7285 };
7286 let caps = TerminalCapabilities::builder()
7287 .mouse_sgr(true)
7288 .bracketed_paste(true)
7289 .focus_events(true)
7290 .kitty_keyboard(true)
7291 .in_wezterm_mux(true)
7292 .build();
7293 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7294
7295 assert!(sanitized.mouse_capture);
7296 assert!(sanitized.bracketed_paste);
7297 assert!(!sanitized.focus_events);
7298 assert!(!sanitized.kitty_keyboard);
7299 }
7300
7301 #[cfg(feature = "native-backend")]
7302 #[test]
7303 fn sanitize_backend_features_is_conservative_in_tmux() {
7304 let requested = BackendFeatures {
7305 mouse_capture: true,
7306 bracketed_paste: true,
7307 focus_events: true,
7308 kitty_keyboard: true,
7309 };
7310 let caps = TerminalCapabilities::builder()
7311 .mouse_sgr(true)
7312 .bracketed_paste(true)
7313 .focus_events(true)
7314 .kitty_keyboard(true)
7315 .in_tmux(true)
7316 .build();
7317 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7318
7319 assert!(sanitized.mouse_capture);
7320 assert!(sanitized.bracketed_paste);
7321 assert!(!sanitized.focus_events);
7322 assert!(!sanitized.kitty_keyboard);
7323 }
7324
7325 #[test]
7326 fn program_config_mouse_policy_auto_altscreen() {
7327 let config = ProgramConfig::fullscreen();
7328 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7329 assert!(config.resolved_mouse_capture());
7330 }
7331
7332 #[test]
7333 fn program_config_mouse_policy_force_off() {
7334 let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7335 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7336 assert!(!config.resolved_mouse_capture());
7337 }
7338
7339 #[test]
7340 fn program_config_mouse_policy_force_on_inline() {
7341 let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7342 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7343 assert!(config.resolved_mouse_capture());
7344 }
7345
7346 fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7347 PaneResizeTarget {
7348 split_id: ftui_layout::PaneId::MIN,
7349 axis,
7350 }
7351 }
7352
7353 fn pane_id(raw: u64) -> ftui_layout::PaneId {
7354 ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7355 }
7356
7357 fn nested_pane_tree() -> ftui_layout::PaneTree {
7358 let root = pane_id(1);
7359 let left = pane_id(2);
7360 let right_split = pane_id(3);
7361 let right_top = pane_id(4);
7362 let right_bottom = pane_id(5);
7363 let snapshot = ftui_layout::PaneTreeSnapshot {
7364 schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7365 root,
7366 next_id: pane_id(6),
7367 nodes: vec![
7368 ftui_layout::PaneNodeRecord::split(
7369 root,
7370 None,
7371 ftui_layout::PaneSplit {
7372 axis: SplitAxis::Horizontal,
7373 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7374 first: left,
7375 second: right_split,
7376 },
7377 ),
7378 ftui_layout::PaneNodeRecord::leaf(
7379 left,
7380 Some(root),
7381 ftui_layout::PaneLeaf::new("left"),
7382 ),
7383 ftui_layout::PaneNodeRecord::split(
7384 right_split,
7385 Some(root),
7386 ftui_layout::PaneSplit {
7387 axis: SplitAxis::Vertical,
7388 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7389 first: right_top,
7390 second: right_bottom,
7391 },
7392 ),
7393 ftui_layout::PaneNodeRecord::leaf(
7394 right_top,
7395 Some(right_split),
7396 ftui_layout::PaneLeaf::new("right_top"),
7397 ),
7398 ftui_layout::PaneNodeRecord::leaf(
7399 right_bottom,
7400 Some(right_split),
7401 ftui_layout::PaneLeaf::new("right_bottom"),
7402 ),
7403 ],
7404 extensions: std::collections::BTreeMap::new(),
7405 };
7406 ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7407 }
7408
7409 #[test]
7410 fn pane_terminal_splitter_resolution_is_deterministic() {
7411 let tree = nested_pane_tree();
7412 let layout = tree
7413 .solve_layout(Rect::new(0, 0, 50, 20))
7414 .expect("layout should solve");
7415 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7416 assert_eq!(handles.len(), 2);
7417
7418 let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7421 .expect("overlap cell should resolve");
7422 assert_eq!(overlap.split_id, pane_id(1));
7423 assert_eq!(overlap.axis, SplitAxis::Horizontal);
7424
7425 let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7426 .expect("right split should resolve");
7427 assert_eq!(right_only.split_id, pane_id(3));
7428 assert_eq!(right_only.axis, SplitAxis::Vertical);
7429 }
7430
7431 #[test]
7432 fn pane_terminal_splitter_hits_register_and_decode_target() {
7433 let tree = nested_pane_tree();
7434 let layout = tree
7435 .solve_layout(Rect::new(0, 0, 50, 20))
7436 .expect("layout should solve");
7437 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7438
7439 let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7440 let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7441 let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7442 assert_eq!(registered, handles.len());
7443
7444 let root_hit = frame
7445 .hit_test(25, 2)
7446 .expect("root splitter should be hittable");
7447 assert_eq!(root_hit.1, HitRegion::Handle);
7448 let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7449 assert_eq!(root_target.split_id, pane_id(1));
7450 assert_eq!(root_target.axis, SplitAxis::Horizontal);
7451
7452 let right_hit = frame
7453 .hit_test(40, 10)
7454 .expect("right splitter should be hittable");
7455 assert_eq!(right_hit.1, HitRegion::Handle);
7456 let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7457 assert_eq!(right_target.split_id, pane_id(3));
7458 assert_eq!(right_target.axis, SplitAxis::Vertical);
7459 }
7460
7461 #[test]
7462 fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7463 let mut adapter =
7464 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7465 let target = pane_target(SplitAxis::Horizontal);
7466
7467 let down = Event::Mouse(MouseEvent::new(
7468 MouseEventKind::Down(MouseButton::Left),
7469 10,
7470 4,
7471 ));
7472 let down_dispatch = adapter.translate(&down, Some(target));
7473 let down_event = down_dispatch
7474 .primary_event
7475 .as_ref()
7476 .expect("pointer down semantic event");
7477 assert_eq!(down_event.sequence, 1);
7478 assert!(matches!(
7479 down_event.kind,
7480 PaneSemanticInputEventKind::PointerDown {
7481 target: actual_target,
7482 pointer_id: 1,
7483 button: PanePointerButton::Primary,
7484 position
7485 } if actual_target == target && position == PanePointerPosition::new(10, 4)
7486 ));
7487 assert!(down_event.validate().is_ok());
7488
7489 let drag = Event::Mouse(MouseEvent::new(
7490 MouseEventKind::Drag(MouseButton::Left),
7491 14,
7492 4,
7493 ));
7494 let drag_dispatch = adapter.translate(&drag, None);
7495 let drag_event = drag_dispatch
7496 .primary_event
7497 .as_ref()
7498 .expect("pointer move semantic event");
7499 assert_eq!(drag_event.sequence, 2);
7500 assert!(matches!(
7501 drag_event.kind,
7502 PaneSemanticInputEventKind::PointerMove {
7503 target: actual_target,
7504 pointer_id: 1,
7505 position,
7506 delta_x: 4,
7507 delta_y: 0
7508 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7509 ));
7510 let drag_motion = drag_dispatch
7511 .motion
7512 .expect("drag should emit motion metadata");
7513 assert_eq!(drag_motion.delta_x, 4);
7514 assert_eq!(drag_motion.delta_y, 0);
7515 assert_eq!(drag_motion.direction_changes, 0);
7516 assert!(drag_motion.speed > 0.0);
7517 assert!(drag_dispatch.pressure_snap_profile().is_some());
7518
7519 let up = Event::Mouse(MouseEvent::new(
7520 MouseEventKind::Up(MouseButton::Left),
7521 14,
7522 4,
7523 ));
7524 let up_dispatch = adapter.translate(&up, None);
7525 let up_event = up_dispatch
7526 .primary_event
7527 .as_ref()
7528 .expect("pointer up semantic event");
7529 assert_eq!(up_event.sequence, 3);
7530 assert!(matches!(
7531 up_event.kind,
7532 PaneSemanticInputEventKind::PointerUp {
7533 target: actual_target,
7534 pointer_id: 1,
7535 button: PanePointerButton::Primary,
7536 position
7537 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7538 ));
7539 let up_motion = up_dispatch
7540 .motion
7541 .expect("up should emit final motion metadata");
7542 assert_eq!(up_motion.delta_x, 4);
7543 assert_eq!(up_motion.delta_y, 0);
7544 assert_eq!(up_motion.direction_changes, 0);
7545 let inertial_throw = up_dispatch
7546 .inertial_throw
7547 .expect("up should emit inertial throw metadata");
7548 assert_eq!(
7549 up_dispatch.projected_position,
7550 Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7551 );
7552 assert_eq!(adapter.active_pointer_id(), None);
7553 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7554 }
7555
7556 #[test]
7557 fn pane_terminal_adapter_focus_loss_emits_cancel() {
7558 let mut adapter =
7559 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7560 let target = pane_target(SplitAxis::Vertical);
7561
7562 let down = Event::Mouse(MouseEvent::new(
7563 MouseEventKind::Down(MouseButton::Left),
7564 3,
7565 9,
7566 ));
7567 let _ = adapter.translate(&down, Some(target));
7568 assert_eq!(adapter.active_pointer_id(), Some(1));
7569
7570 let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
7571 let cancel_event = cancel_dispatch
7572 .primary_event
7573 .as_ref()
7574 .expect("focus-loss cancel event");
7575 assert!(matches!(
7576 cancel_event.kind,
7577 PaneSemanticInputEventKind::Cancel {
7578 target: Some(actual_target),
7579 reason: PaneCancelReason::FocusLost
7580 } if actual_target == target
7581 ));
7582 assert_eq!(adapter.active_pointer_id(), None);
7583 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7584 }
7585
7586 #[test]
7587 fn pane_terminal_adapter_recovers_missing_mouse_up() {
7588 let mut adapter =
7589 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7590 let first_target = pane_target(SplitAxis::Horizontal);
7591 let second_target = pane_target(SplitAxis::Vertical);
7592
7593 let first_down = Event::Mouse(MouseEvent::new(
7594 MouseEventKind::Down(MouseButton::Left),
7595 5,
7596 5,
7597 ));
7598 let _ = adapter.translate(&first_down, Some(first_target));
7599
7600 let second_down = Event::Mouse(MouseEvent::new(
7601 MouseEventKind::Down(MouseButton::Left),
7602 8,
7603 11,
7604 ));
7605 let dispatch = adapter.translate(&second_down, Some(second_target));
7606 let recovery = dispatch
7607 .recovery_event
7608 .as_ref()
7609 .expect("recovery cancel expected");
7610 assert!(matches!(
7611 recovery.kind,
7612 PaneSemanticInputEventKind::Cancel {
7613 target: Some(actual_target),
7614 reason: PaneCancelReason::PointerCancel
7615 } if actual_target == first_target
7616 ));
7617 let primary = dispatch
7618 .primary_event
7619 .as_ref()
7620 .expect("second pointer down expected");
7621 assert!(matches!(
7622 primary.kind,
7623 PaneSemanticInputEventKind::PointerDown {
7624 target: actual_target,
7625 pointer_id: 1,
7626 button: PanePointerButton::Primary,
7627 position
7628 } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
7629 ));
7630 assert_eq!(recovery.sequence, 2);
7631 assert_eq!(primary.sequence, 3);
7632 assert!(matches!(
7633 dispatch.log.outcome,
7634 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
7635 ));
7636 assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
7637 }
7638
7639 #[test]
7640 fn pane_terminal_adapter_modifier_parity() {
7641 let mut adapter =
7642 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7643 let target = pane_target(SplitAxis::Horizontal);
7644
7645 let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
7646 .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
7647 let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
7648 let event = dispatch.primary_event.expect("semantic event");
7649 assert!(event.modifiers.shift);
7650 assert!(event.modifiers.alt);
7651 assert!(event.modifiers.ctrl);
7652 assert!(event.modifiers.meta);
7653 }
7654
7655 #[test]
7656 fn pane_terminal_adapter_keyboard_resize_mapping() {
7657 let mut adapter =
7658 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7659 let target = pane_target(SplitAxis::Horizontal);
7660
7661 let key = KeyEvent::new(KeyCode::Right);
7662 let dispatch = adapter.translate(&Event::Key(key), Some(target));
7663 let event = dispatch.primary_event.expect("keyboard resize event");
7664 assert!(matches!(
7665 event.kind,
7666 PaneSemanticInputEventKind::KeyboardResize {
7667 target: actual_target,
7668 direction: PaneResizeDirection::Increase,
7669 units: 1
7670 } if actual_target == target
7671 ));
7672
7673 let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
7674 let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
7675 let shifted_event = shifted_dispatch
7676 .primary_event
7677 .expect("shifted resize event");
7678 assert!(matches!(
7679 shifted_event.kind,
7680 PaneSemanticInputEventKind::KeyboardResize {
7681 direction: PaneResizeDirection::Increase,
7682 units: 5,
7683 ..
7684 }
7685 ));
7686 assert!(shifted_event.modifiers.shift);
7687 }
7688
7689 #[test]
7690 fn pane_terminal_adapter_keyboard_resize_requires_focus() {
7691 let mut adapter =
7692 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7693 let target = pane_target(SplitAxis::Horizontal);
7694
7695 let _ = adapter.translate(&Event::Focus(false), None);
7696 assert!(!adapter.window_focused());
7697
7698 let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7699 assert!(unfocused.primary_event.is_none());
7700 assert!(matches!(
7701 unfocused.log.outcome,
7702 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
7703 ));
7704
7705 let _ = adapter.translate(&Event::Focus(true), None);
7706 assert!(adapter.window_focused());
7707
7708 let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
7709 assert!(focused.primary_event.is_some());
7710 }
7711
7712 #[test]
7713 fn pane_terminal_adapter_drag_updates_are_coalesced() {
7714 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
7715 drag_update_coalesce_distance: 2,
7716 ..PaneTerminalAdapterConfig::default()
7717 })
7718 .expect("valid adapter");
7719 let target = pane_target(SplitAxis::Horizontal);
7720
7721 let down = Event::Mouse(MouseEvent::new(
7722 MouseEventKind::Down(MouseButton::Left),
7723 10,
7724 4,
7725 ));
7726 let _ = adapter.translate(&down, Some(target));
7727
7728 let drag_start = Event::Mouse(MouseEvent::new(
7729 MouseEventKind::Drag(MouseButton::Left),
7730 14,
7731 4,
7732 ));
7733 let started = adapter.translate(&drag_start, None);
7734 assert!(started.primary_event.is_some());
7735 assert!(matches!(
7736 adapter.machine_state(),
7737 PaneDragResizeState::Dragging { .. }
7738 ));
7739
7740 let coalesced = Event::Mouse(MouseEvent::new(
7741 MouseEventKind::Drag(MouseButton::Left),
7742 15,
7743 4,
7744 ));
7745 let coalesced_dispatch = adapter.translate(&coalesced, None);
7746 assert!(coalesced_dispatch.primary_event.is_none());
7747 assert!(matches!(
7748 coalesced_dispatch.log.outcome,
7749 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
7750 ));
7751
7752 let forwarded = Event::Mouse(MouseEvent::new(
7753 MouseEventKind::Drag(MouseButton::Left),
7754 16,
7755 4,
7756 ));
7757 let forwarded_dispatch = adapter.translate(&forwarded, None);
7758 let forwarded_event = forwarded_dispatch
7759 .primary_event
7760 .as_ref()
7761 .expect("coalesced movement should flush once threshold reached");
7762 assert!(matches!(
7763 forwarded_event.kind,
7764 PaneSemanticInputEventKind::PointerMove {
7765 delta_x: 2,
7766 delta_y: 0,
7767 ..
7768 }
7769 ));
7770 }
7771
7772 #[test]
7773 fn pane_terminal_adapter_motion_tracks_direction_changes() {
7774 let mut adapter =
7775 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7776 let target = pane_target(SplitAxis::Horizontal);
7777
7778 let down = Event::Mouse(MouseEvent::new(
7779 MouseEventKind::Down(MouseButton::Left),
7780 10,
7781 4,
7782 ));
7783 let _ = adapter.translate(&down, Some(target));
7784
7785 let drag_forward = Event::Mouse(MouseEvent::new(
7786 MouseEventKind::Drag(MouseButton::Left),
7787 14,
7788 4,
7789 ));
7790 let forward_dispatch = adapter.translate(&drag_forward, None);
7791 let forward_motion = forward_dispatch
7792 .motion
7793 .expect("forward drag should emit motion metadata");
7794 assert_eq!(forward_motion.direction_changes, 0);
7795
7796 let drag_reverse = Event::Mouse(MouseEvent::new(
7797 MouseEventKind::Drag(MouseButton::Left),
7798 12,
7799 4,
7800 ));
7801 let reverse_dispatch = adapter.translate(&drag_reverse, None);
7802 let reverse_motion = reverse_dispatch
7803 .motion
7804 .expect("reverse drag should emit motion metadata");
7805 assert_eq!(reverse_motion.direction_changes, 1);
7806
7807 let up = Event::Mouse(MouseEvent::new(
7808 MouseEventKind::Up(MouseButton::Left),
7809 12,
7810 4,
7811 ));
7812 let up_dispatch = adapter.translate(&up, None);
7813 let up_motion = up_dispatch
7814 .motion
7815 .expect("release should include cumulative motion metadata");
7816 assert_eq!(up_motion.direction_changes, 1);
7817 }
7818
7819 #[test]
7820 fn pane_terminal_adapter_translate_with_handles_resolves_target() {
7821 let tree = nested_pane_tree();
7822 let layout = tree
7823 .solve_layout(Rect::new(0, 0, 50, 20))
7824 .expect("layout should solve");
7825 let handles =
7826 pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
7827 let mut adapter =
7828 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7829
7830 let down = Event::Mouse(MouseEvent::new(
7831 MouseEventKind::Down(MouseButton::Left),
7832 25,
7833 10,
7834 ));
7835 let dispatch = adapter.translate_with_handles(&down, &handles);
7836 let event = dispatch
7837 .primary_event
7838 .as_ref()
7839 .expect("pointer down should be routed from handles");
7840 assert!(matches!(
7841 event.kind,
7842 PaneSemanticInputEventKind::PointerDown {
7843 target:
7844 PaneResizeTarget {
7845 split_id,
7846 axis: SplitAxis::Horizontal
7847 },
7848 ..
7849 } if split_id == pane_id(1)
7850 ));
7851 }
7852
7853 #[test]
7854 fn model_update() {
7855 let mut model = TestModel { value: 0 };
7856 model.update(TestMsg::Increment);
7857 assert_eq!(model.value, 1);
7858 model.update(TestMsg::Decrement);
7859 assert_eq!(model.value, 0);
7860 assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
7861 }
7862
7863 #[test]
7864 fn model_init_default() {
7865 let mut model = TestModel { value: 0 };
7866 let cmd = model.init();
7867 assert!(matches!(cmd, Cmd::None));
7868 }
7869
7870 #[test]
7877 fn cmd_sequence_executes_in_order() {
7878 use crate::simulator::ProgramSimulator;
7880
7881 struct SeqModel {
7882 trace: Vec<i32>,
7883 }
7884
7885 #[derive(Debug)]
7886 enum SeqMsg {
7887 Append(i32),
7888 TriggerSequence,
7889 }
7890
7891 impl From<Event> for SeqMsg {
7892 fn from(_: Event) -> Self {
7893 SeqMsg::Append(0)
7894 }
7895 }
7896
7897 impl Model for SeqModel {
7898 type Message = SeqMsg;
7899
7900 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7901 match msg {
7902 SeqMsg::Append(n) => {
7903 self.trace.push(n);
7904 Cmd::none()
7905 }
7906 SeqMsg::TriggerSequence => Cmd::sequence(vec![
7907 Cmd::msg(SeqMsg::Append(1)),
7908 Cmd::msg(SeqMsg::Append(2)),
7909 Cmd::msg(SeqMsg::Append(3)),
7910 ]),
7911 }
7912 }
7913
7914 fn view(&self, _frame: &mut Frame) {}
7915 }
7916
7917 let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
7918 sim.init();
7919 sim.send(SeqMsg::TriggerSequence);
7920
7921 assert_eq!(sim.model().trace, vec![1, 2, 3]);
7922 }
7923
7924 #[test]
7925 fn cmd_batch_executes_all_regardless_of_order() {
7926 use crate::simulator::ProgramSimulator;
7928
7929 struct BatchModel {
7930 values: Vec<i32>,
7931 }
7932
7933 #[derive(Debug)]
7934 enum BatchMsg {
7935 Add(i32),
7936 TriggerBatch,
7937 }
7938
7939 impl From<Event> for BatchMsg {
7940 fn from(_: Event) -> Self {
7941 BatchMsg::Add(0)
7942 }
7943 }
7944
7945 impl Model for BatchModel {
7946 type Message = BatchMsg;
7947
7948 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7949 match msg {
7950 BatchMsg::Add(n) => {
7951 self.values.push(n);
7952 Cmd::none()
7953 }
7954 BatchMsg::TriggerBatch => Cmd::batch(vec![
7955 Cmd::msg(BatchMsg::Add(10)),
7956 Cmd::msg(BatchMsg::Add(20)),
7957 Cmd::msg(BatchMsg::Add(30)),
7958 ]),
7959 }
7960 }
7961
7962 fn view(&self, _frame: &mut Frame) {}
7963 }
7964
7965 let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
7966 sim.init();
7967 sim.send(BatchMsg::TriggerBatch);
7968
7969 assert_eq!(sim.model().values.len(), 3);
7971 assert!(sim.model().values.contains(&10));
7972 assert!(sim.model().values.contains(&20));
7973 assert!(sim.model().values.contains(&30));
7974 }
7975
7976 #[test]
7977 fn cmd_sequence_stops_on_quit() {
7978 use crate::simulator::ProgramSimulator;
7980
7981 struct SeqQuitModel {
7982 trace: Vec<i32>,
7983 }
7984
7985 #[derive(Debug)]
7986 enum SeqQuitMsg {
7987 Append(i32),
7988 TriggerSequenceWithQuit,
7989 }
7990
7991 impl From<Event> for SeqQuitMsg {
7992 fn from(_: Event) -> Self {
7993 SeqQuitMsg::Append(0)
7994 }
7995 }
7996
7997 impl Model for SeqQuitModel {
7998 type Message = SeqQuitMsg;
7999
8000 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8001 match msg {
8002 SeqQuitMsg::Append(n) => {
8003 self.trace.push(n);
8004 Cmd::none()
8005 }
8006 SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
8007 Cmd::msg(SeqQuitMsg::Append(1)),
8008 Cmd::quit(),
8009 Cmd::msg(SeqQuitMsg::Append(2)), ]),
8011 }
8012 }
8013
8014 fn view(&self, _frame: &mut Frame) {}
8015 }
8016
8017 let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
8018 sim.init();
8019 sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
8020
8021 assert_eq!(sim.model().trace, vec![1]);
8022 assert!(!sim.is_running());
8023 }
8024
8025 #[test]
8026 fn identical_input_produces_identical_state() {
8027 use crate::simulator::ProgramSimulator;
8029
8030 fn run_scenario() -> Vec<i32> {
8031 struct DetModel {
8032 values: Vec<i32>,
8033 }
8034
8035 #[derive(Debug, Clone)]
8036 enum DetMsg {
8037 Add(i32),
8038 Double,
8039 }
8040
8041 impl From<Event> for DetMsg {
8042 fn from(_: Event) -> Self {
8043 DetMsg::Add(1)
8044 }
8045 }
8046
8047 impl Model for DetModel {
8048 type Message = DetMsg;
8049
8050 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8051 match msg {
8052 DetMsg::Add(n) => {
8053 self.values.push(n);
8054 Cmd::none()
8055 }
8056 DetMsg::Double => {
8057 if let Some(&last) = self.values.last() {
8058 self.values.push(last * 2);
8059 }
8060 Cmd::none()
8061 }
8062 }
8063 }
8064
8065 fn view(&self, _frame: &mut Frame) {}
8066 }
8067
8068 let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
8069 sim.init();
8070 sim.send(DetMsg::Add(5));
8071 sim.send(DetMsg::Double);
8072 sim.send(DetMsg::Add(3));
8073 sim.send(DetMsg::Double);
8074
8075 sim.model().values.clone()
8076 }
8077
8078 let run1 = run_scenario();
8080 let run2 = run_scenario();
8081 let run3 = run_scenario();
8082
8083 assert_eq!(run1, run2);
8084 assert_eq!(run2, run3);
8085 assert_eq!(run1, vec![5, 10, 3, 6]);
8086 }
8087
8088 #[test]
8089 fn identical_state_produces_identical_render() {
8090 use crate::simulator::ProgramSimulator;
8092
8093 struct RenderModel {
8094 counter: i32,
8095 }
8096
8097 #[derive(Debug)]
8098 enum RenderMsg {
8099 Set(i32),
8100 }
8101
8102 impl From<Event> for RenderMsg {
8103 fn from(_: Event) -> Self {
8104 RenderMsg::Set(0)
8105 }
8106 }
8107
8108 impl Model for RenderModel {
8109 type Message = RenderMsg;
8110
8111 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8112 match msg {
8113 RenderMsg::Set(n) => {
8114 self.counter = n;
8115 Cmd::none()
8116 }
8117 }
8118 }
8119
8120 fn view(&self, frame: &mut Frame) {
8121 let text = format!("Value: {}", self.counter);
8122 for (i, c) in text.chars().enumerate() {
8123 if (i as u16) < frame.width() {
8124 use ftui_render::cell::Cell;
8125 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
8126 }
8127 }
8128 }
8129 }
8130
8131 let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
8133 let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
8134
8135 let buf1 = sim1.capture_frame(80, 24);
8136 let buf2 = sim2.capture_frame(80, 24);
8137
8138 for y in 0..24 {
8140 for x in 0..80 {
8141 let cell1 = buf1.get(x, y).unwrap();
8142 let cell2 = buf2.get(x, y).unwrap();
8143 assert_eq!(
8144 cell1.content.as_char(),
8145 cell2.content.as_char(),
8146 "Mismatch at ({}, {})",
8147 x,
8148 y
8149 );
8150 }
8151 }
8152 }
8153
8154 #[test]
8157 fn cmd_log_creates_log_command() {
8158 let cmd: Cmd<TestMsg> = Cmd::log("test message");
8159 assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
8160 }
8161
8162 #[test]
8163 fn cmd_log_from_string() {
8164 let msg = String::from("dynamic message");
8165 let cmd: Cmd<TestMsg> = Cmd::log(msg);
8166 assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
8167 }
8168
8169 #[test]
8170 fn program_simulator_logs_jsonl_with_seed_and_run_id() {
8171 use crate::simulator::ProgramSimulator;
8173
8174 struct LogModel {
8175 run_id: &'static str,
8176 seed: u64,
8177 }
8178
8179 #[derive(Debug)]
8180 enum LogMsg {
8181 Emit,
8182 }
8183
8184 impl From<Event> for LogMsg {
8185 fn from(_: Event) -> Self {
8186 LogMsg::Emit
8187 }
8188 }
8189
8190 impl Model for LogModel {
8191 type Message = LogMsg;
8192
8193 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
8194 let line = format!(
8195 r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
8196 self.run_id, self.seed
8197 );
8198 Cmd::log(line)
8199 }
8200
8201 fn view(&self, _frame: &mut Frame) {}
8202 }
8203
8204 let mut sim = ProgramSimulator::new(LogModel {
8205 run_id: "test-run-001",
8206 seed: 4242,
8207 });
8208 sim.init();
8209 sim.send(LogMsg::Emit);
8210
8211 let logs = sim.logs();
8212 assert_eq!(logs.len(), 1);
8213 assert!(logs[0].contains(r#""run_id":"test-run-001""#));
8214 assert!(logs[0].contains(r#""seed":4242"#));
8215 }
8216
8217 #[test]
8218 fn cmd_sequence_single_unwraps() {
8219 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
8220 assert!(matches!(cmd, Cmd::Quit));
8222 }
8223
8224 #[test]
8225 fn cmd_sequence_multiple() {
8226 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
8227 assert!(matches!(cmd, Cmd::Sequence(_)));
8228 }
8229
8230 #[test]
8231 fn cmd_default_is_none() {
8232 let cmd: Cmd<TestMsg> = Cmd::default();
8233 assert!(matches!(cmd, Cmd::None));
8234 }
8235
8236 #[test]
8237 fn cmd_debug_all_variants() {
8238 let none: Cmd<TestMsg> = Cmd::none();
8240 assert_eq!(format!("{none:?}"), "None");
8241
8242 let quit: Cmd<TestMsg> = Cmd::quit();
8243 assert_eq!(format!("{quit:?}"), "Quit");
8244
8245 let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
8246 assert!(format!("{msg:?}").starts_with("Msg("));
8247
8248 let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
8249 assert!(format!("{batch:?}").starts_with("Batch("));
8250
8251 let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
8252 assert!(format!("{seq:?}").starts_with("Sequence("));
8253
8254 let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
8255 assert!(format!("{tick:?}").starts_with("Tick("));
8256
8257 let log: Cmd<TestMsg> = Cmd::log("test");
8258 assert!(format!("{log:?}").starts_with("Log("));
8259 }
8260
8261 #[test]
8262 fn program_config_with_budget() {
8263 let budget = FrameBudgetConfig {
8264 total: Duration::from_millis(50),
8265 ..Default::default()
8266 };
8267 let config = ProgramConfig::default().with_budget(budget);
8268 assert_eq!(config.budget.total, Duration::from_millis(50));
8269 }
8270
8271 #[test]
8272 fn load_governor_default_is_enabled() {
8273 let config = LoadGovernorConfig::default();
8274 assert!(config.enabled);
8275 assert_eq!(
8276 config.budget_controller.degradation_floor,
8277 DegradationLevel::SimpleBorders
8278 );
8279 }
8280
8281 #[test]
8282 fn program_config_load_governor_builders() {
8283 let governor = LoadGovernorConfig::disabled().with_enabled(true);
8284 let config = ProgramConfig::default().with_load_governor(governor);
8285 assert!(config.load_governor.enabled);
8286
8287 let config = config.without_load_governor();
8288 assert!(!config.load_governor.enabled);
8289 }
8290
8291 fn governor_observation(
8292 frame_time_ms: f64,
8293 in_flight: u64,
8294 dropped: u64,
8295 degradation: DegradationLevel,
8296 resize_coalescing_active: bool,
8297 strict_semantics_violation: bool,
8298 ) -> LoadGovernorObservation {
8299 LoadGovernorObservation {
8300 frame_time_us: frame_time_ms * 1_000.0,
8301 budget_us: 16_000.0,
8302 degradation,
8303 queue: crate::effect_system::QueueTelemetry {
8304 enqueued: in_flight.saturating_add(dropped),
8305 processed: 0,
8306 dropped,
8307 high_water: in_flight,
8308 in_flight,
8309 },
8310 resize_coalescing_active,
8311 strict_semantics_violation,
8312 }
8313 }
8314
8315 fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
8316 let policy = LoadGovernorPolicy {
8317 recovery_intervals,
8318 ..Default::default()
8319 };
8320 LoadGovernorState::new(
8321 LoadGovernorConfig::enabled().with_policy(policy),
8322 max_queue_depth,
8323 )
8324 }
8325
8326 #[test]
8327 fn load_governor_policy_defaults_match_runtime_contract() {
8328 let policy = LoadGovernorPolicy::default().normalized();
8329
8330 assert_eq!(policy.stressed_queue_watermark, 0.5);
8331 assert_eq!(policy.degraded_queue_watermark, 0.8);
8332 assert_eq!(policy.recovery_queue_watermark, 0.25);
8333 assert_eq!(policy.recovery_intervals, 3);
8334 assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
8335 }
8336
8337 #[test]
8338 fn load_governor_classifies_queue_watermarks_and_recovery() {
8339 let mut governor = test_load_governor(100, 2);
8340
8341 let steady = governor.observe(governor_observation(
8342 8.0,
8343 0,
8344 0,
8345 DegradationLevel::Full,
8346 false,
8347 false,
8348 ));
8349 assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
8350 assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
8351 assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
8352
8353 let stressed = governor.observe(governor_observation(
8354 8.0,
8355 50,
8356 0,
8357 DegradationLevel::Full,
8358 false,
8359 false,
8360 ));
8361 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
8362 assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
8363 assert_eq!(
8364 stressed.disposition,
8365 RuntimeWorkDisposition::CoalesceVisibleDeferBackground
8366 );
8367 assert_eq!(stressed.reason_code, "queue_stressed_watermark");
8368
8369 let degraded = governor.observe(governor_observation(
8370 8.0,
8371 80,
8372 0,
8373 DegradationLevel::Full,
8374 false,
8375 false,
8376 ));
8377 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
8378 assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
8379 assert_eq!(
8380 degraded.disposition,
8381 RuntimeWorkDisposition::DeferBackgroundDropBestEffort
8382 );
8383 assert_eq!(degraded.reason_code, "queue_degraded_watermark");
8384
8385 let recovery_pending = governor.observe(governor_observation(
8386 8.0,
8387 10,
8388 0,
8389 DegradationLevel::Full,
8390 false,
8391 false,
8392 ));
8393 assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
8394 assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
8395 assert_eq!(recovery_pending.recovery_intervals_observed, 1);
8396
8397 let recovered = governor.observe(governor_observation(
8398 8.0,
8399 10,
8400 0,
8401 DegradationLevel::Full,
8402 false,
8403 false,
8404 ));
8405 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
8406 assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
8407 assert_eq!(
8408 recovered.disposition,
8409 RuntimeWorkDisposition::ReadmitAfterHysteresis
8410 );
8411
8412 let healthy = governor.observe(governor_observation(
8413 8.0,
8414 10,
8415 0,
8416 DegradationLevel::Full,
8417 false,
8418 false,
8419 ));
8420 assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
8421 assert_eq!(healthy.reason_code, "recovered_interval_closed");
8422 }
8423
8424 #[test]
8425 fn load_governor_uses_uncapped_budget_pressure_fallback() {
8426 let mut governor = test_load_governor(0, 2);
8427
8428 let stressed = governor.observe(governor_observation(
8429 20.0,
8430 0,
8431 0,
8432 DegradationLevel::Full,
8433 false,
8434 false,
8435 ));
8436 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
8437 assert_eq!(stressed.reason_code, "frame_budget_overrun");
8438 assert_eq!(stressed.queue_max_depth, None);
8439
8440 let degraded = governor.observe(governor_observation(
8441 8.0,
8442 0,
8443 0,
8444 DegradationLevel::SimpleBorders,
8445 false,
8446 false,
8447 ));
8448 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
8449 assert_eq!(degraded.reason_code, "budget_degradation_active");
8450 }
8451
8452 #[test]
8453 fn load_governor_strict_semantics_failure_is_terminal() {
8454 let mut governor = test_load_governor(100, 2);
8455
8456 let unsafe_snapshot = governor.observe(governor_observation(
8457 8.0,
8458 0,
8459 0,
8460 DegradationLevel::Full,
8461 false,
8462 true,
8463 ));
8464
8465 assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
8466 assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
8467 assert_eq!(
8468 unsafe_snapshot.disposition,
8469 RuntimeWorkDisposition::FailFastStrictGuarantee
8470 );
8471 assert!(!unsafe_snapshot.strict_semantics_preserved);
8472 assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
8473 }
8474
8475 #[test]
8476 fn load_governor_unsafe_latches_through_later_pressure() {
8477 let mut governor = test_load_governor(100, 2);
8478
8479 let entered = governor.observe(governor_observation(
8481 8.0,
8482 0,
8483 0,
8484 DegradationLevel::Full,
8485 false,
8486 true,
8487 ));
8488 assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
8489
8490 let hard = governor.observe(governor_observation(
8493 8.0,
8494 90,
8495 0,
8496 DegradationLevel::SimpleBorders,
8497 false,
8498 false,
8499 ));
8500 assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
8501 assert_eq!(
8502 hard.disposition,
8503 RuntimeWorkDisposition::FailFastStrictGuarantee
8504 );
8505 assert!(!hard.strict_semantics_preserved);
8506 assert_eq!(hard.reason_code, "strict_semantics_violation");
8507
8508 let soft = governor.observe(governor_observation(
8511 8.0,
8512 60,
8513 0,
8514 DegradationLevel::Full,
8515 true,
8516 false,
8517 ));
8518 assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
8519
8520 let steady = governor.observe(governor_observation(
8522 8.0,
8523 0,
8524 0,
8525 DegradationLevel::Full,
8526 false,
8527 false,
8528 ));
8529 assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
8530 assert!(!steady.strict_semantics_preserved);
8531 }
8532
8533 #[test]
8534 fn load_governor_hysteresis_prevents_single_sample_recovery() {
8535 let mut governor = test_load_governor(100, 3);
8536
8537 governor.observe(governor_observation(
8538 8.0,
8539 90,
8540 0,
8541 DegradationLevel::Full,
8542 false,
8543 false,
8544 ));
8545
8546 for expected in 1..=2 {
8547 let snapshot = governor.observe(governor_observation(
8548 8.0,
8549 0,
8550 0,
8551 DegradationLevel::Full,
8552 false,
8553 false,
8554 ));
8555 assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
8556 assert_eq!(snapshot.recovery_intervals_observed, expected);
8557 assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
8558 }
8559
8560 let recovered = governor.observe(governor_observation(
8561 8.0,
8562 0,
8563 0,
8564 DegradationLevel::Full,
8565 false,
8566 false,
8567 ));
8568 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
8569 }
8570
8571 #[test]
8572 fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
8573 let mut governor = test_load_governor(50, 2);
8574 let scenario = [
8575 governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
8576 governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
8577 governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
8578 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8579 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8580 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
8581 ];
8582
8583 let mut modes = Vec::new();
8584 for observation in scenario {
8585 let snapshot = governor.observe(observation);
8586 modes.push(snapshot.mode);
8587 println!(
8588 "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
8589 snapshot.mode.as_str(),
8590 snapshot.pressure_class.as_str(),
8591 snapshot.disposition.as_str(),
8592 snapshot.reason_code,
8593 snapshot.transition,
8594 snapshot.deferred_work_total,
8595 snapshot.coalesced_work_total,
8596 snapshot.dropped_work_total
8597 );
8598 }
8599
8600 assert!(modes.contains(&RuntimeLoadMode::Stressed));
8601 assert!(modes.contains(&RuntimeLoadMode::Degraded));
8602 assert!(modes.contains(&RuntimeLoadMode::Recovered));
8603 assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
8604 }
8605
8606 #[test]
8607 fn headless_program_default_load_governor_attaches_controller() {
8608 let program =
8609 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8610 assert!(program.budget.controller().is_some());
8611 }
8612
8613 #[test]
8614 fn headless_program_load_governor_target_tracks_frame_budget() {
8615 let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
8616 total: Duration::from_millis(50),
8617 ..Default::default()
8618 });
8619 let program = headless_program_with_config(TestModel { value: 0 }, config);
8620 assert_eq!(
8621 program.budget.controller().unwrap().config().target,
8622 Duration::from_millis(50)
8623 );
8624 }
8625
8626 #[test]
8627 fn headless_program_without_load_governor_uses_legacy_budget() {
8628 let program = headless_program_with_config(
8629 TestModel { value: 0 },
8630 ProgramConfig::default().without_load_governor(),
8631 );
8632 assert!(program.budget.controller().is_none());
8633 }
8634
8635 #[test]
8636 fn app_builder_without_load_governor_sets_config() {
8637 let builder = App::new(TestModel { value: 0 }).without_load_governor();
8638 assert!(!builder.config.load_governor.enabled);
8639 }
8640
8641 #[test]
8642 fn program_config_with_conformal() {
8643 let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
8644 alpha: 0.2,
8645 ..Default::default()
8646 });
8647 assert!(config.conformal_config.is_some());
8648 assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
8649 }
8650
8651 #[test]
8652 fn program_config_forced_size_clamps_minimums() {
8653 let config = ProgramConfig::default().with_forced_size(0, 0);
8654 assert_eq!(config.forced_size, Some((1, 1)));
8655
8656 let cleared = config.without_forced_size();
8657 assert!(cleared.forced_size.is_none());
8658 }
8659
8660 #[test]
8661 fn effect_queue_config_defaults_are_safe() {
8662 let config = EffectQueueConfig::default();
8663 assert!(!config.enabled);
8664 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
8665 assert!(config.scheduler.smith_enabled);
8666 assert!(!config.scheduler.preemptive);
8667 assert_eq!(config.scheduler.aging_factor, 0.0);
8668 assert_eq!(config.scheduler.wait_starve_ms, 0.0);
8669 }
8670
8671 #[test]
8672 fn handle_effect_command_enqueues_or_executes_inline() {
8673 let (result_tx, result_rx) = mpsc::channel::<u32>();
8674 let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
8675 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8676
8677 let ran = Arc::new(AtomicUsize::new(0));
8678 let ran_task = ran.clone();
8679 let cmd = EffectCommand::Enqueue(
8680 TaskSpec::default(),
8681 Box::new(move || {
8682 ran_task.fetch_add(1, Ordering::SeqCst);
8683 7
8684 }),
8685 );
8686
8687 let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
8688 assert_eq!(shutdown, EffectLoopControl::Continue);
8689 assert_eq!(ran.load(Ordering::SeqCst), 0);
8690 assert_eq!(tasks.len(), 1);
8691 assert!(result_rx.try_recv().is_err());
8692
8693 let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
8694 max_queue_size: 0,
8695 ..Default::default()
8696 });
8697 let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8698 let ran_full = Arc::new(AtomicUsize::new(0));
8699 let ran_full_task = ran_full.clone();
8700 let cmd_full = EffectCommand::Enqueue(
8701 TaskSpec::default(),
8702 Box::new(move || {
8703 ran_full_task.fetch_add(1, Ordering::SeqCst);
8704 42
8705 }),
8706 );
8707
8708 let shutdown_full = handle_effect_command(
8709 cmd_full,
8710 &mut full_scheduler,
8711 &mut full_tasks,
8712 &result_tx,
8713 None,
8714 0,
8715 );
8716 assert_eq!(shutdown_full, EffectLoopControl::Continue);
8717 assert!(full_tasks.is_empty());
8718 assert_eq!(ran_full.load(Ordering::SeqCst), 1);
8719 assert_eq!(
8720 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
8721 42
8722 );
8723
8724 let shutdown = handle_effect_command(
8725 EffectCommand::Shutdown,
8726 &mut full_scheduler,
8727 &mut full_tasks,
8728 &result_tx,
8729 None,
8730 0,
8731 );
8732 assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
8733 }
8734
8735 #[test]
8736 fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
8737 let evidence_path = temp_evidence_path("task_executor_backpressure");
8738 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
8739 let sink = EvidenceSink::from_config(&sink_config)
8740 .expect("evidence sink config")
8741 .expect("evidence sink enabled");
8742 let (result_tx, result_rx) = mpsc::channel::<u32>();
8743 let mut scheduler = QueueingScheduler::new(SchedulerConfig {
8744 max_queue_size: 0,
8745 ..Default::default()
8746 });
8747 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
8748
8749 let shutdown = handle_effect_command(
8750 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
8751 &mut scheduler,
8752 &mut tasks,
8753 &result_tx,
8754 Some(&sink),
8755 0,
8756 );
8757
8758 assert_eq!(shutdown, EffectLoopControl::Continue);
8759 assert!(tasks.is_empty());
8760 assert_eq!(
8761 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
8762 7
8763 );
8764
8765 let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
8766 assert_eq!(backpressure_line["backend"], "queued");
8767 assert_eq!(backpressure_line["action"], "inline_fallback");
8768 assert_eq!(backpressure_line["max_queue_size"], 0);
8769 assert_eq!(backpressure_line["total_rejected"], 1);
8770
8771 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
8772 assert_eq!(completion_line["backend"], "queued-inline-fallback");
8773 assert!(completion_line["duration_us"].is_number());
8774 }
8775
8776 #[test]
8777 fn effect_queue_loop_executes_tasks_and_shutdowns() {
8778 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8779 let (result_tx, result_rx) = mpsc::channel::<u32>();
8780 let config = EffectQueueConfig {
8781 enabled: true,
8782 backend: TaskExecutorBackend::EffectQueue,
8783 scheduler: SchedulerConfig {
8784 preemptive: false,
8785 ..Default::default()
8786 },
8787 explicit_backend: true,
8788 ..Default::default()
8789 };
8790
8791 let handle = std::thread::spawn(move || {
8792 effect_queue_loop(config, cmd_rx, result_tx, None);
8793 });
8794
8795 cmd_tx
8796 .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
8797 .unwrap();
8798 cmd_tx
8799 .send(EffectCommand::Enqueue(
8800 TaskSpec::new(2.0, 5.0).with_name("second"),
8801 Box::new(|| 20),
8802 ))
8803 .unwrap();
8804
8805 let mut results = vec![
8806 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8807 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8808 ];
8809 results.sort_unstable();
8810 assert_eq!(results, vec![10, 20]);
8811
8812 cmd_tx.send(EffectCommand::Shutdown).unwrap();
8813 let _ = handle.join();
8814 }
8815
8816 #[test]
8817 fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
8818 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8819 let (result_tx, result_rx) = mpsc::channel::<u32>();
8820 let config = EffectQueueConfig {
8821 enabled: true,
8822 backend: TaskExecutorBackend::EffectQueue,
8823 scheduler: SchedulerConfig {
8824 preemptive: false,
8825 ..Default::default()
8826 },
8827 explicit_backend: true,
8828 ..Default::default()
8829 };
8830
8831 let handle = std::thread::spawn(move || {
8832 effect_queue_loop(config, cmd_rx, result_tx, None);
8833 });
8834
8835 cmd_tx
8836 .send(EffectCommand::Enqueue(
8837 TaskSpec::default().with_name("slow"),
8838 Box::new(|| {
8839 std::thread::sleep(Duration::from_millis(20));
8840 10
8841 }),
8842 ))
8843 .unwrap();
8844 cmd_tx
8845 .send(EffectCommand::Enqueue(
8846 TaskSpec::new(2.0, 5.0).with_name("fast"),
8847 Box::new(|| 20),
8848 ))
8849 .unwrap();
8850 cmd_tx.send(EffectCommand::Shutdown).unwrap();
8851
8852 let mut results = vec![
8853 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8854 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8855 ];
8856 results.sort_unstable();
8857 assert_eq!(results, vec![10, 20]);
8858
8859 handle
8860 .join()
8861 .expect("effect queue thread joins after draining");
8862 }
8863
8864 #[test]
8865 fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
8866 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8867 let (result_tx, result_rx) = mpsc::channel::<u32>();
8868 let config = EffectQueueConfig {
8869 enabled: true,
8870 backend: TaskExecutorBackend::EffectQueue,
8871 scheduler: SchedulerConfig {
8872 preemptive: false,
8873 ..Default::default()
8874 },
8875 explicit_backend: true,
8876 ..Default::default()
8877 };
8878
8879 let handle = std::thread::spawn(move || {
8880 effect_queue_loop(config, cmd_rx, result_tx, None);
8881 });
8882
8883 cmd_tx
8884 .send(EffectCommand::Enqueue(
8885 TaskSpec::new(3.0, 1.0).with_name("panic"),
8886 Box::new(|| panic!("queued panic")),
8887 ))
8888 .unwrap();
8889 cmd_tx
8890 .send(EffectCommand::Enqueue(
8891 TaskSpec::new(1.0, 5.0).with_name("after"),
8892 Box::new(|| 99),
8893 ))
8894 .unwrap();
8895
8896 assert_eq!(
8897 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8898 99
8899 );
8900
8901 cmd_tx.send(EffectCommand::Shutdown).unwrap();
8902 handle
8903 .join()
8904 .expect("effect queue thread survives task panic");
8905 }
8906
8907 #[test]
8908 fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
8909 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
8910 let (result_tx, result_rx) = mpsc::channel::<u32>();
8911 let config = EffectQueueConfig {
8912 enabled: true,
8913 backend: TaskExecutorBackend::EffectQueue,
8914 scheduler: SchedulerConfig {
8915 preemptive: false,
8916 ..Default::default()
8917 },
8918 explicit_backend: true,
8919 ..Default::default()
8920 };
8921
8922 let handle = std::thread::spawn(move || {
8923 effect_queue_loop(config, cmd_rx, result_tx, None);
8924 });
8925
8926 cmd_tx
8927 .send(EffectCommand::Enqueue(
8928 TaskSpec::default().with_name("slow"),
8929 Box::new(|| {
8930 std::thread::sleep(Duration::from_millis(20));
8931 10
8932 }),
8933 ))
8934 .unwrap();
8935 cmd_tx.send(EffectCommand::Shutdown).unwrap();
8936 cmd_tx
8937 .send(EffectCommand::Enqueue(
8938 TaskSpec::new(1.0, 1.0).with_name("late"),
8939 Box::new(|| 99),
8940 ))
8941 .unwrap();
8942
8943 assert_eq!(
8944 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
8945 10
8946 );
8947 assert!(
8948 result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
8949 "post-shutdown enqueue should not execute"
8950 );
8951
8952 handle
8953 .join()
8954 .expect("effect queue thread joins after rejecting post-shutdown work");
8955 }
8956
8957 #[test]
8958 fn effect_queue_enqueue_after_shutdown_records_drop() {
8959 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8960 drop(rx);
8961
8962 let queue = EffectQueue {
8963 sender: tx,
8964 handle: None,
8965 closed: true,
8966 };
8967 let runs = Arc::new(AtomicUsize::new(0));
8968 let before = crate::effect_system::effects_queue_dropped();
8969
8970 queue.enqueue(
8971 TaskSpec::default(),
8972 Box::new({
8973 let runs = Arc::clone(&runs);
8974 move || {
8975 runs.fetch_add(1, Ordering::SeqCst);
8976 7
8977 }
8978 }),
8979 );
8980
8981 let after = crate::effect_system::effects_queue_dropped();
8982 assert_eq!(runs.load(Ordering::SeqCst), 0);
8983 assert!(
8984 after > before,
8985 "enqueue after shutdown should increment dropped counter"
8986 );
8987 }
8988
8989 #[test]
8990 fn effect_queue_enqueue_with_closed_channel_records_drop() {
8991 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
8992 drop(rx);
8993
8994 let queue = EffectQueue {
8995 sender: tx,
8996 handle: None,
8997 closed: false,
8998 };
8999 let runs = Arc::new(AtomicUsize::new(0));
9000 let before = crate::effect_system::effects_queue_dropped();
9001
9002 queue.enqueue(
9003 TaskSpec::default(),
9004 Box::new({
9005 let runs = Arc::clone(&runs);
9006 move || {
9007 runs.fetch_add(1, Ordering::SeqCst);
9008 9
9009 }
9010 }),
9011 );
9012
9013 let after = crate::effect_system::effects_queue_dropped();
9014 assert_eq!(runs.load(Ordering::SeqCst), 0);
9015 assert!(
9016 after > before,
9017 "enqueue into a closed queue channel should increment dropped counter"
9018 );
9019 }
9020
9021 #[test]
9026 fn backpressure_drops_tasks_beyond_max_depth() {
9027 let (result_tx, _result_rx) = mpsc::channel::<u32>();
9028 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9029 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9030
9031 let r1 = handle_effect_command(
9033 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
9034 &mut scheduler,
9035 &mut tasks,
9036 &result_tx,
9037 None,
9038 2,
9039 );
9040 assert_eq!(r1, EffectLoopControl::Continue);
9041 assert_eq!(tasks.len(), 1);
9042
9043 let r2 = handle_effect_command(
9044 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
9045 &mut scheduler,
9046 &mut tasks,
9047 &result_tx,
9048 None,
9049 2,
9050 );
9051 assert_eq!(r2, EffectLoopControl::Continue);
9052 assert_eq!(tasks.len(), 2);
9053
9054 let dropped_before = crate::effect_system::effects_queue_dropped();
9056 let r3 = handle_effect_command(
9057 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
9058 &mut scheduler,
9059 &mut tasks,
9060 &result_tx,
9061 None,
9062 2,
9063 );
9064 assert_eq!(r3, EffectLoopControl::Continue);
9065 assert_eq!(
9066 tasks.len(),
9067 2,
9068 "task should have been dropped, not enqueued"
9069 );
9070 assert!(
9071 crate::effect_system::effects_queue_dropped() > dropped_before,
9072 "dropped counter should increment"
9073 );
9074 }
9075
9076 #[test]
9077 fn backpressure_zero_depth_means_unbounded() {
9078 let (result_tx, _result_rx) = mpsc::channel::<u32>();
9079 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9080 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9081
9082 for i in 0..20 {
9084 let r = handle_effect_command(
9085 EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
9086 &mut scheduler,
9087 &mut tasks,
9088 &result_tx,
9089 None,
9090 0,
9091 );
9092 assert_eq!(r, EffectLoopControl::Continue);
9093 }
9094 }
9096
9097 #[test]
9098 fn inline_auto_remeasure_reset_clears_decision() {
9099 let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
9100 state.sampler.decide(Instant::now());
9101 assert!(state.sampler.last_decision().is_some());
9102
9103 state.reset();
9104 assert!(state.sampler.last_decision().is_none());
9105 }
9106
9107 #[test]
9108 fn budget_decision_jsonl_contains_required_fields() {
9109 let evidence = BudgetDecisionEvidence {
9110 frame_idx: 7,
9111 decision: BudgetDecision::Degrade,
9112 controller_decision: BudgetDecision::Hold,
9113 degradation_before: DegradationLevel::Full,
9114 degradation_after: DegradationLevel::NoStyling,
9115 frame_time_us: 12_345.678,
9116 budget_us: 16_000.0,
9117 pid_output: 1.25,
9118 pid_p: 0.5,
9119 pid_i: 0.25,
9120 pid_d: 0.5,
9121 e_value: 2.0,
9122 frames_observed: 42,
9123 frames_since_change: 3,
9124 in_warmup: false,
9125 controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
9126 load_governor: LoadGovernorSnapshot {
9127 mode: RuntimeLoadMode::Degraded,
9128 mode_before: RuntimeLoadMode::Stressed,
9129 pressure_class: RuntimePressureClass::HardOverload,
9130 disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
9131 reason_code: "budget_degradation_active",
9132 transition: true,
9133 strict_semantics_preserved: true,
9134 queue_in_flight: 8,
9135 queue_max_depth: Some(10),
9136 queue_dropped_delta: 0,
9137 resize_coalescing_active: false,
9138 recovery_intervals_observed: 0,
9139 recovery_intervals_required: 3,
9140 deferred_work_total: 2,
9141 coalesced_work_total: 1,
9142 dropped_work_total: 0,
9143 },
9144 conformal: Some(ConformalEvidence {
9145 bucket_key: "inline:dirty:10".to_string(),
9146 n_b: 32,
9147 alpha: 0.05,
9148 q_b: 1000.0,
9149 y_hat: 12_000.0,
9150 upper_us: 13_000.0,
9151 risk: true,
9152 fallback_level: 1,
9153 window_size: 256,
9154 reset_count: 2,
9155 }),
9156 };
9157
9158 let jsonl = evidence.to_jsonl();
9159 assert!(jsonl.contains("\"event\":\"budget_decision\""));
9160 assert!(jsonl.contains("\"decision\":\"degrade\""));
9161 assert!(jsonl.contains("\"decision_controller\":\"stay\""));
9162 assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
9163 assert!(jsonl.contains("\"degradation_before\":\"Full\""));
9164 assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
9165 assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
9166 assert!(jsonl.contains("\"budget_us\":16000.000000"));
9167 assert!(jsonl.contains("\"pid_output\":1.250000"));
9168 assert!(jsonl.contains("\"e_value\":2.000000"));
9169 assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
9170 assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
9171 assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
9172 assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
9173 assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
9174 assert!(jsonl.contains("\"governor_transition\":true"));
9175 assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
9176 assert!(jsonl.contains("\"queue_in_flight\":8"));
9177 assert!(jsonl.contains("\"queue_max_depth\":10"));
9178 assert!(jsonl.contains("\"deferred_work_total\":2"));
9179 assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
9180 assert!(jsonl.contains("\"n_b\":32"));
9181 assert!(jsonl.contains("\"alpha\":0.050000"));
9182 assert!(jsonl.contains("\"q_b\":1000.000000"));
9183 assert!(jsonl.contains("\"y_hat\":12000.000000"));
9184 assert!(jsonl.contains("\"upper_us\":13000.000000"));
9185 assert!(jsonl.contains("\"risk\":true"));
9186 assert!(jsonl.contains("\"fallback_level\":1"));
9187 assert!(jsonl.contains("\"window_size\":256"));
9188 assert!(jsonl.contains("\"reset_count\":2"));
9189 }
9190
9191 fn make_signal(
9192 widget_id: u64,
9193 essential: bool,
9194 priority: f32,
9195 staleness_ms: u64,
9196 cost_us: f32,
9197 ) -> WidgetSignal {
9198 WidgetSignal {
9199 widget_id,
9200 essential,
9201 priority,
9202 staleness_ms,
9203 focus_boost: 0.0,
9204 interaction_boost: 0.0,
9205 area_cells: 1,
9206 cost_estimate_us: cost_us,
9207 recent_cost_us: 0.0,
9208 estimate_source: CostEstimateSource::FixedDefault,
9209 }
9210 }
9211
9212 fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
9213 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
9214 let staleness_window = config.staleness_window_ms.max(1) as f32;
9215 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
9216 let mut value = config.weight_priority * signal.priority
9217 + config.weight_staleness * staleness_score
9218 + config.weight_focus * signal.focus_boost
9219 + config.weight_interaction * signal.interaction_boost;
9220 if starved {
9221 value += config.starve_boost;
9222 }
9223 let raw_cost = if signal.recent_cost_us > 0.0 {
9224 signal.recent_cost_us
9225 } else {
9226 signal.cost_estimate_us
9227 };
9228 let cost_us = raw_cost.max(config.min_cost_us);
9229 (value, cost_us, starved)
9230 }
9231
9232 fn fifo_select(
9233 signals: &[WidgetSignal],
9234 budget_us: f64,
9235 config: &WidgetRefreshConfig,
9236 ) -> (Vec<u64>, f64, usize) {
9237 let mut selected = Vec::new();
9238 let mut total_value = 0.0f64;
9239 let mut starved_selected = 0usize;
9240 let mut remaining = budget_us;
9241
9242 for signal in signals {
9243 if !signal.essential {
9244 continue;
9245 }
9246 let (value, cost_us, starved) = signal_value_cost(signal, config);
9247 remaining -= cost_us as f64;
9248 total_value += value as f64;
9249 if starved {
9250 starved_selected = starved_selected.saturating_add(1);
9251 }
9252 selected.push(signal.widget_id);
9253 }
9254 for signal in signals {
9255 if signal.essential {
9256 continue;
9257 }
9258 let (value, cost_us, starved) = signal_value_cost(signal, config);
9259 if remaining >= cost_us as f64 {
9260 remaining -= cost_us as f64;
9261 total_value += value as f64;
9262 if starved {
9263 starved_selected = starved_selected.saturating_add(1);
9264 }
9265 selected.push(signal.widget_id);
9266 }
9267 }
9268
9269 (selected, total_value, starved_selected)
9270 }
9271
9272 fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
9273 if signals.is_empty() {
9274 return Vec::new();
9275 }
9276 let mut rotated = Vec::with_capacity(signals.len());
9277 for idx in 0..signals.len() {
9278 rotated.push(signals[(idx + offset) % signals.len()].clone());
9279 }
9280 rotated
9281 }
9282
9283 #[test]
9284 fn widget_refresh_selects_essentials_first() {
9285 let signals = vec![
9286 make_signal(1, true, 0.6, 0, 5.0),
9287 make_signal(2, false, 0.9, 0, 4.0),
9288 ];
9289 let mut plan = WidgetRefreshPlan::new();
9290 let config = WidgetRefreshConfig::default();
9291 plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
9292 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9293 assert_eq!(selected, vec![1]);
9294 assert!(!plan.over_budget);
9295 }
9296
9297 #[test]
9298 fn widget_refresh_degradation_essential_only_skips_nonessential() {
9299 let signals = vec![
9300 make_signal(1, true, 0.5, 0, 2.0),
9301 make_signal(2, false, 1.0, 0, 1.0),
9302 ];
9303 let mut plan = WidgetRefreshPlan::new();
9304 let config = WidgetRefreshConfig::default();
9305 plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
9306 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9307 assert_eq!(selected, vec![1]);
9308 assert_eq!(plan.skipped_count, 1);
9309 }
9310
9311 #[test]
9312 fn widget_refresh_starvation_guard_forces_one_starved() {
9313 let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
9314 let mut plan = WidgetRefreshPlan::new();
9315 let config = WidgetRefreshConfig {
9316 starve_ms: 1_000,
9317 max_starved_per_frame: 1,
9318 ..Default::default()
9319 };
9320 plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
9321 assert_eq!(plan.selected.len(), 1);
9322 assert!(plan.selected[0].starved);
9323 assert!(plan.over_budget);
9324 }
9325
9326 #[test]
9327 fn widget_refresh_budget_blocks_when_no_selection() {
9328 let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
9329 let mut plan = WidgetRefreshPlan::new();
9330 let config = WidgetRefreshConfig {
9331 starve_ms: 0,
9332 max_starved_per_frame: 0,
9333 ..Default::default()
9334 };
9335 plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
9336 let budget = plan.as_budget();
9337 assert!(!budget.allows(42, false));
9338 }
9339
9340 #[test]
9341 fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
9342 let signals = vec![
9343 make_signal(1, false, 0.4, 0, 10.0),
9344 make_signal(2, false, 0.4, 0, 10.0),
9345 make_signal(3, false, 0.4, 0, 10.0),
9346 make_signal(4, false, 0.4, 0, 10.0),
9347 ];
9348 let mut plan = WidgetRefreshPlan::new();
9349 let config = WidgetRefreshConfig {
9350 starve_ms: 0,
9351 max_starved_per_frame: 0,
9352 max_drop_fraction: 0.5,
9353 ..Default::default()
9354 };
9355 plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
9356 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9357 assert_eq!(selected, vec![1, 2]);
9358 }
9359
9360 #[test]
9361 fn widget_refresh_greedy_beats_fifo_and_round_robin() {
9362 let signals = vec![
9363 make_signal(1, false, 0.1, 0, 6.0),
9364 make_signal(2, false, 0.2, 0, 6.0),
9365 make_signal(3, false, 1.0, 0, 4.0),
9366 make_signal(4, false, 0.9, 0, 3.0),
9367 make_signal(5, false, 0.8, 0, 3.0),
9368 make_signal(6, false, 0.1, 4_000, 2.0),
9369 ];
9370 let budget_us = 10.0;
9371 let config = WidgetRefreshConfig::default();
9372
9373 let mut plan = WidgetRefreshPlan::new();
9374 plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
9375 let greedy_value = plan.selected_value;
9376 let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
9377
9378 let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
9379 let rotated = rotate_signals(&signals, 2);
9380 let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
9381
9382 assert!(
9383 greedy_value > fifo_value,
9384 "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
9385 greedy_selected,
9386 fifo_selected
9387 );
9388 assert!(
9389 greedy_value > rr_value,
9390 "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
9391 greedy_selected,
9392 rr_selected
9393 );
9394 assert!(
9395 plan.starved_selected > 0,
9396 "greedy did not select starved widget; greedy={:?}",
9397 greedy_selected
9398 );
9399 }
9400
9401 #[test]
9402 fn widget_refresh_jsonl_contains_required_fields() {
9403 let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
9404 let mut plan = WidgetRefreshPlan::new();
9405 let config = WidgetRefreshConfig::default();
9406 plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
9407 let jsonl = plan.to_jsonl();
9408 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
9409 assert!(jsonl.contains("\"frame_idx\":9"));
9410 assert!(jsonl.contains("\"selected_count\":1"));
9411 assert!(jsonl.contains("\"id\":7"));
9412 }
9413
9414 #[test]
9415 fn program_config_with_resize_coalescer() {
9416 let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
9417 steady_delay_ms: 8,
9418 burst_delay_ms: 20,
9419 hard_deadline_ms: 80,
9420 burst_enter_rate: 12.0,
9421 burst_exit_rate: 6.0,
9422 cooldown_frames: 2,
9423 rate_window_size: 6,
9424 enable_logging: true,
9425 enable_bocpd: false,
9426 bocpd_config: None,
9427 });
9428 assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
9429 assert!(config.resize_coalescer.enable_logging);
9430 }
9431
9432 #[test]
9433 fn program_config_with_resize_behavior() {
9434 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
9435 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
9436 }
9437
9438 #[test]
9439 fn program_config_with_legacy_resize_enabled() {
9440 let config = ProgramConfig::default().with_legacy_resize(true);
9441 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
9442 }
9443
9444 #[test]
9445 fn program_config_with_legacy_resize_disabled_keeps_default() {
9446 let config = ProgramConfig::default().with_legacy_resize(false);
9447 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
9448 }
9449
9450 fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
9451 let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
9452 let mut writer = TerminalWriter::with_diff_config(
9453 Vec::<u8>::new(),
9454 ScreenMode::AltScreen,
9455 UiAnchor::Bottom,
9456 TerminalCapabilities::basic(),
9457 config,
9458 );
9459 writer.set_size(8, 4);
9460
9461 let mut buffer = Buffer::new(8, 4);
9462 let mut trace = Vec::new();
9463
9464 writer.present_ui(&buffer, None, false).unwrap();
9465 trace.push(
9466 writer
9467 .last_diff_strategy()
9468 .unwrap_or(DiffStrategy::FullRedraw),
9469 );
9470
9471 buffer.set_raw(0, 0, Cell::from_char('A'));
9472 writer.present_ui(&buffer, None, false).unwrap();
9473 trace.push(
9474 writer
9475 .last_diff_strategy()
9476 .unwrap_or(DiffStrategy::FullRedraw),
9477 );
9478
9479 buffer.set_raw(1, 1, Cell::from_char('B'));
9480 writer.present_ui(&buffer, None, false).unwrap();
9481 trace.push(
9482 writer
9483 .last_diff_strategy()
9484 .unwrap_or(DiffStrategy::FullRedraw),
9485 );
9486
9487 trace
9488 }
9489
9490 fn coalescer_checksum(enable_bocpd: bool) -> String {
9491 let mut config = CoalescerConfig::default().with_logging(true);
9492 if enable_bocpd {
9493 config = config.with_bocpd();
9494 }
9495
9496 let base = Instant::now();
9497 let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
9498
9499 let events = [
9500 (0_u64, (82_u16, 24_u16)),
9501 (10, (83, 25)),
9502 (20, (84, 26)),
9503 (35, (90, 28)),
9504 (55, (92, 30)),
9505 ];
9506
9507 let mut idx = 0usize;
9508 for t_ms in (0_u64..=160).step_by(8) {
9509 let now = base + Duration::from_millis(t_ms);
9510 while idx < events.len() && events[idx].0 == t_ms {
9511 let (w, h) = events[idx].1;
9512 coalescer.handle_resize_at(w, h, now);
9513 idx += 1;
9514 }
9515 coalescer.tick_at(now);
9516 }
9517
9518 coalescer.decision_checksum_hex()
9519 }
9520
9521 fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
9522 if !enabled {
9523 return Vec::new();
9524 }
9525
9526 let mut predictor = ConformalPredictor::new(ConformalConfig::default());
9527 let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
9528 let mut trace = Vec::new();
9529
9530 for i in 0..30 {
9531 let y_hat = 16_000.0 + (i as f64) * 15.0;
9532 let observed = y_hat + (i % 7) as f64 * 120.0;
9533 predictor.observe(key, y_hat, observed);
9534 let prediction = predictor.predict(key, y_hat, 20_000.0);
9535 trace.push((prediction.upper_us, prediction.risk));
9536 }
9537
9538 trace
9539 }
9540
9541 #[test]
9542 fn policy_toggle_matrix_determinism() {
9543 for &bayesian in &[false, true] {
9544 for &bocpd in &[false, true] {
9545 for &conformal in &[false, true] {
9546 let diff_a = diff_strategy_trace(bayesian);
9547 let diff_b = diff_strategy_trace(bayesian);
9548 assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
9549
9550 let checksum_a = coalescer_checksum(bocpd);
9551 let checksum_b = coalescer_checksum(bocpd);
9552 assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
9553
9554 let conf_a = conformal_trace(conformal);
9555 let conf_b = conformal_trace(conformal);
9556 assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
9557
9558 if conformal {
9559 assert!(!conf_a.is_empty(), "conformal trace should be populated");
9560 } else {
9561 assert!(conf_a.is_empty(), "conformal trace should be empty");
9562 }
9563 }
9564 }
9565 }
9566 }
9567
9568 #[test]
9569 fn resize_behavior_uses_coalescer_flag() {
9570 assert!(ResizeBehavior::Throttled.uses_coalescer());
9571 assert!(!ResizeBehavior::Immediate.uses_coalescer());
9572 }
9573
9574 #[test]
9575 fn nested_cmd_msg_executes_recursively() {
9576 use crate::simulator::ProgramSimulator;
9578
9579 struct NestedModel {
9580 depth: usize,
9581 }
9582
9583 #[derive(Debug)]
9584 enum NestedMsg {
9585 Nest(usize),
9586 }
9587
9588 impl From<Event> for NestedMsg {
9589 fn from(_: Event) -> Self {
9590 NestedMsg::Nest(0)
9591 }
9592 }
9593
9594 impl Model for NestedModel {
9595 type Message = NestedMsg;
9596
9597 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9598 match msg {
9599 NestedMsg::Nest(n) => {
9600 self.depth += 1;
9601 if n > 0 {
9602 Cmd::msg(NestedMsg::Nest(n - 1))
9603 } else {
9604 Cmd::none()
9605 }
9606 }
9607 }
9608 }
9609
9610 fn view(&self, _frame: &mut Frame) {}
9611 }
9612
9613 let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
9614 sim.init();
9615 sim.send(NestedMsg::Nest(3));
9616
9617 assert_eq!(sim.model().depth, 4);
9619 }
9620
9621 #[test]
9622 fn task_executes_synchronously_in_simulator() {
9623 use crate::simulator::ProgramSimulator;
9625
9626 struct TaskModel {
9627 completed: bool,
9628 }
9629
9630 #[derive(Debug)]
9631 enum TaskMsg {
9632 Complete,
9633 SpawnTask,
9634 }
9635
9636 impl From<Event> for TaskMsg {
9637 fn from(_: Event) -> Self {
9638 TaskMsg::Complete
9639 }
9640 }
9641
9642 impl Model for TaskModel {
9643 type Message = TaskMsg;
9644
9645 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9646 match msg {
9647 TaskMsg::Complete => {
9648 self.completed = true;
9649 Cmd::none()
9650 }
9651 TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
9652 }
9653 }
9654
9655 fn view(&self, _frame: &mut Frame) {}
9656 }
9657
9658 let mut sim = ProgramSimulator::new(TaskModel { completed: false });
9659 sim.init();
9660 sim.send(TaskMsg::SpawnTask);
9661
9662 assert!(sim.model().completed);
9664 }
9665
9666 #[test]
9667 fn multiple_updates_accumulate_correctly() {
9668 use crate::simulator::ProgramSimulator;
9670
9671 struct AccumModel {
9672 sum: i32,
9673 }
9674
9675 #[derive(Debug)]
9676 enum AccumMsg {
9677 Add(i32),
9678 Multiply(i32),
9679 }
9680
9681 impl From<Event> for AccumMsg {
9682 fn from(_: Event) -> Self {
9683 AccumMsg::Add(1)
9684 }
9685 }
9686
9687 impl Model for AccumModel {
9688 type Message = AccumMsg;
9689
9690 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9691 match msg {
9692 AccumMsg::Add(n) => {
9693 self.sum += n;
9694 Cmd::none()
9695 }
9696 AccumMsg::Multiply(n) => {
9697 self.sum *= n;
9698 Cmd::none()
9699 }
9700 }
9701 }
9702
9703 fn view(&self, _frame: &mut Frame) {}
9704 }
9705
9706 let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
9707 sim.init();
9708
9709 sim.send(AccumMsg::Add(5));
9711 sim.send(AccumMsg::Multiply(2));
9712 sim.send(AccumMsg::Add(3));
9713
9714 assert_eq!(sim.model().sum, 13);
9715 }
9716
9717 #[test]
9718 fn init_command_executes_before_first_update() {
9719 use crate::simulator::ProgramSimulator;
9721
9722 struct InitModel {
9723 initialized: bool,
9724 updates: usize,
9725 }
9726
9727 #[derive(Debug)]
9728 enum InitMsg {
9729 Update,
9730 MarkInit,
9731 }
9732
9733 impl From<Event> for InitMsg {
9734 fn from(_: Event) -> Self {
9735 InitMsg::Update
9736 }
9737 }
9738
9739 impl Model for InitModel {
9740 type Message = InitMsg;
9741
9742 fn init(&mut self) -> Cmd<Self::Message> {
9743 Cmd::msg(InitMsg::MarkInit)
9744 }
9745
9746 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9747 match msg {
9748 InitMsg::MarkInit => {
9749 self.initialized = true;
9750 Cmd::none()
9751 }
9752 InitMsg::Update => {
9753 self.updates += 1;
9754 Cmd::none()
9755 }
9756 }
9757 }
9758
9759 fn view(&self, _frame: &mut Frame) {}
9760 }
9761
9762 let mut sim = ProgramSimulator::new(InitModel {
9763 initialized: false,
9764 updates: 0,
9765 });
9766 sim.init();
9767
9768 assert!(sim.model().initialized);
9769 sim.send(InitMsg::Update);
9770 assert_eq!(sim.model().updates, 1);
9771 }
9772
9773 #[test]
9778 fn ui_height_returns_correct_value_inline_mode() {
9779 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9781 use ftui_core::terminal_capabilities::TerminalCapabilities;
9782
9783 let output = Vec::new();
9784 let writer = TerminalWriter::new(
9785 output,
9786 ScreenMode::Inline { ui_height: 10 },
9787 UiAnchor::Bottom,
9788 TerminalCapabilities::basic(),
9789 );
9790 assert_eq!(writer.ui_height(), 10);
9791 }
9792
9793 #[test]
9794 fn ui_height_returns_term_height_altscreen_mode() {
9795 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9797 use ftui_core::terminal_capabilities::TerminalCapabilities;
9798
9799 let output = Vec::new();
9800 let mut writer = TerminalWriter::new(
9801 output,
9802 ScreenMode::AltScreen,
9803 UiAnchor::Bottom,
9804 TerminalCapabilities::basic(),
9805 );
9806 writer.set_size(80, 24);
9807 assert_eq!(writer.ui_height(), 24);
9808 }
9809
9810 #[test]
9811 fn inline_mode_frame_uses_ui_height_not_terminal_height() {
9812 use crate::simulator::ProgramSimulator;
9815 use std::cell::Cell as StdCell;
9816
9817 thread_local! {
9818 static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
9819 }
9820
9821 struct FrameSizeTracker;
9822
9823 #[derive(Debug)]
9824 enum SizeMsg {
9825 Check,
9826 }
9827
9828 impl From<Event> for SizeMsg {
9829 fn from(_: Event) -> Self {
9830 SizeMsg::Check
9831 }
9832 }
9833
9834 impl Model for FrameSizeTracker {
9835 type Message = SizeMsg;
9836
9837 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9838 Cmd::none()
9839 }
9840
9841 fn view(&self, frame: &mut Frame) {
9842 CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
9844 }
9845 }
9846
9847 let mut sim = ProgramSimulator::new(FrameSizeTracker);
9849 sim.init();
9850
9851 let buf = sim.capture_frame(80, 10);
9853 assert_eq!(buf.height(), 10);
9854 assert_eq!(buf.width(), 80);
9855
9856 }
9860
9861 #[test]
9862 fn altscreen_frame_uses_full_terminal_height() {
9863 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9865 use ftui_core::terminal_capabilities::TerminalCapabilities;
9866
9867 let output = Vec::new();
9868 let mut writer = TerminalWriter::new(
9869 output,
9870 ScreenMode::AltScreen,
9871 UiAnchor::Bottom,
9872 TerminalCapabilities::basic(),
9873 );
9874 writer.set_size(80, 40);
9875
9876 assert_eq!(writer.ui_height(), 40);
9878 }
9879
9880 #[test]
9881 fn ui_height_clamped_to_terminal_height() {
9882 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
9885 use ftui_core::terminal_capabilities::TerminalCapabilities;
9886
9887 let output = Vec::new();
9888 let mut writer = TerminalWriter::new(
9889 output,
9890 ScreenMode::Inline { ui_height: 100 },
9891 UiAnchor::Bottom,
9892 TerminalCapabilities::basic(),
9893 );
9894 writer.set_size(80, 10);
9895
9896 assert_eq!(writer.ui_height(), 100);
9902 }
9903
9904 #[test]
9909 fn tick_event_delivered_to_model_update() {
9910 use crate::simulator::ProgramSimulator;
9913
9914 struct TickTracker {
9915 tick_count: usize,
9916 }
9917
9918 #[derive(Debug)]
9919 enum TickMsg {
9920 Tick,
9921 Other,
9922 }
9923
9924 impl From<Event> for TickMsg {
9925 fn from(event: Event) -> Self {
9926 match event {
9927 Event::Tick => TickMsg::Tick,
9928 _ => TickMsg::Other,
9929 }
9930 }
9931 }
9932
9933 impl Model for TickTracker {
9934 type Message = TickMsg;
9935
9936 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9937 match msg {
9938 TickMsg::Tick => {
9939 self.tick_count += 1;
9940 Cmd::none()
9941 }
9942 TickMsg::Other => Cmd::none(),
9943 }
9944 }
9945
9946 fn view(&self, _frame: &mut Frame) {}
9947 }
9948
9949 let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
9950 sim.init();
9951
9952 sim.inject_event(Event::Tick);
9954 assert_eq!(sim.model().tick_count, 1);
9955
9956 sim.inject_event(Event::Tick);
9957 sim.inject_event(Event::Tick);
9958 assert_eq!(sim.model().tick_count, 3);
9959 }
9960
9961 #[test]
9962 fn tick_command_sets_tick_rate() {
9963 use crate::simulator::{CmdRecord, ProgramSimulator};
9965
9966 struct TickModel;
9967
9968 #[derive(Debug)]
9969 enum Msg {
9970 SetTick,
9971 Noop,
9972 }
9973
9974 impl From<Event> for Msg {
9975 fn from(_: Event) -> Self {
9976 Msg::Noop
9977 }
9978 }
9979
9980 impl Model for TickModel {
9981 type Message = Msg;
9982
9983 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9984 match msg {
9985 Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
9986 Msg::Noop => Cmd::none(),
9987 }
9988 }
9989
9990 fn view(&self, _frame: &mut Frame) {}
9991 }
9992
9993 let mut sim = ProgramSimulator::new(TickModel);
9994 sim.init();
9995 sim.send(Msg::SetTick);
9996
9997 let commands = sim.command_log();
9999 assert!(
10000 commands
10001 .iter()
10002 .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
10003 );
10004 }
10005
10006 #[test]
10007 fn tick_can_trigger_further_commands() {
10008 use crate::simulator::ProgramSimulator;
10010
10011 struct ChainModel {
10012 stage: usize,
10013 }
10014
10015 #[derive(Debug)]
10016 enum ChainMsg {
10017 Tick,
10018 Advance,
10019 Noop,
10020 }
10021
10022 impl From<Event> for ChainMsg {
10023 fn from(event: Event) -> Self {
10024 match event {
10025 Event::Tick => ChainMsg::Tick,
10026 _ => ChainMsg::Noop,
10027 }
10028 }
10029 }
10030
10031 impl Model for ChainModel {
10032 type Message = ChainMsg;
10033
10034 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10035 match msg {
10036 ChainMsg::Tick => {
10037 self.stage += 1;
10038 Cmd::msg(ChainMsg::Advance)
10040 }
10041 ChainMsg::Advance => {
10042 self.stage += 10;
10043 Cmd::none()
10044 }
10045 ChainMsg::Noop => Cmd::none(),
10046 }
10047 }
10048
10049 fn view(&self, _frame: &mut Frame) {}
10050 }
10051
10052 let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
10053 sim.init();
10054 sim.inject_event(Event::Tick);
10055
10056 assert_eq!(sim.model().stage, 11);
10058 }
10059
10060 #[test]
10061 fn tick_disabled_with_zero_duration() {
10062 use crate::simulator::ProgramSimulator;
10064
10065 struct ZeroTickModel {
10066 disabled: bool,
10067 }
10068
10069 #[derive(Debug)]
10070 enum ZeroMsg {
10071 DisableTick,
10072 Noop,
10073 }
10074
10075 impl From<Event> for ZeroMsg {
10076 fn from(_: Event) -> Self {
10077 ZeroMsg::Noop
10078 }
10079 }
10080
10081 impl Model for ZeroTickModel {
10082 type Message = ZeroMsg;
10083
10084 fn init(&mut self) -> Cmd<Self::Message> {
10085 Cmd::tick(Duration::from_millis(100))
10087 }
10088
10089 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10090 match msg {
10091 ZeroMsg::DisableTick => {
10092 self.disabled = true;
10093 Cmd::tick(Duration::ZERO)
10095 }
10096 ZeroMsg::Noop => Cmd::none(),
10097 }
10098 }
10099
10100 fn view(&self, _frame: &mut Frame) {}
10101 }
10102
10103 let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
10104 sim.init();
10105
10106 assert!(sim.tick_rate().is_some());
10108 assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
10109
10110 sim.send(ZeroMsg::DisableTick);
10112 assert!(sim.model().disabled);
10113
10114 assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
10117 }
10118
10119 #[test]
10120 fn tick_event_distinguishable_from_other_events() {
10121 let tick = Event::Tick;
10123 let key = Event::Key(ftui_core::event::KeyEvent::new(
10124 ftui_core::event::KeyCode::Char('a'),
10125 ));
10126
10127 assert!(matches!(tick, Event::Tick));
10128 assert!(!matches!(key, Event::Tick));
10129 }
10130
10131 #[test]
10132 fn tick_event_clone_and_eq() {
10133 let tick1 = Event::Tick;
10135 let tick2 = tick1.clone();
10136 assert_eq!(tick1, tick2);
10137 }
10138
10139 #[test]
10140 fn model_receives_tick_and_input_events() {
10141 use crate::simulator::ProgramSimulator;
10143
10144 struct MixedModel {
10145 ticks: usize,
10146 keys: usize,
10147 }
10148
10149 #[derive(Debug)]
10150 enum MixedMsg {
10151 Tick,
10152 Key,
10153 }
10154
10155 impl From<Event> for MixedMsg {
10156 fn from(event: Event) -> Self {
10157 match event {
10158 Event::Tick => MixedMsg::Tick,
10159 _ => MixedMsg::Key,
10160 }
10161 }
10162 }
10163
10164 impl Model for MixedModel {
10165 type Message = MixedMsg;
10166
10167 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10168 match msg {
10169 MixedMsg::Tick => {
10170 self.ticks += 1;
10171 Cmd::none()
10172 }
10173 MixedMsg::Key => {
10174 self.keys += 1;
10175 Cmd::none()
10176 }
10177 }
10178 }
10179
10180 fn view(&self, _frame: &mut Frame) {}
10181 }
10182
10183 let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
10184 sim.init();
10185
10186 sim.inject_event(Event::Tick);
10188 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
10189 ftui_core::event::KeyCode::Char('a'),
10190 )));
10191 sim.inject_event(Event::Tick);
10192 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
10193 ftui_core::event::KeyCode::Char('b'),
10194 )));
10195 sim.inject_event(Event::Tick);
10196
10197 assert_eq!(sim.model().ticks, 3);
10198 assert_eq!(sim.model().keys, 2);
10199 }
10200
10201 fn headless_program_with_resolved_config<M: Model>(
10206 model: M,
10207 config: ProgramConfig,
10208 ) -> Program<M, HeadlessEventSource, Vec<u8>>
10209 where
10210 M::Message: Send + 'static,
10211 {
10212 clear_termination_signal();
10213 let effect_queue_config = config.resolved_effect_queue_config();
10214 let capabilities = TerminalCapabilities::basic();
10215 let mut writer = TerminalWriter::with_diff_config(
10216 Vec::new(),
10217 config.screen_mode,
10218 config.ui_anchor,
10219 capabilities,
10220 config.diff_config.clone(),
10221 );
10222 let frame_timing = config.frame_timing.clone();
10223 writer.set_timing_enabled(frame_timing.is_some());
10224
10225 let (width, height) = config.forced_size.unwrap_or((80, 24));
10226 let width = width.max(1);
10227 let height = height.max(1);
10228 writer.set_size(width, height);
10229
10230 let mouse_capture = config.resolved_mouse_capture();
10231 let initial_features = BackendFeatures {
10232 mouse_capture,
10233 bracketed_paste: config.bracketed_paste,
10234 focus_events: config.focus_reporting,
10235 kitty_keyboard: config.kitty_keyboard,
10236 };
10237 let events = HeadlessEventSource::new(width, height, initial_features);
10238 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
10239 .expect("headless evidence sink config");
10240
10241 let budget = render_budget_from_program_config(&config);
10242 let load_governor = LoadGovernorState::new(
10243 config.load_governor.clone(),
10244 effect_queue_config.max_queue_depth,
10245 );
10246 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
10247 let locale_context = config.locale_context.clone();
10248 let locale_version = locale_context.version();
10249 let mut resize_coalescer =
10250 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
10251 if let Some(ref sink) = evidence_sink {
10252 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
10253 }
10254 let subscriptions = SubscriptionManager::new();
10255 let (task_sender, task_receiver) = std::sync::mpsc::channel();
10256 let inline_auto_remeasure = config
10257 .inline_auto_remeasure
10258 .clone()
10259 .map(InlineAutoRemeasureState::new);
10260 let guardrails = FrameGuardrails::new(config.guardrails);
10261 let task_executor = TaskExecutor::new(
10262 &effect_queue_config,
10263 task_sender.clone(),
10264 evidence_sink.clone(),
10265 )
10266 .expect("task executor");
10267
10268 Program {
10269 model,
10270 writer,
10271 events,
10272 backend_features: initial_features,
10273 running: true,
10274 tick_rate: None,
10275 executed_cmd_count: 0,
10276 last_tick: Instant::now(),
10277 dirty: true,
10278 frame_idx: 0,
10279 tick_count: 0,
10280 widget_signals: Vec::new(),
10281 widget_refresh_config: config.widget_refresh,
10282 widget_refresh_plan: WidgetRefreshPlan::new(),
10283 width,
10284 height,
10285 forced_size: config.forced_size,
10286 poll_timeout: config.poll_timeout,
10287 intercept_signals: config.intercept_signals,
10288 immediate_drain_config: config.immediate_drain,
10289 immediate_drain_stats: ImmediateDrainStats::default(),
10290 budget,
10291 load_governor,
10292 conformal_predictor,
10293 last_frame_time_us: None,
10294 last_update_us: None,
10295 frame_timing,
10296 locale_context,
10297 locale_version,
10298 resize_coalescer,
10299 evidence_sink,
10300 fairness_config_logged: false,
10301 resize_behavior: config.resize_behavior,
10302 fairness_guard: InputFairnessGuard::new(),
10303 event_recorder: None,
10304 subscriptions,
10305 #[cfg(test)]
10306 task_sender,
10307 task_receiver,
10308 task_executor,
10309 state_registry: config.persistence.registry.clone(),
10310 persistence_config: config.persistence,
10311 last_checkpoint: Instant::now(),
10312 inline_auto_remeasure,
10313 frame_arena: FrameArena::default(),
10314 guardrails,
10315 tick_strategy: config
10316 .tick_strategy
10317 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
10318 last_active_screen_for_strategy: None,
10319 }
10320 }
10321
10322 fn headless_program_with_config<M: Model>(
10323 model: M,
10324 config: ProgramConfig,
10325 ) -> Program<M, HeadlessEventSource, Vec<u8>>
10326 where
10327 M::Message: Send + 'static,
10328 {
10329 headless_program_with_resolved_config(model, config.with_signal_interception(false))
10332 }
10333
10334 fn headless_signal_program_with_config<M: Model>(
10335 model: M,
10336 config: ProgramConfig,
10337 ) -> Program<M, HeadlessEventSource, Vec<u8>>
10338 where
10339 M::Message: Send + 'static,
10340 {
10341 headless_program_with_resolved_config(model, config)
10342 }
10343
10344 fn temp_evidence_path(label: &str) -> PathBuf {
10345 static COUNTER: AtomicUsize = AtomicUsize::new(0);
10346 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
10347 let pid = std::process::id();
10348 let mut path = std::env::temp_dir();
10349 path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
10350 path
10351 }
10352
10353 fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
10354 let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
10355 let needle = format!("\"event\":\"{event}\"");
10356 let missing_msg = format!("missing {event} line");
10357 let line = jsonl
10358 .lines()
10359 .find(|line| line.contains(&needle))
10360 .expect(&missing_msg);
10361 serde_json::from_str(line).expect("valid evidence json")
10362 }
10363
10364 #[test]
10365 fn headless_apply_resize_updates_model_and_dimensions() {
10366 struct ResizeModel {
10367 last_size: Option<(u16, u16)>,
10368 }
10369
10370 #[derive(Debug)]
10371 enum ResizeMsg {
10372 Resize(u16, u16),
10373 Other,
10374 }
10375
10376 impl From<Event> for ResizeMsg {
10377 fn from(event: Event) -> Self {
10378 match event {
10379 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
10380 _ => ResizeMsg::Other,
10381 }
10382 }
10383 }
10384
10385 impl Model for ResizeModel {
10386 type Message = ResizeMsg;
10387
10388 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10389 if let ResizeMsg::Resize(w, h) = msg {
10390 self.last_size = Some((w, h));
10391 }
10392 Cmd::none()
10393 }
10394
10395 fn view(&self, _frame: &mut Frame) {}
10396 }
10397
10398 let mut program =
10399 headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
10400 program.dirty = false;
10401
10402 program
10403 .apply_resize(0, 0, Duration::ZERO, false)
10404 .expect("resize");
10405
10406 assert_eq!(program.width, 1);
10407 assert_eq!(program.height, 1);
10408 assert_eq!(program.model().last_size, Some((1, 1)));
10409 assert!(program.dirty);
10410 }
10411
10412 #[test]
10413 fn headless_apply_resize_reconciles_subscriptions() {
10414 use crate::subscription::{StopSignal, SubId, Subscription};
10415
10416 struct ResizeSubModel {
10417 subscribed: bool,
10418 }
10419
10420 #[derive(Debug)]
10421 enum ResizeSubMsg {
10422 Resize,
10423 Other,
10424 }
10425
10426 impl From<Event> for ResizeSubMsg {
10427 fn from(event: Event) -> Self {
10428 match event {
10429 Event::Resize { .. } => Self::Resize,
10430 _ => Self::Other,
10431 }
10432 }
10433 }
10434
10435 impl Model for ResizeSubModel {
10436 type Message = ResizeSubMsg;
10437
10438 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10439 if matches!(msg, ResizeSubMsg::Resize) {
10440 self.subscribed = true;
10441 }
10442 Cmd::none()
10443 }
10444
10445 fn view(&self, _frame: &mut Frame) {}
10446
10447 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10448 if self.subscribed {
10449 vec![Box::new(ResizeSubscription)]
10450 } else {
10451 vec![]
10452 }
10453 }
10454 }
10455
10456 struct ResizeSubscription;
10457
10458 impl Subscription<ResizeSubMsg> for ResizeSubscription {
10459 fn id(&self) -> SubId {
10460 1
10461 }
10462
10463 fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
10464 }
10465
10466 let mut program = headless_program_with_config(
10467 ResizeSubModel { subscribed: false },
10468 ProgramConfig::default(),
10469 );
10470
10471 assert_eq!(program.subscriptions.active_count(), 0);
10472 program
10473 .apply_resize(120, 40, Duration::ZERO, false)
10474 .expect("resize");
10475
10476 assert!(program.model().subscribed);
10477 assert_eq!(program.subscriptions.active_count(), 1);
10478 }
10479
10480 #[test]
10481 fn headless_execute_cmd_log_writes_output() {
10482 let mut program =
10483 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10484 program.execute_cmd(Cmd::log("hello world")).expect("log");
10485
10486 let bytes = program.writer.into_inner().expect("writer output");
10487 let output = String::from_utf8_lossy(&bytes);
10488 assert!(output.contains("hello world"));
10489 }
10490
10491 #[test]
10492 fn headless_process_task_results_updates_model() {
10493 struct TaskModel {
10494 updates: usize,
10495 }
10496
10497 #[derive(Debug)]
10498 enum TaskMsg {
10499 Done,
10500 }
10501
10502 impl From<Event> for TaskMsg {
10503 fn from(_: Event) -> Self {
10504 TaskMsg::Done
10505 }
10506 }
10507
10508 impl Model for TaskModel {
10509 type Message = TaskMsg;
10510
10511 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10512 self.updates += 1;
10513 Cmd::none()
10514 }
10515
10516 fn view(&self, _frame: &mut Frame) {}
10517 }
10518
10519 let mut program =
10520 headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
10521 program.dirty = false;
10522 program.task_sender.send(TaskMsg::Done).unwrap();
10523
10524 program
10525 .process_task_results()
10526 .expect("process task results");
10527 assert_eq!(program.model().updates, 1);
10528 assert!(program.dirty);
10529 }
10530
10531 #[test]
10532 fn run_invokes_on_shutdown_after_quit() {
10533 use std::sync::{
10534 Arc,
10535 atomic::{AtomicUsize, Ordering},
10536 };
10537
10538 struct ShutdownModel {
10539 shutdowns: Arc<AtomicUsize>,
10540 }
10541
10542 #[derive(Debug, Clone, Copy)]
10543 enum ShutdownMsg {
10544 Quit,
10545 ShutdownRan,
10546 }
10547
10548 impl From<Event> for ShutdownMsg {
10549 fn from(_: Event) -> Self {
10550 ShutdownMsg::Quit
10551 }
10552 }
10553
10554 impl Model for ShutdownModel {
10555 type Message = ShutdownMsg;
10556
10557 fn init(&mut self) -> Cmd<Self::Message> {
10558 Cmd::quit()
10559 }
10560
10561 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10562 match msg {
10563 ShutdownMsg::Quit => Cmd::quit(),
10564 ShutdownMsg::ShutdownRan => {
10565 self.shutdowns.fetch_add(1, Ordering::SeqCst);
10566 Cmd::none()
10567 }
10568 }
10569 }
10570
10571 fn view(&self, _frame: &mut Frame) {}
10572
10573 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10574 Cmd::msg(ShutdownMsg::ShutdownRan)
10575 }
10576 }
10577
10578 let shutdowns = Arc::new(AtomicUsize::new(0));
10579 let mut program = headless_program_with_config(
10580 ShutdownModel {
10581 shutdowns: Arc::clone(&shutdowns),
10582 },
10583 ProgramConfig::default(),
10584 );
10585
10586 program.run().expect("program run");
10587
10588 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10589 }
10590
10591 #[test]
10592 fn run_processes_shutdown_task_results_before_exit() {
10593 use std::sync::{
10594 Arc,
10595 atomic::{AtomicUsize, Ordering},
10596 };
10597
10598 struct ShutdownTaskModel {
10599 shutdowns: Arc<AtomicUsize>,
10600 }
10601
10602 #[derive(Debug, Clone, Copy)]
10603 enum ShutdownTaskMsg {
10604 Quit,
10605 ShutdownRan,
10606 }
10607
10608 impl From<Event> for ShutdownTaskMsg {
10609 fn from(_: Event) -> Self {
10610 ShutdownTaskMsg::Quit
10611 }
10612 }
10613
10614 impl Model for ShutdownTaskModel {
10615 type Message = ShutdownTaskMsg;
10616
10617 fn init(&mut self) -> Cmd<Self::Message> {
10618 Cmd::quit()
10619 }
10620
10621 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10622 match msg {
10623 ShutdownTaskMsg::Quit => Cmd::quit(),
10624 ShutdownTaskMsg::ShutdownRan => {
10625 self.shutdowns.fetch_add(1, Ordering::SeqCst);
10626 Cmd::none()
10627 }
10628 }
10629 }
10630
10631 fn view(&self, _frame: &mut Frame) {}
10632
10633 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10634 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10635 }
10636 }
10637
10638 let shutdowns = Arc::new(AtomicUsize::new(0));
10639 let mut program = headless_program_with_config(
10640 ShutdownTaskModel {
10641 shutdowns: Arc::clone(&shutdowns),
10642 },
10643 ProgramConfig::default(),
10644 );
10645
10646 program.run().expect("program run");
10647
10648 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10649 }
10650
10651 #[test]
10652 fn run_processes_shutdown_task_results_with_effect_queue_backend() {
10653 use std::sync::{
10654 Arc,
10655 atomic::{AtomicUsize, Ordering},
10656 };
10657
10658 struct ShutdownTaskModel {
10659 shutdowns: Arc<AtomicUsize>,
10660 }
10661
10662 #[derive(Debug, Clone, Copy)]
10663 enum ShutdownTaskMsg {
10664 Quit,
10665 ShutdownRan,
10666 }
10667
10668 impl From<Event> for ShutdownTaskMsg {
10669 fn from(_: Event) -> Self {
10670 ShutdownTaskMsg::Quit
10671 }
10672 }
10673
10674 impl Model for ShutdownTaskModel {
10675 type Message = ShutdownTaskMsg;
10676
10677 fn init(&mut self) -> Cmd<Self::Message> {
10678 Cmd::quit()
10679 }
10680
10681 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10682 match msg {
10683 ShutdownTaskMsg::Quit => Cmd::quit(),
10684 ShutdownTaskMsg::ShutdownRan => {
10685 self.shutdowns.fetch_add(1, Ordering::SeqCst);
10686 Cmd::none()
10687 }
10688 }
10689 }
10690
10691 fn view(&self, _frame: &mut Frame) {}
10692
10693 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10694 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10695 }
10696 }
10697
10698 let shutdowns = Arc::new(AtomicUsize::new(0));
10699 let mut program = headless_program_with_config(
10700 ShutdownTaskModel {
10701 shutdowns: Arc::clone(&shutdowns),
10702 },
10703 ProgramConfig::default().with_effect_queue(
10704 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
10705 ),
10706 );
10707
10708 program.run().expect("program run");
10709
10710 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10711 }
10712
10713 #[test]
10714 fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
10715 use std::sync::{
10716 Arc,
10717 atomic::{AtomicUsize, Ordering},
10718 };
10719
10720 struct ShutdownTaskModel {
10721 shutdowns: Arc<AtomicUsize>,
10722 follow_up_runs: Arc<AtomicUsize>,
10723 }
10724
10725 #[derive(Debug, Clone, Copy)]
10726 enum ShutdownTaskMsg {
10727 Quit,
10728 ShutdownRan,
10729 FollowUp,
10730 }
10731
10732 impl From<Event> for ShutdownTaskMsg {
10733 fn from(_: Event) -> Self {
10734 ShutdownTaskMsg::Quit
10735 }
10736 }
10737
10738 impl Model for ShutdownTaskModel {
10739 type Message = ShutdownTaskMsg;
10740
10741 fn init(&mut self) -> Cmd<Self::Message> {
10742 Cmd::quit()
10743 }
10744
10745 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10746 match msg {
10747 ShutdownTaskMsg::Quit => Cmd::quit(),
10748 ShutdownTaskMsg::ShutdownRan => {
10749 self.shutdowns.fetch_add(1, Ordering::SeqCst);
10750 let follow_up_runs = Arc::clone(&self.follow_up_runs);
10751 Cmd::task(move || {
10752 follow_up_runs.fetch_add(1, Ordering::SeqCst);
10753 ShutdownTaskMsg::FollowUp
10754 })
10755 }
10756 ShutdownTaskMsg::FollowUp => {
10757 self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
10758 Cmd::none()
10759 }
10760 }
10761 }
10762
10763 fn view(&self, _frame: &mut Frame) {}
10764
10765 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10766 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
10767 }
10768 }
10769
10770 let shutdowns = Arc::new(AtomicUsize::new(0));
10771 let follow_up_runs = Arc::new(AtomicUsize::new(0));
10772 let mut program = headless_program_with_config(
10773 ShutdownTaskModel {
10774 shutdowns: Arc::clone(&shutdowns),
10775 follow_up_runs: Arc::clone(&follow_up_runs),
10776 },
10777 ProgramConfig::default(),
10778 );
10779
10780 program.run().expect("program run");
10781
10782 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10783 assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
10784 }
10785
10786 #[test]
10787 fn run_quit_from_init_skips_initial_render_and_subscription_start() {
10788 use crate::subscription::{StopSignal, SubId, Subscription};
10789
10790 struct InitQuitModel {
10791 render_calls: Arc<AtomicUsize>,
10792 subscription_starts: Arc<AtomicUsize>,
10793 }
10794
10795 #[derive(Debug, Clone, Copy)]
10796 enum InitQuitMsg {
10797 Noop,
10798 }
10799
10800 impl From<Event> for InitQuitMsg {
10801 fn from(_: Event) -> Self {
10802 Self::Noop
10803 }
10804 }
10805
10806 impl Model for InitQuitModel {
10807 type Message = InitQuitMsg;
10808
10809 fn init(&mut self) -> Cmd<Self::Message> {
10810 Cmd::quit()
10811 }
10812
10813 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
10814 Cmd::none()
10815 }
10816
10817 fn view(&self, _frame: &mut Frame) {
10818 self.render_calls.fetch_add(1, Ordering::SeqCst);
10819 }
10820
10821 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10822 vec![Box::new(InitQuitSubscription {
10823 starts: Arc::clone(&self.subscription_starts),
10824 })]
10825 }
10826 }
10827
10828 struct InitQuitSubscription {
10829 starts: Arc<AtomicUsize>,
10830 }
10831
10832 impl Subscription<InitQuitMsg> for InitQuitSubscription {
10833 fn id(&self) -> SubId {
10834 1
10835 }
10836
10837 fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
10838 self.starts.fetch_add(1, Ordering::SeqCst);
10839 let _ = stop.wait_timeout(Duration::from_millis(10));
10840 }
10841 }
10842
10843 let render_calls = Arc::new(AtomicUsize::new(0));
10844 let subscription_starts = Arc::new(AtomicUsize::new(0));
10845 let mut program = headless_program_with_config(
10846 InitQuitModel {
10847 render_calls: Arc::clone(&render_calls),
10848 subscription_starts: Arc::clone(&subscription_starts),
10849 },
10850 ProgramConfig::default(),
10851 );
10852
10853 program.run().expect("program run");
10854
10855 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
10856 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10857 }
10858
10859 #[test]
10860 fn run_invokes_on_shutdown_before_returning_signal_error() {
10861 use std::sync::{
10862 Arc,
10863 atomic::{AtomicUsize, Ordering},
10864 };
10865
10866 struct ShutdownModel {
10867 shutdowns: Arc<AtomicUsize>,
10868 }
10869
10870 #[derive(Debug, Clone, Copy)]
10871 enum ShutdownMsg {
10872 Noop,
10873 ShutdownRan,
10874 }
10875
10876 impl From<Event> for ShutdownMsg {
10877 fn from(_: Event) -> Self {
10878 ShutdownMsg::Noop
10879 }
10880 }
10881
10882 impl Model for ShutdownModel {
10883 type Message = ShutdownMsg;
10884
10885 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10886 match msg {
10887 ShutdownMsg::Noop => Cmd::none(),
10888 ShutdownMsg::ShutdownRan => {
10889 self.shutdowns.fetch_add(1, Ordering::SeqCst);
10890 Cmd::none()
10891 }
10892 }
10893 }
10894
10895 fn view(&self, _frame: &mut Frame) {}
10896
10897 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
10898 Cmd::msg(ShutdownMsg::ShutdownRan)
10899 }
10900 }
10901
10902 let shutdowns = Arc::new(AtomicUsize::new(0));
10903 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
10904 let mut program = headless_signal_program_with_config(
10905 ShutdownModel {
10906 shutdowns: Arc::clone(&shutdowns),
10907 },
10908 ProgramConfig::default().with_signal_interception(true),
10909 );
10910
10911 ftui_core::shutdown_signal::record_pending_termination_signal(2);
10912 let err = program.run().expect_err("signal should stop runtime");
10913
10914 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
10915 assert_eq!(signal_termination_from_error(&err), Some(2));
10916 assert_eq!(check_termination_signal(), None);
10917 });
10918 }
10919
10920 #[test]
10921 fn run_pending_signal_skips_initial_render_and_subscription_start() {
10922 use crate::subscription::{StopSignal, SubId, Subscription};
10923
10924 struct SignalStopModel {
10925 render_calls: Arc<AtomicUsize>,
10926 subscription_starts: Arc<AtomicUsize>,
10927 }
10928
10929 #[derive(Debug, Clone, Copy)]
10930 enum SignalStopMsg {
10931 Noop,
10932 }
10933
10934 impl From<Event> for SignalStopMsg {
10935 fn from(_: Event) -> Self {
10936 Self::Noop
10937 }
10938 }
10939
10940 impl Model for SignalStopModel {
10941 type Message = SignalStopMsg;
10942
10943 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
10944 Cmd::none()
10945 }
10946
10947 fn view(&self, _frame: &mut Frame) {
10948 self.render_calls.fetch_add(1, Ordering::SeqCst);
10949 }
10950
10951 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
10952 vec![Box::new(SignalStopSubscription {
10953 starts: Arc::clone(&self.subscription_starts),
10954 })]
10955 }
10956 }
10957
10958 struct SignalStopSubscription {
10959 starts: Arc<AtomicUsize>,
10960 }
10961
10962 impl Subscription<SignalStopMsg> for SignalStopSubscription {
10963 fn id(&self) -> SubId {
10964 11
10965 }
10966
10967 fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
10968 self.starts.fetch_add(1, Ordering::SeqCst);
10969 let _ = stop.wait_timeout(Duration::from_millis(10));
10970 }
10971 }
10972
10973 let render_calls = Arc::new(AtomicUsize::new(0));
10974 let subscription_starts = Arc::new(AtomicUsize::new(0));
10975 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
10976 let mut program = headless_signal_program_with_config(
10977 SignalStopModel {
10978 render_calls: Arc::clone(&render_calls),
10979 subscription_starts: Arc::clone(&subscription_starts),
10980 },
10981 ProgramConfig::default().with_signal_interception(true),
10982 );
10983
10984 ftui_core::shutdown_signal::record_pending_termination_signal(15);
10985 let err = program.run().expect_err("signal should stop runtime");
10986
10987 assert_eq!(signal_termination_from_error(&err), Some(15));
10988 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
10989 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
10990 assert_eq!(check_termination_signal(), None);
10991 });
10992 }
10993
10994 #[test]
10995 fn headless_should_tick_and_timeout_behaviors() {
10996 let mut program =
10997 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
10998 program.tick_rate = Some(Duration::from_millis(5));
10999 program.last_tick = Instant::now() - Duration::from_millis(10);
11000
11001 assert!(program.should_tick());
11002 assert!(!program.should_tick());
11003
11004 let timeout = program.effective_timeout();
11005 assert!(timeout <= Duration::from_millis(5));
11006
11007 program.tick_rate = None;
11008 program.poll_timeout = Duration::from_millis(33);
11009 assert_eq!(program.effective_timeout(), Duration::from_millis(33));
11010 }
11011
11012 #[test]
11013 fn headless_effective_timeout_respects_resize_coalescer() {
11014 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11015 config.resize_coalescer.steady_delay_ms = 0;
11016 config.resize_coalescer.burst_delay_ms = 0;
11017
11018 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11019 program.tick_rate = Some(Duration::from_millis(50));
11020
11021 program.resize_coalescer.handle_resize(120, 40);
11022 assert!(program.resize_coalescer.has_pending());
11023
11024 let timeout = program.effective_timeout();
11025 assert_eq!(timeout, Duration::ZERO);
11026 }
11027
11028 #[test]
11029 fn headless_ui_height_remeasure_clears_auto_height() {
11030 let mut config = ProgramConfig::inline_auto(2, 6);
11031 config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
11032
11033 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11034 program.dirty = false;
11035 program.writer.set_auto_ui_height(5);
11036
11037 assert_eq!(program.writer.auto_ui_height(), Some(5));
11038 program.request_ui_height_remeasure();
11039
11040 assert_eq!(program.writer.auto_ui_height(), None);
11041 assert!(program.dirty);
11042 }
11043
11044 #[test]
11045 fn headless_recording_lifecycle_and_locale_change() {
11046 let mut program =
11047 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11048 program.dirty = false;
11049
11050 program.start_recording("demo");
11051 assert!(program.is_recording());
11052 let recorded = program.stop_recording();
11053 assert!(recorded.is_some());
11054 assert!(!program.is_recording());
11055
11056 let prev_dirty = program.dirty;
11057 program.locale_context.set_locale("fr");
11058 program.check_locale_change();
11059 assert!(program.dirty || prev_dirty);
11060 }
11061
11062 #[test]
11063 fn headless_render_frame_marks_clean_and_sets_diff() {
11064 struct RenderModel;
11065
11066 #[derive(Debug)]
11067 enum RenderMsg {
11068 Noop,
11069 }
11070
11071 impl From<Event> for RenderMsg {
11072 fn from(_: Event) -> Self {
11073 RenderMsg::Noop
11074 }
11075 }
11076
11077 impl Model for RenderModel {
11078 type Message = RenderMsg;
11079
11080 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11081 Cmd::none()
11082 }
11083
11084 fn view(&self, frame: &mut Frame) {
11085 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
11086 }
11087 }
11088
11089 let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
11090 program.render_frame().expect("render frame");
11091
11092 assert!(!program.dirty);
11093 assert!(program.writer.last_diff_strategy().is_some());
11094 assert_eq!(program.frame_idx, 1);
11095 }
11096
11097 #[test]
11098 fn headless_render_frame_skips_when_budget_exhausted() {
11099 let config = ProgramConfig {
11100 budget: FrameBudgetConfig::with_total(Duration::ZERO),
11101 ..Default::default()
11102 };
11103
11104 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11105 program.dirty = true;
11106 program.render_frame().expect("render frame");
11107
11108 assert!(program.dirty);
11111 assert_eq!(program.frame_idx, 1);
11112 }
11113
11114 #[test]
11115 fn headless_render_frame_emits_budget_evidence_with_controller() {
11116 use ftui_render::budget::BudgetControllerConfig;
11117
11118 struct RenderModel;
11119
11120 #[derive(Debug)]
11121 enum RenderMsg {
11122 Noop,
11123 }
11124
11125 impl From<Event> for RenderMsg {
11126 fn from(_: Event) -> Self {
11127 RenderMsg::Noop
11128 }
11129 }
11130
11131 impl Model for RenderModel {
11132 type Message = RenderMsg;
11133
11134 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11135 Cmd::none()
11136 }
11137
11138 fn view(&self, frame: &mut Frame) {
11139 frame.buffer.set_raw(0, 0, Cell::from_char('E'));
11140 }
11141 }
11142
11143 let config =
11144 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
11145 let mut program = headless_program_with_config(RenderModel, config);
11146 program.budget = program
11147 .budget
11148 .with_controller(BudgetControllerConfig::default());
11149
11150 program.render_frame().expect("render frame");
11151 assert!(program.budget.telemetry().is_some());
11152 assert_eq!(program.frame_idx, 1);
11153 }
11154
11155 #[test]
11156 fn headless_handle_event_updates_model() {
11157 struct EventModel {
11158 events: usize,
11159 last_resize: Option<(u16, u16)>,
11160 }
11161
11162 #[derive(Debug)]
11163 enum EventMsg {
11164 Resize(u16, u16),
11165 Other,
11166 }
11167
11168 impl From<Event> for EventMsg {
11169 fn from(event: Event) -> Self {
11170 match event {
11171 Event::Resize { width, height } => EventMsg::Resize(width, height),
11172 _ => EventMsg::Other,
11173 }
11174 }
11175 }
11176
11177 impl Model for EventModel {
11178 type Message = EventMsg;
11179
11180 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11181 self.events += 1;
11182 if let EventMsg::Resize(w, h) = msg {
11183 self.last_resize = Some((w, h));
11184 }
11185 Cmd::none()
11186 }
11187
11188 fn view(&self, _frame: &mut Frame) {}
11189 }
11190
11191 let mut program = headless_program_with_config(
11192 EventModel {
11193 events: 0,
11194 last_resize: None,
11195 },
11196 ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
11197 );
11198
11199 program
11200 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
11201 ftui_core::event::KeyCode::Char('x'),
11202 )))
11203 .expect("handle key");
11204 assert_eq!(program.model().events, 1);
11205
11206 program
11207 .handle_event(Event::Resize {
11208 width: 10,
11209 height: 5,
11210 })
11211 .expect("handle resize");
11212 assert_eq!(program.model().events, 2);
11213 assert_eq!(program.model().last_resize, Some((10, 5)));
11214 assert_eq!(program.width, 10);
11215 assert_eq!(program.height, 5);
11216 }
11217
11218 #[test]
11219 fn headless_handle_event_quit_skips_subscription_reconcile() {
11220 use crate::subscription::{StopSignal, SubId, Subscription};
11221
11222 struct QuitSubModel {
11223 quitting: bool,
11224 subscription_starts: Arc<AtomicUsize>,
11225 }
11226
11227 #[derive(Debug)]
11228 enum QuitSubMsg {
11229 Quit,
11230 Other,
11231 }
11232
11233 impl From<Event> for QuitSubMsg {
11234 fn from(event: Event) -> Self {
11235 match event {
11236 Event::Key(_) => Self::Quit,
11237 _ => Self::Other,
11238 }
11239 }
11240 }
11241
11242 impl Model for QuitSubModel {
11243 type Message = QuitSubMsg;
11244
11245 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11246 match msg {
11247 QuitSubMsg::Quit => {
11248 self.quitting = true;
11249 Cmd::quit()
11250 }
11251 QuitSubMsg::Other => Cmd::none(),
11252 }
11253 }
11254
11255 fn view(&self, _frame: &mut Frame) {}
11256
11257 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11258 if self.quitting {
11259 vec![Box::new(QuitSubSubscription {
11260 starts: Arc::clone(&self.subscription_starts),
11261 })]
11262 } else {
11263 vec![]
11264 }
11265 }
11266 }
11267
11268 struct QuitSubSubscription {
11269 starts: Arc<AtomicUsize>,
11270 }
11271
11272 impl Subscription<QuitSubMsg> for QuitSubSubscription {
11273 fn id(&self) -> SubId {
11274 7
11275 }
11276
11277 fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
11278 self.starts.fetch_add(1, Ordering::SeqCst);
11279 let _ = stop.wait_timeout(Duration::from_millis(10));
11280 }
11281 }
11282
11283 let subscription_starts = Arc::new(AtomicUsize::new(0));
11284 let mut program = headless_program_with_config(
11285 QuitSubModel {
11286 quitting: false,
11287 subscription_starts: Arc::clone(&subscription_starts),
11288 },
11289 ProgramConfig::default(),
11290 );
11291
11292 program
11293 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
11294 ftui_core::event::KeyCode::Char('q'),
11295 )))
11296 .expect("handle event");
11297
11298 assert!(!program.is_running());
11299 assert_eq!(program.subscriptions.active_count(), 0);
11300 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11301 }
11302
11303 #[test]
11304 fn headless_handle_resize_ignored_when_forced_size() {
11305 struct ResizeModel {
11306 resized: bool,
11307 }
11308
11309 #[derive(Debug)]
11310 enum ResizeMsg {
11311 Resize,
11312 Other,
11313 }
11314
11315 impl From<Event> for ResizeMsg {
11316 fn from(event: Event) -> Self {
11317 match event {
11318 Event::Resize { .. } => ResizeMsg::Resize,
11319 _ => ResizeMsg::Other,
11320 }
11321 }
11322 }
11323
11324 impl Model for ResizeModel {
11325 type Message = ResizeMsg;
11326
11327 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11328 if matches!(msg, ResizeMsg::Resize) {
11329 self.resized = true;
11330 }
11331 Cmd::none()
11332 }
11333
11334 fn view(&self, _frame: &mut Frame) {}
11335 }
11336
11337 let config = ProgramConfig::default().with_forced_size(80, 24);
11338 let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
11339
11340 program
11341 .handle_event(Event::Resize {
11342 width: 120,
11343 height: 40,
11344 })
11345 .expect("handle resize");
11346
11347 assert_eq!(program.width, 80);
11348 assert_eq!(program.height, 24);
11349 assert!(!program.model().resized);
11350 }
11351
11352 #[test]
11353 fn headless_execute_cmd_batch_sequence_and_quit() {
11354 struct BatchModel {
11355 count: usize,
11356 }
11357
11358 #[derive(Debug)]
11359 enum BatchMsg {
11360 Inc,
11361 }
11362
11363 impl From<Event> for BatchMsg {
11364 fn from(_: Event) -> Self {
11365 BatchMsg::Inc
11366 }
11367 }
11368
11369 impl Model for BatchModel {
11370 type Message = BatchMsg;
11371
11372 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11373 match msg {
11374 BatchMsg::Inc => {
11375 self.count += 1;
11376 Cmd::none()
11377 }
11378 }
11379 }
11380
11381 fn view(&self, _frame: &mut Frame) {}
11382 }
11383
11384 let mut program =
11385 headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
11386
11387 program
11388 .execute_cmd(Cmd::Batch(vec![
11389 Cmd::msg(BatchMsg::Inc),
11390 Cmd::Sequence(vec![
11391 Cmd::msg(BatchMsg::Inc),
11392 Cmd::quit(),
11393 Cmd::msg(BatchMsg::Inc),
11394 ]),
11395 ]))
11396 .expect("batch cmd");
11397
11398 assert_eq!(program.model().count, 2);
11399 assert!(!program.running);
11400 }
11401
11402 #[test]
11403 fn headless_process_subscription_messages_updates_model() {
11404 use crate::subscription::{StopSignal, SubId, Subscription};
11405
11406 struct SubModel {
11407 pings: usize,
11408 ready_tx: mpsc::Sender<()>,
11409 }
11410
11411 #[derive(Debug)]
11412 enum SubMsg {
11413 Ping,
11414 Other,
11415 }
11416
11417 impl From<Event> for SubMsg {
11418 fn from(_: Event) -> Self {
11419 SubMsg::Other
11420 }
11421 }
11422
11423 impl Model for SubModel {
11424 type Message = SubMsg;
11425
11426 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11427 if let SubMsg::Ping = msg {
11428 self.pings += 1;
11429 }
11430 Cmd::none()
11431 }
11432
11433 fn view(&self, _frame: &mut Frame) {}
11434
11435 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11436 vec![Box::new(TestSubscription {
11437 ready_tx: self.ready_tx.clone(),
11438 })]
11439 }
11440 }
11441
11442 struct TestSubscription {
11443 ready_tx: mpsc::Sender<()>,
11444 }
11445
11446 impl Subscription<SubMsg> for TestSubscription {
11447 fn id(&self) -> SubId {
11448 1
11449 }
11450
11451 fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
11452 let _ = sender.send(SubMsg::Ping);
11453 let _ = self.ready_tx.send(());
11454 }
11455 }
11456
11457 let (ready_tx, ready_rx) = mpsc::channel();
11458 let mut program =
11459 headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
11460
11461 program.reconcile_subscriptions();
11462 ready_rx
11463 .recv_timeout(Duration::from_millis(200))
11464 .expect("subscription started");
11465 program
11466 .process_subscription_messages()
11467 .expect("process subscriptions");
11468
11469 assert_eq!(program.model().pings, 1);
11470 }
11471
11472 #[test]
11473 fn headless_execute_cmd_task_spawns_and_reaps() {
11474 struct TaskModel {
11475 done: bool,
11476 }
11477
11478 #[derive(Debug)]
11479 enum TaskMsg {
11480 Done,
11481 }
11482
11483 impl From<Event> for TaskMsg {
11484 fn from(_: Event) -> Self {
11485 TaskMsg::Done
11486 }
11487 }
11488
11489 impl Model for TaskModel {
11490 type Message = TaskMsg;
11491
11492 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11493 match msg {
11494 TaskMsg::Done => {
11495 self.done = true;
11496 Cmd::none()
11497 }
11498 }
11499 }
11500
11501 fn view(&self, _frame: &mut Frame) {}
11502 }
11503
11504 let mut program =
11505 headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
11506 program
11507 .execute_cmd(Cmd::task(|| TaskMsg::Done))
11508 .expect("task cmd");
11509
11510 let deadline = Instant::now() + Duration::from_millis(200);
11511 while !program.model().done && Instant::now() <= deadline {
11512 program
11513 .process_task_results()
11514 .expect("process task results");
11515 program.reap_finished_tasks();
11516 }
11517
11518 assert!(program.model().done, "task result did not arrive in time");
11519 }
11520
11521 #[test]
11522 fn headless_default_task_executor_is_spawned_for_structured_lane() {
11523 let program =
11531 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11532 assert_eq!(program.task_executor.kind_name(), "spawned");
11533 }
11534
11535 #[test]
11536 fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
11537 let evidence_path = temp_evidence_path("task_executor_spawned_backend");
11541 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11542 let config = ProgramConfig::default().with_evidence_sink(sink_config);
11543 let _program = headless_program_with_config(TestModel { value: 0 }, config);
11544
11545 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
11546 assert_eq!(backend_line["backend"], "spawned");
11547 }
11548
11549 #[test]
11550 fn headless_legacy_lane_task_executor_is_spawned() {
11551 let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
11552 let program = headless_program_with_config(TestModel { value: 0 }, config);
11553 assert_eq!(program.task_executor.kind_name(), "spawned");
11554 }
11555
11556 #[test]
11557 fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
11558 let config = ProgramConfig::default().with_effect_queue(
11559 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
11560 );
11561 let program = headless_program_with_config(TestModel { value: 0 }, config);
11562 assert_eq!(program.task_executor.kind_name(), "spawned");
11563 }
11564
11565 #[cfg(feature = "asupersync-executor")]
11566 #[test]
11567 fn headless_asupersync_task_executor_is_selected() {
11568 let config = ProgramConfig::default().with_effect_queue(
11569 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11570 );
11571 let program = headless_program_with_config(TestModel { value: 0 }, config);
11572 assert_eq!(program.task_executor.kind_name(), "asupersync");
11573 }
11574
11575 #[test]
11576 fn headless_persistence_commands_with_registry() {
11577 use crate::state_persistence::{MemoryStorage, StateRegistry};
11578 use std::sync::Arc;
11579
11580 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
11581 let config = ProgramConfig::default().with_registry(registry.clone());
11582 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11583
11584 assert!(program.has_persistence());
11585 assert!(program.state_registry().is_some());
11586
11587 program.execute_cmd(Cmd::save_state()).expect("save");
11588 program.execute_cmd(Cmd::restore_state()).expect("restore");
11589
11590 let saved = program.trigger_save().expect("trigger save");
11591 let loaded = program.trigger_load().expect("trigger load");
11592 assert!(!saved);
11593 assert_eq!(loaded, 0);
11594 }
11595
11596 #[test]
11597 fn headless_process_resize_coalescer_applies_pending_resize() {
11598 struct ResizeModel {
11599 last_size: Option<(u16, u16)>,
11600 }
11601
11602 #[derive(Debug)]
11603 enum ResizeMsg {
11604 Resize(u16, u16),
11605 Other,
11606 }
11607
11608 impl From<Event> for ResizeMsg {
11609 fn from(event: Event) -> Self {
11610 match event {
11611 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11612 _ => ResizeMsg::Other,
11613 }
11614 }
11615 }
11616
11617 impl Model for ResizeModel {
11618 type Message = ResizeMsg;
11619
11620 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11621 if let ResizeMsg::Resize(w, h) = msg {
11622 self.last_size = Some((w, h));
11623 }
11624 Cmd::none()
11625 }
11626
11627 fn view(&self, _frame: &mut Frame) {}
11628 }
11629
11630 let evidence_path = temp_evidence_path("fairness_allow");
11631 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11632 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11633 config.resize_coalescer.steady_delay_ms = 0;
11634 config.resize_coalescer.burst_delay_ms = 0;
11635 config.resize_coalescer.hard_deadline_ms = 1_000;
11636 config.evidence_sink = sink_config.clone();
11637
11638 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
11639 let sink = EvidenceSink::from_config(&sink_config)
11640 .expect("evidence sink config")
11641 .expect("evidence sink enabled");
11642 program.evidence_sink = Some(sink);
11643
11644 program.resize_coalescer.handle_resize(120, 40);
11645 assert!(program.resize_coalescer.has_pending());
11646
11647 program
11648 .process_resize_coalescer()
11649 .expect("process resize coalescer");
11650
11651 assert_eq!(program.width, 120);
11652 assert_eq!(program.height, 40);
11653 assert_eq!(program.model().last_size, Some((120, 40)));
11654
11655 let config_line = read_evidence_event(&evidence_path, "fairness_config");
11656 assert_eq!(config_line["event"], "fairness_config");
11657 assert!(config_line["enabled"].is_boolean());
11658 assert!(config_line["input_priority_threshold_ms"].is_number());
11659 assert!(config_line["dominance_threshold"].is_number());
11660 assert!(config_line["fairness_threshold"].is_number());
11661
11662 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
11663 assert_eq!(decision_line["event"], "fairness_decision");
11664 assert_eq!(decision_line["decision"], "allow");
11665 assert_eq!(decision_line["reason"], "none");
11666 assert!(decision_line["pending_input_latency_ms"].is_null());
11667 assert!(decision_line["jain_index"].is_number());
11668 assert!(decision_line["resize_dominance_count"].is_number());
11669 assert!(decision_line["dominance_threshold"].is_number());
11670 assert!(decision_line["fairness_threshold"].is_number());
11671 assert!(decision_line["input_priority_threshold_ms"].is_number());
11672 }
11673
11674 #[test]
11675 fn headless_process_resize_coalescer_yields_to_input() {
11676 struct ResizeModel {
11677 last_size: Option<(u16, u16)>,
11678 }
11679
11680 #[derive(Debug)]
11681 enum ResizeMsg {
11682 Resize(u16, u16),
11683 Other,
11684 }
11685
11686 impl From<Event> for ResizeMsg {
11687 fn from(event: Event) -> Self {
11688 match event {
11689 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11690 _ => ResizeMsg::Other,
11691 }
11692 }
11693 }
11694
11695 impl Model for ResizeModel {
11696 type Message = ResizeMsg;
11697
11698 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11699 if let ResizeMsg::Resize(w, h) = msg {
11700 self.last_size = Some((w, h));
11701 }
11702 Cmd::none()
11703 }
11704
11705 fn view(&self, _frame: &mut Frame) {}
11706 }
11707
11708 let evidence_path = temp_evidence_path("fairness_yield");
11709 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11710 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11711 config.resize_coalescer.steady_delay_ms = 0;
11712 config.resize_coalescer.burst_delay_ms = 0;
11713 config.resize_coalescer.hard_deadline_ms = 10_000;
11716 config.evidence_sink = sink_config.clone();
11717
11718 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
11719 let sink = EvidenceSink::from_config(&sink_config)
11720 .expect("evidence sink config")
11721 .expect("evidence sink enabled");
11722 program.evidence_sink = Some(sink);
11723
11724 program.fairness_guard = InputFairnessGuard::with_config(
11725 crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
11726 );
11727 program
11728 .fairness_guard
11729 .input_arrived(Instant::now() - Duration::from_millis(1));
11730
11731 program.resize_coalescer.handle_resize(120, 40);
11732 assert!(program.resize_coalescer.has_pending());
11733
11734 program
11735 .process_resize_coalescer()
11736 .expect("process resize coalescer");
11737
11738 assert_eq!(program.width, 80);
11739 assert_eq!(program.height, 24);
11740 assert_eq!(program.model().last_size, None);
11741 assert!(program.resize_coalescer.has_pending());
11742
11743 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
11744 assert_eq!(decision_line["event"], "fairness_decision");
11745 assert_eq!(decision_line["decision"], "yield");
11746 assert_eq!(decision_line["reason"], "input_latency");
11747 assert!(decision_line["pending_input_latency_ms"].is_number());
11748 assert!(decision_line["jain_index"].is_number());
11749 assert!(decision_line["resize_dominance_count"].is_number());
11750 assert!(decision_line["dominance_threshold"].is_number());
11751 assert!(decision_line["fairness_threshold"].is_number());
11752 assert!(decision_line["input_priority_threshold_ms"].is_number());
11753 }
11754
11755 #[test]
11756 fn headless_execute_cmd_task_with_effect_queue() {
11757 struct TaskModel {
11758 done: bool,
11759 }
11760
11761 #[derive(Debug)]
11762 enum TaskMsg {
11763 Done,
11764 }
11765
11766 impl From<Event> for TaskMsg {
11767 fn from(_: Event) -> Self {
11768 TaskMsg::Done
11769 }
11770 }
11771
11772 impl Model for TaskModel {
11773 type Message = TaskMsg;
11774
11775 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11776 match msg {
11777 TaskMsg::Done => {
11778 self.done = true;
11779 Cmd::none()
11780 }
11781 }
11782 }
11783
11784 fn view(&self, _frame: &mut Frame) {}
11785 }
11786
11787 let effect_queue = EffectQueueConfig {
11788 enabled: true,
11789 backend: TaskExecutorBackend::EffectQueue,
11790 scheduler: SchedulerConfig {
11791 max_queue_size: 0,
11792 ..Default::default()
11793 },
11794 explicit_backend: true,
11795 ..Default::default()
11796 };
11797 let config = ProgramConfig::default().with_effect_queue(effect_queue);
11798 let mut program = headless_program_with_config(TaskModel { done: false }, config);
11799
11800 program
11801 .execute_cmd(Cmd::task(|| TaskMsg::Done))
11802 .expect("task cmd");
11803
11804 let deadline = Instant::now() + Duration::from_millis(200);
11805 while !program.model().done && Instant::now() <= deadline {
11806 program
11807 .process_task_results()
11808 .expect("process task results");
11809 }
11810
11811 assert!(
11812 program.model().done,
11813 "effect queue task result did not arrive in time"
11814 );
11815 assert_eq!(program.task_executor.kind_name(), "queued");
11816 }
11817
11818 #[test]
11819 fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
11820 struct TaskModel {
11821 done: bool,
11822 }
11823
11824 #[derive(Debug)]
11825 enum TaskMsg {
11826 Done,
11827 }
11828
11829 impl From<Event> for TaskMsg {
11830 fn from(_: Event) -> Self {
11831 TaskMsg::Done
11832 }
11833 }
11834
11835 impl Model for TaskModel {
11836 type Message = TaskMsg;
11837
11838 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11839 match msg {
11840 TaskMsg::Done => {
11841 self.done = true;
11842 Cmd::none()
11843 }
11844 }
11845 }
11846
11847 fn view(&self, _frame: &mut Frame) {}
11848 }
11849
11850 let evidence_path = temp_evidence_path("task_executor_spawned_complete");
11851 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11852 let config = ProgramConfig::default()
11853 .with_lane(RuntimeLane::Legacy)
11854 .with_evidence_sink(sink_config);
11855 let mut program = headless_program_with_config(TaskModel { done: false }, config);
11856
11857 program
11858 .execute_cmd(Cmd::task(|| TaskMsg::Done))
11859 .expect("task cmd");
11860
11861 let deadline = Instant::now() + Duration::from_millis(200);
11862 while !program.model().done && Instant::now() <= deadline {
11863 program
11864 .process_task_results()
11865 .expect("process task results");
11866 program.reap_finished_tasks();
11867 }
11868
11869 assert!(
11870 program.model().done,
11871 "spawned task result did not arrive in time"
11872 );
11873
11874 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
11875 assert_eq!(completion_line["backend"], "spawned");
11876 assert!(completion_line["duration_us"].is_number());
11877 }
11878
11879 #[test]
11880 fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
11881 struct TaskModel {
11882 done: bool,
11883 }
11884
11885 #[derive(Debug)]
11886 enum TaskMsg {
11887 Done,
11888 }
11889
11890 impl From<Event> for TaskMsg {
11891 fn from(_: Event) -> Self {
11892 TaskMsg::Done
11893 }
11894 }
11895
11896 impl Model for TaskModel {
11897 type Message = TaskMsg;
11898
11899 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11900 match msg {
11901 TaskMsg::Done => {
11902 self.done = true;
11903 Cmd::none()
11904 }
11905 }
11906 }
11907
11908 fn view(&self, _frame: &mut Frame) {}
11909 }
11910
11911 let evidence_path = temp_evidence_path("task_executor_queued_panic");
11912 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
11913 let config = ProgramConfig::default()
11914 .with_evidence_sink(sink_config)
11915 .with_effect_queue(
11916 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11917 );
11918 let mut program = headless_program_with_config(TaskModel { done: false }, config);
11919
11920 program
11921 .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
11922 .expect("panic task cmd");
11923 program
11924 .execute_cmd(Cmd::task(|| TaskMsg::Done))
11925 .expect("follow-up task cmd");
11926
11927 let deadline = Instant::now() + Duration::from_millis(500);
11928 while !program.model().done && Instant::now() <= deadline {
11929 program
11930 .process_task_results()
11931 .expect("process task results");
11932 }
11933
11934 assert!(
11935 program.model().done,
11936 "effect queue should continue after a panicking task"
11937 );
11938
11939 let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
11940 assert_eq!(panic_line["backend"], "queued");
11941 assert_eq!(panic_line["panic_msg"], "queued panic evidence");
11942 }
11943
11944 #[cfg(feature = "asupersync-executor")]
11945 #[test]
11946 fn headless_execute_cmd_task_with_asupersync_backend() {
11947 struct TaskModel {
11948 done: bool,
11949 }
11950
11951 #[derive(Debug)]
11952 enum TaskMsg {
11953 Done,
11954 }
11955
11956 impl From<Event> for TaskMsg {
11957 fn from(_: Event) -> Self {
11958 TaskMsg::Done
11959 }
11960 }
11961
11962 impl Model for TaskModel {
11963 type Message = TaskMsg;
11964
11965 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11966 match msg {
11967 TaskMsg::Done => {
11968 self.done = true;
11969 Cmd::none()
11970 }
11971 }
11972 }
11973
11974 fn view(&self, _frame: &mut Frame) {}
11975 }
11976
11977 let config = ProgramConfig::default().with_effect_queue(
11978 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
11979 );
11980 let mut program = headless_program_with_config(TaskModel { done: false }, config);
11981
11982 program
11983 .execute_cmd(Cmd::task(|| TaskMsg::Done))
11984 .expect("task cmd");
11985
11986 let deadline = Instant::now() + Duration::from_millis(200);
11987 while !program.model().done && Instant::now() <= deadline {
11988 program
11989 .process_task_results()
11990 .expect("process task results");
11991 program.reap_finished_tasks();
11992 }
11993
11994 assert!(
11995 program.model().done,
11996 "asupersync task result did not arrive in time"
11997 );
11998 assert_eq!(program.task_executor.kind_name(), "asupersync");
11999 }
12000
12001 #[cfg(feature = "asupersync-executor")]
12002 #[test]
12003 fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
12004 struct TaskModel {
12005 done: bool,
12006 }
12007
12008 #[derive(Debug)]
12009 enum TaskMsg {
12010 Done,
12011 }
12012
12013 impl From<Event> for TaskMsg {
12014 fn from(_: Event) -> Self {
12015 TaskMsg::Done
12016 }
12017 }
12018
12019 impl Model for TaskModel {
12020 type Message = TaskMsg;
12021
12022 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12023 match msg {
12024 TaskMsg::Done => {
12025 self.done = true;
12026 Cmd::none()
12027 }
12028 }
12029 }
12030
12031 fn view(&self, _frame: &mut Frame) {}
12032 }
12033
12034 let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
12035 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12036 let config = ProgramConfig::default()
12037 .with_evidence_sink(sink_config)
12038 .with_effect_queue(
12039 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12040 );
12041 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12042
12043 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12044 assert_eq!(backend_line["backend"], "asupersync");
12045
12046 program
12047 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12048 .expect("task cmd");
12049
12050 let deadline = Instant::now() + Duration::from_millis(200);
12051 while !program.model().done && Instant::now() <= deadline {
12052 program
12053 .process_task_results()
12054 .expect("process task results");
12055 program.reap_finished_tasks();
12056 }
12057
12058 assert!(
12059 program.model().done,
12060 "asupersync task result did not arrive in time"
12061 );
12062
12063 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12064 assert_eq!(completion_line["backend"], "asupersync");
12065 assert!(completion_line["duration_us"].is_number());
12066 }
12067
12068 #[test]
12073 fn unit_tau_monotone() {
12074 let mut bc = BatchController::new();
12077
12078 bc.observe_service(Duration::from_millis(20));
12080 bc.observe_service(Duration::from_millis(20));
12081 bc.observe_service(Duration::from_millis(20));
12082 let tau_high = bc.tau_s();
12083
12084 for _ in 0..20 {
12086 bc.observe_service(Duration::from_millis(1));
12087 }
12088 let tau_low = bc.tau_s();
12089
12090 assert!(
12091 tau_low <= tau_high,
12092 "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
12093 );
12094 }
12095
12096 #[test]
12097 fn unit_tau_monotone_lambda() {
12098 let mut bc = BatchController::new();
12102 let base = Instant::now();
12103
12104 for i in 0..10 {
12106 bc.observe_arrival(base + Duration::from_millis(i * 10));
12107 }
12108 let rho_fast = bc.rho_est();
12109
12110 for i in 10..20 {
12112 bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
12113 }
12114 let rho_slow = bc.rho_est();
12115
12116 assert!(
12117 rho_slow < rho_fast,
12118 "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
12119 );
12120 }
12121
12122 #[test]
12123 fn unit_stability() {
12124 let mut bc = BatchController::new();
12126 let base = Instant::now();
12127
12128 for i in 0..30 {
12130 bc.observe_arrival(base + Duration::from_millis(i * 33));
12131 bc.observe_service(Duration::from_millis(5)); }
12133
12134 assert!(
12135 bc.is_stable(),
12136 "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
12137 bc.rho_est()
12138 );
12139 assert!(
12140 bc.rho_est() < 1.0,
12141 "utilization should be < 1: ρ={:.4}",
12142 bc.rho_est()
12143 );
12144
12145 assert!(
12147 bc.tau_s() > bc.service_est_s(),
12148 "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
12149 bc.tau_s(),
12150 bc.service_est_s()
12151 );
12152 }
12153
12154 #[test]
12155 fn unit_stability_high_load() {
12156 let mut bc = BatchController::new();
12158 let base = Instant::now();
12159
12160 for i in 0..50 {
12162 bc.observe_arrival(base + Duration::from_millis(i * 10));
12163 bc.observe_service(Duration::from_millis(8));
12164 }
12165
12166 let tau = bc.tau_s();
12168 let rho_eff = bc.service_est_s() / tau;
12169 assert!(
12170 rho_eff < 1.0,
12171 "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
12172 bc.service_est_s()
12173 );
12174 }
12175
12176 #[test]
12177 fn batch_controller_defaults() {
12178 let bc = BatchController::new();
12179 assert!(bc.tau_s() >= bc.tau_min_s);
12180 assert!(bc.tau_s() <= bc.tau_max_s);
12181 assert_eq!(bc.observations(), 0);
12182 assert!(bc.is_stable());
12183 }
12184
12185 #[test]
12186 fn batch_controller_tau_clamped() {
12187 let mut bc = BatchController::new();
12188
12189 for _ in 0..20 {
12191 bc.observe_service(Duration::from_micros(10));
12192 }
12193 assert!(
12194 bc.tau_s() >= bc.tau_min_s,
12195 "τ should be >= tau_min: τ={:.6}, min={:.6}",
12196 bc.tau_s(),
12197 bc.tau_min_s
12198 );
12199
12200 for _ in 0..20 {
12202 bc.observe_service(Duration::from_millis(100));
12203 }
12204 assert!(
12205 bc.tau_s() <= bc.tau_max_s,
12206 "τ should be <= tau_max: τ={:.6}, max={:.6}",
12207 bc.tau_s(),
12208 bc.tau_max_s
12209 );
12210 }
12211
12212 #[test]
12213 fn batch_controller_duration_conversion() {
12214 let bc = BatchController::new();
12215 let tau = bc.tau();
12216 let tau_s = bc.tau_s();
12217 let diff = (tau.as_secs_f64() - tau_s).abs();
12219 assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
12220 }
12221
12222 #[test]
12223 fn batch_controller_lambda_estimation() {
12224 let mut bc = BatchController::new();
12225 let base = Instant::now();
12226
12227 for i in 0..20 {
12229 bc.observe_arrival(base + Duration::from_millis(i * 20));
12230 }
12231
12232 let lambda = bc.lambda_est();
12234 assert!(
12235 lambda > 20.0 && lambda < 100.0,
12236 "λ should be near 50: got {lambda:.1}"
12237 );
12238 }
12239
12240 #[test]
12245 fn cmd_save_state() {
12246 let cmd: Cmd<TestMsg> = Cmd::save_state();
12247 assert!(matches!(cmd, Cmd::SaveState));
12248 }
12249
12250 #[test]
12251 fn cmd_restore_state() {
12252 let cmd: Cmd<TestMsg> = Cmd::restore_state();
12253 assert!(matches!(cmd, Cmd::RestoreState));
12254 }
12255
12256 #[test]
12257 fn persistence_config_default() {
12258 let config = PersistenceConfig::default();
12259 assert!(config.registry.is_none());
12260 assert!(config.checkpoint_interval.is_none());
12261 assert!(config.auto_load);
12262 assert!(config.auto_save);
12263 }
12264
12265 #[test]
12266 fn persistence_config_disabled() {
12267 let config = PersistenceConfig::disabled();
12268 assert!(config.registry.is_none());
12269 }
12270
12271 #[test]
12272 fn persistence_config_with_registry() {
12273 use crate::state_persistence::{MemoryStorage, StateRegistry};
12274 use std::sync::Arc;
12275
12276 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12277 let config = PersistenceConfig::with_registry(registry.clone());
12278
12279 assert!(config.registry.is_some());
12280 assert!(config.auto_load);
12281 assert!(config.auto_save);
12282 }
12283
12284 #[test]
12285 fn persistence_config_checkpoint_interval() {
12286 use crate::state_persistence::{MemoryStorage, StateRegistry};
12287 use std::sync::Arc;
12288
12289 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12290 let config = PersistenceConfig::with_registry(registry)
12291 .checkpoint_every(Duration::from_secs(30))
12292 .auto_load(false)
12293 .auto_save(true);
12294
12295 assert!(config.checkpoint_interval.is_some());
12296 assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
12297 assert!(!config.auto_load);
12298 assert!(config.auto_save);
12299 }
12300
12301 #[test]
12302 fn program_config_with_persistence() {
12303 use crate::state_persistence::{MemoryStorage, StateRegistry};
12304 use std::sync::Arc;
12305
12306 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12307 let config = ProgramConfig::default().with_registry(registry);
12308
12309 assert!(config.persistence.registry.is_some());
12310 }
12311
12312 #[test]
12317 fn task_spec_default() {
12318 let spec = TaskSpec::default();
12319 assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
12320 assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
12321 assert!(spec.name.is_none());
12322 }
12323
12324 #[test]
12325 fn task_spec_new() {
12326 let spec = TaskSpec::new(5.0, 20.0);
12327 assert_eq!(spec.weight, 5.0);
12328 assert_eq!(spec.estimate_ms, 20.0);
12329 assert!(spec.name.is_none());
12330 }
12331
12332 #[test]
12333 fn task_spec_with_name() {
12334 let spec = TaskSpec::default().with_name("fetch_data");
12335 assert_eq!(spec.name.as_deref(), Some("fetch_data"));
12336 }
12337
12338 #[test]
12339 fn task_spec_debug() {
12340 let spec = TaskSpec::new(2.0, 15.0).with_name("test");
12341 let debug = format!("{spec:?}");
12342 assert!(debug.contains("2.0"));
12343 assert!(debug.contains("15.0"));
12344 assert!(debug.contains("test"));
12345 }
12346
12347 #[test]
12352 fn cmd_count_none() {
12353 let cmd: Cmd<TestMsg> = Cmd::none();
12354 assert_eq!(cmd.count(), 0);
12355 }
12356
12357 #[test]
12358 fn cmd_count_atomic() {
12359 assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
12360 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
12361 assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
12362 assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
12363 assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
12364 assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
12365 assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
12366 }
12367
12368 #[test]
12369 fn cmd_count_batch() {
12370 let cmd: Cmd<TestMsg> =
12371 Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
12372 assert_eq!(cmd.count(), 2); }
12374
12375 #[test]
12376 fn cmd_count_nested() {
12377 let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
12378 Cmd::msg(TestMsg::Increment),
12379 Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
12380 ]);
12381 assert_eq!(cmd.count(), 3);
12382 }
12383
12384 #[test]
12389 fn cmd_type_name_all_variants() {
12390 assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
12391 assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
12392 assert_eq!(
12393 Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
12394 "Batch"
12395 );
12396 assert_eq!(
12397 Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
12398 "Sequence"
12399 );
12400 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
12401 assert_eq!(
12402 Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
12403 "Tick"
12404 );
12405 assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
12406 assert_eq!(
12407 Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
12408 "Task"
12409 );
12410 assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
12411 assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
12412 assert_eq!(
12413 Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
12414 "SetMouseCapture"
12415 );
12416 }
12417
12418 #[test]
12423 fn cmd_batch_empty_returns_none() {
12424 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
12425 assert!(matches!(cmd, Cmd::None));
12426 }
12427
12428 #[test]
12429 fn cmd_batch_single_unwraps() {
12430 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
12431 assert!(matches!(cmd, Cmd::Quit));
12432 }
12433
12434 #[test]
12435 fn cmd_batch_multiple_stays_batch() {
12436 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
12437 assert!(matches!(cmd, Cmd::Batch(_)));
12438 }
12439
12440 #[test]
12441 fn cmd_sequence_empty_returns_none() {
12442 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
12443 assert!(matches!(cmd, Cmd::None));
12444 }
12445
12446 #[test]
12447 fn cmd_sequence_single_unwraps_to_inner() {
12448 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
12449 assert!(matches!(cmd, Cmd::Quit));
12450 }
12451
12452 #[test]
12453 fn cmd_sequence_multiple_stays_sequence() {
12454 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
12455 assert!(matches!(cmd, Cmd::Sequence(_)));
12456 }
12457
12458 #[test]
12463 fn cmd_task_with_spec() {
12464 let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
12465 let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
12466 match cmd {
12467 Cmd::Task(s, _) => {
12468 assert_eq!(s.weight, 3.0);
12469 assert_eq!(s.estimate_ms, 25.0);
12470 assert_eq!(s.name.as_deref(), Some("my_task"));
12471 }
12472 _ => panic!("expected Task variant"),
12473 }
12474 }
12475
12476 #[test]
12477 fn cmd_task_weighted() {
12478 let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
12479 match cmd {
12480 Cmd::Task(s, _) => {
12481 assert_eq!(s.weight, 2.0);
12482 assert_eq!(s.estimate_ms, 50.0);
12483 assert!(s.name.is_none());
12484 }
12485 _ => panic!("expected Task variant"),
12486 }
12487 }
12488
12489 #[test]
12490 fn cmd_task_named() {
12491 let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
12492 match cmd {
12493 Cmd::Task(s, _) => {
12494 assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
12495 assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
12496 assert_eq!(s.name.as_deref(), Some("background_fetch"));
12497 }
12498 _ => panic!("expected Task variant"),
12499 }
12500 }
12501
12502 #[test]
12507 fn cmd_debug_all_variant_strings() {
12508 assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
12509 assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
12510 assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
12511 assert!(
12512 format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
12513 );
12514 assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
12515 assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
12516 assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
12517 assert_eq!(
12518 format!("{:?}", Cmd::<TestMsg>::restore_state()),
12519 "RestoreState"
12520 );
12521 assert_eq!(
12522 format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
12523 "SetMouseCapture(true)"
12524 );
12525 }
12526
12527 #[test]
12532 fn headless_execute_cmd_set_mouse_capture() {
12533 let mut program =
12534 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12535 assert!(!program.backend_features.mouse_capture);
12536
12537 program
12538 .execute_cmd(Cmd::set_mouse_capture(true))
12539 .expect("set mouse capture true");
12540 assert!(program.backend_features.mouse_capture);
12541
12542 program
12543 .execute_cmd(Cmd::set_mouse_capture(false))
12544 .expect("set mouse capture false");
12545 assert!(!program.backend_features.mouse_capture);
12546 }
12547
12548 #[test]
12553 fn resize_behavior_uses_coalescer() {
12554 assert!(ResizeBehavior::Throttled.uses_coalescer());
12555 assert!(!ResizeBehavior::Immediate.uses_coalescer());
12556 }
12557
12558 #[test]
12559 fn resize_behavior_eq_and_debug() {
12560 assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
12561 assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
12562 let debug = format!("{:?}", ResizeBehavior::Throttled);
12563 assert_eq!(debug, "Throttled");
12564 }
12565
12566 #[test]
12571 fn widget_refresh_config_defaults() {
12572 let config = WidgetRefreshConfig::default();
12573 assert!(config.enabled);
12574 assert_eq!(config.staleness_window_ms, 1_000);
12575 assert_eq!(config.starve_ms, 3_000);
12576 assert_eq!(config.max_starved_per_frame, 2);
12577 assert_eq!(config.max_drop_fraction, 1.0);
12578 assert_eq!(config.weight_priority, 1.0);
12579 assert_eq!(config.weight_staleness, 0.5);
12580 assert_eq!(config.weight_focus, 0.75);
12581 assert_eq!(config.weight_interaction, 0.5);
12582 assert_eq!(config.starve_boost, 1.5);
12583 assert_eq!(config.min_cost_us, 1.0);
12584 }
12585
12586 #[test]
12591 fn effect_queue_config_default() {
12592 let config = EffectQueueConfig::default();
12593 assert!(!config.enabled);
12594 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
12595 assert!(!config.explicit_backend);
12596 assert!(config.scheduler.smith_enabled);
12597 assert!(!config.scheduler.force_fifo);
12598 assert!(!config.scheduler.preemptive);
12599 }
12600
12601 #[test]
12602 fn effect_queue_config_with_enabled() {
12603 let config = EffectQueueConfig::default().with_enabled(true);
12604 assert!(config.enabled);
12605 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
12606 assert!(config.explicit_backend);
12607 }
12608
12609 #[test]
12610 fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
12611 let config = EffectQueueConfig::default().with_enabled(false);
12612 assert!(!config.enabled);
12613 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
12614 assert!(config.explicit_backend);
12615 }
12616
12617 #[test]
12618 fn effect_queue_config_with_backend() {
12619 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
12620 assert!(config.enabled);
12621 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
12622 assert!(config.explicit_backend);
12623 }
12624
12625 #[cfg(feature = "asupersync-executor")]
12626 #[test]
12627 fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
12628 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
12629 assert!(!config.enabled);
12630 assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
12631 }
12632
12633 #[test]
12634 fn effect_queue_config_with_scheduler() {
12635 let sched = SchedulerConfig {
12636 force_fifo: true,
12637 ..Default::default()
12638 };
12639 let config = EffectQueueConfig::default().with_scheduler(sched);
12640 assert!(config.scheduler.force_fifo);
12641 }
12642
12643 #[test]
12648 fn inline_auto_remeasure_config_defaults() {
12649 let config = InlineAutoRemeasureConfig::default();
12650 assert_eq!(config.change_threshold_rows, 1);
12651 assert_eq!(config.voi.prior_alpha, 1.0);
12652 assert_eq!(config.voi.prior_beta, 9.0);
12653 assert_eq!(config.voi.max_interval_ms, 1000);
12654 assert_eq!(config.voi.min_interval_ms, 100);
12655 assert_eq!(config.voi.sample_cost, 0.08);
12656 }
12657
12658 #[test]
12663 fn headless_event_source_size() {
12664 let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
12665 assert_eq!(source.size().unwrap(), (120, 40));
12666 }
12667
12668 #[test]
12669 fn headless_event_source_poll_always_false() {
12670 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12671 assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
12672 }
12673
12674 #[test]
12675 fn headless_event_source_read_always_none() {
12676 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12677 assert!(source.read_event().unwrap().is_none());
12678 }
12679
12680 #[test]
12681 fn headless_event_source_set_features() {
12682 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
12683 let features = BackendFeatures {
12684 mouse_capture: true,
12685 bracketed_paste: true,
12686 focus_events: true,
12687 kitty_keyboard: true,
12688 };
12689 source.set_features(features).unwrap();
12690 assert_eq!(source.features, features);
12691 }
12692
12693 #[test]
12694 fn immediate_drain_budget_adds_backoff_poll_under_burst() {
12695 use ftui_core::event::{KeyCode, KeyEvent};
12696
12697 struct DrainBurstModel {
12698 processed: usize,
12699 quit_after: usize,
12700 }
12701
12702 #[derive(Debug)]
12703 #[allow(dead_code)]
12704 enum DrainBurstMsg {
12705 Event(Event),
12706 }
12707
12708 impl From<Event> for DrainBurstMsg {
12709 fn from(event: Event) -> Self {
12710 DrainBurstMsg::Event(event)
12711 }
12712 }
12713
12714 impl Model for DrainBurstModel {
12715 type Message = DrainBurstMsg;
12716
12717 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12718 match msg {
12719 DrainBurstMsg::Event(_) => {
12720 self.processed = self.processed.saturating_add(1);
12721 if self.processed >= self.quit_after {
12722 Cmd::quit()
12723 } else {
12724 Cmd::none()
12725 }
12726 }
12727 }
12728 }
12729
12730 fn view(&self, _frame: &mut Frame) {}
12731 }
12732
12733 struct DrainBurstEventSource {
12734 queue: VecDeque<Event>,
12735 poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
12736 size: (u16, u16),
12737 }
12738
12739 impl BackendEventSource for DrainBurstEventSource {
12740 type Error = io::Error;
12741
12742 fn size(&self) -> Result<(u16, u16), Self::Error> {
12743 Ok(self.size)
12744 }
12745
12746 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12747 Ok(())
12748 }
12749
12750 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
12751 self.poll_timeouts.lock().unwrap().push(timeout);
12752 Ok(!self.queue.is_empty())
12753 }
12754
12755 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12756 Ok(self.queue.pop_front())
12757 }
12758 }
12759
12760 let burst_events = 24usize;
12761 let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
12762 let mut queue = VecDeque::new();
12763 for _ in 0..burst_events {
12764 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
12765 }
12766
12767 let events = DrainBurstEventSource {
12768 queue,
12769 poll_timeouts: poll_timeouts.clone(),
12770 size: (80, 24),
12771 };
12772 let writer = TerminalWriter::new(
12773 Vec::<u8>::new(),
12774 ScreenMode::AltScreen,
12775 UiAnchor::Bottom,
12776 TerminalCapabilities::dumb(),
12777 );
12778 let config = ProgramConfig::default()
12779 .with_forced_size(80, 24)
12780 .with_signal_interception(false)
12781 .with_immediate_drain(ImmediateDrainConfig {
12782 max_zero_timeout_polls_per_burst: 3,
12783 max_burst_duration: Duration::from_secs(1),
12784 backoff_timeout: Duration::from_millis(1),
12785 });
12786
12787 let model = DrainBurstModel {
12788 processed: 0,
12789 quit_after: burst_events,
12790 };
12791 let mut program =
12792 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12793 .expect("program creation");
12794 program.run().expect("run burst");
12795
12796 assert_eq!(program.model().processed, burst_events);
12797
12798 let stats = program.immediate_drain_stats();
12799 assert_eq!(stats.bursts, 1);
12800 assert!(stats.capped_bursts >= 1);
12801 assert!(stats.backoff_polls >= 1);
12802 assert!(stats.zero_timeout_polls >= 1);
12803 assert!(stats.max_zero_timeout_polls_in_burst <= 3);
12804
12805 let timeouts = poll_timeouts.lock().unwrap();
12806 assert!(timeouts.contains(&Duration::ZERO));
12807 assert!(timeouts.contains(&Duration::from_millis(1)));
12808 }
12809
12810 #[test]
12811 fn immediate_drain_zero_poll_limit_is_clamped() {
12812 use ftui_core::event::{KeyCode, KeyEvent};
12813
12814 struct ClampModel {
12815 processed: usize,
12816 quit_after: usize,
12817 }
12818
12819 #[derive(Debug)]
12820 #[allow(dead_code)]
12821 enum ClampMsg {
12822 Event(Event),
12823 }
12824
12825 impl From<Event> for ClampMsg {
12826 fn from(event: Event) -> Self {
12827 ClampMsg::Event(event)
12828 }
12829 }
12830
12831 impl Model for ClampModel {
12832 type Message = ClampMsg;
12833
12834 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12835 match msg {
12836 ClampMsg::Event(_) => {
12837 self.processed = self.processed.saturating_add(1);
12838 if self.processed >= self.quit_after {
12839 Cmd::quit()
12840 } else {
12841 Cmd::none()
12842 }
12843 }
12844 }
12845 }
12846
12847 fn view(&self, _frame: &mut Frame) {}
12848 }
12849
12850 struct ClampSource {
12851 queue: VecDeque<Event>,
12852 }
12853
12854 impl BackendEventSource for ClampSource {
12855 type Error = io::Error;
12856
12857 fn size(&self) -> Result<(u16, u16), Self::Error> {
12858 Ok((80, 24))
12859 }
12860
12861 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12862 Ok(())
12863 }
12864
12865 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
12866 Ok(!self.queue.is_empty())
12867 }
12868
12869 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12870 Ok(self.queue.pop_front())
12871 }
12872 }
12873
12874 let burst_events = 8usize;
12875 let mut queue = VecDeque::new();
12876 for _ in 0..burst_events {
12877 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
12878 }
12879 let events = ClampSource { queue };
12880
12881 let writer = TerminalWriter::new(
12882 Vec::<u8>::new(),
12883 ScreenMode::AltScreen,
12884 UiAnchor::Bottom,
12885 TerminalCapabilities::dumb(),
12886 );
12887 let config = ProgramConfig::default()
12888 .with_forced_size(80, 24)
12889 .with_signal_interception(false)
12890 .with_immediate_drain(ImmediateDrainConfig {
12891 max_zero_timeout_polls_per_burst: 0,
12892 max_burst_duration: Duration::from_secs(1),
12893 backoff_timeout: Duration::from_millis(1),
12894 });
12895 let model = ClampModel {
12896 processed: 0,
12897 quit_after: burst_events,
12898 };
12899
12900 let mut program =
12901 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
12902 .expect("program creation");
12903 program.run().expect("run clamp");
12904
12905 let stats = program.immediate_drain_stats();
12906 assert!(stats.max_zero_timeout_polls_in_burst <= 1);
12907 }
12908
12909 #[test]
12910 fn quit_stops_draining_remaining_burst_events() {
12911 use ftui_core::event::{KeyCode, KeyEvent};
12912
12913 struct QuitBurstModel {
12914 processed: usize,
12915 quit_after: usize,
12916 }
12917
12918 #[derive(Debug)]
12919 #[allow(dead_code)]
12920 enum QuitBurstMsg {
12921 Event(Event),
12922 }
12923
12924 impl From<Event> for QuitBurstMsg {
12925 fn from(event: Event) -> Self {
12926 Self::Event(event)
12927 }
12928 }
12929
12930 impl Model for QuitBurstModel {
12931 type Message = QuitBurstMsg;
12932
12933 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12934 match msg {
12935 QuitBurstMsg::Event(_) => {
12936 self.processed = self.processed.saturating_add(1);
12937 if self.processed >= self.quit_after {
12938 Cmd::quit()
12939 } else {
12940 Cmd::none()
12941 }
12942 }
12943 }
12944 }
12945
12946 fn view(&self, _frame: &mut Frame) {}
12947 }
12948
12949 struct QuitBurstSource {
12950 queue: VecDeque<Event>,
12951 }
12952
12953 impl BackendEventSource for QuitBurstSource {
12954 type Error = io::Error;
12955
12956 fn size(&self) -> Result<(u16, u16), Self::Error> {
12957 Ok((80, 24))
12958 }
12959
12960 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
12961 Ok(())
12962 }
12963
12964 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
12965 Ok(!self.queue.is_empty())
12966 }
12967
12968 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
12969 Ok(self.queue.pop_front())
12970 }
12971 }
12972
12973 let total_events = 8usize;
12974 let quit_after = 3usize;
12975 let mut queue = VecDeque::new();
12976 for _ in 0..total_events {
12977 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
12978 }
12979
12980 let writer = TerminalWriter::new(
12981 Vec::<u8>::new(),
12982 ScreenMode::AltScreen,
12983 UiAnchor::Bottom,
12984 TerminalCapabilities::dumb(),
12985 );
12986 let config = ProgramConfig::default()
12987 .with_forced_size(80, 24)
12988 .with_signal_interception(false)
12989 .with_immediate_drain(ImmediateDrainConfig {
12990 max_zero_timeout_polls_per_burst: 64,
12991 max_burst_duration: Duration::from_secs(1),
12992 backoff_timeout: Duration::from_millis(1),
12993 });
12994 let model = QuitBurstModel {
12995 processed: 0,
12996 quit_after,
12997 };
12998 let events = QuitBurstSource { queue };
12999
13000 let mut program =
13001 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13002 .expect("program creation");
13003 program.run().expect("run burst quit");
13004
13005 assert_eq!(program.model().processed, quit_after);
13006 }
13007
13008 #[test]
13013 fn headless_program_quit_and_is_running() {
13014 let mut program =
13015 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13016 assert!(program.is_running());
13017
13018 program.quit();
13019 assert!(!program.is_running());
13020 }
13021
13022 #[test]
13023 fn headless_program_model_mut() {
13024 let mut program =
13025 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13026 assert_eq!(program.model().value, 0);
13027
13028 program.model_mut().value = 42;
13029 assert_eq!(program.model().value, 42);
13030 }
13031
13032 #[test]
13033 fn headless_program_request_redraw() {
13034 let mut program =
13035 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13036 program.dirty = false;
13037
13038 program.request_redraw();
13039 assert!(program.dirty);
13040 }
13041
13042 #[test]
13043 fn headless_program_last_widget_signals_initially_empty() {
13044 let program =
13045 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13046 assert!(program.last_widget_signals().is_empty());
13047 }
13048
13049 #[test]
13050 fn headless_program_no_persistence_by_default() {
13051 let program =
13052 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13053 assert!(!program.has_persistence());
13054 assert!(program.state_registry().is_none());
13055 }
13056
13057 #[test]
13062 fn classify_event_fairness_key_is_input() {
13063 let event = Event::Key(ftui_core::event::KeyEvent::new(
13064 ftui_core::event::KeyCode::Char('a'),
13065 ));
13066 let classification =
13067 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13068 assert_eq!(classification, FairnessEventType::Input);
13069 }
13070
13071 #[test]
13072 fn classify_event_fairness_resize_is_resize() {
13073 let event = Event::Resize {
13074 width: 80,
13075 height: 24,
13076 };
13077 let classification =
13078 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13079 assert_eq!(classification, FairnessEventType::Resize);
13080 }
13081
13082 #[test]
13083 fn classify_event_fairness_tick_is_tick() {
13084 let event = Event::Tick;
13085 let classification =
13086 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13087 assert_eq!(classification, FairnessEventType::Tick);
13088 }
13089
13090 #[test]
13091 fn classify_event_fairness_paste_is_input() {
13092 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
13093 let classification =
13094 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13095 assert_eq!(classification, FairnessEventType::Input);
13096 }
13097
13098 #[test]
13099 fn classify_event_fairness_focus_is_input() {
13100 let event = Event::Focus(true);
13101 let classification =
13102 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
13103 assert_eq!(classification, FairnessEventType::Input);
13104 }
13105
13106 #[test]
13111 fn program_config_with_diff_config() {
13112 let diff = RuntimeDiffConfig::default();
13113 let config = ProgramConfig::default().with_diff_config(diff.clone());
13114 let _ = format!("{:?}", config);
13116 }
13117
13118 #[test]
13119 fn program_config_with_evidence_sink() {
13120 let config =
13121 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
13122 let _ = format!("{:?}", config);
13123 }
13124
13125 #[test]
13126 fn program_config_with_render_trace() {
13127 let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
13128 let _ = format!("{:?}", config);
13129 }
13130
13131 #[test]
13132 fn program_config_with_locale() {
13133 let config = ProgramConfig::default().with_locale("fr");
13134 let _ = format!("{:?}", config);
13135 }
13136
13137 #[test]
13138 fn program_config_with_locale_context() {
13139 let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
13140 let _ = format!("{:?}", config);
13141 }
13142
13143 #[test]
13144 fn program_config_without_forced_size() {
13145 let config = ProgramConfig::default()
13146 .with_forced_size(80, 24)
13147 .without_forced_size();
13148 assert!(config.forced_size.is_none());
13149 }
13150
13151 #[test]
13152 fn program_config_forced_size_clamps_min() {
13153 let config = ProgramConfig::default().with_forced_size(0, 0);
13154 assert_eq!(config.forced_size, Some((1, 1)));
13155 }
13156
13157 #[test]
13158 fn program_config_with_widget_refresh() {
13159 let wrc = WidgetRefreshConfig {
13160 enabled: false,
13161 ..Default::default()
13162 };
13163 let config = ProgramConfig::default().with_widget_refresh(wrc);
13164 assert!(!config.widget_refresh.enabled);
13165 }
13166
13167 #[test]
13168 fn program_config_with_effect_queue() {
13169 let eqc = EffectQueueConfig::default().with_enabled(true);
13170 let config = ProgramConfig::default().with_effect_queue(eqc);
13171 assert!(config.effect_queue.enabled);
13172 assert_eq!(
13173 config.effect_queue.backend,
13174 TaskExecutorBackend::EffectQueue
13175 );
13176 }
13177
13178 #[test]
13179 fn program_config_with_resize_coalescer_custom() {
13180 let cc = CoalescerConfig {
13181 steady_delay_ms: 42,
13182 ..Default::default()
13183 };
13184 let config = ProgramConfig::default().with_resize_coalescer(cc);
13185 assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
13186 }
13187
13188 #[test]
13189 fn program_config_with_inline_auto_remeasure() {
13190 let config = ProgramConfig::default()
13191 .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
13192 assert!(config.inline_auto_remeasure.is_some());
13193
13194 let config = config.without_inline_auto_remeasure();
13195 assert!(config.inline_auto_remeasure.is_none());
13196 }
13197
13198 #[test]
13199 fn program_config_with_persistence_full() {
13200 let pc = PersistenceConfig::disabled();
13201 let config = ProgramConfig::default().with_persistence(pc);
13202 assert!(config.persistence.registry.is_none());
13203 }
13204
13205 #[test]
13206 fn program_config_with_conformal_config() {
13207 let config = ProgramConfig::default()
13208 .with_conformal_config(ConformalConfig::default())
13209 .without_conformal();
13210 assert!(config.conformal_config.is_none());
13211 }
13212
13213 #[test]
13218 fn program_config_with_lane() {
13219 let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
13220 assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
13221 }
13222
13223 #[test]
13224 fn program_config_default_lane_resolves_to_spawned_backend() {
13225 let resolved = ProgramConfig::default().resolved_effect_queue_config();
13233 assert!(!resolved.enabled);
13234 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13235 }
13236
13237 #[test]
13238 fn program_config_legacy_lane_resolves_to_spawned_backend() {
13239 let resolved = ProgramConfig::default()
13240 .with_lane(RuntimeLane::Legacy)
13241 .resolved_effect_queue_config();
13242 assert!(!resolved.enabled);
13243 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13244 }
13245
13246 #[test]
13247 fn program_config_explicit_spawned_backend_is_preserved() {
13248 let resolved = ProgramConfig::default()
13249 .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
13250 .resolved_effect_queue_config();
13251 assert!(!resolved.enabled);
13252 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
13253 }
13254
13255 #[test]
13256 fn program_config_with_rollout_policy() {
13257 let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
13258 assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
13259 }
13260
13261 #[test]
13262 fn rollout_policy_labels() {
13263 assert_eq!(RolloutPolicy::Off.label(), "off");
13264 assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
13265 assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
13266 assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
13267 }
13268
13269 #[test]
13270 fn rollout_policy_is_shadow() {
13271 assert!(!RolloutPolicy::Off.is_shadow());
13272 assert!(RolloutPolicy::Shadow.is_shadow());
13273 assert!(!RolloutPolicy::Enabled.is_shadow());
13274 }
13275
13276 #[test]
13277 fn rollout_policy_default_is_off() {
13278 assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
13279 }
13280
13281 #[test]
13282 fn runtime_lane_parse_legacy() {
13283 assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
13284 }
13285
13286 #[test]
13287 fn runtime_lane_parse_structured_case_insensitive() {
13288 assert_eq!(
13289 RuntimeLane::parse("Structured"),
13290 Some(RuntimeLane::Structured)
13291 );
13292 }
13293
13294 #[test]
13295 fn runtime_lane_parse_asupersync_uppercase() {
13296 assert_eq!(
13297 RuntimeLane::parse("ASUPERSYNC"),
13298 Some(RuntimeLane::Asupersync)
13299 );
13300 }
13301
13302 #[test]
13303 fn runtime_lane_parse_unrecognized() {
13304 assert_eq!(RuntimeLane::parse("bogus"), None);
13305 }
13306
13307 #[test]
13308 fn rollout_policy_parse_shadow() {
13309 assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
13310 }
13311
13312 #[test]
13313 fn rollout_policy_parse_enabled() {
13314 assert_eq!(
13315 RolloutPolicy::parse("enabled"),
13316 Some(RolloutPolicy::Enabled)
13317 );
13318 }
13319
13320 #[test]
13321 fn rollout_policy_parse_off() {
13322 assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
13323 }
13324
13325 #[test]
13326 fn rollout_policy_parse_unrecognized() {
13327 assert_eq!(RolloutPolicy::parse("bogus"), None);
13328 }
13329
13330 #[test]
13335 fn persistence_config_debug() {
13336 let config = PersistenceConfig::default();
13337 let debug = format!("{config:?}");
13338 assert!(debug.contains("PersistenceConfig"));
13339 assert!(debug.contains("auto_load"));
13340 assert!(debug.contains("auto_save"));
13341 }
13342
13343 #[test]
13348 fn frame_timing_config_debug() {
13349 use std::sync::Arc;
13350
13351 struct DummySink;
13352 impl FrameTimingSink for DummySink {
13353 fn record_frame(&self, _timing: &FrameTiming) {}
13354 }
13355
13356 let config = FrameTimingConfig::new(Arc::new(DummySink));
13357 let debug = format!("{config:?}");
13358 assert!(debug.contains("FrameTimingConfig"));
13359 }
13360
13361 #[test]
13362 fn program_config_with_frame_timing() {
13363 use std::sync::Arc;
13364
13365 struct DummySink;
13366 impl FrameTimingSink for DummySink {
13367 fn record_frame(&self, _timing: &FrameTiming) {}
13368 }
13369
13370 let config =
13371 ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
13372 assert!(config.frame_timing.is_some());
13373 }
13374
13375 #[test]
13380 fn budget_decision_evidence_decision_from_levels() {
13381 use ftui_render::budget::DegradationLevel;
13382 assert_eq!(
13384 BudgetDecisionEvidence::decision_from_levels(
13385 DegradationLevel::Full,
13386 DegradationLevel::SimpleBorders
13387 ),
13388 BudgetDecision::Degrade
13389 );
13390 assert_eq!(
13392 BudgetDecisionEvidence::decision_from_levels(
13393 DegradationLevel::SimpleBorders,
13394 DegradationLevel::Full
13395 ),
13396 BudgetDecision::Upgrade
13397 );
13398 assert_eq!(
13400 BudgetDecisionEvidence::decision_from_levels(
13401 DegradationLevel::Full,
13402 DegradationLevel::Full
13403 ),
13404 BudgetDecision::Hold
13405 );
13406 }
13407
13408 #[test]
13413 fn widget_refresh_plan_clear() {
13414 let mut plan = WidgetRefreshPlan::new();
13415 plan.frame_idx = 5;
13416 plan.budget_us = 100.0;
13417 plan.signal_count = 3;
13418 plan.over_budget = true;
13419 plan.clear();
13420 assert_eq!(plan.frame_idx, 0);
13421 assert_eq!(plan.budget_us, 0.0);
13422 assert_eq!(plan.signal_count, 0);
13423 assert!(!plan.over_budget);
13424 }
13425
13426 #[test]
13427 fn widget_refresh_plan_as_budget_empty_signals() {
13428 let plan = WidgetRefreshPlan::new();
13429 let budget = plan.as_budget();
13430 assert!(budget.allows(0, false));
13432 assert!(budget.allows(999, false));
13433 }
13434
13435 #[test]
13436 fn widget_refresh_plan_to_jsonl_structure() {
13437 let plan = WidgetRefreshPlan::new();
13438 let jsonl = plan.to_jsonl();
13439 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
13440 assert!(jsonl.contains("\"frame_idx\":0"));
13441 assert!(jsonl.contains("\"selected\":[]"));
13442 }
13443
13444 #[test]
13449 fn batch_controller_default_trait() {
13450 let bc = BatchController::default();
13451 let bc2 = BatchController::new();
13452 assert_eq!(bc.tau_s(), bc2.tau_s());
13454 assert_eq!(bc.observations(), bc2.observations());
13455 }
13456
13457 #[test]
13458 fn batch_controller_observe_arrival_stale_gap_ignored() {
13459 let mut bc = BatchController::new();
13460 let base = Instant::now();
13461 bc.observe_arrival(base);
13463 bc.observe_arrival(base + Duration::from_secs(15));
13465 assert_eq!(bc.observations(), 0);
13466 }
13467
13468 #[test]
13469 fn batch_controller_observe_service_out_of_range() {
13470 let mut bc = BatchController::new();
13471 let original_service = bc.service_est_s();
13472 bc.observe_service(Duration::from_secs(15));
13474 assert_eq!(bc.service_est_s(), original_service);
13475 }
13476
13477 #[test]
13478 fn batch_controller_lambda_zero_inter_arrival() {
13479 let bc = BatchController {
13481 ema_inter_arrival_s: 0.0,
13482 ..BatchController::new()
13483 };
13484 assert_eq!(bc.lambda_est(), 0.0);
13485 }
13486
13487 #[test]
13492 fn headless_execute_cmd_log_appends_newline_if_missing() {
13493 let mut program =
13494 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13495 program.execute_cmd(Cmd::log("no newline")).expect("log");
13496
13497 let bytes = program.writer.into_inner().expect("writer output");
13498 let output = String::from_utf8_lossy(&bytes);
13499 assert!(output.contains("no newline"));
13501 }
13502
13503 #[test]
13504 fn headless_execute_cmd_log_preserves_trailing_newline() {
13505 let mut program =
13506 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13507 program
13508 .execute_cmd(Cmd::log("with newline\n"))
13509 .expect("log");
13510
13511 let bytes = program.writer.into_inner().expect("writer output");
13512 let output = String::from_utf8_lossy(&bytes);
13513 assert!(output.contains("with newline"));
13514 }
13515
13516 #[test]
13521 fn headless_handle_event_immediate_resize() {
13522 struct ResizeModel {
13523 last_size: Option<(u16, u16)>,
13524 }
13525
13526 #[derive(Debug)]
13527 enum ResizeMsg {
13528 Resize(u16, u16),
13529 Other,
13530 }
13531
13532 impl From<Event> for ResizeMsg {
13533 fn from(event: Event) -> Self {
13534 match event {
13535 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
13536 _ => ResizeMsg::Other,
13537 }
13538 }
13539 }
13540
13541 impl Model for ResizeModel {
13542 type Message = ResizeMsg;
13543
13544 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13545 if let ResizeMsg::Resize(w, h) = msg {
13546 self.last_size = Some((w, h));
13547 }
13548 Cmd::none()
13549 }
13550
13551 fn view(&self, _frame: &mut Frame) {}
13552 }
13553
13554 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
13555 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
13556
13557 program
13558 .handle_event(Event::Resize {
13559 width: 120,
13560 height: 40,
13561 })
13562 .expect("handle resize");
13563
13564 assert_eq!(program.width, 120);
13565 assert_eq!(program.height, 40);
13566 assert_eq!(program.model().last_size, Some((120, 40)));
13567 }
13568
13569 #[test]
13574 fn headless_apply_resize_clamps_zero_to_one() {
13575 struct SimpleModel;
13576
13577 #[derive(Debug)]
13578 enum SimpleMsg {
13579 Noop,
13580 }
13581
13582 impl From<Event> for SimpleMsg {
13583 fn from(_: Event) -> Self {
13584 SimpleMsg::Noop
13585 }
13586 }
13587
13588 impl Model for SimpleModel {
13589 type Message = SimpleMsg;
13590
13591 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
13592 Cmd::none()
13593 }
13594
13595 fn view(&self, _frame: &mut Frame) {}
13596 }
13597
13598 let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
13599 program
13600 .apply_resize(0, 0, Duration::ZERO, false)
13601 .expect("resize");
13602
13603 assert_eq!(program.width, 1);
13605 assert_eq!(program.height, 1);
13606 }
13607
13608 #[test]
13613 fn force_cancel_all_idle_returns_none() {
13614 let mut adapter =
13615 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13616 assert!(adapter.force_cancel_all().is_none());
13617 }
13618
13619 #[test]
13620 fn force_cancel_all_after_pointer_down_returns_diagnostics() {
13621 let mut adapter =
13622 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13623 let target = pane_target(SplitAxis::Horizontal);
13624
13625 let down = Event::Mouse(MouseEvent::new(
13626 MouseEventKind::Down(MouseButton::Left),
13627 5,
13628 5,
13629 ));
13630 let _ = adapter.translate(&down, Some(target));
13631 assert!(adapter.active_pointer_id().is_some());
13632
13633 let diag = adapter
13634 .force_cancel_all()
13635 .expect("should produce diagnostics");
13636 assert!(diag.had_active_pointer);
13637 assert_eq!(diag.active_pointer_id, Some(1));
13638 assert!(diag.machine_transition.is_some());
13639
13640 assert_eq!(adapter.active_pointer_id(), None);
13642 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
13643 }
13644
13645 #[test]
13646 fn force_cancel_all_during_drag_returns_diagnostics() {
13647 let mut adapter =
13648 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13649 let target = pane_target(SplitAxis::Vertical);
13650
13651 let down = Event::Mouse(MouseEvent::new(
13653 MouseEventKind::Down(MouseButton::Left),
13654 3,
13655 3,
13656 ));
13657 let _ = adapter.translate(&down, Some(target));
13658
13659 let drag = Event::Mouse(MouseEvent::new(
13661 MouseEventKind::Drag(MouseButton::Left),
13662 8,
13663 3,
13664 ));
13665 let _ = adapter.translate(&drag, None);
13666
13667 let diag = adapter
13668 .force_cancel_all()
13669 .expect("should produce diagnostics");
13670 assert!(diag.had_active_pointer);
13671 assert!(diag.machine_transition.is_some());
13672 let transition = diag.machine_transition.unwrap();
13673 assert!(matches!(
13674 transition.effect,
13675 PaneDragResizeEffect::Canceled {
13676 reason: PaneCancelReason::Programmatic,
13677 ..
13678 }
13679 ));
13680 }
13681
13682 #[test]
13683 fn force_cancel_all_is_idempotent() {
13684 let mut adapter =
13685 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13686 let target = pane_target(SplitAxis::Horizontal);
13687
13688 let down = Event::Mouse(MouseEvent::new(
13689 MouseEventKind::Down(MouseButton::Left),
13690 5,
13691 5,
13692 ));
13693 let _ = adapter.translate(&down, Some(target));
13694
13695 let first = adapter.force_cancel_all();
13696 assert!(first.is_some());
13697
13698 let second = adapter.force_cancel_all();
13699 assert!(second.is_none());
13700 }
13701
13702 #[test]
13707 fn pane_interaction_guard_finish_when_idle() {
13708 let mut adapter =
13709 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13710 let guard = PaneInteractionGuard::new(&mut adapter);
13711 let diag = guard.finish();
13712 assert!(diag.is_none());
13713 }
13714
13715 #[test]
13716 fn pane_interaction_guard_finish_returns_diagnostics() {
13717 let mut adapter =
13718 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13719 let target = pane_target(SplitAxis::Horizontal);
13720
13721 let down = Event::Mouse(MouseEvent::new(
13723 MouseEventKind::Down(MouseButton::Left),
13724 5,
13725 5,
13726 ));
13727 let _ = adapter.translate(&down, Some(target));
13728
13729 let guard = PaneInteractionGuard::new(&mut adapter);
13730 let diag = guard.finish().expect("should produce diagnostics");
13731 assert!(diag.had_active_pointer);
13732 assert_eq!(diag.active_pointer_id, Some(1));
13733 }
13734
13735 #[test]
13736 fn pane_interaction_guard_drop_cancels_active_interaction() {
13737 let mut adapter =
13738 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13739 let target = pane_target(SplitAxis::Vertical);
13740
13741 let down = Event::Mouse(MouseEvent::new(
13742 MouseEventKind::Down(MouseButton::Left),
13743 7,
13744 7,
13745 ));
13746 let _ = adapter.translate(&down, Some(target));
13747 assert!(adapter.active_pointer_id().is_some());
13748
13749 {
13750 let _guard = PaneInteractionGuard::new(&mut adapter);
13751 }
13753
13754 assert_eq!(adapter.active_pointer_id(), None);
13756 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
13757 }
13758
13759 #[test]
13760 fn pane_interaction_guard_adapter_access_works() {
13761 let mut adapter =
13762 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13763 let target = pane_target(SplitAxis::Horizontal);
13764
13765 let mut guard = PaneInteractionGuard::new(&mut adapter);
13766
13767 let down = Event::Mouse(MouseEvent::new(
13769 MouseEventKind::Down(MouseButton::Left),
13770 5,
13771 5,
13772 ));
13773 let dispatch = guard.adapter().translate(&down, Some(target));
13774 assert!(dispatch.primary_event.is_some());
13775
13776 let diag = guard.finish().expect("should produce diagnostics");
13778 assert!(diag.had_active_pointer);
13779 }
13780
13781 #[test]
13782 fn pane_interaction_guard_finish_then_drop_is_safe() {
13783 let mut adapter =
13784 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
13785 let target = pane_target(SplitAxis::Horizontal);
13786
13787 let down = Event::Mouse(MouseEvent::new(
13788 MouseEventKind::Down(MouseButton::Left),
13789 5,
13790 5,
13791 ));
13792 let _ = adapter.translate(&down, Some(target));
13793
13794 let guard = PaneInteractionGuard::new(&mut adapter);
13795 let _diag = guard.finish();
13796 assert_eq!(adapter.active_pointer_id(), None);
13799 }
13800
13801 fn caps_modern() -> TerminalCapabilities {
13806 TerminalCapabilities::modern()
13807 }
13808
13809 fn caps_with_mux(
13810 mux: PaneMuxEnvironment,
13811 ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
13812 let mut caps = TerminalCapabilities::modern();
13813 match mux {
13814 PaneMuxEnvironment::Tmux => caps.in_tmux = true,
13815 PaneMuxEnvironment::Screen => caps.in_screen = true,
13816 PaneMuxEnvironment::Zellij => caps.in_zellij = true,
13817 PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
13818 PaneMuxEnvironment::None => {}
13819 }
13820 caps
13821 }
13822
13823 #[test]
13824 fn capability_matrix_bare_terminal_modern() {
13825 let caps = caps_modern();
13826 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13827
13828 assert_eq!(mat.mux, PaneMuxEnvironment::None);
13829 assert!(mat.mouse_sgr);
13830 assert!(mat.mouse_drag_reliable);
13831 assert!(mat.mouse_button_discrimination);
13832 assert!(mat.focus_events);
13833 assert!(mat.unicode_box_drawing);
13834 assert!(mat.true_color);
13835 assert!(!mat.degraded);
13836 assert!(mat.drag_enabled());
13837 assert!(mat.focus_cancel_effective());
13838 assert!(mat.limitations().is_empty());
13839 }
13840
13841 #[test]
13842 fn capability_matrix_tmux() {
13843 let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
13844 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13845
13846 assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
13847 assert!(mat.mouse_drag_reliable);
13849 assert!(!mat.focus_events);
13850 assert!(mat.drag_enabled());
13851 assert!(!mat.focus_cancel_effective());
13852 assert!(mat.degraded);
13853 }
13854
13855 #[test]
13856 fn capability_matrix_screen_degrades_drag() {
13857 let caps = caps_with_mux(PaneMuxEnvironment::Screen);
13858 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13859
13860 assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
13861 assert!(!mat.mouse_drag_reliable);
13862 assert!(!mat.focus_events);
13863 assert!(!mat.drag_enabled());
13864 assert!(!mat.focus_cancel_effective());
13865 assert!(mat.degraded);
13866
13867 let lims = mat.limitations();
13868 assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
13869 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
13870 }
13871
13872 #[test]
13873 fn capability_matrix_zellij() {
13874 let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
13875 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13876
13877 assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
13878 assert!(mat.mouse_drag_reliable);
13879 assert!(!mat.focus_events);
13880 assert!(mat.drag_enabled());
13881 assert!(!mat.focus_cancel_effective());
13882 assert!(mat.degraded);
13883 }
13884
13885 #[test]
13886 fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
13887 let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
13888 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13889
13890 assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
13891 assert!(mat.mouse_drag_reliable);
13892 assert!(!mat.focus_events);
13893 assert!(mat.drag_enabled());
13894 assert!(!mat.focus_cancel_effective());
13895 assert!(mat.degraded);
13896 }
13897
13898 #[test]
13899 fn capability_matrix_no_sgr_mouse() {
13900 let mut caps = caps_modern();
13901 caps.mouse_sgr = false;
13902 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13903
13904 assert!(!mat.mouse_sgr);
13905 assert!(!mat.mouse_button_discrimination);
13906 assert!(mat.degraded);
13907
13908 let lims = mat.limitations();
13909 assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
13910 assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
13911 }
13912
13913 #[test]
13914 fn capability_matrix_no_focus_events() {
13915 let mut caps = caps_modern();
13916 caps.focus_events = false;
13917 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13918
13919 assert!(!mat.focus_events);
13920 assert!(!mat.focus_cancel_effective());
13921 assert!(mat.degraded);
13922
13923 let lims = mat.limitations();
13924 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
13925 }
13926
13927 #[test]
13928 fn capability_matrix_dumb_terminal() {
13929 let caps = TerminalCapabilities::dumb();
13930 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13931
13932 assert_eq!(mat.mux, PaneMuxEnvironment::None);
13933 assert!(!mat.mouse_sgr);
13934 assert!(!mat.focus_events);
13935 assert!(!mat.unicode_box_drawing);
13936 assert!(!mat.true_color);
13937 assert!(mat.degraded);
13938 assert!(mat.limitations().len() >= 3);
13939 }
13940
13941 #[test]
13942 fn capability_matrix_limitations_have_fallbacks() {
13943 let caps = TerminalCapabilities::dumb();
13944 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
13945
13946 for lim in mat.limitations() {
13947 assert!(!lim.id.is_empty());
13948 assert!(!lim.description.is_empty());
13949 assert!(!lim.fallback.is_empty());
13950 }
13951 }
13952
13953 struct MultiScreenModel {
13960 active: String,
13961 screens: Vec<String>,
13962 ticked_screens: Vec<(String, u64)>,
13963 }
13964
13965 #[derive(Debug)]
13966 enum MultiScreenMsg {
13967 #[expect(dead_code)]
13968 Event(Event),
13969 }
13970
13971 impl From<Event> for MultiScreenMsg {
13972 fn from(event: Event) -> Self {
13973 MultiScreenMsg::Event(event)
13974 }
13975 }
13976
13977 impl Model for MultiScreenModel {
13978 type Message = MultiScreenMsg;
13979
13980 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13981 match msg {
13982 MultiScreenMsg::Event(_) => Cmd::none(),
13983 }
13984 }
13985
13986 fn view(&self, _frame: &mut Frame) {}
13987
13988 fn as_screen_tick_dispatch(
13989 &mut self,
13990 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
13991 Some(self)
13992 }
13993 }
13994
13995 impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
13996 fn screen_ids(&self) -> Vec<String> {
13997 self.screens.clone()
13998 }
13999
14000 fn active_screen_id(&self) -> String {
14001 self.active.clone()
14002 }
14003
14004 fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
14005 self.ticked_screens.push((screen_id.to_owned(), tick_count));
14006 }
14007 }
14008
14009 type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
14011
14012 struct RecordingStrategy {
14015 log: TransitionLog,
14016 }
14017
14018 impl RecordingStrategy {
14019 fn new(log: TransitionLog) -> Self {
14020 Self { log }
14021 }
14022 }
14023
14024 impl crate::tick_strategy::TickStrategy for RecordingStrategy {
14025 fn should_tick(
14026 &mut self,
14027 _screen_id: &str,
14028 _tick_count: u64,
14029 _active_screen: &str,
14030 ) -> crate::tick_strategy::TickDecision {
14031 crate::tick_strategy::TickDecision::Skip
14032 }
14033
14034 fn on_screen_transition(&mut self, from: &str, to: &str) {
14035 self.log
14036 .lock()
14037 .unwrap()
14038 .push((from.to_owned(), to.to_owned()));
14039 }
14040
14041 fn name(&self) -> &str {
14042 "Recording"
14043 }
14044
14045 fn debug_stats(&self) -> Vec<(String, String)> {
14046 vec![("strategy".into(), "Recording".into())]
14047 }
14048 }
14049
14050 fn headless_multi_screen_program(
14054 active: &str,
14055 screens: &[&str],
14056 ) -> (
14057 Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
14058 TransitionLog,
14059 ) {
14060 let model = MultiScreenModel {
14061 active: active.to_owned(),
14062 screens: screens.iter().map(|s| (*s).to_owned()).collect(),
14063 ticked_screens: Vec::new(),
14064 };
14065 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14066 let writer = TerminalWriter::new(
14067 Vec::<u8>::new(),
14068 ScreenMode::AltScreen,
14069 UiAnchor::Bottom,
14070 TerminalCapabilities::dumb(),
14071 );
14072 let config = ProgramConfig {
14073 forced_size: Some((80, 24)),
14074 tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
14075 ..ProgramConfig::default()
14076 };
14077 let mut prog =
14078 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14079 .expect("headless program creation failed");
14080
14081 let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
14083 prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
14084
14085 (prog, log)
14086 }
14087
14088 #[test]
14089 fn check_screen_transition_first_call_records_active() {
14090 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14091
14092 assert!(prog.last_active_screen_for_strategy.is_none());
14093 prog.check_screen_transition();
14094 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
14095
14096 assert!(prog.model.ticked_screens.is_empty());
14098 assert!(log.lock().unwrap().is_empty());
14099 }
14100
14101 #[test]
14102 fn check_screen_transition_no_change_is_noop() {
14103 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14104
14105 prog.check_screen_transition();
14107
14108 prog.check_screen_transition();
14110 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
14111
14112 assert!(prog.model.ticked_screens.is_empty());
14114 assert!(log.lock().unwrap().is_empty());
14115 }
14116
14117 #[test]
14118 fn check_screen_transition_detects_switch_and_force_ticks() {
14119 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14120
14121 prog.check_screen_transition(); prog.model.active = "B".to_owned();
14125 prog.check_screen_transition();
14126
14127 assert_eq!(prog.model.ticked_screens.len(), 1);
14129 assert_eq!(prog.model.ticked_screens[0].0, "B");
14130
14131 let transitions = log.lock().unwrap();
14133 assert_eq!(transitions.len(), 1);
14134 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
14135
14136 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
14138 }
14139
14140 #[test]
14141 fn check_screen_transition_marks_dirty_on_change() {
14142 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14143
14144 prog.check_screen_transition();
14145 prog.dirty = false;
14146
14147 prog.model.active = "B".to_owned();
14148 prog.check_screen_transition();
14149
14150 assert!(prog.dirty);
14151 }
14152
14153 #[test]
14154 fn check_screen_transition_not_dirty_when_unchanged() {
14155 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14156
14157 prog.check_screen_transition();
14158 prog.dirty = false;
14159
14160 prog.check_screen_transition();
14161
14162 assert!(!prog.dirty);
14163 }
14164
14165 #[test]
14166 fn check_screen_transition_noop_without_strategy() {
14167 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14168
14169 prog.tick_strategy = None;
14171
14172 prog.check_screen_transition();
14173 assert!(prog.last_active_screen_for_strategy.is_none());
14174 }
14175
14176 #[test]
14177 fn check_screen_transition_multiple_switches_notifies_strategy() {
14178 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
14179
14180 prog.check_screen_transition(); prog.model.active = "B".to_owned();
14184 prog.check_screen_transition();
14185 assert_eq!(prog.model.ticked_screens.len(), 1);
14186 assert_eq!(prog.model.ticked_screens[0].0, "B");
14187
14188 prog.model.active = "C".to_owned();
14190 prog.check_screen_transition();
14191 assert_eq!(prog.model.ticked_screens.len(), 2);
14192 assert_eq!(prog.model.ticked_screens[1].0, "C");
14193
14194 prog.model.active = "A".to_owned();
14196 prog.check_screen_transition();
14197 assert_eq!(prog.model.ticked_screens.len(), 3);
14198 assert_eq!(prog.model.ticked_screens[2].0, "A");
14199
14200 let transitions = log.lock().unwrap();
14202 assert_eq!(transitions.len(), 3);
14203 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
14204 assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
14205 assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
14206 }
14207
14208 #[test]
14209 fn check_screen_transition_uses_current_tick_count() {
14210 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14211 prog.tick_count = 42;
14212
14213 prog.check_screen_transition(); prog.model.active = "B".to_owned();
14216 prog.check_screen_transition();
14217
14218 assert_eq!(prog.model.ticked_screens[0].1, 42);
14220 }
14221
14222 #[test]
14223 fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
14224 use crate::subscription::{StopSignal, SubId, Subscription};
14225
14226 struct TransitionSubModel {
14227 active: String,
14228 screens: Vec<String>,
14229 subscribed: bool,
14230 }
14231
14232 #[derive(Debug)]
14233 #[allow(dead_code)]
14234 enum TransitionSubMsg {
14235 Event(Event),
14236 }
14237
14238 impl From<Event> for TransitionSubMsg {
14239 fn from(event: Event) -> Self {
14240 Self::Event(event)
14241 }
14242 }
14243
14244 impl Model for TransitionSubModel {
14245 type Message = TransitionSubMsg;
14246
14247 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
14248 Cmd::none()
14249 }
14250
14251 fn view(&self, _frame: &mut Frame) {}
14252
14253 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
14254 if self.subscribed {
14255 vec![Box::new(TransitionSubscription)]
14256 } else {
14257 vec![]
14258 }
14259 }
14260
14261 fn as_screen_tick_dispatch(
14262 &mut self,
14263 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
14264 Some(self)
14265 }
14266 }
14267
14268 impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
14269 fn screen_ids(&self) -> Vec<String> {
14270 self.screens.clone()
14271 }
14272
14273 fn active_screen_id(&self) -> String {
14274 self.active.clone()
14275 }
14276
14277 fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
14278 if screen_id == self.active {
14279 self.subscribed = true;
14280 }
14281 }
14282 }
14283
14284 struct TransitionSubscription;
14285
14286 impl Subscription<TransitionSubMsg> for TransitionSubscription {
14287 fn id(&self) -> SubId {
14288 1
14289 }
14290
14291 fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
14292 }
14293
14294 struct TransitionStrategy;
14295
14296 impl crate::tick_strategy::TickStrategy for TransitionStrategy {
14297 fn should_tick(
14298 &mut self,
14299 _screen_id: &str,
14300 _tick_count: u64,
14301 _active_screen: &str,
14302 ) -> crate::tick_strategy::TickDecision {
14303 crate::tick_strategy::TickDecision::Skip
14304 }
14305
14306 fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
14307
14308 fn name(&self) -> &str {
14309 "TransitionStrategy"
14310 }
14311
14312 fn debug_stats(&self) -> Vec<(String, String)> {
14313 vec![]
14314 }
14315 }
14316
14317 let model = TransitionSubModel {
14318 active: "A".to_owned(),
14319 screens: vec!["A".to_owned(), "B".to_owned()],
14320 subscribed: false,
14321 };
14322 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14323 let writer = TerminalWriter::new(
14324 Vec::<u8>::new(),
14325 ScreenMode::AltScreen,
14326 UiAnchor::Bottom,
14327 TerminalCapabilities::dumb(),
14328 );
14329 let config = ProgramConfig::default().with_forced_size(80, 24);
14330
14331 let mut program =
14332 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14333 .expect("program creation");
14334 program.tick_strategy = Some(Box::new(TransitionStrategy));
14335
14336 program.check_screen_transition();
14337 assert_eq!(program.subscriptions.active_count(), 0);
14338
14339 program.model.active = "B".to_owned();
14340 program.check_screen_transition();
14341
14342 assert!(program.model().subscribed);
14343 assert_eq!(program.subscriptions.active_count(), 1);
14344 }
14345
14346 #[test]
14347 fn tick_strategy_stats_returns_empty_without_strategy() {
14348 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14349 prog.tick_strategy = None;
14350 assert!(prog.tick_strategy_stats().is_empty());
14351 }
14352
14353 #[test]
14354 fn tick_strategy_stats_returns_strategy_fields() {
14355 let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
14356 let stats = prog.tick_strategy_stats();
14357 assert!(
14359 !stats.is_empty(),
14360 "stats should not be empty when strategy is configured"
14361 );
14362 }
14363}