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(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 self.apply_pointer_motion(
1355 active,
1356 position,
1357 modifiers,
1358 PaneTerminalLifecyclePhase::MouseDrag,
1359 )
1360 }
1361 MouseEventKind::Moved => {
1362 let Some(active) = self.active else {
1363 return PaneTerminalDispatch::ignored(
1364 PaneTerminalLifecyclePhase::MouseMove,
1365 PaneTerminalIgnoredReason::NoActivePointer,
1366 None,
1367 target_hint,
1368 );
1369 };
1370 self.apply_pointer_motion(
1371 active,
1372 position,
1373 modifiers,
1374 PaneTerminalLifecyclePhase::MouseMove,
1375 )
1376 }
1377 MouseEventKind::Up(button) => {
1378 let pane_button = pane_button(button);
1379 let Some(active) = self.active else {
1380 return PaneTerminalDispatch::ignored(
1381 PaneTerminalLifecyclePhase::MouseUp,
1382 PaneTerminalIgnoredReason::NoActivePointer,
1383 Some(pointer_id_for_button(pane_button)),
1384 target_hint,
1385 );
1386 };
1387 if active.button != pane_button {
1388 return PaneTerminalDispatch::ignored(
1389 PaneTerminalLifecyclePhase::MouseUp,
1390 PaneTerminalIgnoredReason::PointerButtonMismatch,
1391 Some(pointer_id_for_button(pane_button)),
1392 Some(active.target),
1393 );
1394 }
1395 let kind = PaneSemanticInputEventKind::PointerUp {
1396 target: active.target,
1397 pointer_id: active.pointer_id,
1398 button: active.button,
1399 position,
1400 };
1401 let mut dispatch = self.forward_semantic(
1402 PaneTerminalLifecyclePhase::MouseUp,
1403 Some(active.pointer_id),
1404 Some(active.target),
1405 kind,
1406 modifiers,
1407 );
1408 if dispatch.primary_transition.is_some() {
1409 let duration = active.start_time.elapsed().as_millis() as u32;
1410 let motion = PaneMotionVector::from_delta(
1411 active.cumulative_delta_x,
1412 active.cumulative_delta_y,
1413 duration,
1414 active.direction_changes,
1415 );
1416 let inertial_throw = PaneInertialThrow::from_motion(motion);
1417 dispatch.motion = Some(motion);
1418 dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1419 dispatch.inertial_throw = Some(inertial_throw);
1420 self.active = None;
1421 }
1422 dispatch
1423 }
1424 MouseEventKind::ScrollUp
1425 | MouseEventKind::ScrollDown
1426 | MouseEventKind::ScrollLeft
1427 | MouseEventKind::ScrollRight => {
1428 let target = target_hint.or(self.active.map(|active| active.target));
1429 let Some(target) = target else {
1430 return PaneTerminalDispatch::ignored(
1431 PaneTerminalLifecyclePhase::MouseScroll,
1432 PaneTerminalIgnoredReason::MissingTarget,
1433 None,
1434 None,
1435 );
1436 };
1437 let lines = match mouse.kind {
1438 MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1439 MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1440 _ => unreachable!("handled by outer match"),
1441 };
1442 let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1443 self.forward_semantic(
1444 PaneTerminalLifecyclePhase::MouseScroll,
1445 None,
1446 Some(target),
1447 kind,
1448 modifiers,
1449 )
1450 }
1451 }
1452 }
1453
1454 fn apply_pointer_motion(
1467 &mut self,
1468 mut active: PaneTerminalActivePointer,
1469 position: PanePointerPosition,
1470 modifiers: PaneModifierSnapshot,
1471 phase: PaneTerminalLifecyclePhase,
1472 ) -> PaneTerminalDispatch {
1473 let delta_x = position.x.saturating_sub(active.last_position.x);
1474 let delta_y = position.y.saturating_sub(active.last_position.y);
1475 if self.should_coalesce_drag(delta_x, delta_y) {
1476 return PaneTerminalDispatch::ignored(
1477 phase,
1478 PaneTerminalIgnoredReason::DragCoalesced,
1479 Some(active.pointer_id),
1480 Some(active.target),
1481 );
1482 }
1483 if active.sample_count > 0 {
1484 let flipped_x = delta_x.signum() != 0
1485 && active.previous_step_delta_x.signum() != 0
1486 && delta_x.signum() != active.previous_step_delta_x.signum();
1487 let flipped_y = delta_y.signum() != 0
1488 && active.previous_step_delta_y.signum() != 0
1489 && delta_y.signum() != active.previous_step_delta_y.signum();
1490 if flipped_x || flipped_y {
1491 active.direction_changes = active.direction_changes.saturating_add(1);
1492 }
1493 }
1494 active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1495 active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1496 active.sample_count = active.sample_count.saturating_add(1);
1497 active.previous_step_delta_x = delta_x;
1498 active.previous_step_delta_y = delta_y;
1499 let kind = PaneSemanticInputEventKind::PointerMove {
1500 target: active.target,
1501 pointer_id: active.pointer_id,
1502 position,
1503 delta_x,
1504 delta_y,
1505 };
1506 let mut dispatch = self.forward_semantic(
1507 phase,
1508 Some(active.pointer_id),
1509 Some(active.target),
1510 kind,
1511 modifiers,
1512 );
1513 if dispatch.primary_transition.is_some() {
1514 active.last_position = position;
1515 self.active = Some(active);
1516 let duration = active.start_time.elapsed().as_millis() as u32;
1517 dispatch.motion = Some(PaneMotionVector::from_delta(
1518 active.cumulative_delta_x,
1519 active.cumulative_delta_y,
1520 duration,
1521 active.direction_changes,
1522 ));
1523 }
1524 dispatch
1525 }
1526
1527 fn translate_key(
1528 &mut self,
1529 key: KeyEvent,
1530 target_hint: Option<PaneResizeTarget>,
1531 ) -> PaneTerminalDispatch {
1532 if !self.window_focused {
1533 return PaneTerminalDispatch::ignored(
1534 PaneTerminalLifecyclePhase::KeyResize,
1535 PaneTerminalIgnoredReason::WindowNotFocused,
1536 self.active_pointer_id(),
1537 target_hint.or(self.active.map(|active| active.target)),
1538 );
1539 }
1540 if key.kind == KeyEventKind::Release {
1541 return PaneTerminalDispatch::ignored(
1542 PaneTerminalLifecyclePhase::Other,
1543 PaneTerminalIgnoredReason::UnsupportedKey,
1544 None,
1545 target_hint,
1546 );
1547 }
1548 if matches!(key.code, KeyCode::Escape) {
1549 return self.cancel_active_dispatch(
1550 PaneTerminalLifecyclePhase::KeyCancel,
1551 PaneCancelReason::EscapeKey,
1552 PaneTerminalIgnoredReason::NoActivePointer,
1553 );
1554 }
1555 let target = target_hint.or(self.active.map(|active| active.target));
1556 let Some(target) = target else {
1557 return PaneTerminalDispatch::ignored(
1558 PaneTerminalLifecyclePhase::KeyResize,
1559 PaneTerminalIgnoredReason::MissingTarget,
1560 None,
1561 None,
1562 );
1563 };
1564 let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1565 return PaneTerminalDispatch::ignored(
1566 PaneTerminalLifecyclePhase::KeyResize,
1567 PaneTerminalIgnoredReason::UnsupportedKey,
1568 None,
1569 Some(target),
1570 );
1571 };
1572 let units = keyboard_resize_units(key.modifiers);
1573 let kind = PaneSemanticInputEventKind::KeyboardResize {
1574 target,
1575 direction,
1576 units,
1577 };
1578 self.forward_semantic(
1579 PaneTerminalLifecyclePhase::KeyResize,
1580 self.active_pointer_id(),
1581 Some(target),
1582 kind,
1583 pane_modifiers(key.modifiers),
1584 )
1585 }
1586
1587 fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1588 if focused {
1589 self.window_focused = true;
1590 return PaneTerminalDispatch::ignored(
1591 PaneTerminalLifecyclePhase::Other,
1592 PaneTerminalIgnoredReason::FocusGainNoop,
1593 self.active_pointer_id(),
1594 self.active.map(|active| active.target),
1595 );
1596 }
1597 self.window_focused = false;
1598 if !self.config.cancel_on_focus_lost {
1599 return PaneTerminalDispatch::ignored(
1600 PaneTerminalLifecyclePhase::FocusLoss,
1601 PaneTerminalIgnoredReason::ResizeNoop,
1602 self.active_pointer_id(),
1603 self.active.map(|active| active.target),
1604 );
1605 }
1606 self.cancel_active_dispatch(
1607 PaneTerminalLifecyclePhase::FocusLoss,
1608 PaneCancelReason::FocusLost,
1609 PaneTerminalIgnoredReason::NoActivePointer,
1610 )
1611 }
1612
1613 fn translate_resize(&mut self) -> PaneTerminalDispatch {
1614 if !self.config.cancel_on_resize {
1615 return PaneTerminalDispatch::ignored(
1616 PaneTerminalLifecyclePhase::ResizeInterrupt,
1617 PaneTerminalIgnoredReason::ResizeNoop,
1618 self.active_pointer_id(),
1619 self.active.map(|active| active.target),
1620 );
1621 }
1622 self.cancel_active_dispatch(
1623 PaneTerminalLifecyclePhase::ResizeInterrupt,
1624 PaneCancelReason::Programmatic,
1625 PaneTerminalIgnoredReason::ResizeNoop,
1626 )
1627 }
1628
1629 fn cancel_active_dispatch(
1630 &mut self,
1631 phase: PaneTerminalLifecyclePhase,
1632 reason: PaneCancelReason,
1633 no_active_reason: PaneTerminalIgnoredReason,
1634 ) -> PaneTerminalDispatch {
1635 let Some(active) = self.active else {
1636 return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1637 };
1638 let kind = PaneSemanticInputEventKind::Cancel {
1639 target: Some(active.target),
1640 reason,
1641 };
1642 let dispatch = self.forward_semantic(
1643 phase,
1644 Some(active.pointer_id),
1645 Some(active.target),
1646 kind,
1647 PaneModifierSnapshot::default(),
1648 );
1649 if dispatch.primary_transition.is_some() {
1650 self.active = None;
1651 }
1652 dispatch
1653 }
1654
1655 fn cancel_active_internal(
1656 &mut self,
1657 reason: PaneCancelReason,
1658 ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1659 let active = self.active?;
1660 let kind = PaneSemanticInputEventKind::Cancel {
1661 target: Some(active.target),
1662 reason,
1663 };
1664 let result = self
1665 .apply_semantic(kind, PaneModifierSnapshot::default())
1666 .ok();
1667 if result.is_some() {
1668 self.active = None;
1669 }
1670 result
1671 }
1672
1673 fn forward_semantic(
1674 &mut self,
1675 phase: PaneTerminalLifecyclePhase,
1676 pointer_id: Option<u32>,
1677 target: Option<PaneResizeTarget>,
1678 kind: PaneSemanticInputEventKind,
1679 modifiers: PaneModifierSnapshot,
1680 ) -> PaneTerminalDispatch {
1681 match self.apply_semantic(kind, modifiers) {
1682 Ok((event, transition)) => {
1683 PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1684 }
1685 Err(_) => PaneTerminalDispatch::ignored(
1686 phase,
1687 PaneTerminalIgnoredReason::MachineRejectedEvent,
1688 pointer_id,
1689 target,
1690 ),
1691 }
1692 }
1693
1694 fn apply_semantic(
1695 &mut self,
1696 kind: PaneSemanticInputEventKind,
1697 modifiers: PaneModifierSnapshot,
1698 ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1699 {
1700 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1701 event.modifiers = modifiers;
1702 let transition = self.machine.apply_event(&event)?;
1703 Ok((event, transition))
1704 }
1705
1706 fn next_sequence(&mut self) -> u64 {
1707 let sequence = self.next_sequence;
1708 self.next_sequence = self.next_sequence.saturating_add(1);
1709 sequence
1710 }
1711
1712 fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1713 if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1714 return false;
1715 }
1716 let movement = delta_x
1717 .unsigned_abs()
1718 .saturating_add(delta_y.unsigned_abs());
1719 movement < u32::from(self.config.drag_update_coalesce_distance)
1720 }
1721
1722 pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1731 let was_active = self.active.is_some();
1732 let machine_state_before = self.machine.state();
1733 let machine_transition = self.machine.force_cancel();
1734 let active_pointer = self.active.take();
1735 if !was_active && machine_transition.is_none() {
1736 return None;
1737 }
1738 Some(PaneCleanupDiagnostics {
1739 had_active_pointer: was_active,
1740 active_pointer_id: active_pointer.map(|a| a.pointer_id),
1741 machine_state_before,
1742 machine_transition,
1743 })
1744 }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq)]
1752pub struct PaneCleanupDiagnostics {
1753 pub had_active_pointer: bool,
1755 pub active_pointer_id: Option<u32>,
1757 pub machine_state_before: PaneDragResizeState,
1759 pub machine_transition: Option<PaneDragResizeTransition>,
1762}
1763
1764pub struct PaneInteractionGuard<'a> {
1779 adapter: &'a mut PaneTerminalAdapter,
1780 finished: bool,
1781 diagnostics: Option<PaneCleanupDiagnostics>,
1782}
1783
1784impl<'a> PaneInteractionGuard<'a> {
1785 pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1787 Self {
1788 adapter,
1789 finished: false,
1790 diagnostics: None,
1791 }
1792 }
1793
1794 pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1796 self.adapter
1797 }
1798
1799 pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1804 self.finished = true;
1805 let diagnostics = self.adapter.force_cancel_all();
1806 self.diagnostics = diagnostics.clone();
1807 diagnostics
1808 }
1809}
1810
1811impl Drop for PaneInteractionGuard<'_> {
1812 fn drop(&mut self) {
1813 if !self.finished {
1814 self.diagnostics = self.adapter.force_cancel_all();
1815 }
1816 }
1817}
1818
1819fn pane_button(button: MouseButton) -> PanePointerButton {
1820 match button {
1821 MouseButton::Left => PanePointerButton::Primary,
1822 MouseButton::Right => PanePointerButton::Secondary,
1823 MouseButton::Middle => PanePointerButton::Middle,
1824 }
1825}
1826
1827fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1828 match button {
1829 PanePointerButton::Primary => 1,
1830 PanePointerButton::Secondary => 2,
1831 PanePointerButton::Middle => 3,
1832 }
1833}
1834
1835fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1836 PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1837}
1838
1839fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1840 PaneModifierSnapshot {
1841 shift: modifiers.contains(Modifiers::SHIFT),
1842 alt: modifiers.contains(Modifiers::ALT),
1843 ctrl: modifiers.contains(Modifiers::CTRL),
1844 meta: modifiers.contains(Modifiers::SUPER),
1845 }
1846}
1847
1848fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1849 match (axis, code) {
1850 (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1851 (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1852 (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1853 (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1854 (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1855 (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1856 _ => None,
1857 }
1858}
1859
1860fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1861 if modifiers.contains(Modifiers::SHIFT) {
1862 5
1863 } else {
1864 1
1865 }
1866}
1867
1868#[derive(Clone)]
1872pub struct PersistenceConfig {
1873 pub registry: Option<std::sync::Arc<StateRegistry>>,
1875 pub checkpoint_interval: Option<Duration>,
1877 pub auto_load: bool,
1879 pub auto_save: bool,
1881}
1882
1883impl Default for PersistenceConfig {
1884 fn default() -> Self {
1885 Self {
1886 registry: None,
1887 checkpoint_interval: None,
1888 auto_load: true,
1889 auto_save: true,
1890 }
1891 }
1892}
1893
1894impl std::fmt::Debug for PersistenceConfig {
1895 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1896 f.debug_struct("PersistenceConfig")
1897 .field(
1898 "registry",
1899 &self.registry.as_ref().map(|r| r.backend_name()),
1900 )
1901 .field("checkpoint_interval", &self.checkpoint_interval)
1902 .field("auto_load", &self.auto_load)
1903 .field("auto_save", &self.auto_save)
1904 .finish()
1905 }
1906}
1907
1908impl PersistenceConfig {
1909 #[must_use]
1911 pub fn disabled() -> Self {
1912 Self::default()
1913 }
1914
1915 #[must_use]
1917 pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1918 Self {
1919 registry: Some(registry),
1920 ..Default::default()
1921 }
1922 }
1923
1924 #[must_use]
1926 pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1927 self.checkpoint_interval = Some(interval);
1928 self
1929 }
1930
1931 #[must_use]
1933 pub fn auto_load(mut self, enabled: bool) -> Self {
1934 self.auto_load = enabled;
1935 self
1936 }
1937
1938 #[must_use]
1940 pub fn auto_save(mut self, enabled: bool) -> Self {
1941 self.auto_save = enabled;
1942 self
1943 }
1944}
1945
1946#[derive(Debug, Clone)]
1958pub struct WidgetRefreshConfig {
1959 pub enabled: bool,
1961 pub staleness_window_ms: u64,
1963 pub starve_ms: u64,
1965 pub max_starved_per_frame: usize,
1967 pub max_drop_fraction: f32,
1970 pub weight_priority: f32,
1972 pub weight_staleness: f32,
1974 pub weight_focus: f32,
1976 pub weight_interaction: f32,
1978 pub starve_boost: f32,
1980 pub min_cost_us: f32,
1982}
1983
1984impl Default for WidgetRefreshConfig {
1985 fn default() -> Self {
1986 Self {
1987 enabled: true,
1988 staleness_window_ms: 1_000,
1989 starve_ms: 3_000,
1990 max_starved_per_frame: 2,
1991 max_drop_fraction: 1.0,
1992 weight_priority: 1.0,
1993 weight_staleness: 0.5,
1994 weight_focus: 0.75,
1995 weight_interaction: 0.5,
1996 starve_boost: 1.5,
1997 min_cost_us: 1.0,
1998 }
1999 }
2000}
2001
2002#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2004pub enum TaskExecutorBackend {
2005 #[default]
2007 Spawned,
2008 EffectQueue,
2010 #[cfg(feature = "asupersync-executor")]
2012 Asupersync,
2013}
2014
2015#[derive(Debug, Clone)]
2016pub struct EffectQueueConfig {
2017 pub enabled: bool,
2022 pub backend: TaskExecutorBackend,
2024 pub scheduler: SchedulerConfig,
2026 pub max_queue_depth: usize,
2032 explicit_backend: bool,
2034}
2035
2036impl Default for EffectQueueConfig {
2037 fn default() -> Self {
2038 let scheduler = SchedulerConfig {
2039 smith_enabled: true,
2040 force_fifo: false,
2041 preemptive: false,
2042 aging_factor: 0.0,
2043 wait_starve_ms: 0.0,
2044 enable_logging: false,
2045 ..Default::default()
2046 };
2047 Self {
2048 enabled: false,
2049 backend: TaskExecutorBackend::Spawned,
2050 scheduler,
2051 max_queue_depth: 0,
2052 explicit_backend: false,
2053 }
2054 }
2055}
2056
2057impl EffectQueueConfig {
2058 #[must_use]
2060 pub fn with_enabled(mut self, enabled: bool) -> Self {
2061 self.enabled = enabled;
2062 self.backend = if enabled {
2063 TaskExecutorBackend::EffectQueue
2064 } else {
2065 TaskExecutorBackend::Spawned
2066 };
2067 self.explicit_backend = true;
2068 self
2069 }
2070
2071 #[must_use]
2073 pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2074 self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2075 self.backend = backend;
2076 self.explicit_backend = true;
2077 self
2078 }
2079
2080 #[must_use]
2082 pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2083 self.scheduler = scheduler;
2084 self
2085 }
2086
2087 #[must_use]
2092 pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2093 self.max_queue_depth = depth;
2094 self
2095 }
2096
2097 #[must_use]
2098 fn uses_legacy_default_backend(&self) -> bool {
2099 !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2100 }
2101}
2102
2103#[derive(Debug, Clone)]
2110pub struct ImmediateDrainConfig {
2111 pub max_zero_timeout_polls_per_burst: usize,
2113 pub max_burst_duration: Duration,
2115 pub backoff_timeout: Duration,
2117}
2118
2119impl Default for ImmediateDrainConfig {
2120 fn default() -> Self {
2121 Self {
2122 max_zero_timeout_polls_per_burst: 64,
2123 max_burst_duration: Duration::from_millis(2),
2124 backoff_timeout: Duration::from_millis(1),
2125 }
2126 }
2127}
2128
2129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2131pub struct ImmediateDrainStats {
2132 pub bursts: u64,
2134 pub zero_timeout_polls: u64,
2136 pub backoff_polls: u64,
2138 pub capped_bursts: u64,
2140 pub max_zero_timeout_polls_in_burst: u64,
2142}
2143
2144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2158pub enum RuntimeLoadMode {
2159 Healthy,
2161 Stressed,
2163 Degraded,
2165 Recovered,
2167 Unsafe,
2169}
2170
2171impl RuntimeLoadMode {
2172 #[inline]
2174 #[must_use]
2175 pub const fn as_str(self) -> &'static str {
2176 match self {
2177 Self::Healthy => "healthy",
2178 Self::Stressed => "stressed",
2179 Self::Degraded => "degraded",
2180 Self::Recovered => "recovered",
2181 Self::Unsafe => "unsafe",
2182 }
2183 }
2184}
2185
2186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2188pub enum RuntimePressureClass {
2189 SteadyState,
2191 SoftOverload,
2193 HardOverload,
2195 Unsafe,
2197}
2198
2199impl RuntimePressureClass {
2200 #[inline]
2202 #[must_use]
2203 pub const fn as_str(self) -> &'static str {
2204 match self {
2205 Self::SteadyState => "steady_state",
2206 Self::SoftOverload => "soft_overload",
2207 Self::HardOverload => "hard_overload",
2208 Self::Unsafe => "unsafe",
2209 }
2210 }
2211}
2212
2213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2215pub enum RuntimeWorkDisposition {
2216 AdmitAll,
2218 CoalesceVisibleDeferBackground,
2220 DeferBackgroundDropBestEffort,
2222 ReadmitAfterHysteresis,
2224 FailFastStrictGuarantee,
2226}
2227
2228impl RuntimeWorkDisposition {
2229 #[inline]
2231 #[must_use]
2232 pub const fn as_str(self) -> &'static str {
2233 match self {
2234 Self::AdmitAll => "admit_all",
2235 Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2236 Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2237 Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2238 Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2239 }
2240 }
2241}
2242
2243#[derive(Debug, Clone, Copy, PartialEq)]
2250pub struct LoadGovernorPolicy {
2251 pub stressed_queue_watermark: f64,
2253 pub degraded_queue_watermark: f64,
2255 pub recovery_queue_watermark: f64,
2257 pub recovery_intervals: u8,
2259 pub budget_overrun_soft_ratio: f64,
2261}
2262
2263impl Default for LoadGovernorPolicy {
2264 fn default() -> Self {
2265 Self {
2266 stressed_queue_watermark: 0.5,
2267 degraded_queue_watermark: 0.8,
2268 recovery_queue_watermark: 0.25,
2269 recovery_intervals: 3,
2270 budget_overrun_soft_ratio: 1.0,
2271 }
2272 }
2273}
2274
2275impl LoadGovernorPolicy {
2276 #[must_use]
2277 fn normalized(self) -> Self {
2278 let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2279 let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2280 let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2281 Self {
2282 recovery_queue_watermark: recovery,
2283 stressed_queue_watermark: stressed,
2284 degraded_queue_watermark: degraded,
2285 recovery_intervals: self.recovery_intervals.max(1),
2286 budget_overrun_soft_ratio: normalize_positive_ratio(
2287 self.budget_overrun_soft_ratio,
2288 1.0,
2289 ),
2290 }
2291 }
2292}
2293
2294#[inline]
2295fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2296 if value.is_finite() {
2297 value.clamp(0.0, 1.0)
2298 } else {
2299 fallback
2300 }
2301}
2302
2303#[inline]
2304fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2305 if value.is_finite() && value > 0.0 {
2306 value
2307 } else {
2308 fallback
2309 }
2310}
2311
2312#[derive(Debug, Clone, PartialEq)]
2320pub struct LoadGovernorConfig {
2321 pub enabled: bool,
2323 pub budget_controller: BudgetControllerConfig,
2325 pub policy: LoadGovernorPolicy,
2327}
2328
2329impl Default for LoadGovernorConfig {
2330 fn default() -> Self {
2331 Self {
2332 enabled: true,
2333 budget_controller: BudgetControllerConfig::default(),
2334 policy: LoadGovernorPolicy::default(),
2335 }
2336 }
2337}
2338
2339impl LoadGovernorConfig {
2340 #[must_use]
2342 pub fn enabled() -> Self {
2343 Self::default()
2344 }
2345
2346 #[must_use]
2348 pub fn disabled() -> Self {
2349 Self {
2350 enabled: false,
2351 budget_controller: BudgetControllerConfig::default(),
2352 policy: LoadGovernorPolicy::default(),
2353 }
2354 }
2355
2356 #[must_use]
2358 pub fn with_enabled(mut self, enabled: bool) -> Self {
2359 self.enabled = enabled;
2360 self
2361 }
2362
2363 #[must_use]
2365 pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2366 self.budget_controller = config;
2367 self
2368 }
2369
2370 #[must_use]
2372 pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2373 self.policy = policy.normalized();
2374 self
2375 }
2376}
2377
2378#[derive(Debug, Clone, Copy)]
2379struct LoadGovernorObservation {
2380 frame_time_us: f64,
2381 budget_us: f64,
2382 degradation: DegradationLevel,
2383 queue: crate::effect_system::QueueTelemetry,
2384 resize_coalescing_active: bool,
2385 strict_semantics_violation: bool,
2386}
2387
2388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2389struct LoadGovernorSnapshot {
2390 mode: RuntimeLoadMode,
2391 mode_before: RuntimeLoadMode,
2392 pressure_class: RuntimePressureClass,
2393 disposition: RuntimeWorkDisposition,
2394 reason_code: &'static str,
2395 transition: bool,
2396 strict_semantics_preserved: bool,
2397 queue_in_flight: u64,
2398 queue_max_depth: Option<usize>,
2399 queue_dropped_delta: u64,
2400 resize_coalescing_active: bool,
2401 recovery_intervals_observed: u8,
2402 recovery_intervals_required: u8,
2403 deferred_work_total: u64,
2404 coalesced_work_total: u64,
2405 dropped_work_total: u64,
2406}
2407
2408#[derive(Debug, Clone)]
2409struct LoadGovernorState {
2410 enabled: bool,
2411 policy: LoadGovernorPolicy,
2412 max_queue_depth: usize,
2413 mode: RuntimeLoadMode,
2414 recovery_intervals_observed: u8,
2415 last_queue_dropped: u64,
2416 queue_baseline_initialized: bool,
2417 deferred_work_total: u64,
2418 coalesced_work_total: u64,
2419 dropped_work_total: u64,
2420}
2421
2422impl LoadGovernorState {
2423 fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2424 Self {
2425 enabled: config.enabled,
2426 policy: config.policy.normalized(),
2427 max_queue_depth,
2428 mode: RuntimeLoadMode::Healthy,
2429 recovery_intervals_observed: 0,
2430 last_queue_dropped: 0,
2431 queue_baseline_initialized: false,
2432 deferred_work_total: 0,
2433 coalesced_work_total: 0,
2434 dropped_work_total: 0,
2435 }
2436 }
2437
2438 fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2439 if !self.enabled {
2440 return self.snapshot(
2441 RuntimeLoadMode::Healthy,
2442 RuntimeLoadMode::Healthy,
2443 RuntimePressureClass::SteadyState,
2444 RuntimeWorkDisposition::AdmitAll,
2445 "governor_disabled",
2446 false,
2447 true,
2448 observation,
2449 0,
2450 );
2451 }
2452
2453 let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2454 let pressure = self.classify_pressure(observation, dropped_delta);
2455 let mode_before = self.mode;
2456 let reason_code = self.reason_code(observation, pressure, dropped_delta);
2457
2458 match pressure {
2459 RuntimePressureClass::Unsafe => {
2460 self.mode = RuntimeLoadMode::Unsafe;
2461 self.recovery_intervals_observed = 0;
2462 }
2463 _ if self.mode == RuntimeLoadMode::Unsafe => {
2471 self.recovery_intervals_observed = 0;
2472 }
2473 RuntimePressureClass::HardOverload => {
2474 self.mode = RuntimeLoadMode::Degraded;
2475 self.recovery_intervals_observed = 0;
2476 }
2477 RuntimePressureClass::SoftOverload => {
2478 if self.mode != RuntimeLoadMode::Degraded {
2479 self.mode = RuntimeLoadMode::Stressed;
2480 }
2481 self.recovery_intervals_observed = 0;
2482 }
2483 RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2484 }
2485
2486 self.record_work_disposition(observation, dropped_delta);
2487 let disposition = Self::disposition_for_mode(self.mode);
2488 self.snapshot(
2489 self.mode,
2490 mode_before,
2491 pressure,
2492 disposition,
2493 if self.mode == RuntimeLoadMode::Unsafe {
2494 "strict_semantics_violation"
2497 } else if self.mode == RuntimeLoadMode::Recovered {
2498 "recovery_hysteresis_satisfied"
2499 } else if mode_before == RuntimeLoadMode::Recovered
2500 && self.mode == RuntimeLoadMode::Healthy
2501 {
2502 "recovered_interval_closed"
2503 } else {
2504 reason_code
2505 },
2506 mode_before != self.mode,
2507 self.mode != RuntimeLoadMode::Unsafe,
2511 observation,
2512 dropped_delta,
2513 )
2514 }
2515
2516 fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2517 if !self.queue_baseline_initialized {
2518 self.queue_baseline_initialized = true;
2519 self.last_queue_dropped = dropped_total;
2520 return 0;
2521 }
2522 let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2523 self.last_queue_dropped = dropped_total;
2524 delta
2525 }
2526
2527 fn classify_pressure(
2528 &self,
2529 observation: LoadGovernorObservation,
2530 dropped_delta: u64,
2531 ) -> RuntimePressureClass {
2532 if observation.strict_semantics_violation {
2533 return RuntimePressureClass::Unsafe;
2534 }
2535 if dropped_delta > 0
2536 || self
2537 .queue_ratio(observation.queue.in_flight)
2538 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2539 || observation.degradation > DegradationLevel::Full
2540 {
2541 return RuntimePressureClass::HardOverload;
2542 }
2543 if self
2544 .queue_ratio(observation.queue.in_flight)
2545 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2546 || observation.resize_coalescing_active
2547 || observation.frame_time_us
2548 > observation.budget_us * self.policy.budget_overrun_soft_ratio
2549 {
2550 return RuntimePressureClass::SoftOverload;
2551 }
2552 RuntimePressureClass::SteadyState
2553 }
2554
2555 fn observe_steady_interval(&mut self) {
2556 match self.mode {
2557 RuntimeLoadMode::Degraded => {
2558 self.recovery_intervals_observed = self
2559 .recovery_intervals_observed
2560 .saturating_add(1)
2561 .min(self.policy.recovery_intervals);
2562 if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2563 self.mode = RuntimeLoadMode::Recovered;
2564 }
2565 }
2566 RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2567 self.mode = RuntimeLoadMode::Healthy;
2568 self.recovery_intervals_observed = 0;
2569 }
2570 RuntimeLoadMode::Healthy => {
2571 self.recovery_intervals_observed = 0;
2572 }
2573 RuntimeLoadMode::Unsafe => {}
2574 }
2575 }
2576
2577 fn record_work_disposition(
2578 &mut self,
2579 observation: LoadGovernorObservation,
2580 dropped_delta: u64,
2581 ) {
2582 if dropped_delta > 0 {
2583 self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2584 }
2585 match self.mode {
2586 RuntimeLoadMode::Stressed => {
2587 if observation.resize_coalescing_active {
2588 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2589 }
2590 }
2591 RuntimeLoadMode::Degraded => {
2592 self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2593 if observation.resize_coalescing_active {
2594 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2595 }
2596 }
2597 RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2598 }
2599 }
2600
2601 fn reason_code(
2602 &self,
2603 observation: LoadGovernorObservation,
2604 pressure: RuntimePressureClass,
2605 dropped_delta: u64,
2606 ) -> &'static str {
2607 match pressure {
2608 RuntimePressureClass::Unsafe => "strict_semantics_violation",
2609 RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2610 RuntimePressureClass::HardOverload
2611 if self
2612 .queue_ratio(observation.queue.in_flight)
2613 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2614 {
2615 "queue_degraded_watermark"
2616 }
2617 RuntimePressureClass::HardOverload => "budget_degradation_active",
2618 RuntimePressureClass::SoftOverload
2619 if self
2620 .queue_ratio(observation.queue.in_flight)
2621 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2622 {
2623 "queue_stressed_watermark"
2624 }
2625 RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2626 "resize_coalescing_active"
2627 }
2628 RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2629 RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2630 "recovery_hysteresis_pending"
2631 }
2632 RuntimePressureClass::SteadyState => "steady_state",
2633 }
2634 }
2635
2636 fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2637 (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2638 }
2639
2640 const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2641 match mode {
2642 RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2643 RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2644 RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2645 RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2646 RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2647 }
2648 }
2649
2650 #[allow(clippy::too_many_arguments)]
2654 fn snapshot(
2655 &self,
2656 mode: RuntimeLoadMode,
2657 mode_before: RuntimeLoadMode,
2658 pressure_class: RuntimePressureClass,
2659 disposition: RuntimeWorkDisposition,
2660 reason_code: &'static str,
2661 transition: bool,
2662 strict_semantics_preserved: bool,
2663 observation: LoadGovernorObservation,
2664 dropped_delta: u64,
2665 ) -> LoadGovernorSnapshot {
2666 LoadGovernorSnapshot {
2667 mode,
2668 mode_before,
2669 pressure_class,
2670 disposition,
2671 reason_code,
2672 transition,
2673 strict_semantics_preserved,
2674 queue_in_flight: observation.queue.in_flight,
2675 queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2676 queue_dropped_delta: dropped_delta,
2677 resize_coalescing_active: observation.resize_coalescing_active,
2678 recovery_intervals_observed: self.recovery_intervals_observed,
2679 recovery_intervals_required: self.policy.recovery_intervals,
2680 deferred_work_total: self.deferred_work_total,
2681 coalesced_work_total: self.coalesced_work_total,
2682 dropped_work_total: self.dropped_work_total,
2683 }
2684 }
2685}
2686
2687#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2701pub enum RuntimeLane {
2702 Legacy,
2705 #[default]
2708 Structured,
2709 Asupersync,
2712}
2713
2714impl RuntimeLane {
2715 #[must_use]
2720 pub fn resolve(self) -> Self {
2721 match self {
2722 Self::Asupersync => {
2723 tracing::info!(
2724 target: "ftui.runtime",
2725 requested = "asupersync",
2726 resolved = "structured",
2727 "Asupersync lane not yet available; falling back to structured cancellation"
2728 );
2729 Self::Structured
2730 }
2731 other => other,
2732 }
2733 }
2734
2735 #[must_use]
2737 pub fn label(self) -> &'static str {
2738 match self {
2739 Self::Legacy => "legacy",
2740 Self::Structured => "structured",
2741 Self::Asupersync => "asupersync",
2742 }
2743 }
2744
2745 #[must_use]
2747 pub fn uses_structured_cancellation(self) -> bool {
2748 matches!(self, Self::Structured | Self::Asupersync)
2749 }
2750
2751 #[must_use]
2773 fn task_executor_backend(self) -> TaskExecutorBackend {
2774 match self {
2775 Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2780 Self::Asupersync => {
2781 #[cfg(feature = "asupersync-executor")]
2782 {
2783 TaskExecutorBackend::Asupersync
2784 }
2785 #[cfg(not(feature = "asupersync-executor"))]
2786 {
2787 TaskExecutorBackend::EffectQueue
2788 }
2789 }
2790 }
2791 }
2792
2793 #[must_use]
2798 pub fn from_env() -> Option<Self> {
2799 let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2800 Self::parse(&val)
2801 }
2802
2803 #[must_use]
2807 pub fn parse(s: &str) -> Option<Self> {
2808 match s.to_ascii_lowercase().as_str() {
2809 "legacy" => Some(Self::Legacy),
2810 "structured" => Some(Self::Structured),
2811 "asupersync" => Some(Self::Asupersync),
2812 _ => {
2813 tracing::warn!(
2814 target: "ftui.runtime",
2815 value = s,
2816 "RuntimeLane::parse: unrecognized value"
2817 );
2818 None
2819 }
2820 }
2821 }
2822}
2823
2824#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2838pub enum RolloutPolicy {
2839 #[default]
2841 Off,
2842 Shadow,
2844 Enabled,
2846}
2847
2848impl RolloutPolicy {
2849 #[must_use]
2854 pub fn from_env() -> Option<Self> {
2855 let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2856 Self::parse(&val)
2857 }
2858
2859 #[must_use]
2863 pub fn parse(s: &str) -> Option<Self> {
2864 match s.to_ascii_lowercase().as_str() {
2865 "off" => Some(Self::Off),
2866 "shadow" => Some(Self::Shadow),
2867 "enabled" => Some(Self::Enabled),
2868 _ => {
2869 tracing::warn!(
2870 target: "ftui.runtime",
2871 value = s,
2872 "RolloutPolicy::parse: unrecognized value"
2873 );
2874 None
2875 }
2876 }
2877 }
2878
2879 #[must_use]
2881 pub fn label(self) -> &'static str {
2882 match self {
2883 Self::Off => "off",
2884 Self::Shadow => "shadow",
2885 Self::Enabled => "enabled",
2886 }
2887 }
2888
2889 #[must_use]
2891 pub fn is_shadow(self) -> bool {
2892 matches!(self, Self::Shadow)
2893 }
2894}
2895
2896impl std::fmt::Display for RolloutPolicy {
2897 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2898 f.write_str(self.label())
2899 }
2900}
2901
2902impl std::fmt::Display for RuntimeLane {
2903 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2904 f.write_str(self.label())
2905 }
2906}
2907
2908#[derive(Debug, Clone)]
2910pub struct ProgramConfig {
2911 pub screen_mode: ScreenMode,
2913 pub ui_anchor: UiAnchor,
2915 pub budget: FrameBudgetConfig,
2917 pub load_governor: LoadGovernorConfig,
2919 pub diff_config: RuntimeDiffConfig,
2921 pub evidence_sink: EvidenceSinkConfig,
2923 pub render_trace: RenderTraceConfig,
2925 pub frame_timing: Option<FrameTimingConfig>,
2927 pub conformal_config: Option<ConformalConfig>,
2929 pub locale_context: LocaleContext,
2931 pub poll_timeout: Duration,
2933 pub immediate_drain: ImmediateDrainConfig,
2935 pub resize_coalescer: CoalescerConfig,
2937 pub resize_behavior: ResizeBehavior,
2939 pub forced_size: Option<(u16, u16)>,
2941 pub mouse_capture_policy: MouseCapturePolicy,
2945 pub bracketed_paste: bool,
2947 pub focus_reporting: bool,
2949 pub kitty_keyboard: bool,
2951 pub persistence: PersistenceConfig,
2953 pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2955 pub widget_refresh: WidgetRefreshConfig,
2957 pub effect_queue: EffectQueueConfig,
2959 pub guardrails: GuardrailsConfig,
2961 pub intercept_signals: bool,
2966 pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2971 pub runtime_lane: RuntimeLane,
2977 pub rollout_policy: RolloutPolicy,
2983}
2984
2985impl Default for ProgramConfig {
2986 fn default() -> Self {
2987 Self {
2988 screen_mode: ScreenMode::Inline { ui_height: 4 },
2989 ui_anchor: UiAnchor::Bottom,
2990 budget: FrameBudgetConfig::default(),
2991 load_governor: LoadGovernorConfig::default(),
2992 diff_config: RuntimeDiffConfig::default(),
2993 evidence_sink: EvidenceSinkConfig::default(),
2994 render_trace: RenderTraceConfig::default(),
2995 frame_timing: None,
2996 conformal_config: None,
2997 locale_context: LocaleContext::global(),
2998 poll_timeout: Duration::from_millis(100),
2999 immediate_drain: ImmediateDrainConfig::default(),
3000 resize_coalescer: CoalescerConfig::default(),
3001 resize_behavior: ResizeBehavior::Throttled,
3002 forced_size: None,
3003 mouse_capture_policy: MouseCapturePolicy::Auto,
3004 bracketed_paste: true,
3005 focus_reporting: false,
3006 kitty_keyboard: false,
3007 persistence: PersistenceConfig::default(),
3008 inline_auto_remeasure: None,
3009 widget_refresh: WidgetRefreshConfig::default(),
3010 effect_queue: EffectQueueConfig::default(),
3011 guardrails: GuardrailsConfig::default(),
3012 intercept_signals: true,
3013 tick_strategy: None,
3014 runtime_lane: RuntimeLane::default(),
3015 rollout_policy: RolloutPolicy::default(),
3016 }
3017 }
3018}
3019
3020impl ProgramConfig {
3021 pub fn fullscreen() -> Self {
3023 Self {
3024 screen_mode: ScreenMode::AltScreen,
3025 ..Default::default()
3026 }
3027 }
3028
3029 pub fn inline(height: u16) -> Self {
3031 Self {
3032 screen_mode: ScreenMode::Inline { ui_height: height },
3033 ..Default::default()
3034 }
3035 }
3036
3037 pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3039 Self {
3040 screen_mode: ScreenMode::InlineAuto {
3041 min_height,
3042 max_height,
3043 },
3044 inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3045 ..Default::default()
3046 }
3047 }
3048
3049 #[must_use]
3051 pub fn with_mouse(mut self) -> Self {
3052 self.mouse_capture_policy = MouseCapturePolicy::On;
3053 self
3054 }
3055
3056 #[must_use]
3058 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3059 self.mouse_capture_policy = policy;
3060 self
3061 }
3062
3063 #[must_use]
3065 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3066 self.mouse_capture_policy = if enabled {
3067 MouseCapturePolicy::On
3068 } else {
3069 MouseCapturePolicy::Off
3070 };
3071 self
3072 }
3073
3074 #[must_use]
3076 pub const fn resolved_mouse_capture(&self) -> bool {
3077 self.mouse_capture_policy.resolve(self.screen_mode)
3078 }
3079
3080 #[must_use]
3082 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3083 self.budget = budget;
3084 self
3085 }
3086
3087 #[must_use]
3089 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3090 self.load_governor = config;
3091 self
3092 }
3093
3094 #[must_use]
3096 pub fn without_load_governor(mut self) -> Self {
3097 self.load_governor = LoadGovernorConfig::disabled();
3098 self
3099 }
3100
3101 #[must_use]
3103 pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3104 self.diff_config = diff_config;
3105 self
3106 }
3107
3108 #[must_use]
3110 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3111 self.evidence_sink = config;
3112 self
3113 }
3114
3115 #[must_use]
3117 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3118 self.render_trace = config;
3119 self
3120 }
3121
3122 #[must_use]
3124 pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3125 self.frame_timing = Some(config);
3126 self
3127 }
3128
3129 #[must_use]
3131 pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3132 self.conformal_config = Some(config);
3133 self
3134 }
3135
3136 #[must_use]
3138 pub fn without_conformal(mut self) -> Self {
3139 self.conformal_config = None;
3140 self
3141 }
3142
3143 #[must_use]
3145 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3146 self.locale_context = locale_context;
3147 self
3148 }
3149
3150 #[must_use]
3152 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3153 self.locale_context = LocaleContext::new(locale);
3154 self
3155 }
3156
3157 #[must_use]
3159 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3160 self.widget_refresh = config;
3161 self
3162 }
3163
3164 #[must_use]
3166 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3167 self.effect_queue = config;
3168 self
3169 }
3170
3171 #[must_use]
3173 pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3174 self.resize_coalescer = config;
3175 self
3176 }
3177
3178 #[must_use]
3180 pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3181 self.resize_behavior = behavior;
3182 self
3183 }
3184
3185 #[must_use]
3187 pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3188 let width = width.max(1);
3189 let height = height.max(1);
3190 self.forced_size = Some((width, height));
3191 self
3192 }
3193
3194 #[must_use]
3196 pub fn without_forced_size(mut self) -> Self {
3197 self.forced_size = None;
3198 self
3199 }
3200
3201 #[must_use]
3203 pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3204 if enabled {
3205 self.resize_behavior = ResizeBehavior::Immediate;
3206 }
3207 self
3208 }
3209
3210 #[must_use]
3212 pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3213 self.persistence = persistence;
3214 self
3215 }
3216
3217 #[must_use]
3219 pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3220 self.persistence = PersistenceConfig::with_registry(registry);
3221 self
3222 }
3223
3224 #[must_use]
3226 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3227 self.inline_auto_remeasure = Some(config);
3228 self
3229 }
3230
3231 #[must_use]
3233 pub fn without_inline_auto_remeasure(mut self) -> Self {
3234 self.inline_auto_remeasure = None;
3235 self
3236 }
3237
3238 #[must_use]
3240 pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3241 self.intercept_signals = enabled;
3242 self
3243 }
3244
3245 #[must_use]
3247 pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3248 self.guardrails = config;
3249 self
3250 }
3251
3252 #[must_use]
3254 pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3255 self.immediate_drain = config;
3256 self
3257 }
3258
3259 #[must_use]
3270 pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3271 self.tick_strategy = Some(strategy);
3272 self
3273 }
3274
3275 #[must_use]
3277 pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3278 self.runtime_lane = lane;
3279 self
3280 }
3281
3282 #[must_use]
3284 pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3285 self.rollout_policy = policy;
3286 self
3287 }
3288
3289 #[must_use]
3295 pub fn with_env_overrides(mut self) -> Self {
3296 if let Some(lane) = RuntimeLane::from_env() {
3297 self.runtime_lane = lane;
3298 }
3299 if let Some(policy) = RolloutPolicy::from_env() {
3300 self.rollout_policy = policy;
3301 }
3302 self
3303 }
3304
3305 #[must_use]
3306 fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3307 if !self.effect_queue.uses_legacy_default_backend() {
3308 return self.effect_queue.clone();
3309 }
3310
3311 self.effect_queue
3312 .clone()
3313 .with_backend(self.runtime_lane.resolve().task_executor_backend())
3314 }
3315}
3316
3317fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3318 let budget = RenderBudget::from_config(&config.budget);
3319 if config.load_governor.enabled {
3320 let mut controller = config.load_governor.budget_controller.clone();
3321 controller.target = config.budget.total;
3322 budget.with_controller(controller)
3323 } else {
3324 budget
3325 }
3326}
3327
3328enum EffectCommand<M> {
3329 Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3330 Shutdown,
3331}
3332
3333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3334enum EffectLoopControl {
3335 Continue,
3336 ShutdownRequested,
3337}
3338
3339struct EffectQueue<M: Send + 'static> {
3340 sender: mpsc::Sender<EffectCommand<M>>,
3341 handle: Option<JoinHandle<()>>,
3342 closed: bool,
3343}
3344
3345impl<M: Send + 'static> EffectQueue<M> {
3346 fn start(
3347 config: EffectQueueConfig,
3348 result_sender: mpsc::Sender<M>,
3349 evidence_sink: Option<EvidenceSink>,
3350 ) -> io::Result<Self> {
3351 let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3352 let handle = thread::Builder::new()
3353 .name("ftui-effects".into())
3354 .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3355
3356 Ok(Self {
3357 sender: tx,
3358 handle: Some(handle),
3359 closed: false,
3360 })
3361 }
3362
3363 fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3364 if self.closed {
3365 crate::effect_system::record_queue_drop("post_shutdown");
3366 tracing::debug!("rejecting task enqueue after effect queue shutdown");
3367 return;
3368 }
3369 if self
3370 .sender
3371 .send(EffectCommand::Enqueue(spec, task))
3372 .is_err()
3373 {
3374 crate::effect_system::record_queue_drop("channel_closed");
3375 }
3376 }
3377
3378 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3380 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3386
3387 fn shutdown(&mut self) {
3388 self.closed = true;
3389 let _ = self.sender.send(EffectCommand::Shutdown);
3390 if let Some(handle) = self.handle.take() {
3391 let start = Instant::now();
3392 if handle.is_finished() {
3395 let _ = handle.join();
3396 let elapsed_us = start.elapsed().as_micros() as u64;
3397 tracing::debug!(
3398 target: "ftui.runtime",
3399 elapsed_us,
3400 "effect-queue shutdown (fast path)"
3401 );
3402 return;
3403 }
3404 while !handle.is_finished() {
3406 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3407 tracing::warn!(
3408 target: "ftui.runtime",
3409 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3410 "effect-queue thread did not stop within timeout; detaching"
3411 );
3412 return;
3413 }
3414 thread::sleep(Self::SHUTDOWN_POLL);
3415 }
3416 let _ = handle.join();
3417 let elapsed_us = start.elapsed().as_micros() as u64;
3418 tracing::debug!(
3419 target: "ftui.runtime",
3420 elapsed_us,
3421 "effect-queue shutdown (slow path)"
3422 );
3423 }
3424 }
3425}
3426
3427impl<M: Send + 'static> Drop for EffectQueue<M> {
3428 fn drop(&mut self) {
3429 self.shutdown();
3430 }
3431}
3432
3433struct SpawnTaskExecutor<M: Send + 'static> {
3434 result_sender: mpsc::Sender<M>,
3435 evidence_sink: Option<EvidenceSink>,
3436 handles: Vec<JoinHandle<()>>,
3437 closed: bool,
3438}
3439
3440impl<M: Send + 'static> SpawnTaskExecutor<M> {
3441 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3442 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3448
3449 fn new(result_sender: mpsc::Sender<M>, evidence_sink: Option<EvidenceSink>) -> Self {
3450 Self {
3451 result_sender,
3452 evidence_sink,
3453 handles: Vec::new(),
3454 closed: false,
3455 }
3456 }
3457
3458 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3459 if self.closed {
3460 tracing::debug!("rejecting spawned task submit after shutdown");
3461 return;
3462 }
3463 let sender = self.result_sender.clone();
3464 let evidence_sink = self.evidence_sink.clone();
3465 let handle = thread::spawn(move || {
3466 let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3467 });
3468 self.handles.push(handle);
3469 }
3470
3471 fn reap_finished(&mut self) {
3472 if self.handles.is_empty() {
3473 return;
3474 }
3475
3476 let mut i = 0;
3477 while i < self.handles.len() {
3478 if self.handles[i].is_finished() {
3479 let handle = self.handles.swap_remove(i);
3480 let _ = handle.join();
3481 } else {
3482 i += 1;
3483 }
3484 }
3485 }
3486
3487 fn shutdown(&mut self) {
3488 self.closed = true;
3489 let start = Instant::now();
3490 self.reap_finished();
3492 if self.handles.is_empty() {
3493 let elapsed_us = start.elapsed().as_micros() as u64;
3494 tracing::debug!(
3495 target: "ftui.runtime",
3496 elapsed_us,
3497 "spawn-executor shutdown (fast path, all tasks already finished)"
3498 );
3499 return;
3500 }
3501 let pending_at_start = self.handles.len();
3503 while self.handles.iter().any(|handle| !handle.is_finished()) {
3504 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3505 let still_pending = self
3506 .handles
3507 .iter()
3508 .filter(|handle| !handle.is_finished())
3509 .count();
3510 tracing::warn!(
3511 target: "ftui.runtime",
3512 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3513 pending_handles = still_pending,
3514 "background task threads did not stop within timeout; detaching"
3515 );
3516 self.handles.clear();
3517 return;
3518 }
3519 thread::sleep(Self::SHUTDOWN_POLL);
3520 }
3521 self.reap_finished();
3522 let elapsed_us = start.elapsed().as_micros() as u64;
3523 tracing::debug!(
3524 target: "ftui.runtime",
3525 elapsed_us,
3526 pending_at_start,
3527 "spawn-executor shutdown (slow path)"
3528 );
3529 }
3530}
3531
3532#[cfg(feature = "asupersync-executor")]
3533struct AsupersyncTaskExecutor<M: Send + 'static> {
3534 result_sender: mpsc::Sender<M>,
3535 evidence_sink: Option<EvidenceSink>,
3536 runtime: AsupersyncRuntime,
3537 handles: Vec<BlockingTaskHandle>,
3538 max_queue_depth: usize,
3542 dropped: u64,
3544 closed: bool,
3545}
3546
3547#[cfg(feature = "asupersync-executor")]
3548impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3549 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3550
3551 fn new(
3552 result_sender: mpsc::Sender<M>,
3553 evidence_sink: Option<EvidenceSink>,
3554 max_queue_depth: usize,
3555 ) -> io::Result<Self> {
3556 let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3557 let runtime = RuntimeBuilder::new()
3558 .blocking_threads(1, max_threads)
3559 .thread_name_prefix("ftui-asupersync-task")
3560 .build()
3561 .map_err(|error| {
3562 io::Error::other(format!("asupersync runtime init failed: {error}"))
3563 })?;
3564
3565 Ok(Self {
3566 result_sender,
3567 evidence_sink,
3568 runtime,
3569 handles: Vec::new(),
3570 max_queue_depth,
3571 dropped: 0,
3572 closed: false,
3573 })
3574 }
3575
3576 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3577 if self.closed {
3578 self.dropped += 1;
3579 crate::effect_system::record_queue_drop("post_shutdown");
3580 tracing::debug!("rejecting asupersync task submit after shutdown");
3581 return;
3582 }
3583 self.handles.retain(|handle| !handle.is_done());
3586 if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3589 self.dropped += 1;
3590 crate::effect_system::record_queue_drop("backpressure");
3591 emit_task_executor_backpressure_evidence(
3592 self.evidence_sink.as_ref(),
3593 "asupersync",
3594 "drop",
3595 self.handles.len(),
3596 self.max_queue_depth,
3597 self.dropped,
3598 );
3599 return;
3600 }
3601 crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3602 let sender = self.result_sender.clone();
3603 let evidence_sink = self.evidence_sink.clone();
3604 let handle = self
3605 .runtime
3606 .spawn_blocking(move || {
3607 let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3608 crate::effect_system::record_queue_processed();
3609 })
3610 .expect("asupersync blocking pool must be configured");
3611 self.handles.push(handle);
3612 }
3613
3614 fn reap_finished(&mut self) {
3615 self.handles.retain(|handle| !handle.is_done());
3616 }
3617
3618 fn shutdown(&mut self) {
3619 self.closed = true;
3620 let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3621 for handle in &self.handles {
3622 let remaining = deadline.saturating_duration_since(Instant::now());
3623 if remaining.is_zero() || !handle.wait_timeout(remaining) {
3624 tracing::warn!(
3625 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3626 pending_handles = self
3627 .handles
3628 .iter()
3629 .filter(|pending| !pending.is_done())
3630 .count(),
3631 "Asupersync blocking tasks did not stop within timeout; detaching"
3632 );
3633 self.handles.clear();
3634 return;
3635 }
3636 }
3637 self.handles.clear();
3638 }
3639}
3640
3641enum TaskExecutor<M: Send + 'static> {
3642 Spawned(SpawnTaskExecutor<M>),
3643 Queued(EffectQueue<M>),
3644 #[cfg(feature = "asupersync-executor")]
3645 Asupersync(AsupersyncTaskExecutor<M>),
3646}
3647
3648impl<M: Send + 'static> TaskExecutor<M> {
3649 fn new(
3650 config: &EffectQueueConfig,
3651 result_sender: mpsc::Sender<M>,
3652 evidence_sink: Option<EvidenceSink>,
3653 ) -> io::Result<Self> {
3654 let executor = match config.backend {
3655 TaskExecutorBackend::Spawned => {
3656 Self::Spawned(SpawnTaskExecutor::new(result_sender, evidence_sink.clone()))
3657 }
3658 TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3659 config.clone(),
3660 result_sender,
3661 evidence_sink.clone(),
3662 )?),
3663 #[cfg(feature = "asupersync-executor")]
3664 TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3665 result_sender,
3666 evidence_sink.clone(),
3667 config.max_queue_depth,
3668 )?),
3669 };
3670
3671 emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3672 Ok(executor)
3673 }
3674
3675 fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3676 match self {
3677 Self::Spawned(executor) => executor.submit(task),
3678 Self::Queued(queue) => queue.enqueue(spec, task),
3679 #[cfg(feature = "asupersync-executor")]
3680 Self::Asupersync(executor) => executor.submit(task),
3681 }
3682 }
3683
3684 fn reap_finished(&mut self) {
3685 match self {
3686 Self::Spawned(executor) => executor.reap_finished(),
3687 #[cfg(feature = "asupersync-executor")]
3688 Self::Asupersync(executor) => executor.reap_finished(),
3689 Self::Queued(_) => {}
3690 }
3691 }
3692
3693 fn shutdown(&mut self) {
3694 match self {
3695 Self::Spawned(executor) => executor.shutdown(),
3696 Self::Queued(queue) => queue.shutdown(),
3697 #[cfg(feature = "asupersync-executor")]
3698 Self::Asupersync(executor) => executor.shutdown(),
3699 }
3700 }
3701
3702 #[cfg(test)]
3703 fn kind_name(&self) -> &'static str {
3704 self.kind_name_for_logs()
3705 }
3706
3707 fn kind_name_for_logs(&self) -> &'static str {
3708 match self {
3709 Self::Spawned(_) => "spawned",
3710 Self::Queued(_) => "queued",
3711 #[cfg(feature = "asupersync-executor")]
3712 Self::Asupersync(_) => "asupersync",
3713 }
3714 }
3715}
3716
3717fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3718 let Some(sink) = sink else {
3719 return;
3720 };
3721 let _ = sink.write_jsonl(&format!(
3722 r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3723 ));
3724}
3725
3726fn emit_task_executor_completion_evidence(
3727 sink: Option<&EvidenceSink>,
3728 backend: &str,
3729 duration_us: u64,
3730) {
3731 let Some(sink) = sink else {
3732 return;
3733 };
3734 let _ = sink.write_jsonl(&format!(
3735 r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3736 ));
3737}
3738
3739fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3740 let Some(sink) = sink else {
3741 return;
3742 };
3743 let escaped = panic_msg
3744 .replace('\\', "\\\\")
3745 .replace('"', "\\\"")
3746 .replace('\n', "\\n")
3747 .replace('\r', "\\r")
3748 .replace('\t', "\\t");
3749 let _ = sink.write_jsonl(&format!(
3750 r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3751 ));
3752}
3753
3754fn emit_task_executor_backpressure_evidence(
3755 sink: Option<&EvidenceSink>,
3756 backend: &str,
3757 action: &str,
3758 queue_length: usize,
3759 max_queue_size: usize,
3760 total_rejected: u64,
3761) {
3762 let Some(sink) = sink else {
3763 return;
3764 };
3765 let _ = sink.write_jsonl(&format!(
3766 r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3767 ));
3768}
3769
3770fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3771 if let Some(s) = payload.downcast_ref::<&str>() {
3772 (*s).to_owned()
3773 } else if let Some(s) = payload.downcast_ref::<String>() {
3774 s.clone()
3775 } else {
3776 "unknown panic payload".to_owned()
3777 }
3778}
3779
3780fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3781 #[cfg(feature = "tracing")]
3782 tracing::error!(
3783 executor_backend = backend,
3784 panic_msg,
3785 "task executor task panicked"
3786 );
3787 #[cfg(not(feature = "tracing"))]
3788 eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3789}
3790
3791fn run_task_closure<M: Send + 'static>(
3792 task: Box<dyn FnOnce() -> M + Send>,
3793 backend: &str,
3794 evidence_sink: Option<&EvidenceSink>,
3795 result_sender: &mpsc::Sender<M>,
3796) -> bool {
3797 let start = Instant::now();
3798 match panic::catch_unwind(AssertUnwindSafe(task)) {
3799 Ok(msg) => {
3800 let duration_us = start.elapsed().as_micros() as u64;
3801 tracing::debug!(
3802 target: "ftui.effect",
3803 command_type = "task",
3804 executor_backend = backend,
3805 duration_us = duration_us,
3806 effect_duration_us = duration_us,
3807 "task effect completed"
3808 );
3809 emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3810 let _ = result_sender.send(msg);
3811 true
3812 }
3813 Err(payload) => {
3814 let panic_msg = panic_payload_message(payload);
3815 log_task_executor_panic(backend, &panic_msg);
3816 emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3817 false
3818 }
3819 }
3820}
3821
3822fn effect_queue_loop<M: Send + 'static>(
3823 config: EffectQueueConfig,
3824 rx: mpsc::Receiver<EffectCommand<M>>,
3825 result_sender: mpsc::Sender<M>,
3826 evidence_sink: Option<EvidenceSink>,
3827) {
3828 let mut scheduler = QueueingScheduler::new(config.scheduler);
3829 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3830 let mut shutdown_requested = false;
3831 let max_depth = config.max_queue_depth;
3832
3833 loop {
3834 if tasks.is_empty() {
3835 if shutdown_requested {
3836 return;
3837 }
3838 match rx.recv() {
3839 Ok(cmd) => {
3840 if matches!(
3841 handle_effect_command(
3842 cmd,
3843 &mut scheduler,
3844 &mut tasks,
3845 &result_sender,
3846 evidence_sink.as_ref(),
3847 max_depth,
3848 ),
3849 EffectLoopControl::ShutdownRequested
3850 ) {
3851 shutdown_requested = true;
3852 }
3853 }
3854 Err(_) => return,
3855 }
3856 }
3857
3858 while let Ok(cmd) = rx.try_recv() {
3859 if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3860 crate::effect_system::record_queue_drop("post_shutdown");
3861 continue;
3862 }
3863 if matches!(
3864 handle_effect_command(
3865 cmd,
3866 &mut scheduler,
3867 &mut tasks,
3868 &result_sender,
3869 evidence_sink.as_ref(),
3870 max_depth,
3871 ),
3872 EffectLoopControl::ShutdownRequested
3873 ) {
3874 shutdown_requested = true;
3875 }
3876 }
3877
3878 if tasks.is_empty() {
3879 if shutdown_requested {
3880 return;
3881 }
3882 continue;
3883 }
3884
3885 let Some(job) = scheduler.peek_next().cloned() else {
3886 continue;
3887 };
3888
3889 if let Some(ref sink) = evidence_sink {
3890 let evidence = scheduler.evidence();
3891 let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3892 }
3893
3894 let completed = scheduler.tick(job.remaining_time);
3895 for job_id in completed {
3896 if let Some(task) = tasks.remove(&job_id) {
3897 let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3898 crate::effect_system::record_queue_processed();
3899 }
3900 }
3901 }
3902}
3903
3904fn handle_effect_command<M: Send + 'static>(
3905 cmd: EffectCommand<M>,
3906 scheduler: &mut QueueingScheduler,
3907 tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3908 result_sender: &mpsc::Sender<M>,
3909 evidence_sink: Option<&EvidenceSink>,
3910 max_depth: usize,
3911) -> EffectLoopControl {
3912 match cmd {
3913 EffectCommand::Enqueue(spec, task) => {
3914 if max_depth > 0 && tasks.len() >= max_depth {
3916 crate::effect_system::record_queue_drop("backpressure");
3917 return EffectLoopControl::Continue;
3918 }
3919 let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3920 WeightSource::Default
3921 } else {
3922 WeightSource::Explicit
3923 };
3924 let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3925 EstimateSource::Default
3926 } else {
3927 EstimateSource::Explicit
3928 };
3929 let id = scheduler.submit_with_sources(
3930 spec.weight,
3931 spec.estimate_ms,
3932 weight_source,
3933 estimate_source,
3934 spec.name,
3935 );
3936 if let Some(id) = id {
3937 tasks.insert(id, task);
3938 crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3939 } else {
3940 let stats = scheduler.stats();
3941 emit_task_executor_backpressure_evidence(
3942 evidence_sink,
3943 "queued",
3944 "inline_fallback",
3945 stats.queue_length,
3946 scheduler.max_queue_size(),
3947 stats.total_rejected,
3948 );
3949 let _ =
3950 run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
3951 }
3952 EffectLoopControl::Continue
3953 }
3954 EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
3955 }
3956}
3957
3958#[derive(Debug, Clone)]
3966pub struct InlineAutoRemeasureConfig {
3967 pub voi: VoiConfig,
3969 pub change_threshold_rows: u16,
3971}
3972
3973impl Default for InlineAutoRemeasureConfig {
3974 fn default() -> Self {
3975 Self {
3976 voi: VoiConfig {
3977 prior_alpha: 1.0,
3979 prior_beta: 9.0,
3980 max_interval_ms: 1000,
3982 min_interval_ms: 100,
3984 max_interval_events: 0,
3986 min_interval_events: 0,
3987 sample_cost: 0.08,
3989 ..VoiConfig::default()
3990 },
3991 change_threshold_rows: 1,
3992 }
3993 }
3994}
3995
3996#[derive(Debug)]
3997struct InlineAutoRemeasureState {
3998 config: InlineAutoRemeasureConfig,
3999 sampler: VoiSampler,
4000}
4001
4002impl InlineAutoRemeasureState {
4003 fn new(config: InlineAutoRemeasureConfig) -> Self {
4004 let sampler = VoiSampler::new(config.voi.clone());
4005 Self { config, sampler }
4006 }
4007
4008 fn reset(&mut self) {
4009 self.sampler = VoiSampler::new(self.config.voi.clone());
4010 }
4011}
4012
4013#[derive(Debug, Clone)]
4014struct ConformalEvidence {
4015 bucket_key: String,
4016 n_b: usize,
4017 alpha: f64,
4018 q_b: f64,
4019 y_hat: f64,
4020 upper_us: f64,
4021 risk: bool,
4022 fallback_level: u8,
4023 window_size: usize,
4024 reset_count: u64,
4025}
4026
4027impl ConformalEvidence {
4028 fn from_prediction(prediction: &ConformalPrediction) -> Self {
4029 let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4030 Self {
4031 bucket_key: prediction.bucket.to_string(),
4032 n_b: prediction.sample_count,
4033 alpha,
4034 q_b: prediction.quantile,
4035 y_hat: prediction.y_hat,
4036 upper_us: prediction.upper_us,
4037 risk: prediction.risk,
4038 fallback_level: prediction.fallback_level,
4039 window_size: prediction.window_size,
4040 reset_count: prediction.reset_count,
4041 }
4042 }
4043}
4044
4045#[derive(Debug, Clone)]
4046struct BudgetDecisionEvidence {
4047 frame_idx: u64,
4048 decision: BudgetDecision,
4049 controller_decision: BudgetDecision,
4050 degradation_before: DegradationLevel,
4051 degradation_after: DegradationLevel,
4052 frame_time_us: f64,
4053 budget_us: f64,
4054 pid_output: f64,
4055 pid_p: f64,
4056 pid_i: f64,
4057 pid_d: f64,
4058 e_value: f64,
4059 frames_observed: u32,
4060 frames_since_change: u32,
4061 in_warmup: bool,
4062 controller_reason: BudgetDecisionReason,
4063 load_governor: LoadGovernorSnapshot,
4064 conformal: Option<ConformalEvidence>,
4065}
4066
4067impl BudgetDecisionEvidence {
4068 fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4069 if after > before {
4070 BudgetDecision::Degrade
4071 } else if after < before {
4072 BudgetDecision::Upgrade
4073 } else {
4074 BudgetDecision::Hold
4075 }
4076 }
4077
4078 #[must_use]
4079 fn to_jsonl(&self) -> String {
4080 let conformal = self.conformal.as_ref();
4081 let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4082 let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4083 let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4084 let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4085 let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4086 let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4087 let risk = Self::opt_bool(conformal.map(|c| c.risk));
4088 let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4089 let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4090 let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4091 let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4092
4093 format!(
4094 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":{}}}"#,
4095 self.frame_idx,
4096 self.decision.as_str(),
4097 self.controller_decision.as_str(),
4098 self.controller_reason.as_str(),
4099 self.degradation_before.as_str(),
4100 self.degradation_after.as_str(),
4101 self.frame_time_us,
4102 self.budget_us,
4103 self.pid_output,
4104 self.pid_p,
4105 self.pid_i,
4106 self.pid_d,
4107 self.e_value,
4108 self.frames_observed,
4109 self.frames_since_change,
4110 self.in_warmup,
4111 self.load_governor.mode.as_str(),
4112 self.load_governor.mode_before.as_str(),
4113 self.load_governor.pressure_class.as_str(),
4114 self.load_governor.disposition.as_str(),
4115 self.load_governor.reason_code,
4116 self.load_governor.transition,
4117 self.load_governor.strict_semantics_preserved,
4118 self.load_governor.queue_in_flight,
4119 queue_max_depth,
4120 self.load_governor.queue_dropped_delta,
4121 self.load_governor.resize_coalescing_active,
4122 self.load_governor.recovery_intervals_observed,
4123 self.load_governor.recovery_intervals_required,
4124 self.load_governor.deferred_work_total,
4125 self.load_governor.coalesced_work_total,
4126 self.load_governor.dropped_work_total,
4127 bucket_key,
4128 n_b,
4129 alpha,
4130 q_b,
4131 y_hat,
4132 upper_us,
4133 risk,
4134 fallback_level,
4135 window_size,
4136 reset_count
4137 )
4138 }
4139
4140 fn opt_f64(value: Option<f64>) -> String {
4141 value
4142 .map(|v| format!("{v:.6}"))
4143 .unwrap_or_else(|| "null".to_string())
4144 }
4145
4146 fn opt_u64(value: Option<u64>) -> String {
4147 value
4148 .map(|v| v.to_string())
4149 .unwrap_or_else(|| "null".to_string())
4150 }
4151
4152 fn opt_u8(value: Option<u8>) -> String {
4153 value
4154 .map(|v| v.to_string())
4155 .unwrap_or_else(|| "null".to_string())
4156 }
4157
4158 fn opt_usize(value: Option<usize>) -> String {
4159 value
4160 .map(|v| v.to_string())
4161 .unwrap_or_else(|| "null".to_string())
4162 }
4163
4164 fn opt_bool(value: Option<bool>) -> String {
4165 value
4166 .map(|v| v.to_string())
4167 .unwrap_or_else(|| "null".to_string())
4168 }
4169
4170 fn opt_str(value: Option<&str>) -> String {
4171 value
4172 .map(|v| {
4173 format!(
4174 "\"{}\"",
4175 v.replace('\\', "\\\\")
4176 .replace('"', "\\\"")
4177 .replace('\n', "\\n")
4178 .replace('\r', "\\r")
4179 .replace('\t', "\\t")
4180 )
4181 })
4182 .unwrap_or_else(|| "null".to_string())
4183 }
4184}
4185
4186#[derive(Debug, Clone)]
4187struct FairnessConfigEvidence {
4188 enabled: bool,
4189 input_priority_threshold_ms: u64,
4190 dominance_threshold: u32,
4191 fairness_threshold: f64,
4192}
4193
4194impl FairnessConfigEvidence {
4195 #[must_use]
4196 fn to_jsonl(&self) -> String {
4197 format!(
4198 r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4199 self.enabled,
4200 self.input_priority_threshold_ms,
4201 self.dominance_threshold,
4202 self.fairness_threshold
4203 )
4204 }
4205}
4206
4207#[derive(Debug, Clone)]
4208struct FairnessDecisionEvidence {
4209 frame_idx: u64,
4210 decision: &'static str,
4211 reason: &'static str,
4212 pending_input_latency_ms: Option<u64>,
4213 jain_index: f64,
4214 resize_dominance_count: u32,
4215 dominance_threshold: u32,
4216 fairness_threshold: f64,
4217 input_priority_threshold_ms: u64,
4218}
4219
4220impl FairnessDecisionEvidence {
4221 #[must_use]
4222 fn to_jsonl(&self) -> String {
4223 let pending_latency = self
4224 .pending_input_latency_ms
4225 .map(|v| v.to_string())
4226 .unwrap_or_else(|| "null".to_string());
4227 format!(
4228 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":{}}}"#,
4229 self.frame_idx,
4230 self.decision,
4231 self.reason,
4232 pending_latency,
4233 self.jain_index,
4234 self.resize_dominance_count,
4235 self.dominance_threshold,
4236 self.fairness_threshold,
4237 self.input_priority_threshold_ms
4238 )
4239 }
4240}
4241
4242#[derive(Debug, Clone)]
4243struct WidgetRefreshEntry {
4244 widget_id: u64,
4245 essential: bool,
4246 starved: bool,
4247 value: f32,
4248 cost_us: f32,
4249 score: f32,
4250 staleness_ms: u64,
4251}
4252
4253impl WidgetRefreshEntry {
4254 fn to_json(&self) -> String {
4255 format!(
4256 r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4257 self.widget_id,
4258 self.cost_us,
4259 self.value,
4260 self.score,
4261 self.essential,
4262 self.starved,
4263 self.staleness_ms
4264 )
4265 }
4266}
4267
4268#[derive(Debug, Clone)]
4269struct WidgetRefreshPlan {
4270 frame_idx: u64,
4271 budget_us: f64,
4272 degradation: DegradationLevel,
4273 essentials_cost_us: f64,
4274 selected_cost_us: f64,
4275 selected_value: f64,
4276 signal_count: usize,
4277 selected: Vec<WidgetRefreshEntry>,
4278 skipped_count: usize,
4279 skipped_starved: usize,
4280 starved_selected: usize,
4281 over_budget: bool,
4282}
4283
4284impl WidgetRefreshPlan {
4285 fn new() -> Self {
4286 Self {
4287 frame_idx: 0,
4288 budget_us: 0.0,
4289 degradation: DegradationLevel::Full,
4290 essentials_cost_us: 0.0,
4291 selected_cost_us: 0.0,
4292 selected_value: 0.0,
4293 signal_count: 0,
4294 selected: Vec::new(),
4295 skipped_count: 0,
4296 skipped_starved: 0,
4297 starved_selected: 0,
4298 over_budget: false,
4299 }
4300 }
4301
4302 fn clear(&mut self) {
4303 self.frame_idx = 0;
4304 self.budget_us = 0.0;
4305 self.degradation = DegradationLevel::Full;
4306 self.essentials_cost_us = 0.0;
4307 self.selected_cost_us = 0.0;
4308 self.selected_value = 0.0;
4309 self.signal_count = 0;
4310 self.selected.clear();
4311 self.skipped_count = 0;
4312 self.skipped_starved = 0;
4313 self.starved_selected = 0;
4314 self.over_budget = false;
4315 }
4316
4317 fn as_budget(&self) -> WidgetBudget {
4318 if self.signal_count == 0 {
4319 return WidgetBudget::allow_all();
4320 }
4321 let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4322 WidgetBudget::allow_only(ids)
4323 }
4324
4325 fn recompute(
4326 &mut self,
4327 frame_idx: u64,
4328 budget_us: f64,
4329 degradation: DegradationLevel,
4330 signals: &[WidgetSignal],
4331 config: &WidgetRefreshConfig,
4332 ) {
4333 self.clear();
4334 self.frame_idx = frame_idx;
4335 self.budget_us = budget_us;
4336 self.degradation = degradation;
4337
4338 if !config.enabled || signals.is_empty() {
4339 return;
4340 }
4341
4342 self.signal_count = signals.len();
4343 let mut essentials_cost = 0.0f64;
4344 let mut selected_cost = 0.0f64;
4345 let mut selected_value = 0.0f64;
4346
4347 let staleness_window = config.staleness_window_ms.max(1) as f32;
4348 let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4349
4350 for signal in signals {
4351 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4352 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4353 let mut value = config.weight_priority * signal.priority
4354 + config.weight_staleness * staleness_score
4355 + config.weight_focus * signal.focus_boost
4356 + config.weight_interaction * signal.interaction_boost;
4357 if starved {
4358 value += config.starve_boost;
4359 }
4360 let raw_cost = if signal.recent_cost_us > 0.0 {
4361 signal.recent_cost_us
4362 } else {
4363 signal.cost_estimate_us
4364 };
4365 let cost_us = raw_cost.max(config.min_cost_us);
4366 let score = if cost_us > 0.0 {
4367 value / cost_us
4368 } else {
4369 value
4370 };
4371
4372 let entry = WidgetRefreshEntry {
4373 widget_id: signal.widget_id,
4374 essential: signal.essential,
4375 starved,
4376 value,
4377 cost_us,
4378 score,
4379 staleness_ms: signal.staleness_ms,
4380 };
4381
4382 if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4383 self.skipped_count += 1;
4384 if starved {
4385 self.skipped_starved = self.skipped_starved.saturating_add(1);
4386 }
4387 continue;
4388 }
4389
4390 if signal.essential {
4391 essentials_cost += cost_us as f64;
4392 selected_cost += cost_us as f64;
4393 selected_value += value as f64;
4394 if starved {
4395 self.starved_selected = self.starved_selected.saturating_add(1);
4396 }
4397 self.selected.push(entry);
4398 } else {
4399 candidates.push(entry);
4400 }
4401 }
4402
4403 let mut remaining = budget_us - selected_cost;
4404
4405 if degradation < DegradationLevel::EssentialOnly {
4406 let nonessential_total = candidates.len();
4407 let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4408 let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4409 let min_nonessential_selected = if enforce_drop_rate {
4410 let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4411 ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4412 } else {
4413 0
4414 };
4415
4416 candidates.sort_by(|a, b| {
4417 b.starved
4418 .cmp(&a.starved)
4419 .then_with(|| b.score.total_cmp(&a.score))
4420 .then_with(|| b.value.total_cmp(&a.value))
4421 .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4422 .then_with(|| a.widget_id.cmp(&b.widget_id))
4423 });
4424
4425 let mut forced_starved = 0usize;
4426 let mut nonessential_selected = 0usize;
4427 let mut skipped_candidates = if enforce_drop_rate {
4428 Vec::with_capacity(candidates.len())
4429 } else {
4430 Vec::new()
4431 };
4432
4433 for entry in candidates.into_iter() {
4434 if entry.starved && forced_starved >= config.max_starved_per_frame {
4435 self.skipped_count += 1;
4436 self.skipped_starved = self.skipped_starved.saturating_add(1);
4437 if enforce_drop_rate {
4438 skipped_candidates.push(entry);
4439 }
4440 continue;
4441 }
4442
4443 if remaining >= entry.cost_us as f64 {
4444 remaining -= entry.cost_us as f64;
4445 selected_cost += entry.cost_us as f64;
4446 selected_value += entry.value as f64;
4447 if entry.starved {
4448 self.starved_selected = self.starved_selected.saturating_add(1);
4449 forced_starved += 1;
4450 }
4451 nonessential_selected += 1;
4452 self.selected.push(entry);
4453 } else if entry.starved
4454 && forced_starved < config.max_starved_per_frame
4455 && nonessential_selected == 0
4456 {
4457 selected_cost += entry.cost_us as f64;
4459 selected_value += entry.value as f64;
4460 self.starved_selected = self.starved_selected.saturating_add(1);
4461 forced_starved += 1;
4462 nonessential_selected += 1;
4463 self.selected.push(entry);
4464 } else {
4465 self.skipped_count += 1;
4466 if entry.starved {
4467 self.skipped_starved = self.skipped_starved.saturating_add(1);
4468 }
4469 if enforce_drop_rate {
4470 skipped_candidates.push(entry);
4471 }
4472 }
4473 }
4474
4475 if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4476 for entry in skipped_candidates.into_iter() {
4477 if nonessential_selected >= min_nonessential_selected {
4478 break;
4479 }
4480 if entry.starved && forced_starved >= config.max_starved_per_frame {
4481 continue;
4482 }
4483 selected_cost += entry.cost_us as f64;
4484 selected_value += entry.value as f64;
4485 if entry.starved {
4486 self.starved_selected = self.starved_selected.saturating_add(1);
4487 forced_starved += 1;
4488 self.skipped_starved = self.skipped_starved.saturating_sub(1);
4489 }
4490 self.skipped_count = self.skipped_count.saturating_sub(1);
4491 nonessential_selected += 1;
4492 self.selected.push(entry);
4493 }
4494 }
4495 }
4496
4497 self.essentials_cost_us = essentials_cost;
4498 self.selected_cost_us = selected_cost;
4499 self.selected_value = selected_value;
4500 self.over_budget = selected_cost > budget_us;
4501 }
4502
4503 #[must_use]
4504 fn to_jsonl(&self) -> String {
4505 let mut out = String::with_capacity(256 + self.selected.len() * 96);
4506 out.push_str(r#"{"event":"widget_refresh""#);
4507 out.push_str(&format!(
4508 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":{}"#,
4509 self.frame_idx,
4510 self.budget_us,
4511 self.degradation.as_str(),
4512 self.essentials_cost_us,
4513 self.selected_cost_us,
4514 self.selected_value,
4515 self.selected.len(),
4516 self.skipped_count,
4517 self.starved_selected,
4518 self.skipped_starved,
4519 self.over_budget
4520 ));
4521 out.push_str(r#","selected":["#);
4522 for (i, entry) in self.selected.iter().enumerate() {
4523 if i > 0 {
4524 out.push(',');
4525 }
4526 out.push_str(&entry.to_json());
4527 }
4528 out.push_str("]}");
4529 out
4530 }
4531}
4532
4533#[cfg(feature = "crossterm-compat")]
4538pub struct CrosstermEventSource {
4544 session: TerminalSession,
4545 features: BackendFeatures,
4546}
4547
4548#[cfg(feature = "crossterm-compat")]
4549impl CrosstermEventSource {
4550 pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4552 Self {
4553 session,
4554 features: initial_features,
4555 }
4556 }
4557}
4558
4559#[cfg(feature = "crossterm-compat")]
4560impl BackendEventSource for CrosstermEventSource {
4561 type Error = io::Error;
4562
4563 fn size(&self) -> Result<(u16, u16), io::Error> {
4564 self.session.size()
4565 }
4566
4567 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4568 if features.mouse_capture != self.features.mouse_capture {
4569 self.session.set_mouse_capture(features.mouse_capture)?;
4570 }
4571 self.features = features;
4575 Ok(())
4576 }
4577
4578 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4579 self.session.poll_event(timeout)
4580 }
4581
4582 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4583 self.session.read_event()
4584 }
4585}
4586
4587pub struct HeadlessEventSource {
4597 width: u16,
4598 height: u16,
4599 features: BackendFeatures,
4600}
4601
4602impl HeadlessEventSource {
4603 pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4605 Self {
4606 width,
4607 height,
4608 features,
4609 }
4610 }
4611}
4612
4613impl BackendEventSource for HeadlessEventSource {
4614 type Error = io::Error;
4615
4616 fn size(&self) -> Result<(u16, u16), io::Error> {
4617 Ok((self.width, self.height))
4618 }
4619
4620 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4621 self.features = features;
4622 Ok(())
4623 }
4624
4625 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4626 Ok(false)
4627 }
4628
4629 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4630 Ok(None)
4631 }
4632}
4633
4634pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4640 model: M,
4642 writer: TerminalWriter<W>,
4644 events: E,
4646 backend_features: BackendFeatures,
4648 running: bool,
4650 tick_rate: Option<Duration>,
4652 executed_cmd_count: usize,
4654 last_tick: Instant,
4656 dirty: bool,
4658 frame_idx: u64,
4660 tick_count: u64,
4662 widget_signals: Vec<WidgetSignal>,
4664 widget_refresh_config: WidgetRefreshConfig,
4666 widget_refresh_plan: WidgetRefreshPlan,
4668 width: u16,
4670 height: u16,
4672 forced_size: Option<(u16, u16)>,
4674 poll_timeout: Duration,
4676 intercept_signals: bool,
4678 immediate_drain_config: ImmediateDrainConfig,
4680 immediate_drain_stats: ImmediateDrainStats,
4682 budget: RenderBudget,
4684 load_governor: LoadGovernorState,
4686 conformal_predictor: Option<ConformalPredictor>,
4688 last_frame_time_us: Option<f64>,
4690 last_update_us: Option<u64>,
4692 frame_timing: Option<FrameTimingConfig>,
4694 locale_context: LocaleContext,
4696 locale_version: u64,
4698 resize_coalescer: ResizeCoalescer,
4700 evidence_sink: Option<EvidenceSink>,
4702 fairness_config_logged: bool,
4704 resize_behavior: ResizeBehavior,
4706 fairness_guard: InputFairnessGuard,
4708 event_recorder: Option<EventRecorder>,
4710 subscriptions: SubscriptionManager<M::Message>,
4712 #[cfg(test)]
4714 task_sender: std::sync::mpsc::Sender<M::Message>,
4715 task_receiver: std::sync::mpsc::Receiver<M::Message>,
4717 task_executor: TaskExecutor<M::Message>,
4719 state_registry: Option<std::sync::Arc<StateRegistry>>,
4721 persistence_config: PersistenceConfig,
4723 last_checkpoint: Instant,
4725 inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4727 frame_arena: FrameArena,
4729 guardrails: FrameGuardrails,
4731 tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4733 last_active_screen_for_strategy: Option<String>,
4735}
4736
4737#[cfg(feature = "crossterm-compat")]
4738impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4739 pub fn new(model: M) -> io::Result<Self>
4741 where
4742 M::Message: Send + 'static,
4743 {
4744 Self::with_config(model, ProgramConfig::default())
4745 }
4746
4747 pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4749 where
4750 M::Message: Send + 'static,
4751 {
4752 let resolved_lane = config.runtime_lane.resolve();
4753 let effect_queue_config = config.resolved_effect_queue_config();
4754 let capabilities = TerminalCapabilities::with_overrides();
4755 let mouse_capture = config.resolved_mouse_capture();
4756 let requested_features = BackendFeatures {
4757 mouse_capture,
4758 bracketed_paste: config.bracketed_paste,
4759 focus_events: config.focus_reporting,
4760 kitty_keyboard: config.kitty_keyboard,
4761 };
4762 let initial_features =
4763 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4764 let session = TerminalSession::new(SessionOptions {
4765 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4766 mouse_capture: initial_features.mouse_capture,
4767 bracketed_paste: initial_features.bracketed_paste,
4768 focus_events: initial_features.focus_events,
4769 kitty_keyboard: initial_features.kitty_keyboard,
4770 intercept_signals: config.intercept_signals,
4771 })?;
4772 let events = CrosstermEventSource::new(session, initial_features);
4773
4774 let mut writer = TerminalWriter::with_diff_config(
4775 io::stdout(),
4776 config.screen_mode,
4777 config.ui_anchor,
4778 capabilities,
4779 config.diff_config.clone(),
4780 );
4781
4782 let frame_timing = config.frame_timing.clone();
4783 writer.set_timing_enabled(frame_timing.is_some());
4784
4785 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4786 if let Some(ref sink) = evidence_sink {
4787 writer = writer.with_evidence_sink(sink.clone());
4788 }
4789
4790 let render_trace = crate::RenderTraceRecorder::from_config(
4791 &config.render_trace,
4792 crate::RenderTraceContext {
4793 capabilities: writer.capabilities(),
4794 diff_config: config.diff_config.clone(),
4795 resize_config: config.resize_coalescer.clone(),
4796 conformal_config: config.conformal_config.clone(),
4797 },
4798 )?;
4799 if let Some(recorder) = render_trace {
4800 writer = writer.with_render_trace(recorder);
4801 }
4802
4803 let (w, h) = config
4805 .forced_size
4806 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4807 let width = w.max(1);
4808 let height = h.max(1);
4809 writer.set_size(width, height);
4810
4811 let budget = render_budget_from_program_config(&config);
4812 let load_governor = LoadGovernorState::new(
4813 config.load_governor.clone(),
4814 effect_queue_config.max_queue_depth,
4815 );
4816 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4817 let locale_context = config.locale_context.clone();
4818 let locale_version = locale_context.version();
4819 let mut resize_coalescer =
4820 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4821 .with_screen_mode(config.screen_mode);
4822 if let Some(ref sink) = evidence_sink {
4823 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4824 }
4825 let subscriptions = SubscriptionManager::new();
4826 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4827 let inline_auto_remeasure = config
4828 .inline_auto_remeasure
4829 .clone()
4830 .map(InlineAutoRemeasureState::new);
4831 let task_executor = TaskExecutor::new(
4832 &effect_queue_config,
4833 task_sender.clone(),
4834 evidence_sink.clone(),
4835 )?;
4836 let guardrails = FrameGuardrails::new(config.guardrails);
4837
4838 tracing::info!(
4840 target: "ftui.runtime",
4841 requested_lane = config.runtime_lane.label(),
4842 resolved_lane = resolved_lane.label(),
4843 rollout_policy = config.rollout_policy.label(),
4844 "runtime startup: lane={}, rollout={}",
4845 resolved_lane.label(),
4846 config.rollout_policy.label(),
4847 );
4848
4849 Ok(Self {
4850 model,
4851 writer,
4852 events,
4853 backend_features: initial_features,
4854 running: true,
4855 tick_rate: None,
4856 executed_cmd_count: 0,
4857 last_tick: Instant::now(),
4858 dirty: true,
4859 frame_idx: 0,
4860 tick_count: 0,
4861 widget_signals: Vec::new(),
4862 widget_refresh_config: config.widget_refresh,
4863 widget_refresh_plan: WidgetRefreshPlan::new(),
4864 width,
4865 height,
4866 forced_size: config.forced_size,
4867 poll_timeout: config.poll_timeout,
4868 intercept_signals: config.intercept_signals,
4869 immediate_drain_config: config.immediate_drain,
4870 immediate_drain_stats: ImmediateDrainStats::default(),
4871 budget,
4872 load_governor,
4873 conformal_predictor,
4874 last_frame_time_us: None,
4875 last_update_us: None,
4876 frame_timing,
4877 locale_context,
4878 locale_version,
4879 resize_coalescer,
4880 evidence_sink,
4881 fairness_config_logged: false,
4882 resize_behavior: config.resize_behavior,
4883 fairness_guard: InputFairnessGuard::new(),
4884 event_recorder: None,
4885 subscriptions,
4886 #[cfg(test)]
4887 task_sender,
4888 task_receiver,
4889 task_executor,
4890 state_registry: config.persistence.registry.clone(),
4891 persistence_config: config.persistence,
4892 last_checkpoint: Instant::now(),
4893 inline_auto_remeasure,
4894 frame_arena: FrameArena::default(),
4895 guardrails,
4896 tick_strategy: config
4897 .tick_strategy
4898 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4899 last_active_screen_for_strategy: None,
4900 })
4901 }
4902}
4903
4904impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4905 pub fn with_event_source(
4912 model: M,
4913 events: E,
4914 backend_features: BackendFeatures,
4915 writer: TerminalWriter<W>,
4916 config: ProgramConfig,
4917 ) -> io::Result<Self>
4918 where
4919 M::Message: Send + 'static,
4920 {
4921 let effect_queue_config = config.resolved_effect_queue_config();
4922 let (width, height) = config
4923 .forced_size
4924 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4925 let width = width.max(1);
4926 let height = height.max(1);
4927
4928 let mut writer = writer;
4929 writer.set_size(width, height);
4930
4931 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4932 if let Some(ref sink) = evidence_sink {
4933 writer = writer.with_evidence_sink(sink.clone());
4934 }
4935
4936 let render_trace = crate::RenderTraceRecorder::from_config(
4937 &config.render_trace,
4938 crate::RenderTraceContext {
4939 capabilities: writer.capabilities(),
4940 diff_config: config.diff_config.clone(),
4941 resize_config: config.resize_coalescer.clone(),
4942 conformal_config: config.conformal_config.clone(),
4943 },
4944 )?;
4945 if let Some(recorder) = render_trace {
4946 writer = writer.with_render_trace(recorder);
4947 }
4948
4949 let frame_timing = config.frame_timing.clone();
4950 writer.set_timing_enabled(frame_timing.is_some());
4951
4952 let budget = render_budget_from_program_config(&config);
4953 let load_governor = LoadGovernorState::new(
4954 config.load_governor.clone(),
4955 effect_queue_config.max_queue_depth,
4956 );
4957 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4958 let locale_context = config.locale_context.clone();
4959 let locale_version = locale_context.version();
4960 let mut resize_coalescer =
4961 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4962 .with_screen_mode(config.screen_mode);
4963 if let Some(ref sink) = evidence_sink {
4964 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4965 }
4966 let subscriptions = SubscriptionManager::new();
4967 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4968 let inline_auto_remeasure = config
4969 .inline_auto_remeasure
4970 .clone()
4971 .map(InlineAutoRemeasureState::new);
4972 let task_executor = TaskExecutor::new(
4973 &effect_queue_config,
4974 task_sender.clone(),
4975 evidence_sink.clone(),
4976 )?;
4977
4978 let guardrails = FrameGuardrails::new(config.guardrails);
4979
4980 Ok(Self {
4981 model,
4982 writer,
4983 events,
4984 backend_features,
4985 running: true,
4986 tick_rate: None,
4987 executed_cmd_count: 0,
4988 last_tick: Instant::now(),
4989 dirty: true,
4990 frame_idx: 0,
4991 tick_count: 0,
4992 widget_signals: Vec::new(),
4993 widget_refresh_config: config.widget_refresh,
4994 widget_refresh_plan: WidgetRefreshPlan::new(),
4995 width,
4996 height,
4997 forced_size: config.forced_size,
4998 poll_timeout: config.poll_timeout,
4999 intercept_signals: config.intercept_signals,
5000 immediate_drain_config: config.immediate_drain,
5001 immediate_drain_stats: ImmediateDrainStats::default(),
5002 budget,
5003 load_governor,
5004 conformal_predictor,
5005 last_frame_time_us: None,
5006 last_update_us: None,
5007 frame_timing,
5008 locale_context,
5009 locale_version,
5010 resize_coalescer,
5011 evidence_sink,
5012 fairness_config_logged: false,
5013 resize_behavior: config.resize_behavior,
5014 fairness_guard: InputFairnessGuard::new(),
5015 event_recorder: None,
5016 subscriptions,
5017 #[cfg(test)]
5018 task_sender,
5019 task_receiver,
5020 task_executor,
5021 state_registry: config.persistence.registry.clone(),
5022 persistence_config: config.persistence,
5023 last_checkpoint: Instant::now(),
5024 inline_auto_remeasure,
5025 frame_arena: FrameArena::default(),
5026 guardrails,
5027 tick_strategy: config
5028 .tick_strategy
5029 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5030 last_active_screen_for_strategy: None,
5031 })
5032 }
5033}
5034
5035#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5040#[inline]
5041const fn sanitize_backend_features_for_capabilities(
5042 requested: BackendFeatures,
5043 capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5044) -> BackendFeatures {
5045 let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5046 let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5047
5048 BackendFeatures {
5049 mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5050 bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5051 focus_events: requested.focus_events && focus_events_supported,
5052 kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5053 }
5054}
5055
5056#[cfg(feature = "native-backend")]
5057impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5058 #[cfg(unix)]
5070 pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5071 where
5072 M::Message: Send + 'static,
5073 {
5074 let mut capabilities =
5075 ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5076 let mouse_capture = config.resolved_mouse_capture();
5077 let requested_features = BackendFeatures {
5078 mouse_capture,
5079 bracketed_paste: config.bracketed_paste,
5080 focus_events: config.focus_reporting,
5081 kitty_keyboard: config.kitty_keyboard,
5082 };
5083 let features =
5084 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5085 let options = ftui_tty::TtySessionOptions {
5086 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5087 features,
5088 intercept_signals: config.intercept_signals,
5089 };
5090 let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5091
5092 if !capabilities.true_color && capabilities.colors_256 {
5109 let probe =
5110 ftui_core::caps_probe::probe_capabilities(&ftui_core::caps_probe::ProbeConfig {
5111 timeout: std::time::Duration::from_millis(300),
5112 probe_da1: false,
5113 probe_da2: false,
5114 probe_background: false,
5115 probe_truecolor: true,
5116 });
5117 capabilities.refine_from_probe(&probe);
5118 }
5119
5120 let writer = TerminalWriter::with_diff_config(
5121 io::stdout(),
5122 config.screen_mode,
5123 config.ui_anchor,
5124 capabilities,
5125 config.diff_config.clone(),
5126 );
5127
5128 Self::with_event_source(model, backend, features, writer, config)
5129 }
5130}
5131
5132impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5133 pub fn run(&mut self) -> io::Result<()> {
5141 self.run_event_loop()
5142 }
5143
5144 #[inline]
5145 fn observed_termination_signal(&self) -> Option<i32> {
5146 if self.intercept_signals {
5147 check_termination_signal()
5148 } else {
5149 None
5150 }
5151 }
5152
5153 #[inline]
5155 pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5156 &self.widget_signals
5157 }
5158
5159 #[inline]
5161 pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5162 self.immediate_drain_stats
5163 }
5164
5165 fn run_event_loop(&mut self) -> io::Result<()> {
5167 if self.persistence_config.auto_load {
5169 self.load_state();
5170 }
5171
5172 let cmd = {
5174 let _span = info_span!("ftui.program.init").entered();
5175 self.model.init()
5176 };
5177 self.execute_cmd(cmd)?;
5178
5179 let mut termination_signal = self.observed_termination_signal();
5180 if self.running && termination_signal.is_none() {
5181 self.reconcile_subscriptions();
5183
5184 self.render_frame()?;
5186 }
5187
5188 let mut loop_count: u64 = 0;
5190 while self.running {
5191 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5192 if termination_signal.is_some() {
5193 self.running = false;
5194 break;
5195 }
5196
5197 loop_count += 1;
5198 if loop_count.is_multiple_of(100) {
5200 crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5201 }
5202
5203 let timeout = self.effective_timeout();
5205
5206 let poll_result = self.events.poll_event(timeout)?;
5208 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5209 if termination_signal.is_some() {
5210 self.running = false;
5211 break;
5212 }
5213 if poll_result {
5214 self.drain_ready_events()?;
5215 }
5216 if !self.running {
5217 break;
5218 }
5219 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5220 if termination_signal.is_some() {
5221 self.running = false;
5222 break;
5223 }
5224
5225 self.process_subscription_messages()?;
5227 if !self.running {
5228 break;
5229 }
5230
5231 self.process_task_results()?;
5233 self.reap_finished_tasks();
5234 if !self.running {
5235 break;
5236 }
5237
5238 self.process_resize_coalescer()?;
5239 if !self.running {
5240 break;
5241 }
5242 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5243 if termination_signal.is_some() {
5244 self.running = false;
5245 break;
5246 }
5247
5248 self.check_screen_transition();
5252
5253 if self.should_tick() {
5255 self.tick_count = self.tick_count.wrapping_add(1);
5256 let tick_count = self.tick_count;
5257
5258 let mut used_screen_dispatch = false;
5259
5260 if let Some(strategy) = self.tick_strategy.as_mut() {
5265 let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5268 let active = dispatch.active_screen_id();
5269 let all_screens = dispatch.screen_ids();
5270 (active, all_screens)
5271 });
5272
5273 if let Some((active, all_screens)) = dispatch_snapshot {
5274 used_screen_dispatch = true;
5275
5276 if let Some(previous_active) =
5279 self.last_active_screen_for_strategy.as_deref()
5280 && previous_active != active
5281 {
5282 strategy.on_screen_transition(previous_active, &active);
5283 }
5284 self.last_active_screen_for_strategy = Some(active.clone());
5285
5286 let all_screens_count = all_screens.len();
5287 let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5288 tick_targets.push(active.clone());
5290
5291 for screen_id in all_screens {
5293 if screen_id != active
5294 && strategy.should_tick(&screen_id, tick_count, &active)
5295 == crate::tick_strategy::TickDecision::Tick
5296 {
5297 tick_targets.push(screen_id);
5298 }
5299 }
5300
5301 let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5303
5304 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5305 for screen_id in &tick_targets {
5306 dispatch.tick_screen(screen_id, tick_count);
5307 }
5308 }
5309
5310 trace!(
5311 tick = tick_count,
5312 active = %active,
5313 ticked = tick_targets.len(),
5314 skipped = skipped_count,
5315 "tick_strategy.frame"
5316 );
5317
5318 strategy.maintenance_tick(tick_count);
5320 self.mark_dirty();
5321 }
5322 }
5323
5324 if used_screen_dispatch && self.running {
5325 self.reconcile_subscriptions();
5326 }
5327
5328 if !used_screen_dispatch {
5329 self.last_active_screen_for_strategy = None;
5332 let msg = M::Message::from(Event::Tick);
5333 let cmd = {
5334 let _span = debug_span!(
5335 "ftui.program.update",
5336 msg_type = "Tick",
5337 duration_us = tracing::field::Empty,
5338 cmd_type = tracing::field::Empty
5339 )
5340 .entered();
5341 let start = Instant::now();
5342 let cmd = self.model.update(msg);
5343 tracing::Span::current()
5344 .record("duration_us", start.elapsed().as_micros() as u64);
5345 tracing::Span::current()
5346 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5347 cmd
5348 };
5349 self.mark_dirty();
5350 self.execute_cmd(cmd)?;
5351 if self.running {
5352 self.reconcile_subscriptions();
5353 }
5354 }
5355 }
5356
5357 self.check_checkpoint_save();
5359
5360 self.check_locale_change();
5362 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5363 if termination_signal.is_some() {
5364 self.running = false;
5365 break;
5366 }
5367
5368 if self.dirty {
5370 self.render_frame()?;
5371 }
5372
5373 if loop_count.is_multiple_of(1000) {
5375 self.writer.gc(None);
5376 }
5377 }
5378
5379 let shutdown_cmd = {
5380 let _span = info_span!("ftui.program.shutdown").entered();
5381 self.model.on_shutdown()
5382 };
5383 self.execute_cmd(shutdown_cmd)?;
5384
5385 if self.persistence_config.auto_save {
5387 self.save_state();
5388 }
5389
5390 if let Some(ref mut strategy) = self.tick_strategy {
5392 strategy.shutdown();
5393 }
5394
5395 self.subscriptions.stop_all();
5397 self.task_executor.shutdown();
5398 self.reap_finished_tasks();
5399 self.drain_shutdown_task_results()?;
5400
5401 if let Some(signal) = termination_signal {
5402 clear_termination_signal();
5403 let err = io::Error::new(
5404 io::ErrorKind::Interrupted,
5405 SignalTerminationError { signal },
5406 );
5407 debug_assert_eq!(signal_termination_from_error(&err), Some(signal));
5408 return Err(err);
5409 }
5410
5411 Ok(())
5412 }
5413
5414 fn drain_ready_events(&mut self) -> io::Result<()> {
5420 self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5421
5422 let zero_poll_limit = self
5423 .immediate_drain_config
5424 .max_zero_timeout_polls_per_burst
5425 .max(1);
5426 let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5427 let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5428
5429 let mut burst_start = Instant::now();
5430 let mut zero_polls_in_burst_window: u64 = 0;
5431 let mut capped_this_burst = false;
5432
5433 loop {
5434 if let Some(event) = self.events.read_event()? {
5435 self.handle_event(event)?;
5436 if !self.running {
5437 break;
5438 }
5439 }
5440
5441 let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5442 || burst_start.elapsed() >= max_burst_duration;
5443
5444 if budget_exhausted {
5445 if !capped_this_burst {
5446 capped_this_burst = true;
5447 self.immediate_drain_stats.capped_bursts =
5448 self.immediate_drain_stats.capped_bursts.saturating_add(1);
5449 }
5450
5451 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5452 .immediate_drain_stats
5453 .max_zero_timeout_polls_in_burst
5454 .max(zero_polls_in_burst_window);
5455
5456 std::thread::yield_now();
5457 self.immediate_drain_stats.backoff_polls =
5458 self.immediate_drain_stats.backoff_polls.saturating_add(1);
5459 if !self.events.poll_event(backoff_timeout)? {
5460 break;
5461 }
5462 zero_polls_in_burst_window = 0;
5463 burst_start = Instant::now();
5464 continue;
5465 }
5466
5467 self.immediate_drain_stats.zero_timeout_polls = self
5468 .immediate_drain_stats
5469 .zero_timeout_polls
5470 .saturating_add(1);
5471 zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5472 if !self.events.poll_event(Duration::ZERO)? {
5473 break;
5474 }
5475 }
5476
5477 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5478 .immediate_drain_stats
5479 .max_zero_timeout_polls_in_burst
5480 .max(zero_polls_in_burst_window);
5481
5482 Ok(())
5483 }
5484
5485 fn load_state(&mut self) {
5487 if let Some(registry) = &self.state_registry {
5488 match registry.load() {
5489 Ok(count) => {
5490 info!(count, "loaded widget state from persistence");
5491 }
5492 Err(e) => {
5493 tracing::warn!(error = %e, "failed to load widget state");
5494 }
5495 }
5496 }
5497 }
5498
5499 fn save_state(&mut self) {
5501 if let Some(registry) = &self.state_registry {
5502 match registry.flush() {
5503 Ok(true) => {
5504 debug!("saved widget state to persistence");
5505 }
5506 Ok(false) => {
5507 }
5509 Err(e) => {
5510 tracing::warn!(error = %e, "failed to save widget state");
5511 }
5512 }
5513 }
5514 }
5515
5516 fn check_checkpoint_save(&mut self) {
5518 if let Some(interval) = self.persistence_config.checkpoint_interval
5519 && self.last_checkpoint.elapsed() >= interval
5520 {
5521 self.save_state();
5522 self.last_checkpoint = Instant::now();
5523 }
5524 }
5525
5526 fn handle_event(&mut self, event: Event) -> io::Result<()> {
5527 let event_start = Instant::now();
5529 let fairness_event_type = Self::classify_event_for_fairness(&event);
5530 if fairness_event_type == FairnessEventType::Input {
5531 self.fairness_guard.input_arrived(event_start);
5532 }
5533
5534 if let Some(recorder) = &mut self.event_recorder {
5536 recorder.record(&event);
5537 }
5538
5539 let event = match event {
5540 Event::Resize { width, height } => {
5541 debug!(
5542 width,
5543 height,
5544 behavior = ?self.resize_behavior,
5545 "Resize event received"
5546 );
5547 if let Some((forced_width, forced_height)) = self.forced_size {
5548 debug!(
5549 forced_width,
5550 forced_height, "Resize ignored due to forced size override"
5551 );
5552 self.fairness_guard.event_processed(
5553 fairness_event_type,
5554 event_start.elapsed(),
5555 Instant::now(),
5556 );
5557 return Ok(());
5558 }
5559 let width = width.max(1);
5561 let height = height.max(1);
5562 match self.resize_behavior {
5563 ResizeBehavior::Immediate => {
5564 self.resize_coalescer
5565 .record_external_apply(width, height, Instant::now());
5566 let result = self.apply_resize(width, height, Duration::ZERO, false);
5567 self.fairness_guard.event_processed(
5568 fairness_event_type,
5569 event_start.elapsed(),
5570 Instant::now(),
5571 );
5572 return result;
5573 }
5574 ResizeBehavior::Throttled => {
5575 let action = self.resize_coalescer.handle_resize(width, height);
5576 if let CoalesceAction::ApplyResize {
5577 width,
5578 height,
5579 coalesce_time,
5580 forced_by_deadline,
5581 } = action
5582 {
5583 let result =
5584 self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5585 self.fairness_guard.event_processed(
5586 fairness_event_type,
5587 event_start.elapsed(),
5588 Instant::now(),
5589 );
5590 return result;
5591 }
5592
5593 self.fairness_guard.event_processed(
5594 fairness_event_type,
5595 event_start.elapsed(),
5596 Instant::now(),
5597 );
5598 return Ok(());
5599 }
5600 }
5601 }
5602 other => other,
5603 };
5604
5605 let msg = M::Message::from(event);
5606 let cmd = {
5607 let _span = debug_span!(
5608 "ftui.program.update",
5609 msg_type = "event",
5610 duration_us = tracing::field::Empty,
5611 cmd_type = tracing::field::Empty
5612 )
5613 .entered();
5614 let start = Instant::now();
5615 let cmd = self.model.update(msg);
5616 let elapsed_us = start.elapsed().as_micros() as u64;
5617 self.last_update_us = Some(elapsed_us);
5618 tracing::Span::current().record("duration_us", elapsed_us);
5619 tracing::Span::current()
5620 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5621 cmd
5622 };
5623 self.mark_dirty();
5624 self.execute_cmd(cmd)?;
5625 if self.running {
5626 self.reconcile_subscriptions();
5627 }
5628
5629 self.fairness_guard.event_processed(
5631 fairness_event_type,
5632 event_start.elapsed(),
5633 Instant::now(),
5634 );
5635
5636 Ok(())
5637 }
5638
5639 fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5641 match event {
5642 Event::Key(_)
5643 | Event::Mouse(_)
5644 | Event::Paste(_)
5645 | Event::Ime(_)
5646 | Event::Focus(_)
5647 | Event::Clipboard(_) => FairnessEventType::Input,
5648 Event::Resize { .. } => FairnessEventType::Resize,
5649 Event::Tick => FairnessEventType::Tick,
5650 }
5651 }
5652
5653 fn reconcile_subscriptions(&mut self) {
5655 let _span = debug_span!(
5656 "ftui.program.subscriptions",
5657 active_count = tracing::field::Empty,
5658 started = tracing::field::Empty,
5659 stopped = tracing::field::Empty
5660 )
5661 .entered();
5662 let subs = self.model.subscriptions();
5663 let before_count = self.subscriptions.active_count();
5664 self.subscriptions.reconcile(subs);
5665 let after_count = self.subscriptions.active_count();
5666 let started = after_count.saturating_sub(before_count);
5667 let stopped = before_count.saturating_sub(after_count);
5668 crate::debug_trace!(
5669 "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5670 before_count,
5671 after_count,
5672 started,
5673 stopped
5674 );
5675 if after_count == 0 {
5676 crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5677 }
5678 let current = tracing::Span::current();
5679 current.record("active_count", after_count);
5680 current.record("started", started);
5682 current.record("stopped", stopped);
5683 }
5684
5685 fn process_subscription_messages(&mut self) -> io::Result<()> {
5687 let messages = self.subscriptions.drain_messages();
5688 let msg_count = messages.len();
5689 if msg_count > 0 {
5690 crate::debug_trace!("processing {} subscription message(s)", msg_count);
5691 }
5692 for msg in messages {
5693 let cmd = {
5694 let _span = debug_span!(
5695 "ftui.program.update",
5696 msg_type = "subscription",
5697 duration_us = tracing::field::Empty,
5698 cmd_type = tracing::field::Empty
5699 )
5700 .entered();
5701 let start = Instant::now();
5702 let cmd = self.model.update(msg);
5703 let elapsed_us = start.elapsed().as_micros() as u64;
5704 self.last_update_us = Some(elapsed_us);
5705 tracing::Span::current().record("duration_us", elapsed_us);
5706 tracing::Span::current()
5707 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5708 cmd
5709 };
5710 self.mark_dirty();
5711 self.execute_cmd(cmd)?;
5712 if !self.running {
5713 break;
5714 }
5715 }
5716 if self.running && self.dirty {
5717 self.reconcile_subscriptions();
5718 }
5719 Ok(())
5720 }
5721
5722 fn process_task_results(&mut self) -> io::Result<()> {
5724 while let Ok(msg) = self.task_receiver.try_recv() {
5725 let cmd = {
5726 let _span = debug_span!(
5727 "ftui.program.update",
5728 msg_type = "task",
5729 duration_us = tracing::field::Empty,
5730 cmd_type = tracing::field::Empty
5731 )
5732 .entered();
5733 let start = Instant::now();
5734 let cmd = self.model.update(msg);
5735 let elapsed_us = start.elapsed().as_micros() as u64;
5736 self.last_update_us = Some(elapsed_us);
5737 tracing::Span::current().record("duration_us", elapsed_us);
5738 tracing::Span::current()
5739 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5740 cmd
5741 };
5742 self.mark_dirty();
5743 self.execute_cmd(cmd)?;
5744 if !self.running {
5745 break;
5746 }
5747 }
5748 if self.running && self.dirty {
5749 self.reconcile_subscriptions();
5750 }
5751 Ok(())
5752 }
5753
5754 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5756 self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5757 match cmd {
5758 Cmd::None => {}
5759 Cmd::Quit => self.running = false,
5760 Cmd::Msg(m) => {
5761 let start = Instant::now();
5762 let cmd = self.model.update(m);
5763 let elapsed_us = start.elapsed().as_micros() as u64;
5764 self.last_update_us = Some(elapsed_us);
5765 self.mark_dirty();
5766 self.execute_cmd(cmd)?;
5767 }
5768 Cmd::Batch(cmds) => {
5769 for c in cmds {
5772 self.execute_cmd(c)?;
5773 if !self.running {
5774 break;
5775 }
5776 }
5777 }
5778 Cmd::Sequence(cmds) => {
5779 for c in cmds {
5780 self.execute_cmd(c)?;
5781 if !self.running {
5782 break;
5783 }
5784 }
5785 }
5786 Cmd::Tick(duration) => {
5787 self.tick_rate = Some(duration);
5788 self.last_tick = Instant::now();
5789 }
5790 Cmd::Log(text) => {
5791 let sanitized = sanitize(&text);
5792 let mut text_crlf = if sanitized.contains('\n') {
5793 sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5794 } else {
5795 sanitized.into_owned()
5796 };
5797 if !text_crlf.ends_with("\r\n") {
5798 if text_crlf.ends_with('\n') {
5799 text_crlf.pop();
5800 }
5801 text_crlf.push_str("\r\n");
5802 }
5803 self.writer.write_log(&text_crlf)?;
5804 }
5805 Cmd::Task(spec, f) => {
5806 crate::effect_system::record_command_effect("task", 0);
5807 self.task_executor.submit(spec, f);
5808 }
5809 Cmd::SaveState => {
5810 self.save_state();
5811 }
5812 Cmd::RestoreState => {
5813 self.load_state();
5814 }
5815 Cmd::SetMouseCapture(enabled) => {
5816 self.backend_features.mouse_capture = enabled;
5817 self.events.set_features(self.backend_features)?;
5818 }
5819 Cmd::SetTickStrategy(strategy) => {
5820 let new_name = strategy.name().to_owned();
5821 if let Some(mut previous) = self.tick_strategy.replace(strategy) {
5822 let old_name = previous.name().to_owned();
5823 previous.shutdown();
5824 info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
5825 } else {
5826 info!(new = %new_name, "tick strategy changed at runtime");
5827 }
5828 self.last_active_screen_for_strategy = None;
5829 }
5830 }
5831 Ok(())
5832 }
5833
5834 fn check_screen_transition(&mut self) {
5844 if self.tick_strategy.is_none() {
5845 return;
5846 }
5847
5848 let current_active = match self.model.as_screen_tick_dispatch() {
5850 Some(dispatch) => dispatch.active_screen_id(),
5851 None => return,
5852 };
5853
5854 let previous = match self.last_active_screen_for_strategy.take() {
5856 Some(prev) => prev,
5857 None => {
5858 self.last_active_screen_for_strategy = Some(current_active);
5859 return;
5860 }
5861 };
5862
5863 if previous == current_active {
5864 self.last_active_screen_for_strategy = Some(current_active);
5865 return;
5866 }
5867
5868 if let Some(strategy) = self.tick_strategy.as_mut() {
5870 strategy.on_screen_transition(&previous, ¤t_active);
5871 }
5872
5873 let mut force_ticked = false;
5875 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5876 dispatch.tick_screen(¤t_active, self.tick_count);
5877 force_ticked = true;
5878 }
5879 if force_ticked && self.running {
5880 self.reconcile_subscriptions();
5881 }
5882
5883 self.last_active_screen_for_strategy = Some(current_active);
5884 self.mark_dirty();
5885 }
5886
5887 fn reap_finished_tasks(&mut self) {
5888 self.task_executor.reap_finished();
5889 }
5890
5891 fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
5892 while let Ok(msg) = self.task_receiver.try_recv() {
5893 let cmd = {
5894 let _span = debug_span!(
5895 "ftui.program.update",
5896 msg_type = "shutdown_task",
5897 duration_us = tracing::field::Empty,
5898 cmd_type = tracing::field::Empty
5899 )
5900 .entered();
5901 let start = Instant::now();
5902 let cmd = self.model.update(msg);
5903 let elapsed_us = start.elapsed().as_micros() as u64;
5904 self.last_update_us = Some(elapsed_us);
5905 tracing::Span::current().record("duration_us", elapsed_us);
5906 tracing::Span::current()
5907 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5908 cmd
5909 };
5910 self.mark_dirty();
5911 self.execute_cmd(cmd)?;
5912 }
5913 Ok(())
5914 }
5915
5916 fn render_frame(&mut self) -> io::Result<()> {
5918 crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
5919
5920 self.frame_idx = self.frame_idx.wrapping_add(1);
5921 let frame_idx = self.frame_idx;
5922 let degradation_start = self.budget.degradation();
5923
5924 self.budget.next_frame();
5926
5927 let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
5929 let verdict = self.guardrails.check_frame(memory_bytes, 0);
5931
5932 if verdict.should_drop_frame() {
5933 return Ok(());
5935 }
5936
5937 if verdict.should_degrade() {
5938 let current = self.budget.degradation();
5940 if verdict.recommended_level > current {
5941 self.budget.set_degradation(verdict.recommended_level);
5942 }
5943 }
5944
5945 let mut conformal_prediction = None;
5947 if let Some(predictor) = self.conformal_predictor.as_ref() {
5948 let baseline_us = self
5949 .last_frame_time_us
5950 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
5951 let diff_strategy = self
5952 .writer
5953 .last_diff_strategy()
5954 .unwrap_or(DiffStrategy::Full);
5955 let frame_height_hint = self.writer.render_height_hint().max(1);
5956 let key = BucketKey::from_context(
5957 self.writer.screen_mode(),
5958 diff_strategy,
5959 self.width,
5960 frame_height_hint,
5961 );
5962 let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
5963 let prediction = predictor.predict(key, baseline_us, budget_us);
5964 if prediction.risk {
5965 self.budget.degrade();
5966 info!(
5967 bucket = %prediction.bucket,
5968 upper_us = prediction.upper_us,
5969 budget_us = prediction.budget_us,
5970 fallback_level = prediction.fallback_level,
5971 degradation = self.budget.degradation().as_str(),
5972 "conformal gate triggered strategy downgrade"
5973 );
5974 debug!(
5975 monotonic.counter.conformal_gate_triggers_total = 1_u64,
5976 bucket = %prediction.bucket,
5977 "conformal gate trigger"
5978 );
5979 }
5980 debug!(
5981 bucket = %prediction.bucket,
5982 upper_us = prediction.upper_us,
5983 budget_us = prediction.budget_us,
5984 fallback = prediction.fallback_level,
5985 risk = prediction.risk,
5986 "conformal risk gate"
5987 );
5988 debug!(
5989 monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
5990 bucket = %prediction.bucket,
5991 "conformal prediction interval width"
5992 );
5993 conformal_prediction = Some(prediction);
5994 }
5995
5996 if self.budget.exhausted() {
5998 self.budget.record_frame_time(Duration::ZERO);
5999 let load_snapshot =
6000 self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
6001 self.emit_budget_evidence(
6002 frame_idx,
6003 degradation_start,
6004 0.0,
6005 conformal_prediction.as_ref(),
6006 &load_snapshot,
6007 );
6008 crate::debug_trace!(
6009 "frame skipped: budget exhausted (degradation={})",
6010 self.budget.degradation().as_str()
6011 );
6012 debug!(
6013 degradation = self.budget.degradation().as_str(),
6014 "frame skipped: budget exhausted before render"
6015 );
6016 return Ok(());
6019 }
6020
6021 let auto_bounds = self.writer.inline_auto_bounds();
6022 let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
6023 let mut should_measure = needs_measure;
6024 if auto_bounds.is_some()
6025 && let Some(state) = self.inline_auto_remeasure.as_mut()
6026 {
6027 let decision = state.sampler.decide(Instant::now());
6028 if decision.should_sample {
6029 should_measure = true;
6030 }
6031 } else {
6032 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6033 }
6034
6035 let render_start = Instant::now();
6037 if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
6038 let measure_height = if needs_measure {
6039 self.writer.render_height_hint().max(1)
6040 } else {
6041 max_height.max(1)
6042 };
6043 let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6044 let measured_height = measure_buffer.content_height();
6045 let clamped = measured_height.clamp(min_height, max_height);
6046 let previous_height = self.writer.auto_ui_height();
6047 self.writer.set_auto_ui_height(clamped);
6048 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6049 let threshold = state.config.change_threshold_rows;
6050 let violated = previous_height
6051 .map(|prev| prev.abs_diff(clamped) >= threshold)
6052 .unwrap_or(false);
6053 state.sampler.observe(violated);
6054 }
6055 }
6056 if auto_bounds.is_some()
6057 && let Some(state) = self.inline_auto_remeasure.as_ref()
6058 {
6059 let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6060 crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6061 }
6062
6063 let frame_height = self.writer.render_height_hint().max(1);
6064 let _frame_span = info_span!(
6065 "ftui.render.frame",
6066 width = self.width,
6067 height = frame_height,
6068 duration_us = tracing::field::Empty
6069 )
6070 .entered();
6071 let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6072 self.update_widget_refresh_plan(frame_idx);
6073 let render_elapsed = render_start.elapsed();
6074 let mut present_elapsed = Duration::ZERO;
6075 let mut presented = false;
6076
6077 let render_budget = self.budget.phase_budgets().render;
6079 if render_elapsed > render_budget {
6080 debug!(
6081 render_ms = render_elapsed.as_millis() as u32,
6082 budget_ms = render_budget.as_millis() as u32,
6083 "render phase exceeded budget"
6084 );
6085 if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6089 self.budget.degrade();
6090 }
6091 }
6092
6093 if !self.budget.exhausted() {
6095 let present_start = Instant::now();
6096 {
6097 let _present_span = debug_span!("ftui.render.present").entered();
6098 self.writer
6099 .present_ui_owned(buffer, cursor, cursor_visible)?;
6100 }
6101 presented = true;
6102 present_elapsed = present_start.elapsed();
6103
6104 let present_budget = self.budget.phase_budgets().present;
6105 if present_elapsed > present_budget {
6106 debug!(
6107 present_ms = present_elapsed.as_millis() as u32,
6108 budget_ms = present_budget.as_millis() as u32,
6109 "present phase exceeded budget"
6110 );
6111 }
6112 } else {
6113 debug!(
6114 degradation = self.budget.degradation().as_str(),
6115 elapsed_ms = self.budget.elapsed().as_millis() as u32,
6116 "frame present skipped: budget exhausted after render"
6117 );
6118 }
6119
6120 if let Some(ref frame_timing) = self.frame_timing {
6121 let update_us = self.last_update_us.unwrap_or(0);
6122 let render_us = render_elapsed.as_micros() as u64;
6123 let present_us = present_elapsed.as_micros() as u64;
6124 let diff_us = if presented {
6125 self.writer
6126 .take_last_present_timings()
6127 .map(|timings| timings.diff_us)
6128 .unwrap_or(0)
6129 } else {
6130 let _ = self.writer.take_last_present_timings();
6131 0
6132 };
6133 let total_us = update_us
6134 .saturating_add(render_us)
6135 .saturating_add(present_us);
6136 let timing = FrameTiming {
6137 frame_idx,
6138 update_us,
6139 render_us,
6140 diff_us,
6141 present_us,
6142 total_us,
6143 };
6144 frame_timing.sink.record_frame(&timing);
6145 }
6146
6147 let frame_time = render_elapsed.saturating_add(present_elapsed);
6148 self.budget.record_frame_time(frame_time);
6149 let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6150
6151 if let (Some(predictor), Some(prediction)) = (
6152 self.conformal_predictor.as_mut(),
6153 conformal_prediction.as_ref(),
6154 ) {
6155 let diff_strategy = self
6156 .writer
6157 .last_diff_strategy()
6158 .unwrap_or(DiffStrategy::Full);
6159 let key = BucketKey::from_context(
6160 self.writer.screen_mode(),
6161 diff_strategy,
6162 self.width,
6163 frame_height,
6164 );
6165 predictor.observe(key, prediction.y_hat, frame_time_us);
6166 }
6167 self.last_frame_time_us = Some(frame_time_us);
6168 let load_snapshot = self.update_load_governor_snapshot(
6169 frame_idx,
6170 frame_time_us,
6171 conformal_prediction.as_ref(),
6172 );
6173 self.emit_budget_evidence(
6174 frame_idx,
6175 degradation_start,
6176 frame_time_us,
6177 conformal_prediction.as_ref(),
6178 &load_snapshot,
6179 );
6180
6181 if presented {
6185 self.dirty = false;
6186 }
6187
6188 Ok(())
6189 }
6190
6191 fn update_load_governor_snapshot(
6192 &mut self,
6193 _frame_idx: u64,
6194 frame_time_us: f64,
6195 conformal_prediction: Option<&ConformalPrediction>,
6196 ) -> LoadGovernorSnapshot {
6197 let budget_us = conformal_prediction
6198 .map(|prediction| prediction.budget_us)
6199 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6200 let resize_stats = self.resize_coalescer.stats();
6201 self.load_governor.observe(LoadGovernorObservation {
6202 frame_time_us,
6203 budget_us,
6204 degradation: self.budget.degradation(),
6205 queue: crate::effect_system::queue_telemetry(),
6206 resize_coalescing_active: self.resize_behavior.uses_coalescer()
6210 && (resize_stats.has_pending
6211 || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady)),
6212 strict_semantics_violation: false,
6213 })
6214 }
6215
6216 fn emit_budget_evidence(
6217 &self,
6218 frame_idx: u64,
6219 degradation_start: DegradationLevel,
6220 frame_time_us: f64,
6221 conformal_prediction: Option<&ConformalPrediction>,
6222 load_snapshot: &LoadGovernorSnapshot,
6223 ) {
6224 let Some(telemetry) = self.budget.telemetry() else {
6225 set_budget_snapshot(None);
6226 return;
6227 };
6228
6229 let budget_us = conformal_prediction
6230 .map(|prediction| prediction.budget_us)
6231 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6232 let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6233 let degradation_after = self.budget.degradation();
6234
6235 let evidence = BudgetDecisionEvidence {
6236 frame_idx,
6237 decision: BudgetDecisionEvidence::decision_from_levels(
6238 degradation_start,
6239 degradation_after,
6240 ),
6241 controller_decision: telemetry.last_decision,
6242 degradation_before: degradation_start,
6243 degradation_after,
6244 frame_time_us,
6245 budget_us,
6246 pid_output: telemetry.pid_output,
6247 pid_p: telemetry.pid_p,
6248 pid_i: telemetry.pid_i,
6249 pid_d: telemetry.pid_d,
6250 e_value: telemetry.e_value,
6251 frames_observed: telemetry.frames_observed,
6252 frames_since_change: telemetry.frames_since_change,
6253 in_warmup: telemetry.in_warmup,
6254 controller_reason: telemetry.decision_reason,
6255 load_governor: *load_snapshot,
6256 conformal,
6257 };
6258
6259 let conformal_snapshot = evidence
6260 .conformal
6261 .as_ref()
6262 .map(|snapshot| ConformalSnapshot {
6263 bucket_key: snapshot.bucket_key.clone(),
6264 sample_count: snapshot.n_b,
6265 upper_us: snapshot.upper_us,
6266 risk: snapshot.risk,
6267 });
6268 set_budget_snapshot(Some(BudgetDecisionSnapshot {
6269 frame_idx: evidence.frame_idx,
6270 decision: evidence.decision,
6271 controller_decision: evidence.controller_decision,
6272 degradation_before: evidence.degradation_before,
6273 degradation_after: evidence.degradation_after,
6274 frame_time_us: evidence.frame_time_us,
6275 budget_us: evidence.budget_us,
6276 pid_output: evidence.pid_output,
6277 e_value: evidence.e_value,
6278 frames_observed: evidence.frames_observed,
6279 frames_since_change: evidence.frames_since_change,
6280 in_warmup: evidence.in_warmup,
6281 conformal: conformal_snapshot,
6282 }));
6283
6284 if let Some(ref sink) = self.evidence_sink {
6285 let _ = sink.write_jsonl(&evidence.to_jsonl());
6286 }
6287 }
6288
6289 fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6290 if !self.widget_refresh_config.enabled {
6291 self.widget_refresh_plan.clear();
6292 return;
6293 }
6294
6295 let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6296 let degradation = self.budget.degradation();
6297 self.widget_refresh_plan.recompute(
6298 frame_idx,
6299 budget_us,
6300 degradation,
6301 &self.widget_signals,
6302 &self.widget_refresh_config,
6303 );
6304
6305 if let Some(ref sink) = self.evidence_sink {
6306 let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6307 }
6308 }
6309
6310 fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6311 self.frame_arena.reset();
6313
6314 let buffer = self.writer.take_render_buffer(self.width, frame_height);
6317 let (pool, links) = self.writer.pool_and_links_mut();
6318 let mut frame = Frame::from_buffer(buffer, pool);
6319 frame.set_degradation(self.budget.degradation());
6320 frame.set_links(links);
6321 frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6322 frame.set_arena(&self.frame_arena);
6323
6324 let view_start = Instant::now();
6325 let _view_span = debug_span!(
6326 "ftui.program.view",
6327 duration_us = tracing::field::Empty,
6328 widget_count = tracing::field::Empty
6329 )
6330 .entered();
6331 self.model.view(&mut frame);
6332 self.widget_signals = frame.take_widget_signals();
6333 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6334 (frame.buffer, frame.cursor_position, frame.cursor_visible)
6337 }
6338
6339 fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6340 let Some(ref sink) = self.evidence_sink else {
6341 return;
6342 };
6343
6344 let config = self.fairness_guard.config();
6345 if !self.fairness_config_logged {
6346 let config_entry = FairnessConfigEvidence {
6347 enabled: config.enabled,
6348 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6349 dominance_threshold: config.dominance_threshold,
6350 fairness_threshold: config.fairness_threshold,
6351 };
6352 let _ = sink.write_jsonl(&config_entry.to_jsonl());
6353 self.fairness_config_logged = true;
6354 }
6355
6356 let evidence = FairnessDecisionEvidence {
6357 frame_idx: self.frame_idx,
6358 decision: if decision.should_process {
6359 "allow"
6360 } else {
6361 "yield"
6362 },
6363 reason: decision.reason.as_str(),
6364 pending_input_latency_ms: decision
6365 .pending_input_latency
6366 .map(|latency| latency.as_millis() as u64),
6367 jain_index: decision.jain_index,
6368 resize_dominance_count: dominance_count,
6369 dominance_threshold: config.dominance_threshold,
6370 fairness_threshold: config.fairness_threshold,
6371 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6372 };
6373
6374 let _ = sink.write_jsonl(&evidence.to_jsonl());
6375 }
6376
6377 fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6378 self.frame_arena.reset();
6380
6381 let pool = self.writer.pool_mut();
6382 let mut frame = Frame::new(self.width, frame_height, pool);
6383 frame.set_degradation(self.budget.degradation());
6384 frame.set_arena(&self.frame_arena);
6385
6386 let view_start = Instant::now();
6387 let _view_span = debug_span!(
6388 "ftui.program.view",
6389 duration_us = tracing::field::Empty,
6390 widget_count = tracing::field::Empty
6391 )
6392 .entered();
6393 self.model.view(&mut frame);
6394 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6395
6396 (frame.buffer, frame.cursor_position)
6397 }
6398
6399 fn effective_timeout(&self) -> Duration {
6401 if let Some(tick_rate) = self.tick_rate {
6402 let elapsed = self.last_tick.elapsed();
6403 let mut timeout = tick_rate.saturating_sub(elapsed);
6404 if self.resize_behavior.uses_coalescer()
6405 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6406 {
6407 timeout = timeout.min(resize_timeout);
6408 }
6409 timeout
6410 } else {
6411 let mut timeout = self.poll_timeout;
6412 if self.resize_behavior.uses_coalescer()
6413 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6414 {
6415 timeout = timeout.min(resize_timeout);
6416 }
6417 timeout
6418 }
6419 }
6420
6421 fn should_tick(&mut self) -> bool {
6423 if let Some(tick_rate) = self.tick_rate
6424 && self.last_tick.elapsed() >= tick_rate
6425 {
6426 self.last_tick = Instant::now();
6427 return true;
6428 }
6429 false
6430 }
6431
6432 fn process_resize_coalescer(&mut self) -> io::Result<()> {
6433 if !self.resize_behavior.uses_coalescer() {
6434 return Ok(());
6435 }
6436
6437 let dominance_count = self.fairness_guard.resize_dominance_count();
6440 let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6441 self.emit_fairness_evidence(&fairness_decision, dominance_count);
6442 if !fairness_decision.should_process {
6443 debug!(
6444 reason = ?fairness_decision.reason,
6445 pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6446 "Resize yielding to input for fairness"
6447 );
6448 return Ok(());
6450 }
6451
6452 let action = self.resize_coalescer.tick();
6453 let resize_snapshot =
6454 self.resize_coalescer
6455 .logs()
6456 .last()
6457 .map(|entry| ResizeDecisionSnapshot {
6458 event_idx: entry.event_idx,
6459 action: entry.action,
6460 dt_ms: entry.dt_ms,
6461 event_rate: entry.event_rate,
6462 regime: entry.regime,
6463 pending_size: entry.pending_size,
6464 applied_size: entry.applied_size,
6465 time_since_render_ms: entry.time_since_render_ms,
6466 bocpd: self
6467 .resize_coalescer
6468 .bocpd()
6469 .and_then(|detector| detector.last_evidence().cloned()),
6470 });
6471 set_resize_snapshot(resize_snapshot);
6472
6473 match action {
6474 CoalesceAction::ApplyResize {
6475 width,
6476 height,
6477 coalesce_time,
6478 forced_by_deadline,
6479 } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6480 _ => Ok(()),
6481 }
6482 }
6483
6484 fn apply_resize(
6485 &mut self,
6486 width: u16,
6487 height: u16,
6488 coalesce_time: Duration,
6489 forced_by_deadline: bool,
6490 ) -> io::Result<()> {
6491 let width = width.max(1);
6493 let height = height.max(1);
6494 self.width = width;
6495 self.height = height;
6496 self.writer.set_size(width, height);
6497 info!(
6498 width = width,
6499 height = height,
6500 coalesce_ms = coalesce_time.as_millis() as u64,
6501 forced = forced_by_deadline,
6502 "Resize applied"
6503 );
6504
6505 let msg = M::Message::from(Event::Resize { width, height });
6506 let start = Instant::now();
6507 let cmd = self.model.update(msg);
6508 let elapsed_us = start.elapsed().as_micros() as u64;
6509 self.last_update_us = Some(elapsed_us);
6510 self.mark_dirty();
6511 self.execute_cmd(cmd)?;
6512 if self.running && self.dirty {
6513 self.reconcile_subscriptions();
6514 }
6515 Ok(())
6516 }
6517
6518 pub fn model(&self) -> &M {
6522 &self.model
6523 }
6524
6525 pub fn model_mut(&mut self) -> &mut M {
6527 &mut self.model
6528 }
6529
6530 pub fn is_running(&self) -> bool {
6532 self.running
6533 }
6534
6535 #[must_use]
6537 pub const fn tick_rate(&self) -> Option<Duration> {
6538 self.tick_rate
6539 }
6540
6541 #[must_use]
6543 pub const fn executed_cmd_count(&self) -> usize {
6544 self.executed_cmd_count
6545 }
6546
6547 pub fn quit(&mut self) {
6549 self.running = false;
6550 }
6551
6552 pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6554 self.state_registry.as_ref()
6555 }
6556
6557 pub fn has_persistence(&self) -> bool {
6559 self.state_registry.is_some()
6560 }
6561
6562 #[must_use]
6568 pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6569 self.tick_strategy
6570 .as_ref()
6571 .map(|s| s.debug_stats())
6572 .unwrap_or_default()
6573 }
6574
6575 pub fn trigger_save(&mut self) -> StorageResult<bool> {
6580 if let Some(registry) = &self.state_registry {
6581 registry.flush()
6582 } else {
6583 Ok(false)
6584 }
6585 }
6586
6587 pub fn trigger_load(&mut self) -> StorageResult<usize> {
6592 if let Some(registry) = &self.state_registry {
6593 registry.load()
6594 } else {
6595 Ok(0)
6596 }
6597 }
6598
6599 fn mark_dirty(&mut self) {
6600 self.dirty = true;
6601 }
6602
6603 fn check_locale_change(&mut self) {
6604 let version = self.locale_context.version();
6605 if version != self.locale_version {
6606 self.locale_version = version;
6607 self.mark_dirty();
6608 }
6609 }
6610
6611 pub fn request_redraw(&mut self) {
6613 self.mark_dirty();
6614 }
6615
6616 pub fn request_ui_height_remeasure(&mut self) {
6618 if self.writer.inline_auto_bounds().is_some() {
6619 self.writer.clear_auto_ui_height();
6620 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6621 state.reset();
6622 }
6623 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6624 self.mark_dirty();
6625 }
6626 }
6627
6628 pub fn start_recording(&mut self, name: impl Into<String>) {
6633 let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6634 recorder.start();
6635 self.event_recorder = Some(recorder);
6636 }
6637
6638 pub fn stop_recording(&mut self) -> Option<InputMacro> {
6642 self.event_recorder.take().map(EventRecorder::finish)
6643 }
6644
6645 pub fn is_recording(&self) -> bool {
6647 self.event_recorder
6648 .as_ref()
6649 .is_some_and(EventRecorder::is_recording)
6650 }
6651}
6652
6653pub struct App;
6655
6656impl App {
6657 #[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6660 AppBuilder {
6661 model,
6662 config: ProgramConfig::default(),
6663 }
6664 }
6665
6666 pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6668 AppBuilder {
6669 model,
6670 config: ProgramConfig::fullscreen(),
6671 }
6672 }
6673
6674 pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6676 AppBuilder {
6677 model,
6678 config: ProgramConfig::inline(height),
6679 }
6680 }
6681
6682 pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6684 AppBuilder {
6685 model,
6686 config: ProgramConfig::inline_auto(min_height, max_height),
6687 }
6688 }
6689
6690 pub fn string_model<S: crate::string_model::StringModel>(
6695 model: S,
6696 ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6697 AppBuilder {
6698 model: crate::string_model::StringModelAdapter::new(model),
6699 config: ProgramConfig::fullscreen(),
6700 }
6701 }
6702}
6703
6704#[must_use]
6706pub struct AppBuilder<M: Model> {
6707 model: M,
6708 config: ProgramConfig,
6709}
6710
6711impl<M: Model> AppBuilder<M> {
6712 pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6714 self.config.screen_mode = mode;
6715 self
6716 }
6717
6718 pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6720 self.config.ui_anchor = anchor;
6721 self
6722 }
6723
6724 pub fn with_mouse(mut self) -> Self {
6726 self.config.mouse_capture_policy = MouseCapturePolicy::On;
6727 self
6728 }
6729
6730 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6732 self.config.mouse_capture_policy = policy;
6733 self
6734 }
6735
6736 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6738 self.config.mouse_capture_policy = if enabled {
6739 MouseCapturePolicy::On
6740 } else {
6741 MouseCapturePolicy::Off
6742 };
6743 self
6744 }
6745
6746 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6748 self.config.budget = budget;
6749 self
6750 }
6751
6752 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6754 self.config.load_governor = config;
6755 self
6756 }
6757
6758 pub fn without_load_governor(mut self) -> Self {
6760 self.config.load_governor = LoadGovernorConfig::disabled();
6761 self
6762 }
6763
6764 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
6766 self.config.evidence_sink = config;
6767 self
6768 }
6769
6770 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
6772 self.config.render_trace = config;
6773 self
6774 }
6775
6776 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
6778 self.config.widget_refresh = config;
6779 self
6780 }
6781
6782 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
6784 self.config.effect_queue = config;
6785 self
6786 }
6787
6788 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
6790 self.config.inline_auto_remeasure = Some(config);
6791 self
6792 }
6793
6794 pub fn without_inline_auto_remeasure(mut self) -> Self {
6796 self.config.inline_auto_remeasure = None;
6797 self
6798 }
6799
6800 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
6802 self.config.locale_context = locale_context;
6803 self
6804 }
6805
6806 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
6808 self.config.locale_context = LocaleContext::new(locale);
6809 self
6810 }
6811
6812 pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
6814 self.config.resize_coalescer = config;
6815 self
6816 }
6817
6818 pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
6820 self.config.resize_behavior = behavior;
6821 self
6822 }
6823
6824 pub fn legacy_resize(mut self, enabled: bool) -> Self {
6826 if enabled {
6827 self.config.resize_behavior = ResizeBehavior::Immediate;
6828 }
6829 self
6830 }
6831
6832 pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
6834 self.config.tick_strategy = Some(strategy);
6835 self
6836 }
6837
6838 #[cfg(feature = "crossterm-compat")]
6840 pub fn run(self) -> io::Result<()>
6841 where
6842 M::Message: Send + 'static,
6843 {
6844 let mut program = Program::with_config(self.model, self.config)?;
6845 let result = program.run();
6846 if let Err(ref err) = result
6847 && let Some(signal) = signal_termination_from_error(err)
6848 {
6849 drop(program);
6850 std::process::exit(128 + signal);
6851 }
6852 result
6853 }
6854
6855 #[cfg(all(feature = "native-backend", unix))]
6857 pub fn run_native(self) -> io::Result<()>
6858 where
6859 M::Message: Send + 'static,
6860 {
6861 let mut program = Program::with_native_backend(self.model, self.config)?;
6862 let result = program.run();
6863 if let Err(ref err) = result
6864 && let Some(signal) = signal_termination_from_error(err)
6865 {
6866 drop(program);
6867 std::process::exit(128 + signal);
6868 }
6869 result
6870 }
6871
6872 #[cfg(not(feature = "crossterm-compat"))]
6874 pub fn run(self) -> io::Result<()>
6875 where
6876 M::Message: Send + 'static,
6877 {
6878 let _ = (self.model, self.config);
6879 Err(io::Error::new(
6880 io::ErrorKind::Unsupported,
6881 "enable `crossterm-compat` feature to use AppBuilder::run()",
6882 ))
6883 }
6884
6885 #[cfg(any(not(feature = "native-backend"), not(unix)))]
6890 pub fn run_native(self) -> io::Result<()>
6891 where
6892 M::Message: Send + 'static,
6893 {
6894 let _ = (self.model, self.config);
6895 #[cfg(not(unix))]
6901 let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
6902 #[cfg(all(unix, not(feature = "native-backend")))]
6903 let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
6904 Err(io::Error::new(io::ErrorKind::Unsupported, msg))
6905 }
6906}
6907
6908#[derive(Debug, Clone)]
6977pub struct BatchController {
6978 ema_inter_arrival_s: f64,
6980 ema_service_s: f64,
6982 alpha: f64,
6984 tau_min_s: f64,
6986 tau_max_s: f64,
6988 headroom: f64,
6990 last_arrival: Option<Instant>,
6992 observations: u64,
6994}
6995
6996impl BatchController {
6997 pub fn new() -> Self {
7004 Self {
7005 ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
7008 tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
7011 last_arrival: None,
7012 observations: 0,
7013 }
7014 }
7015
7016 pub fn observe_arrival(&mut self, now: Instant) {
7018 if let Some(last) = self.last_arrival {
7019 let dt = now.saturating_duration_since(last).as_secs_f64();
7020 if dt > 0.0 && dt < 10.0 {
7021 self.ema_inter_arrival_s =
7023 self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
7024 self.observations += 1;
7025 }
7026 }
7027 self.last_arrival = Some(now);
7028 }
7029
7030 pub fn observe_service(&mut self, duration: Duration) {
7032 let dt = duration.as_secs_f64();
7033 if (0.0..10.0).contains(&dt) {
7034 self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
7035 }
7036 }
7037
7038 #[inline]
7040 pub fn lambda_est(&self) -> f64 {
7041 if self.ema_inter_arrival_s > 0.0 {
7042 1.0 / self.ema_inter_arrival_s
7043 } else {
7044 0.0
7045 }
7046 }
7047
7048 #[inline]
7050 pub fn service_est_s(&self) -> f64 {
7051 self.ema_service_s
7052 }
7053
7054 #[inline]
7056 pub fn rho_est(&self) -> f64 {
7057 self.lambda_est() * self.ema_service_s
7058 }
7059
7060 pub fn tau_s(&self) -> f64 {
7066 let base = self.ema_service_s * self.headroom;
7067 base.clamp(self.tau_min_s, self.tau_max_s)
7068 }
7069
7070 pub fn tau(&self) -> Duration {
7072 Duration::from_secs_f64(self.tau_s())
7073 }
7074
7075 #[inline]
7077 pub fn is_stable(&self) -> bool {
7078 self.rho_est() < 1.0
7079 }
7080
7081 #[inline]
7083 pub fn observations(&self) -> u64 {
7084 self.observations
7085 }
7086}
7087
7088impl Default for BatchController {
7089 fn default() -> Self {
7090 Self::new()
7091 }
7092}
7093
7094#[cfg(test)]
7095mod tests {
7096 use super::*;
7097 use ftui_core::terminal_capabilities::TerminalCapabilities;
7098 use ftui_layout::PaneDragResizeEffect;
7099 use ftui_render::buffer::Buffer;
7100 use ftui_render::cell::Cell;
7101 use ftui_render::diff_strategy::DiffStrategy;
7102 use ftui_render::frame::CostEstimateSource;
7103 use serde_json::Value;
7104 use std::collections::{HashMap, VecDeque};
7105 use std::path::PathBuf;
7106 use std::sync::mpsc;
7107 use std::sync::{
7108 Arc,
7109 atomic::{AtomicUsize, Ordering},
7110 };
7111
7112 struct TestModel {
7114 value: i32,
7115 }
7116
7117 #[derive(Debug)]
7118 enum TestMsg {
7119 Increment,
7120 Decrement,
7121 Quit,
7122 }
7123
7124 impl From<Event> for TestMsg {
7125 fn from(_event: Event) -> Self {
7126 TestMsg::Increment
7127 }
7128 }
7129
7130 impl Model for TestModel {
7131 type Message = TestMsg;
7132
7133 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7134 match msg {
7135 TestMsg::Increment => {
7136 self.value += 1;
7137 Cmd::none()
7138 }
7139 TestMsg::Decrement => {
7140 self.value -= 1;
7141 Cmd::none()
7142 }
7143 TestMsg::Quit => Cmd::quit(),
7144 }
7145 }
7146
7147 fn view(&self, _frame: &mut Frame) {
7148 }
7150 }
7151
7152 #[test]
7153 fn cmd_none() {
7154 let cmd: Cmd<TestMsg> = Cmd::none();
7155 assert!(matches!(cmd, Cmd::None));
7156 }
7157
7158 #[test]
7159 fn cmd_quit() {
7160 let cmd: Cmd<TestMsg> = Cmd::quit();
7161 assert!(matches!(cmd, Cmd::Quit));
7162 }
7163
7164 #[test]
7165 fn cmd_msg() {
7166 let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7167 assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7168 }
7169
7170 #[test]
7171 fn cmd_batch_empty() {
7172 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7173 assert!(matches!(cmd, Cmd::None));
7174 }
7175
7176 #[test]
7177 fn cmd_batch_single() {
7178 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7179 assert!(matches!(cmd, Cmd::Quit));
7180 }
7181
7182 #[test]
7183 fn cmd_batch_multiple() {
7184 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7185 assert!(matches!(cmd, Cmd::Batch(_)));
7186 }
7187
7188 #[test]
7189 fn cmd_sequence_empty() {
7190 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7191 assert!(matches!(cmd, Cmd::None));
7192 }
7193
7194 #[test]
7195 fn cmd_tick() {
7196 let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7197 assert!(matches!(cmd, Cmd::Tick(_)));
7198 }
7199
7200 #[test]
7201 fn cmd_task() {
7202 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7203 assert!(matches!(cmd, Cmd::Task(..)));
7204 }
7205
7206 #[test]
7207 fn cmd_debug_format() {
7208 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7209 let debug = format!("{cmd:?}");
7210 assert_eq!(
7211 debug,
7212 "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7213 );
7214 }
7215
7216 #[test]
7217 fn model_subscriptions_default_empty() {
7218 let model = TestModel { value: 0 };
7219 let subs = model.subscriptions();
7220 assert!(subs.is_empty());
7221 }
7222
7223 #[test]
7224 fn program_config_default() {
7225 let config = ProgramConfig::default();
7226 assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7227 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7228 assert!(!config.resolved_mouse_capture());
7229 assert!(config.bracketed_paste);
7230 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7231 assert!(config.inline_auto_remeasure.is_none());
7232 assert!(config.conformal_config.is_none());
7233 assert!(config.diff_config.bayesian_enabled);
7234 assert!(config.diff_config.dirty_rows_enabled);
7235 assert!(!config.resize_coalescer.enable_bocpd);
7236 assert!(!config.effect_queue.enabled);
7237 assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7238 assert_eq!(
7239 config.immediate_drain.max_burst_duration,
7240 Duration::from_millis(2)
7241 );
7242 assert_eq!(
7243 config.immediate_drain.backoff_timeout,
7244 Duration::from_millis(1)
7245 );
7246 assert_eq!(
7247 config.resize_coalescer.steady_delay_ms,
7248 CoalescerConfig::default().steady_delay_ms
7249 );
7250 }
7251
7252 #[test]
7253 fn program_config_with_immediate_drain() {
7254 let custom = ImmediateDrainConfig {
7255 max_zero_timeout_polls_per_burst: 7,
7256 max_burst_duration: Duration::from_millis(9),
7257 backoff_timeout: Duration::from_millis(3),
7258 };
7259 let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7260 assert_eq!(
7261 config.immediate_drain.max_zero_timeout_polls_per_burst,
7262 custom.max_zero_timeout_polls_per_burst
7263 );
7264 assert_eq!(
7265 config.immediate_drain.max_burst_duration,
7266 custom.max_burst_duration
7267 );
7268 assert_eq!(
7269 config.immediate_drain.backoff_timeout,
7270 custom.backoff_timeout
7271 );
7272 }
7273
7274 #[test]
7275 fn program_config_fullscreen() {
7276 let config = ProgramConfig::fullscreen();
7277 assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7278 }
7279
7280 #[test]
7281 fn program_config_inline() {
7282 let config = ProgramConfig::inline(10);
7283 assert!(matches!(
7284 config.screen_mode,
7285 ScreenMode::Inline { ui_height: 10 }
7286 ));
7287 }
7288
7289 #[test]
7290 fn program_config_inline_auto() {
7291 let config = ProgramConfig::inline_auto(3, 9);
7292 assert!(matches!(
7293 config.screen_mode,
7294 ScreenMode::InlineAuto {
7295 min_height: 3,
7296 max_height: 9
7297 }
7298 ));
7299 assert!(config.inline_auto_remeasure.is_some());
7300 }
7301
7302 #[test]
7303 fn program_config_with_mouse() {
7304 let config = ProgramConfig::default().with_mouse();
7305 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7306 assert!(config.resolved_mouse_capture());
7307 }
7308
7309 #[cfg(feature = "native-backend")]
7310 #[test]
7311 fn sanitize_backend_features_disables_unsupported_features() {
7312 let requested = BackendFeatures {
7313 mouse_capture: true,
7314 bracketed_paste: true,
7315 focus_events: true,
7316 kitty_keyboard: true,
7317 };
7318 let sanitized =
7319 sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7320 assert_eq!(sanitized, BackendFeatures::default());
7321 }
7322
7323 #[cfg(feature = "native-backend")]
7324 #[test]
7325 fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7326 let requested = BackendFeatures {
7327 mouse_capture: true,
7328 bracketed_paste: true,
7329 focus_events: true,
7330 kitty_keyboard: true,
7331 };
7332 let caps = TerminalCapabilities::builder()
7333 .mouse_sgr(true)
7334 .bracketed_paste(true)
7335 .focus_events(true)
7336 .kitty_keyboard(true)
7337 .in_wezterm_mux(true)
7338 .build();
7339 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7340
7341 assert!(sanitized.mouse_capture);
7342 assert!(sanitized.bracketed_paste);
7343 assert!(!sanitized.focus_events);
7344 assert!(!sanitized.kitty_keyboard);
7345 }
7346
7347 #[cfg(feature = "native-backend")]
7348 #[test]
7349 fn sanitize_backend_features_is_conservative_in_tmux() {
7350 let requested = BackendFeatures {
7351 mouse_capture: true,
7352 bracketed_paste: true,
7353 focus_events: true,
7354 kitty_keyboard: true,
7355 };
7356 let caps = TerminalCapabilities::builder()
7357 .mouse_sgr(true)
7358 .bracketed_paste(true)
7359 .focus_events(true)
7360 .kitty_keyboard(true)
7361 .in_tmux(true)
7362 .build();
7363 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7364
7365 assert!(sanitized.mouse_capture);
7366 assert!(sanitized.bracketed_paste);
7367 assert!(!sanitized.focus_events);
7368 assert!(!sanitized.kitty_keyboard);
7369 }
7370
7371 #[test]
7372 fn program_config_mouse_policy_auto_altscreen() {
7373 let config = ProgramConfig::fullscreen();
7374 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7375 assert!(config.resolved_mouse_capture());
7376 }
7377
7378 #[test]
7379 fn program_config_mouse_policy_force_off() {
7380 let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7381 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7382 assert!(!config.resolved_mouse_capture());
7383 }
7384
7385 #[test]
7386 fn program_config_mouse_policy_force_on_inline() {
7387 let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7388 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7389 assert!(config.resolved_mouse_capture());
7390 }
7391
7392 fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7393 PaneResizeTarget {
7394 split_id: ftui_layout::PaneId::MIN,
7395 axis,
7396 }
7397 }
7398
7399 fn pane_id(raw: u64) -> ftui_layout::PaneId {
7400 ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7401 }
7402
7403 fn nested_pane_tree() -> ftui_layout::PaneTree {
7404 let root = pane_id(1);
7405 let left = pane_id(2);
7406 let right_split = pane_id(3);
7407 let right_top = pane_id(4);
7408 let right_bottom = pane_id(5);
7409 let snapshot = ftui_layout::PaneTreeSnapshot {
7410 schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7411 root,
7412 next_id: pane_id(6),
7413 nodes: vec![
7414 ftui_layout::PaneNodeRecord::split(
7415 root,
7416 None,
7417 ftui_layout::PaneSplit {
7418 axis: SplitAxis::Horizontal,
7419 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7420 first: left,
7421 second: right_split,
7422 },
7423 ),
7424 ftui_layout::PaneNodeRecord::leaf(
7425 left,
7426 Some(root),
7427 ftui_layout::PaneLeaf::new("left"),
7428 ),
7429 ftui_layout::PaneNodeRecord::split(
7430 right_split,
7431 Some(root),
7432 ftui_layout::PaneSplit {
7433 axis: SplitAxis::Vertical,
7434 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7435 first: right_top,
7436 second: right_bottom,
7437 },
7438 ),
7439 ftui_layout::PaneNodeRecord::leaf(
7440 right_top,
7441 Some(right_split),
7442 ftui_layout::PaneLeaf::new("right_top"),
7443 ),
7444 ftui_layout::PaneNodeRecord::leaf(
7445 right_bottom,
7446 Some(right_split),
7447 ftui_layout::PaneLeaf::new("right_bottom"),
7448 ),
7449 ],
7450 extensions: std::collections::BTreeMap::new(),
7451 };
7452 ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7453 }
7454
7455 fn root_first_share_bps(tree: &ftui_layout::PaneTree, split: ftui_layout::PaneId) -> u32 {
7457 match &tree.node(split).expect("split node present").kind {
7458 PaneNodeKind::Split(node) => {
7459 node.ratio.numerator() * 10_000
7460 / (node.ratio.numerator() + node.ratio.denominator())
7461 }
7462 other => panic!("expected split node, got {other:?}"),
7463 }
7464 }
7465
7466 fn fixed_neutral_pressure() -> PanePressureSnapProfile {
7468 PanePressureSnapProfile {
7469 strength_bps: 5_000,
7470 hysteresis_bps: 100,
7471 }
7472 }
7473
7474 fn apply_dispatch_fixed(
7482 tree: &mut ftui_layout::PaneTree,
7483 layout: &ftui_layout::PaneLayout,
7484 dispatch: &PaneTerminalDispatch,
7485 pressure: PanePressureSnapProfile,
7486 seed: &mut u64,
7487 ) -> usize {
7488 let Some(transition) = dispatch.primary_transition.as_ref() else {
7489 return 0;
7490 };
7491 let ops = tree.operations_for_transition(transition, layout, pressure);
7492 let applied = ops.len();
7493 for op in ops {
7494 tree.apply_operation(*seed, op).expect("operation applies");
7495 *seed += 1;
7496 }
7497 applied
7498 }
7499
7500 #[allow(clippy::too_many_arguments)]
7507 fn drive_horizontal_drag_fixed(
7508 adapter: &mut PaneTerminalAdapter,
7509 tree: &mut ftui_layout::PaneTree,
7510 layout: &ftui_layout::PaneLayout,
7511 target: PaneResizeTarget,
7512 down_x: u16,
7513 y: u16,
7514 drag_xs: &[u16],
7515 pressure: PanePressureSnapProfile,
7516 seed: &mut u64,
7517 ) -> usize {
7518 let down = Event::Mouse(MouseEvent::new(
7519 MouseEventKind::Down(MouseButton::Left),
7520 down_x,
7521 y,
7522 ));
7523 let mut applied = apply_dispatch_fixed(
7524 tree,
7525 layout,
7526 &adapter.translate(&down, Some(target)),
7527 pressure,
7528 seed,
7529 );
7530
7531 let last = drag_xs.len().saturating_sub(1);
7532 for (idx, &x) in drag_xs.iter().enumerate() {
7533 let kind = if idx == last {
7534 MouseEventKind::Up(MouseButton::Left)
7535 } else {
7536 MouseEventKind::Drag(MouseButton::Left)
7537 };
7538 let event = Event::Mouse(MouseEvent::new(kind, x, y));
7539 applied += apply_dispatch_fixed(
7540 tree,
7541 layout,
7542 &adapter.translate(&event, None),
7543 pressure,
7544 seed,
7545 );
7546 }
7547 applied
7548 }
7549
7550 #[test]
7551 fn pane_terminal_splitter_resolution_is_deterministic() {
7552 let tree = nested_pane_tree();
7553 let layout = tree
7554 .solve_layout(Rect::new(0, 0, 50, 20))
7555 .expect("layout should solve");
7556 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7557 assert_eq!(handles.len(), 2);
7558
7559 let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7562 .expect("overlap cell should resolve");
7563 assert_eq!(overlap.split_id, pane_id(1));
7564 assert_eq!(overlap.axis, SplitAxis::Horizontal);
7565
7566 let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7567 .expect("right split should resolve");
7568 assert_eq!(right_only.split_id, pane_id(3));
7569 assert_eq!(right_only.axis, SplitAxis::Vertical);
7570 }
7571
7572 #[test]
7573 fn pane_terminal_splitter_hits_register_and_decode_target() {
7574 let tree = nested_pane_tree();
7575 let layout = tree
7576 .solve_layout(Rect::new(0, 0, 50, 20))
7577 .expect("layout should solve");
7578 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7579
7580 let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7581 let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7582 let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7583 assert_eq!(registered, handles.len());
7584
7585 let root_hit = frame
7586 .hit_test(25, 2)
7587 .expect("root splitter should be hittable");
7588 assert_eq!(root_hit.1, HitRegion::Handle);
7589 let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7590 assert_eq!(root_target.split_id, pane_id(1));
7591 assert_eq!(root_target.axis, SplitAxis::Horizontal);
7592
7593 let right_hit = frame
7594 .hit_test(40, 10)
7595 .expect("right splitter should be hittable");
7596 assert_eq!(right_hit.1, HitRegion::Handle);
7597 let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7598 assert_eq!(right_target.split_id, pane_id(3));
7599 assert_eq!(right_target.axis, SplitAxis::Vertical);
7600 }
7601
7602 #[test]
7603 fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7604 let mut adapter =
7605 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7606 let target = pane_target(SplitAxis::Horizontal);
7607
7608 let down = Event::Mouse(MouseEvent::new(
7609 MouseEventKind::Down(MouseButton::Left),
7610 10,
7611 4,
7612 ));
7613 let down_dispatch = adapter.translate(&down, Some(target));
7614 let down_event = down_dispatch
7615 .primary_event
7616 .as_ref()
7617 .expect("pointer down semantic event");
7618 assert_eq!(down_event.sequence, 1);
7619 assert!(matches!(
7620 down_event.kind,
7621 PaneSemanticInputEventKind::PointerDown {
7622 target: actual_target,
7623 pointer_id: 1,
7624 button: PanePointerButton::Primary,
7625 position
7626 } if actual_target == target && position == PanePointerPosition::new(10, 4)
7627 ));
7628 assert!(down_event.validate().is_ok());
7629
7630 let drag = Event::Mouse(MouseEvent::new(
7631 MouseEventKind::Drag(MouseButton::Left),
7632 14,
7633 4,
7634 ));
7635 let drag_dispatch = adapter.translate(&drag, None);
7636 let drag_event = drag_dispatch
7637 .primary_event
7638 .as_ref()
7639 .expect("pointer move semantic event");
7640 assert_eq!(drag_event.sequence, 2);
7641 assert!(matches!(
7642 drag_event.kind,
7643 PaneSemanticInputEventKind::PointerMove {
7644 target: actual_target,
7645 pointer_id: 1,
7646 position,
7647 delta_x: 4,
7648 delta_y: 0
7649 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7650 ));
7651 let drag_motion = drag_dispatch
7652 .motion
7653 .expect("drag should emit motion metadata");
7654 assert_eq!(drag_motion.delta_x, 4);
7655 assert_eq!(drag_motion.delta_y, 0);
7656 assert_eq!(drag_motion.direction_changes, 0);
7657 assert!(drag_motion.speed > 0.0);
7658 assert!(drag_dispatch.pressure_snap_profile().is_some());
7659
7660 let up = Event::Mouse(MouseEvent::new(
7661 MouseEventKind::Up(MouseButton::Left),
7662 14,
7663 4,
7664 ));
7665 let up_dispatch = adapter.translate(&up, None);
7666 let up_event = up_dispatch
7667 .primary_event
7668 .as_ref()
7669 .expect("pointer up semantic event");
7670 assert_eq!(up_event.sequence, 3);
7671 assert!(matches!(
7672 up_event.kind,
7673 PaneSemanticInputEventKind::PointerUp {
7674 target: actual_target,
7675 pointer_id: 1,
7676 button: PanePointerButton::Primary,
7677 position
7678 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7679 ));
7680 let up_motion = up_dispatch
7681 .motion
7682 .expect("up should emit final motion metadata");
7683 assert_eq!(up_motion.delta_x, 4);
7684 assert_eq!(up_motion.delta_y, 0);
7685 assert_eq!(up_motion.direction_changes, 0);
7686 let inertial_throw = up_dispatch
7687 .inertial_throw
7688 .expect("up should emit inertial throw metadata");
7689 assert_eq!(
7690 up_dispatch.projected_position,
7691 Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7692 );
7693 assert_eq!(adapter.active_pointer_id(), None);
7694 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7695 }
7696
7697 #[test]
7698 fn pane_terminal_adapter_drives_live_tree_mutation() {
7699 fn apply_dispatch(
7707 tree: &mut ftui_layout::PaneTree,
7708 layout: &ftui_layout::PaneLayout,
7709 dispatch: &PaneTerminalDispatch,
7710 neutral: PanePressureSnapProfile,
7711 seed: &mut u64,
7712 ) -> usize {
7713 let Some(transition) = dispatch.primary_transition.as_ref() else {
7714 return 0;
7715 };
7716 let pressure = dispatch.pressure_snap_profile().unwrap_or(neutral);
7717 let ops = tree.operations_for_transition(transition, layout, pressure);
7718 let applied = ops.len();
7719 for op in ops {
7720 tree.apply_operation(*seed, op).expect("operation applies");
7721 *seed += 1;
7722 }
7723 applied
7724 }
7725
7726 let mut tree = nested_pane_tree();
7727 let layout = tree
7730 .solve_layout(Rect::new(0, 0, 50, 20))
7731 .expect("layout should solve");
7732 let root_split = pane_id(1);
7733 let target = PaneResizeTarget {
7734 split_id: root_split,
7735 axis: SplitAxis::Horizontal,
7736 };
7737 let neutral = PanePressureSnapProfile {
7738 strength_bps: 5_000,
7739 hysteresis_bps: 100,
7740 };
7741
7742 let before_share = root_first_share_bps(&tree, root_split);
7743
7744 let mut adapter =
7745 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7746 let mut op_seed = 7_000u64;
7747 let mut geometry_transitions = 0usize;
7748
7749 let down = Event::Mouse(MouseEvent::new(
7751 MouseEventKind::Down(MouseButton::Left),
7752 25,
7753 10,
7754 ));
7755 let down_dispatch = adapter.translate(&down, Some(target));
7756 geometry_transitions +=
7757 apply_dispatch(&mut tree, &layout, &down_dispatch, neutral, &mut op_seed);
7758
7759 for x in [30u16, 36, 42] {
7761 let drag = Event::Mouse(MouseEvent::new(
7762 MouseEventKind::Drag(MouseButton::Left),
7763 x,
7764 10,
7765 ));
7766 let dispatch = adapter.translate(&drag, None);
7767 geometry_transitions +=
7768 apply_dispatch(&mut tree, &layout, &dispatch, neutral, &mut op_seed);
7769 }
7770
7771 let up = Event::Mouse(MouseEvent::new(
7773 MouseEventKind::Up(MouseButton::Left),
7774 42,
7775 10,
7776 ));
7777 let up_dispatch = adapter.translate(&up, None);
7778 geometry_transitions +=
7779 apply_dispatch(&mut tree, &layout, &up_dispatch, neutral, &mut op_seed);
7780
7781 assert!(
7782 geometry_transitions > 0,
7783 "drag lifecycle should yield at least one geometry-bearing transition"
7784 );
7785 let after_share = root_first_share_bps(&tree, root_split);
7786 assert!(
7787 after_share > before_share,
7788 "rightward splitter drag should grow the first child: after={after_share} before={before_share}"
7789 );
7790 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7791 }
7792
7793 #[test]
7794 fn pane_terminal_adapter_resize_interrupt_cancels_drag_and_preserves_tree() {
7795 let mut tree = nested_pane_tree();
7800 let layout = tree
7801 .solve_layout(Rect::new(0, 0, 50, 20))
7802 .expect("layout should solve");
7803 let root_split = pane_id(1);
7804 let target = PaneResizeTarget {
7805 split_id: root_split,
7806 axis: SplitAxis::Horizontal,
7807 };
7808 let pressure = fixed_neutral_pressure();
7809 let mut seed = 4_100u64;
7810
7811 let initial_hash = tree.state_hash();
7812
7813 let mut adapter =
7814 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7815
7816 let _ = apply_dispatch_fixed(
7818 &mut tree,
7819 &layout,
7820 &adapter.translate(
7821 &Event::Mouse(MouseEvent::new(
7822 MouseEventKind::Down(MouseButton::Left),
7823 25,
7824 10,
7825 )),
7826 Some(target),
7827 ),
7828 pressure,
7829 &mut seed,
7830 );
7831 for x in [30u16, 36] {
7832 let _ = apply_dispatch_fixed(
7833 &mut tree,
7834 &layout,
7835 &adapter.translate(
7836 &Event::Mouse(MouseEvent::new(
7837 MouseEventKind::Drag(MouseButton::Left),
7838 x,
7839 10,
7840 )),
7841 None,
7842 ),
7843 pressure,
7844 &mut seed,
7845 );
7846 }
7847 let mid_drag_hash = tree.state_hash();
7848 assert_ne!(
7849 mid_drag_hash, initial_hash,
7850 "drag should have mutated the tree before the interrupt"
7851 );
7852 assert!(tree.validate().is_ok());
7853 assert_eq!(adapter.active_pointer_id(), Some(1));
7854
7855 let resize_dispatch = adapter.translate(
7857 &Event::Resize {
7858 width: 60,
7859 height: 24,
7860 },
7861 None,
7862 );
7863 let cancel = resize_dispatch
7864 .primary_event
7865 .as_ref()
7866 .expect("resize interrupt should emit a cancel semantic event");
7867 assert!(matches!(
7868 cancel.kind,
7869 PaneSemanticInputEventKind::Cancel {
7870 target: Some(actual),
7871 reason: PaneCancelReason::Programmatic
7872 } if actual == target
7873 ));
7874 assert_eq!(
7875 resize_dispatch.log.phase,
7876 PaneTerminalLifecyclePhase::ResizeInterrupt
7877 );
7878 assert_eq!(
7879 resize_dispatch.log.outcome,
7880 PaneTerminalLogOutcome::SemanticForwarded
7881 );
7882
7883 let cancel_transition = resize_dispatch
7886 .primary_transition
7887 .as_ref()
7888 .expect("cancel transition present");
7889 let cancel_ops = tree.operations_for_transition(cancel_transition, &layout, pressure);
7890 assert!(
7891 cancel_ops.is_empty(),
7892 "cancel transition must not mutate the live tree"
7893 );
7894 assert_eq!(tree.state_hash(), mid_drag_hash);
7895 assert!(tree.validate().is_ok());
7896
7897 assert_eq!(adapter.active_pointer_id(), None);
7899 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7900
7901 let new_layout = tree
7903 .solve_layout(Rect::new(0, 0, 60, 24))
7904 .expect("layout should solve at new size");
7905 let before_reuse = tree.state_hash();
7906 let applied = drive_horizontal_drag_fixed(
7907 &mut adapter,
7908 &mut tree,
7909 &new_layout,
7910 target,
7911 30,
7912 12,
7913 &[36, 42, 48],
7914 pressure,
7915 &mut seed,
7916 );
7917 assert!(applied > 0, "fresh gesture should apply operations");
7918 assert_ne!(
7919 tree.state_hash(),
7920 before_reuse,
7921 "adapter must remain usable after a resize interrupt"
7922 );
7923 assert!(tree.validate().is_ok());
7924 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7925 }
7926
7927 #[test]
7928 fn pane_terminal_adapter_survives_resize_storm_and_is_deterministic() {
7929 fn run_storm() -> u64 {
7936 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
7937 .expect("valid adapter");
7938 let mut tree = nested_pane_tree();
7939 let root_split = pane_id(1);
7940 let target = PaneResizeTarget {
7941 split_id: root_split,
7942 axis: SplitAxis::Horizontal,
7943 };
7944 let pressure = fixed_neutral_pressure();
7945 let mut seed = 5_200u64;
7946
7947 let sizes: [(u16, u16); 6] =
7949 [(50, 20), (80, 24), (40, 16), (120, 40), (30, 12), (100, 30)];
7950
7951 for (idx, &(w, h)) in sizes.iter().enumerate() {
7952 let layout = tree
7953 .solve_layout(Rect::new(0, 0, w, h))
7954 .expect("layout should solve under storm");
7955
7956 let down_x = (w / 5).max(2);
7959 let drag_x = (3 * w / 5).max(down_x + 2);
7960 let _ = apply_dispatch_fixed(
7961 &mut tree,
7962 &layout,
7963 &adapter.translate(
7964 &Event::Mouse(MouseEvent::new(
7965 MouseEventKind::Down(MouseButton::Left),
7966 down_x,
7967 h / 2,
7968 )),
7969 Some(target),
7970 ),
7971 pressure,
7972 &mut seed,
7973 );
7974 let _ = apply_dispatch_fixed(
7975 &mut tree,
7976 &layout,
7977 &adapter.translate(
7978 &Event::Mouse(MouseEvent::new(
7979 MouseEventKind::Drag(MouseButton::Left),
7980 drag_x,
7981 h / 2,
7982 )),
7983 None,
7984 ),
7985 pressure,
7986 &mut seed,
7987 );
7988 assert!(
7989 tree.validate().is_ok(),
7990 "tree invalid after drag at step {idx}"
7991 );
7992 let share = root_first_share_bps(&tree, root_split);
7993 assert!(
7994 share > 0 && share < 10_000,
7995 "split ratio escaped bounds at step {idx}: {share}"
7996 );
7997
7998 let resize = adapter.translate(
8000 &Event::Resize {
8001 width: w,
8002 height: h,
8003 },
8004 None,
8005 );
8006 if let Some(cancel_transition) = resize.primary_transition.as_ref() {
8007 let cancel_ops =
8008 tree.operations_for_transition(cancel_transition, &layout, pressure);
8009 assert!(
8010 cancel_ops.is_empty(),
8011 "resize cancel must not mutate the tree at step {idx}"
8012 );
8013 }
8014 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8015 assert_eq!(adapter.active_pointer_id(), None);
8016 assert!(
8017 tree.validate().is_ok(),
8018 "tree invalid after resize at step {idx}"
8019 );
8020 }
8021
8022 let (w, h) = (90u16, 30u16);
8024 let layout = tree
8025 .solve_layout(Rect::new(0, 0, w, h))
8026 .expect("final layout should solve");
8027 let _ = drive_horizontal_drag_fixed(
8028 &mut adapter,
8029 &mut tree,
8030 &layout,
8031 target,
8032 (w / 5).max(2),
8033 h / 2,
8034 &[w / 2, 3 * w / 5],
8035 pressure,
8036 &mut seed,
8037 );
8038 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8039 assert!(tree.validate().is_ok());
8040
8041 tree.state_hash()
8042 }
8043
8044 let first = run_storm();
8045 let second = run_storm();
8046 assert_eq!(
8047 first, second,
8048 "resize storm with repeated re-grabs must be deterministic"
8049 );
8050 }
8051
8052 #[test]
8053 fn pane_terminal_adapter_geometry_is_capability_invariant() {
8054 fn drag_under(
8063 over: ftui_core::capability_override::CapabilityOverride,
8064 ) -> (bool, bool, bool, u64) {
8065 ftui_core::capability_override::with_capability_override(over, || {
8066 let caps = ftui_core::capability_override::current_capabilities();
8067 let mut tree = nested_pane_tree();
8068 let layout = tree
8069 .solve_layout(Rect::new(0, 0, 50, 20))
8070 .expect("layout should solve");
8071 let target = PaneResizeTarget {
8072 split_id: pane_id(1),
8073 axis: SplitAxis::Horizontal,
8074 };
8075 let pressure = fixed_neutral_pressure();
8076 let mut seed = 6_300u64;
8077 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8078 .expect("valid adapter");
8079 let applied = drive_horizontal_drag_fixed(
8080 &mut adapter,
8081 &mut tree,
8082 &layout,
8083 target,
8084 25,
8085 10,
8086 &[30, 36, 42],
8087 pressure,
8088 &mut seed,
8089 );
8090 assert!(
8091 applied > 0,
8092 "drag should apply operations under every profile"
8093 );
8094 (
8095 caps.mouse_sgr,
8096 caps.true_color,
8097 caps.in_tmux,
8098 tree.state_hash(),
8099 )
8100 })
8101 }
8102
8103 let (dumb_sgr, dumb_truecolor, _dumb_tmux, dumb_hash) =
8104 drag_under(ftui_core::capability_override::CapabilityOverride::dumb());
8105 let (modern_sgr, modern_truecolor, modern_tmux, modern_hash) =
8106 drag_under(ftui_core::capability_override::CapabilityOverride::modern());
8107 let (_tmux_sgr, _tmux_truecolor, tmux_tmux, tmux_hash) =
8108 drag_under(ftui_core::capability_override::CapabilityOverride::tmux());
8109
8110 assert!(
8112 dumb_sgr != modern_sgr || dumb_truecolor != modern_truecolor,
8113 "dumb and modern profiles must differ in capabilities"
8114 );
8115 assert!(
8116 modern_tmux != tmux_tmux,
8117 "modern and tmux profiles must differ in the in_tmux flag"
8118 );
8119
8120 assert_eq!(
8122 dumb_hash, modern_hash,
8123 "geometry must not depend on terminal capabilities"
8124 );
8125 assert_eq!(
8126 modern_hash, tmux_hash,
8127 "geometry must not depend on terminal capabilities"
8128 );
8129 }
8130
8131 #[test]
8132 fn pane_terminal_adapter_dispatch_log_traces_routing_and_failures() {
8133 let mut adapter =
8138 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8139 let target = pane_target(SplitAxis::Horizontal);
8140
8141 let wrong_button = adapter.translate(
8143 &Event::Mouse(MouseEvent::new(
8144 MouseEventKind::Down(MouseButton::Right),
8145 5,
8146 5,
8147 )),
8148 Some(target),
8149 );
8150 assert_eq!(
8151 wrong_button.log.phase,
8152 PaneTerminalLifecyclePhase::MouseDown
8153 );
8154 assert_eq!(
8155 wrong_button.log.outcome,
8156 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ActivationButtonRequired)
8157 );
8158 assert!(wrong_button.primary_event.is_none());
8159 assert_eq!(adapter.active_pointer_id(), None);
8160
8161 let down = adapter.translate(
8164 &Event::Mouse(MouseEvent::new(
8165 MouseEventKind::Down(MouseButton::Left),
8166 25,
8167 10,
8168 )),
8169 Some(target),
8170 );
8171 assert_eq!(down.log.phase, PaneTerminalLifecyclePhase::MouseDown);
8172 assert_eq!(down.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8173 assert_eq!(down.log.target, Some(target));
8174 assert_eq!(down.log.pointer_id, Some(1));
8175 let down_seq = down.log.sequence.expect("down sequence");
8176
8177 let drag = adapter.translate(
8178 &Event::Mouse(MouseEvent::new(
8179 MouseEventKind::Drag(MouseButton::Left),
8180 31,
8181 10,
8182 )),
8183 None,
8184 );
8185 assert_eq!(drag.log.phase, PaneTerminalLifecyclePhase::MouseDrag);
8186 assert_eq!(drag.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8187 let drag_seq = drag.log.sequence.expect("drag sequence");
8188 assert!(drag_seq > down_seq, "sequence numbers must be monotonic");
8189
8190 let up = adapter.translate(
8191 &Event::Mouse(MouseEvent::new(
8192 MouseEventKind::Up(MouseButton::Left),
8193 31,
8194 10,
8195 )),
8196 None,
8197 );
8198 assert_eq!(up.log.phase, PaneTerminalLifecyclePhase::MouseUp);
8199 assert_eq!(up.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8200 assert!(up.log.sequence.expect("up sequence") > drag_seq);
8201
8202 let idle_resize = adapter.translate(
8205 &Event::Resize {
8206 width: 80,
8207 height: 24,
8208 },
8209 None,
8210 );
8211 assert_eq!(
8212 idle_resize.log.phase,
8213 PaneTerminalLifecyclePhase::ResizeInterrupt
8214 );
8215 assert_eq!(
8216 idle_resize.log.outcome,
8217 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8218 );
8219 assert!(idle_resize.primary_event.is_none());
8220 }
8221
8222 #[test]
8223 fn pane_terminal_adapter_resize_preserves_gesture_when_cancel_disabled() {
8224 let config = PaneTerminalAdapterConfig {
8228 cancel_on_resize: false,
8229 ..PaneTerminalAdapterConfig::default()
8230 };
8231 let mut adapter = PaneTerminalAdapter::new(config).expect("valid adapter");
8232 let mut tree = nested_pane_tree();
8233 let layout = tree
8234 .solve_layout(Rect::new(0, 0, 50, 20))
8235 .expect("layout should solve");
8236 let root_split = pane_id(1);
8237 let target = PaneResizeTarget {
8238 split_id: root_split,
8239 axis: SplitAxis::Horizontal,
8240 };
8241 let pressure = fixed_neutral_pressure();
8242 let mut seed = 7_700u64;
8243
8244 let _ = apply_dispatch_fixed(
8246 &mut tree,
8247 &layout,
8248 &adapter.translate(
8249 &Event::Mouse(MouseEvent::new(
8250 MouseEventKind::Down(MouseButton::Left),
8251 20,
8252 10,
8253 )),
8254 Some(target),
8255 ),
8256 pressure,
8257 &mut seed,
8258 );
8259 let _ = apply_dispatch_fixed(
8260 &mut tree,
8261 &layout,
8262 &adapter.translate(
8263 &Event::Mouse(MouseEvent::new(
8264 MouseEventKind::Drag(MouseButton::Left),
8265 26,
8266 10,
8267 )),
8268 None,
8269 ),
8270 pressure,
8271 &mut seed,
8272 );
8273 assert_eq!(adapter.active_pointer_id(), Some(1));
8274
8275 let resize = adapter.translate(
8277 &Event::Resize {
8278 width: 50,
8279 height: 20,
8280 },
8281 None,
8282 );
8283 assert!(resize.primary_event.is_none());
8284 assert!(resize.primary_transition.is_none());
8285 assert_eq!(
8286 resize.log.outcome,
8287 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8288 );
8289 assert_eq!(
8290 adapter.active_pointer_id(),
8291 Some(1),
8292 "gesture must survive the resize when cancel_on_resize is disabled"
8293 );
8294
8295 let before_finish = tree.state_hash();
8297 for x in [32u16, 38] {
8298 let _ = apply_dispatch_fixed(
8299 &mut tree,
8300 &layout,
8301 &adapter.translate(
8302 &Event::Mouse(MouseEvent::new(
8303 MouseEventKind::Drag(MouseButton::Left),
8304 x,
8305 10,
8306 )),
8307 None,
8308 ),
8309 pressure,
8310 &mut seed,
8311 );
8312 }
8313 let _ = apply_dispatch_fixed(
8314 &mut tree,
8315 &layout,
8316 &adapter.translate(
8317 &Event::Mouse(MouseEvent::new(
8318 MouseEventKind::Up(MouseButton::Left),
8319 38,
8320 10,
8321 )),
8322 None,
8323 ),
8324 pressure,
8325 &mut seed,
8326 );
8327 assert_ne!(
8328 tree.state_hash(),
8329 before_finish,
8330 "the surviving gesture should keep mutating the tree"
8331 );
8332 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8333 assert!(tree.validate().is_ok());
8334 }
8335
8336 #[test]
8337 fn pane_terminal_adapter_focus_loss_emits_cancel() {
8338 let mut adapter =
8339 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8340 let target = pane_target(SplitAxis::Vertical);
8341
8342 let down = Event::Mouse(MouseEvent::new(
8343 MouseEventKind::Down(MouseButton::Left),
8344 3,
8345 9,
8346 ));
8347 let _ = adapter.translate(&down, Some(target));
8348 assert_eq!(adapter.active_pointer_id(), Some(1));
8349
8350 let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
8351 let cancel_event = cancel_dispatch
8352 .primary_event
8353 .as_ref()
8354 .expect("focus-loss cancel event");
8355 assert!(matches!(
8356 cancel_event.kind,
8357 PaneSemanticInputEventKind::Cancel {
8358 target: Some(actual_target),
8359 reason: PaneCancelReason::FocusLost
8360 } if actual_target == target
8361 ));
8362 assert_eq!(adapter.active_pointer_id(), None);
8363 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8364 }
8365
8366 #[test]
8367 fn pane_terminal_adapter_recovers_missing_mouse_up() {
8368 let mut adapter =
8369 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8370 let first_target = pane_target(SplitAxis::Horizontal);
8371 let second_target = pane_target(SplitAxis::Vertical);
8372
8373 let first_down = Event::Mouse(MouseEvent::new(
8374 MouseEventKind::Down(MouseButton::Left),
8375 5,
8376 5,
8377 ));
8378 let _ = adapter.translate(&first_down, Some(first_target));
8379
8380 let second_down = Event::Mouse(MouseEvent::new(
8381 MouseEventKind::Down(MouseButton::Left),
8382 8,
8383 11,
8384 ));
8385 let dispatch = adapter.translate(&second_down, Some(second_target));
8386 let recovery = dispatch
8387 .recovery_event
8388 .as_ref()
8389 .expect("recovery cancel expected");
8390 assert!(matches!(
8391 recovery.kind,
8392 PaneSemanticInputEventKind::Cancel {
8393 target: Some(actual_target),
8394 reason: PaneCancelReason::PointerCancel
8395 } if actual_target == first_target
8396 ));
8397 let primary = dispatch
8398 .primary_event
8399 .as_ref()
8400 .expect("second pointer down expected");
8401 assert!(matches!(
8402 primary.kind,
8403 PaneSemanticInputEventKind::PointerDown {
8404 target: actual_target,
8405 pointer_id: 1,
8406 button: PanePointerButton::Primary,
8407 position
8408 } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
8409 ));
8410 assert_eq!(recovery.sequence, 2);
8411 assert_eq!(primary.sequence, 3);
8412 assert!(matches!(
8413 dispatch.log.outcome,
8414 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
8415 ));
8416 assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
8417 }
8418
8419 #[test]
8420 fn pane_terminal_adapter_modifier_parity() {
8421 let mut adapter =
8422 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8423 let target = pane_target(SplitAxis::Horizontal);
8424
8425 let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
8426 .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
8427 let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
8428 let event = dispatch.primary_event.expect("semantic event");
8429 assert!(event.modifiers.shift);
8430 assert!(event.modifiers.alt);
8431 assert!(event.modifiers.ctrl);
8432 assert!(event.modifiers.meta);
8433 }
8434
8435 #[test]
8436 fn pane_terminal_adapter_keyboard_resize_mapping() {
8437 let mut adapter =
8438 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8439 let target = pane_target(SplitAxis::Horizontal);
8440
8441 let key = KeyEvent::new(KeyCode::Right);
8442 let dispatch = adapter.translate(&Event::Key(key), Some(target));
8443 let event = dispatch.primary_event.expect("keyboard resize event");
8444 assert!(matches!(
8445 event.kind,
8446 PaneSemanticInputEventKind::KeyboardResize {
8447 target: actual_target,
8448 direction: PaneResizeDirection::Increase,
8449 units: 1
8450 } if actual_target == target
8451 ));
8452
8453 let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
8454 let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
8455 let shifted_event = shifted_dispatch
8456 .primary_event
8457 .expect("shifted resize event");
8458 assert!(matches!(
8459 shifted_event.kind,
8460 PaneSemanticInputEventKind::KeyboardResize {
8461 direction: PaneResizeDirection::Increase,
8462 units: 5,
8463 ..
8464 }
8465 ));
8466 assert!(shifted_event.modifiers.shift);
8467 }
8468
8469 #[test]
8470 fn pane_terminal_adapter_keyboard_resize_requires_focus() {
8471 let mut adapter =
8472 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8473 let target = pane_target(SplitAxis::Horizontal);
8474
8475 let _ = adapter.translate(&Event::Focus(false), None);
8476 assert!(!adapter.window_focused());
8477
8478 let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8479 assert!(unfocused.primary_event.is_none());
8480 assert!(matches!(
8481 unfocused.log.outcome,
8482 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
8483 ));
8484
8485 let _ = adapter.translate(&Event::Focus(true), None);
8486 assert!(adapter.window_focused());
8487
8488 let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8489 assert!(focused.primary_event.is_some());
8490 }
8491
8492 #[test]
8493 fn pane_terminal_adapter_drag_updates_are_coalesced() {
8494 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
8495 drag_update_coalesce_distance: 2,
8496 ..PaneTerminalAdapterConfig::default()
8497 })
8498 .expect("valid adapter");
8499 let target = pane_target(SplitAxis::Horizontal);
8500
8501 let down = Event::Mouse(MouseEvent::new(
8502 MouseEventKind::Down(MouseButton::Left),
8503 10,
8504 4,
8505 ));
8506 let _ = adapter.translate(&down, Some(target));
8507
8508 let drag_start = Event::Mouse(MouseEvent::new(
8509 MouseEventKind::Drag(MouseButton::Left),
8510 14,
8511 4,
8512 ));
8513 let started = adapter.translate(&drag_start, None);
8514 assert!(started.primary_event.is_some());
8515 assert!(matches!(
8516 adapter.machine_state(),
8517 PaneDragResizeState::Dragging { .. }
8518 ));
8519
8520 let coalesced = Event::Mouse(MouseEvent::new(
8521 MouseEventKind::Drag(MouseButton::Left),
8522 15,
8523 4,
8524 ));
8525 let coalesced_dispatch = adapter.translate(&coalesced, None);
8526 assert!(coalesced_dispatch.primary_event.is_none());
8527 assert!(matches!(
8528 coalesced_dispatch.log.outcome,
8529 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
8530 ));
8531
8532 let forwarded = Event::Mouse(MouseEvent::new(
8533 MouseEventKind::Drag(MouseButton::Left),
8534 16,
8535 4,
8536 ));
8537 let forwarded_dispatch = adapter.translate(&forwarded, None);
8538 let forwarded_event = forwarded_dispatch
8539 .primary_event
8540 .as_ref()
8541 .expect("coalesced movement should flush once threshold reached");
8542 assert!(matches!(
8543 forwarded_event.kind,
8544 PaneSemanticInputEventKind::PointerMove {
8545 delta_x: 2,
8546 delta_y: 0,
8547 ..
8548 }
8549 ));
8550 }
8551
8552 #[test]
8553 fn pane_terminal_adapter_motion_tracks_direction_changes() {
8554 let mut adapter =
8555 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8556 let target = pane_target(SplitAxis::Horizontal);
8557
8558 let down = Event::Mouse(MouseEvent::new(
8559 MouseEventKind::Down(MouseButton::Left),
8560 10,
8561 4,
8562 ));
8563 let _ = adapter.translate(&down, Some(target));
8564
8565 let drag_forward = Event::Mouse(MouseEvent::new(
8566 MouseEventKind::Drag(MouseButton::Left),
8567 14,
8568 4,
8569 ));
8570 let forward_dispatch = adapter.translate(&drag_forward, None);
8571 let forward_motion = forward_dispatch
8572 .motion
8573 .expect("forward drag should emit motion metadata");
8574 assert_eq!(forward_motion.direction_changes, 0);
8575
8576 let drag_reverse = Event::Mouse(MouseEvent::new(
8577 MouseEventKind::Drag(MouseButton::Left),
8578 12,
8579 4,
8580 ));
8581 let reverse_dispatch = adapter.translate(&drag_reverse, None);
8582 let reverse_motion = reverse_dispatch
8583 .motion
8584 .expect("reverse drag should emit motion metadata");
8585 assert_eq!(reverse_motion.direction_changes, 1);
8586
8587 let up = Event::Mouse(MouseEvent::new(
8588 MouseEventKind::Up(MouseButton::Left),
8589 12,
8590 4,
8591 ));
8592 let up_dispatch = adapter.translate(&up, None);
8593 let up_motion = up_dispatch
8594 .motion
8595 .expect("release should include cumulative motion metadata");
8596 assert_eq!(up_motion.direction_changes, 1);
8597 }
8598
8599 #[test]
8600 fn pane_terminal_adapter_drag_and_moved_share_motion_bookkeeping() {
8601 let run = |moved: bool| {
8607 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8608 .expect("valid adapter");
8609 let target = pane_target(SplitAxis::Horizontal);
8610 let down = Event::Mouse(MouseEvent::new(
8611 MouseEventKind::Down(MouseButton::Left),
8612 10,
8613 4,
8614 ));
8615 let _ = adapter.translate(&down, Some(target));
8616 let mut motions = Vec::new();
8617 for (x, y) in [(14u16, 4u16), (12, 4), (16, 4)] {
8618 let kind = if moved {
8619 MouseEventKind::Moved
8620 } else {
8621 MouseEventKind::Drag(MouseButton::Left)
8622 };
8623 let dispatch = adapter.translate(&Event::Mouse(MouseEvent::new(kind, x, y)), None);
8624 assert!(dispatch.primary_event.is_some());
8625 motions.push(dispatch.motion.expect("motion metadata present"));
8626 }
8627 motions
8628 };
8629
8630 let via_drag = run(false);
8631 let via_moved = run(true);
8632 assert_eq!(via_drag, via_moved);
8633 assert_eq!(via_drag[1].direction_changes, 1);
8635 }
8636
8637 #[test]
8638 fn pane_terminal_adapter_translate_with_handles_resolves_target() {
8639 let tree = nested_pane_tree();
8640 let layout = tree
8641 .solve_layout(Rect::new(0, 0, 50, 20))
8642 .expect("layout should solve");
8643 let handles =
8644 pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
8645 let mut adapter =
8646 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8647
8648 let down = Event::Mouse(MouseEvent::new(
8649 MouseEventKind::Down(MouseButton::Left),
8650 25,
8651 10,
8652 ));
8653 let dispatch = adapter.translate_with_handles(&down, &handles);
8654 let event = dispatch
8655 .primary_event
8656 .as_ref()
8657 .expect("pointer down should be routed from handles");
8658 assert!(matches!(
8659 event.kind,
8660 PaneSemanticInputEventKind::PointerDown {
8661 target:
8662 PaneResizeTarget {
8663 split_id,
8664 axis: SplitAxis::Horizontal
8665 },
8666 ..
8667 } if split_id == pane_id(1)
8668 ));
8669 }
8670
8671 #[test]
8672 fn model_update() {
8673 let mut model = TestModel { value: 0 };
8674 model.update(TestMsg::Increment);
8675 assert_eq!(model.value, 1);
8676 model.update(TestMsg::Decrement);
8677 assert_eq!(model.value, 0);
8678 assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
8679 }
8680
8681 #[test]
8682 fn model_init_default() {
8683 let mut model = TestModel { value: 0 };
8684 let cmd = model.init();
8685 assert!(matches!(cmd, Cmd::None));
8686 }
8687
8688 #[test]
8695 fn cmd_sequence_executes_in_order() {
8696 use crate::simulator::ProgramSimulator;
8698
8699 struct SeqModel {
8700 trace: Vec<i32>,
8701 }
8702
8703 #[derive(Debug)]
8704 enum SeqMsg {
8705 Append(i32),
8706 TriggerSequence,
8707 }
8708
8709 impl From<Event> for SeqMsg {
8710 fn from(_: Event) -> Self {
8711 SeqMsg::Append(0)
8712 }
8713 }
8714
8715 impl Model for SeqModel {
8716 type Message = SeqMsg;
8717
8718 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8719 match msg {
8720 SeqMsg::Append(n) => {
8721 self.trace.push(n);
8722 Cmd::none()
8723 }
8724 SeqMsg::TriggerSequence => Cmd::sequence(vec![
8725 Cmd::msg(SeqMsg::Append(1)),
8726 Cmd::msg(SeqMsg::Append(2)),
8727 Cmd::msg(SeqMsg::Append(3)),
8728 ]),
8729 }
8730 }
8731
8732 fn view(&self, _frame: &mut Frame) {}
8733 }
8734
8735 let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
8736 sim.init();
8737 sim.send(SeqMsg::TriggerSequence);
8738
8739 assert_eq!(sim.model().trace, vec![1, 2, 3]);
8740 }
8741
8742 #[test]
8743 fn cmd_batch_executes_all_regardless_of_order() {
8744 use crate::simulator::ProgramSimulator;
8746
8747 struct BatchModel {
8748 values: Vec<i32>,
8749 }
8750
8751 #[derive(Debug)]
8752 enum BatchMsg {
8753 Add(i32),
8754 TriggerBatch,
8755 }
8756
8757 impl From<Event> for BatchMsg {
8758 fn from(_: Event) -> Self {
8759 BatchMsg::Add(0)
8760 }
8761 }
8762
8763 impl Model for BatchModel {
8764 type Message = BatchMsg;
8765
8766 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8767 match msg {
8768 BatchMsg::Add(n) => {
8769 self.values.push(n);
8770 Cmd::none()
8771 }
8772 BatchMsg::TriggerBatch => Cmd::batch(vec![
8773 Cmd::msg(BatchMsg::Add(10)),
8774 Cmd::msg(BatchMsg::Add(20)),
8775 Cmd::msg(BatchMsg::Add(30)),
8776 ]),
8777 }
8778 }
8779
8780 fn view(&self, _frame: &mut Frame) {}
8781 }
8782
8783 let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
8784 sim.init();
8785 sim.send(BatchMsg::TriggerBatch);
8786
8787 assert_eq!(sim.model().values.len(), 3);
8789 assert!(sim.model().values.contains(&10));
8790 assert!(sim.model().values.contains(&20));
8791 assert!(sim.model().values.contains(&30));
8792 }
8793
8794 #[test]
8795 fn cmd_sequence_stops_on_quit() {
8796 use crate::simulator::ProgramSimulator;
8798
8799 struct SeqQuitModel {
8800 trace: Vec<i32>,
8801 }
8802
8803 #[derive(Debug)]
8804 enum SeqQuitMsg {
8805 Append(i32),
8806 TriggerSequenceWithQuit,
8807 }
8808
8809 impl From<Event> for SeqQuitMsg {
8810 fn from(_: Event) -> Self {
8811 SeqQuitMsg::Append(0)
8812 }
8813 }
8814
8815 impl Model for SeqQuitModel {
8816 type Message = SeqQuitMsg;
8817
8818 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8819 match msg {
8820 SeqQuitMsg::Append(n) => {
8821 self.trace.push(n);
8822 Cmd::none()
8823 }
8824 SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
8825 Cmd::msg(SeqQuitMsg::Append(1)),
8826 Cmd::quit(),
8827 Cmd::msg(SeqQuitMsg::Append(2)), ]),
8829 }
8830 }
8831
8832 fn view(&self, _frame: &mut Frame) {}
8833 }
8834
8835 let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
8836 sim.init();
8837 sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
8838
8839 assert_eq!(sim.model().trace, vec![1]);
8840 assert!(!sim.is_running());
8841 }
8842
8843 #[test]
8844 fn identical_input_produces_identical_state() {
8845 use crate::simulator::ProgramSimulator;
8847
8848 fn run_scenario() -> Vec<i32> {
8849 struct DetModel {
8850 values: Vec<i32>,
8851 }
8852
8853 #[derive(Debug, Clone)]
8854 enum DetMsg {
8855 Add(i32),
8856 Double,
8857 }
8858
8859 impl From<Event> for DetMsg {
8860 fn from(_: Event) -> Self {
8861 DetMsg::Add(1)
8862 }
8863 }
8864
8865 impl Model for DetModel {
8866 type Message = DetMsg;
8867
8868 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8869 match msg {
8870 DetMsg::Add(n) => {
8871 self.values.push(n);
8872 Cmd::none()
8873 }
8874 DetMsg::Double => {
8875 if let Some(&last) = self.values.last() {
8876 self.values.push(last * 2);
8877 }
8878 Cmd::none()
8879 }
8880 }
8881 }
8882
8883 fn view(&self, _frame: &mut Frame) {}
8884 }
8885
8886 let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
8887 sim.init();
8888 sim.send(DetMsg::Add(5));
8889 sim.send(DetMsg::Double);
8890 sim.send(DetMsg::Add(3));
8891 sim.send(DetMsg::Double);
8892
8893 sim.model().values.clone()
8894 }
8895
8896 let run1 = run_scenario();
8898 let run2 = run_scenario();
8899 let run3 = run_scenario();
8900
8901 assert_eq!(run1, run2);
8902 assert_eq!(run2, run3);
8903 assert_eq!(run1, vec![5, 10, 3, 6]);
8904 }
8905
8906 #[test]
8907 fn identical_state_produces_identical_render() {
8908 use crate::simulator::ProgramSimulator;
8910
8911 struct RenderModel {
8912 counter: i32,
8913 }
8914
8915 #[derive(Debug)]
8916 enum RenderMsg {
8917 Set(i32),
8918 }
8919
8920 impl From<Event> for RenderMsg {
8921 fn from(_: Event) -> Self {
8922 RenderMsg::Set(0)
8923 }
8924 }
8925
8926 impl Model for RenderModel {
8927 type Message = RenderMsg;
8928
8929 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8930 match msg {
8931 RenderMsg::Set(n) => {
8932 self.counter = n;
8933 Cmd::none()
8934 }
8935 }
8936 }
8937
8938 fn view(&self, frame: &mut Frame) {
8939 let text = format!("Value: {}", self.counter);
8940 for (i, c) in text.chars().enumerate() {
8941 if (i as u16) < frame.width() {
8942 use ftui_render::cell::Cell;
8943 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
8944 }
8945 }
8946 }
8947 }
8948
8949 let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
8951 let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
8952
8953 let buf1 = sim1.capture_frame(80, 24);
8954 let buf2 = sim2.capture_frame(80, 24);
8955
8956 for y in 0..24 {
8958 for x in 0..80 {
8959 let cell1 = buf1.get(x, y).unwrap();
8960 let cell2 = buf2.get(x, y).unwrap();
8961 assert_eq!(
8962 cell1.content.as_char(),
8963 cell2.content.as_char(),
8964 "Mismatch at ({}, {})",
8965 x,
8966 y
8967 );
8968 }
8969 }
8970 }
8971
8972 #[test]
8975 fn cmd_log_creates_log_command() {
8976 let cmd: Cmd<TestMsg> = Cmd::log("test message");
8977 assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
8978 }
8979
8980 #[test]
8981 fn cmd_log_from_string() {
8982 let msg = String::from("dynamic message");
8983 let cmd: Cmd<TestMsg> = Cmd::log(msg);
8984 assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
8985 }
8986
8987 #[test]
8988 fn program_simulator_logs_jsonl_with_seed_and_run_id() {
8989 use crate::simulator::ProgramSimulator;
8991
8992 struct LogModel {
8993 run_id: &'static str,
8994 seed: u64,
8995 }
8996
8997 #[derive(Debug)]
8998 enum LogMsg {
8999 Emit,
9000 }
9001
9002 impl From<Event> for LogMsg {
9003 fn from(_: Event) -> Self {
9004 LogMsg::Emit
9005 }
9006 }
9007
9008 impl Model for LogModel {
9009 type Message = LogMsg;
9010
9011 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9012 let line = format!(
9013 r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
9014 self.run_id, self.seed
9015 );
9016 Cmd::log(line)
9017 }
9018
9019 fn view(&self, _frame: &mut Frame) {}
9020 }
9021
9022 let mut sim = ProgramSimulator::new(LogModel {
9023 run_id: "test-run-001",
9024 seed: 4242,
9025 });
9026 sim.init();
9027 sim.send(LogMsg::Emit);
9028
9029 let logs = sim.logs();
9030 assert_eq!(logs.len(), 1);
9031 assert!(logs[0].contains(r#""run_id":"test-run-001""#));
9032 assert!(logs[0].contains(r#""seed":4242"#));
9033 }
9034
9035 #[test]
9036 fn cmd_sequence_single_unwraps() {
9037 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
9038 assert!(matches!(cmd, Cmd::Quit));
9040 }
9041
9042 #[test]
9043 fn cmd_sequence_multiple() {
9044 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
9045 assert!(matches!(cmd, Cmd::Sequence(_)));
9046 }
9047
9048 #[test]
9049 fn cmd_default_is_none() {
9050 let cmd: Cmd<TestMsg> = Cmd::default();
9051 assert!(matches!(cmd, Cmd::None));
9052 }
9053
9054 #[test]
9055 fn cmd_debug_all_variants() {
9056 let none: Cmd<TestMsg> = Cmd::none();
9058 assert_eq!(format!("{none:?}"), "None");
9059
9060 let quit: Cmd<TestMsg> = Cmd::quit();
9061 assert_eq!(format!("{quit:?}"), "Quit");
9062
9063 let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
9064 assert!(format!("{msg:?}").starts_with("Msg("));
9065
9066 let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
9067 assert!(format!("{batch:?}").starts_with("Batch("));
9068
9069 let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
9070 assert!(format!("{seq:?}").starts_with("Sequence("));
9071
9072 let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
9073 assert!(format!("{tick:?}").starts_with("Tick("));
9074
9075 let log: Cmd<TestMsg> = Cmd::log("test");
9076 assert!(format!("{log:?}").starts_with("Log("));
9077 }
9078
9079 #[test]
9080 fn program_config_with_budget() {
9081 let budget = FrameBudgetConfig {
9082 total: Duration::from_millis(50),
9083 ..Default::default()
9084 };
9085 let config = ProgramConfig::default().with_budget(budget);
9086 assert_eq!(config.budget.total, Duration::from_millis(50));
9087 }
9088
9089 #[test]
9090 fn load_governor_default_is_enabled() {
9091 let config = LoadGovernorConfig::default();
9092 assert!(config.enabled);
9093 assert_eq!(
9094 config.budget_controller.degradation_floor,
9095 DegradationLevel::SimpleBorders
9096 );
9097 }
9098
9099 #[test]
9100 fn program_config_load_governor_builders() {
9101 let governor = LoadGovernorConfig::disabled().with_enabled(true);
9102 let config = ProgramConfig::default().with_load_governor(governor);
9103 assert!(config.load_governor.enabled);
9104
9105 let config = config.without_load_governor();
9106 assert!(!config.load_governor.enabled);
9107 }
9108
9109 fn governor_observation(
9110 frame_time_ms: f64,
9111 in_flight: u64,
9112 dropped: u64,
9113 degradation: DegradationLevel,
9114 resize_coalescing_active: bool,
9115 strict_semantics_violation: bool,
9116 ) -> LoadGovernorObservation {
9117 LoadGovernorObservation {
9118 frame_time_us: frame_time_ms * 1_000.0,
9119 budget_us: 16_000.0,
9120 degradation,
9121 queue: crate::effect_system::QueueTelemetry {
9122 enqueued: in_flight,
9125 processed: 0,
9126 dropped,
9127 high_water: in_flight,
9128 in_flight,
9129 },
9130 resize_coalescing_active,
9131 strict_semantics_violation,
9132 }
9133 }
9134
9135 fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
9136 let policy = LoadGovernorPolicy {
9137 recovery_intervals,
9138 ..Default::default()
9139 };
9140 LoadGovernorState::new(
9141 LoadGovernorConfig::enabled().with_policy(policy),
9142 max_queue_depth,
9143 )
9144 }
9145
9146 #[test]
9147 fn load_governor_policy_defaults_match_runtime_contract() {
9148 let policy = LoadGovernorPolicy::default().normalized();
9149
9150 assert_eq!(policy.stressed_queue_watermark, 0.5);
9151 assert_eq!(policy.degraded_queue_watermark, 0.8);
9152 assert_eq!(policy.recovery_queue_watermark, 0.25);
9153 assert_eq!(policy.recovery_intervals, 3);
9154 assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
9155 }
9156
9157 #[test]
9158 fn load_governor_classifies_queue_watermarks_and_recovery() {
9159 let mut governor = test_load_governor(100, 2);
9160
9161 let steady = governor.observe(governor_observation(
9162 8.0,
9163 0,
9164 0,
9165 DegradationLevel::Full,
9166 false,
9167 false,
9168 ));
9169 assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
9170 assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
9171 assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
9172
9173 let stressed = governor.observe(governor_observation(
9174 8.0,
9175 50,
9176 0,
9177 DegradationLevel::Full,
9178 false,
9179 false,
9180 ));
9181 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9182 assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
9183 assert_eq!(
9184 stressed.disposition,
9185 RuntimeWorkDisposition::CoalesceVisibleDeferBackground
9186 );
9187 assert_eq!(stressed.reason_code, "queue_stressed_watermark");
9188
9189 let degraded = governor.observe(governor_observation(
9190 8.0,
9191 80,
9192 0,
9193 DegradationLevel::Full,
9194 false,
9195 false,
9196 ));
9197 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9198 assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
9199 assert_eq!(
9200 degraded.disposition,
9201 RuntimeWorkDisposition::DeferBackgroundDropBestEffort
9202 );
9203 assert_eq!(degraded.reason_code, "queue_degraded_watermark");
9204
9205 let recovery_pending = governor.observe(governor_observation(
9206 8.0,
9207 10,
9208 0,
9209 DegradationLevel::Full,
9210 false,
9211 false,
9212 ));
9213 assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
9214 assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
9215 assert_eq!(recovery_pending.recovery_intervals_observed, 1);
9216
9217 let recovered = governor.observe(governor_observation(
9218 8.0,
9219 10,
9220 0,
9221 DegradationLevel::Full,
9222 false,
9223 false,
9224 ));
9225 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9226 assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
9227 assert_eq!(
9228 recovered.disposition,
9229 RuntimeWorkDisposition::ReadmitAfterHysteresis
9230 );
9231
9232 let healthy = governor.observe(governor_observation(
9233 8.0,
9234 10,
9235 0,
9236 DegradationLevel::Full,
9237 false,
9238 false,
9239 ));
9240 assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
9241 assert_eq!(healthy.reason_code, "recovered_interval_closed");
9242 }
9243
9244 #[test]
9245 fn load_governor_uses_uncapped_budget_pressure_fallback() {
9246 let mut governor = test_load_governor(0, 2);
9247
9248 let stressed = governor.observe(governor_observation(
9249 20.0,
9250 0,
9251 0,
9252 DegradationLevel::Full,
9253 false,
9254 false,
9255 ));
9256 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9257 assert_eq!(stressed.reason_code, "frame_budget_overrun");
9258 assert_eq!(stressed.queue_max_depth, None);
9259
9260 let degraded = governor.observe(governor_observation(
9261 8.0,
9262 0,
9263 0,
9264 DegradationLevel::SimpleBorders,
9265 false,
9266 false,
9267 ));
9268 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9269 assert_eq!(degraded.reason_code, "budget_degradation_active");
9270 }
9271
9272 #[test]
9273 fn load_governor_strict_semantics_failure_is_terminal() {
9274 let mut governor = test_load_governor(100, 2);
9275
9276 let unsafe_snapshot = governor.observe(governor_observation(
9277 8.0,
9278 0,
9279 0,
9280 DegradationLevel::Full,
9281 false,
9282 true,
9283 ));
9284
9285 assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
9286 assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
9287 assert_eq!(
9288 unsafe_snapshot.disposition,
9289 RuntimeWorkDisposition::FailFastStrictGuarantee
9290 );
9291 assert!(!unsafe_snapshot.strict_semantics_preserved);
9292 assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
9293 }
9294
9295 #[test]
9296 fn load_governor_unsafe_latches_through_later_pressure() {
9297 let mut governor = test_load_governor(100, 2);
9298
9299 let entered = governor.observe(governor_observation(
9301 8.0,
9302 0,
9303 0,
9304 DegradationLevel::Full,
9305 false,
9306 true,
9307 ));
9308 assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
9309
9310 let hard = governor.observe(governor_observation(
9313 8.0,
9314 90,
9315 0,
9316 DegradationLevel::SimpleBorders,
9317 false,
9318 false,
9319 ));
9320 assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
9321 assert_eq!(
9322 hard.disposition,
9323 RuntimeWorkDisposition::FailFastStrictGuarantee
9324 );
9325 assert!(!hard.strict_semantics_preserved);
9326 assert_eq!(hard.reason_code, "strict_semantics_violation");
9327
9328 let soft = governor.observe(governor_observation(
9331 8.0,
9332 60,
9333 0,
9334 DegradationLevel::Full,
9335 true,
9336 false,
9337 ));
9338 assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
9339
9340 let steady = governor.observe(governor_observation(
9342 8.0,
9343 0,
9344 0,
9345 DegradationLevel::Full,
9346 false,
9347 false,
9348 ));
9349 assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
9350 assert!(!steady.strict_semantics_preserved);
9351 }
9352
9353 #[test]
9354 fn load_governor_hysteresis_prevents_single_sample_recovery() {
9355 let mut governor = test_load_governor(100, 3);
9356
9357 governor.observe(governor_observation(
9358 8.0,
9359 90,
9360 0,
9361 DegradationLevel::Full,
9362 false,
9363 false,
9364 ));
9365
9366 for expected in 1..=2 {
9367 let snapshot = governor.observe(governor_observation(
9368 8.0,
9369 0,
9370 0,
9371 DegradationLevel::Full,
9372 false,
9373 false,
9374 ));
9375 assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
9376 assert_eq!(snapshot.recovery_intervals_observed, expected);
9377 assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
9378 }
9379
9380 let recovered = governor.observe(governor_observation(
9381 8.0,
9382 0,
9383 0,
9384 DegradationLevel::Full,
9385 false,
9386 false,
9387 ));
9388 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9389 }
9390
9391 #[test]
9392 fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
9393 let mut governor = test_load_governor(50, 2);
9394 let scenario = [
9395 governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
9396 governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
9397 governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
9398 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9399 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9400 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9401 ];
9402
9403 let mut modes = Vec::new();
9404 for observation in scenario {
9405 let snapshot = governor.observe(observation);
9406 modes.push(snapshot.mode);
9407 println!(
9408 "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
9409 snapshot.mode.as_str(),
9410 snapshot.pressure_class.as_str(),
9411 snapshot.disposition.as_str(),
9412 snapshot.reason_code,
9413 snapshot.transition,
9414 snapshot.deferred_work_total,
9415 snapshot.coalesced_work_total,
9416 snapshot.dropped_work_total
9417 );
9418 }
9419
9420 assert!(modes.contains(&RuntimeLoadMode::Stressed));
9421 assert!(modes.contains(&RuntimeLoadMode::Degraded));
9422 assert!(modes.contains(&RuntimeLoadMode::Recovered));
9423 assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
9424 }
9425
9426 #[test]
9427 fn headless_program_default_load_governor_attaches_controller() {
9428 let program =
9429 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9430 assert!(program.budget.controller().is_some());
9431 }
9432
9433 #[test]
9434 fn headless_program_load_governor_target_tracks_frame_budget() {
9435 let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
9436 total: Duration::from_millis(50),
9437 ..Default::default()
9438 });
9439 let program = headless_program_with_config(TestModel { value: 0 }, config);
9440 assert_eq!(
9441 program.budget.controller().unwrap().config().target,
9442 Duration::from_millis(50)
9443 );
9444 }
9445
9446 #[test]
9447 fn headless_program_without_load_governor_uses_legacy_budget() {
9448 let program = headless_program_with_config(
9449 TestModel { value: 0 },
9450 ProgramConfig::default().without_load_governor(),
9451 );
9452 assert!(program.budget.controller().is_none());
9453 }
9454
9455 #[test]
9456 fn app_builder_without_load_governor_sets_config() {
9457 let builder = App::new(TestModel { value: 0 }).without_load_governor();
9458 assert!(!builder.config.load_governor.enabled);
9459 }
9460
9461 #[test]
9462 fn program_config_with_conformal() {
9463 let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
9464 alpha: 0.2,
9465 ..Default::default()
9466 });
9467 assert!(config.conformal_config.is_some());
9468 assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
9469 }
9470
9471 #[test]
9472 fn program_config_forced_size_clamps_minimums() {
9473 let config = ProgramConfig::default().with_forced_size(0, 0);
9474 assert_eq!(config.forced_size, Some((1, 1)));
9475
9476 let cleared = config.without_forced_size();
9477 assert!(cleared.forced_size.is_none());
9478 }
9479
9480 #[test]
9481 fn effect_queue_config_defaults_are_safe() {
9482 let config = EffectQueueConfig::default();
9483 assert!(!config.enabled);
9484 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
9485 assert!(config.scheduler.smith_enabled);
9486 assert!(!config.scheduler.preemptive);
9487 assert_eq!(config.scheduler.aging_factor, 0.0);
9488 assert_eq!(config.scheduler.wait_starve_ms, 0.0);
9489 }
9490
9491 #[test]
9492 fn handle_effect_command_enqueues_or_executes_inline() {
9493 let (result_tx, result_rx) = mpsc::channel::<u32>();
9494 let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
9495 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9496
9497 let ran = Arc::new(AtomicUsize::new(0));
9498 let ran_task = ran.clone();
9499 let cmd = EffectCommand::Enqueue(
9500 TaskSpec::default(),
9501 Box::new(move || {
9502 ran_task.fetch_add(1, Ordering::SeqCst);
9503 7
9504 }),
9505 );
9506
9507 let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
9508 assert_eq!(shutdown, EffectLoopControl::Continue);
9509 assert_eq!(ran.load(Ordering::SeqCst), 0);
9510 assert_eq!(tasks.len(), 1);
9511 assert!(result_rx.try_recv().is_err());
9512
9513 let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
9514 max_queue_size: 0,
9515 ..Default::default()
9516 });
9517 let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9518 let ran_full = Arc::new(AtomicUsize::new(0));
9519 let ran_full_task = ran_full.clone();
9520 let cmd_full = EffectCommand::Enqueue(
9521 TaskSpec::default(),
9522 Box::new(move || {
9523 ran_full_task.fetch_add(1, Ordering::SeqCst);
9524 42
9525 }),
9526 );
9527
9528 let shutdown_full = handle_effect_command(
9529 cmd_full,
9530 &mut full_scheduler,
9531 &mut full_tasks,
9532 &result_tx,
9533 None,
9534 0,
9535 );
9536 assert_eq!(shutdown_full, EffectLoopControl::Continue);
9537 assert!(full_tasks.is_empty());
9538 assert_eq!(ran_full.load(Ordering::SeqCst), 1);
9539 assert_eq!(
9540 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9541 42
9542 );
9543
9544 let shutdown = handle_effect_command(
9545 EffectCommand::Shutdown,
9546 &mut full_scheduler,
9547 &mut full_tasks,
9548 &result_tx,
9549 None,
9550 0,
9551 );
9552 assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
9553 }
9554
9555 #[test]
9556 fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
9557 let evidence_path = temp_evidence_path("task_executor_backpressure");
9558 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
9559 let sink = EvidenceSink::from_config(&sink_config)
9560 .expect("evidence sink config")
9561 .expect("evidence sink enabled");
9562 let (result_tx, result_rx) = mpsc::channel::<u32>();
9563 let mut scheduler = QueueingScheduler::new(SchedulerConfig {
9564 max_queue_size: 0,
9565 ..Default::default()
9566 });
9567 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9568
9569 let shutdown = handle_effect_command(
9570 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
9571 &mut scheduler,
9572 &mut tasks,
9573 &result_tx,
9574 Some(&sink),
9575 0,
9576 );
9577
9578 assert_eq!(shutdown, EffectLoopControl::Continue);
9579 assert!(tasks.is_empty());
9580 assert_eq!(
9581 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9582 7
9583 );
9584
9585 let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
9586 assert_eq!(backpressure_line["backend"], "queued");
9587 assert_eq!(backpressure_line["action"], "inline_fallback");
9588 assert_eq!(backpressure_line["max_queue_size"], 0);
9589 assert_eq!(backpressure_line["total_rejected"], 1);
9590
9591 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
9592 assert_eq!(completion_line["backend"], "queued-inline-fallback");
9593 assert!(completion_line["duration_us"].is_number());
9594 }
9595
9596 #[test]
9597 fn effect_queue_loop_executes_tasks_and_shutdowns() {
9598 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9599 let (result_tx, result_rx) = mpsc::channel::<u32>();
9600 let config = EffectQueueConfig {
9601 enabled: true,
9602 backend: TaskExecutorBackend::EffectQueue,
9603 scheduler: SchedulerConfig {
9604 preemptive: false,
9605 ..Default::default()
9606 },
9607 explicit_backend: true,
9608 ..Default::default()
9609 };
9610
9611 let handle = std::thread::spawn(move || {
9612 effect_queue_loop(config, cmd_rx, result_tx, None);
9613 });
9614
9615 cmd_tx
9616 .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
9617 .unwrap();
9618 cmd_tx
9619 .send(EffectCommand::Enqueue(
9620 TaskSpec::new(2.0, 5.0).with_name("second"),
9621 Box::new(|| 20),
9622 ))
9623 .unwrap();
9624
9625 let mut results = vec![
9626 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9627 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9628 ];
9629 results.sort_unstable();
9630 assert_eq!(results, vec![10, 20]);
9631
9632 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9633 let _ = handle.join();
9634 }
9635
9636 #[test]
9637 fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
9638 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9639 let (result_tx, result_rx) = mpsc::channel::<u32>();
9640 let config = EffectQueueConfig {
9641 enabled: true,
9642 backend: TaskExecutorBackend::EffectQueue,
9643 scheduler: SchedulerConfig {
9644 preemptive: false,
9645 ..Default::default()
9646 },
9647 explicit_backend: true,
9648 ..Default::default()
9649 };
9650
9651 let handle = std::thread::spawn(move || {
9652 effect_queue_loop(config, cmd_rx, result_tx, None);
9653 });
9654
9655 cmd_tx
9656 .send(EffectCommand::Enqueue(
9657 TaskSpec::default().with_name("slow"),
9658 Box::new(|| {
9659 std::thread::sleep(Duration::from_millis(20));
9660 10
9661 }),
9662 ))
9663 .unwrap();
9664 cmd_tx
9665 .send(EffectCommand::Enqueue(
9666 TaskSpec::new(2.0, 5.0).with_name("fast"),
9667 Box::new(|| 20),
9668 ))
9669 .unwrap();
9670 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9671
9672 let mut results = vec![
9673 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9674 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9675 ];
9676 results.sort_unstable();
9677 assert_eq!(results, vec![10, 20]);
9678
9679 handle
9680 .join()
9681 .expect("effect queue thread joins after draining");
9682 }
9683
9684 #[test]
9685 fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
9686 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9687 let (result_tx, result_rx) = mpsc::channel::<u32>();
9688 let config = EffectQueueConfig {
9689 enabled: true,
9690 backend: TaskExecutorBackend::EffectQueue,
9691 scheduler: SchedulerConfig {
9692 preemptive: false,
9693 ..Default::default()
9694 },
9695 explicit_backend: true,
9696 ..Default::default()
9697 };
9698
9699 let handle = std::thread::spawn(move || {
9700 effect_queue_loop(config, cmd_rx, result_tx, None);
9701 });
9702
9703 cmd_tx
9704 .send(EffectCommand::Enqueue(
9705 TaskSpec::new(3.0, 1.0).with_name("panic"),
9706 Box::new(|| panic!("queued panic")),
9707 ))
9708 .unwrap();
9709 cmd_tx
9710 .send(EffectCommand::Enqueue(
9711 TaskSpec::new(1.0, 5.0).with_name("after"),
9712 Box::new(|| 99),
9713 ))
9714 .unwrap();
9715
9716 assert_eq!(
9717 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9718 99
9719 );
9720
9721 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9722 handle
9723 .join()
9724 .expect("effect queue thread survives task panic");
9725 }
9726
9727 #[test]
9728 fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
9729 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9730 let (result_tx, result_rx) = mpsc::channel::<u32>();
9731 let config = EffectQueueConfig {
9732 enabled: true,
9733 backend: TaskExecutorBackend::EffectQueue,
9734 scheduler: SchedulerConfig {
9735 preemptive: false,
9736 ..Default::default()
9737 },
9738 explicit_backend: true,
9739 ..Default::default()
9740 };
9741
9742 let handle = std::thread::spawn(move || {
9743 effect_queue_loop(config, cmd_rx, result_tx, None);
9744 });
9745
9746 cmd_tx
9747 .send(EffectCommand::Enqueue(
9748 TaskSpec::default().with_name("slow"),
9749 Box::new(|| {
9750 std::thread::sleep(Duration::from_millis(20));
9751 10
9752 }),
9753 ))
9754 .unwrap();
9755 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9756 cmd_tx
9757 .send(EffectCommand::Enqueue(
9758 TaskSpec::new(1.0, 1.0).with_name("late"),
9759 Box::new(|| 99),
9760 ))
9761 .unwrap();
9762
9763 assert_eq!(
9764 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9765 10
9766 );
9767 assert!(
9768 result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
9769 "post-shutdown enqueue should not execute"
9770 );
9771
9772 handle
9773 .join()
9774 .expect("effect queue thread joins after rejecting post-shutdown work");
9775 }
9776
9777 #[test]
9778 fn effect_queue_enqueue_after_shutdown_records_drop() {
9779 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
9780 drop(rx);
9781
9782 let queue = EffectQueue {
9783 sender: tx,
9784 handle: None,
9785 closed: true,
9786 };
9787 let runs = Arc::new(AtomicUsize::new(0));
9788 let before = crate::effect_system::effects_queue_dropped();
9789
9790 queue.enqueue(
9791 TaskSpec::default(),
9792 Box::new({
9793 let runs = Arc::clone(&runs);
9794 move || {
9795 runs.fetch_add(1, Ordering::SeqCst);
9796 7
9797 }
9798 }),
9799 );
9800
9801 let after = crate::effect_system::effects_queue_dropped();
9802 assert_eq!(runs.load(Ordering::SeqCst), 0);
9803 assert!(
9804 after > before,
9805 "enqueue after shutdown should increment dropped counter"
9806 );
9807 }
9808
9809 #[test]
9810 fn effect_queue_enqueue_with_closed_channel_records_drop() {
9811 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
9812 drop(rx);
9813
9814 let queue = EffectQueue {
9815 sender: tx,
9816 handle: None,
9817 closed: false,
9818 };
9819 let runs = Arc::new(AtomicUsize::new(0));
9820 let before = crate::effect_system::effects_queue_dropped();
9821
9822 queue.enqueue(
9823 TaskSpec::default(),
9824 Box::new({
9825 let runs = Arc::clone(&runs);
9826 move || {
9827 runs.fetch_add(1, Ordering::SeqCst);
9828 9
9829 }
9830 }),
9831 );
9832
9833 let after = crate::effect_system::effects_queue_dropped();
9834 assert_eq!(runs.load(Ordering::SeqCst), 0);
9835 assert!(
9836 after > before,
9837 "enqueue into a closed queue channel should increment dropped counter"
9838 );
9839 }
9840
9841 #[test]
9846 fn backpressure_drops_tasks_beyond_max_depth() {
9847 let (result_tx, _result_rx) = mpsc::channel::<u32>();
9848 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9849 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9850
9851 let r1 = handle_effect_command(
9853 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
9854 &mut scheduler,
9855 &mut tasks,
9856 &result_tx,
9857 None,
9858 2,
9859 );
9860 assert_eq!(r1, EffectLoopControl::Continue);
9861 assert_eq!(tasks.len(), 1);
9862
9863 let r2 = handle_effect_command(
9864 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
9865 &mut scheduler,
9866 &mut tasks,
9867 &result_tx,
9868 None,
9869 2,
9870 );
9871 assert_eq!(r2, EffectLoopControl::Continue);
9872 assert_eq!(tasks.len(), 2);
9873
9874 let dropped_before = crate::effect_system::effects_queue_dropped();
9876 let r3 = handle_effect_command(
9877 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
9878 &mut scheduler,
9879 &mut tasks,
9880 &result_tx,
9881 None,
9882 2,
9883 );
9884 assert_eq!(r3, EffectLoopControl::Continue);
9885 assert_eq!(
9886 tasks.len(),
9887 2,
9888 "task should have been dropped, not enqueued"
9889 );
9890 assert!(
9891 crate::effect_system::effects_queue_dropped() > dropped_before,
9892 "dropped counter should increment"
9893 );
9894 }
9895
9896 #[test]
9897 fn backpressure_zero_depth_means_unbounded() {
9898 let (result_tx, _result_rx) = mpsc::channel::<u32>();
9899 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
9900 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9901
9902 for i in 0..20 {
9904 let r = handle_effect_command(
9905 EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
9906 &mut scheduler,
9907 &mut tasks,
9908 &result_tx,
9909 None,
9910 0,
9911 );
9912 assert_eq!(r, EffectLoopControl::Continue);
9913 }
9914 }
9916
9917 #[test]
9918 fn inline_auto_remeasure_reset_clears_decision() {
9919 let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
9920 state.sampler.decide(Instant::now());
9921 assert!(state.sampler.last_decision().is_some());
9922
9923 state.reset();
9924 assert!(state.sampler.last_decision().is_none());
9925 }
9926
9927 #[test]
9928 fn budget_decision_jsonl_contains_required_fields() {
9929 let evidence = BudgetDecisionEvidence {
9930 frame_idx: 7,
9931 decision: BudgetDecision::Degrade,
9932 controller_decision: BudgetDecision::Hold,
9933 degradation_before: DegradationLevel::Full,
9934 degradation_after: DegradationLevel::NoStyling,
9935 frame_time_us: 12_345.678,
9936 budget_us: 16_000.0,
9937 pid_output: 1.25,
9938 pid_p: 0.5,
9939 pid_i: 0.25,
9940 pid_d: 0.5,
9941 e_value: 2.0,
9942 frames_observed: 42,
9943 frames_since_change: 3,
9944 in_warmup: false,
9945 controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
9946 load_governor: LoadGovernorSnapshot {
9947 mode: RuntimeLoadMode::Degraded,
9948 mode_before: RuntimeLoadMode::Stressed,
9949 pressure_class: RuntimePressureClass::HardOverload,
9950 disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
9951 reason_code: "budget_degradation_active",
9952 transition: true,
9953 strict_semantics_preserved: true,
9954 queue_in_flight: 8,
9955 queue_max_depth: Some(10),
9956 queue_dropped_delta: 0,
9957 resize_coalescing_active: false,
9958 recovery_intervals_observed: 0,
9959 recovery_intervals_required: 3,
9960 deferred_work_total: 2,
9961 coalesced_work_total: 1,
9962 dropped_work_total: 0,
9963 },
9964 conformal: Some(ConformalEvidence {
9965 bucket_key: "inline:dirty:10".to_string(),
9966 n_b: 32,
9967 alpha: 0.05,
9968 q_b: 1000.0,
9969 y_hat: 12_000.0,
9970 upper_us: 13_000.0,
9971 risk: true,
9972 fallback_level: 1,
9973 window_size: 256,
9974 reset_count: 2,
9975 }),
9976 };
9977
9978 let jsonl = evidence.to_jsonl();
9979 assert!(jsonl.contains("\"event\":\"budget_decision\""));
9980 assert!(jsonl.contains("\"decision\":\"degrade\""));
9981 assert!(jsonl.contains("\"decision_controller\":\"stay\""));
9982 assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
9983 assert!(jsonl.contains("\"degradation_before\":\"Full\""));
9984 assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
9985 assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
9986 assert!(jsonl.contains("\"budget_us\":16000.000000"));
9987 assert!(jsonl.contains("\"pid_output\":1.250000"));
9988 assert!(jsonl.contains("\"e_value\":2.000000"));
9989 assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
9990 assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
9991 assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
9992 assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
9993 assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
9994 assert!(jsonl.contains("\"governor_transition\":true"));
9995 assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
9996 assert!(jsonl.contains("\"queue_in_flight\":8"));
9997 assert!(jsonl.contains("\"queue_max_depth\":10"));
9998 assert!(jsonl.contains("\"deferred_work_total\":2"));
9999 assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
10000 assert!(jsonl.contains("\"n_b\":32"));
10001 assert!(jsonl.contains("\"alpha\":0.050000"));
10002 assert!(jsonl.contains("\"q_b\":1000.000000"));
10003 assert!(jsonl.contains("\"y_hat\":12000.000000"));
10004 assert!(jsonl.contains("\"upper_us\":13000.000000"));
10005 assert!(jsonl.contains("\"risk\":true"));
10006 assert!(jsonl.contains("\"fallback_level\":1"));
10007 assert!(jsonl.contains("\"window_size\":256"));
10008 assert!(jsonl.contains("\"reset_count\":2"));
10009 }
10010
10011 fn make_signal(
10012 widget_id: u64,
10013 essential: bool,
10014 priority: f32,
10015 staleness_ms: u64,
10016 cost_us: f32,
10017 ) -> WidgetSignal {
10018 WidgetSignal {
10019 widget_id,
10020 essential,
10021 priority,
10022 staleness_ms,
10023 focus_boost: 0.0,
10024 interaction_boost: 0.0,
10025 area_cells: 1,
10026 cost_estimate_us: cost_us,
10027 recent_cost_us: 0.0,
10028 estimate_source: CostEstimateSource::FixedDefault,
10029 }
10030 }
10031
10032 fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
10033 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
10034 let staleness_window = config.staleness_window_ms.max(1) as f32;
10035 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
10036 let mut value = config.weight_priority * signal.priority
10037 + config.weight_staleness * staleness_score
10038 + config.weight_focus * signal.focus_boost
10039 + config.weight_interaction * signal.interaction_boost;
10040 if starved {
10041 value += config.starve_boost;
10042 }
10043 let raw_cost = if signal.recent_cost_us > 0.0 {
10044 signal.recent_cost_us
10045 } else {
10046 signal.cost_estimate_us
10047 };
10048 let cost_us = raw_cost.max(config.min_cost_us);
10049 (value, cost_us, starved)
10050 }
10051
10052 fn fifo_select(
10053 signals: &[WidgetSignal],
10054 budget_us: f64,
10055 config: &WidgetRefreshConfig,
10056 ) -> (Vec<u64>, f64, usize) {
10057 let mut selected = Vec::new();
10058 let mut total_value = 0.0f64;
10059 let mut starved_selected = 0usize;
10060 let mut remaining = budget_us;
10061
10062 for signal in signals {
10063 if !signal.essential {
10064 continue;
10065 }
10066 let (value, cost_us, starved) = signal_value_cost(signal, config);
10067 remaining -= cost_us as f64;
10068 total_value += value as f64;
10069 if starved {
10070 starved_selected = starved_selected.saturating_add(1);
10071 }
10072 selected.push(signal.widget_id);
10073 }
10074 for signal in signals {
10075 if signal.essential {
10076 continue;
10077 }
10078 let (value, cost_us, starved) = signal_value_cost(signal, config);
10079 if remaining >= cost_us as f64 {
10080 remaining -= cost_us as f64;
10081 total_value += value as f64;
10082 if starved {
10083 starved_selected = starved_selected.saturating_add(1);
10084 }
10085 selected.push(signal.widget_id);
10086 }
10087 }
10088
10089 (selected, total_value, starved_selected)
10090 }
10091
10092 fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
10093 if signals.is_empty() {
10094 return Vec::new();
10095 }
10096 let mut rotated = Vec::with_capacity(signals.len());
10097 for idx in 0..signals.len() {
10098 rotated.push(signals[(idx + offset) % signals.len()].clone());
10099 }
10100 rotated
10101 }
10102
10103 #[test]
10104 fn widget_refresh_selects_essentials_first() {
10105 let signals = vec![
10106 make_signal(1, true, 0.6, 0, 5.0),
10107 make_signal(2, false, 0.9, 0, 4.0),
10108 ];
10109 let mut plan = WidgetRefreshPlan::new();
10110 let config = WidgetRefreshConfig::default();
10111 plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
10112 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10113 assert_eq!(selected, vec![1]);
10114 assert!(!plan.over_budget);
10115 }
10116
10117 #[test]
10118 fn widget_refresh_degradation_essential_only_skips_nonessential() {
10119 let signals = vec![
10120 make_signal(1, true, 0.5, 0, 2.0),
10121 make_signal(2, false, 1.0, 0, 1.0),
10122 ];
10123 let mut plan = WidgetRefreshPlan::new();
10124 let config = WidgetRefreshConfig::default();
10125 plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
10126 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10127 assert_eq!(selected, vec![1]);
10128 assert_eq!(plan.skipped_count, 1);
10129 }
10130
10131 #[test]
10132 fn widget_refresh_starvation_guard_forces_one_starved() {
10133 let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
10134 let mut plan = WidgetRefreshPlan::new();
10135 let config = WidgetRefreshConfig {
10136 starve_ms: 1_000,
10137 max_starved_per_frame: 1,
10138 ..Default::default()
10139 };
10140 plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
10141 assert_eq!(plan.selected.len(), 1);
10142 assert!(plan.selected[0].starved);
10143 assert!(plan.over_budget);
10144 }
10145
10146 #[test]
10147 fn widget_refresh_budget_blocks_when_no_selection() {
10148 let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
10149 let mut plan = WidgetRefreshPlan::new();
10150 let config = WidgetRefreshConfig {
10151 starve_ms: 0,
10152 max_starved_per_frame: 0,
10153 ..Default::default()
10154 };
10155 plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
10156 let budget = plan.as_budget();
10157 assert!(!budget.allows(42, false));
10158 }
10159
10160 #[test]
10161 fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
10162 let signals = vec![
10163 make_signal(1, false, 0.4, 0, 10.0),
10164 make_signal(2, false, 0.4, 0, 10.0),
10165 make_signal(3, false, 0.4, 0, 10.0),
10166 make_signal(4, false, 0.4, 0, 10.0),
10167 ];
10168 let mut plan = WidgetRefreshPlan::new();
10169 let config = WidgetRefreshConfig {
10170 starve_ms: 0,
10171 max_starved_per_frame: 0,
10172 max_drop_fraction: 0.5,
10173 ..Default::default()
10174 };
10175 plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
10176 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10177 assert_eq!(selected, vec![1, 2]);
10178 }
10179
10180 #[test]
10181 fn widget_refresh_greedy_beats_fifo_and_round_robin() {
10182 let signals = vec![
10183 make_signal(1, false, 0.1, 0, 6.0),
10184 make_signal(2, false, 0.2, 0, 6.0),
10185 make_signal(3, false, 1.0, 0, 4.0),
10186 make_signal(4, false, 0.9, 0, 3.0),
10187 make_signal(5, false, 0.8, 0, 3.0),
10188 make_signal(6, false, 0.1, 4_000, 2.0),
10189 ];
10190 let budget_us = 10.0;
10191 let config = WidgetRefreshConfig::default();
10192
10193 let mut plan = WidgetRefreshPlan::new();
10194 plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
10195 let greedy_value = plan.selected_value;
10196 let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10197
10198 let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
10199 let rotated = rotate_signals(&signals, 2);
10200 let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
10201
10202 assert!(
10203 greedy_value > fifo_value,
10204 "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
10205 greedy_selected,
10206 fifo_selected
10207 );
10208 assert!(
10209 greedy_value > rr_value,
10210 "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
10211 greedy_selected,
10212 rr_selected
10213 );
10214 assert!(
10215 plan.starved_selected > 0,
10216 "greedy did not select starved widget; greedy={:?}",
10217 greedy_selected
10218 );
10219 }
10220
10221 #[test]
10222 fn widget_refresh_jsonl_contains_required_fields() {
10223 let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
10224 let mut plan = WidgetRefreshPlan::new();
10225 let config = WidgetRefreshConfig::default();
10226 plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
10227 let jsonl = plan.to_jsonl();
10228 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
10229 assert!(jsonl.contains("\"frame_idx\":9"));
10230 assert!(jsonl.contains("\"selected_count\":1"));
10231 assert!(jsonl.contains("\"id\":7"));
10232 }
10233
10234 #[test]
10235 fn program_config_with_resize_coalescer() {
10236 let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
10237 steady_delay_ms: 8,
10238 burst_delay_ms: 20,
10239 hard_deadline_ms: 80,
10240 burst_enter_rate: 12.0,
10241 burst_exit_rate: 6.0,
10242 cooldown_frames: 2,
10243 rate_window_size: 6,
10244 enable_logging: true,
10245 enable_bocpd: false,
10246 bocpd_config: None,
10247 });
10248 assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
10249 assert!(config.resize_coalescer.enable_logging);
10250 }
10251
10252 #[test]
10253 fn program_config_with_resize_behavior() {
10254 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
10255 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10256 }
10257
10258 #[test]
10259 fn program_config_with_legacy_resize_enabled() {
10260 let config = ProgramConfig::default().with_legacy_resize(true);
10261 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10262 }
10263
10264 #[test]
10265 fn program_config_with_legacy_resize_disabled_keeps_default() {
10266 let config = ProgramConfig::default().with_legacy_resize(false);
10267 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
10268 }
10269
10270 fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
10271 let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
10272 let mut writer = TerminalWriter::with_diff_config(
10273 Vec::<u8>::new(),
10274 ScreenMode::AltScreen,
10275 UiAnchor::Bottom,
10276 TerminalCapabilities::basic(),
10277 config,
10278 );
10279 writer.set_size(8, 4);
10280
10281 let mut buffer = Buffer::new(8, 4);
10282 let mut trace = Vec::new();
10283
10284 writer.present_ui(&buffer, None, false).unwrap();
10285 trace.push(
10286 writer
10287 .last_diff_strategy()
10288 .unwrap_or(DiffStrategy::FullRedraw),
10289 );
10290
10291 buffer.set_raw(0, 0, Cell::from_char('A'));
10292 writer.present_ui(&buffer, None, false).unwrap();
10293 trace.push(
10294 writer
10295 .last_diff_strategy()
10296 .unwrap_or(DiffStrategy::FullRedraw),
10297 );
10298
10299 buffer.set_raw(1, 1, Cell::from_char('B'));
10300 writer.present_ui(&buffer, None, false).unwrap();
10301 trace.push(
10302 writer
10303 .last_diff_strategy()
10304 .unwrap_or(DiffStrategy::FullRedraw),
10305 );
10306
10307 trace
10308 }
10309
10310 fn coalescer_checksum(enable_bocpd: bool) -> String {
10311 let mut config = CoalescerConfig::default().with_logging(true);
10312 if enable_bocpd {
10313 config = config.with_bocpd();
10314 }
10315
10316 let base = Instant::now();
10317 let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
10318
10319 let events = [
10320 (0_u64, (82_u16, 24_u16)),
10321 (10, (83, 25)),
10322 (20, (84, 26)),
10323 (35, (90, 28)),
10324 (55, (92, 30)),
10325 ];
10326
10327 let mut idx = 0usize;
10328 for t_ms in (0_u64..=160).step_by(8) {
10329 let now = base + Duration::from_millis(t_ms);
10330 while idx < events.len() && events[idx].0 == t_ms {
10331 let (w, h) = events[idx].1;
10332 coalescer.handle_resize_at(w, h, now);
10333 idx += 1;
10334 }
10335 coalescer.tick_at(now);
10336 }
10337
10338 coalescer.decision_checksum_hex()
10339 }
10340
10341 fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
10342 if !enabled {
10343 return Vec::new();
10344 }
10345
10346 let mut predictor = ConformalPredictor::new(ConformalConfig::default());
10347 let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
10348 let mut trace = Vec::new();
10349
10350 for i in 0..30 {
10351 let y_hat = 16_000.0 + (i as f64) * 15.0;
10352 let observed = y_hat + (i % 7) as f64 * 120.0;
10353 predictor.observe(key, y_hat, observed);
10354 let prediction = predictor.predict(key, y_hat, 20_000.0);
10355 trace.push((prediction.upper_us, prediction.risk));
10356 }
10357
10358 trace
10359 }
10360
10361 #[test]
10362 fn policy_toggle_matrix_determinism() {
10363 for &bayesian in &[false, true] {
10364 for &bocpd in &[false, true] {
10365 for &conformal in &[false, true] {
10366 let diff_a = diff_strategy_trace(bayesian);
10367 let diff_b = diff_strategy_trace(bayesian);
10368 assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
10369
10370 let checksum_a = coalescer_checksum(bocpd);
10371 let checksum_b = coalescer_checksum(bocpd);
10372 assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
10373
10374 let conf_a = conformal_trace(conformal);
10375 let conf_b = conformal_trace(conformal);
10376 assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
10377
10378 if conformal {
10379 assert!(!conf_a.is_empty(), "conformal trace should be populated");
10380 } else {
10381 assert!(conf_a.is_empty(), "conformal trace should be empty");
10382 }
10383 }
10384 }
10385 }
10386 }
10387
10388 #[test]
10389 fn resize_behavior_uses_coalescer_flag() {
10390 assert!(ResizeBehavior::Throttled.uses_coalescer());
10391 assert!(!ResizeBehavior::Immediate.uses_coalescer());
10392 }
10393
10394 #[test]
10395 fn nested_cmd_msg_executes_recursively() {
10396 use crate::simulator::ProgramSimulator;
10398
10399 struct NestedModel {
10400 depth: usize,
10401 }
10402
10403 #[derive(Debug)]
10404 enum NestedMsg {
10405 Nest(usize),
10406 }
10407
10408 impl From<Event> for NestedMsg {
10409 fn from(_: Event) -> Self {
10410 NestedMsg::Nest(0)
10411 }
10412 }
10413
10414 impl Model for NestedModel {
10415 type Message = NestedMsg;
10416
10417 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10418 match msg {
10419 NestedMsg::Nest(n) => {
10420 self.depth += 1;
10421 if n > 0 {
10422 Cmd::msg(NestedMsg::Nest(n - 1))
10423 } else {
10424 Cmd::none()
10425 }
10426 }
10427 }
10428 }
10429
10430 fn view(&self, _frame: &mut Frame) {}
10431 }
10432
10433 let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
10434 sim.init();
10435 sim.send(NestedMsg::Nest(3));
10436
10437 assert_eq!(sim.model().depth, 4);
10439 }
10440
10441 #[test]
10442 fn task_executes_synchronously_in_simulator() {
10443 use crate::simulator::ProgramSimulator;
10445
10446 struct TaskModel {
10447 completed: bool,
10448 }
10449
10450 #[derive(Debug)]
10451 enum TaskMsg {
10452 Complete,
10453 SpawnTask,
10454 }
10455
10456 impl From<Event> for TaskMsg {
10457 fn from(_: Event) -> Self {
10458 TaskMsg::Complete
10459 }
10460 }
10461
10462 impl Model for TaskModel {
10463 type Message = TaskMsg;
10464
10465 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10466 match msg {
10467 TaskMsg::Complete => {
10468 self.completed = true;
10469 Cmd::none()
10470 }
10471 TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
10472 }
10473 }
10474
10475 fn view(&self, _frame: &mut Frame) {}
10476 }
10477
10478 let mut sim = ProgramSimulator::new(TaskModel { completed: false });
10479 sim.init();
10480 sim.send(TaskMsg::SpawnTask);
10481
10482 assert!(sim.model().completed);
10484 }
10485
10486 #[test]
10487 fn multiple_updates_accumulate_correctly() {
10488 use crate::simulator::ProgramSimulator;
10490
10491 struct AccumModel {
10492 sum: i32,
10493 }
10494
10495 #[derive(Debug)]
10496 enum AccumMsg {
10497 Add(i32),
10498 Multiply(i32),
10499 }
10500
10501 impl From<Event> for AccumMsg {
10502 fn from(_: Event) -> Self {
10503 AccumMsg::Add(1)
10504 }
10505 }
10506
10507 impl Model for AccumModel {
10508 type Message = AccumMsg;
10509
10510 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10511 match msg {
10512 AccumMsg::Add(n) => {
10513 self.sum += n;
10514 Cmd::none()
10515 }
10516 AccumMsg::Multiply(n) => {
10517 self.sum *= n;
10518 Cmd::none()
10519 }
10520 }
10521 }
10522
10523 fn view(&self, _frame: &mut Frame) {}
10524 }
10525
10526 let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
10527 sim.init();
10528
10529 sim.send(AccumMsg::Add(5));
10531 sim.send(AccumMsg::Multiply(2));
10532 sim.send(AccumMsg::Add(3));
10533
10534 assert_eq!(sim.model().sum, 13);
10535 }
10536
10537 #[test]
10538 fn init_command_executes_before_first_update() {
10539 use crate::simulator::ProgramSimulator;
10541
10542 struct InitModel {
10543 initialized: bool,
10544 updates: usize,
10545 }
10546
10547 #[derive(Debug)]
10548 enum InitMsg {
10549 Update,
10550 MarkInit,
10551 }
10552
10553 impl From<Event> for InitMsg {
10554 fn from(_: Event) -> Self {
10555 InitMsg::Update
10556 }
10557 }
10558
10559 impl Model for InitModel {
10560 type Message = InitMsg;
10561
10562 fn init(&mut self) -> Cmd<Self::Message> {
10563 Cmd::msg(InitMsg::MarkInit)
10564 }
10565
10566 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10567 match msg {
10568 InitMsg::MarkInit => {
10569 self.initialized = true;
10570 Cmd::none()
10571 }
10572 InitMsg::Update => {
10573 self.updates += 1;
10574 Cmd::none()
10575 }
10576 }
10577 }
10578
10579 fn view(&self, _frame: &mut Frame) {}
10580 }
10581
10582 let mut sim = ProgramSimulator::new(InitModel {
10583 initialized: false,
10584 updates: 0,
10585 });
10586 sim.init();
10587
10588 assert!(sim.model().initialized);
10589 sim.send(InitMsg::Update);
10590 assert_eq!(sim.model().updates, 1);
10591 }
10592
10593 #[test]
10598 fn ui_height_returns_correct_value_inline_mode() {
10599 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10601 use ftui_core::terminal_capabilities::TerminalCapabilities;
10602
10603 let output = Vec::new();
10604 let writer = TerminalWriter::new(
10605 output,
10606 ScreenMode::Inline { ui_height: 10 },
10607 UiAnchor::Bottom,
10608 TerminalCapabilities::basic(),
10609 );
10610 assert_eq!(writer.ui_height(), 10);
10611 }
10612
10613 #[test]
10614 fn ui_height_returns_term_height_altscreen_mode() {
10615 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10617 use ftui_core::terminal_capabilities::TerminalCapabilities;
10618
10619 let output = Vec::new();
10620 let mut writer = TerminalWriter::new(
10621 output,
10622 ScreenMode::AltScreen,
10623 UiAnchor::Bottom,
10624 TerminalCapabilities::basic(),
10625 );
10626 writer.set_size(80, 24);
10627 assert_eq!(writer.ui_height(), 24);
10628 }
10629
10630 #[test]
10631 fn inline_mode_frame_uses_ui_height_not_terminal_height() {
10632 use crate::simulator::ProgramSimulator;
10635 use std::cell::Cell as StdCell;
10636
10637 thread_local! {
10638 static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
10639 }
10640
10641 struct FrameSizeTracker;
10642
10643 #[derive(Debug)]
10644 enum SizeMsg {
10645 Check,
10646 }
10647
10648 impl From<Event> for SizeMsg {
10649 fn from(_: Event) -> Self {
10650 SizeMsg::Check
10651 }
10652 }
10653
10654 impl Model for FrameSizeTracker {
10655 type Message = SizeMsg;
10656
10657 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10658 Cmd::none()
10659 }
10660
10661 fn view(&self, frame: &mut Frame) {
10662 CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
10664 }
10665 }
10666
10667 let mut sim = ProgramSimulator::new(FrameSizeTracker);
10669 sim.init();
10670
10671 let buf = sim.capture_frame(80, 10);
10673 assert_eq!(buf.height(), 10);
10674 assert_eq!(buf.width(), 80);
10675
10676 }
10680
10681 #[test]
10682 fn altscreen_frame_uses_full_terminal_height() {
10683 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10685 use ftui_core::terminal_capabilities::TerminalCapabilities;
10686
10687 let output = Vec::new();
10688 let mut writer = TerminalWriter::new(
10689 output,
10690 ScreenMode::AltScreen,
10691 UiAnchor::Bottom,
10692 TerminalCapabilities::basic(),
10693 );
10694 writer.set_size(80, 40);
10695
10696 assert_eq!(writer.ui_height(), 40);
10698 }
10699
10700 #[test]
10701 fn ui_height_clamped_to_terminal_height() {
10702 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10705 use ftui_core::terminal_capabilities::TerminalCapabilities;
10706
10707 let output = Vec::new();
10708 let mut writer = TerminalWriter::new(
10709 output,
10710 ScreenMode::Inline { ui_height: 100 },
10711 UiAnchor::Bottom,
10712 TerminalCapabilities::basic(),
10713 );
10714 writer.set_size(80, 10);
10715
10716 assert_eq!(writer.ui_height(), 100);
10722 }
10723
10724 #[test]
10729 fn tick_event_delivered_to_model_update() {
10730 use crate::simulator::ProgramSimulator;
10733
10734 struct TickTracker {
10735 tick_count: usize,
10736 }
10737
10738 #[derive(Debug)]
10739 enum TickMsg {
10740 Tick,
10741 Other,
10742 }
10743
10744 impl From<Event> for TickMsg {
10745 fn from(event: Event) -> Self {
10746 match event {
10747 Event::Tick => TickMsg::Tick,
10748 _ => TickMsg::Other,
10749 }
10750 }
10751 }
10752
10753 impl Model for TickTracker {
10754 type Message = TickMsg;
10755
10756 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10757 match msg {
10758 TickMsg::Tick => {
10759 self.tick_count += 1;
10760 Cmd::none()
10761 }
10762 TickMsg::Other => Cmd::none(),
10763 }
10764 }
10765
10766 fn view(&self, _frame: &mut Frame) {}
10767 }
10768
10769 let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
10770 sim.init();
10771
10772 sim.inject_event(Event::Tick);
10774 assert_eq!(sim.model().tick_count, 1);
10775
10776 sim.inject_event(Event::Tick);
10777 sim.inject_event(Event::Tick);
10778 assert_eq!(sim.model().tick_count, 3);
10779 }
10780
10781 #[test]
10782 fn tick_command_sets_tick_rate() {
10783 use crate::simulator::{CmdRecord, ProgramSimulator};
10785
10786 struct TickModel;
10787
10788 #[derive(Debug)]
10789 enum Msg {
10790 SetTick,
10791 Noop,
10792 }
10793
10794 impl From<Event> for Msg {
10795 fn from(_: Event) -> Self {
10796 Msg::Noop
10797 }
10798 }
10799
10800 impl Model for TickModel {
10801 type Message = Msg;
10802
10803 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10804 match msg {
10805 Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
10806 Msg::Noop => Cmd::none(),
10807 }
10808 }
10809
10810 fn view(&self, _frame: &mut Frame) {}
10811 }
10812
10813 let mut sim = ProgramSimulator::new(TickModel);
10814 sim.init();
10815 sim.send(Msg::SetTick);
10816
10817 let commands = sim.command_log();
10819 assert!(
10820 commands
10821 .iter()
10822 .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
10823 );
10824 }
10825
10826 #[test]
10827 fn tick_can_trigger_further_commands() {
10828 use crate::simulator::ProgramSimulator;
10830
10831 struct ChainModel {
10832 stage: usize,
10833 }
10834
10835 #[derive(Debug)]
10836 enum ChainMsg {
10837 Tick,
10838 Advance,
10839 Noop,
10840 }
10841
10842 impl From<Event> for ChainMsg {
10843 fn from(event: Event) -> Self {
10844 match event {
10845 Event::Tick => ChainMsg::Tick,
10846 _ => ChainMsg::Noop,
10847 }
10848 }
10849 }
10850
10851 impl Model for ChainModel {
10852 type Message = ChainMsg;
10853
10854 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10855 match msg {
10856 ChainMsg::Tick => {
10857 self.stage += 1;
10858 Cmd::msg(ChainMsg::Advance)
10860 }
10861 ChainMsg::Advance => {
10862 self.stage += 10;
10863 Cmd::none()
10864 }
10865 ChainMsg::Noop => Cmd::none(),
10866 }
10867 }
10868
10869 fn view(&self, _frame: &mut Frame) {}
10870 }
10871
10872 let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
10873 sim.init();
10874 sim.inject_event(Event::Tick);
10875
10876 assert_eq!(sim.model().stage, 11);
10878 }
10879
10880 #[test]
10881 fn tick_disabled_with_zero_duration() {
10882 use crate::simulator::ProgramSimulator;
10884
10885 struct ZeroTickModel {
10886 disabled: bool,
10887 }
10888
10889 #[derive(Debug)]
10890 enum ZeroMsg {
10891 DisableTick,
10892 Noop,
10893 }
10894
10895 impl From<Event> for ZeroMsg {
10896 fn from(_: Event) -> Self {
10897 ZeroMsg::Noop
10898 }
10899 }
10900
10901 impl Model for ZeroTickModel {
10902 type Message = ZeroMsg;
10903
10904 fn init(&mut self) -> Cmd<Self::Message> {
10905 Cmd::tick(Duration::from_millis(100))
10907 }
10908
10909 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10910 match msg {
10911 ZeroMsg::DisableTick => {
10912 self.disabled = true;
10913 Cmd::tick(Duration::ZERO)
10915 }
10916 ZeroMsg::Noop => Cmd::none(),
10917 }
10918 }
10919
10920 fn view(&self, _frame: &mut Frame) {}
10921 }
10922
10923 let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
10924 sim.init();
10925
10926 assert!(sim.tick_rate().is_some());
10928 assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
10929
10930 sim.send(ZeroMsg::DisableTick);
10932 assert!(sim.model().disabled);
10933
10934 assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
10937 }
10938
10939 #[test]
10940 fn tick_event_distinguishable_from_other_events() {
10941 let tick = Event::Tick;
10943 let key = Event::Key(ftui_core::event::KeyEvent::new(
10944 ftui_core::event::KeyCode::Char('a'),
10945 ));
10946
10947 assert!(matches!(tick, Event::Tick));
10948 assert!(!matches!(key, Event::Tick));
10949 }
10950
10951 #[test]
10952 fn tick_event_clone_and_eq() {
10953 let tick1 = Event::Tick;
10955 let tick2 = tick1.clone();
10956 assert_eq!(tick1, tick2);
10957 }
10958
10959 #[test]
10960 fn model_receives_tick_and_input_events() {
10961 use crate::simulator::ProgramSimulator;
10963
10964 struct MixedModel {
10965 ticks: usize,
10966 keys: usize,
10967 }
10968
10969 #[derive(Debug)]
10970 enum MixedMsg {
10971 Tick,
10972 Key,
10973 }
10974
10975 impl From<Event> for MixedMsg {
10976 fn from(event: Event) -> Self {
10977 match event {
10978 Event::Tick => MixedMsg::Tick,
10979 _ => MixedMsg::Key,
10980 }
10981 }
10982 }
10983
10984 impl Model for MixedModel {
10985 type Message = MixedMsg;
10986
10987 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10988 match msg {
10989 MixedMsg::Tick => {
10990 self.ticks += 1;
10991 Cmd::none()
10992 }
10993 MixedMsg::Key => {
10994 self.keys += 1;
10995 Cmd::none()
10996 }
10997 }
10998 }
10999
11000 fn view(&self, _frame: &mut Frame) {}
11001 }
11002
11003 let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
11004 sim.init();
11005
11006 sim.inject_event(Event::Tick);
11008 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11009 ftui_core::event::KeyCode::Char('a'),
11010 )));
11011 sim.inject_event(Event::Tick);
11012 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11013 ftui_core::event::KeyCode::Char('b'),
11014 )));
11015 sim.inject_event(Event::Tick);
11016
11017 assert_eq!(sim.model().ticks, 3);
11018 assert_eq!(sim.model().keys, 2);
11019 }
11020
11021 fn headless_program_with_resolved_config<M: Model>(
11026 model: M,
11027 config: ProgramConfig,
11028 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11029 where
11030 M::Message: Send + 'static,
11031 {
11032 clear_termination_signal();
11033 let effect_queue_config = config.resolved_effect_queue_config();
11034 let capabilities = TerminalCapabilities::basic();
11035 let mut writer = TerminalWriter::with_diff_config(
11036 Vec::new(),
11037 config.screen_mode,
11038 config.ui_anchor,
11039 capabilities,
11040 config.diff_config.clone(),
11041 );
11042 let frame_timing = config.frame_timing.clone();
11043 writer.set_timing_enabled(frame_timing.is_some());
11044
11045 let (width, height) = config.forced_size.unwrap_or((80, 24));
11046 let width = width.max(1);
11047 let height = height.max(1);
11048 writer.set_size(width, height);
11049
11050 let mouse_capture = config.resolved_mouse_capture();
11051 let initial_features = BackendFeatures {
11052 mouse_capture,
11053 bracketed_paste: config.bracketed_paste,
11054 focus_events: config.focus_reporting,
11055 kitty_keyboard: config.kitty_keyboard,
11056 };
11057 let events = HeadlessEventSource::new(width, height, initial_features);
11058 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
11059 .expect("headless evidence sink config");
11060
11061 let budget = render_budget_from_program_config(&config);
11062 let load_governor = LoadGovernorState::new(
11063 config.load_governor.clone(),
11064 effect_queue_config.max_queue_depth,
11065 );
11066 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
11067 let locale_context = config.locale_context.clone();
11068 let locale_version = locale_context.version();
11069 let mut resize_coalescer =
11070 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
11071 if let Some(ref sink) = evidence_sink {
11072 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
11073 }
11074 let subscriptions = SubscriptionManager::new();
11075 let (task_sender, task_receiver) = std::sync::mpsc::channel();
11076 let inline_auto_remeasure = config
11077 .inline_auto_remeasure
11078 .clone()
11079 .map(InlineAutoRemeasureState::new);
11080 let guardrails = FrameGuardrails::new(config.guardrails);
11081 let task_executor = TaskExecutor::new(
11082 &effect_queue_config,
11083 task_sender.clone(),
11084 evidence_sink.clone(),
11085 )
11086 .expect("task executor");
11087
11088 Program {
11089 model,
11090 writer,
11091 events,
11092 backend_features: initial_features,
11093 running: true,
11094 tick_rate: None,
11095 executed_cmd_count: 0,
11096 last_tick: Instant::now(),
11097 dirty: true,
11098 frame_idx: 0,
11099 tick_count: 0,
11100 widget_signals: Vec::new(),
11101 widget_refresh_config: config.widget_refresh,
11102 widget_refresh_plan: WidgetRefreshPlan::new(),
11103 width,
11104 height,
11105 forced_size: config.forced_size,
11106 poll_timeout: config.poll_timeout,
11107 intercept_signals: config.intercept_signals,
11108 immediate_drain_config: config.immediate_drain,
11109 immediate_drain_stats: ImmediateDrainStats::default(),
11110 budget,
11111 load_governor,
11112 conformal_predictor,
11113 last_frame_time_us: None,
11114 last_update_us: None,
11115 frame_timing,
11116 locale_context,
11117 locale_version,
11118 resize_coalescer,
11119 evidence_sink,
11120 fairness_config_logged: false,
11121 resize_behavior: config.resize_behavior,
11122 fairness_guard: InputFairnessGuard::new(),
11123 event_recorder: None,
11124 subscriptions,
11125 #[cfg(test)]
11126 task_sender,
11127 task_receiver,
11128 task_executor,
11129 state_registry: config.persistence.registry.clone(),
11130 persistence_config: config.persistence,
11131 last_checkpoint: Instant::now(),
11132 inline_auto_remeasure,
11133 frame_arena: FrameArena::default(),
11134 guardrails,
11135 tick_strategy: config
11136 .tick_strategy
11137 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
11138 last_active_screen_for_strategy: None,
11139 }
11140 }
11141
11142 fn headless_program_with_config<M: Model>(
11143 model: M,
11144 config: ProgramConfig,
11145 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11146 where
11147 M::Message: Send + 'static,
11148 {
11149 headless_program_with_resolved_config(model, config.with_signal_interception(false))
11152 }
11153
11154 fn headless_signal_program_with_config<M: Model>(
11155 model: M,
11156 config: ProgramConfig,
11157 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11158 where
11159 M::Message: Send + 'static,
11160 {
11161 headless_program_with_resolved_config(model, config)
11162 }
11163
11164 fn temp_evidence_path(label: &str) -> PathBuf {
11165 static COUNTER: AtomicUsize = AtomicUsize::new(0);
11166 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
11167 let pid = std::process::id();
11168 let mut path = std::env::temp_dir();
11169 path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
11170 path
11171 }
11172
11173 fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
11174 let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
11175 let needle = format!("\"event\":\"{event}\"");
11176 let missing_msg = format!("missing {event} line");
11177 let line = jsonl
11178 .lines()
11179 .find(|line| line.contains(&needle))
11180 .expect(&missing_msg);
11181 serde_json::from_str(line).expect("valid evidence json")
11182 }
11183
11184 #[test]
11185 fn headless_apply_resize_updates_model_and_dimensions() {
11186 struct ResizeModel {
11187 last_size: Option<(u16, u16)>,
11188 }
11189
11190 #[derive(Debug)]
11191 enum ResizeMsg {
11192 Resize(u16, u16),
11193 Other,
11194 }
11195
11196 impl From<Event> for ResizeMsg {
11197 fn from(event: Event) -> Self {
11198 match event {
11199 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11200 _ => ResizeMsg::Other,
11201 }
11202 }
11203 }
11204
11205 impl Model for ResizeModel {
11206 type Message = ResizeMsg;
11207
11208 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11209 if let ResizeMsg::Resize(w, h) = msg {
11210 self.last_size = Some((w, h));
11211 }
11212 Cmd::none()
11213 }
11214
11215 fn view(&self, _frame: &mut Frame) {}
11216 }
11217
11218 let mut program =
11219 headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
11220 program.dirty = false;
11221
11222 program
11223 .apply_resize(0, 0, Duration::ZERO, false)
11224 .expect("resize");
11225
11226 assert_eq!(program.width, 1);
11227 assert_eq!(program.height, 1);
11228 assert_eq!(program.model().last_size, Some((1, 1)));
11229 assert!(program.dirty);
11230 }
11231
11232 #[test]
11233 fn headless_apply_resize_reconciles_subscriptions() {
11234 use crate::subscription::{StopSignal, SubId, Subscription};
11235
11236 struct ResizeSubModel {
11237 subscribed: bool,
11238 }
11239
11240 #[derive(Debug)]
11241 enum ResizeSubMsg {
11242 Resize,
11243 Other,
11244 }
11245
11246 impl From<Event> for ResizeSubMsg {
11247 fn from(event: Event) -> Self {
11248 match event {
11249 Event::Resize { .. } => Self::Resize,
11250 _ => Self::Other,
11251 }
11252 }
11253 }
11254
11255 impl Model for ResizeSubModel {
11256 type Message = ResizeSubMsg;
11257
11258 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11259 if matches!(msg, ResizeSubMsg::Resize) {
11260 self.subscribed = true;
11261 }
11262 Cmd::none()
11263 }
11264
11265 fn view(&self, _frame: &mut Frame) {}
11266
11267 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11268 if self.subscribed {
11269 vec![Box::new(ResizeSubscription)]
11270 } else {
11271 vec![]
11272 }
11273 }
11274 }
11275
11276 struct ResizeSubscription;
11277
11278 impl Subscription<ResizeSubMsg> for ResizeSubscription {
11279 fn id(&self) -> SubId {
11280 1
11281 }
11282
11283 fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
11284 }
11285
11286 let mut program = headless_program_with_config(
11287 ResizeSubModel { subscribed: false },
11288 ProgramConfig::default(),
11289 );
11290
11291 assert_eq!(program.subscriptions.active_count(), 0);
11292 program
11293 .apply_resize(120, 40, Duration::ZERO, false)
11294 .expect("resize");
11295
11296 assert!(program.model().subscribed);
11297 assert_eq!(program.subscriptions.active_count(), 1);
11298 }
11299
11300 #[test]
11301 fn headless_execute_cmd_log_writes_output() {
11302 let mut program =
11303 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11304 program.execute_cmd(Cmd::log("hello world")).expect("log");
11305
11306 let bytes = program.writer.into_inner().expect("writer output");
11307 let output = String::from_utf8_lossy(&bytes);
11308 assert!(output.contains("hello world"));
11309 }
11310
11311 #[test]
11312 fn headless_process_task_results_updates_model() {
11313 struct TaskModel {
11314 updates: usize,
11315 }
11316
11317 #[derive(Debug)]
11318 enum TaskMsg {
11319 Done,
11320 }
11321
11322 impl From<Event> for TaskMsg {
11323 fn from(_: Event) -> Self {
11324 TaskMsg::Done
11325 }
11326 }
11327
11328 impl Model for TaskModel {
11329 type Message = TaskMsg;
11330
11331 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11332 self.updates += 1;
11333 Cmd::none()
11334 }
11335
11336 fn view(&self, _frame: &mut Frame) {}
11337 }
11338
11339 let mut program =
11340 headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
11341 program.dirty = false;
11342 program.task_sender.send(TaskMsg::Done).unwrap();
11343
11344 program
11345 .process_task_results()
11346 .expect("process task results");
11347 assert_eq!(program.model().updates, 1);
11348 assert!(program.dirty);
11349 }
11350
11351 #[test]
11352 fn run_invokes_on_shutdown_after_quit() {
11353 use std::sync::{
11354 Arc,
11355 atomic::{AtomicUsize, Ordering},
11356 };
11357
11358 struct ShutdownModel {
11359 shutdowns: Arc<AtomicUsize>,
11360 }
11361
11362 #[derive(Debug, Clone, Copy)]
11363 enum ShutdownMsg {
11364 Quit,
11365 ShutdownRan,
11366 }
11367
11368 impl From<Event> for ShutdownMsg {
11369 fn from(_: Event) -> Self {
11370 ShutdownMsg::Quit
11371 }
11372 }
11373
11374 impl Model for ShutdownModel {
11375 type Message = ShutdownMsg;
11376
11377 fn init(&mut self) -> Cmd<Self::Message> {
11378 Cmd::quit()
11379 }
11380
11381 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11382 match msg {
11383 ShutdownMsg::Quit => Cmd::quit(),
11384 ShutdownMsg::ShutdownRan => {
11385 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11386 Cmd::none()
11387 }
11388 }
11389 }
11390
11391 fn view(&self, _frame: &mut Frame) {}
11392
11393 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11394 Cmd::msg(ShutdownMsg::ShutdownRan)
11395 }
11396 }
11397
11398 let shutdowns = Arc::new(AtomicUsize::new(0));
11399 let mut program = headless_program_with_config(
11400 ShutdownModel {
11401 shutdowns: Arc::clone(&shutdowns),
11402 },
11403 ProgramConfig::default(),
11404 );
11405
11406 program.run().expect("program run");
11407
11408 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11409 }
11410
11411 #[test]
11412 fn run_processes_shutdown_task_results_before_exit() {
11413 use std::sync::{
11414 Arc,
11415 atomic::{AtomicUsize, Ordering},
11416 };
11417
11418 struct ShutdownTaskModel {
11419 shutdowns: Arc<AtomicUsize>,
11420 }
11421
11422 #[derive(Debug, Clone, Copy)]
11423 enum ShutdownTaskMsg {
11424 Quit,
11425 ShutdownRan,
11426 }
11427
11428 impl From<Event> for ShutdownTaskMsg {
11429 fn from(_: Event) -> Self {
11430 ShutdownTaskMsg::Quit
11431 }
11432 }
11433
11434 impl Model for ShutdownTaskModel {
11435 type Message = ShutdownTaskMsg;
11436
11437 fn init(&mut self) -> Cmd<Self::Message> {
11438 Cmd::quit()
11439 }
11440
11441 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11442 match msg {
11443 ShutdownTaskMsg::Quit => Cmd::quit(),
11444 ShutdownTaskMsg::ShutdownRan => {
11445 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11446 Cmd::none()
11447 }
11448 }
11449 }
11450
11451 fn view(&self, _frame: &mut Frame) {}
11452
11453 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11454 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11455 }
11456 }
11457
11458 let shutdowns = Arc::new(AtomicUsize::new(0));
11459 let mut program = headless_program_with_config(
11460 ShutdownTaskModel {
11461 shutdowns: Arc::clone(&shutdowns),
11462 },
11463 ProgramConfig::default(),
11464 );
11465
11466 program.run().expect("program run");
11467
11468 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11469 }
11470
11471 #[test]
11472 fn run_processes_shutdown_task_results_with_effect_queue_backend() {
11473 use std::sync::{
11474 Arc,
11475 atomic::{AtomicUsize, Ordering},
11476 };
11477
11478 struct ShutdownTaskModel {
11479 shutdowns: Arc<AtomicUsize>,
11480 }
11481
11482 #[derive(Debug, Clone, Copy)]
11483 enum ShutdownTaskMsg {
11484 Quit,
11485 ShutdownRan,
11486 }
11487
11488 impl From<Event> for ShutdownTaskMsg {
11489 fn from(_: Event) -> Self {
11490 ShutdownTaskMsg::Quit
11491 }
11492 }
11493
11494 impl Model for ShutdownTaskModel {
11495 type Message = ShutdownTaskMsg;
11496
11497 fn init(&mut self) -> Cmd<Self::Message> {
11498 Cmd::quit()
11499 }
11500
11501 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11502 match msg {
11503 ShutdownTaskMsg::Quit => Cmd::quit(),
11504 ShutdownTaskMsg::ShutdownRan => {
11505 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11506 Cmd::none()
11507 }
11508 }
11509 }
11510
11511 fn view(&self, _frame: &mut Frame) {}
11512
11513 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11514 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11515 }
11516 }
11517
11518 let shutdowns = Arc::new(AtomicUsize::new(0));
11519 let mut program = headless_program_with_config(
11520 ShutdownTaskModel {
11521 shutdowns: Arc::clone(&shutdowns),
11522 },
11523 ProgramConfig::default().with_effect_queue(
11524 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11525 ),
11526 );
11527
11528 program.run().expect("program run");
11529
11530 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11531 }
11532
11533 #[test]
11534 fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
11535 use std::sync::{
11536 Arc,
11537 atomic::{AtomicUsize, Ordering},
11538 };
11539
11540 struct ShutdownTaskModel {
11541 shutdowns: Arc<AtomicUsize>,
11542 follow_up_runs: Arc<AtomicUsize>,
11543 }
11544
11545 #[derive(Debug, Clone, Copy)]
11546 enum ShutdownTaskMsg {
11547 Quit,
11548 ShutdownRan,
11549 FollowUp,
11550 }
11551
11552 impl From<Event> for ShutdownTaskMsg {
11553 fn from(_: Event) -> Self {
11554 ShutdownTaskMsg::Quit
11555 }
11556 }
11557
11558 impl Model for ShutdownTaskModel {
11559 type Message = ShutdownTaskMsg;
11560
11561 fn init(&mut self) -> Cmd<Self::Message> {
11562 Cmd::quit()
11563 }
11564
11565 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11566 match msg {
11567 ShutdownTaskMsg::Quit => Cmd::quit(),
11568 ShutdownTaskMsg::ShutdownRan => {
11569 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11570 let follow_up_runs = Arc::clone(&self.follow_up_runs);
11571 Cmd::task(move || {
11572 follow_up_runs.fetch_add(1, Ordering::SeqCst);
11573 ShutdownTaskMsg::FollowUp
11574 })
11575 }
11576 ShutdownTaskMsg::FollowUp => {
11577 self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
11578 Cmd::none()
11579 }
11580 }
11581 }
11582
11583 fn view(&self, _frame: &mut Frame) {}
11584
11585 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11586 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11587 }
11588 }
11589
11590 let shutdowns = Arc::new(AtomicUsize::new(0));
11591 let follow_up_runs = Arc::new(AtomicUsize::new(0));
11592 let mut program = headless_program_with_config(
11593 ShutdownTaskModel {
11594 shutdowns: Arc::clone(&shutdowns),
11595 follow_up_runs: Arc::clone(&follow_up_runs),
11596 },
11597 ProgramConfig::default(),
11598 );
11599
11600 program.run().expect("program run");
11601
11602 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11603 assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
11604 }
11605
11606 #[test]
11607 fn run_quit_from_init_skips_initial_render_and_subscription_start() {
11608 use crate::subscription::{StopSignal, SubId, Subscription};
11609
11610 struct InitQuitModel {
11611 render_calls: Arc<AtomicUsize>,
11612 subscription_starts: Arc<AtomicUsize>,
11613 }
11614
11615 #[derive(Debug, Clone, Copy)]
11616 enum InitQuitMsg {
11617 Noop,
11618 }
11619
11620 impl From<Event> for InitQuitMsg {
11621 fn from(_: Event) -> Self {
11622 Self::Noop
11623 }
11624 }
11625
11626 impl Model for InitQuitModel {
11627 type Message = InitQuitMsg;
11628
11629 fn init(&mut self) -> Cmd<Self::Message> {
11630 Cmd::quit()
11631 }
11632
11633 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11634 Cmd::none()
11635 }
11636
11637 fn view(&self, _frame: &mut Frame) {
11638 self.render_calls.fetch_add(1, Ordering::SeqCst);
11639 }
11640
11641 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11642 vec![Box::new(InitQuitSubscription {
11643 starts: Arc::clone(&self.subscription_starts),
11644 })]
11645 }
11646 }
11647
11648 struct InitQuitSubscription {
11649 starts: Arc<AtomicUsize>,
11650 }
11651
11652 impl Subscription<InitQuitMsg> for InitQuitSubscription {
11653 fn id(&self) -> SubId {
11654 1
11655 }
11656
11657 fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
11658 self.starts.fetch_add(1, Ordering::SeqCst);
11659 let _ = stop.wait_timeout(Duration::from_millis(10));
11660 }
11661 }
11662
11663 let render_calls = Arc::new(AtomicUsize::new(0));
11664 let subscription_starts = Arc::new(AtomicUsize::new(0));
11665 let mut program = headless_program_with_config(
11666 InitQuitModel {
11667 render_calls: Arc::clone(&render_calls),
11668 subscription_starts: Arc::clone(&subscription_starts),
11669 },
11670 ProgramConfig::default(),
11671 );
11672
11673 program.run().expect("program run");
11674
11675 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11676 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11677 }
11678
11679 #[test]
11680 fn run_invokes_on_shutdown_before_returning_signal_error() {
11681 use std::sync::{
11682 Arc,
11683 atomic::{AtomicUsize, Ordering},
11684 };
11685
11686 struct ShutdownModel {
11687 shutdowns: Arc<AtomicUsize>,
11688 }
11689
11690 #[derive(Debug, Clone, Copy)]
11691 enum ShutdownMsg {
11692 Noop,
11693 ShutdownRan,
11694 }
11695
11696 impl From<Event> for ShutdownMsg {
11697 fn from(_: Event) -> Self {
11698 ShutdownMsg::Noop
11699 }
11700 }
11701
11702 impl Model for ShutdownModel {
11703 type Message = ShutdownMsg;
11704
11705 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11706 match msg {
11707 ShutdownMsg::Noop => Cmd::none(),
11708 ShutdownMsg::ShutdownRan => {
11709 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11710 Cmd::none()
11711 }
11712 }
11713 }
11714
11715 fn view(&self, _frame: &mut Frame) {}
11716
11717 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11718 Cmd::msg(ShutdownMsg::ShutdownRan)
11719 }
11720 }
11721
11722 let shutdowns = Arc::new(AtomicUsize::new(0));
11723 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11724 let mut program = headless_signal_program_with_config(
11725 ShutdownModel {
11726 shutdowns: Arc::clone(&shutdowns),
11727 },
11728 ProgramConfig::default().with_signal_interception(true),
11729 );
11730
11731 ftui_core::shutdown_signal::record_pending_termination_signal(2);
11732 let err = program.run().expect_err("signal should stop runtime");
11733
11734 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11735 assert_eq!(signal_termination_from_error(&err), Some(2));
11736 assert_eq!(check_termination_signal(), None);
11737 });
11738 }
11739
11740 #[test]
11741 fn run_pending_signal_skips_initial_render_and_subscription_start() {
11742 use crate::subscription::{StopSignal, SubId, Subscription};
11743
11744 struct SignalStopModel {
11745 render_calls: Arc<AtomicUsize>,
11746 subscription_starts: Arc<AtomicUsize>,
11747 }
11748
11749 #[derive(Debug, Clone, Copy)]
11750 enum SignalStopMsg {
11751 Noop,
11752 }
11753
11754 impl From<Event> for SignalStopMsg {
11755 fn from(_: Event) -> Self {
11756 Self::Noop
11757 }
11758 }
11759
11760 impl Model for SignalStopModel {
11761 type Message = SignalStopMsg;
11762
11763 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11764 Cmd::none()
11765 }
11766
11767 fn view(&self, _frame: &mut Frame) {
11768 self.render_calls.fetch_add(1, Ordering::SeqCst);
11769 }
11770
11771 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11772 vec![Box::new(SignalStopSubscription {
11773 starts: Arc::clone(&self.subscription_starts),
11774 })]
11775 }
11776 }
11777
11778 struct SignalStopSubscription {
11779 starts: Arc<AtomicUsize>,
11780 }
11781
11782 impl Subscription<SignalStopMsg> for SignalStopSubscription {
11783 fn id(&self) -> SubId {
11784 11
11785 }
11786
11787 fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
11788 self.starts.fetch_add(1, Ordering::SeqCst);
11789 let _ = stop.wait_timeout(Duration::from_millis(10));
11790 }
11791 }
11792
11793 let render_calls = Arc::new(AtomicUsize::new(0));
11794 let subscription_starts = Arc::new(AtomicUsize::new(0));
11795 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11796 let mut program = headless_signal_program_with_config(
11797 SignalStopModel {
11798 render_calls: Arc::clone(&render_calls),
11799 subscription_starts: Arc::clone(&subscription_starts),
11800 },
11801 ProgramConfig::default().with_signal_interception(true),
11802 );
11803
11804 ftui_core::shutdown_signal::record_pending_termination_signal(15);
11805 let err = program.run().expect_err("signal should stop runtime");
11806
11807 assert_eq!(signal_termination_from_error(&err), Some(15));
11808 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11809 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11810 assert_eq!(check_termination_signal(), None);
11811 });
11812 }
11813
11814 #[test]
11815 fn headless_should_tick_and_timeout_behaviors() {
11816 let mut program =
11817 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11818 program.tick_rate = Some(Duration::from_millis(5));
11819 program.last_tick = Instant::now() - Duration::from_millis(10);
11820
11821 assert!(program.should_tick());
11822 assert!(!program.should_tick());
11823
11824 let timeout = program.effective_timeout();
11825 assert!(timeout <= Duration::from_millis(5));
11826
11827 program.tick_rate = None;
11828 program.poll_timeout = Duration::from_millis(33);
11829 assert_eq!(program.effective_timeout(), Duration::from_millis(33));
11830 }
11831
11832 #[test]
11833 fn headless_effective_timeout_respects_resize_coalescer() {
11834 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
11835 config.resize_coalescer.steady_delay_ms = 0;
11836 config.resize_coalescer.burst_delay_ms = 0;
11837
11838 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11839 program.tick_rate = Some(Duration::from_millis(50));
11840
11841 program.resize_coalescer.handle_resize(120, 40);
11842 assert!(program.resize_coalescer.has_pending());
11843
11844 let timeout = program.effective_timeout();
11845 assert_eq!(timeout, Duration::ZERO);
11846 }
11847
11848 #[test]
11849 fn headless_ui_height_remeasure_clears_auto_height() {
11850 let mut config = ProgramConfig::inline_auto(2, 6);
11851 config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
11852
11853 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11854 program.dirty = false;
11855 program.writer.set_auto_ui_height(5);
11856
11857 assert_eq!(program.writer.auto_ui_height(), Some(5));
11858 program.request_ui_height_remeasure();
11859
11860 assert_eq!(program.writer.auto_ui_height(), None);
11861 assert!(program.dirty);
11862 }
11863
11864 #[test]
11865 fn headless_recording_lifecycle_and_locale_change() {
11866 let mut program =
11867 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11868 program.dirty = false;
11869
11870 program.start_recording("demo");
11871 assert!(program.is_recording());
11872 let recorded = program.stop_recording();
11873 assert!(recorded.is_some());
11874 assert!(!program.is_recording());
11875
11876 let prev_dirty = program.dirty;
11877 program.locale_context.set_locale("fr");
11878 program.check_locale_change();
11879 assert!(program.dirty || prev_dirty);
11880 }
11881
11882 #[test]
11883 fn headless_render_frame_marks_clean_and_sets_diff() {
11884 struct RenderModel;
11885
11886 #[derive(Debug)]
11887 enum RenderMsg {
11888 Noop,
11889 }
11890
11891 impl From<Event> for RenderMsg {
11892 fn from(_: Event) -> Self {
11893 RenderMsg::Noop
11894 }
11895 }
11896
11897 impl Model for RenderModel {
11898 type Message = RenderMsg;
11899
11900 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11901 Cmd::none()
11902 }
11903
11904 fn view(&self, frame: &mut Frame) {
11905 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
11906 }
11907 }
11908
11909 let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
11910 program.render_frame().expect("render frame");
11911
11912 assert!(!program.dirty);
11913 assert!(program.writer.last_diff_strategy().is_some());
11914 assert_eq!(program.frame_idx, 1);
11915 }
11916
11917 #[test]
11918 fn headless_render_frame_skips_when_budget_exhausted() {
11919 let config = ProgramConfig {
11920 budget: FrameBudgetConfig::with_total(Duration::ZERO),
11921 ..Default::default()
11922 };
11923
11924 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
11925 program.dirty = true;
11926 program.render_frame().expect("render frame");
11927
11928 assert!(program.dirty);
11931 assert_eq!(program.frame_idx, 1);
11932 }
11933
11934 #[test]
11935 fn headless_render_frame_emits_budget_evidence_with_controller() {
11936 use ftui_render::budget::BudgetControllerConfig;
11937
11938 struct RenderModel;
11939
11940 #[derive(Debug)]
11941 enum RenderMsg {
11942 Noop,
11943 }
11944
11945 impl From<Event> for RenderMsg {
11946 fn from(_: Event) -> Self {
11947 RenderMsg::Noop
11948 }
11949 }
11950
11951 impl Model for RenderModel {
11952 type Message = RenderMsg;
11953
11954 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11955 Cmd::none()
11956 }
11957
11958 fn view(&self, frame: &mut Frame) {
11959 frame.buffer.set_raw(0, 0, Cell::from_char('E'));
11960 }
11961 }
11962
11963 let config =
11964 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
11965 let mut program = headless_program_with_config(RenderModel, config);
11966 program.budget = program
11967 .budget
11968 .with_controller(BudgetControllerConfig::default());
11969
11970 program.render_frame().expect("render frame");
11971 assert!(program.budget.telemetry().is_some());
11972 assert_eq!(program.frame_idx, 1);
11973 }
11974
11975 #[test]
11976 fn headless_handle_event_updates_model() {
11977 struct EventModel {
11978 events: usize,
11979 last_resize: Option<(u16, u16)>,
11980 }
11981
11982 #[derive(Debug)]
11983 enum EventMsg {
11984 Resize(u16, u16),
11985 Other,
11986 }
11987
11988 impl From<Event> for EventMsg {
11989 fn from(event: Event) -> Self {
11990 match event {
11991 Event::Resize { width, height } => EventMsg::Resize(width, height),
11992 _ => EventMsg::Other,
11993 }
11994 }
11995 }
11996
11997 impl Model for EventModel {
11998 type Message = EventMsg;
11999
12000 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12001 self.events += 1;
12002 if let EventMsg::Resize(w, h) = msg {
12003 self.last_resize = Some((w, h));
12004 }
12005 Cmd::none()
12006 }
12007
12008 fn view(&self, _frame: &mut Frame) {}
12009 }
12010
12011 let mut program = headless_program_with_config(
12012 EventModel {
12013 events: 0,
12014 last_resize: None,
12015 },
12016 ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
12017 );
12018
12019 program
12020 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12021 ftui_core::event::KeyCode::Char('x'),
12022 )))
12023 .expect("handle key");
12024 assert_eq!(program.model().events, 1);
12025
12026 program
12027 .handle_event(Event::Resize {
12028 width: 10,
12029 height: 5,
12030 })
12031 .expect("handle resize");
12032 assert_eq!(program.model().events, 2);
12033 assert_eq!(program.model().last_resize, Some((10, 5)));
12034 assert_eq!(program.width, 10);
12035 assert_eq!(program.height, 5);
12036 }
12037
12038 #[test]
12039 fn headless_handle_event_quit_skips_subscription_reconcile() {
12040 use crate::subscription::{StopSignal, SubId, Subscription};
12041
12042 struct QuitSubModel {
12043 quitting: bool,
12044 subscription_starts: Arc<AtomicUsize>,
12045 }
12046
12047 #[derive(Debug)]
12048 enum QuitSubMsg {
12049 Quit,
12050 Other,
12051 }
12052
12053 impl From<Event> for QuitSubMsg {
12054 fn from(event: Event) -> Self {
12055 match event {
12056 Event::Key(_) => Self::Quit,
12057 _ => Self::Other,
12058 }
12059 }
12060 }
12061
12062 impl Model for QuitSubModel {
12063 type Message = QuitSubMsg;
12064
12065 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12066 match msg {
12067 QuitSubMsg::Quit => {
12068 self.quitting = true;
12069 Cmd::quit()
12070 }
12071 QuitSubMsg::Other => Cmd::none(),
12072 }
12073 }
12074
12075 fn view(&self, _frame: &mut Frame) {}
12076
12077 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12078 if self.quitting {
12079 vec![Box::new(QuitSubSubscription {
12080 starts: Arc::clone(&self.subscription_starts),
12081 })]
12082 } else {
12083 vec![]
12084 }
12085 }
12086 }
12087
12088 struct QuitSubSubscription {
12089 starts: Arc<AtomicUsize>,
12090 }
12091
12092 impl Subscription<QuitSubMsg> for QuitSubSubscription {
12093 fn id(&self) -> SubId {
12094 7
12095 }
12096
12097 fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
12098 self.starts.fetch_add(1, Ordering::SeqCst);
12099 let _ = stop.wait_timeout(Duration::from_millis(10));
12100 }
12101 }
12102
12103 let subscription_starts = Arc::new(AtomicUsize::new(0));
12104 let mut program = headless_program_with_config(
12105 QuitSubModel {
12106 quitting: false,
12107 subscription_starts: Arc::clone(&subscription_starts),
12108 },
12109 ProgramConfig::default(),
12110 );
12111
12112 program
12113 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12114 ftui_core::event::KeyCode::Char('q'),
12115 )))
12116 .expect("handle event");
12117
12118 assert!(!program.is_running());
12119 assert_eq!(program.subscriptions.active_count(), 0);
12120 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12121 }
12122
12123 #[test]
12124 fn headless_handle_resize_ignored_when_forced_size() {
12125 struct ResizeModel {
12126 resized: bool,
12127 }
12128
12129 #[derive(Debug)]
12130 enum ResizeMsg {
12131 Resize,
12132 Other,
12133 }
12134
12135 impl From<Event> for ResizeMsg {
12136 fn from(event: Event) -> Self {
12137 match event {
12138 Event::Resize { .. } => ResizeMsg::Resize,
12139 _ => ResizeMsg::Other,
12140 }
12141 }
12142 }
12143
12144 impl Model for ResizeModel {
12145 type Message = ResizeMsg;
12146
12147 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12148 if matches!(msg, ResizeMsg::Resize) {
12149 self.resized = true;
12150 }
12151 Cmd::none()
12152 }
12153
12154 fn view(&self, _frame: &mut Frame) {}
12155 }
12156
12157 let config = ProgramConfig::default().with_forced_size(80, 24);
12158 let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
12159
12160 program
12161 .handle_event(Event::Resize {
12162 width: 120,
12163 height: 40,
12164 })
12165 .expect("handle resize");
12166
12167 assert_eq!(program.width, 80);
12168 assert_eq!(program.height, 24);
12169 assert!(!program.model().resized);
12170 }
12171
12172 #[test]
12173 fn headless_execute_cmd_batch_sequence_and_quit() {
12174 struct BatchModel {
12175 count: usize,
12176 }
12177
12178 #[derive(Debug)]
12179 enum BatchMsg {
12180 Inc,
12181 }
12182
12183 impl From<Event> for BatchMsg {
12184 fn from(_: Event) -> Self {
12185 BatchMsg::Inc
12186 }
12187 }
12188
12189 impl Model for BatchModel {
12190 type Message = BatchMsg;
12191
12192 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12193 match msg {
12194 BatchMsg::Inc => {
12195 self.count += 1;
12196 Cmd::none()
12197 }
12198 }
12199 }
12200
12201 fn view(&self, _frame: &mut Frame) {}
12202 }
12203
12204 let mut program =
12205 headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
12206
12207 program
12208 .execute_cmd(Cmd::Batch(vec![
12209 Cmd::msg(BatchMsg::Inc),
12210 Cmd::Sequence(vec![
12211 Cmd::msg(BatchMsg::Inc),
12212 Cmd::quit(),
12213 Cmd::msg(BatchMsg::Inc),
12214 ]),
12215 ]))
12216 .expect("batch cmd");
12217
12218 assert_eq!(program.model().count, 2);
12219 assert!(!program.running);
12220 }
12221
12222 #[test]
12223 fn headless_process_subscription_messages_updates_model() {
12224 use crate::subscription::{StopSignal, SubId, Subscription};
12225
12226 struct SubModel {
12227 pings: usize,
12228 ready_tx: mpsc::Sender<()>,
12229 }
12230
12231 #[derive(Debug)]
12232 enum SubMsg {
12233 Ping,
12234 Other,
12235 }
12236
12237 impl From<Event> for SubMsg {
12238 fn from(_: Event) -> Self {
12239 SubMsg::Other
12240 }
12241 }
12242
12243 impl Model for SubModel {
12244 type Message = SubMsg;
12245
12246 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12247 if let SubMsg::Ping = msg {
12248 self.pings += 1;
12249 }
12250 Cmd::none()
12251 }
12252
12253 fn view(&self, _frame: &mut Frame) {}
12254
12255 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12256 vec![Box::new(TestSubscription {
12257 ready_tx: self.ready_tx.clone(),
12258 })]
12259 }
12260 }
12261
12262 struct TestSubscription {
12263 ready_tx: mpsc::Sender<()>,
12264 }
12265
12266 impl Subscription<SubMsg> for TestSubscription {
12267 fn id(&self) -> SubId {
12268 1
12269 }
12270
12271 fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
12272 let _ = sender.send(SubMsg::Ping);
12273 let _ = self.ready_tx.send(());
12274 }
12275 }
12276
12277 let (ready_tx, ready_rx) = mpsc::channel();
12278 let mut program =
12279 headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
12280
12281 program.reconcile_subscriptions();
12282 ready_rx
12283 .recv_timeout(Duration::from_millis(200))
12284 .expect("subscription started");
12285 program
12286 .process_subscription_messages()
12287 .expect("process subscriptions");
12288
12289 assert_eq!(program.model().pings, 1);
12290 }
12291
12292 #[test]
12293 fn headless_execute_cmd_task_spawns_and_reaps() {
12294 struct TaskModel {
12295 done: bool,
12296 }
12297
12298 #[derive(Debug)]
12299 enum TaskMsg {
12300 Done,
12301 }
12302
12303 impl From<Event> for TaskMsg {
12304 fn from(_: Event) -> Self {
12305 TaskMsg::Done
12306 }
12307 }
12308
12309 impl Model for TaskModel {
12310 type Message = TaskMsg;
12311
12312 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12313 match msg {
12314 TaskMsg::Done => {
12315 self.done = true;
12316 Cmd::none()
12317 }
12318 }
12319 }
12320
12321 fn view(&self, _frame: &mut Frame) {}
12322 }
12323
12324 let mut program =
12325 headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
12326 program
12327 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12328 .expect("task cmd");
12329
12330 let deadline = Instant::now() + Duration::from_millis(200);
12331 while !program.model().done && Instant::now() <= deadline {
12332 program
12333 .process_task_results()
12334 .expect("process task results");
12335 program.reap_finished_tasks();
12336 }
12337
12338 assert!(program.model().done, "task result did not arrive in time");
12339 }
12340
12341 #[test]
12342 fn headless_default_task_executor_is_spawned_for_structured_lane() {
12343 let program =
12351 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12352 assert_eq!(program.task_executor.kind_name(), "spawned");
12353 }
12354
12355 #[test]
12356 fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
12357 let evidence_path = temp_evidence_path("task_executor_spawned_backend");
12361 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12362 let config = ProgramConfig::default().with_evidence_sink(sink_config);
12363 let _program = headless_program_with_config(TestModel { value: 0 }, config);
12364
12365 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12366 assert_eq!(backend_line["backend"], "spawned");
12367 }
12368
12369 #[test]
12370 fn headless_legacy_lane_task_executor_is_spawned() {
12371 let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
12372 let program = headless_program_with_config(TestModel { value: 0 }, config);
12373 assert_eq!(program.task_executor.kind_name(), "spawned");
12374 }
12375
12376 #[test]
12377 fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
12378 let config = ProgramConfig::default().with_effect_queue(
12379 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
12380 );
12381 let program = headless_program_with_config(TestModel { value: 0 }, config);
12382 assert_eq!(program.task_executor.kind_name(), "spawned");
12383 }
12384
12385 #[cfg(feature = "asupersync-executor")]
12386 #[test]
12387 fn headless_asupersync_task_executor_is_selected() {
12388 let config = ProgramConfig::default().with_effect_queue(
12389 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12390 );
12391 let program = headless_program_with_config(TestModel { value: 0 }, config);
12392 assert_eq!(program.task_executor.kind_name(), "asupersync");
12393 }
12394
12395 #[test]
12396 fn headless_persistence_commands_with_registry() {
12397 use crate::state_persistence::{MemoryStorage, StateRegistry};
12398 use std::sync::Arc;
12399
12400 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12401 let config = ProgramConfig::default().with_registry(registry.clone());
12402 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12403
12404 assert!(program.has_persistence());
12405 assert!(program.state_registry().is_some());
12406
12407 program.execute_cmd(Cmd::save_state()).expect("save");
12408 program.execute_cmd(Cmd::restore_state()).expect("restore");
12409
12410 let saved = program.trigger_save().expect("trigger save");
12411 let loaded = program.trigger_load().expect("trigger load");
12412 assert!(!saved);
12413 assert_eq!(loaded, 0);
12414 }
12415
12416 #[test]
12417 fn headless_process_resize_coalescer_applies_pending_resize() {
12418 struct ResizeModel {
12419 last_size: Option<(u16, u16)>,
12420 }
12421
12422 #[derive(Debug)]
12423 enum ResizeMsg {
12424 Resize(u16, u16),
12425 Other,
12426 }
12427
12428 impl From<Event> for ResizeMsg {
12429 fn from(event: Event) -> Self {
12430 match event {
12431 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12432 _ => ResizeMsg::Other,
12433 }
12434 }
12435 }
12436
12437 impl Model for ResizeModel {
12438 type Message = ResizeMsg;
12439
12440 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12441 if let ResizeMsg::Resize(w, h) = msg {
12442 self.last_size = Some((w, h));
12443 }
12444 Cmd::none()
12445 }
12446
12447 fn view(&self, _frame: &mut Frame) {}
12448 }
12449
12450 let evidence_path = temp_evidence_path("fairness_allow");
12451 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12452 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12453 config.resize_coalescer.steady_delay_ms = 0;
12454 config.resize_coalescer.burst_delay_ms = 0;
12455 config.resize_coalescer.hard_deadline_ms = 1_000;
12456 config.evidence_sink = sink_config.clone();
12457
12458 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12459 let sink = EvidenceSink::from_config(&sink_config)
12460 .expect("evidence sink config")
12461 .expect("evidence sink enabled");
12462 program.evidence_sink = Some(sink);
12463
12464 program.resize_coalescer.handle_resize(120, 40);
12465 assert!(program.resize_coalescer.has_pending());
12466
12467 program
12468 .process_resize_coalescer()
12469 .expect("process resize coalescer");
12470
12471 assert_eq!(program.width, 120);
12472 assert_eq!(program.height, 40);
12473 assert_eq!(program.model().last_size, Some((120, 40)));
12474
12475 let config_line = read_evidence_event(&evidence_path, "fairness_config");
12476 assert_eq!(config_line["event"], "fairness_config");
12477 assert!(config_line["enabled"].is_boolean());
12478 assert!(config_line["input_priority_threshold_ms"].is_number());
12479 assert!(config_line["dominance_threshold"].is_number());
12480 assert!(config_line["fairness_threshold"].is_number());
12481
12482 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12483 assert_eq!(decision_line["event"], "fairness_decision");
12484 assert_eq!(decision_line["decision"], "allow");
12485 assert_eq!(decision_line["reason"], "none");
12486 assert!(decision_line["pending_input_latency_ms"].is_null());
12487 assert!(decision_line["jain_index"].is_number());
12488 assert!(decision_line["resize_dominance_count"].is_number());
12489 assert!(decision_line["dominance_threshold"].is_number());
12490 assert!(decision_line["fairness_threshold"].is_number());
12491 assert!(decision_line["input_priority_threshold_ms"].is_number());
12492 }
12493
12494 #[test]
12495 fn headless_process_resize_coalescer_yields_to_input() {
12496 struct ResizeModel {
12497 last_size: Option<(u16, u16)>,
12498 }
12499
12500 #[derive(Debug)]
12501 enum ResizeMsg {
12502 Resize(u16, u16),
12503 Other,
12504 }
12505
12506 impl From<Event> for ResizeMsg {
12507 fn from(event: Event) -> Self {
12508 match event {
12509 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12510 _ => ResizeMsg::Other,
12511 }
12512 }
12513 }
12514
12515 impl Model for ResizeModel {
12516 type Message = ResizeMsg;
12517
12518 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12519 if let ResizeMsg::Resize(w, h) = msg {
12520 self.last_size = Some((w, h));
12521 }
12522 Cmd::none()
12523 }
12524
12525 fn view(&self, _frame: &mut Frame) {}
12526 }
12527
12528 let evidence_path = temp_evidence_path("fairness_yield");
12529 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12530 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12531 config.resize_coalescer.steady_delay_ms = 0;
12532 config.resize_coalescer.burst_delay_ms = 0;
12533 config.resize_coalescer.hard_deadline_ms = 10_000;
12536 config.evidence_sink = sink_config.clone();
12537
12538 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12539 let sink = EvidenceSink::from_config(&sink_config)
12540 .expect("evidence sink config")
12541 .expect("evidence sink enabled");
12542 program.evidence_sink = Some(sink);
12543
12544 program.fairness_guard = InputFairnessGuard::with_config(
12545 crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
12546 );
12547 program
12548 .fairness_guard
12549 .input_arrived(Instant::now() - Duration::from_millis(1));
12550
12551 program.resize_coalescer.handle_resize(120, 40);
12552 assert!(program.resize_coalescer.has_pending());
12553
12554 program
12555 .process_resize_coalescer()
12556 .expect("process resize coalescer");
12557
12558 assert_eq!(program.width, 80);
12559 assert_eq!(program.height, 24);
12560 assert_eq!(program.model().last_size, None);
12561 assert!(program.resize_coalescer.has_pending());
12562
12563 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12564 assert_eq!(decision_line["event"], "fairness_decision");
12565 assert_eq!(decision_line["decision"], "yield");
12566 assert_eq!(decision_line["reason"], "input_latency");
12567 assert!(decision_line["pending_input_latency_ms"].is_number());
12568 assert!(decision_line["jain_index"].is_number());
12569 assert!(decision_line["resize_dominance_count"].is_number());
12570 assert!(decision_line["dominance_threshold"].is_number());
12571 assert!(decision_line["fairness_threshold"].is_number());
12572 assert!(decision_line["input_priority_threshold_ms"].is_number());
12573 }
12574
12575 #[test]
12576 fn headless_execute_cmd_task_with_effect_queue() {
12577 struct TaskModel {
12578 done: bool,
12579 }
12580
12581 #[derive(Debug)]
12582 enum TaskMsg {
12583 Done,
12584 }
12585
12586 impl From<Event> for TaskMsg {
12587 fn from(_: Event) -> Self {
12588 TaskMsg::Done
12589 }
12590 }
12591
12592 impl Model for TaskModel {
12593 type Message = TaskMsg;
12594
12595 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12596 match msg {
12597 TaskMsg::Done => {
12598 self.done = true;
12599 Cmd::none()
12600 }
12601 }
12602 }
12603
12604 fn view(&self, _frame: &mut Frame) {}
12605 }
12606
12607 let effect_queue = EffectQueueConfig {
12608 enabled: true,
12609 backend: TaskExecutorBackend::EffectQueue,
12610 scheduler: SchedulerConfig {
12611 max_queue_size: 0,
12612 ..Default::default()
12613 },
12614 explicit_backend: true,
12615 ..Default::default()
12616 };
12617 let config = ProgramConfig::default().with_effect_queue(effect_queue);
12618 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12619
12620 program
12621 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12622 .expect("task cmd");
12623
12624 let deadline = Instant::now() + Duration::from_millis(200);
12625 while !program.model().done && Instant::now() <= deadline {
12626 program
12627 .process_task_results()
12628 .expect("process task results");
12629 }
12630
12631 assert!(
12632 program.model().done,
12633 "effect queue task result did not arrive in time"
12634 );
12635 assert_eq!(program.task_executor.kind_name(), "queued");
12636 }
12637
12638 #[test]
12639 fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
12640 struct TaskModel {
12641 done: bool,
12642 }
12643
12644 #[derive(Debug)]
12645 enum TaskMsg {
12646 Done,
12647 }
12648
12649 impl From<Event> for TaskMsg {
12650 fn from(_: Event) -> Self {
12651 TaskMsg::Done
12652 }
12653 }
12654
12655 impl Model for TaskModel {
12656 type Message = TaskMsg;
12657
12658 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12659 match msg {
12660 TaskMsg::Done => {
12661 self.done = true;
12662 Cmd::none()
12663 }
12664 }
12665 }
12666
12667 fn view(&self, _frame: &mut Frame) {}
12668 }
12669
12670 let evidence_path = temp_evidence_path("task_executor_spawned_complete");
12671 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12672 let config = ProgramConfig::default()
12673 .with_lane(RuntimeLane::Legacy)
12674 .with_evidence_sink(sink_config);
12675 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12676
12677 program
12678 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12679 .expect("task cmd");
12680
12681 let deadline = Instant::now() + Duration::from_millis(200);
12682 while !program.model().done && Instant::now() <= deadline {
12683 program
12684 .process_task_results()
12685 .expect("process task results");
12686 program.reap_finished_tasks();
12687 }
12688
12689 assert!(
12690 program.model().done,
12691 "spawned task result did not arrive in time"
12692 );
12693
12694 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12695 assert_eq!(completion_line["backend"], "spawned");
12696 assert!(completion_line["duration_us"].is_number());
12697 }
12698
12699 #[test]
12700 fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
12701 struct TaskModel {
12702 done: bool,
12703 }
12704
12705 #[derive(Debug)]
12706 enum TaskMsg {
12707 Done,
12708 }
12709
12710 impl From<Event> for TaskMsg {
12711 fn from(_: Event) -> Self {
12712 TaskMsg::Done
12713 }
12714 }
12715
12716 impl Model for TaskModel {
12717 type Message = TaskMsg;
12718
12719 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12720 match msg {
12721 TaskMsg::Done => {
12722 self.done = true;
12723 Cmd::none()
12724 }
12725 }
12726 }
12727
12728 fn view(&self, _frame: &mut Frame) {}
12729 }
12730
12731 let evidence_path = temp_evidence_path("task_executor_queued_panic");
12732 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12733 let config = ProgramConfig::default()
12734 .with_evidence_sink(sink_config)
12735 .with_effect_queue(
12736 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
12737 );
12738 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12739
12740 program
12741 .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
12742 .expect("panic task cmd");
12743 program
12744 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12745 .expect("follow-up task cmd");
12746
12747 let deadline = Instant::now() + Duration::from_millis(500);
12748 while !program.model().done && Instant::now() <= deadline {
12749 program
12750 .process_task_results()
12751 .expect("process task results");
12752 }
12753
12754 assert!(
12755 program.model().done,
12756 "effect queue should continue after a panicking task"
12757 );
12758
12759 let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
12760 assert_eq!(panic_line["backend"], "queued");
12761 assert_eq!(panic_line["panic_msg"], "queued panic evidence");
12762 }
12763
12764 #[cfg(feature = "asupersync-executor")]
12765 #[test]
12766 fn headless_execute_cmd_task_with_asupersync_backend() {
12767 struct TaskModel {
12768 done: bool,
12769 }
12770
12771 #[derive(Debug)]
12772 enum TaskMsg {
12773 Done,
12774 }
12775
12776 impl From<Event> for TaskMsg {
12777 fn from(_: Event) -> Self {
12778 TaskMsg::Done
12779 }
12780 }
12781
12782 impl Model for TaskModel {
12783 type Message = TaskMsg;
12784
12785 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12786 match msg {
12787 TaskMsg::Done => {
12788 self.done = true;
12789 Cmd::none()
12790 }
12791 }
12792 }
12793
12794 fn view(&self, _frame: &mut Frame) {}
12795 }
12796
12797 let config = ProgramConfig::default().with_effect_queue(
12798 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12799 );
12800 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12801
12802 program
12803 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12804 .expect("task cmd");
12805
12806 let deadline = Instant::now() + Duration::from_millis(200);
12807 while !program.model().done && Instant::now() <= deadline {
12808 program
12809 .process_task_results()
12810 .expect("process task results");
12811 program.reap_finished_tasks();
12812 }
12813
12814 assert!(
12815 program.model().done,
12816 "asupersync task result did not arrive in time"
12817 );
12818 assert_eq!(program.task_executor.kind_name(), "asupersync");
12819 }
12820
12821 #[cfg(feature = "asupersync-executor")]
12822 #[test]
12823 fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
12824 struct TaskModel {
12825 done: bool,
12826 }
12827
12828 #[derive(Debug)]
12829 enum TaskMsg {
12830 Done,
12831 }
12832
12833 impl From<Event> for TaskMsg {
12834 fn from(_: Event) -> Self {
12835 TaskMsg::Done
12836 }
12837 }
12838
12839 impl Model for TaskModel {
12840 type Message = TaskMsg;
12841
12842 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12843 match msg {
12844 TaskMsg::Done => {
12845 self.done = true;
12846 Cmd::none()
12847 }
12848 }
12849 }
12850
12851 fn view(&self, _frame: &mut Frame) {}
12852 }
12853
12854 let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
12855 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12856 let config = ProgramConfig::default()
12857 .with_evidence_sink(sink_config)
12858 .with_effect_queue(
12859 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12860 );
12861 let mut program = headless_program_with_config(TaskModel { done: false }, config);
12862
12863 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12864 assert_eq!(backend_line["backend"], "asupersync");
12865
12866 program
12867 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12868 .expect("task cmd");
12869
12870 let deadline = Instant::now() + Duration::from_millis(200);
12871 while !program.model().done && Instant::now() <= deadline {
12872 program
12873 .process_task_results()
12874 .expect("process task results");
12875 program.reap_finished_tasks();
12876 }
12877
12878 assert!(
12879 program.model().done,
12880 "asupersync task result did not arrive in time"
12881 );
12882
12883 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
12884 assert_eq!(completion_line["backend"], "asupersync");
12885 assert!(completion_line["duration_us"].is_number());
12886 }
12887
12888 #[cfg(feature = "asupersync-executor")]
12896 fn collect_task_batch(backend: TaskExecutorBackend, count: u32) -> Vec<u32> {
12897 struct CollectModel {
12898 got: Vec<u32>,
12899 }
12900 #[derive(Debug)]
12901 enum CollectMsg {
12902 Got(u32),
12903 }
12904 impl From<Event> for CollectMsg {
12905 fn from(_: Event) -> Self {
12906 CollectMsg::Got(u32::MAX)
12907 }
12908 }
12909 impl Model for CollectModel {
12910 type Message = CollectMsg;
12911 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12912 match msg {
12913 CollectMsg::Got(value) => self.got.push(value),
12914 }
12915 Cmd::none()
12916 }
12917 fn view(&self, _frame: &mut Frame) {}
12918 }
12919
12920 let config = ProgramConfig::default()
12921 .with_effect_queue(EffectQueueConfig::default().with_backend(backend));
12922 let mut program = headless_program_with_config(CollectModel { got: Vec::new() }, config);
12923 for index in 0..count {
12924 program
12925 .execute_cmd(Cmd::task(move || CollectMsg::Got(index)))
12926 .expect("task cmd");
12927 }
12928 let deadline = Instant::now() + Duration::from_secs(5);
12929 while program.model().got.len() < count as usize && Instant::now() <= deadline {
12930 program
12931 .process_task_results()
12932 .expect("process task results");
12933 program.reap_finished_tasks();
12934 }
12935 let mut got = program.model().got.clone();
12936 got.sort_unstable();
12937 got
12938 }
12939
12940 #[cfg(feature = "asupersync-executor")]
12941 #[test]
12942 fn asupersync_semantic_parity_with_spawned_backend() {
12943 let expected: Vec<u32> = (0..16).collect();
12947 let spawned = collect_task_batch(TaskExecutorBackend::Spawned, 16);
12948 let asupersync = collect_task_batch(TaskExecutorBackend::Asupersync, 16);
12949 assert_eq!(
12950 spawned, expected,
12951 "spawned backend lost or reordered results"
12952 );
12953 assert_eq!(
12954 asupersync, expected,
12955 "asupersync backend diverged from the expected results"
12956 );
12957 assert_eq!(
12958 asupersync, spawned,
12959 "asupersync diverged from spawned (parity)"
12960 );
12961 }
12962
12963 #[cfg(feature = "asupersync-executor")]
12964 #[test]
12965 fn asupersync_stress_delivers_every_task() {
12966 let count: u32 = 200;
12968 let got = collect_task_batch(TaskExecutorBackend::Asupersync, count);
12969 assert_eq!(
12970 got.len(),
12971 count as usize,
12972 "asupersync dropped tasks under load"
12973 );
12974 assert_eq!(got, (0..count).collect::<Vec<_>>());
12975 }
12976
12977 #[cfg(feature = "asupersync-executor")]
12978 #[test]
12979 fn asupersync_task_panic_is_isolated() {
12980 struct PanicModel {
12981 survivor_seen: bool,
12982 }
12983 #[derive(Debug)]
12984 enum PanicMsg {
12985 Survivor,
12986 }
12987 impl From<Event> for PanicMsg {
12988 fn from(_: Event) -> Self {
12989 PanicMsg::Survivor
12990 }
12991 }
12992 impl Model for PanicModel {
12993 type Message = PanicMsg;
12994 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12995 match msg {
12996 PanicMsg::Survivor => self.survivor_seen = true,
12997 }
12998 Cmd::none()
12999 }
13000 fn view(&self, _frame: &mut Frame) {}
13001 }
13002
13003 let evidence_path = temp_evidence_path("asupersync_panic");
13004 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13005 let config = ProgramConfig::default()
13006 .with_evidence_sink(sink_config)
13007 .with_effect_queue(
13008 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13009 );
13010 let mut program = headless_program_with_config(
13011 PanicModel {
13012 survivor_seen: false,
13013 },
13014 config,
13015 );
13016
13017 program
13019 .execute_cmd(Cmd::task(|| -> PanicMsg { panic!("asupersync boom") }))
13020 .expect("panic task cmd");
13021 program
13023 .execute_cmd(Cmd::task(|| PanicMsg::Survivor))
13024 .expect("survivor task cmd");
13025
13026 let deadline = Instant::now() + Duration::from_secs(5);
13027 while !program.model().survivor_seen && Instant::now() <= deadline {
13028 program
13029 .process_task_results()
13030 .expect("process task results");
13031 program.reap_finished_tasks();
13032 }
13033 assert!(
13034 program.model().survivor_seen,
13035 "executor did not survive a panicking task"
13036 );
13037 let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13038 assert_eq!(panic_line["backend"], "asupersync");
13039 }
13040
13041 #[cfg(feature = "asupersync-executor")]
13042 #[test]
13043 fn asupersync_rejects_tasks_after_shutdown() {
13044 struct ShutdownModel {
13045 seen: bool,
13046 }
13047 #[derive(Debug)]
13048 enum ShutdownMsg {
13049 Seen,
13050 }
13051 impl From<Event> for ShutdownMsg {
13052 fn from(_: Event) -> Self {
13053 ShutdownMsg::Seen
13054 }
13055 }
13056 impl Model for ShutdownModel {
13057 type Message = ShutdownMsg;
13058 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13059 match msg {
13060 ShutdownMsg::Seen => self.seen = true,
13061 }
13062 Cmd::none()
13063 }
13064 fn view(&self, _frame: &mut Frame) {}
13065 }
13066
13067 let config = ProgramConfig::default().with_effect_queue(
13068 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13069 );
13070 let mut program = headless_program_with_config(ShutdownModel { seen: false }, config);
13071
13072 program.task_executor.shutdown();
13075 program
13076 .execute_cmd(Cmd::task(|| ShutdownMsg::Seen))
13077 .expect("post-shutdown task cmd");
13078
13079 let deadline = Instant::now() + Duration::from_millis(200);
13080 while Instant::now() <= deadline {
13081 program
13082 .process_task_results()
13083 .expect("process task results");
13084 program.reap_finished_tasks();
13085 }
13086 assert!(
13087 !program.model().seen,
13088 "a task submitted after shutdown was executed"
13089 );
13090 }
13091
13092 #[cfg(feature = "asupersync-executor")]
13093 #[test]
13094 fn asupersync_backpressure_sheds_excess_in_flight_tasks() {
13095 struct SlowModel {
13096 done: u32,
13097 }
13098 #[derive(Debug)]
13099 enum SlowMsg {
13100 Done,
13101 }
13102 impl From<Event> for SlowMsg {
13103 fn from(_: Event) -> Self {
13104 SlowMsg::Done
13105 }
13106 }
13107 impl Model for SlowModel {
13108 type Message = SlowMsg;
13109 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13110 match msg {
13111 SlowMsg::Done => self.done += 1,
13112 }
13113 Cmd::none()
13114 }
13115 fn view(&self, _frame: &mut Frame) {}
13116 }
13117
13118 let evidence_path = temp_evidence_path("asupersync_backpressure");
13119 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13120 let config = ProgramConfig::default()
13121 .with_evidence_sink(sink_config)
13122 .with_effect_queue(
13123 EffectQueueConfig::default()
13124 .with_backend(TaskExecutorBackend::Asupersync)
13125 .with_max_queue_depth(2),
13126 );
13127 let mut program = headless_program_with_config(SlowModel { done: 0 }, config);
13128
13129 for _ in 0..6 {
13132 program
13133 .execute_cmd(Cmd::task(|| {
13134 std::thread::sleep(Duration::from_millis(120));
13135 SlowMsg::Done
13136 }))
13137 .expect("slow task cmd");
13138 }
13139
13140 let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
13142 assert_eq!(backpressure_line["backend"], "asupersync");
13143 assert_eq!(backpressure_line["action"], "drop");
13144
13145 let deadline = Instant::now() + Duration::from_secs(5);
13148 while program.model().done < 2 && Instant::now() <= deadline {
13149 program
13150 .process_task_results()
13151 .expect("process task results");
13152 program.reap_finished_tasks();
13153 }
13154 assert!(
13155 program.model().done <= 2,
13156 "more tasks completed than the in-flight depth cap permits"
13157 );
13158 }
13159
13160 #[test]
13165 fn unit_tau_monotone() {
13166 let mut bc = BatchController::new();
13169
13170 bc.observe_service(Duration::from_millis(20));
13172 bc.observe_service(Duration::from_millis(20));
13173 bc.observe_service(Duration::from_millis(20));
13174 let tau_high = bc.tau_s();
13175
13176 for _ in 0..20 {
13178 bc.observe_service(Duration::from_millis(1));
13179 }
13180 let tau_low = bc.tau_s();
13181
13182 assert!(
13183 tau_low <= tau_high,
13184 "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
13185 );
13186 }
13187
13188 #[test]
13189 fn unit_tau_monotone_lambda() {
13190 let mut bc = BatchController::new();
13194 let base = Instant::now();
13195
13196 for i in 0..10 {
13198 bc.observe_arrival(base + Duration::from_millis(i * 10));
13199 }
13200 let rho_fast = bc.rho_est();
13201
13202 for i in 10..20 {
13204 bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
13205 }
13206 let rho_slow = bc.rho_est();
13207
13208 assert!(
13209 rho_slow < rho_fast,
13210 "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
13211 );
13212 }
13213
13214 #[test]
13215 fn unit_stability() {
13216 let mut bc = BatchController::new();
13218 let base = Instant::now();
13219
13220 for i in 0..30 {
13222 bc.observe_arrival(base + Duration::from_millis(i * 33));
13223 bc.observe_service(Duration::from_millis(5)); }
13225
13226 assert!(
13227 bc.is_stable(),
13228 "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
13229 bc.rho_est()
13230 );
13231 assert!(
13232 bc.rho_est() < 1.0,
13233 "utilization should be < 1: ρ={:.4}",
13234 bc.rho_est()
13235 );
13236
13237 assert!(
13239 bc.tau_s() > bc.service_est_s(),
13240 "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
13241 bc.tau_s(),
13242 bc.service_est_s()
13243 );
13244 }
13245
13246 #[test]
13247 fn unit_stability_high_load() {
13248 let mut bc = BatchController::new();
13250 let base = Instant::now();
13251
13252 for i in 0..50 {
13254 bc.observe_arrival(base + Duration::from_millis(i * 10));
13255 bc.observe_service(Duration::from_millis(8));
13256 }
13257
13258 let tau = bc.tau_s();
13260 let rho_eff = bc.service_est_s() / tau;
13261 assert!(
13262 rho_eff < 1.0,
13263 "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
13264 bc.service_est_s()
13265 );
13266 }
13267
13268 #[test]
13269 fn batch_controller_defaults() {
13270 let bc = BatchController::new();
13271 assert!(bc.tau_s() >= bc.tau_min_s);
13272 assert!(bc.tau_s() <= bc.tau_max_s);
13273 assert_eq!(bc.observations(), 0);
13274 assert!(bc.is_stable());
13275 }
13276
13277 #[test]
13278 fn batch_controller_tau_clamped() {
13279 let mut bc = BatchController::new();
13280
13281 for _ in 0..20 {
13283 bc.observe_service(Duration::from_micros(10));
13284 }
13285 assert!(
13286 bc.tau_s() >= bc.tau_min_s,
13287 "τ should be >= tau_min: τ={:.6}, min={:.6}",
13288 bc.tau_s(),
13289 bc.tau_min_s
13290 );
13291
13292 for _ in 0..20 {
13294 bc.observe_service(Duration::from_millis(100));
13295 }
13296 assert!(
13297 bc.tau_s() <= bc.tau_max_s,
13298 "τ should be <= tau_max: τ={:.6}, max={:.6}",
13299 bc.tau_s(),
13300 bc.tau_max_s
13301 );
13302 }
13303
13304 #[test]
13305 fn batch_controller_duration_conversion() {
13306 let bc = BatchController::new();
13307 let tau = bc.tau();
13308 let tau_s = bc.tau_s();
13309 let diff = (tau.as_secs_f64() - tau_s).abs();
13311 assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
13312 }
13313
13314 #[test]
13315 fn batch_controller_lambda_estimation() {
13316 let mut bc = BatchController::new();
13317 let base = Instant::now();
13318
13319 for i in 0..20 {
13321 bc.observe_arrival(base + Duration::from_millis(i * 20));
13322 }
13323
13324 let lambda = bc.lambda_est();
13326 assert!(
13327 lambda > 20.0 && lambda < 100.0,
13328 "λ should be near 50: got {lambda:.1}"
13329 );
13330 }
13331
13332 #[test]
13337 fn cmd_save_state() {
13338 let cmd: Cmd<TestMsg> = Cmd::save_state();
13339 assert!(matches!(cmd, Cmd::SaveState));
13340 }
13341
13342 #[test]
13343 fn cmd_restore_state() {
13344 let cmd: Cmd<TestMsg> = Cmd::restore_state();
13345 assert!(matches!(cmd, Cmd::RestoreState));
13346 }
13347
13348 #[test]
13349 fn persistence_config_default() {
13350 let config = PersistenceConfig::default();
13351 assert!(config.registry.is_none());
13352 assert!(config.checkpoint_interval.is_none());
13353 assert!(config.auto_load);
13354 assert!(config.auto_save);
13355 }
13356
13357 #[test]
13358 fn persistence_config_disabled() {
13359 let config = PersistenceConfig::disabled();
13360 assert!(config.registry.is_none());
13361 }
13362
13363 #[test]
13364 fn persistence_config_with_registry() {
13365 use crate::state_persistence::{MemoryStorage, StateRegistry};
13366 use std::sync::Arc;
13367
13368 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13369 let config = PersistenceConfig::with_registry(registry.clone());
13370
13371 assert!(config.registry.is_some());
13372 assert!(config.auto_load);
13373 assert!(config.auto_save);
13374 }
13375
13376 #[test]
13377 fn persistence_config_checkpoint_interval() {
13378 use crate::state_persistence::{MemoryStorage, StateRegistry};
13379 use std::sync::Arc;
13380
13381 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13382 let config = PersistenceConfig::with_registry(registry)
13383 .checkpoint_every(Duration::from_secs(30))
13384 .auto_load(false)
13385 .auto_save(true);
13386
13387 assert!(config.checkpoint_interval.is_some());
13388 assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
13389 assert!(!config.auto_load);
13390 assert!(config.auto_save);
13391 }
13392
13393 #[test]
13394 fn program_config_with_persistence() {
13395 use crate::state_persistence::{MemoryStorage, StateRegistry};
13396 use std::sync::Arc;
13397
13398 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13399 let config = ProgramConfig::default().with_registry(registry);
13400
13401 assert!(config.persistence.registry.is_some());
13402 }
13403
13404 #[test]
13409 fn task_spec_default() {
13410 let spec = TaskSpec::default();
13411 assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
13412 assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13413 assert!(spec.name.is_none());
13414 }
13415
13416 #[test]
13417 fn task_spec_new() {
13418 let spec = TaskSpec::new(5.0, 20.0);
13419 assert_eq!(spec.weight, 5.0);
13420 assert_eq!(spec.estimate_ms, 20.0);
13421 assert!(spec.name.is_none());
13422 }
13423
13424 #[test]
13425 fn task_spec_with_name() {
13426 let spec = TaskSpec::default().with_name("fetch_data");
13427 assert_eq!(spec.name.as_deref(), Some("fetch_data"));
13428 }
13429
13430 #[test]
13431 fn task_spec_debug() {
13432 let spec = TaskSpec::new(2.0, 15.0).with_name("test");
13433 let debug = format!("{spec:?}");
13434 assert!(debug.contains("2.0"));
13435 assert!(debug.contains("15.0"));
13436 assert!(debug.contains("test"));
13437 }
13438
13439 #[test]
13444 fn cmd_count_none() {
13445 let cmd: Cmd<TestMsg> = Cmd::none();
13446 assert_eq!(cmd.count(), 0);
13447 }
13448
13449 #[test]
13450 fn cmd_count_atomic() {
13451 assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
13452 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
13453 assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
13454 assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
13455 assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
13456 assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
13457 assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
13458 }
13459
13460 #[test]
13461 fn cmd_count_batch() {
13462 let cmd: Cmd<TestMsg> =
13463 Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
13464 assert_eq!(cmd.count(), 2); }
13466
13467 #[test]
13468 fn cmd_count_nested() {
13469 let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
13470 Cmd::msg(TestMsg::Increment),
13471 Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
13472 ]);
13473 assert_eq!(cmd.count(), 3);
13474 }
13475
13476 #[test]
13481 fn cmd_type_name_all_variants() {
13482 assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
13483 assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
13484 assert_eq!(
13485 Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
13486 "Batch"
13487 );
13488 assert_eq!(
13489 Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
13490 "Sequence"
13491 );
13492 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
13493 assert_eq!(
13494 Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
13495 "Tick"
13496 );
13497 assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
13498 assert_eq!(
13499 Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
13500 "Task"
13501 );
13502 assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
13503 assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
13504 assert_eq!(
13505 Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
13506 "SetMouseCapture"
13507 );
13508 }
13509
13510 #[test]
13515 fn cmd_batch_empty_returns_none() {
13516 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
13517 assert!(matches!(cmd, Cmd::None));
13518 }
13519
13520 #[test]
13521 fn cmd_batch_single_unwraps() {
13522 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
13523 assert!(matches!(cmd, Cmd::Quit));
13524 }
13525
13526 #[test]
13527 fn cmd_batch_multiple_stays_batch() {
13528 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13529 assert!(matches!(cmd, Cmd::Batch(_)));
13530 }
13531
13532 #[test]
13533 fn cmd_sequence_empty_returns_none() {
13534 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
13535 assert!(matches!(cmd, Cmd::None));
13536 }
13537
13538 #[test]
13539 fn cmd_sequence_single_unwraps_to_inner() {
13540 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
13541 assert!(matches!(cmd, Cmd::Quit));
13542 }
13543
13544 #[test]
13545 fn cmd_sequence_multiple_stays_sequence() {
13546 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13547 assert!(matches!(cmd, Cmd::Sequence(_)));
13548 }
13549
13550 #[test]
13555 fn cmd_task_with_spec() {
13556 let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
13557 let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
13558 match cmd {
13559 Cmd::Task(s, _) => {
13560 assert_eq!(s.weight, 3.0);
13561 assert_eq!(s.estimate_ms, 25.0);
13562 assert_eq!(s.name.as_deref(), Some("my_task"));
13563 }
13564 _ => panic!("expected Task variant"),
13565 }
13566 }
13567
13568 #[test]
13569 fn cmd_task_weighted() {
13570 let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
13571 match cmd {
13572 Cmd::Task(s, _) => {
13573 assert_eq!(s.weight, 2.0);
13574 assert_eq!(s.estimate_ms, 50.0);
13575 assert!(s.name.is_none());
13576 }
13577 _ => panic!("expected Task variant"),
13578 }
13579 }
13580
13581 #[test]
13582 fn cmd_task_named() {
13583 let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
13584 match cmd {
13585 Cmd::Task(s, _) => {
13586 assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
13587 assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13588 assert_eq!(s.name.as_deref(), Some("background_fetch"));
13589 }
13590 _ => panic!("expected Task variant"),
13591 }
13592 }
13593
13594 #[test]
13599 fn cmd_debug_all_variant_strings() {
13600 assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
13601 assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
13602 assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
13603 assert!(
13604 format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
13605 );
13606 assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
13607 assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
13608 assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
13609 assert_eq!(
13610 format!("{:?}", Cmd::<TestMsg>::restore_state()),
13611 "RestoreState"
13612 );
13613 assert_eq!(
13614 format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
13615 "SetMouseCapture(true)"
13616 );
13617 }
13618
13619 #[test]
13624 fn headless_execute_cmd_set_mouse_capture() {
13625 let mut program =
13626 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
13627 assert!(!program.backend_features.mouse_capture);
13628
13629 program
13630 .execute_cmd(Cmd::set_mouse_capture(true))
13631 .expect("set mouse capture true");
13632 assert!(program.backend_features.mouse_capture);
13633
13634 program
13635 .execute_cmd(Cmd::set_mouse_capture(false))
13636 .expect("set mouse capture false");
13637 assert!(!program.backend_features.mouse_capture);
13638 }
13639
13640 #[test]
13645 fn resize_behavior_uses_coalescer() {
13646 assert!(ResizeBehavior::Throttled.uses_coalescer());
13647 assert!(!ResizeBehavior::Immediate.uses_coalescer());
13648 }
13649
13650 #[test]
13651 fn resize_behavior_eq_and_debug() {
13652 assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
13653 assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
13654 let debug = format!("{:?}", ResizeBehavior::Throttled);
13655 assert_eq!(debug, "Throttled");
13656 }
13657
13658 #[test]
13663 fn widget_refresh_config_defaults() {
13664 let config = WidgetRefreshConfig::default();
13665 assert!(config.enabled);
13666 assert_eq!(config.staleness_window_ms, 1_000);
13667 assert_eq!(config.starve_ms, 3_000);
13668 assert_eq!(config.max_starved_per_frame, 2);
13669 assert_eq!(config.max_drop_fraction, 1.0);
13670 assert_eq!(config.weight_priority, 1.0);
13671 assert_eq!(config.weight_staleness, 0.5);
13672 assert_eq!(config.weight_focus, 0.75);
13673 assert_eq!(config.weight_interaction, 0.5);
13674 assert_eq!(config.starve_boost, 1.5);
13675 assert_eq!(config.min_cost_us, 1.0);
13676 }
13677
13678 #[test]
13683 fn effect_queue_config_default() {
13684 let config = EffectQueueConfig::default();
13685 assert!(!config.enabled);
13686 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
13687 assert!(!config.explicit_backend);
13688 assert!(config.scheduler.smith_enabled);
13689 assert!(!config.scheduler.force_fifo);
13690 assert!(!config.scheduler.preemptive);
13691 }
13692
13693 #[test]
13694 fn effect_queue_config_with_enabled() {
13695 let config = EffectQueueConfig::default().with_enabled(true);
13696 assert!(config.enabled);
13697 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
13698 assert!(config.explicit_backend);
13699 }
13700
13701 #[test]
13702 fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
13703 let config = EffectQueueConfig::default().with_enabled(false);
13704 assert!(!config.enabled);
13705 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
13706 assert!(config.explicit_backend);
13707 }
13708
13709 #[test]
13710 fn effect_queue_config_with_backend() {
13711 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
13712 assert!(config.enabled);
13713 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
13714 assert!(config.explicit_backend);
13715 }
13716
13717 #[cfg(feature = "asupersync-executor")]
13718 #[test]
13719 fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
13720 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
13721 assert!(!config.enabled);
13722 assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
13723 }
13724
13725 #[test]
13726 fn effect_queue_config_with_scheduler() {
13727 let sched = SchedulerConfig {
13728 force_fifo: true,
13729 ..Default::default()
13730 };
13731 let config = EffectQueueConfig::default().with_scheduler(sched);
13732 assert!(config.scheduler.force_fifo);
13733 }
13734
13735 #[test]
13740 fn inline_auto_remeasure_config_defaults() {
13741 let config = InlineAutoRemeasureConfig::default();
13742 assert_eq!(config.change_threshold_rows, 1);
13743 assert_eq!(config.voi.prior_alpha, 1.0);
13744 assert_eq!(config.voi.prior_beta, 9.0);
13745 assert_eq!(config.voi.max_interval_ms, 1000);
13746 assert_eq!(config.voi.min_interval_ms, 100);
13747 assert_eq!(config.voi.sample_cost, 0.08);
13748 }
13749
13750 #[test]
13755 fn headless_event_source_size() {
13756 let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
13757 assert_eq!(source.size().unwrap(), (120, 40));
13758 }
13759
13760 #[test]
13761 fn headless_event_source_poll_always_false() {
13762 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13763 assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
13764 }
13765
13766 #[test]
13767 fn headless_event_source_read_always_none() {
13768 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13769 assert!(source.read_event().unwrap().is_none());
13770 }
13771
13772 #[test]
13773 fn headless_event_source_set_features() {
13774 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
13775 let features = BackendFeatures {
13776 mouse_capture: true,
13777 bracketed_paste: true,
13778 focus_events: true,
13779 kitty_keyboard: true,
13780 };
13781 source.set_features(features).unwrap();
13782 assert_eq!(source.features, features);
13783 }
13784
13785 #[test]
13786 fn immediate_drain_budget_adds_backoff_poll_under_burst() {
13787 use ftui_core::event::{KeyCode, KeyEvent};
13788
13789 struct DrainBurstModel {
13790 processed: usize,
13791 quit_after: usize,
13792 }
13793
13794 #[derive(Debug)]
13795 #[allow(dead_code)]
13796 enum DrainBurstMsg {
13797 Event(Event),
13798 }
13799
13800 impl From<Event> for DrainBurstMsg {
13801 fn from(event: Event) -> Self {
13802 DrainBurstMsg::Event(event)
13803 }
13804 }
13805
13806 impl Model for DrainBurstModel {
13807 type Message = DrainBurstMsg;
13808
13809 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13810 match msg {
13811 DrainBurstMsg::Event(_) => {
13812 self.processed = self.processed.saturating_add(1);
13813 if self.processed >= self.quit_after {
13814 Cmd::quit()
13815 } else {
13816 Cmd::none()
13817 }
13818 }
13819 }
13820 }
13821
13822 fn view(&self, _frame: &mut Frame) {}
13823 }
13824
13825 struct DrainBurstEventSource {
13826 queue: VecDeque<Event>,
13827 poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
13828 size: (u16, u16),
13829 }
13830
13831 impl BackendEventSource for DrainBurstEventSource {
13832 type Error = io::Error;
13833
13834 fn size(&self) -> Result<(u16, u16), Self::Error> {
13835 Ok(self.size)
13836 }
13837
13838 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
13839 Ok(())
13840 }
13841
13842 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
13843 self.poll_timeouts.lock().unwrap().push(timeout);
13844 Ok(!self.queue.is_empty())
13845 }
13846
13847 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
13848 Ok(self.queue.pop_front())
13849 }
13850 }
13851
13852 let burst_events = 24usize;
13853 let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
13854 let mut queue = VecDeque::new();
13855 for _ in 0..burst_events {
13856 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
13857 }
13858
13859 let events = DrainBurstEventSource {
13860 queue,
13861 poll_timeouts: poll_timeouts.clone(),
13862 size: (80, 24),
13863 };
13864 let writer = TerminalWriter::new(
13865 Vec::<u8>::new(),
13866 ScreenMode::AltScreen,
13867 UiAnchor::Bottom,
13868 TerminalCapabilities::dumb(),
13869 );
13870 let config = ProgramConfig::default()
13871 .with_forced_size(80, 24)
13872 .with_signal_interception(false)
13873 .with_immediate_drain(ImmediateDrainConfig {
13874 max_zero_timeout_polls_per_burst: 3,
13875 max_burst_duration: Duration::from_secs(1),
13876 backoff_timeout: Duration::from_millis(1),
13877 });
13878
13879 let model = DrainBurstModel {
13880 processed: 0,
13881 quit_after: burst_events,
13882 };
13883 let mut program =
13884 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13885 .expect("program creation");
13886 program.run().expect("run burst");
13887
13888 assert_eq!(program.model().processed, burst_events);
13889
13890 let stats = program.immediate_drain_stats();
13891 assert_eq!(stats.bursts, 1);
13892 assert!(stats.capped_bursts >= 1);
13893 assert!(stats.backoff_polls >= 1);
13894 assert!(stats.zero_timeout_polls >= 1);
13895 assert!(stats.max_zero_timeout_polls_in_burst <= 3);
13896
13897 let timeouts = poll_timeouts.lock().unwrap();
13898 assert!(timeouts.contains(&Duration::ZERO));
13899 assert!(timeouts.contains(&Duration::from_millis(1)));
13900 }
13901
13902 #[test]
13903 fn immediate_drain_zero_poll_limit_is_clamped() {
13904 use ftui_core::event::{KeyCode, KeyEvent};
13905
13906 struct ClampModel {
13907 processed: usize,
13908 quit_after: usize,
13909 }
13910
13911 #[derive(Debug)]
13912 #[allow(dead_code)]
13913 enum ClampMsg {
13914 Event(Event),
13915 }
13916
13917 impl From<Event> for ClampMsg {
13918 fn from(event: Event) -> Self {
13919 ClampMsg::Event(event)
13920 }
13921 }
13922
13923 impl Model for ClampModel {
13924 type Message = ClampMsg;
13925
13926 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13927 match msg {
13928 ClampMsg::Event(_) => {
13929 self.processed = self.processed.saturating_add(1);
13930 if self.processed >= self.quit_after {
13931 Cmd::quit()
13932 } else {
13933 Cmd::none()
13934 }
13935 }
13936 }
13937 }
13938
13939 fn view(&self, _frame: &mut Frame) {}
13940 }
13941
13942 struct ClampSource {
13943 queue: VecDeque<Event>,
13944 }
13945
13946 impl BackendEventSource for ClampSource {
13947 type Error = io::Error;
13948
13949 fn size(&self) -> Result<(u16, u16), Self::Error> {
13950 Ok((80, 24))
13951 }
13952
13953 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
13954 Ok(())
13955 }
13956
13957 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
13958 Ok(!self.queue.is_empty())
13959 }
13960
13961 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
13962 Ok(self.queue.pop_front())
13963 }
13964 }
13965
13966 let burst_events = 8usize;
13967 let mut queue = VecDeque::new();
13968 for _ in 0..burst_events {
13969 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
13970 }
13971 let events = ClampSource { queue };
13972
13973 let writer = TerminalWriter::new(
13974 Vec::<u8>::new(),
13975 ScreenMode::AltScreen,
13976 UiAnchor::Bottom,
13977 TerminalCapabilities::dumb(),
13978 );
13979 let config = ProgramConfig::default()
13980 .with_forced_size(80, 24)
13981 .with_signal_interception(false)
13982 .with_immediate_drain(ImmediateDrainConfig {
13983 max_zero_timeout_polls_per_burst: 0,
13984 max_burst_duration: Duration::from_secs(1),
13985 backoff_timeout: Duration::from_millis(1),
13986 });
13987 let model = ClampModel {
13988 processed: 0,
13989 quit_after: burst_events,
13990 };
13991
13992 let mut program =
13993 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
13994 .expect("program creation");
13995 program.run().expect("run clamp");
13996
13997 let stats = program.immediate_drain_stats();
13998 assert!(stats.max_zero_timeout_polls_in_burst <= 1);
13999 }
14000
14001 #[test]
14002 fn quit_stops_draining_remaining_burst_events() {
14003 use ftui_core::event::{KeyCode, KeyEvent};
14004
14005 struct QuitBurstModel {
14006 processed: usize,
14007 quit_after: usize,
14008 }
14009
14010 #[derive(Debug)]
14011 #[allow(dead_code)]
14012 enum QuitBurstMsg {
14013 Event(Event),
14014 }
14015
14016 impl From<Event> for QuitBurstMsg {
14017 fn from(event: Event) -> Self {
14018 Self::Event(event)
14019 }
14020 }
14021
14022 impl Model for QuitBurstModel {
14023 type Message = QuitBurstMsg;
14024
14025 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14026 match msg {
14027 QuitBurstMsg::Event(_) => {
14028 self.processed = self.processed.saturating_add(1);
14029 if self.processed >= self.quit_after {
14030 Cmd::quit()
14031 } else {
14032 Cmd::none()
14033 }
14034 }
14035 }
14036 }
14037
14038 fn view(&self, _frame: &mut Frame) {}
14039 }
14040
14041 struct QuitBurstSource {
14042 queue: VecDeque<Event>,
14043 }
14044
14045 impl BackendEventSource for QuitBurstSource {
14046 type Error = io::Error;
14047
14048 fn size(&self) -> Result<(u16, u16), Self::Error> {
14049 Ok((80, 24))
14050 }
14051
14052 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14053 Ok(())
14054 }
14055
14056 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14057 Ok(!self.queue.is_empty())
14058 }
14059
14060 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14061 Ok(self.queue.pop_front())
14062 }
14063 }
14064
14065 let total_events = 8usize;
14066 let quit_after = 3usize;
14067 let mut queue = VecDeque::new();
14068 for _ in 0..total_events {
14069 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
14070 }
14071
14072 let writer = TerminalWriter::new(
14073 Vec::<u8>::new(),
14074 ScreenMode::AltScreen,
14075 UiAnchor::Bottom,
14076 TerminalCapabilities::dumb(),
14077 );
14078 let config = ProgramConfig::default()
14079 .with_forced_size(80, 24)
14080 .with_signal_interception(false)
14081 .with_immediate_drain(ImmediateDrainConfig {
14082 max_zero_timeout_polls_per_burst: 64,
14083 max_burst_duration: Duration::from_secs(1),
14084 backoff_timeout: Duration::from_millis(1),
14085 });
14086 let model = QuitBurstModel {
14087 processed: 0,
14088 quit_after,
14089 };
14090 let events = QuitBurstSource { queue };
14091
14092 let mut program =
14093 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14094 .expect("program creation");
14095 program.run().expect("run burst quit");
14096
14097 assert_eq!(program.model().processed, quit_after);
14098 }
14099
14100 #[test]
14105 fn headless_program_quit_and_is_running() {
14106 let mut program =
14107 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14108 assert!(program.is_running());
14109
14110 program.quit();
14111 assert!(!program.is_running());
14112 }
14113
14114 #[test]
14115 fn headless_program_model_mut() {
14116 let mut program =
14117 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14118 assert_eq!(program.model().value, 0);
14119
14120 program.model_mut().value = 42;
14121 assert_eq!(program.model().value, 42);
14122 }
14123
14124 #[test]
14125 fn headless_program_request_redraw() {
14126 let mut program =
14127 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14128 program.dirty = false;
14129
14130 program.request_redraw();
14131 assert!(program.dirty);
14132 }
14133
14134 #[test]
14135 fn headless_program_last_widget_signals_initially_empty() {
14136 let program =
14137 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14138 assert!(program.last_widget_signals().is_empty());
14139 }
14140
14141 #[test]
14142 fn headless_program_no_persistence_by_default() {
14143 let program =
14144 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14145 assert!(!program.has_persistence());
14146 assert!(program.state_registry().is_none());
14147 }
14148
14149 #[test]
14154 fn classify_event_fairness_key_is_input() {
14155 let event = Event::Key(ftui_core::event::KeyEvent::new(
14156 ftui_core::event::KeyCode::Char('a'),
14157 ));
14158 let classification =
14159 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14160 assert_eq!(classification, FairnessEventType::Input);
14161 }
14162
14163 #[test]
14164 fn classify_event_fairness_resize_is_resize() {
14165 let event = Event::Resize {
14166 width: 80,
14167 height: 24,
14168 };
14169 let classification =
14170 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14171 assert_eq!(classification, FairnessEventType::Resize);
14172 }
14173
14174 #[test]
14175 fn classify_event_fairness_tick_is_tick() {
14176 let event = Event::Tick;
14177 let classification =
14178 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14179 assert_eq!(classification, FairnessEventType::Tick);
14180 }
14181
14182 #[test]
14183 fn classify_event_fairness_paste_is_input() {
14184 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
14185 let classification =
14186 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14187 assert_eq!(classification, FairnessEventType::Input);
14188 }
14189
14190 #[test]
14191 fn classify_event_fairness_focus_is_input() {
14192 let event = Event::Focus(true);
14193 let classification =
14194 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14195 assert_eq!(classification, FairnessEventType::Input);
14196 }
14197
14198 #[test]
14203 fn program_config_with_diff_config() {
14204 let diff = RuntimeDiffConfig::default();
14205 let config = ProgramConfig::default().with_diff_config(diff.clone());
14206 let _ = format!("{:?}", config);
14208 }
14209
14210 #[test]
14211 fn program_config_with_evidence_sink() {
14212 let config =
14213 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
14214 let _ = format!("{:?}", config);
14215 }
14216
14217 #[test]
14218 fn program_config_with_render_trace() {
14219 let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
14220 let _ = format!("{:?}", config);
14221 }
14222
14223 #[test]
14224 fn program_config_with_locale() {
14225 let config = ProgramConfig::default().with_locale("fr");
14226 let _ = format!("{:?}", config);
14227 }
14228
14229 #[test]
14230 fn program_config_with_locale_context() {
14231 let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
14232 let _ = format!("{:?}", config);
14233 }
14234
14235 #[test]
14236 fn program_config_without_forced_size() {
14237 let config = ProgramConfig::default()
14238 .with_forced_size(80, 24)
14239 .without_forced_size();
14240 assert!(config.forced_size.is_none());
14241 }
14242
14243 #[test]
14244 fn program_config_forced_size_clamps_min() {
14245 let config = ProgramConfig::default().with_forced_size(0, 0);
14246 assert_eq!(config.forced_size, Some((1, 1)));
14247 }
14248
14249 #[test]
14250 fn program_config_with_widget_refresh() {
14251 let wrc = WidgetRefreshConfig {
14252 enabled: false,
14253 ..Default::default()
14254 };
14255 let config = ProgramConfig::default().with_widget_refresh(wrc);
14256 assert!(!config.widget_refresh.enabled);
14257 }
14258
14259 #[test]
14260 fn program_config_with_effect_queue() {
14261 let eqc = EffectQueueConfig::default().with_enabled(true);
14262 let config = ProgramConfig::default().with_effect_queue(eqc);
14263 assert!(config.effect_queue.enabled);
14264 assert_eq!(
14265 config.effect_queue.backend,
14266 TaskExecutorBackend::EffectQueue
14267 );
14268 }
14269
14270 #[test]
14271 fn program_config_with_resize_coalescer_custom() {
14272 let cc = CoalescerConfig {
14273 steady_delay_ms: 42,
14274 ..Default::default()
14275 };
14276 let config = ProgramConfig::default().with_resize_coalescer(cc);
14277 assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
14278 }
14279
14280 #[test]
14281 fn program_config_with_inline_auto_remeasure() {
14282 let config = ProgramConfig::default()
14283 .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
14284 assert!(config.inline_auto_remeasure.is_some());
14285
14286 let config = config.without_inline_auto_remeasure();
14287 assert!(config.inline_auto_remeasure.is_none());
14288 }
14289
14290 #[test]
14291 fn program_config_with_persistence_full() {
14292 let pc = PersistenceConfig::disabled();
14293 let config = ProgramConfig::default().with_persistence(pc);
14294 assert!(config.persistence.registry.is_none());
14295 }
14296
14297 #[test]
14298 fn program_config_with_conformal_config() {
14299 let config = ProgramConfig::default()
14300 .with_conformal_config(ConformalConfig::default())
14301 .without_conformal();
14302 assert!(config.conformal_config.is_none());
14303 }
14304
14305 #[test]
14310 fn program_config_with_lane() {
14311 let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
14312 assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
14313 }
14314
14315 #[test]
14316 fn program_config_default_lane_resolves_to_spawned_backend() {
14317 let resolved = ProgramConfig::default().resolved_effect_queue_config();
14325 assert!(!resolved.enabled);
14326 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14327 }
14328
14329 #[test]
14330 fn program_config_legacy_lane_resolves_to_spawned_backend() {
14331 let resolved = ProgramConfig::default()
14332 .with_lane(RuntimeLane::Legacy)
14333 .resolved_effect_queue_config();
14334 assert!(!resolved.enabled);
14335 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14336 }
14337
14338 #[test]
14339 fn program_config_explicit_spawned_backend_is_preserved() {
14340 let resolved = ProgramConfig::default()
14341 .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
14342 .resolved_effect_queue_config();
14343 assert!(!resolved.enabled);
14344 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14345 }
14346
14347 #[test]
14348 fn program_config_with_rollout_policy() {
14349 let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
14350 assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
14351 }
14352
14353 #[test]
14354 fn rollout_policy_labels() {
14355 assert_eq!(RolloutPolicy::Off.label(), "off");
14356 assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
14357 assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
14358 assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
14359 }
14360
14361 #[test]
14362 fn rollout_policy_is_shadow() {
14363 assert!(!RolloutPolicy::Off.is_shadow());
14364 assert!(RolloutPolicy::Shadow.is_shadow());
14365 assert!(!RolloutPolicy::Enabled.is_shadow());
14366 }
14367
14368 #[test]
14369 fn rollout_policy_default_is_off() {
14370 assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
14371 }
14372
14373 #[test]
14374 fn runtime_lane_parse_legacy() {
14375 assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
14376 }
14377
14378 #[test]
14379 fn runtime_lane_parse_structured_case_insensitive() {
14380 assert_eq!(
14381 RuntimeLane::parse("Structured"),
14382 Some(RuntimeLane::Structured)
14383 );
14384 }
14385
14386 #[test]
14387 fn runtime_lane_parse_asupersync_uppercase() {
14388 assert_eq!(
14389 RuntimeLane::parse("ASUPERSYNC"),
14390 Some(RuntimeLane::Asupersync)
14391 );
14392 }
14393
14394 #[test]
14395 fn runtime_lane_parse_unrecognized() {
14396 assert_eq!(RuntimeLane::parse("bogus"), None);
14397 }
14398
14399 #[test]
14400 fn rollout_policy_parse_shadow() {
14401 assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
14402 }
14403
14404 #[test]
14405 fn rollout_policy_parse_enabled() {
14406 assert_eq!(
14407 RolloutPolicy::parse("enabled"),
14408 Some(RolloutPolicy::Enabled)
14409 );
14410 }
14411
14412 #[test]
14413 fn rollout_policy_parse_off() {
14414 assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
14415 }
14416
14417 #[test]
14418 fn rollout_policy_parse_unrecognized() {
14419 assert_eq!(RolloutPolicy::parse("bogus"), None);
14420 }
14421
14422 #[test]
14427 fn persistence_config_debug() {
14428 let config = PersistenceConfig::default();
14429 let debug = format!("{config:?}");
14430 assert!(debug.contains("PersistenceConfig"));
14431 assert!(debug.contains("auto_load"));
14432 assert!(debug.contains("auto_save"));
14433 }
14434
14435 #[test]
14440 fn frame_timing_config_debug() {
14441 use std::sync::Arc;
14442
14443 struct DummySink;
14444 impl FrameTimingSink for DummySink {
14445 fn record_frame(&self, _timing: &FrameTiming) {}
14446 }
14447
14448 let config = FrameTimingConfig::new(Arc::new(DummySink));
14449 let debug = format!("{config:?}");
14450 assert!(debug.contains("FrameTimingConfig"));
14451 }
14452
14453 #[test]
14454 fn program_config_with_frame_timing() {
14455 use std::sync::Arc;
14456
14457 struct DummySink;
14458 impl FrameTimingSink for DummySink {
14459 fn record_frame(&self, _timing: &FrameTiming) {}
14460 }
14461
14462 let config =
14463 ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
14464 assert!(config.frame_timing.is_some());
14465 }
14466
14467 #[test]
14472 fn budget_decision_evidence_decision_from_levels() {
14473 use ftui_render::budget::DegradationLevel;
14474 assert_eq!(
14476 BudgetDecisionEvidence::decision_from_levels(
14477 DegradationLevel::Full,
14478 DegradationLevel::SimpleBorders
14479 ),
14480 BudgetDecision::Degrade
14481 );
14482 assert_eq!(
14484 BudgetDecisionEvidence::decision_from_levels(
14485 DegradationLevel::SimpleBorders,
14486 DegradationLevel::Full
14487 ),
14488 BudgetDecision::Upgrade
14489 );
14490 assert_eq!(
14492 BudgetDecisionEvidence::decision_from_levels(
14493 DegradationLevel::Full,
14494 DegradationLevel::Full
14495 ),
14496 BudgetDecision::Hold
14497 );
14498 }
14499
14500 #[test]
14505 fn widget_refresh_plan_clear() {
14506 let mut plan = WidgetRefreshPlan::new();
14507 plan.frame_idx = 5;
14508 plan.budget_us = 100.0;
14509 plan.signal_count = 3;
14510 plan.over_budget = true;
14511 plan.clear();
14512 assert_eq!(plan.frame_idx, 0);
14513 assert_eq!(plan.budget_us, 0.0);
14514 assert_eq!(plan.signal_count, 0);
14515 assert!(!plan.over_budget);
14516 }
14517
14518 #[test]
14519 fn widget_refresh_plan_as_budget_empty_signals() {
14520 let plan = WidgetRefreshPlan::new();
14521 let budget = plan.as_budget();
14522 assert!(budget.allows(0, false));
14524 assert!(budget.allows(999, false));
14525 }
14526
14527 #[test]
14528 fn widget_refresh_plan_to_jsonl_structure() {
14529 let plan = WidgetRefreshPlan::new();
14530 let jsonl = plan.to_jsonl();
14531 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
14532 assert!(jsonl.contains("\"frame_idx\":0"));
14533 assert!(jsonl.contains("\"selected\":[]"));
14534 }
14535
14536 #[test]
14541 fn batch_controller_default_trait() {
14542 let bc = BatchController::default();
14543 let bc2 = BatchController::new();
14544 assert_eq!(bc.tau_s(), bc2.tau_s());
14546 assert_eq!(bc.observations(), bc2.observations());
14547 }
14548
14549 #[test]
14550 fn batch_controller_observe_arrival_stale_gap_ignored() {
14551 let mut bc = BatchController::new();
14552 let base = Instant::now();
14553 bc.observe_arrival(base);
14555 bc.observe_arrival(base + Duration::from_secs(15));
14557 assert_eq!(bc.observations(), 0);
14558 }
14559
14560 #[test]
14561 fn batch_controller_observe_service_out_of_range() {
14562 let mut bc = BatchController::new();
14563 let original_service = bc.service_est_s();
14564 bc.observe_service(Duration::from_secs(15));
14566 assert_eq!(bc.service_est_s(), original_service);
14567 }
14568
14569 #[test]
14570 fn batch_controller_lambda_zero_inter_arrival() {
14571 let bc = BatchController {
14573 ema_inter_arrival_s: 0.0,
14574 ..BatchController::new()
14575 };
14576 assert_eq!(bc.lambda_est(), 0.0);
14577 }
14578
14579 #[test]
14584 fn headless_execute_cmd_log_appends_newline_if_missing() {
14585 let mut program =
14586 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14587 program.execute_cmd(Cmd::log("no newline")).expect("log");
14588
14589 let bytes = program.writer.into_inner().expect("writer output");
14590 let output = String::from_utf8_lossy(&bytes);
14591 assert!(output.contains("no newline"));
14593 }
14594
14595 #[test]
14596 fn headless_execute_cmd_log_preserves_trailing_newline() {
14597 let mut program =
14598 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14599 program
14600 .execute_cmd(Cmd::log("with newline\n"))
14601 .expect("log");
14602
14603 let bytes = program.writer.into_inner().expect("writer output");
14604 let output = String::from_utf8_lossy(&bytes);
14605 assert!(output.contains("with newline"));
14606 }
14607
14608 #[test]
14613 fn headless_handle_event_immediate_resize() {
14614 struct ResizeModel {
14615 last_size: Option<(u16, u16)>,
14616 }
14617
14618 #[derive(Debug)]
14619 enum ResizeMsg {
14620 Resize(u16, u16),
14621 Other,
14622 }
14623
14624 impl From<Event> for ResizeMsg {
14625 fn from(event: Event) -> Self {
14626 match event {
14627 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
14628 _ => ResizeMsg::Other,
14629 }
14630 }
14631 }
14632
14633 impl Model for ResizeModel {
14634 type Message = ResizeMsg;
14635
14636 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14637 if let ResizeMsg::Resize(w, h) = msg {
14638 self.last_size = Some((w, h));
14639 }
14640 Cmd::none()
14641 }
14642
14643 fn view(&self, _frame: &mut Frame) {}
14644 }
14645
14646 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
14647 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
14648
14649 program
14650 .handle_event(Event::Resize {
14651 width: 120,
14652 height: 40,
14653 })
14654 .expect("handle resize");
14655
14656 assert_eq!(program.width, 120);
14657 assert_eq!(program.height, 40);
14658 assert_eq!(program.model().last_size, Some((120, 40)));
14659 }
14660
14661 #[test]
14666 fn headless_apply_resize_clamps_zero_to_one() {
14667 struct SimpleModel;
14668
14669 #[derive(Debug)]
14670 enum SimpleMsg {
14671 Noop,
14672 }
14673
14674 impl From<Event> for SimpleMsg {
14675 fn from(_: Event) -> Self {
14676 SimpleMsg::Noop
14677 }
14678 }
14679
14680 impl Model for SimpleModel {
14681 type Message = SimpleMsg;
14682
14683 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
14684 Cmd::none()
14685 }
14686
14687 fn view(&self, _frame: &mut Frame) {}
14688 }
14689
14690 let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
14691 program
14692 .apply_resize(0, 0, Duration::ZERO, false)
14693 .expect("resize");
14694
14695 assert_eq!(program.width, 1);
14697 assert_eq!(program.height, 1);
14698 }
14699
14700 #[test]
14705 fn force_cancel_all_idle_returns_none() {
14706 let mut adapter =
14707 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14708 assert!(adapter.force_cancel_all().is_none());
14709 }
14710
14711 #[test]
14712 fn force_cancel_all_after_pointer_down_returns_diagnostics() {
14713 let mut adapter =
14714 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14715 let target = pane_target(SplitAxis::Horizontal);
14716
14717 let down = Event::Mouse(MouseEvent::new(
14718 MouseEventKind::Down(MouseButton::Left),
14719 5,
14720 5,
14721 ));
14722 let _ = adapter.translate(&down, Some(target));
14723 assert!(adapter.active_pointer_id().is_some());
14724
14725 let diag = adapter
14726 .force_cancel_all()
14727 .expect("should produce diagnostics");
14728 assert!(diag.had_active_pointer);
14729 assert_eq!(diag.active_pointer_id, Some(1));
14730 assert!(diag.machine_transition.is_some());
14731
14732 assert_eq!(adapter.active_pointer_id(), None);
14734 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
14735 }
14736
14737 #[test]
14738 fn force_cancel_all_during_drag_returns_diagnostics() {
14739 let mut adapter =
14740 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14741 let target = pane_target(SplitAxis::Vertical);
14742
14743 let down = Event::Mouse(MouseEvent::new(
14745 MouseEventKind::Down(MouseButton::Left),
14746 3,
14747 3,
14748 ));
14749 let _ = adapter.translate(&down, Some(target));
14750
14751 let drag = Event::Mouse(MouseEvent::new(
14753 MouseEventKind::Drag(MouseButton::Left),
14754 8,
14755 3,
14756 ));
14757 let _ = adapter.translate(&drag, None);
14758
14759 let diag = adapter
14760 .force_cancel_all()
14761 .expect("should produce diagnostics");
14762 assert!(diag.had_active_pointer);
14763 assert!(diag.machine_transition.is_some());
14764 let transition = diag.machine_transition.unwrap();
14765 assert!(matches!(
14766 transition.effect,
14767 PaneDragResizeEffect::Canceled {
14768 reason: PaneCancelReason::Programmatic,
14769 ..
14770 }
14771 ));
14772 }
14773
14774 #[test]
14775 fn force_cancel_all_is_idempotent() {
14776 let mut adapter =
14777 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14778 let target = pane_target(SplitAxis::Horizontal);
14779
14780 let down = Event::Mouse(MouseEvent::new(
14781 MouseEventKind::Down(MouseButton::Left),
14782 5,
14783 5,
14784 ));
14785 let _ = adapter.translate(&down, Some(target));
14786
14787 let first = adapter.force_cancel_all();
14788 assert!(first.is_some());
14789
14790 let second = adapter.force_cancel_all();
14791 assert!(second.is_none());
14792 }
14793
14794 #[test]
14799 fn pane_interaction_guard_finish_when_idle() {
14800 let mut adapter =
14801 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14802 let guard = PaneInteractionGuard::new(&mut adapter);
14803 let diag = guard.finish();
14804 assert!(diag.is_none());
14805 }
14806
14807 #[test]
14808 fn pane_interaction_guard_finish_returns_diagnostics() {
14809 let mut adapter =
14810 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14811 let target = pane_target(SplitAxis::Horizontal);
14812
14813 let down = Event::Mouse(MouseEvent::new(
14815 MouseEventKind::Down(MouseButton::Left),
14816 5,
14817 5,
14818 ));
14819 let _ = adapter.translate(&down, Some(target));
14820
14821 let guard = PaneInteractionGuard::new(&mut adapter);
14822 let diag = guard.finish().expect("should produce diagnostics");
14823 assert!(diag.had_active_pointer);
14824 assert_eq!(diag.active_pointer_id, Some(1));
14825 }
14826
14827 #[test]
14828 fn pane_interaction_guard_drop_cancels_active_interaction() {
14829 let mut adapter =
14830 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14831 let target = pane_target(SplitAxis::Vertical);
14832
14833 let down = Event::Mouse(MouseEvent::new(
14834 MouseEventKind::Down(MouseButton::Left),
14835 7,
14836 7,
14837 ));
14838 let _ = adapter.translate(&down, Some(target));
14839 assert!(adapter.active_pointer_id().is_some());
14840
14841 {
14842 let _guard = PaneInteractionGuard::new(&mut adapter);
14843 }
14845
14846 assert_eq!(adapter.active_pointer_id(), None);
14848 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
14849 }
14850
14851 #[test]
14852 fn pane_interaction_guard_adapter_access_works() {
14853 let mut adapter =
14854 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14855 let target = pane_target(SplitAxis::Horizontal);
14856
14857 let mut guard = PaneInteractionGuard::new(&mut adapter);
14858
14859 let down = Event::Mouse(MouseEvent::new(
14861 MouseEventKind::Down(MouseButton::Left),
14862 5,
14863 5,
14864 ));
14865 let dispatch = guard.adapter().translate(&down, Some(target));
14866 assert!(dispatch.primary_event.is_some());
14867
14868 let diag = guard.finish().expect("should produce diagnostics");
14870 assert!(diag.had_active_pointer);
14871 }
14872
14873 #[test]
14874 fn pane_interaction_guard_finish_then_drop_is_safe() {
14875 let mut adapter =
14876 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
14877 let target = pane_target(SplitAxis::Horizontal);
14878
14879 let down = Event::Mouse(MouseEvent::new(
14880 MouseEventKind::Down(MouseButton::Left),
14881 5,
14882 5,
14883 ));
14884 let _ = adapter.translate(&down, Some(target));
14885
14886 let guard = PaneInteractionGuard::new(&mut adapter);
14887 let _diag = guard.finish();
14888 assert_eq!(adapter.active_pointer_id(), None);
14891 }
14892
14893 fn caps_modern() -> TerminalCapabilities {
14898 TerminalCapabilities::modern()
14899 }
14900
14901 fn caps_with_mux(
14902 mux: PaneMuxEnvironment,
14903 ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
14904 let mut caps = TerminalCapabilities::modern();
14905 match mux {
14906 PaneMuxEnvironment::Tmux => caps.in_tmux = true,
14907 PaneMuxEnvironment::Screen => caps.in_screen = true,
14908 PaneMuxEnvironment::Zellij => caps.in_zellij = true,
14909 PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
14910 PaneMuxEnvironment::None => {}
14911 }
14912 caps
14913 }
14914
14915 #[test]
14916 fn capability_matrix_bare_terminal_modern() {
14917 let caps = caps_modern();
14918 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14919
14920 assert_eq!(mat.mux, PaneMuxEnvironment::None);
14921 assert!(mat.mouse_sgr);
14922 assert!(mat.mouse_drag_reliable);
14923 assert!(mat.mouse_button_discrimination);
14924 assert!(mat.focus_events);
14925 assert!(mat.unicode_box_drawing);
14926 assert!(mat.true_color);
14927 assert!(!mat.degraded);
14928 assert!(mat.drag_enabled());
14929 assert!(mat.focus_cancel_effective());
14930 assert!(mat.limitations().is_empty());
14931 }
14932
14933 #[test]
14934 fn capability_matrix_tmux() {
14935 let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
14936 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14937
14938 assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
14939 assert!(mat.mouse_drag_reliable);
14941 assert!(!mat.focus_events);
14942 assert!(mat.drag_enabled());
14943 assert!(!mat.focus_cancel_effective());
14944 assert!(mat.degraded);
14945 }
14946
14947 #[test]
14948 fn capability_matrix_screen_degrades_drag() {
14949 let caps = caps_with_mux(PaneMuxEnvironment::Screen);
14950 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14951
14952 assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
14953 assert!(!mat.mouse_drag_reliable);
14954 assert!(!mat.focus_events);
14955 assert!(!mat.drag_enabled());
14956 assert!(!mat.focus_cancel_effective());
14957 assert!(mat.degraded);
14958
14959 let lims = mat.limitations();
14960 assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
14961 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
14962 }
14963
14964 #[test]
14965 fn capability_matrix_zellij() {
14966 let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
14967 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14968
14969 assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
14970 assert!(mat.mouse_drag_reliable);
14971 assert!(!mat.focus_events);
14972 assert!(mat.drag_enabled());
14973 assert!(!mat.focus_cancel_effective());
14974 assert!(mat.degraded);
14975 }
14976
14977 #[test]
14978 fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
14979 let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
14980 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14981
14982 assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
14983 assert!(mat.mouse_drag_reliable);
14984 assert!(!mat.focus_events);
14985 assert!(mat.drag_enabled());
14986 assert!(!mat.focus_cancel_effective());
14987 assert!(mat.degraded);
14988 }
14989
14990 #[test]
14991 fn capability_matrix_no_sgr_mouse() {
14992 let mut caps = caps_modern();
14993 caps.mouse_sgr = false;
14994 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
14995
14996 assert!(!mat.mouse_sgr);
14997 assert!(!mat.mouse_button_discrimination);
14998 assert!(mat.degraded);
14999
15000 let lims = mat.limitations();
15001 assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
15002 assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
15003 }
15004
15005 #[test]
15006 fn capability_matrix_no_focus_events() {
15007 let mut caps = caps_modern();
15008 caps.focus_events = false;
15009 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15010
15011 assert!(!mat.focus_events);
15012 assert!(!mat.focus_cancel_effective());
15013 assert!(mat.degraded);
15014
15015 let lims = mat.limitations();
15016 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15017 }
15018
15019 #[test]
15020 fn capability_matrix_dumb_terminal() {
15021 let caps = TerminalCapabilities::dumb();
15022 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15023
15024 assert_eq!(mat.mux, PaneMuxEnvironment::None);
15025 assert!(!mat.mouse_sgr);
15026 assert!(!mat.focus_events);
15027 assert!(!mat.unicode_box_drawing);
15028 assert!(!mat.true_color);
15029 assert!(mat.degraded);
15030 assert!(mat.limitations().len() >= 3);
15031 }
15032
15033 #[test]
15034 fn capability_matrix_limitations_have_fallbacks() {
15035 let caps = TerminalCapabilities::dumb();
15036 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15037
15038 for lim in mat.limitations() {
15039 assert!(!lim.id.is_empty());
15040 assert!(!lim.description.is_empty());
15041 assert!(!lim.fallback.is_empty());
15042 }
15043 }
15044
15045 struct MultiScreenModel {
15052 active: String,
15053 screens: Vec<String>,
15054 ticked_screens: Vec<(String, u64)>,
15055 }
15056
15057 #[derive(Debug)]
15058 enum MultiScreenMsg {
15059 #[expect(dead_code)]
15060 Event(Event),
15061 }
15062
15063 impl From<Event> for MultiScreenMsg {
15064 fn from(event: Event) -> Self {
15065 MultiScreenMsg::Event(event)
15066 }
15067 }
15068
15069 impl Model for MultiScreenModel {
15070 type Message = MultiScreenMsg;
15071
15072 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15073 match msg {
15074 MultiScreenMsg::Event(_) => Cmd::none(),
15075 }
15076 }
15077
15078 fn view(&self, _frame: &mut Frame) {}
15079
15080 fn as_screen_tick_dispatch(
15081 &mut self,
15082 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15083 Some(self)
15084 }
15085 }
15086
15087 impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
15088 fn screen_ids(&self) -> Vec<String> {
15089 self.screens.clone()
15090 }
15091
15092 fn active_screen_id(&self) -> String {
15093 self.active.clone()
15094 }
15095
15096 fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
15097 self.ticked_screens.push((screen_id.to_owned(), tick_count));
15098 }
15099 }
15100
15101 type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
15103
15104 struct RecordingStrategy {
15107 log: TransitionLog,
15108 }
15109
15110 impl RecordingStrategy {
15111 fn new(log: TransitionLog) -> Self {
15112 Self { log }
15113 }
15114 }
15115
15116 impl crate::tick_strategy::TickStrategy for RecordingStrategy {
15117 fn should_tick(
15118 &mut self,
15119 _screen_id: &str,
15120 _tick_count: u64,
15121 _active_screen: &str,
15122 ) -> crate::tick_strategy::TickDecision {
15123 crate::tick_strategy::TickDecision::Skip
15124 }
15125
15126 fn on_screen_transition(&mut self, from: &str, to: &str) {
15127 self.log
15128 .lock()
15129 .unwrap()
15130 .push((from.to_owned(), to.to_owned()));
15131 }
15132
15133 fn name(&self) -> &str {
15134 "Recording"
15135 }
15136
15137 fn debug_stats(&self) -> Vec<(String, String)> {
15138 vec![("strategy".into(), "Recording".into())]
15139 }
15140 }
15141
15142 fn headless_multi_screen_program(
15146 active: &str,
15147 screens: &[&str],
15148 ) -> (
15149 Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
15150 TransitionLog,
15151 ) {
15152 let model = MultiScreenModel {
15153 active: active.to_owned(),
15154 screens: screens.iter().map(|s| (*s).to_owned()).collect(),
15155 ticked_screens: Vec::new(),
15156 };
15157 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15158 let writer = TerminalWriter::new(
15159 Vec::<u8>::new(),
15160 ScreenMode::AltScreen,
15161 UiAnchor::Bottom,
15162 TerminalCapabilities::dumb(),
15163 );
15164 let config = ProgramConfig {
15165 forced_size: Some((80, 24)),
15166 tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
15167 ..ProgramConfig::default()
15168 };
15169 let mut prog =
15170 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15171 .expect("headless program creation failed");
15172
15173 let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
15175 prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
15176
15177 (prog, log)
15178 }
15179
15180 #[test]
15181 fn check_screen_transition_first_call_records_active() {
15182 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15183
15184 assert!(prog.last_active_screen_for_strategy.is_none());
15185 prog.check_screen_transition();
15186 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15187
15188 assert!(prog.model.ticked_screens.is_empty());
15190 assert!(log.lock().unwrap().is_empty());
15191 }
15192
15193 #[test]
15194 fn check_screen_transition_no_change_is_noop() {
15195 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15196
15197 prog.check_screen_transition();
15199
15200 prog.check_screen_transition();
15202 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15203
15204 assert!(prog.model.ticked_screens.is_empty());
15206 assert!(log.lock().unwrap().is_empty());
15207 }
15208
15209 #[test]
15210 fn check_screen_transition_detects_switch_and_force_ticks() {
15211 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15212
15213 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15217 prog.check_screen_transition();
15218
15219 assert_eq!(prog.model.ticked_screens.len(), 1);
15221 assert_eq!(prog.model.ticked_screens[0].0, "B");
15222
15223 let transitions = log.lock().unwrap();
15225 assert_eq!(transitions.len(), 1);
15226 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15227
15228 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
15230 }
15231
15232 #[test]
15233 fn check_screen_transition_marks_dirty_on_change() {
15234 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15235
15236 prog.check_screen_transition();
15237 prog.dirty = false;
15238
15239 prog.model.active = "B".to_owned();
15240 prog.check_screen_transition();
15241
15242 assert!(prog.dirty);
15243 }
15244
15245 #[test]
15246 fn check_screen_transition_not_dirty_when_unchanged() {
15247 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15248
15249 prog.check_screen_transition();
15250 prog.dirty = false;
15251
15252 prog.check_screen_transition();
15253
15254 assert!(!prog.dirty);
15255 }
15256
15257 #[test]
15258 fn check_screen_transition_noop_without_strategy() {
15259 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15260
15261 prog.tick_strategy = None;
15263
15264 prog.check_screen_transition();
15265 assert!(prog.last_active_screen_for_strategy.is_none());
15266 }
15267
15268 #[test]
15269 fn check_screen_transition_multiple_switches_notifies_strategy() {
15270 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15271
15272 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15276 prog.check_screen_transition();
15277 assert_eq!(prog.model.ticked_screens.len(), 1);
15278 assert_eq!(prog.model.ticked_screens[0].0, "B");
15279
15280 prog.model.active = "C".to_owned();
15282 prog.check_screen_transition();
15283 assert_eq!(prog.model.ticked_screens.len(), 2);
15284 assert_eq!(prog.model.ticked_screens[1].0, "C");
15285
15286 prog.model.active = "A".to_owned();
15288 prog.check_screen_transition();
15289 assert_eq!(prog.model.ticked_screens.len(), 3);
15290 assert_eq!(prog.model.ticked_screens[2].0, "A");
15291
15292 let transitions = log.lock().unwrap();
15294 assert_eq!(transitions.len(), 3);
15295 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15296 assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
15297 assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
15298 }
15299
15300 #[test]
15301 fn check_screen_transition_uses_current_tick_count() {
15302 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15303 prog.tick_count = 42;
15304
15305 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15308 prog.check_screen_transition();
15309
15310 assert_eq!(prog.model.ticked_screens[0].1, 42);
15312 }
15313
15314 #[test]
15315 fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
15316 use crate::subscription::{StopSignal, SubId, Subscription};
15317
15318 struct TransitionSubModel {
15319 active: String,
15320 screens: Vec<String>,
15321 subscribed: bool,
15322 }
15323
15324 #[derive(Debug)]
15325 #[allow(dead_code)]
15326 enum TransitionSubMsg {
15327 Event(Event),
15328 }
15329
15330 impl From<Event> for TransitionSubMsg {
15331 fn from(event: Event) -> Self {
15332 Self::Event(event)
15333 }
15334 }
15335
15336 impl Model for TransitionSubModel {
15337 type Message = TransitionSubMsg;
15338
15339 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15340 Cmd::none()
15341 }
15342
15343 fn view(&self, _frame: &mut Frame) {}
15344
15345 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
15346 if self.subscribed {
15347 vec![Box::new(TransitionSubscription)]
15348 } else {
15349 vec![]
15350 }
15351 }
15352
15353 fn as_screen_tick_dispatch(
15354 &mut self,
15355 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15356 Some(self)
15357 }
15358 }
15359
15360 impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
15361 fn screen_ids(&self) -> Vec<String> {
15362 self.screens.clone()
15363 }
15364
15365 fn active_screen_id(&self) -> String {
15366 self.active.clone()
15367 }
15368
15369 fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
15370 if screen_id == self.active {
15371 self.subscribed = true;
15372 }
15373 }
15374 }
15375
15376 struct TransitionSubscription;
15377
15378 impl Subscription<TransitionSubMsg> for TransitionSubscription {
15379 fn id(&self) -> SubId {
15380 1
15381 }
15382
15383 fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
15384 }
15385
15386 struct TransitionStrategy;
15387
15388 impl crate::tick_strategy::TickStrategy for TransitionStrategy {
15389 fn should_tick(
15390 &mut self,
15391 _screen_id: &str,
15392 _tick_count: u64,
15393 _active_screen: &str,
15394 ) -> crate::tick_strategy::TickDecision {
15395 crate::tick_strategy::TickDecision::Skip
15396 }
15397
15398 fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
15399
15400 fn name(&self) -> &str {
15401 "TransitionStrategy"
15402 }
15403
15404 fn debug_stats(&self) -> Vec<(String, String)> {
15405 vec![]
15406 }
15407 }
15408
15409 let model = TransitionSubModel {
15410 active: "A".to_owned(),
15411 screens: vec!["A".to_owned(), "B".to_owned()],
15412 subscribed: false,
15413 };
15414 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15415 let writer = TerminalWriter::new(
15416 Vec::<u8>::new(),
15417 ScreenMode::AltScreen,
15418 UiAnchor::Bottom,
15419 TerminalCapabilities::dumb(),
15420 );
15421 let config = ProgramConfig::default().with_forced_size(80, 24);
15422
15423 let mut program =
15424 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15425 .expect("program creation");
15426 program.tick_strategy = Some(Box::new(TransitionStrategy));
15427
15428 program.check_screen_transition();
15429 assert_eq!(program.subscriptions.active_count(), 0);
15430
15431 program.model.active = "B".to_owned();
15432 program.check_screen_transition();
15433
15434 assert!(program.model().subscribed);
15435 assert_eq!(program.subscriptions.active_count(), 1);
15436 }
15437
15438 #[test]
15439 fn tick_strategy_stats_returns_empty_without_strategy() {
15440 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15441 prog.tick_strategy = None;
15442 assert!(prog.tick_strategy_stats().is_empty());
15443 }
15444
15445 #[test]
15446 fn tick_strategy_stats_returns_strategy_fields() {
15447 let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15448 let stats = prog.tick_strategy_stats();
15449 assert!(
15451 !stats.is_empty(),
15452 "stats should not be empty when strategy is configured"
15453 );
15454 }
15455}