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::{
98 AlertSeverity, FrameGuardrails, GuardrailKind, GuardrailsConfig,
99};
100use ftui_render::sanitize::sanitize;
101use std::any::Any;
102use std::collections::HashMap;
103use std::io::{self, Stdout, Write};
104use std::panic::{self, AssertUnwindSafe};
105use std::sync::Arc;
106
107#[inline]
110fn check_termination_signal() -> Option<i32> {
111 ftui_core::shutdown_signal::pending_termination_signal()
112}
113
114#[inline]
116fn clear_termination_signal() {
117 ftui_core::shutdown_signal::clear_pending_termination_signal();
118}
119use std::sync::mpsc;
120use std::thread::{self, JoinHandle};
121use tracing::{debug, debug_span, info, info_span, trace};
122use web_time::{Duration, Instant};
123
124pub trait Model: Sized {
129 type Message: From<Event> + Send + 'static;
134
135 fn init(&mut self) -> Cmd<Self::Message> {
140 Cmd::none()
141 }
142
143 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
148
149 fn view(&self, frame: &mut Frame);
153
154 fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
164 vec![]
165 }
166
167 fn as_screen_tick_dispatch(
176 &mut self,
177 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
178 None
179 }
180
181 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
191 Cmd::none()
192 }
193
194 fn on_error(&mut self, _error: &str) -> Cmd<Self::Message> {
206 Cmd::none()
207 }
208}
209
210const DEFAULT_TASK_WEIGHT: f64 = 1.0;
212
213const SOFT_TRIM_COOLDOWN_FRAMES: u64 = 60;
220
221const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
223
224#[derive(Debug, Clone)]
226pub struct TaskSpec {
227 pub weight: f64,
229 pub estimate_ms: f64,
231 pub name: Option<String>,
233}
234
235impl Default for TaskSpec {
236 fn default() -> Self {
237 Self {
238 weight: DEFAULT_TASK_WEIGHT,
239 estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
240 name: None,
241 }
242 }
243}
244
245impl TaskSpec {
246 #[must_use]
248 pub fn new(weight: f64, estimate_ms: f64) -> Self {
249 Self {
250 weight,
251 estimate_ms,
252 name: None,
253 }
254 }
255
256 #[must_use]
258 pub fn with_name(mut self, name: impl Into<String>) -> Self {
259 self.name = Some(name.into());
260 self
261 }
262}
263
264#[derive(Debug, Clone, Copy)]
266pub struct FrameTiming {
267 pub frame_idx: u64,
268 pub update_us: u64,
269 pub render_us: u64,
270 pub diff_us: u64,
271 pub present_us: u64,
272 pub total_us: u64,
273}
274
275#[derive(Debug)]
276struct SignalTerminationError {
277 signal: i32,
278}
279
280impl std::fmt::Display for SignalTerminationError {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 write!(f, "terminated by signal {}", self.signal)
283 }
284}
285
286impl std::error::Error for SignalTerminationError {}
287
288fn signal_termination_from_error(err: &io::Error) -> Option<i32> {
289 err.get_ref()
290 .and_then(|inner| inner.downcast_ref::<SignalTerminationError>())
291 .map(|inner| inner.signal)
292}
293
294pub trait FrameTimingSink: Send + Sync {
296 fn record_frame(&self, timing: &FrameTiming);
297}
298
299#[derive(Clone)]
301pub struct FrameTimingConfig {
302 pub sink: Arc<dyn FrameTimingSink>,
303}
304
305impl FrameTimingConfig {
306 #[must_use]
307 pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
308 Self { sink }
309 }
310}
311
312impl std::fmt::Debug for FrameTimingConfig {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 f.debug_struct("FrameTimingConfig")
315 .field("sink", &"<dyn FrameTimingSink>")
316 .finish()
317 }
318}
319
320#[derive(Default)]
325pub enum Cmd<M> {
326 #[default]
328 None,
329 Quit,
331 Batch(Vec<Cmd<M>>),
333 Sequence(Vec<Cmd<M>>),
335 Msg(M),
337 Tick(Duration),
339 Log(String),
344 Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
351 SaveState,
356 RestoreState,
362 SetMouseCapture(bool),
367 SetTickStrategy(Box<dyn crate::tick_strategy::TickStrategy>),
373}
374
375impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 Self::None => write!(f, "None"),
379 Self::Quit => write!(f, "Quit"),
380 Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
381 Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
382 Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
383 Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
384 Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
385 Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
386 Self::SaveState => write!(f, "SaveState"),
387 Self::RestoreState => write!(f, "RestoreState"),
388 Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
389 Self::SetTickStrategy(s) => write!(f, "SetTickStrategy({})", s.name()),
390 }
391 }
392}
393
394impl<M> Cmd<M> {
395 #[inline]
397 pub fn none() -> Self {
398 Self::None
399 }
400
401 #[inline]
403 pub fn quit() -> Self {
404 Self::Quit
405 }
406
407 #[inline]
409 pub fn msg(m: M) -> Self {
410 Self::Msg(m)
411 }
412
413 #[inline]
418 pub fn log(msg: impl Into<String>) -> Self {
419 Self::Log(msg.into())
420 }
421
422 pub fn batch(cmds: Vec<Self>) -> Self {
424 if cmds.is_empty() {
425 Self::None
426 } else if cmds.len() == 1 {
427 cmds.into_iter().next().unwrap_or(Self::None)
428 } else {
429 Self::Batch(cmds)
430 }
431 }
432
433 pub fn sequence(cmds: Vec<Self>) -> Self {
435 if cmds.is_empty() {
436 Self::None
437 } else if cmds.len() == 1 {
438 cmds.into_iter().next().unwrap_or(Self::None)
439 } else {
440 Self::Sequence(cmds)
441 }
442 }
443
444 #[inline]
446 pub fn type_name(&self) -> &'static str {
447 match self {
448 Self::None => "None",
449 Self::Quit => "Quit",
450 Self::Batch(_) => "Batch",
451 Self::Sequence(_) => "Sequence",
452 Self::Msg(_) => "Msg",
453 Self::Tick(_) => "Tick",
454 Self::Log(_) => "Log",
455 Self::Task(..) => "Task",
456 Self::SaveState => "SaveState",
457 Self::RestoreState => "RestoreState",
458 Self::SetMouseCapture(_) => "SetMouseCapture",
459 Self::SetTickStrategy(_) => "SetTickStrategy",
460 }
461 }
462
463 #[inline]
465 pub fn tick(duration: Duration) -> Self {
466 Self::Tick(duration)
467 }
468
469 pub fn task<F>(f: F) -> Self
475 where
476 F: FnOnce() -> M + Send + 'static,
477 {
478 Self::Task(TaskSpec::default(), Box::new(f))
479 }
480
481 pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
483 where
484 F: FnOnce() -> M + Send + 'static,
485 {
486 Self::Task(spec, Box::new(f))
487 }
488
489 pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
491 where
492 F: FnOnce() -> M + Send + 'static,
493 {
494 Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
495 }
496
497 pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
499 where
500 F: FnOnce() -> M + Send + 'static,
501 {
502 Self::Task(TaskSpec::default().with_name(name), Box::new(f))
503 }
504
505 pub fn set_tick_strategy(strategy: impl crate::tick_strategy::TickStrategy + 'static) -> Self {
510 Self::SetTickStrategy(Box::new(strategy))
511 }
512
513 #[inline]
518 pub fn save_state() -> Self {
519 Self::SaveState
520 }
521
522 #[inline]
527 pub fn restore_state() -> Self {
528 Self::RestoreState
529 }
530
531 #[inline]
536 pub fn set_mouse_capture(enabled: bool) -> Self {
537 Self::SetMouseCapture(enabled)
538 }
539
540 pub fn count(&self) -> usize {
544 match self {
545 Self::None => 0,
546 Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
547 _ => 1,
548 }
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum ResizeBehavior {
555 Immediate,
557 Throttled,
559}
560
561impl ResizeBehavior {
562 const fn uses_coalescer(self) -> bool {
563 matches!(self, ResizeBehavior::Throttled)
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
573pub enum MouseCapturePolicy {
574 #[default]
576 Auto,
577 On,
579 Off,
581}
582
583impl MouseCapturePolicy {
584 #[must_use]
586 pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
587 match self {
588 Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
589 Self::On => true,
590 Self::Off => false,
591 }
592 }
593}
594
595const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
596const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
600pub struct PaneTerminalSplitterHandle {
601 pub target: PaneResizeTarget,
603 pub rect: Rect,
605 pub boundary: i32,
607}
608
609#[must_use]
613pub fn pane_terminal_splitter_handles(
614 tree: &PaneTree,
615 layout: &PaneLayout,
616 hit_thickness: u16,
617) -> Vec<PaneTerminalSplitterHandle> {
618 let thickness = if hit_thickness == 0 {
619 PANE_TERMINAL_DEFAULT_HIT_THICKNESS
620 } else {
621 hit_thickness
622 };
623 let mut handles = Vec::new();
624 for node in tree.nodes() {
625 let PaneNodeKind::Split(split) = &node.kind else {
626 continue;
627 };
628 let Some(split_rect) = layout.rect(node.id) else {
629 continue;
630 };
631 if split_rect.is_empty() {
632 continue;
633 }
634 let Some(first_rect) = layout.rect(split.first) else {
635 continue;
636 };
637 let Some(second_rect) = layout.rect(split.second) else {
638 continue;
639 };
640
641 let boundary_u16 = match split.axis {
642 SplitAxis::Horizontal => {
643 if second_rect.x == split_rect.x {
645 first_rect.right()
646 } else {
647 second_rect.x
648 }
649 }
650 SplitAxis::Vertical => {
651 if second_rect.y == split_rect.y {
653 first_rect.bottom()
654 } else {
655 second_rect.y
656 }
657 }
658 };
659 let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
660 continue;
661 };
662 handles.push(PaneTerminalSplitterHandle {
663 target: PaneResizeTarget {
664 split_id: node.id,
665 axis: split.axis,
666 },
667 rect,
668 boundary: i32::from(boundary_u16),
669 });
670 }
671 handles
672}
673
674#[must_use]
681pub fn pane_terminal_resolve_splitter_target(
682 handles: &[PaneTerminalSplitterHandle],
683 x: u16,
684 y: u16,
685) -> Option<PaneResizeTarget> {
686 let px = i32::from(x);
687 let py = i32::from(y);
688 let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
689
690 for handle in handles {
691 if !rect_contains_cell(handle.rect, x, y) {
692 continue;
693 }
694 let distance = match handle.target.axis {
695 SplitAxis::Horizontal => px.abs_diff(handle.boundary),
696 SplitAxis::Vertical => py.abs_diff(handle.boundary),
697 };
698 let axis_rank = match handle.target.axis {
699 SplitAxis::Horizontal => 0,
700 SplitAxis::Vertical => 1,
701 };
702 let key = (distance, handle.target.split_id.get(), axis_rank);
703 if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
704 best = Some((key, handle.target));
705 }
706 }
707
708 best.map(|(_, target)| target)
709}
710
711pub fn register_pane_terminal_splitter_hits(
716 frame: &mut Frame,
717 handles: &[PaneTerminalSplitterHandle],
718 hit_id_base: u32,
719) -> usize {
720 let mut registered = 0usize;
721 for (idx, handle) in handles.iter().enumerate() {
722 let Ok(offset) = u32::try_from(idx) else {
723 break;
724 };
725 let hit_id = HitId::new(hit_id_base.saturating_add(offset));
726 if frame.register_hit(
727 handle.rect,
728 hit_id,
729 HitRegion::Handle,
730 encode_pane_resize_target(handle.target),
731 ) {
732 registered = registered.saturating_add(1);
733 }
734 }
735 registered
736}
737
738#[must_use]
740pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
741 let (_, region, data) = hit;
742 if region != HitRegion::Handle {
743 return None;
744 }
745 decode_pane_resize_target(data)
746}
747
748fn splitter_hit_rect(
749 axis: SplitAxis,
750 split_rect: Rect,
751 boundary: u16,
752 thickness: u16,
753) -> Option<Rect> {
754 let half = thickness.saturating_sub(1) / 2;
755 match axis {
756 SplitAxis::Horizontal => {
757 let start = boundary.saturating_sub(half).max(split_rect.x);
758 let end = boundary
759 .saturating_add(thickness.saturating_sub(half))
760 .min(split_rect.right());
761 let width = end.saturating_sub(start);
762 (width > 0 && split_rect.height > 0).then_some(Rect::new(
763 start,
764 split_rect.y,
765 width,
766 split_rect.height,
767 ))
768 }
769 SplitAxis::Vertical => {
770 let start = boundary.saturating_sub(half).max(split_rect.y);
771 let end = boundary
772 .saturating_add(thickness.saturating_sub(half))
773 .min(split_rect.bottom());
774 let height = end.saturating_sub(start);
775 (height > 0 && split_rect.width > 0).then_some(Rect::new(
776 split_rect.x,
777 start,
778 split_rect.width,
779 height,
780 ))
781 }
782 }
783}
784
785fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
786 x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
787}
788
789fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
790 let axis = match target.axis {
791 SplitAxis::Horizontal => 0_u64,
792 SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
793 };
794 (target.split_id.get() << 1) | axis
795}
796
797fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
798 let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
799 SplitAxis::Horizontal
800 } else {
801 SplitAxis::Vertical
802 };
803 let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
804 Some(PaneResizeTarget { split_id, axis })
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
813pub enum PaneMuxEnvironment {
814 None,
816 Tmux,
818 Screen,
820 Zellij,
822 WeztermMux,
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub struct PaneCapabilityMatrix {
834 pub mux: PaneMuxEnvironment,
836
837 pub mouse_sgr: bool,
841 pub mouse_drag_reliable: bool,
844 pub mouse_button_discrimination: bool,
847
848 pub focus_events: bool,
851 pub bracketed_paste: bool,
853
854 pub unicode_box_drawing: bool,
857 pub true_color: bool,
859
860 pub degraded: bool,
863}
864
865#[derive(Debug, Clone, PartialEq, Eq)]
867pub struct PaneCapabilityLimitation {
868 pub id: &'static str,
870 pub description: &'static str,
872 pub fallback: &'static str,
874}
875
876impl PaneCapabilityMatrix {
877 #[must_use]
882 pub fn from_capabilities(
883 caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
884 ) -> Self {
885 let mux = if caps.in_tmux {
886 PaneMuxEnvironment::Tmux
887 } else if caps.in_screen {
888 PaneMuxEnvironment::Screen
889 } else if caps.in_zellij {
890 PaneMuxEnvironment::Zellij
891 } else if caps.in_wezterm_mux {
892 PaneMuxEnvironment::WeztermMux
893 } else {
894 PaneMuxEnvironment::None
895 };
896
897 let mouse_sgr = caps.mouse_sgr;
898
899 let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
902
903 let mouse_button_discrimination = mouse_sgr;
906
907 let focus_events = caps.focus_events && !caps.in_any_mux();
909
910 let bracketed_paste = caps.bracketed_paste;
911 let unicode_box_drawing = caps.unicode_box_drawing;
912 let true_color = caps.supports_true_color();
913
914 let degraded =
915 !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
916
917 Self {
918 mux,
919 mouse_sgr,
920 mouse_drag_reliable,
921 mouse_button_discrimination,
922 focus_events,
923 bracketed_paste,
924 unicode_box_drawing,
925 true_color,
926 degraded,
927 }
928 }
929
930 #[must_use]
936 pub const fn drag_enabled(&self) -> bool {
937 self.mouse_drag_reliable
938 }
939
940 #[must_use]
946 pub const fn focus_cancel_effective(&self) -> bool {
947 self.focus_events
948 }
949
950 #[must_use]
952 pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
953 let mut out = Vec::new();
954
955 if !self.mouse_sgr {
956 out.push(PaneCapabilityLimitation {
957 id: "no_sgr_mouse",
958 description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
959 fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
960 });
961 }
962
963 if !self.mouse_drag_reliable {
964 out.push(PaneCapabilityLimitation {
965 id: "mouse_drag_unreliable",
966 description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
967 fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
968 });
969 }
970
971 if !self.mouse_button_discrimination {
972 out.push(PaneCapabilityLimitation {
973 id: "no_button_discrimination",
974 description: "Mouse release events do not identify which button was released",
975 fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
976 });
977 }
978
979 if !self.focus_events {
980 out.push(PaneCapabilityLimitation {
981 id: "no_focus_events",
982 description: "Terminal does not deliver focus-in/focus-out events",
983 fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
984 });
985 }
986
987 out
988 }
989}
990
991#[derive(Debug, Clone, Copy, PartialEq, Eq)]
996pub struct PaneTerminalAdapterConfig {
997 pub drag_threshold: u16,
999 pub update_hysteresis: u16,
1001 pub activation_button: PanePointerButton,
1003 pub drag_update_coalesce_distance: u16,
1006 pub cancel_on_focus_lost: bool,
1008 pub cancel_on_resize: bool,
1010}
1011
1012impl Default for PaneTerminalAdapterConfig {
1013 fn default() -> Self {
1014 Self {
1015 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
1016 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
1017 activation_button: PanePointerButton::Primary,
1018 drag_update_coalesce_distance: 2,
1019 cancel_on_focus_lost: true,
1020 cancel_on_resize: true,
1021 }
1022 }
1023}
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1026struct PaneTerminalActivePointer {
1027 pointer_id: u32,
1028 target: PaneResizeTarget,
1029 button: PanePointerButton,
1030 last_position: PanePointerPosition,
1031 cumulative_delta_x: i32,
1032 cumulative_delta_y: i32,
1033 direction_changes: u16,
1034 sample_count: u32,
1035 previous_step_delta_x: i32,
1036 previous_step_delta_y: i32,
1037 start_time: Instant,
1038}
1039
1040#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042pub enum PaneTerminalLifecyclePhase {
1043 MouseDown,
1044 MouseDrag,
1045 MouseMove,
1046 MouseUp,
1047 MouseScroll,
1048 KeyResize,
1049 KeyCancel,
1050 FocusLoss,
1051 ResizeInterrupt,
1052 Other,
1053}
1054
1055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1057pub enum PaneTerminalIgnoredReason {
1058 MissingTarget,
1059 NoActivePointer,
1060 PointerButtonMismatch,
1061 ActivationButtonRequired,
1062 WindowNotFocused,
1063 UnsupportedKey,
1064 FocusGainNoop,
1065 ResizeNoop,
1066 DragCoalesced,
1067 NonSemanticEvent,
1068 MachineRejectedEvent,
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1073pub enum PaneTerminalLogOutcome {
1074 SemanticForwarded,
1075 SemanticForwardedAfterRecovery,
1076 Ignored(PaneTerminalIgnoredReason),
1077}
1078
1079#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081pub struct PaneTerminalLogEntry {
1082 pub phase: PaneTerminalLifecyclePhase,
1083 pub sequence: Option<u64>,
1084 pub pointer_id: Option<u32>,
1085 pub target: Option<PaneResizeTarget>,
1086 pub recovery_cancel_sequence: Option<u64>,
1087 pub outcome: PaneTerminalLogOutcome,
1088}
1089
1090#[derive(Debug, Clone, PartialEq)]
1096pub struct PaneTerminalDispatch {
1097 pub primary_event: Option<PaneSemanticInputEvent>,
1098 pub primary_transition: Option<PaneDragResizeTransition>,
1099 pub motion: Option<PaneMotionVector>,
1100 pub inertial_throw: Option<PaneInertialThrow>,
1101 pub projected_position: Option<PanePointerPosition>,
1102 pub recovery_event: Option<PaneSemanticInputEvent>,
1103 pub recovery_transition: Option<PaneDragResizeTransition>,
1104 pub log: PaneTerminalLogEntry,
1105}
1106
1107impl PaneTerminalDispatch {
1108 fn ignored(
1109 phase: PaneTerminalLifecyclePhase,
1110 reason: PaneTerminalIgnoredReason,
1111 pointer_id: Option<u32>,
1112 target: Option<PaneResizeTarget>,
1113 ) -> Self {
1114 Self {
1115 primary_event: None,
1116 primary_transition: None,
1117 motion: None,
1118 inertial_throw: None,
1119 projected_position: None,
1120 recovery_event: None,
1121 recovery_transition: None,
1122 log: PaneTerminalLogEntry {
1123 phase,
1124 sequence: None,
1125 pointer_id,
1126 target,
1127 recovery_cancel_sequence: None,
1128 outcome: PaneTerminalLogOutcome::Ignored(reason),
1129 },
1130 }
1131 }
1132
1133 fn forwarded(
1134 phase: PaneTerminalLifecyclePhase,
1135 pointer_id: Option<u32>,
1136 target: Option<PaneResizeTarget>,
1137 event: PaneSemanticInputEvent,
1138 transition: PaneDragResizeTransition,
1139 ) -> Self {
1140 let sequence = Some(event.sequence);
1141 Self {
1142 primary_event: Some(event),
1143 primary_transition: Some(transition),
1144 motion: None,
1145 inertial_throw: None,
1146 projected_position: None,
1147 recovery_event: None,
1148 recovery_transition: None,
1149 log: PaneTerminalLogEntry {
1150 phase,
1151 sequence,
1152 pointer_id,
1153 target,
1154 recovery_cancel_sequence: None,
1155 outcome: PaneTerminalLogOutcome::SemanticForwarded,
1156 },
1157 }
1158 }
1159
1160 #[must_use]
1162 pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
1163 self.motion.map(PanePressureSnapProfile::from_motion)
1164 }
1165}
1166
1167#[derive(Debug, Clone)]
1170pub struct PaneTerminalAdapter {
1171 machine: PaneDragResizeMachine,
1172 config: PaneTerminalAdapterConfig,
1173 active: Option<PaneTerminalActivePointer>,
1174 window_focused: bool,
1175 next_sequence: u64,
1176}
1177
1178impl PaneTerminalAdapter {
1179 pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1181 let config = PaneTerminalAdapterConfig {
1182 drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1183 ..config
1184 };
1185 let machine = PaneDragResizeMachine::new_with_hysteresis(
1186 config.drag_threshold,
1187 config.update_hysteresis,
1188 )?;
1189 Ok(Self {
1190 machine,
1191 config,
1192 active: None,
1193 window_focused: true,
1194 next_sequence: 1,
1195 })
1196 }
1197
1198 #[must_use]
1200 pub const fn config(&self) -> PaneTerminalAdapterConfig {
1201 self.config
1202 }
1203
1204 #[must_use]
1206 pub fn active_pointer_id(&self) -> Option<u32> {
1207 self.active.map(|active| active.pointer_id)
1208 }
1209
1210 #[must_use]
1212 pub const fn window_focused(&self) -> bool {
1213 self.window_focused
1214 }
1215
1216 #[must_use]
1218 pub const fn machine_state(&self) -> PaneDragResizeState {
1219 self.machine.state()
1220 }
1221
1222 pub fn translate(
1227 &mut self,
1228 event: &Event,
1229 target_hint: Option<PaneResizeTarget>,
1230 ) -> PaneTerminalDispatch {
1231 match event {
1232 Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1233 Event::Key(key) => self.translate_key(*key, target_hint),
1234 Event::Focus(focused) => self.translate_focus(*focused),
1235 Event::Resize { .. } => self.translate_resize(),
1236 _ => PaneTerminalDispatch::ignored(
1237 PaneTerminalLifecyclePhase::Other,
1238 PaneTerminalIgnoredReason::NonSemanticEvent,
1239 None,
1240 target_hint,
1241 ),
1242 }
1243 }
1244
1245 pub fn translate_with_handles(
1251 &mut self,
1252 event: &Event,
1253 handles: &[PaneTerminalSplitterHandle],
1254 ) -> PaneTerminalDispatch {
1255 let active_target = self.active.map(|active| active.target);
1256 let target_hint = match event {
1257 Event::Mouse(mouse) => {
1258 let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1259 match mouse.kind {
1260 MouseEventKind::Down(_)
1261 | MouseEventKind::ScrollUp
1262 | MouseEventKind::ScrollDown
1263 | MouseEventKind::ScrollLeft
1264 | MouseEventKind::ScrollRight => resolved,
1265 MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1266 resolved.or(active_target)
1267 }
1268 }
1269 }
1270 Event::Key(_) => active_target,
1271 _ => None,
1272 };
1273 self.translate(event, target_hint)
1274 }
1275
1276 fn translate_mouse(
1277 &mut self,
1278 mouse: MouseEvent,
1279 target_hint: Option<PaneResizeTarget>,
1280 ) -> PaneTerminalDispatch {
1281 let position = mouse_position(mouse);
1282 let modifiers = pane_modifiers(mouse.modifiers);
1283 match mouse.kind {
1284 MouseEventKind::Down(button) => {
1285 let pane_button = pane_button(button);
1286 if pane_button != self.config.activation_button {
1287 return PaneTerminalDispatch::ignored(
1288 PaneTerminalLifecyclePhase::MouseDown,
1289 PaneTerminalIgnoredReason::ActivationButtonRequired,
1290 Some(pointer_id_for_button(pane_button)),
1291 target_hint,
1292 );
1293 }
1294 let Some(target) = target_hint else {
1295 return PaneTerminalDispatch::ignored(
1296 PaneTerminalLifecyclePhase::MouseDown,
1297 PaneTerminalIgnoredReason::MissingTarget,
1298 Some(pointer_id_for_button(pane_button)),
1299 None,
1300 );
1301 };
1302
1303 let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1304 let pointer_id = pointer_id_for_button(pane_button);
1305 let kind = PaneSemanticInputEventKind::PointerDown {
1306 target,
1307 pointer_id,
1308 button: pane_button,
1309 position,
1310 };
1311 let mut dispatch = self.forward_semantic(
1312 PaneTerminalLifecyclePhase::MouseDown,
1313 Some(pointer_id),
1314 Some(target),
1315 kind,
1316 modifiers,
1317 );
1318 if dispatch.primary_transition.is_some() {
1319 self.active = Some(PaneTerminalActivePointer {
1320 pointer_id,
1321 target,
1322 button: pane_button,
1323 last_position: position,
1324 cumulative_delta_x: 0,
1325 cumulative_delta_y: 0,
1326 direction_changes: 0,
1327 sample_count: 0,
1328 previous_step_delta_x: 0,
1329 previous_step_delta_y: 0,
1330 start_time: Instant::now(),
1331 });
1332 }
1333 if let Some((cancel_event, cancel_transition)) = recovery {
1334 dispatch.recovery_event = Some(cancel_event);
1335 dispatch.recovery_transition = Some(cancel_transition);
1336 dispatch.log.recovery_cancel_sequence =
1337 dispatch.recovery_event.as_ref().map(|event| event.sequence);
1338 if matches!(
1339 dispatch.log.outcome,
1340 PaneTerminalLogOutcome::SemanticForwarded
1341 ) {
1342 dispatch.log.outcome =
1343 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1344 }
1345 }
1346 dispatch
1347 }
1348 MouseEventKind::Drag(button) => {
1349 let pane_button = pane_button(button);
1350 let Some(active) = self.active else {
1351 return PaneTerminalDispatch::ignored(
1352 PaneTerminalLifecyclePhase::MouseDrag,
1353 PaneTerminalIgnoredReason::NoActivePointer,
1354 Some(pointer_id_for_button(pane_button)),
1355 target_hint,
1356 );
1357 };
1358 if active.button != pane_button {
1359 return PaneTerminalDispatch::ignored(
1360 PaneTerminalLifecyclePhase::MouseDrag,
1361 PaneTerminalIgnoredReason::PointerButtonMismatch,
1362 Some(pointer_id_for_button(pane_button)),
1363 Some(active.target),
1364 );
1365 }
1366 self.apply_pointer_motion(
1367 active,
1368 position,
1369 modifiers,
1370 PaneTerminalLifecyclePhase::MouseDrag,
1371 )
1372 }
1373 MouseEventKind::Moved => {
1374 let Some(active) = self.active else {
1375 return PaneTerminalDispatch::ignored(
1376 PaneTerminalLifecyclePhase::MouseMove,
1377 PaneTerminalIgnoredReason::NoActivePointer,
1378 None,
1379 target_hint,
1380 );
1381 };
1382 self.apply_pointer_motion(
1383 active,
1384 position,
1385 modifiers,
1386 PaneTerminalLifecyclePhase::MouseMove,
1387 )
1388 }
1389 MouseEventKind::Up(button) => {
1390 let pane_button = pane_button(button);
1391 let Some(active) = self.active else {
1392 return PaneTerminalDispatch::ignored(
1393 PaneTerminalLifecyclePhase::MouseUp,
1394 PaneTerminalIgnoredReason::NoActivePointer,
1395 Some(pointer_id_for_button(pane_button)),
1396 target_hint,
1397 );
1398 };
1399 if active.button != pane_button {
1400 return PaneTerminalDispatch::ignored(
1401 PaneTerminalLifecyclePhase::MouseUp,
1402 PaneTerminalIgnoredReason::PointerButtonMismatch,
1403 Some(pointer_id_for_button(pane_button)),
1404 Some(active.target),
1405 );
1406 }
1407 let kind = PaneSemanticInputEventKind::PointerUp {
1408 target: active.target,
1409 pointer_id: active.pointer_id,
1410 button: active.button,
1411 position,
1412 };
1413 let mut dispatch = self.forward_semantic(
1414 PaneTerminalLifecyclePhase::MouseUp,
1415 Some(active.pointer_id),
1416 Some(active.target),
1417 kind,
1418 modifiers,
1419 );
1420 if dispatch.primary_transition.is_some() {
1421 let duration = active.start_time.elapsed().as_millis() as u32;
1422 let motion = PaneMotionVector::from_delta(
1423 active.cumulative_delta_x,
1424 active.cumulative_delta_y,
1425 duration,
1426 active.direction_changes,
1427 );
1428 let inertial_throw = PaneInertialThrow::from_motion(motion);
1429 dispatch.motion = Some(motion);
1430 dispatch.projected_position = Some(inertial_throw.projected_pointer(position));
1431 dispatch.inertial_throw = Some(inertial_throw);
1432 self.active = None;
1433 }
1434 dispatch
1435 }
1436 MouseEventKind::ScrollUp
1437 | MouseEventKind::ScrollDown
1438 | MouseEventKind::ScrollLeft
1439 | MouseEventKind::ScrollRight => {
1440 let target = target_hint.or(self.active.map(|active| active.target));
1441 let Some(target) = target else {
1442 return PaneTerminalDispatch::ignored(
1443 PaneTerminalLifecyclePhase::MouseScroll,
1444 PaneTerminalIgnoredReason::MissingTarget,
1445 None,
1446 None,
1447 );
1448 };
1449 let lines = match mouse.kind {
1450 MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1451 MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1452 _ => unreachable!("handled by outer match"),
1453 };
1454 let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1455 self.forward_semantic(
1456 PaneTerminalLifecyclePhase::MouseScroll,
1457 None,
1458 Some(target),
1459 kind,
1460 modifiers,
1461 )
1462 }
1463 }
1464 }
1465
1466 fn apply_pointer_motion(
1479 &mut self,
1480 mut active: PaneTerminalActivePointer,
1481 position: PanePointerPosition,
1482 modifiers: PaneModifierSnapshot,
1483 phase: PaneTerminalLifecyclePhase,
1484 ) -> PaneTerminalDispatch {
1485 let delta_x = position.x.saturating_sub(active.last_position.x);
1486 let delta_y = position.y.saturating_sub(active.last_position.y);
1487 if self.should_coalesce_drag(delta_x, delta_y) {
1488 return PaneTerminalDispatch::ignored(
1489 phase,
1490 PaneTerminalIgnoredReason::DragCoalesced,
1491 Some(active.pointer_id),
1492 Some(active.target),
1493 );
1494 }
1495 if active.sample_count > 0 {
1496 let flipped_x = delta_x.signum() != 0
1497 && active.previous_step_delta_x.signum() != 0
1498 && delta_x.signum() != active.previous_step_delta_x.signum();
1499 let flipped_y = delta_y.signum() != 0
1500 && active.previous_step_delta_y.signum() != 0
1501 && delta_y.signum() != active.previous_step_delta_y.signum();
1502 if flipped_x || flipped_y {
1503 active.direction_changes = active.direction_changes.saturating_add(1);
1504 }
1505 }
1506 active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(delta_x);
1507 active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(delta_y);
1508 active.sample_count = active.sample_count.saturating_add(1);
1509 active.previous_step_delta_x = delta_x;
1510 active.previous_step_delta_y = delta_y;
1511 let kind = PaneSemanticInputEventKind::PointerMove {
1512 target: active.target,
1513 pointer_id: active.pointer_id,
1514 position,
1515 delta_x,
1516 delta_y,
1517 };
1518 let mut dispatch = self.forward_semantic(
1519 phase,
1520 Some(active.pointer_id),
1521 Some(active.target),
1522 kind,
1523 modifiers,
1524 );
1525 if dispatch.primary_transition.is_some() {
1526 active.last_position = position;
1527 self.active = Some(active);
1528 let duration = active.start_time.elapsed().as_millis() as u32;
1529 dispatch.motion = Some(PaneMotionVector::from_delta(
1530 active.cumulative_delta_x,
1531 active.cumulative_delta_y,
1532 duration,
1533 active.direction_changes,
1534 ));
1535 }
1536 dispatch
1537 }
1538
1539 fn translate_key(
1540 &mut self,
1541 key: KeyEvent,
1542 target_hint: Option<PaneResizeTarget>,
1543 ) -> PaneTerminalDispatch {
1544 if !self.window_focused {
1545 return PaneTerminalDispatch::ignored(
1546 PaneTerminalLifecyclePhase::KeyResize,
1547 PaneTerminalIgnoredReason::WindowNotFocused,
1548 self.active_pointer_id(),
1549 target_hint.or(self.active.map(|active| active.target)),
1550 );
1551 }
1552 if key.kind == KeyEventKind::Release {
1553 return PaneTerminalDispatch::ignored(
1554 PaneTerminalLifecyclePhase::Other,
1555 PaneTerminalIgnoredReason::UnsupportedKey,
1556 None,
1557 target_hint,
1558 );
1559 }
1560 if matches!(key.code, KeyCode::Escape) {
1561 return self.cancel_active_dispatch(
1562 PaneTerminalLifecyclePhase::KeyCancel,
1563 PaneCancelReason::EscapeKey,
1564 PaneTerminalIgnoredReason::NoActivePointer,
1565 );
1566 }
1567 let target = target_hint.or(self.active.map(|active| active.target));
1568 let Some(target) = target else {
1569 return PaneTerminalDispatch::ignored(
1570 PaneTerminalLifecyclePhase::KeyResize,
1571 PaneTerminalIgnoredReason::MissingTarget,
1572 None,
1573 None,
1574 );
1575 };
1576 let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1577 return PaneTerminalDispatch::ignored(
1578 PaneTerminalLifecyclePhase::KeyResize,
1579 PaneTerminalIgnoredReason::UnsupportedKey,
1580 None,
1581 Some(target),
1582 );
1583 };
1584 let units = keyboard_resize_units(key.modifiers);
1585 let kind = PaneSemanticInputEventKind::KeyboardResize {
1586 target,
1587 direction,
1588 units,
1589 };
1590 self.forward_semantic(
1591 PaneTerminalLifecyclePhase::KeyResize,
1592 self.active_pointer_id(),
1593 Some(target),
1594 kind,
1595 pane_modifiers(key.modifiers),
1596 )
1597 }
1598
1599 fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1600 if focused {
1601 self.window_focused = true;
1602 return PaneTerminalDispatch::ignored(
1603 PaneTerminalLifecyclePhase::Other,
1604 PaneTerminalIgnoredReason::FocusGainNoop,
1605 self.active_pointer_id(),
1606 self.active.map(|active| active.target),
1607 );
1608 }
1609 self.window_focused = false;
1610 if !self.config.cancel_on_focus_lost {
1611 return PaneTerminalDispatch::ignored(
1612 PaneTerminalLifecyclePhase::FocusLoss,
1613 PaneTerminalIgnoredReason::ResizeNoop,
1614 self.active_pointer_id(),
1615 self.active.map(|active| active.target),
1616 );
1617 }
1618 self.cancel_active_dispatch(
1619 PaneTerminalLifecyclePhase::FocusLoss,
1620 PaneCancelReason::FocusLost,
1621 PaneTerminalIgnoredReason::NoActivePointer,
1622 )
1623 }
1624
1625 fn translate_resize(&mut self) -> PaneTerminalDispatch {
1626 if !self.config.cancel_on_resize {
1627 return PaneTerminalDispatch::ignored(
1628 PaneTerminalLifecyclePhase::ResizeInterrupt,
1629 PaneTerminalIgnoredReason::ResizeNoop,
1630 self.active_pointer_id(),
1631 self.active.map(|active| active.target),
1632 );
1633 }
1634 self.cancel_active_dispatch(
1635 PaneTerminalLifecyclePhase::ResizeInterrupt,
1636 PaneCancelReason::Programmatic,
1637 PaneTerminalIgnoredReason::ResizeNoop,
1638 )
1639 }
1640
1641 fn cancel_active_dispatch(
1642 &mut self,
1643 phase: PaneTerminalLifecyclePhase,
1644 reason: PaneCancelReason,
1645 no_active_reason: PaneTerminalIgnoredReason,
1646 ) -> PaneTerminalDispatch {
1647 let Some(active) = self.active else {
1648 return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1649 };
1650 let kind = PaneSemanticInputEventKind::Cancel {
1651 target: Some(active.target),
1652 reason,
1653 };
1654 let dispatch = self.forward_semantic(
1655 phase,
1656 Some(active.pointer_id),
1657 Some(active.target),
1658 kind,
1659 PaneModifierSnapshot::default(),
1660 );
1661 if dispatch.primary_transition.is_some() {
1662 self.active = None;
1663 }
1664 dispatch
1665 }
1666
1667 fn cancel_active_internal(
1668 &mut self,
1669 reason: PaneCancelReason,
1670 ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1671 let active = self.active?;
1672 let kind = PaneSemanticInputEventKind::Cancel {
1673 target: Some(active.target),
1674 reason,
1675 };
1676 let result = self
1677 .apply_semantic(kind, PaneModifierSnapshot::default())
1678 .ok();
1679 if result.is_some() {
1680 self.active = None;
1681 }
1682 result
1683 }
1684
1685 fn forward_semantic(
1686 &mut self,
1687 phase: PaneTerminalLifecyclePhase,
1688 pointer_id: Option<u32>,
1689 target: Option<PaneResizeTarget>,
1690 kind: PaneSemanticInputEventKind,
1691 modifiers: PaneModifierSnapshot,
1692 ) -> PaneTerminalDispatch {
1693 match self.apply_semantic(kind, modifiers) {
1694 Ok((event, transition)) => {
1695 PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1696 }
1697 Err(_) => PaneTerminalDispatch::ignored(
1698 phase,
1699 PaneTerminalIgnoredReason::MachineRejectedEvent,
1700 pointer_id,
1701 target,
1702 ),
1703 }
1704 }
1705
1706 fn apply_semantic(
1707 &mut self,
1708 kind: PaneSemanticInputEventKind,
1709 modifiers: PaneModifierSnapshot,
1710 ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1711 {
1712 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1713 event.modifiers = modifiers;
1714 let transition = self.machine.apply_event(&event)?;
1715 Ok((event, transition))
1716 }
1717
1718 fn next_sequence(&mut self) -> u64 {
1719 let sequence = self.next_sequence;
1720 self.next_sequence = self.next_sequence.saturating_add(1);
1721 sequence
1722 }
1723
1724 fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1725 if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1726 return false;
1727 }
1728 let movement = delta_x
1729 .unsigned_abs()
1730 .saturating_add(delta_y.unsigned_abs());
1731 movement < u32::from(self.config.drag_update_coalesce_distance)
1732 }
1733
1734 pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1743 let was_active = self.active.is_some();
1744 let machine_state_before = self.machine.state();
1745 let machine_transition = self.machine.force_cancel();
1746 let active_pointer = self.active.take();
1747 if !was_active && machine_transition.is_none() {
1748 return None;
1749 }
1750 Some(PaneCleanupDiagnostics {
1751 had_active_pointer: was_active,
1752 active_pointer_id: active_pointer.map(|a| a.pointer_id),
1753 machine_state_before,
1754 machine_transition,
1755 })
1756 }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq)]
1764pub struct PaneCleanupDiagnostics {
1765 pub had_active_pointer: bool,
1767 pub active_pointer_id: Option<u32>,
1769 pub machine_state_before: PaneDragResizeState,
1771 pub machine_transition: Option<PaneDragResizeTransition>,
1774}
1775
1776pub struct PaneInteractionGuard<'a> {
1791 adapter: &'a mut PaneTerminalAdapter,
1792 finished: bool,
1793 diagnostics: Option<PaneCleanupDiagnostics>,
1794}
1795
1796impl<'a> PaneInteractionGuard<'a> {
1797 pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1799 Self {
1800 adapter,
1801 finished: false,
1802 diagnostics: None,
1803 }
1804 }
1805
1806 pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1808 self.adapter
1809 }
1810
1811 pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1816 self.finished = true;
1817 let diagnostics = self.adapter.force_cancel_all();
1818 self.diagnostics = diagnostics.clone();
1819 diagnostics
1820 }
1821}
1822
1823impl Drop for PaneInteractionGuard<'_> {
1824 fn drop(&mut self) {
1825 if !self.finished {
1826 self.diagnostics = self.adapter.force_cancel_all();
1827 }
1828 }
1829}
1830
1831fn pane_button(button: MouseButton) -> PanePointerButton {
1832 match button {
1833 MouseButton::Left => PanePointerButton::Primary,
1834 MouseButton::Right => PanePointerButton::Secondary,
1835 MouseButton::Middle => PanePointerButton::Middle,
1836 }
1837}
1838
1839fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1840 match button {
1841 PanePointerButton::Primary => 1,
1842 PanePointerButton::Secondary => 2,
1843 PanePointerButton::Middle => 3,
1844 }
1845}
1846
1847fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1848 PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1849}
1850
1851fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1852 PaneModifierSnapshot {
1853 shift: modifiers.contains(Modifiers::SHIFT),
1854 alt: modifiers.contains(Modifiers::ALT),
1855 ctrl: modifiers.contains(Modifiers::CTRL),
1856 meta: modifiers.contains(Modifiers::SUPER),
1857 }
1858}
1859
1860fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1861 match (axis, code) {
1862 (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1863 (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1864 (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1865 (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1866 (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1867 (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1868 _ => None,
1869 }
1870}
1871
1872fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1873 if modifiers.contains(Modifiers::SHIFT) {
1874 5
1875 } else {
1876 1
1877 }
1878}
1879
1880#[derive(Clone)]
1884pub struct PersistenceConfig {
1885 pub registry: Option<std::sync::Arc<StateRegistry>>,
1887 pub checkpoint_interval: Option<Duration>,
1889 pub auto_load: bool,
1891 pub auto_save: bool,
1893}
1894
1895impl Default for PersistenceConfig {
1896 fn default() -> Self {
1897 Self {
1898 registry: None,
1899 checkpoint_interval: None,
1900 auto_load: true,
1901 auto_save: true,
1902 }
1903 }
1904}
1905
1906impl std::fmt::Debug for PersistenceConfig {
1907 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1908 f.debug_struct("PersistenceConfig")
1909 .field(
1910 "registry",
1911 &self.registry.as_ref().map(|r| r.backend_name()),
1912 )
1913 .field("checkpoint_interval", &self.checkpoint_interval)
1914 .field("auto_load", &self.auto_load)
1915 .field("auto_save", &self.auto_save)
1916 .finish()
1917 }
1918}
1919
1920impl PersistenceConfig {
1921 #[must_use]
1923 pub fn disabled() -> Self {
1924 Self::default()
1925 }
1926
1927 #[must_use]
1929 pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1930 Self {
1931 registry: Some(registry),
1932 ..Default::default()
1933 }
1934 }
1935
1936 #[must_use]
1938 pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1939 self.checkpoint_interval = Some(interval);
1940 self
1941 }
1942
1943 #[must_use]
1945 pub fn auto_load(mut self, enabled: bool) -> Self {
1946 self.auto_load = enabled;
1947 self
1948 }
1949
1950 #[must_use]
1952 pub fn auto_save(mut self, enabled: bool) -> Self {
1953 self.auto_save = enabled;
1954 self
1955 }
1956}
1957
1958#[derive(Debug, Clone)]
1970pub struct WidgetRefreshConfig {
1971 pub enabled: bool,
1973 pub staleness_window_ms: u64,
1975 pub starve_ms: u64,
1977 pub max_starved_per_frame: usize,
1979 pub max_drop_fraction: f32,
1982 pub weight_priority: f32,
1984 pub weight_staleness: f32,
1986 pub weight_focus: f32,
1988 pub weight_interaction: f32,
1990 pub starve_boost: f32,
1992 pub min_cost_us: f32,
1994}
1995
1996impl Default for WidgetRefreshConfig {
1997 fn default() -> Self {
1998 Self {
1999 enabled: true,
2000 staleness_window_ms: 1_000,
2001 starve_ms: 3_000,
2002 max_starved_per_frame: 2,
2003 max_drop_fraction: 1.0,
2004 weight_priority: 1.0,
2005 weight_staleness: 0.5,
2006 weight_focus: 0.75,
2007 weight_interaction: 0.5,
2008 starve_boost: 1.5,
2009 min_cost_us: 1.0,
2010 }
2011 }
2012}
2013
2014#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2016pub enum TaskExecutorBackend {
2017 #[default]
2019 Spawned,
2020 EffectQueue,
2022 #[cfg(feature = "asupersync-executor")]
2024 Asupersync,
2025}
2026
2027#[derive(Debug, Clone)]
2028pub struct EffectQueueConfig {
2029 pub enabled: bool,
2034 pub backend: TaskExecutorBackend,
2036 pub scheduler: SchedulerConfig,
2038 pub max_queue_depth: usize,
2044 explicit_backend: bool,
2046}
2047
2048impl Default for EffectQueueConfig {
2049 fn default() -> Self {
2050 let scheduler = SchedulerConfig {
2051 smith_enabled: true,
2052 force_fifo: false,
2053 preemptive: false,
2054 aging_factor: 0.0,
2055 wait_starve_ms: 0.0,
2056 enable_logging: false,
2057 ..Default::default()
2058 };
2059 Self {
2060 enabled: false,
2061 backend: TaskExecutorBackend::Spawned,
2062 scheduler,
2063 max_queue_depth: 0,
2064 explicit_backend: false,
2065 }
2066 }
2067}
2068
2069impl EffectQueueConfig {
2070 #[must_use]
2072 pub fn with_enabled(mut self, enabled: bool) -> Self {
2073 self.enabled = enabled;
2074 self.backend = if enabled {
2075 TaskExecutorBackend::EffectQueue
2076 } else {
2077 TaskExecutorBackend::Spawned
2078 };
2079 self.explicit_backend = true;
2080 self
2081 }
2082
2083 #[must_use]
2085 pub fn with_backend(mut self, backend: TaskExecutorBackend) -> Self {
2086 self.enabled = matches!(backend, TaskExecutorBackend::EffectQueue);
2087 self.backend = backend;
2088 self.explicit_backend = true;
2089 self
2090 }
2091
2092 #[must_use]
2094 pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
2095 self.scheduler = scheduler;
2096 self
2097 }
2098
2099 #[must_use]
2104 pub fn with_max_queue_depth(mut self, depth: usize) -> Self {
2105 self.max_queue_depth = depth;
2106 self
2107 }
2108
2109 #[must_use]
2110 fn uses_legacy_default_backend(&self) -> bool {
2111 !self.explicit_backend && !self.enabled && self.backend == TaskExecutorBackend::Spawned
2112 }
2113}
2114
2115#[derive(Debug, Clone)]
2122pub struct ImmediateDrainConfig {
2123 pub max_zero_timeout_polls_per_burst: usize,
2125 pub max_burst_duration: Duration,
2127 pub backoff_timeout: Duration,
2129}
2130
2131impl Default for ImmediateDrainConfig {
2132 fn default() -> Self {
2133 Self {
2134 max_zero_timeout_polls_per_burst: 64,
2135 max_burst_duration: Duration::from_millis(2),
2136 backoff_timeout: Duration::from_millis(1),
2137 }
2138 }
2139}
2140
2141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2143pub struct ImmediateDrainStats {
2144 pub bursts: u64,
2146 pub zero_timeout_polls: u64,
2148 pub backoff_polls: u64,
2150 pub capped_bursts: u64,
2152 pub max_zero_timeout_polls_in_burst: u64,
2154}
2155
2156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2170pub enum RuntimeLoadMode {
2171 Healthy,
2173 Stressed,
2175 Degraded,
2177 Recovered,
2179 Unsafe,
2181}
2182
2183impl RuntimeLoadMode {
2184 #[inline]
2186 #[must_use]
2187 pub const fn as_str(self) -> &'static str {
2188 match self {
2189 Self::Healthy => "healthy",
2190 Self::Stressed => "stressed",
2191 Self::Degraded => "degraded",
2192 Self::Recovered => "recovered",
2193 Self::Unsafe => "unsafe",
2194 }
2195 }
2196}
2197
2198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2200pub enum RuntimePressureClass {
2201 SteadyState,
2203 SoftOverload,
2205 HardOverload,
2207 Unsafe,
2209}
2210
2211impl RuntimePressureClass {
2212 #[inline]
2214 #[must_use]
2215 pub const fn as_str(self) -> &'static str {
2216 match self {
2217 Self::SteadyState => "steady_state",
2218 Self::SoftOverload => "soft_overload",
2219 Self::HardOverload => "hard_overload",
2220 Self::Unsafe => "unsafe",
2221 }
2222 }
2223}
2224
2225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2227pub enum RuntimeWorkDisposition {
2228 AdmitAll,
2230 CoalesceVisibleDeferBackground,
2232 DeferBackgroundDropBestEffort,
2234 ReadmitAfterHysteresis,
2236 FailFastStrictGuarantee,
2238}
2239
2240impl RuntimeWorkDisposition {
2241 #[inline]
2243 #[must_use]
2244 pub const fn as_str(self) -> &'static str {
2245 match self {
2246 Self::AdmitAll => "admit_all",
2247 Self::CoalesceVisibleDeferBackground => "coalesce_visible_defer_background",
2248 Self::DeferBackgroundDropBestEffort => "defer_background_drop_best_effort",
2249 Self::ReadmitAfterHysteresis => "readmit_after_hysteresis",
2250 Self::FailFastStrictGuarantee => "fail_fast_strict_guarantee",
2251 }
2252 }
2253}
2254
2255#[derive(Debug, Clone, Copy, PartialEq)]
2262pub struct LoadGovernorPolicy {
2263 pub stressed_queue_watermark: f64,
2265 pub degraded_queue_watermark: f64,
2267 pub recovery_queue_watermark: f64,
2269 pub recovery_intervals: u8,
2271 pub budget_overrun_soft_ratio: f64,
2273}
2274
2275impl Default for LoadGovernorPolicy {
2276 fn default() -> Self {
2277 Self {
2278 stressed_queue_watermark: 0.5,
2279 degraded_queue_watermark: 0.8,
2280 recovery_queue_watermark: 0.25,
2281 recovery_intervals: 3,
2282 budget_overrun_soft_ratio: 1.0,
2283 }
2284 }
2285}
2286
2287impl LoadGovernorPolicy {
2288 #[must_use]
2289 fn normalized(self) -> Self {
2290 let recovery = normalize_ratio(self.recovery_queue_watermark, 0.25);
2291 let stressed = normalize_ratio(self.stressed_queue_watermark, 0.5).max(recovery);
2292 let degraded = normalize_ratio(self.degraded_queue_watermark, 0.8).max(stressed);
2293 Self {
2294 recovery_queue_watermark: recovery,
2295 stressed_queue_watermark: stressed,
2296 degraded_queue_watermark: degraded,
2297 recovery_intervals: self.recovery_intervals.max(1),
2298 budget_overrun_soft_ratio: normalize_positive_ratio(
2299 self.budget_overrun_soft_ratio,
2300 1.0,
2301 ),
2302 }
2303 }
2304}
2305
2306#[inline]
2307fn normalize_ratio(value: f64, fallback: f64) -> f64 {
2308 if value.is_finite() {
2309 value.clamp(0.0, 1.0)
2310 } else {
2311 fallback
2312 }
2313}
2314
2315#[inline]
2316fn normalize_positive_ratio(value: f64, fallback: f64) -> f64 {
2317 if value.is_finite() && value > 0.0 {
2318 value
2319 } else {
2320 fallback
2321 }
2322}
2323
2324#[derive(Debug, Clone, PartialEq)]
2332pub struct LoadGovernorConfig {
2333 pub enabled: bool,
2335 pub budget_controller: BudgetControllerConfig,
2337 pub policy: LoadGovernorPolicy,
2339}
2340
2341impl Default for LoadGovernorConfig {
2342 fn default() -> Self {
2343 Self {
2344 enabled: true,
2345 budget_controller: BudgetControllerConfig::default(),
2346 policy: LoadGovernorPolicy::default(),
2347 }
2348 }
2349}
2350
2351impl LoadGovernorConfig {
2352 #[must_use]
2354 pub fn enabled() -> Self {
2355 Self::default()
2356 }
2357
2358 #[must_use]
2360 pub fn disabled() -> Self {
2361 Self {
2362 enabled: false,
2363 budget_controller: BudgetControllerConfig::default(),
2364 policy: LoadGovernorPolicy::default(),
2365 }
2366 }
2367
2368 #[must_use]
2370 pub fn with_enabled(mut self, enabled: bool) -> Self {
2371 self.enabled = enabled;
2372 self
2373 }
2374
2375 #[must_use]
2377 pub fn with_budget_controller(mut self, config: BudgetControllerConfig) -> Self {
2378 self.budget_controller = config;
2379 self
2380 }
2381
2382 #[must_use]
2384 pub fn with_policy(mut self, policy: LoadGovernorPolicy) -> Self {
2385 self.policy = policy.normalized();
2386 self
2387 }
2388}
2389
2390#[derive(Debug, Clone, Copy)]
2391struct LoadGovernorObservation {
2392 frame_time_us: f64,
2393 budget_us: f64,
2394 degradation: DegradationLevel,
2395 queue: crate::effect_system::QueueTelemetry,
2396 resize_coalescing_active: bool,
2397 strict_semantics_violation: bool,
2398}
2399
2400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2401struct LoadGovernorSnapshot {
2402 mode: RuntimeLoadMode,
2403 mode_before: RuntimeLoadMode,
2404 pressure_class: RuntimePressureClass,
2405 disposition: RuntimeWorkDisposition,
2406 reason_code: &'static str,
2407 transition: bool,
2408 strict_semantics_preserved: bool,
2409 queue_in_flight: u64,
2410 queue_max_depth: Option<usize>,
2411 queue_dropped_delta: u64,
2412 resize_coalescing_active: bool,
2413 recovery_intervals_observed: u8,
2414 recovery_intervals_required: u8,
2415 deferred_work_total: u64,
2416 coalesced_work_total: u64,
2417 dropped_work_total: u64,
2418}
2419
2420#[derive(Debug, Clone)]
2421struct LoadGovernorState {
2422 enabled: bool,
2423 policy: LoadGovernorPolicy,
2424 max_queue_depth: usize,
2425 mode: RuntimeLoadMode,
2426 recovery_intervals_observed: u8,
2427 last_queue_dropped: u64,
2428 queue_baseline_initialized: bool,
2429 deferred_work_total: u64,
2430 coalesced_work_total: u64,
2431 dropped_work_total: u64,
2432}
2433
2434impl LoadGovernorState {
2435 fn new(config: LoadGovernorConfig, max_queue_depth: usize) -> Self {
2436 Self {
2437 enabled: config.enabled,
2438 policy: config.policy.normalized(),
2439 max_queue_depth,
2440 mode: RuntimeLoadMode::Healthy,
2441 recovery_intervals_observed: 0,
2442 last_queue_dropped: 0,
2443 queue_baseline_initialized: false,
2444 deferred_work_total: 0,
2445 coalesced_work_total: 0,
2446 dropped_work_total: 0,
2447 }
2448 }
2449
2450 fn observe(&mut self, observation: LoadGovernorObservation) -> LoadGovernorSnapshot {
2451 if !self.enabled {
2452 return self.snapshot(
2453 RuntimeLoadMode::Healthy,
2454 RuntimeLoadMode::Healthy,
2455 RuntimePressureClass::SteadyState,
2456 RuntimeWorkDisposition::AdmitAll,
2457 "governor_disabled",
2458 false,
2459 true,
2460 observation,
2461 0,
2462 );
2463 }
2464
2465 let dropped_delta = self.queue_dropped_delta(observation.queue.dropped);
2466 let pressure = self.classify_pressure(observation, dropped_delta);
2467 let mode_before = self.mode;
2468 let reason_code = self.reason_code(observation, pressure, dropped_delta);
2469
2470 match pressure {
2471 RuntimePressureClass::Unsafe => {
2472 self.mode = RuntimeLoadMode::Unsafe;
2473 self.recovery_intervals_observed = 0;
2474 }
2475 _ if self.mode == RuntimeLoadMode::Unsafe => {
2483 self.recovery_intervals_observed = 0;
2484 }
2485 RuntimePressureClass::HardOverload => {
2486 self.mode = RuntimeLoadMode::Degraded;
2487 self.recovery_intervals_observed = 0;
2488 }
2489 RuntimePressureClass::SoftOverload => {
2490 if self.mode != RuntimeLoadMode::Degraded {
2491 self.mode = RuntimeLoadMode::Stressed;
2492 }
2493 self.recovery_intervals_observed = 0;
2494 }
2495 RuntimePressureClass::SteadyState => self.observe_steady_interval(),
2496 }
2497
2498 self.record_work_disposition(observation, dropped_delta);
2499 let disposition = Self::disposition_for_mode(self.mode);
2500 self.snapshot(
2501 self.mode,
2502 mode_before,
2503 pressure,
2504 disposition,
2505 if self.mode == RuntimeLoadMode::Unsafe {
2506 "strict_semantics_violation"
2509 } else if self.mode == RuntimeLoadMode::Recovered {
2510 "recovery_hysteresis_satisfied"
2511 } else if mode_before == RuntimeLoadMode::Recovered
2512 && self.mode == RuntimeLoadMode::Healthy
2513 {
2514 "recovered_interval_closed"
2515 } else {
2516 reason_code
2517 },
2518 mode_before != self.mode,
2519 self.mode != RuntimeLoadMode::Unsafe,
2523 observation,
2524 dropped_delta,
2525 )
2526 }
2527
2528 fn queue_dropped_delta(&mut self, dropped_total: u64) -> u64 {
2529 if !self.queue_baseline_initialized {
2530 self.queue_baseline_initialized = true;
2531 self.last_queue_dropped = dropped_total;
2532 return 0;
2533 }
2534 let delta = dropped_total.saturating_sub(self.last_queue_dropped);
2535 self.last_queue_dropped = dropped_total;
2536 delta
2537 }
2538
2539 fn classify_pressure(
2540 &self,
2541 observation: LoadGovernorObservation,
2542 dropped_delta: u64,
2543 ) -> RuntimePressureClass {
2544 if observation.strict_semantics_violation {
2545 return RuntimePressureClass::Unsafe;
2546 }
2547 if dropped_delta > 0
2548 || self
2549 .queue_ratio(observation.queue.in_flight)
2550 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark)
2551 || observation.degradation > DegradationLevel::Full
2552 {
2553 return RuntimePressureClass::HardOverload;
2554 }
2555 if self
2556 .queue_ratio(observation.queue.in_flight)
2557 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark)
2558 || observation.resize_coalescing_active
2559 || observation.frame_time_us
2560 > observation.budget_us * self.policy.budget_overrun_soft_ratio
2561 {
2562 return RuntimePressureClass::SoftOverload;
2563 }
2564 RuntimePressureClass::SteadyState
2565 }
2566
2567 fn observe_steady_interval(&mut self) {
2568 match self.mode {
2569 RuntimeLoadMode::Degraded => {
2570 self.recovery_intervals_observed = self
2571 .recovery_intervals_observed
2572 .saturating_add(1)
2573 .min(self.policy.recovery_intervals);
2574 if self.recovery_intervals_observed >= self.policy.recovery_intervals {
2575 self.mode = RuntimeLoadMode::Recovered;
2576 }
2577 }
2578 RuntimeLoadMode::Recovered | RuntimeLoadMode::Stressed => {
2579 self.mode = RuntimeLoadMode::Healthy;
2580 self.recovery_intervals_observed = 0;
2581 }
2582 RuntimeLoadMode::Healthy => {
2583 self.recovery_intervals_observed = 0;
2584 }
2585 RuntimeLoadMode::Unsafe => {}
2586 }
2587 }
2588
2589 fn record_work_disposition(
2590 &mut self,
2591 observation: LoadGovernorObservation,
2592 dropped_delta: u64,
2593 ) {
2594 if dropped_delta > 0 {
2595 self.dropped_work_total = self.dropped_work_total.saturating_add(dropped_delta);
2596 }
2597 match self.mode {
2598 RuntimeLoadMode::Stressed => {
2599 if observation.resize_coalescing_active {
2600 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2601 }
2602 }
2603 RuntimeLoadMode::Degraded => {
2604 self.deferred_work_total = self.deferred_work_total.saturating_add(1);
2605 if observation.resize_coalescing_active {
2606 self.coalesced_work_total = self.coalesced_work_total.saturating_add(1);
2607 }
2608 }
2609 RuntimeLoadMode::Healthy | RuntimeLoadMode::Recovered | RuntimeLoadMode::Unsafe => {}
2610 }
2611 }
2612
2613 fn reason_code(
2614 &self,
2615 observation: LoadGovernorObservation,
2616 pressure: RuntimePressureClass,
2617 dropped_delta: u64,
2618 ) -> &'static str {
2619 match pressure {
2620 RuntimePressureClass::Unsafe => "strict_semantics_violation",
2621 RuntimePressureClass::HardOverload if dropped_delta > 0 => "effect_queue_drop",
2622 RuntimePressureClass::HardOverload
2623 if self
2624 .queue_ratio(observation.queue.in_flight)
2625 .is_some_and(|ratio| ratio >= self.policy.degraded_queue_watermark) =>
2626 {
2627 "queue_degraded_watermark"
2628 }
2629 RuntimePressureClass::HardOverload => "budget_degradation_active",
2630 RuntimePressureClass::SoftOverload
2631 if self
2632 .queue_ratio(observation.queue.in_flight)
2633 .is_some_and(|ratio| ratio >= self.policy.stressed_queue_watermark) =>
2634 {
2635 "queue_stressed_watermark"
2636 }
2637 RuntimePressureClass::SoftOverload if observation.resize_coalescing_active => {
2638 "resize_coalescing_active"
2639 }
2640 RuntimePressureClass::SoftOverload => "frame_budget_overrun",
2641 RuntimePressureClass::SteadyState if self.mode == RuntimeLoadMode::Degraded => {
2642 "recovery_hysteresis_pending"
2643 }
2644 RuntimePressureClass::SteadyState => "steady_state",
2645 }
2646 }
2647
2648 fn queue_ratio(&self, in_flight: u64) -> Option<f64> {
2649 (self.max_queue_depth > 0).then(|| in_flight as f64 / self.max_queue_depth as f64)
2650 }
2651
2652 const fn disposition_for_mode(mode: RuntimeLoadMode) -> RuntimeWorkDisposition {
2653 match mode {
2654 RuntimeLoadMode::Healthy => RuntimeWorkDisposition::AdmitAll,
2655 RuntimeLoadMode::Stressed => RuntimeWorkDisposition::CoalesceVisibleDeferBackground,
2656 RuntimeLoadMode::Degraded => RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
2657 RuntimeLoadMode::Recovered => RuntimeWorkDisposition::ReadmitAfterHysteresis,
2658 RuntimeLoadMode::Unsafe => RuntimeWorkDisposition::FailFastStrictGuarantee,
2659 }
2660 }
2661
2662 #[allow(clippy::too_many_arguments)]
2666 fn snapshot(
2667 &self,
2668 mode: RuntimeLoadMode,
2669 mode_before: RuntimeLoadMode,
2670 pressure_class: RuntimePressureClass,
2671 disposition: RuntimeWorkDisposition,
2672 reason_code: &'static str,
2673 transition: bool,
2674 strict_semantics_preserved: bool,
2675 observation: LoadGovernorObservation,
2676 dropped_delta: u64,
2677 ) -> LoadGovernorSnapshot {
2678 LoadGovernorSnapshot {
2679 mode,
2680 mode_before,
2681 pressure_class,
2682 disposition,
2683 reason_code,
2684 transition,
2685 strict_semantics_preserved,
2686 queue_in_flight: observation.queue.in_flight,
2687 queue_max_depth: (self.max_queue_depth > 0).then_some(self.max_queue_depth),
2688 queue_dropped_delta: dropped_delta,
2689 resize_coalescing_active: observation.resize_coalescing_active,
2690 recovery_intervals_observed: self.recovery_intervals_observed,
2691 recovery_intervals_required: self.policy.recovery_intervals,
2692 deferred_work_total: self.deferred_work_total,
2693 coalesced_work_total: self.coalesced_work_total,
2694 dropped_work_total: self.dropped_work_total,
2695 }
2696 }
2697}
2698
2699#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2713pub enum RuntimeLane {
2714 Legacy,
2717 #[default]
2720 Structured,
2721 Asupersync,
2724}
2725
2726impl RuntimeLane {
2727 #[must_use]
2732 pub fn resolve(self) -> Self {
2733 match self {
2734 Self::Asupersync => {
2735 tracing::info!(
2736 target: "ftui.runtime",
2737 requested = "asupersync",
2738 resolved = "structured",
2739 "Asupersync lane not yet available; falling back to structured cancellation"
2740 );
2741 Self::Structured
2742 }
2743 other => other,
2744 }
2745 }
2746
2747 #[must_use]
2749 pub fn label(self) -> &'static str {
2750 match self {
2751 Self::Legacy => "legacy",
2752 Self::Structured => "structured",
2753 Self::Asupersync => "asupersync",
2754 }
2755 }
2756
2757 #[must_use]
2759 pub fn uses_structured_cancellation(self) -> bool {
2760 matches!(self, Self::Structured | Self::Asupersync)
2761 }
2762
2763 #[must_use]
2785 fn task_executor_backend(self) -> TaskExecutorBackend {
2786 match self {
2787 Self::Legacy | Self::Structured => TaskExecutorBackend::Spawned,
2792 Self::Asupersync => {
2793 #[cfg(feature = "asupersync-executor")]
2794 {
2795 TaskExecutorBackend::Asupersync
2796 }
2797 #[cfg(not(feature = "asupersync-executor"))]
2798 {
2799 TaskExecutorBackend::EffectQueue
2800 }
2801 }
2802 }
2803 }
2804
2805 #[must_use]
2810 pub fn from_env() -> Option<Self> {
2811 let val = std::env::var("FTUI_RUNTIME_LANE").ok()?;
2812 Self::parse(&val)
2813 }
2814
2815 #[must_use]
2819 pub fn parse(s: &str) -> Option<Self> {
2820 match s.to_ascii_lowercase().as_str() {
2821 "legacy" => Some(Self::Legacy),
2822 "structured" => Some(Self::Structured),
2823 "asupersync" => Some(Self::Asupersync),
2824 _ => {
2825 tracing::warn!(
2826 target: "ftui.runtime",
2827 value = s,
2828 "RuntimeLane::parse: unrecognized value"
2829 );
2830 None
2831 }
2832 }
2833 }
2834}
2835
2836#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
2850pub enum RolloutPolicy {
2851 #[default]
2853 Off,
2854 Shadow,
2856 Enabled,
2858}
2859
2860impl RolloutPolicy {
2861 #[must_use]
2866 pub fn from_env() -> Option<Self> {
2867 let val = std::env::var("FTUI_ROLLOUT_POLICY").ok()?;
2868 Self::parse(&val)
2869 }
2870
2871 #[must_use]
2875 pub fn parse(s: &str) -> Option<Self> {
2876 match s.to_ascii_lowercase().as_str() {
2877 "off" => Some(Self::Off),
2878 "shadow" => Some(Self::Shadow),
2879 "enabled" => Some(Self::Enabled),
2880 _ => {
2881 tracing::warn!(
2882 target: "ftui.runtime",
2883 value = s,
2884 "RolloutPolicy::parse: unrecognized value"
2885 );
2886 None
2887 }
2888 }
2889 }
2890
2891 #[must_use]
2893 pub fn label(self) -> &'static str {
2894 match self {
2895 Self::Off => "off",
2896 Self::Shadow => "shadow",
2897 Self::Enabled => "enabled",
2898 }
2899 }
2900
2901 #[must_use]
2903 pub fn is_shadow(self) -> bool {
2904 matches!(self, Self::Shadow)
2905 }
2906}
2907
2908impl std::fmt::Display for RolloutPolicy {
2909 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2910 f.write_str(self.label())
2911 }
2912}
2913
2914impl std::fmt::Display for RuntimeLane {
2915 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2916 f.write_str(self.label())
2917 }
2918}
2919
2920#[derive(Debug, Clone)]
2922pub struct ProgramConfig {
2923 pub screen_mode: ScreenMode,
2925 pub ui_anchor: UiAnchor,
2927 pub budget: FrameBudgetConfig,
2929 pub load_governor: LoadGovernorConfig,
2931 pub diff_config: RuntimeDiffConfig,
2933 pub evidence_sink: EvidenceSinkConfig,
2935 pub render_trace: RenderTraceConfig,
2937 pub frame_timing: Option<FrameTimingConfig>,
2939 pub conformal_config: Option<ConformalConfig>,
2941 pub locale_context: LocaleContext,
2943 pub poll_timeout: Duration,
2945 pub immediate_drain: ImmediateDrainConfig,
2947 pub resize_coalescer: CoalescerConfig,
2949 pub resize_behavior: ResizeBehavior,
2951 pub forced_size: Option<(u16, u16)>,
2953 pub mouse_capture_policy: MouseCapturePolicy,
2957 pub bracketed_paste: bool,
2959 pub focus_reporting: bool,
2961 pub kitty_keyboard: bool,
2963 pub persistence: PersistenceConfig,
2965 pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
2967 pub widget_refresh: WidgetRefreshConfig,
2969 pub effect_queue: EffectQueueConfig,
2971 pub guardrails: GuardrailsConfig,
2973 pub intercept_signals: bool,
2978 pub tick_strategy: Option<crate::tick_strategy::TickStrategyKind>,
2983 pub runtime_lane: RuntimeLane,
2989 pub rollout_policy: RolloutPolicy,
2995}
2996
2997impl Default for ProgramConfig {
2998 fn default() -> Self {
2999 Self {
3000 screen_mode: ScreenMode::Inline { ui_height: 4 },
3001 ui_anchor: UiAnchor::Bottom,
3002 budget: FrameBudgetConfig::default(),
3003 load_governor: LoadGovernorConfig::default(),
3004 diff_config: RuntimeDiffConfig::default(),
3005 evidence_sink: EvidenceSinkConfig::default(),
3006 render_trace: RenderTraceConfig::default(),
3007 frame_timing: None,
3008 conformal_config: None,
3009 locale_context: LocaleContext::global(),
3010 poll_timeout: Duration::from_millis(100),
3011 immediate_drain: ImmediateDrainConfig::default(),
3012 resize_coalescer: CoalescerConfig::default(),
3013 resize_behavior: ResizeBehavior::Throttled,
3014 forced_size: None,
3015 mouse_capture_policy: MouseCapturePolicy::Auto,
3016 bracketed_paste: true,
3017 focus_reporting: false,
3018 kitty_keyboard: false,
3019 persistence: PersistenceConfig::default(),
3020 inline_auto_remeasure: None,
3021 widget_refresh: WidgetRefreshConfig::default(),
3022 effect_queue: EffectQueueConfig::default(),
3023 guardrails: GuardrailsConfig::default(),
3024 intercept_signals: true,
3025 tick_strategy: None,
3026 runtime_lane: RuntimeLane::default(),
3027 rollout_policy: RolloutPolicy::default(),
3028 }
3029 }
3030}
3031
3032impl ProgramConfig {
3033 pub fn fullscreen() -> Self {
3035 Self {
3036 screen_mode: ScreenMode::AltScreen,
3037 ..Default::default()
3038 }
3039 }
3040
3041 pub fn inline(height: u16) -> Self {
3043 Self {
3044 screen_mode: ScreenMode::Inline { ui_height: height },
3045 ..Default::default()
3046 }
3047 }
3048
3049 pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
3051 Self {
3052 screen_mode: ScreenMode::InlineAuto {
3053 min_height,
3054 max_height,
3055 },
3056 inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
3057 ..Default::default()
3058 }
3059 }
3060
3061 #[must_use]
3063 pub fn with_mouse(mut self) -> Self {
3064 self.mouse_capture_policy = MouseCapturePolicy::On;
3065 self
3066 }
3067
3068 #[must_use]
3070 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
3071 self.mouse_capture_policy = policy;
3072 self
3073 }
3074
3075 #[must_use]
3077 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
3078 self.mouse_capture_policy = if enabled {
3079 MouseCapturePolicy::On
3080 } else {
3081 MouseCapturePolicy::Off
3082 };
3083 self
3084 }
3085
3086 #[must_use]
3088 pub const fn resolved_mouse_capture(&self) -> bool {
3089 self.mouse_capture_policy.resolve(self.screen_mode)
3090 }
3091
3092 #[must_use]
3094 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
3095 self.budget = budget;
3096 self
3097 }
3098
3099 #[must_use]
3101 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
3102 self.load_governor = config;
3103 self
3104 }
3105
3106 #[must_use]
3108 pub fn without_load_governor(mut self) -> Self {
3109 self.load_governor = LoadGovernorConfig::disabled();
3110 self
3111 }
3112
3113 #[must_use]
3115 pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
3116 self.diff_config = diff_config;
3117 self
3118 }
3119
3120 #[must_use]
3122 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
3123 self.evidence_sink = config;
3124 self
3125 }
3126
3127 #[must_use]
3129 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
3130 self.render_trace = config;
3131 self
3132 }
3133
3134 #[must_use]
3136 pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
3137 self.frame_timing = Some(config);
3138 self
3139 }
3140
3141 #[must_use]
3143 pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
3144 self.conformal_config = Some(config);
3145 self
3146 }
3147
3148 #[must_use]
3150 pub fn without_conformal(mut self) -> Self {
3151 self.conformal_config = None;
3152 self
3153 }
3154
3155 #[must_use]
3157 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
3158 self.locale_context = locale_context;
3159 self
3160 }
3161
3162 #[must_use]
3164 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
3165 self.locale_context = LocaleContext::new(locale);
3166 self
3167 }
3168
3169 #[must_use]
3171 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
3172 self.widget_refresh = config;
3173 self
3174 }
3175
3176 #[must_use]
3178 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
3179 self.effect_queue = config;
3180 self
3181 }
3182
3183 #[must_use]
3185 pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
3186 self.resize_coalescer = config;
3187 self
3188 }
3189
3190 #[must_use]
3192 pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
3193 self.resize_behavior = behavior;
3194 self
3195 }
3196
3197 #[must_use]
3199 pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
3200 let width = width.max(1);
3201 let height = height.max(1);
3202 self.forced_size = Some((width, height));
3203 self
3204 }
3205
3206 #[must_use]
3208 pub fn without_forced_size(mut self) -> Self {
3209 self.forced_size = None;
3210 self
3211 }
3212
3213 #[must_use]
3215 pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
3216 if enabled {
3217 self.resize_behavior = ResizeBehavior::Immediate;
3218 }
3219 self
3220 }
3221
3222 #[must_use]
3224 pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
3225 self.persistence = persistence;
3226 self
3227 }
3228
3229 #[must_use]
3231 pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
3232 self.persistence = PersistenceConfig::with_registry(registry);
3233 self
3234 }
3235
3236 #[must_use]
3238 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
3239 self.inline_auto_remeasure = Some(config);
3240 self
3241 }
3242
3243 #[must_use]
3245 pub fn without_inline_auto_remeasure(mut self) -> Self {
3246 self.inline_auto_remeasure = None;
3247 self
3248 }
3249
3250 #[must_use]
3252 pub fn with_signal_interception(mut self, enabled: bool) -> Self {
3253 self.intercept_signals = enabled;
3254 self
3255 }
3256
3257 #[must_use]
3259 pub fn with_guardrails(mut self, config: GuardrailsConfig) -> Self {
3260 self.guardrails = config;
3261 self
3262 }
3263
3264 #[must_use]
3266 pub fn with_immediate_drain(mut self, config: ImmediateDrainConfig) -> Self {
3267 self.immediate_drain = config;
3268 self
3269 }
3270
3271 #[must_use]
3282 pub fn with_tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
3283 self.tick_strategy = Some(strategy);
3284 self
3285 }
3286
3287 #[must_use]
3289 pub fn with_lane(mut self, lane: RuntimeLane) -> Self {
3290 self.runtime_lane = lane;
3291 self
3292 }
3293
3294 #[must_use]
3296 pub fn with_rollout_policy(mut self, policy: RolloutPolicy) -> Self {
3297 self.rollout_policy = policy;
3298 self
3299 }
3300
3301 #[must_use]
3307 pub fn with_env_overrides(mut self) -> Self {
3308 if let Some(lane) = RuntimeLane::from_env() {
3309 self.runtime_lane = lane;
3310 }
3311 if let Some(policy) = RolloutPolicy::from_env() {
3312 self.rollout_policy = policy;
3313 }
3314 self
3315 }
3316
3317 #[must_use]
3318 fn resolved_effect_queue_config(&self) -> EffectQueueConfig {
3319 if !self.effect_queue.uses_legacy_default_backend() {
3320 return self.effect_queue.clone();
3321 }
3322
3323 self.effect_queue
3324 .clone()
3325 .with_backend(self.runtime_lane.resolve().task_executor_backend())
3326 }
3327}
3328
3329fn render_budget_from_program_config(config: &ProgramConfig) -> RenderBudget {
3330 let budget = RenderBudget::from_config(&config.budget);
3331 if config.load_governor.enabled {
3332 let mut controller = config.load_governor.budget_controller.clone();
3333 controller.target = config.budget.total;
3334 budget.with_controller(controller)
3335 } else {
3336 budget
3337 }
3338}
3339
3340enum EffectCommand<M> {
3341 Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
3342 Shutdown,
3343}
3344
3345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3346enum EffectLoopControl {
3347 Continue,
3348 ShutdownRequested,
3349}
3350
3351struct EffectQueue<M: Send + 'static> {
3352 sender: mpsc::Sender<EffectCommand<M>>,
3353 handle: Option<JoinHandle<()>>,
3354 closed: bool,
3355}
3356
3357impl<M: Send + 'static> EffectQueue<M> {
3358 fn start(
3359 config: EffectQueueConfig,
3360 result_sender: mpsc::Sender<M>,
3361 evidence_sink: Option<EvidenceSink>,
3362 ) -> io::Result<Self> {
3363 let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
3364 let handle = thread::Builder::new()
3365 .name("ftui-effects".into())
3366 .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
3367
3368 Ok(Self {
3369 sender: tx,
3370 handle: Some(handle),
3371 closed: false,
3372 })
3373 }
3374
3375 fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3376 if self.closed {
3377 crate::effect_system::record_queue_drop("post_shutdown");
3378 tracing::debug!("rejecting task enqueue after effect queue shutdown");
3379 return;
3380 }
3381 if self
3382 .sender
3383 .send(EffectCommand::Enqueue(spec, task))
3384 .is_err()
3385 {
3386 crate::effect_system::record_queue_drop("channel_closed");
3387 }
3388 }
3389
3390 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3392 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3398
3399 fn shutdown(&mut self) {
3400 self.closed = true;
3401 let _ = self.sender.send(EffectCommand::Shutdown);
3402 if let Some(handle) = self.handle.take() {
3403 let start = Instant::now();
3404 if handle.is_finished() {
3407 let _ = handle.join();
3408 let elapsed_us = start.elapsed().as_micros() as u64;
3409 tracing::debug!(
3410 target: "ftui.runtime",
3411 elapsed_us,
3412 "effect-queue shutdown (fast path)"
3413 );
3414 return;
3415 }
3416 while !handle.is_finished() {
3418 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3419 tracing::warn!(
3420 target: "ftui.runtime",
3421 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3422 "effect-queue thread did not stop within timeout; detaching"
3423 );
3424 return;
3425 }
3426 thread::sleep(Self::SHUTDOWN_POLL);
3427 }
3428 let _ = handle.join();
3429 let elapsed_us = start.elapsed().as_micros() as u64;
3430 tracing::debug!(
3431 target: "ftui.runtime",
3432 elapsed_us,
3433 "effect-queue shutdown (slow path)"
3434 );
3435 }
3436 }
3437}
3438
3439impl<M: Send + 'static> Drop for EffectQueue<M> {
3440 fn drop(&mut self) {
3441 self.shutdown();
3442 }
3443}
3444
3445struct SpawnTaskExecutor<M: Send + 'static> {
3446 result_sender: mpsc::Sender<M>,
3447 evidence_sink: Option<EvidenceSink>,
3448 handles: Vec<JoinHandle<()>>,
3449 max_queue_depth: usize,
3452 dropped: u64,
3454 closed: bool,
3455}
3456
3457impl<M: Send + 'static> SpawnTaskExecutor<M> {
3458 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3459 const SHUTDOWN_POLL: Duration = Duration::from_millis(1);
3465
3466 fn new(
3467 result_sender: mpsc::Sender<M>,
3468 evidence_sink: Option<EvidenceSink>,
3469 max_queue_depth: usize,
3470 ) -> Self {
3471 Self {
3472 result_sender,
3473 evidence_sink,
3474 handles: Vec::new(),
3475 max_queue_depth,
3476 dropped: 0,
3477 closed: false,
3478 }
3479 }
3480
3481 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3482 if self.closed {
3483 self.dropped += 1;
3484 crate::effect_system::record_queue_drop("post_shutdown");
3485 tracing::debug!("rejecting spawned task submit after shutdown");
3486 return;
3487 }
3488 self.reap_finished();
3491 if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3496 self.dropped += 1;
3497 crate::effect_system::record_queue_drop("backpressure");
3498 emit_task_executor_backpressure_evidence(
3499 self.evidence_sink.as_ref(),
3500 "spawned",
3501 "drop",
3502 self.handles.len(),
3503 self.max_queue_depth,
3504 self.dropped,
3505 );
3506 return;
3507 }
3508 crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3509 let sender = self.result_sender.clone();
3510 let evidence_sink = self.evidence_sink.clone();
3511 let handle = thread::spawn(move || {
3512 let _ = run_task_closure(task, "spawned", evidence_sink.as_ref(), &sender);
3513 crate::effect_system::record_queue_processed();
3514 });
3515 self.handles.push(handle);
3516 }
3517
3518 fn reap_finished(&mut self) {
3519 if self.handles.is_empty() {
3520 return;
3521 }
3522
3523 let mut i = 0;
3524 while i < self.handles.len() {
3525 if self.handles[i].is_finished() {
3526 let handle = self.handles.swap_remove(i);
3527 let _ = handle.join();
3528 } else {
3529 i += 1;
3530 }
3531 }
3532 }
3533
3534 fn shutdown(&mut self) {
3535 self.closed = true;
3536 let start = Instant::now();
3537 self.reap_finished();
3539 if self.handles.is_empty() {
3540 let elapsed_us = start.elapsed().as_micros() as u64;
3541 tracing::debug!(
3542 target: "ftui.runtime",
3543 elapsed_us,
3544 "spawn-executor shutdown (fast path, all tasks already finished)"
3545 );
3546 return;
3547 }
3548 let pending_at_start = self.handles.len();
3550 while self.handles.iter().any(|handle| !handle.is_finished()) {
3551 if start.elapsed() >= Self::SHUTDOWN_TIMEOUT {
3552 let still_pending = self
3553 .handles
3554 .iter()
3555 .filter(|handle| !handle.is_finished())
3556 .count();
3557 tracing::warn!(
3558 target: "ftui.runtime",
3559 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3560 pending_handles = still_pending,
3561 "background task threads did not stop within timeout; detaching"
3562 );
3563 self.handles.clear();
3564 return;
3565 }
3566 thread::sleep(Self::SHUTDOWN_POLL);
3567 }
3568 self.reap_finished();
3569 let elapsed_us = start.elapsed().as_micros() as u64;
3570 tracing::debug!(
3571 target: "ftui.runtime",
3572 elapsed_us,
3573 pending_at_start,
3574 "spawn-executor shutdown (slow path)"
3575 );
3576 }
3577}
3578
3579#[cfg(feature = "asupersync-executor")]
3580struct AsupersyncTaskExecutor<M: Send + 'static> {
3581 result_sender: mpsc::Sender<M>,
3582 evidence_sink: Option<EvidenceSink>,
3583 runtime: AsupersyncRuntime,
3584 handles: Vec<BlockingTaskHandle>,
3585 max_queue_depth: usize,
3589 dropped: u64,
3591 closed: bool,
3592}
3593
3594#[cfg(feature = "asupersync-executor")]
3595impl<M: Send + 'static> AsupersyncTaskExecutor<M> {
3596 const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
3597
3598 fn new(
3599 result_sender: mpsc::Sender<M>,
3600 evidence_sink: Option<EvidenceSink>,
3601 max_queue_depth: usize,
3602 ) -> io::Result<Self> {
3603 let max_threads = thread::available_parallelism().map_or(1, |count| count.get().max(1));
3604 let runtime = RuntimeBuilder::new()
3605 .blocking_threads(1, max_threads)
3606 .thread_name_prefix("ftui-asupersync-task")
3607 .build()
3608 .map_err(|error| {
3609 io::Error::other(format!("asupersync runtime init failed: {error}"))
3610 })?;
3611
3612 Ok(Self {
3613 result_sender,
3614 evidence_sink,
3615 runtime,
3616 handles: Vec::new(),
3617 max_queue_depth,
3618 dropped: 0,
3619 closed: false,
3620 })
3621 }
3622
3623 fn submit(&mut self, task: Box<dyn FnOnce() -> M + Send>) {
3624 if self.closed {
3625 self.dropped += 1;
3626 crate::effect_system::record_queue_drop("post_shutdown");
3627 tracing::debug!("rejecting asupersync task submit after shutdown");
3628 return;
3629 }
3630 self.handles.retain(|handle| !handle.is_done());
3633 if self.max_queue_depth > 0 && self.handles.len() >= self.max_queue_depth {
3636 self.dropped += 1;
3637 crate::effect_system::record_queue_drop("backpressure");
3638 emit_task_executor_backpressure_evidence(
3639 self.evidence_sink.as_ref(),
3640 "asupersync",
3641 "drop",
3642 self.handles.len(),
3643 self.max_queue_depth,
3644 self.dropped,
3645 );
3646 return;
3647 }
3648 crate::effect_system::record_queue_enqueue(self.handles.len() as u64 + 1);
3649 let sender = self.result_sender.clone();
3650 let evidence_sink = self.evidence_sink.clone();
3651 let handle = self
3652 .runtime
3653 .spawn_blocking(move || {
3654 let _ = run_task_closure(task, "asupersync", evidence_sink.as_ref(), &sender);
3655 crate::effect_system::record_queue_processed();
3656 })
3657 .expect("asupersync blocking pool must be configured");
3658 self.handles.push(handle);
3659 }
3660
3661 fn reap_finished(&mut self) {
3662 self.handles.retain(|handle| !handle.is_done());
3663 }
3664
3665 fn shutdown(&mut self) {
3666 self.closed = true;
3667 let deadline = Instant::now() + Self::SHUTDOWN_TIMEOUT;
3668 for handle in &self.handles {
3669 let remaining = deadline.saturating_duration_since(Instant::now());
3670 if remaining.is_zero() || !handle.wait_timeout(remaining) {
3671 tracing::warn!(
3672 timeout_ms = Self::SHUTDOWN_TIMEOUT.as_millis() as u64,
3673 pending_handles = self
3674 .handles
3675 .iter()
3676 .filter(|pending| !pending.is_done())
3677 .count(),
3678 "Asupersync blocking tasks did not stop within timeout; detaching"
3679 );
3680 self.handles.clear();
3681 return;
3682 }
3683 }
3684 self.handles.clear();
3685 }
3686}
3687
3688enum TaskExecutor<M: Send + 'static> {
3689 Spawned(SpawnTaskExecutor<M>),
3690 Queued(EffectQueue<M>),
3691 #[cfg(feature = "asupersync-executor")]
3692 Asupersync(AsupersyncTaskExecutor<M>),
3693}
3694
3695impl<M: Send + 'static> TaskExecutor<M> {
3696 fn new(
3697 config: &EffectQueueConfig,
3698 result_sender: mpsc::Sender<M>,
3699 evidence_sink: Option<EvidenceSink>,
3700 ) -> io::Result<Self> {
3701 let executor = match config.backend {
3702 TaskExecutorBackend::Spawned => Self::Spawned(SpawnTaskExecutor::new(
3703 result_sender,
3704 evidence_sink.clone(),
3705 config.max_queue_depth,
3706 )),
3707 TaskExecutorBackend::EffectQueue => Self::Queued(EffectQueue::start(
3708 config.clone(),
3709 result_sender,
3710 evidence_sink.clone(),
3711 )?),
3712 #[cfg(feature = "asupersync-executor")]
3713 TaskExecutorBackend::Asupersync => Self::Asupersync(AsupersyncTaskExecutor::new(
3714 result_sender,
3715 evidence_sink.clone(),
3716 config.max_queue_depth,
3717 )?),
3718 };
3719
3720 emit_task_executor_backend_evidence(evidence_sink.as_ref(), executor.kind_name_for_logs());
3721 Ok(executor)
3722 }
3723
3724 fn submit(&mut self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
3725 match self {
3726 Self::Spawned(executor) => executor.submit(task),
3727 Self::Queued(queue) => queue.enqueue(spec, task),
3728 #[cfg(feature = "asupersync-executor")]
3729 Self::Asupersync(executor) => executor.submit(task),
3730 }
3731 }
3732
3733 fn reap_finished(&mut self) {
3734 match self {
3735 Self::Spawned(executor) => executor.reap_finished(),
3736 #[cfg(feature = "asupersync-executor")]
3737 Self::Asupersync(executor) => executor.reap_finished(),
3738 Self::Queued(_) => {}
3739 }
3740 }
3741
3742 fn shutdown(&mut self) {
3743 match self {
3744 Self::Spawned(executor) => executor.shutdown(),
3745 Self::Queued(queue) => queue.shutdown(),
3746 #[cfg(feature = "asupersync-executor")]
3747 Self::Asupersync(executor) => executor.shutdown(),
3748 }
3749 }
3750
3751 #[cfg(test)]
3752 fn kind_name(&self) -> &'static str {
3753 self.kind_name_for_logs()
3754 }
3755
3756 fn kind_name_for_logs(&self) -> &'static str {
3757 match self {
3758 Self::Spawned(_) => "spawned",
3759 Self::Queued(_) => "queued",
3760 #[cfg(feature = "asupersync-executor")]
3761 Self::Asupersync(_) => "asupersync",
3762 }
3763 }
3764}
3765
3766fn emit_task_executor_backend_evidence(sink: Option<&EvidenceSink>, backend: &str) {
3767 let Some(sink) = sink else {
3768 return;
3769 };
3770 let _ = sink.write_jsonl(&format!(
3771 r#"{{"event":"task_executor_backend","backend":"{backend}"}}"#
3772 ));
3773}
3774
3775fn emit_task_executor_completion_evidence(
3776 sink: Option<&EvidenceSink>,
3777 backend: &str,
3778 duration_us: u64,
3779) {
3780 let Some(sink) = sink else {
3781 return;
3782 };
3783 let _ = sink.write_jsonl(&format!(
3784 r#"{{"event":"task_executor_complete","backend":"{backend}","duration_us":{duration_us}}}"#
3785 ));
3786}
3787
3788fn emit_task_executor_panic_evidence(sink: Option<&EvidenceSink>, backend: &str, panic_msg: &str) {
3789 let Some(sink) = sink else {
3790 return;
3791 };
3792 let escaped = panic_msg
3793 .replace('\\', "\\\\")
3794 .replace('"', "\\\"")
3795 .replace('\n', "\\n")
3796 .replace('\r', "\\r")
3797 .replace('\t', "\\t");
3798 let _ = sink.write_jsonl(&format!(
3799 r#"{{"event":"task_executor_panic","backend":"{backend}","panic_msg":"{escaped}"}}"#
3800 ));
3801}
3802
3803fn emit_task_executor_backpressure_evidence(
3804 sink: Option<&EvidenceSink>,
3805 backend: &str,
3806 action: &str,
3807 queue_length: usize,
3808 max_queue_size: usize,
3809 total_rejected: u64,
3810) {
3811 let Some(sink) = sink else {
3812 return;
3813 };
3814 let _ = sink.write_jsonl(&format!(
3815 r#"{{"event":"task_executor_backpressure","backend":"{backend}","action":"{action}","queue_length":{queue_length},"max_queue_size":{max_queue_size},"total_rejected":{total_rejected}}}"#
3816 ));
3817}
3818
3819fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
3820 if let Some(s) = payload.downcast_ref::<&str>() {
3821 (*s).to_owned()
3822 } else if let Some(s) = payload.downcast_ref::<String>() {
3823 s.clone()
3824 } else {
3825 "unknown panic payload".to_owned()
3826 }
3827}
3828
3829fn log_task_executor_panic(backend: &str, panic_msg: &str) {
3830 #[cfg(feature = "tracing")]
3831 tracing::error!(
3832 executor_backend = backend,
3833 panic_msg,
3834 "task executor task panicked"
3835 );
3836 #[cfg(not(feature = "tracing"))]
3837 eprintln!("ftui: task executor task panicked ({backend}): {panic_msg}");
3838}
3839
3840fn run_task_closure<M: Send + 'static>(
3841 task: Box<dyn FnOnce() -> M + Send>,
3842 backend: &str,
3843 evidence_sink: Option<&EvidenceSink>,
3844 result_sender: &mpsc::Sender<M>,
3845) -> bool {
3846 let start = Instant::now();
3847 let caught =
3853 ftui_core::with_panic_cleanup_suppressed(|| panic::catch_unwind(AssertUnwindSafe(task)));
3854 match caught {
3855 Ok(msg) => {
3856 let duration_us = start.elapsed().as_micros() as u64;
3857 tracing::debug!(
3858 target: "ftui.effect",
3859 command_type = "task",
3860 executor_backend = backend,
3861 duration_us = duration_us,
3862 effect_duration_us = duration_us,
3863 "task effect completed"
3864 );
3865 emit_task_executor_completion_evidence(evidence_sink, backend, duration_us);
3866 let _ = result_sender.send(msg);
3867 true
3868 }
3869 Err(payload) => {
3870 let panic_msg = panic_payload_message(payload);
3871 log_task_executor_panic(backend, &panic_msg);
3872 emit_task_executor_panic_evidence(evidence_sink, backend, &panic_msg);
3873 false
3874 }
3875 }
3876}
3877
3878fn effect_queue_loop<M: Send + 'static>(
3879 config: EffectQueueConfig,
3880 rx: mpsc::Receiver<EffectCommand<M>>,
3881 result_sender: mpsc::Sender<M>,
3882 evidence_sink: Option<EvidenceSink>,
3883) {
3884 let mut scheduler = QueueingScheduler::new(config.scheduler);
3885 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
3886 let mut shutdown_requested = false;
3887 let max_depth = config.max_queue_depth;
3888
3889 loop {
3890 if tasks.is_empty() {
3891 if shutdown_requested {
3892 return;
3893 }
3894 match rx.recv() {
3895 Ok(cmd) => {
3896 if matches!(
3897 handle_effect_command(
3898 cmd,
3899 &mut scheduler,
3900 &mut tasks,
3901 &result_sender,
3902 evidence_sink.as_ref(),
3903 max_depth,
3904 ),
3905 EffectLoopControl::ShutdownRequested
3906 ) {
3907 shutdown_requested = true;
3908 }
3909 }
3910 Err(_) => return,
3911 }
3912 }
3913
3914 while let Ok(cmd) = rx.try_recv() {
3915 if shutdown_requested && matches!(cmd, EffectCommand::Enqueue(_, _)) {
3916 crate::effect_system::record_queue_drop("post_shutdown");
3917 continue;
3918 }
3919 if matches!(
3920 handle_effect_command(
3921 cmd,
3922 &mut scheduler,
3923 &mut tasks,
3924 &result_sender,
3925 evidence_sink.as_ref(),
3926 max_depth,
3927 ),
3928 EffectLoopControl::ShutdownRequested
3929 ) {
3930 shutdown_requested = true;
3931 }
3932 }
3933
3934 if tasks.is_empty() {
3935 if shutdown_requested {
3936 return;
3937 }
3938 continue;
3939 }
3940
3941 let Some(job) = scheduler.peek_next().cloned() else {
3942 continue;
3943 };
3944
3945 if let Some(ref sink) = evidence_sink {
3946 let evidence = scheduler.evidence();
3947 let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
3948 }
3949
3950 let completed = scheduler.tick(job.remaining_time);
3951 for job_id in completed {
3952 if let Some(task) = tasks.remove(&job_id) {
3953 let _ = run_task_closure(task, "queued", evidence_sink.as_ref(), &result_sender);
3954 crate::effect_system::record_queue_processed();
3955 }
3956 }
3957 }
3958}
3959
3960fn handle_effect_command<M: Send + 'static>(
3961 cmd: EffectCommand<M>,
3962 scheduler: &mut QueueingScheduler,
3963 tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
3964 result_sender: &mpsc::Sender<M>,
3965 evidence_sink: Option<&EvidenceSink>,
3966 max_depth: usize,
3967) -> EffectLoopControl {
3968 match cmd {
3969 EffectCommand::Enqueue(spec, task) => {
3970 if max_depth > 0 && tasks.len() >= max_depth {
3972 crate::effect_system::record_queue_drop("backpressure");
3973 return EffectLoopControl::Continue;
3974 }
3975 let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
3976 WeightSource::Default
3977 } else {
3978 WeightSource::Explicit
3979 };
3980 let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
3981 EstimateSource::Default
3982 } else {
3983 EstimateSource::Explicit
3984 };
3985 let id = scheduler.submit_with_sources(
3986 spec.weight,
3987 spec.estimate_ms,
3988 weight_source,
3989 estimate_source,
3990 spec.name,
3991 );
3992 if let Some(id) = id {
3993 tasks.insert(id, task);
3994 crate::effect_system::record_queue_enqueue(tasks.len() as u64);
3995 } else {
3996 let stats = scheduler.stats();
3997 emit_task_executor_backpressure_evidence(
3998 evidence_sink,
3999 "queued",
4000 "inline_fallback",
4001 stats.queue_length,
4002 scheduler.max_queue_size(),
4003 stats.total_rejected,
4004 );
4005 let _ =
4006 run_task_closure(task, "queued-inline-fallback", evidence_sink, result_sender);
4007 }
4008 EffectLoopControl::Continue
4009 }
4010 EffectCommand::Shutdown => EffectLoopControl::ShutdownRequested,
4011 }
4012}
4013
4014#[derive(Debug, Clone)]
4022pub struct InlineAutoRemeasureConfig {
4023 pub voi: VoiConfig,
4025 pub change_threshold_rows: u16,
4027}
4028
4029impl Default for InlineAutoRemeasureConfig {
4030 fn default() -> Self {
4031 Self {
4032 voi: VoiConfig {
4033 prior_alpha: 1.0,
4035 prior_beta: 9.0,
4036 max_interval_ms: 1000,
4038 min_interval_ms: 100,
4040 max_interval_events: 0,
4042 min_interval_events: 0,
4043 sample_cost: 0.08,
4045 ..VoiConfig::default()
4046 },
4047 change_threshold_rows: 1,
4048 }
4049 }
4050}
4051
4052#[derive(Debug)]
4053struct InlineAutoRemeasureState {
4054 config: InlineAutoRemeasureConfig,
4055 sampler: VoiSampler,
4056}
4057
4058impl InlineAutoRemeasureState {
4059 fn new(config: InlineAutoRemeasureConfig) -> Self {
4060 let sampler = VoiSampler::new(config.voi.clone());
4061 Self { config, sampler }
4062 }
4063
4064 fn reset(&mut self) {
4065 self.sampler = VoiSampler::new(self.config.voi.clone());
4066 }
4067}
4068
4069#[derive(Debug, Clone)]
4070struct ConformalEvidence {
4071 bucket_key: String,
4072 n_b: usize,
4073 alpha: f64,
4074 q_b: f64,
4075 y_hat: f64,
4076 upper_us: f64,
4077 risk: bool,
4078 fallback_level: u8,
4079 window_size: usize,
4080 reset_count: u64,
4081}
4082
4083impl ConformalEvidence {
4084 fn from_prediction(prediction: &ConformalPrediction) -> Self {
4085 let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
4086 Self {
4087 bucket_key: prediction.bucket.to_string(),
4088 n_b: prediction.sample_count,
4089 alpha,
4090 q_b: prediction.quantile,
4091 y_hat: prediction.y_hat,
4092 upper_us: prediction.upper_us,
4093 risk: prediction.risk,
4094 fallback_level: prediction.fallback_level,
4095 window_size: prediction.window_size,
4096 reset_count: prediction.reset_count,
4097 }
4098 }
4099}
4100
4101#[derive(Debug, Clone)]
4102struct BudgetDecisionEvidence {
4103 frame_idx: u64,
4104 decision: BudgetDecision,
4105 controller_decision: BudgetDecision,
4106 degradation_before: DegradationLevel,
4107 degradation_after: DegradationLevel,
4108 frame_time_us: f64,
4109 budget_us: f64,
4110 pid_output: f64,
4111 pid_p: f64,
4112 pid_i: f64,
4113 pid_d: f64,
4114 e_value: f64,
4115 frames_observed: u32,
4116 frames_since_change: u32,
4117 in_warmup: bool,
4118 controller_reason: BudgetDecisionReason,
4119 load_governor: LoadGovernorSnapshot,
4120 conformal: Option<ConformalEvidence>,
4121}
4122
4123impl BudgetDecisionEvidence {
4124 fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
4125 if after > before {
4126 BudgetDecision::Degrade
4127 } else if after < before {
4128 BudgetDecision::Upgrade
4129 } else {
4130 BudgetDecision::Hold
4131 }
4132 }
4133
4134 #[must_use]
4135 fn to_jsonl(&self) -> String {
4136 let conformal = self.conformal.as_ref();
4137 let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
4138 let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
4139 let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
4140 let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
4141 let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
4142 let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
4143 let risk = Self::opt_bool(conformal.map(|c| c.risk));
4144 let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
4145 let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
4146 let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
4147 let queue_max_depth = Self::opt_usize(self.load_governor.queue_max_depth);
4148
4149 format!(
4150 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":{}}}"#,
4151 self.frame_idx,
4152 self.decision.as_str(),
4153 self.controller_decision.as_str(),
4154 self.controller_reason.as_str(),
4155 self.degradation_before.as_str(),
4156 self.degradation_after.as_str(),
4157 self.frame_time_us,
4158 self.budget_us,
4159 self.pid_output,
4160 self.pid_p,
4161 self.pid_i,
4162 self.pid_d,
4163 self.e_value,
4164 self.frames_observed,
4165 self.frames_since_change,
4166 self.in_warmup,
4167 self.load_governor.mode.as_str(),
4168 self.load_governor.mode_before.as_str(),
4169 self.load_governor.pressure_class.as_str(),
4170 self.load_governor.disposition.as_str(),
4171 self.load_governor.reason_code,
4172 self.load_governor.transition,
4173 self.load_governor.strict_semantics_preserved,
4174 self.load_governor.queue_in_flight,
4175 queue_max_depth,
4176 self.load_governor.queue_dropped_delta,
4177 self.load_governor.resize_coalescing_active,
4178 self.load_governor.recovery_intervals_observed,
4179 self.load_governor.recovery_intervals_required,
4180 self.load_governor.deferred_work_total,
4181 self.load_governor.coalesced_work_total,
4182 self.load_governor.dropped_work_total,
4183 bucket_key,
4184 n_b,
4185 alpha,
4186 q_b,
4187 y_hat,
4188 upper_us,
4189 risk,
4190 fallback_level,
4191 window_size,
4192 reset_count
4193 )
4194 }
4195
4196 fn opt_f64(value: Option<f64>) -> String {
4197 value
4198 .map(|v| format!("{v:.6}"))
4199 .unwrap_or_else(|| "null".to_string())
4200 }
4201
4202 fn opt_u64(value: Option<u64>) -> String {
4203 value
4204 .map(|v| v.to_string())
4205 .unwrap_or_else(|| "null".to_string())
4206 }
4207
4208 fn opt_u8(value: Option<u8>) -> String {
4209 value
4210 .map(|v| v.to_string())
4211 .unwrap_or_else(|| "null".to_string())
4212 }
4213
4214 fn opt_usize(value: Option<usize>) -> String {
4215 value
4216 .map(|v| v.to_string())
4217 .unwrap_or_else(|| "null".to_string())
4218 }
4219
4220 fn opt_bool(value: Option<bool>) -> String {
4221 value
4222 .map(|v| v.to_string())
4223 .unwrap_or_else(|| "null".to_string())
4224 }
4225
4226 fn opt_str(value: Option<&str>) -> String {
4227 value
4228 .map(|v| {
4229 format!(
4230 "\"{}\"",
4231 v.replace('\\', "\\\\")
4232 .replace('"', "\\\"")
4233 .replace('\n', "\\n")
4234 .replace('\r', "\\r")
4235 .replace('\t', "\\t")
4236 )
4237 })
4238 .unwrap_or_else(|| "null".to_string())
4239 }
4240}
4241
4242#[derive(Debug, Clone)]
4243struct FairnessConfigEvidence {
4244 enabled: bool,
4245 input_priority_threshold_ms: u64,
4246 dominance_threshold: u32,
4247 fairness_threshold: f64,
4248}
4249
4250impl FairnessConfigEvidence {
4251 #[must_use]
4252 fn to_jsonl(&self) -> String {
4253 format!(
4254 r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
4255 self.enabled,
4256 self.input_priority_threshold_ms,
4257 self.dominance_threshold,
4258 self.fairness_threshold
4259 )
4260 }
4261}
4262
4263#[derive(Debug, Clone)]
4264struct FairnessDecisionEvidence {
4265 frame_idx: u64,
4266 decision: &'static str,
4267 reason: &'static str,
4268 pending_input_latency_ms: Option<u64>,
4269 jain_index: f64,
4270 resize_dominance_count: u32,
4271 dominance_threshold: u32,
4272 fairness_threshold: f64,
4273 input_priority_threshold_ms: u64,
4274}
4275
4276impl FairnessDecisionEvidence {
4277 #[must_use]
4278 fn to_jsonl(&self) -> String {
4279 let pending_latency = self
4280 .pending_input_latency_ms
4281 .map(|v| v.to_string())
4282 .unwrap_or_else(|| "null".to_string());
4283 format!(
4284 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":{}}}"#,
4285 self.frame_idx,
4286 self.decision,
4287 self.reason,
4288 pending_latency,
4289 self.jain_index,
4290 self.resize_dominance_count,
4291 self.dominance_threshold,
4292 self.fairness_threshold,
4293 self.input_priority_threshold_ms
4294 )
4295 }
4296}
4297
4298#[derive(Debug, Clone)]
4299struct WidgetRefreshEntry {
4300 widget_id: u64,
4301 essential: bool,
4302 starved: bool,
4303 value: f32,
4304 cost_us: f32,
4305 score: f32,
4306 staleness_ms: u64,
4307}
4308
4309impl WidgetRefreshEntry {
4310 fn to_json(&self) -> String {
4311 format!(
4312 r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
4313 self.widget_id,
4314 self.cost_us,
4315 self.value,
4316 self.score,
4317 self.essential,
4318 self.starved,
4319 self.staleness_ms
4320 )
4321 }
4322}
4323
4324#[derive(Debug, Clone)]
4325struct WidgetRefreshPlan {
4326 frame_idx: u64,
4327 budget_us: f64,
4328 degradation: DegradationLevel,
4329 essentials_cost_us: f64,
4330 selected_cost_us: f64,
4331 selected_value: f64,
4332 signal_count: usize,
4333 selected: Vec<WidgetRefreshEntry>,
4334 skipped_count: usize,
4335 skipped_starved: usize,
4336 starved_selected: usize,
4337 over_budget: bool,
4338}
4339
4340impl WidgetRefreshPlan {
4341 fn new() -> Self {
4342 Self {
4343 frame_idx: 0,
4344 budget_us: 0.0,
4345 degradation: DegradationLevel::Full,
4346 essentials_cost_us: 0.0,
4347 selected_cost_us: 0.0,
4348 selected_value: 0.0,
4349 signal_count: 0,
4350 selected: Vec::new(),
4351 skipped_count: 0,
4352 skipped_starved: 0,
4353 starved_selected: 0,
4354 over_budget: false,
4355 }
4356 }
4357
4358 fn clear(&mut self) {
4359 self.frame_idx = 0;
4360 self.budget_us = 0.0;
4361 self.degradation = DegradationLevel::Full;
4362 self.essentials_cost_us = 0.0;
4363 self.selected_cost_us = 0.0;
4364 self.selected_value = 0.0;
4365 self.signal_count = 0;
4366 self.selected.clear();
4367 self.skipped_count = 0;
4368 self.skipped_starved = 0;
4369 self.starved_selected = 0;
4370 self.over_budget = false;
4371 }
4372
4373 fn as_budget(&self) -> WidgetBudget {
4374 if self.signal_count == 0 {
4375 return WidgetBudget::allow_all();
4376 }
4377 let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
4378 WidgetBudget::allow_only(ids)
4379 }
4380
4381 fn recompute(
4382 &mut self,
4383 frame_idx: u64,
4384 budget_us: f64,
4385 degradation: DegradationLevel,
4386 signals: &[WidgetSignal],
4387 config: &WidgetRefreshConfig,
4388 ) {
4389 self.clear();
4390 self.frame_idx = frame_idx;
4391 self.budget_us = budget_us;
4392 self.degradation = degradation;
4393
4394 if !config.enabled || signals.is_empty() {
4395 return;
4396 }
4397
4398 self.signal_count = signals.len();
4399 let mut essentials_cost = 0.0f64;
4400 let mut selected_cost = 0.0f64;
4401 let mut selected_value = 0.0f64;
4402
4403 let staleness_window = config.staleness_window_ms.max(1) as f32;
4404 let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
4405
4406 for signal in signals {
4407 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
4408 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
4409 let mut value = config.weight_priority * signal.priority
4410 + config.weight_staleness * staleness_score
4411 + config.weight_focus * signal.focus_boost
4412 + config.weight_interaction * signal.interaction_boost;
4413 if starved {
4414 value += config.starve_boost;
4415 }
4416 let raw_cost = if signal.recent_cost_us > 0.0 {
4417 signal.recent_cost_us
4418 } else {
4419 signal.cost_estimate_us
4420 };
4421 let cost_us = raw_cost.max(config.min_cost_us);
4422 let score = if cost_us > 0.0 {
4423 value / cost_us
4424 } else {
4425 value
4426 };
4427
4428 let entry = WidgetRefreshEntry {
4429 widget_id: signal.widget_id,
4430 essential: signal.essential,
4431 starved,
4432 value,
4433 cost_us,
4434 score,
4435 staleness_ms: signal.staleness_ms,
4436 };
4437
4438 if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
4439 self.skipped_count += 1;
4440 if starved {
4441 self.skipped_starved = self.skipped_starved.saturating_add(1);
4442 }
4443 continue;
4444 }
4445
4446 if signal.essential {
4447 essentials_cost += cost_us as f64;
4448 selected_cost += cost_us as f64;
4449 selected_value += value as f64;
4450 if starved {
4451 self.starved_selected = self.starved_selected.saturating_add(1);
4452 }
4453 self.selected.push(entry);
4454 } else {
4455 candidates.push(entry);
4456 }
4457 }
4458
4459 let mut remaining = budget_us - selected_cost;
4460
4461 if degradation < DegradationLevel::EssentialOnly {
4462 let nonessential_total = candidates.len();
4463 let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
4464 let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
4465 let min_nonessential_selected = if enforce_drop_rate {
4466 let min_fraction = (1.0 - max_drop_fraction).max(0.0);
4467 ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
4468 } else {
4469 0
4470 };
4471
4472 candidates.sort_by(|a, b| {
4473 b.starved
4474 .cmp(&a.starved)
4475 .then_with(|| b.score.total_cmp(&a.score))
4476 .then_with(|| b.value.total_cmp(&a.value))
4477 .then_with(|| a.cost_us.total_cmp(&b.cost_us))
4478 .then_with(|| a.widget_id.cmp(&b.widget_id))
4479 });
4480
4481 let mut forced_starved = 0usize;
4482 let mut nonessential_selected = 0usize;
4483 let mut skipped_candidates = if enforce_drop_rate {
4484 Vec::with_capacity(candidates.len())
4485 } else {
4486 Vec::new()
4487 };
4488
4489 for entry in candidates.into_iter() {
4490 if entry.starved && forced_starved >= config.max_starved_per_frame {
4491 self.skipped_count += 1;
4492 self.skipped_starved = self.skipped_starved.saturating_add(1);
4493 if enforce_drop_rate {
4494 skipped_candidates.push(entry);
4495 }
4496 continue;
4497 }
4498
4499 if remaining >= entry.cost_us as f64 {
4500 remaining -= entry.cost_us as f64;
4501 selected_cost += entry.cost_us as f64;
4502 selected_value += entry.value as f64;
4503 if entry.starved {
4504 self.starved_selected = self.starved_selected.saturating_add(1);
4505 forced_starved += 1;
4506 }
4507 nonessential_selected += 1;
4508 self.selected.push(entry);
4509 } else if entry.starved
4510 && forced_starved < config.max_starved_per_frame
4511 && nonessential_selected == 0
4512 {
4513 selected_cost += entry.cost_us as f64;
4515 selected_value += entry.value as f64;
4516 self.starved_selected = self.starved_selected.saturating_add(1);
4517 forced_starved += 1;
4518 nonessential_selected += 1;
4519 self.selected.push(entry);
4520 } else {
4521 self.skipped_count += 1;
4522 if entry.starved {
4523 self.skipped_starved = self.skipped_starved.saturating_add(1);
4524 }
4525 if enforce_drop_rate {
4526 skipped_candidates.push(entry);
4527 }
4528 }
4529 }
4530
4531 if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
4532 for entry in skipped_candidates.into_iter() {
4533 if nonessential_selected >= min_nonessential_selected {
4534 break;
4535 }
4536 if entry.starved && forced_starved >= config.max_starved_per_frame {
4537 continue;
4538 }
4539 selected_cost += entry.cost_us as f64;
4540 selected_value += entry.value as f64;
4541 if entry.starved {
4542 self.starved_selected = self.starved_selected.saturating_add(1);
4543 forced_starved += 1;
4544 self.skipped_starved = self.skipped_starved.saturating_sub(1);
4545 }
4546 self.skipped_count = self.skipped_count.saturating_sub(1);
4547 nonessential_selected += 1;
4548 self.selected.push(entry);
4549 }
4550 }
4551 }
4552
4553 self.essentials_cost_us = essentials_cost;
4554 self.selected_cost_us = selected_cost;
4555 self.selected_value = selected_value;
4556 self.over_budget = selected_cost > budget_us;
4557 }
4558
4559 #[must_use]
4560 fn to_jsonl(&self) -> String {
4561 let mut out = String::with_capacity(256 + self.selected.len() * 96);
4562 out.push_str(r#"{"event":"widget_refresh""#);
4563 out.push_str(&format!(
4564 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":{}"#,
4565 self.frame_idx,
4566 self.budget_us,
4567 self.degradation.as_str(),
4568 self.essentials_cost_us,
4569 self.selected_cost_us,
4570 self.selected_value,
4571 self.selected.len(),
4572 self.skipped_count,
4573 self.starved_selected,
4574 self.skipped_starved,
4575 self.over_budget
4576 ));
4577 out.push_str(r#","selected":["#);
4578 for (i, entry) in self.selected.iter().enumerate() {
4579 if i > 0 {
4580 out.push(',');
4581 }
4582 out.push_str(&entry.to_json());
4583 }
4584 out.push_str("]}");
4585 out
4586 }
4587}
4588
4589#[cfg(feature = "crossterm-compat")]
4594pub struct CrosstermEventSource {
4600 session: TerminalSession,
4601 features: BackendFeatures,
4602}
4603
4604#[cfg(feature = "crossterm-compat")]
4605impl CrosstermEventSource {
4606 pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
4608 Self {
4609 session,
4610 features: initial_features,
4611 }
4612 }
4613}
4614
4615#[cfg(feature = "crossterm-compat")]
4616impl BackendEventSource for CrosstermEventSource {
4617 type Error = io::Error;
4618
4619 fn size(&self) -> Result<(u16, u16), io::Error> {
4620 self.session.size()
4621 }
4622
4623 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4624 if features.mouse_capture != self.features.mouse_capture {
4625 self.session.set_mouse_capture(features.mouse_capture)?;
4626 }
4627 self.features = features;
4631 Ok(())
4632 }
4633
4634 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
4635 self.session.poll_event(timeout)
4636 }
4637
4638 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4639 self.session.read_event()
4640 }
4641}
4642
4643pub struct HeadlessEventSource {
4653 width: u16,
4654 height: u16,
4655 features: BackendFeatures,
4656}
4657
4658impl HeadlessEventSource {
4659 pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
4661 Self {
4662 width,
4663 height,
4664 features,
4665 }
4666 }
4667}
4668
4669impl BackendEventSource for HeadlessEventSource {
4670 type Error = io::Error;
4671
4672 fn size(&self) -> Result<(u16, u16), io::Error> {
4673 Ok((self.width, self.height))
4674 }
4675
4676 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
4677 self.features = features;
4678 Ok(())
4679 }
4680
4681 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
4682 Ok(false)
4683 }
4684
4685 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
4686 Ok(None)
4687 }
4688}
4689
4690pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
4696 model: M,
4698 writer: TerminalWriter<W>,
4700 events: E,
4702 backend_features: BackendFeatures,
4704 running: bool,
4706 shutdown_complete: bool,
4708 tick_rate: Option<Duration>,
4710 executed_cmd_count: usize,
4712 last_tick: Instant,
4714 dirty: bool,
4716 frame_idx: u64,
4718 tick_count: u64,
4720 widget_signals: Vec<WidgetSignal>,
4722 widget_refresh_config: WidgetRefreshConfig,
4724 widget_refresh_plan: WidgetRefreshPlan,
4726 width: u16,
4728 height: u16,
4730 forced_size: Option<(u16, u16)>,
4732 poll_timeout: Duration,
4734 intercept_signals: bool,
4736 immediate_drain_config: ImmediateDrainConfig,
4738 immediate_drain_stats: ImmediateDrainStats,
4740 budget: RenderBudget,
4742 load_governor: LoadGovernorState,
4744 conformal_predictor: Option<ConformalPredictor>,
4746 last_frame_time_us: Option<f64>,
4748 last_update_us: Option<u64>,
4750 frame_timing: Option<FrameTimingConfig>,
4752 locale_context: LocaleContext,
4754 locale_version: u64,
4756 resize_coalescer: ResizeCoalescer,
4758 evidence_sink: Option<EvidenceSink>,
4760 fairness_config_logged: bool,
4762 resize_behavior: ResizeBehavior,
4764 fairness_guard: InputFairnessGuard,
4766 event_recorder: Option<EventRecorder>,
4768 subscriptions: SubscriptionManager<M::Message>,
4770 #[cfg(test)]
4772 task_sender: std::sync::mpsc::Sender<M::Message>,
4773 task_receiver: std::sync::mpsc::Receiver<M::Message>,
4775 task_executor: TaskExecutor<M::Message>,
4777 state_registry: Option<std::sync::Arc<StateRegistry>>,
4779 persistence_config: PersistenceConfig,
4781 last_checkpoint: Instant,
4783 inline_auto_remeasure: Option<InlineAutoRemeasureState>,
4785 frame_arena: FrameArena,
4787 guardrails: FrameGuardrails,
4789 last_soft_trim_frame: Option<u64>,
4797 tick_strategy: Option<Box<dyn crate::tick_strategy::TickStrategy>>,
4799 last_active_screen_for_strategy: Option<String>,
4801}
4802
4803#[cfg(feature = "crossterm-compat")]
4804impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
4805 pub fn new(model: M) -> io::Result<Self>
4807 where
4808 M::Message: Send + 'static,
4809 {
4810 Self::with_config(model, ProgramConfig::default())
4811 }
4812
4813 pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
4815 where
4816 M::Message: Send + 'static,
4817 {
4818 let resolved_lane = config.runtime_lane.resolve();
4819 let effect_queue_config = config.resolved_effect_queue_config();
4820 let capabilities = TerminalCapabilities::with_overrides();
4821 let mouse_capture = config.resolved_mouse_capture();
4822 let requested_features = BackendFeatures {
4823 mouse_capture,
4824 bracketed_paste: config.bracketed_paste,
4825 focus_events: config.focus_reporting,
4826 kitty_keyboard: config.kitty_keyboard,
4827 };
4828 let initial_features =
4829 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
4830 let session = TerminalSession::new(SessionOptions {
4831 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4832 mouse_capture: initial_features.mouse_capture,
4833 bracketed_paste: initial_features.bracketed_paste,
4834 focus_events: initial_features.focus_events,
4835 kitty_keyboard: initial_features.kitty_keyboard,
4836 intercept_signals: config.intercept_signals,
4837 })?;
4838 let events = CrosstermEventSource::new(session, initial_features);
4839
4840 let mut writer = TerminalWriter::with_diff_config(
4841 io::stdout(),
4842 config.screen_mode,
4843 config.ui_anchor,
4844 capabilities,
4845 config.diff_config.clone(),
4846 );
4847
4848 let frame_timing = config.frame_timing.clone();
4849 writer.set_timing_enabled(frame_timing.is_some());
4850
4851 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
4852 if let Some(ref sink) = evidence_sink {
4853 writer = writer.with_evidence_sink(sink.clone());
4854 }
4855
4856 let render_trace = crate::RenderTraceRecorder::from_config(
4857 &config.render_trace,
4858 crate::RenderTraceContext {
4859 capabilities: writer.capabilities(),
4860 diff_config: config.diff_config.clone(),
4861 resize_config: config.resize_coalescer.clone(),
4862 conformal_config: config.conformal_config.clone(),
4863 },
4864 )?;
4865 if let Some(recorder) = render_trace {
4866 writer = writer.with_render_trace(recorder);
4867 }
4868
4869 let (w, h) = config
4871 .forced_size
4872 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4873 let width = w.max(1);
4874 let height = h.max(1);
4875 writer.set_size(width, height);
4876
4877 let budget = render_budget_from_program_config(&config);
4878 let load_governor = LoadGovernorState::new(
4879 config.load_governor.clone(),
4880 effect_queue_config.max_queue_depth,
4881 );
4882 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4883 let locale_context = config.locale_context.clone();
4884 let locale_version = locale_context.version();
4885 let mut resize_coalescer =
4886 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
4887 .with_screen_mode(config.screen_mode);
4888 if let Some(ref sink) = evidence_sink {
4889 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
4890 }
4891 let subscriptions = SubscriptionManager::new();
4892 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4893 let inline_auto_remeasure = config
4894 .inline_auto_remeasure
4895 .clone()
4896 .map(InlineAutoRemeasureState::new);
4897 let task_executor = TaskExecutor::new(
4898 &effect_queue_config,
4899 task_sender.clone(),
4900 evidence_sink.clone(),
4901 )?;
4902 let guardrails = FrameGuardrails::new(config.guardrails);
4903
4904 tracing::info!(
4906 target: "ftui.runtime",
4907 requested_lane = config.runtime_lane.label(),
4908 resolved_lane = resolved_lane.label(),
4909 rollout_policy = config.rollout_policy.label(),
4910 "runtime startup: lane={}, rollout={}",
4911 resolved_lane.label(),
4912 config.rollout_policy.label(),
4913 );
4914
4915 Ok(Self {
4916 model,
4917 writer,
4918 events,
4919 backend_features: initial_features,
4920 running: true,
4921 shutdown_complete: false,
4922 tick_rate: None,
4923 executed_cmd_count: 0,
4924 last_tick: Instant::now(),
4925 dirty: true,
4926 frame_idx: 0,
4927 tick_count: 0,
4928 widget_signals: Vec::new(),
4929 widget_refresh_config: config.widget_refresh,
4930 widget_refresh_plan: WidgetRefreshPlan::new(),
4931 width,
4932 height,
4933 forced_size: config.forced_size,
4934 poll_timeout: config.poll_timeout,
4935 intercept_signals: config.intercept_signals,
4936 immediate_drain_config: config.immediate_drain,
4937 immediate_drain_stats: ImmediateDrainStats::default(),
4938 budget,
4939 load_governor,
4940 conformal_predictor,
4941 last_frame_time_us: None,
4942 last_update_us: None,
4943 frame_timing,
4944 locale_context,
4945 locale_version,
4946 resize_coalescer,
4947 evidence_sink,
4948 fairness_config_logged: false,
4949 resize_behavior: config.resize_behavior,
4950 fairness_guard: InputFairnessGuard::new(),
4951 event_recorder: None,
4952 subscriptions,
4953 #[cfg(test)]
4954 task_sender,
4955 task_receiver,
4956 task_executor,
4957 state_registry: config.persistence.registry.clone(),
4958 persistence_config: config.persistence,
4959 last_checkpoint: Instant::now(),
4960 inline_auto_remeasure,
4961 frame_arena: FrameArena::default(),
4962 guardrails,
4963 last_soft_trim_frame: None,
4964 tick_strategy: config
4965 .tick_strategy
4966 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
4967 last_active_screen_for_strategy: None,
4968 })
4969 }
4970}
4971
4972impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
4973 pub fn with_event_source(
4980 model: M,
4981 events: E,
4982 backend_features: BackendFeatures,
4983 writer: TerminalWriter<W>,
4984 config: ProgramConfig,
4985 ) -> io::Result<Self>
4986 where
4987 M::Message: Send + 'static,
4988 {
4989 let effect_queue_config = config.resolved_effect_queue_config();
4990 let (width, height) = config
4991 .forced_size
4992 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
4993 let width = width.max(1);
4994 let height = height.max(1);
4995
4996 let mut writer = writer;
4997 writer.set_size(width, height);
4998
4999 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
5000 if let Some(ref sink) = evidence_sink {
5001 writer = writer.with_evidence_sink(sink.clone());
5002 }
5003
5004 let render_trace = crate::RenderTraceRecorder::from_config(
5005 &config.render_trace,
5006 crate::RenderTraceContext {
5007 capabilities: writer.capabilities(),
5008 diff_config: config.diff_config.clone(),
5009 resize_config: config.resize_coalescer.clone(),
5010 conformal_config: config.conformal_config.clone(),
5011 },
5012 )?;
5013 if let Some(recorder) = render_trace {
5014 writer = writer.with_render_trace(recorder);
5015 }
5016
5017 let frame_timing = config.frame_timing.clone();
5018 writer.set_timing_enabled(frame_timing.is_some());
5019
5020 let budget = render_budget_from_program_config(&config);
5021 let load_governor = LoadGovernorState::new(
5022 config.load_governor.clone(),
5023 effect_queue_config.max_queue_depth,
5024 );
5025 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
5026 let locale_context = config.locale_context.clone();
5027 let locale_version = locale_context.version();
5028 let mut resize_coalescer =
5029 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
5030 .with_screen_mode(config.screen_mode);
5031 if let Some(ref sink) = evidence_sink {
5032 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
5033 }
5034 let subscriptions = SubscriptionManager::new();
5035 let (task_sender, task_receiver) = std::sync::mpsc::channel();
5036 let inline_auto_remeasure = config
5037 .inline_auto_remeasure
5038 .clone()
5039 .map(InlineAutoRemeasureState::new);
5040 let task_executor = TaskExecutor::new(
5041 &effect_queue_config,
5042 task_sender.clone(),
5043 evidence_sink.clone(),
5044 )?;
5045
5046 let guardrails = FrameGuardrails::new(config.guardrails);
5047
5048 Ok(Self {
5049 model,
5050 writer,
5051 events,
5052 backend_features,
5053 running: true,
5054 shutdown_complete: false,
5055 tick_rate: None,
5056 executed_cmd_count: 0,
5057 last_tick: Instant::now(),
5058 dirty: true,
5059 frame_idx: 0,
5060 tick_count: 0,
5061 widget_signals: Vec::new(),
5062 widget_refresh_config: config.widget_refresh,
5063 widget_refresh_plan: WidgetRefreshPlan::new(),
5064 width,
5065 height,
5066 forced_size: config.forced_size,
5067 poll_timeout: config.poll_timeout,
5068 intercept_signals: config.intercept_signals,
5069 immediate_drain_config: config.immediate_drain,
5070 immediate_drain_stats: ImmediateDrainStats::default(),
5071 budget,
5072 load_governor,
5073 conformal_predictor,
5074 last_frame_time_us: None,
5075 last_update_us: None,
5076 frame_timing,
5077 locale_context,
5078 locale_version,
5079 resize_coalescer,
5080 evidence_sink,
5081 fairness_config_logged: false,
5082 resize_behavior: config.resize_behavior,
5083 fairness_guard: InputFairnessGuard::new(),
5084 event_recorder: None,
5085 subscriptions,
5086 #[cfg(test)]
5087 task_sender,
5088 task_receiver,
5089 task_executor,
5090 state_registry: config.persistence.registry.clone(),
5091 persistence_config: config.persistence,
5092 last_checkpoint: Instant::now(),
5093 inline_auto_remeasure,
5094 frame_arena: FrameArena::default(),
5095 guardrails,
5096 last_soft_trim_frame: None,
5097 tick_strategy: config
5098 .tick_strategy
5099 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
5100 last_active_screen_for_strategy: None,
5101 })
5102 }
5103}
5104
5105#[cfg(any(feature = "crossterm-compat", feature = "native-backend"))]
5110#[inline]
5111const fn sanitize_backend_features_for_capabilities(
5112 requested: BackendFeatures,
5113 capabilities: &ftui_core::terminal_capabilities::TerminalCapabilities,
5114) -> BackendFeatures {
5115 let focus_events_supported = capabilities.focus_events && !capabilities.in_any_mux();
5116 let kitty_keyboard_supported = capabilities.kitty_keyboard && !capabilities.in_any_mux();
5117
5118 BackendFeatures {
5119 mouse_capture: requested.mouse_capture && capabilities.mouse_sgr,
5120 bracketed_paste: requested.bracketed_paste && capabilities.bracketed_paste,
5121 focus_events: requested.focus_events && focus_events_supported,
5122 kitty_keyboard: requested.kitty_keyboard && kitty_keyboard_supported,
5123 }
5124}
5125
5126#[cfg(feature = "native-backend")]
5127impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
5128 #[cfg(unix)]
5140 pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
5141 where
5142 M::Message: Send + 'static,
5143 {
5144 let mut capabilities =
5145 ftui_core::terminal_capabilities::TerminalCapabilities::with_overrides();
5146 let mouse_capture = config.resolved_mouse_capture();
5147 let requested_features = BackendFeatures {
5148 mouse_capture,
5149 bracketed_paste: config.bracketed_paste,
5150 focus_events: config.focus_reporting,
5151 kitty_keyboard: config.kitty_keyboard,
5152 };
5153 let features =
5154 sanitize_backend_features_for_capabilities(requested_features, &capabilities);
5155 let options = ftui_tty::TtySessionOptions {
5156 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
5157 features,
5158 intercept_signals: config.intercept_signals,
5159 };
5160 let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
5161
5162 if capabilities.color_depth == ftui_core::terminal_capabilities::ColorDepth::Ansi256 {
5176 let probe =
5177 ftui_core::caps_probe::probe_capabilities(&ftui_core::caps_probe::ProbeConfig {
5178 timeout: std::time::Duration::from_millis(300),
5179 probe_da1: false,
5180 probe_da2: false,
5181 probe_background: false,
5182 probe_truecolor: true,
5183 });
5184 capabilities.refine_from_probe(&probe);
5185 }
5186
5187 let writer = TerminalWriter::with_diff_config(
5188 io::stdout(),
5189 config.screen_mode,
5190 config.ui_anchor,
5191 capabilities,
5192 config.diff_config.clone(),
5193 );
5194
5195 Self::with_event_source(model, backend, features, writer, config)
5196 }
5197}
5198
5199impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
5200 pub fn run(&mut self) -> io::Result<()> {
5208 if self.shutdown_complete {
5209 return Err(io::Error::new(
5210 io::ErrorKind::InvalidInput,
5211 "program lifecycle has already completed",
5212 ));
5213 }
5214
5215 let run_result = self.run_event_loop();
5216 self.complete_lifecycle(run_result)
5217 }
5218
5219 #[inline]
5220 fn observed_termination_signal(&self) -> Option<i32> {
5221 if self.intercept_signals {
5222 check_termination_signal()
5223 } else {
5224 None
5225 }
5226 }
5227
5228 #[inline]
5230 pub fn last_widget_signals(&self) -> &[WidgetSignal] {
5231 &self.widget_signals
5232 }
5233
5234 #[inline]
5236 pub fn immediate_drain_stats(&self) -> ImmediateDrainStats {
5237 self.immediate_drain_stats
5238 }
5239
5240 fn run_event_loop(&mut self) -> io::Result<Option<i32>> {
5242 if self.persistence_config.auto_load {
5244 self.load_state();
5245 }
5246
5247 let cmd = {
5249 let _span = info_span!("ftui.program.init").entered();
5250 self.model.init()
5251 };
5252 self.execute_cmd(cmd)?;
5253
5254 let mut termination_signal = self.observed_termination_signal();
5255 if self.running && termination_signal.is_none() {
5256 self.reconcile_subscriptions();
5258 self.process_subscription_failures(false)?;
5259
5260 if self.running {
5262 self.render_frame()?;
5263 }
5264 }
5265
5266 let mut loop_count: u64 = 0;
5268 while self.running {
5269 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5270 if termination_signal.is_some() {
5271 self.running = false;
5272 break;
5273 }
5274
5275 loop_count += 1;
5276 if loop_count.is_multiple_of(100) {
5278 crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
5279 }
5280
5281 let timeout = self.effective_timeout();
5283
5284 let poll_result = self.events.poll_event(timeout)?;
5286 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5287 if termination_signal.is_some() {
5288 self.running = false;
5289 break;
5290 }
5291 if poll_result {
5292 self.drain_ready_events()?;
5293 }
5294 if !self.running {
5295 break;
5296 }
5297 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5298 if termination_signal.is_some() {
5299 self.running = false;
5300 break;
5301 }
5302
5303 self.process_subscription_messages()?;
5305 self.process_subscription_failures(false)?;
5306 if !self.running {
5307 break;
5308 }
5309
5310 self.process_task_results()?;
5312 self.reap_finished_tasks();
5313 if !self.running {
5314 break;
5315 }
5316
5317 self.process_resize_coalescer()?;
5318 if !self.running {
5319 break;
5320 }
5321 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5322 if termination_signal.is_some() {
5323 self.running = false;
5324 break;
5325 }
5326
5327 self.check_screen_transition();
5331
5332 if self.should_tick() {
5334 self.tick_count = self.tick_count.wrapping_add(1);
5335 let tick_count = self.tick_count;
5336
5337 let mut used_screen_dispatch = false;
5338
5339 if let Some(strategy) = self.tick_strategy.as_mut() {
5344 let dispatch_snapshot = self.model.as_screen_tick_dispatch().map(|dispatch| {
5347 let active = dispatch.active_screen_id();
5348 let all_screens = dispatch.screen_ids();
5349 (active, all_screens)
5350 });
5351
5352 if let Some((active, all_screens)) = dispatch_snapshot {
5353 used_screen_dispatch = true;
5354
5355 if let Some(previous_active) =
5358 self.last_active_screen_for_strategy.as_deref()
5359 && previous_active != active
5360 {
5361 strategy.on_screen_transition(previous_active, &active);
5362 }
5363 self.last_active_screen_for_strategy = Some(active.clone());
5364
5365 let all_screens_count = all_screens.len();
5366 let mut tick_targets = Vec::with_capacity(all_screens_count.max(1));
5367 tick_targets.push(active.clone());
5369
5370 for screen_id in all_screens {
5372 if screen_id != active
5373 && strategy.should_tick(&screen_id, tick_count, &active)
5374 == crate::tick_strategy::TickDecision::Tick
5375 {
5376 tick_targets.push(screen_id);
5377 }
5378 }
5379
5380 let skipped_count = all_screens_count.saturating_sub(tick_targets.len());
5382
5383 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
5384 for screen_id in &tick_targets {
5385 dispatch.tick_screen(screen_id, tick_count);
5386 }
5387 }
5388
5389 trace!(
5390 tick = tick_count,
5391 active = %active,
5392 ticked = tick_targets.len(),
5393 skipped = skipped_count,
5394 "tick_strategy.frame"
5395 );
5396
5397 strategy.maintenance_tick(tick_count);
5399 self.mark_dirty();
5400 }
5401 }
5402
5403 if used_screen_dispatch && self.running {
5404 self.reconcile_subscriptions();
5405 }
5406
5407 if !used_screen_dispatch {
5408 self.last_active_screen_for_strategy = None;
5411 let msg = M::Message::from(Event::Tick);
5412 let cmd = {
5413 let _span = debug_span!(
5414 "ftui.program.update",
5415 msg_type = "Tick",
5416 duration_us = tracing::field::Empty,
5417 cmd_type = tracing::field::Empty
5418 )
5419 .entered();
5420 let start = Instant::now();
5421 let cmd = self.model.update(msg);
5422 tracing::Span::current()
5423 .record("duration_us", start.elapsed().as_micros() as u64);
5424 tracing::Span::current()
5425 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5426 cmd
5427 };
5428 self.mark_dirty();
5429 self.execute_cmd(cmd)?;
5430 if self.running {
5431 self.reconcile_subscriptions();
5432 }
5433 }
5434 }
5435
5436 self.check_checkpoint_save();
5438
5439 self.check_locale_change();
5441 termination_signal = termination_signal.or_else(|| self.observed_termination_signal());
5442 if termination_signal.is_some() {
5443 self.running = false;
5444 break;
5445 }
5446
5447 if self.dirty {
5449 self.render_frame()?;
5450 }
5451
5452 if loop_count.is_multiple_of(1000) {
5454 self.writer.gc(None);
5455 }
5456 }
5457
5458 Ok(termination_signal)
5459 }
5460
5461 fn complete_lifecycle(&mut self, run_result: io::Result<Option<i32>>) -> io::Result<()> {
5463 let (loop_signal, primary_error) = match run_result {
5464 Ok(signal) => (signal, None),
5465 Err(error) => (None, Some(error)),
5466 };
5467 let termination_signal = loop_signal.or_else(|| self.observed_termination_signal());
5468
5469 self.running = false;
5472
5473 let mut hook_error = None;
5474 if let Some(error) = primary_error.as_ref()
5475 && let Err(error) = self.invoke_error_hook(&error.to_string(), true)
5476 {
5477 hook_error = Some(error);
5478 }
5479
5480 if let Err(error) = self.process_subscription_failures(true) {
5481 hook_error.get_or_insert(error);
5482 }
5483
5484 let shutdown_error = self.shutdown_once().err();
5485
5486 if let Some(signal) = termination_signal {
5490 clear_termination_signal();
5491 let error = io::Error::new(
5492 io::ErrorKind::Interrupted,
5493 SignalTerminationError { signal },
5494 );
5495 debug_assert_eq!(signal_termination_from_error(&error), Some(signal));
5496 return Err(error);
5497 }
5498
5499 if let Some(error) = primary_error {
5500 return Err(error);
5501 }
5502
5503 if let Some(error) = hook_error {
5504 return Err(error);
5505 }
5506
5507 if let Some(error) = shutdown_error {
5508 let _ = self.invoke_error_hook(&error.to_string(), true);
5512 return Err(error);
5513 }
5514
5515 Ok(())
5516 }
5517
5518 fn shutdown_once(&mut self) -> io::Result<()> {
5520 if self.shutdown_complete {
5521 return Ok(());
5522 }
5523 self.shutdown_complete = true;
5524
5525 let shutdown_cmd = {
5526 let _span = info_span!("ftui.program.shutdown").entered();
5527 self.model.on_shutdown()
5528 };
5529 let mut shutdown_error = self.execute_lifecycle_cmd(shutdown_cmd).err();
5533
5534 if self.persistence_config.auto_save {
5535 self.save_state();
5536 }
5537
5538 if let Some(ref mut strategy) = self.tick_strategy {
5539 strategy.shutdown();
5540 }
5541
5542 self.subscriptions.stop_all();
5543 if let Err(error) = self.process_subscription_failures(true) {
5544 shutdown_error.get_or_insert(error);
5545 }
5546
5547 self.task_executor.shutdown();
5548 self.reap_finished_tasks();
5549 if let Err(error) = self.drain_shutdown_task_results() {
5550 shutdown_error.get_or_insert(error);
5551 }
5552
5553 match shutdown_error {
5554 Some(error) => Err(error),
5555 None => Ok(()),
5556 }
5557 }
5558
5559 fn drain_ready_events(&mut self) -> io::Result<()> {
5565 self.immediate_drain_stats.bursts = self.immediate_drain_stats.bursts.saturating_add(1);
5566
5567 let zero_poll_limit = self
5568 .immediate_drain_config
5569 .max_zero_timeout_polls_per_burst
5570 .max(1);
5571 let max_burst_duration = self.immediate_drain_config.max_burst_duration;
5572 let backoff_timeout = self.immediate_drain_config.backoff_timeout;
5573
5574 let mut burst_start = Instant::now();
5575 let mut zero_polls_in_burst_window: u64 = 0;
5576 let mut capped_this_burst = false;
5577
5578 loop {
5579 if let Some(event) = self.events.read_event()? {
5580 self.handle_event(event)?;
5581 if !self.running {
5582 break;
5583 }
5584 }
5585
5586 let budget_exhausted = (zero_polls_in_burst_window as usize) >= zero_poll_limit
5587 || burst_start.elapsed() >= max_burst_duration;
5588
5589 if budget_exhausted {
5590 if !capped_this_burst {
5591 capped_this_burst = true;
5592 self.immediate_drain_stats.capped_bursts =
5593 self.immediate_drain_stats.capped_bursts.saturating_add(1);
5594 }
5595
5596 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5597 .immediate_drain_stats
5598 .max_zero_timeout_polls_in_burst
5599 .max(zero_polls_in_burst_window);
5600
5601 std::thread::yield_now();
5602 self.immediate_drain_stats.backoff_polls =
5603 self.immediate_drain_stats.backoff_polls.saturating_add(1);
5604 if !self.events.poll_event(backoff_timeout)? {
5605 break;
5606 }
5607 zero_polls_in_burst_window = 0;
5608 burst_start = Instant::now();
5609 continue;
5610 }
5611
5612 self.immediate_drain_stats.zero_timeout_polls = self
5613 .immediate_drain_stats
5614 .zero_timeout_polls
5615 .saturating_add(1);
5616 zero_polls_in_burst_window = zero_polls_in_burst_window.saturating_add(1);
5617 if !self.events.poll_event(Duration::ZERO)? {
5618 break;
5619 }
5620 }
5621
5622 self.immediate_drain_stats.max_zero_timeout_polls_in_burst = self
5623 .immediate_drain_stats
5624 .max_zero_timeout_polls_in_burst
5625 .max(zero_polls_in_burst_window);
5626
5627 Ok(())
5628 }
5629
5630 fn load_state(&mut self) {
5632 if let Some(registry) = &self.state_registry {
5633 match registry.load() {
5634 Ok(count) => {
5635 info!(count, "loaded widget state from persistence");
5636 }
5637 Err(e) => {
5638 tracing::warn!(error = %e, "failed to load widget state");
5639 }
5640 }
5641 }
5642 }
5643
5644 fn save_state(&mut self) {
5646 if let Some(registry) = &self.state_registry {
5647 match registry.flush() {
5648 Ok(true) => {
5649 debug!("saved widget state to persistence");
5650 }
5651 Ok(false) => {
5652 }
5654 Err(e) => {
5655 tracing::warn!(error = %e, "failed to save widget state");
5656 }
5657 }
5658 }
5659 }
5660
5661 fn check_checkpoint_save(&mut self) {
5663 if let Some(interval) = self.persistence_config.checkpoint_interval
5664 && self.last_checkpoint.elapsed() >= interval
5665 {
5666 self.save_state();
5667 self.last_checkpoint = Instant::now();
5668 }
5669 }
5670
5671 fn handle_event(&mut self, event: Event) -> io::Result<()> {
5672 let event_start = Instant::now();
5674 let fairness_event_type = Self::classify_event_for_fairness(&event);
5675 if fairness_event_type == FairnessEventType::Input {
5676 self.fairness_guard.input_arrived(event_start);
5677 }
5678
5679 if let Some(recorder) = &mut self.event_recorder {
5681 recorder.record(&event);
5682 }
5683
5684 let event = match event {
5685 Event::Resize { width, height } => {
5686 debug!(
5687 width,
5688 height,
5689 behavior = ?self.resize_behavior,
5690 "Resize event received"
5691 );
5692 if let Some((forced_width, forced_height)) = self.forced_size {
5693 debug!(
5694 forced_width,
5695 forced_height, "Resize ignored due to forced size override"
5696 );
5697 self.fairness_guard.event_processed(
5698 fairness_event_type,
5699 event_start.elapsed(),
5700 Instant::now(),
5701 );
5702 return Ok(());
5703 }
5704 let width = width.max(1);
5706 let height = height.max(1);
5707 match self.resize_behavior {
5708 ResizeBehavior::Immediate => {
5709 self.resize_coalescer
5710 .record_external_apply(width, height, Instant::now());
5711 let result = self.apply_resize(width, height, Duration::ZERO, false);
5712 self.fairness_guard.event_processed(
5713 fairness_event_type,
5714 event_start.elapsed(),
5715 Instant::now(),
5716 );
5717 return result;
5718 }
5719 ResizeBehavior::Throttled => {
5720 let action = self.resize_coalescer.handle_resize(width, height);
5721 if let CoalesceAction::ApplyResize {
5722 width,
5723 height,
5724 coalesce_time,
5725 forced_by_deadline,
5726 } = action
5727 {
5728 let result =
5729 self.apply_resize(width, height, coalesce_time, forced_by_deadline);
5730 self.fairness_guard.event_processed(
5731 fairness_event_type,
5732 event_start.elapsed(),
5733 Instant::now(),
5734 );
5735 return result;
5736 }
5737
5738 self.fairness_guard.event_processed(
5739 fairness_event_type,
5740 event_start.elapsed(),
5741 Instant::now(),
5742 );
5743 return Ok(());
5744 }
5745 }
5746 }
5747 other => other,
5748 };
5749
5750 let msg = M::Message::from(event);
5751 let cmd = {
5752 let _span = debug_span!(
5753 "ftui.program.update",
5754 msg_type = "event",
5755 duration_us = tracing::field::Empty,
5756 cmd_type = tracing::field::Empty
5757 )
5758 .entered();
5759 let start = Instant::now();
5760 let cmd = self.model.update(msg);
5761 let elapsed_us = start.elapsed().as_micros() as u64;
5762 self.last_update_us = Some(elapsed_us);
5763 tracing::Span::current().record("duration_us", elapsed_us);
5764 tracing::Span::current()
5765 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5766 cmd
5767 };
5768 self.mark_dirty();
5769 self.execute_cmd(cmd)?;
5770 if self.running {
5771 self.reconcile_subscriptions();
5772 }
5773
5774 self.fairness_guard.event_processed(
5776 fairness_event_type,
5777 event_start.elapsed(),
5778 Instant::now(),
5779 );
5780
5781 Ok(())
5782 }
5783
5784 fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
5786 match event {
5787 Event::Key(_)
5788 | Event::Mouse(_)
5789 | Event::Paste(_)
5790 | Event::Ime(_)
5791 | Event::Focus(_)
5792 | Event::Clipboard(_) => FairnessEventType::Input,
5793 Event::Resize { .. } => FairnessEventType::Resize,
5794 Event::Tick => FairnessEventType::Tick,
5795 }
5796 }
5797
5798 fn reconcile_subscriptions(&mut self) {
5800 let _span = debug_span!(
5801 "ftui.program.subscriptions",
5802 active_count = tracing::field::Empty,
5803 started = tracing::field::Empty,
5804 stopped = tracing::field::Empty
5805 )
5806 .entered();
5807 let subs = self.model.subscriptions();
5808 let before_count = self.subscriptions.active_count();
5809 self.subscriptions.reconcile(subs);
5810 let after_count = self.subscriptions.active_count();
5811 let started = after_count.saturating_sub(before_count);
5812 let stopped = before_count.saturating_sub(after_count);
5813 crate::debug_trace!(
5814 "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
5815 before_count,
5816 after_count,
5817 started,
5818 stopped
5819 );
5820 if after_count == 0 {
5821 crate::debug_trace!("subscriptions reconcile: no active subscriptions");
5822 }
5823 let current = tracing::Span::current();
5824 current.record("active_count", after_count);
5825 current.record("started", started);
5827 current.record("stopped", stopped);
5828 }
5829
5830 fn process_subscription_failures(&mut self, during_lifecycle: bool) -> io::Result<()> {
5832 let mut first_error = None;
5833 for failure in self.subscriptions.drain_failures() {
5834 let error = format!("subscription {} failed: {}", failure.id, failure.error);
5835 if let Err(error) = self.invoke_error_hook(&error, during_lifecycle) {
5836 first_error.get_or_insert(error);
5837 }
5838 }
5839
5840 match first_error {
5841 Some(error) => Err(error),
5842 None => Ok(()),
5843 }
5844 }
5845
5846 fn invoke_error_hook(&mut self, error: &str, during_lifecycle: bool) -> io::Result<()> {
5848 let cmd = {
5849 let _span = info_span!("ftui.program.error", error).entered();
5850 self.model.on_error(error)
5851 };
5852 if during_lifecycle {
5853 self.execute_lifecycle_cmd(cmd)
5854 } else {
5855 self.execute_cmd(cmd)
5856 }
5857 }
5858
5859 fn execute_lifecycle_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5866 let was_running = std::mem::replace(&mut self.running, true);
5867 let result = self.execute_cmd(cmd);
5868 self.running = was_running && self.running;
5869 result
5870 }
5871
5872 fn process_subscription_messages(&mut self) -> io::Result<()> {
5874 let messages = self.subscriptions.drain_messages();
5875 let msg_count = messages.len();
5876 if msg_count > 0 {
5877 crate::debug_trace!("processing {} subscription message(s)", msg_count);
5878 }
5879 for msg in messages {
5880 let cmd = {
5881 let _span = debug_span!(
5882 "ftui.program.update",
5883 msg_type = "subscription",
5884 duration_us = tracing::field::Empty,
5885 cmd_type = tracing::field::Empty
5886 )
5887 .entered();
5888 let start = Instant::now();
5889 let cmd = self.model.update(msg);
5890 let elapsed_us = start.elapsed().as_micros() as u64;
5891 self.last_update_us = Some(elapsed_us);
5892 tracing::Span::current().record("duration_us", elapsed_us);
5893 tracing::Span::current()
5894 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5895 cmd
5896 };
5897 self.mark_dirty();
5898 self.execute_cmd(cmd)?;
5899 if !self.running {
5900 break;
5901 }
5902 }
5903 if self.running && self.dirty {
5904 self.reconcile_subscriptions();
5905 }
5906 Ok(())
5907 }
5908
5909 fn process_task_results(&mut self) -> io::Result<()> {
5911 while let Ok(msg) = self.task_receiver.try_recv() {
5912 let cmd = {
5913 let _span = debug_span!(
5914 "ftui.program.update",
5915 msg_type = "task",
5916 duration_us = tracing::field::Empty,
5917 cmd_type = tracing::field::Empty
5918 )
5919 .entered();
5920 let start = Instant::now();
5921 let cmd = self.model.update(msg);
5922 let elapsed_us = start.elapsed().as_micros() as u64;
5923 self.last_update_us = Some(elapsed_us);
5924 tracing::Span::current().record("duration_us", elapsed_us);
5925 tracing::Span::current()
5926 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
5927 cmd
5928 };
5929 self.mark_dirty();
5930 self.execute_cmd(cmd)?;
5931 if !self.running {
5932 break;
5933 }
5934 }
5935 if self.running && self.dirty {
5936 self.reconcile_subscriptions();
5937 }
5938 Ok(())
5939 }
5940
5941 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
5943 self.executed_cmd_count = self.executed_cmd_count.saturating_add(1);
5944 match cmd {
5945 Cmd::None => {}
5946 Cmd::Quit => self.running = false,
5947 Cmd::Msg(m) => {
5948 let start = Instant::now();
5949 let cmd = self.model.update(m);
5950 let elapsed_us = start.elapsed().as_micros() as u64;
5951 self.last_update_us = Some(elapsed_us);
5952 self.mark_dirty();
5953 self.execute_cmd(cmd)?;
5954 }
5955 Cmd::Batch(cmds) => {
5956 for c in cmds {
5959 self.execute_cmd(c)?;
5960 if !self.running {
5961 break;
5962 }
5963 }
5964 }
5965 Cmd::Sequence(cmds) => {
5966 for c in cmds {
5967 self.execute_cmd(c)?;
5968 if !self.running {
5969 break;
5970 }
5971 }
5972 }
5973 Cmd::Tick(duration) => {
5974 self.tick_rate = Some(duration);
5975 self.last_tick = Instant::now();
5976 }
5977 Cmd::Log(text) => {
5978 let sanitized = sanitize(&text);
5979 let mut text_crlf = if sanitized.contains('\n') {
5980 sanitized.replace("\r\n", "\n").replace('\n', "\r\n")
5981 } else {
5982 sanitized.into_owned()
5983 };
5984 if !text_crlf.ends_with("\r\n") {
5985 if text_crlf.ends_with('\n') {
5986 text_crlf.pop();
5987 }
5988 text_crlf.push_str("\r\n");
5989 }
5990 self.writer.write_log(&text_crlf)?;
5991 }
5992 Cmd::Task(spec, f) => {
5993 crate::effect_system::record_command_effect("task", 0);
5994 self.task_executor.submit(spec, f);
5995 }
5996 Cmd::SaveState => {
5997 self.save_state();
5998 }
5999 Cmd::RestoreState => {
6000 self.load_state();
6001 }
6002 Cmd::SetMouseCapture(enabled) => {
6003 self.backend_features.mouse_capture = enabled;
6004 self.events.set_features(self.backend_features)?;
6005 }
6006 Cmd::SetTickStrategy(strategy) => {
6007 let new_name = strategy.name().to_owned();
6008 if let Some(mut previous) = self.tick_strategy.replace(strategy) {
6009 let old_name = previous.name().to_owned();
6010 previous.shutdown();
6011 info!(old = %old_name, new = %new_name, "tick strategy changed at runtime");
6012 } else {
6013 info!(new = %new_name, "tick strategy changed at runtime");
6014 }
6015 self.last_active_screen_for_strategy = None;
6016 }
6017 }
6018 Ok(())
6019 }
6020
6021 fn check_screen_transition(&mut self) {
6031 if self.tick_strategy.is_none() {
6032 return;
6033 }
6034
6035 let current_active = match self.model.as_screen_tick_dispatch() {
6037 Some(dispatch) => dispatch.active_screen_id(),
6038 None => return,
6039 };
6040
6041 let previous = match self.last_active_screen_for_strategy.take() {
6043 Some(prev) => prev,
6044 None => {
6045 self.last_active_screen_for_strategy = Some(current_active);
6046 return;
6047 }
6048 };
6049
6050 if previous == current_active {
6051 self.last_active_screen_for_strategy = Some(current_active);
6052 return;
6053 }
6054
6055 if let Some(strategy) = self.tick_strategy.as_mut() {
6057 strategy.on_screen_transition(&previous, ¤t_active);
6058 }
6059
6060 let mut force_ticked = false;
6062 if let Some(dispatch) = self.model.as_screen_tick_dispatch() {
6063 dispatch.tick_screen(¤t_active, self.tick_count);
6064 force_ticked = true;
6065 }
6066 if force_ticked && self.running {
6067 self.reconcile_subscriptions();
6068 }
6069
6070 self.last_active_screen_for_strategy = Some(current_active);
6071 self.mark_dirty();
6072 }
6073
6074 fn reap_finished_tasks(&mut self) {
6075 self.task_executor.reap_finished();
6076 }
6077
6078 fn drain_shutdown_task_results(&mut self) -> io::Result<()> {
6079 while let Ok(msg) = self.task_receiver.try_recv() {
6080 let cmd = {
6081 let _span = debug_span!(
6082 "ftui.program.update",
6083 msg_type = "shutdown_task",
6084 duration_us = tracing::field::Empty,
6085 cmd_type = tracing::field::Empty
6086 )
6087 .entered();
6088 let start = Instant::now();
6089 let cmd = self.model.update(msg);
6090 let elapsed_us = start.elapsed().as_micros() as u64;
6091 self.last_update_us = Some(elapsed_us);
6092 tracing::Span::current().record("duration_us", elapsed_us);
6093 tracing::Span::current()
6094 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
6095 cmd
6096 };
6097 self.mark_dirty();
6098 self.execute_lifecycle_cmd(cmd)?;
6099 }
6100 Ok(())
6101 }
6102
6103 fn render_frame(&mut self) -> io::Result<()> {
6105 crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
6106
6107 self.frame_idx = self.frame_idx.wrapping_add(1);
6108 let frame_idx = self.frame_idx;
6109 let degradation_start = self.budget.degradation();
6110
6111 self.budget.next_frame();
6113
6114 let memory_bytes = self.writer.estimate_memory_usage() + self.frame_arena.allocated_bytes();
6116 let verdict = self.guardrails.check_frame(memory_bytes, 0);
6118
6119 if !verdict.alerts.is_empty()
6124 && let Some(ref sink) = self.evidence_sink
6125 {
6126 let line = format!(
6127 r#"{{"schema_version":"{}","event":"guardrail_snapshot","snapshot":{}}}"#,
6128 crate::evidence_sink::EVIDENCE_SCHEMA_VERSION,
6129 self.guardrails.snapshot().to_jsonl(),
6130 );
6131 let _ = sink.write_jsonl(&line);
6132 }
6133
6134 if verdict.should_drop_frame() {
6135 self.frame_arena = FrameArena::default();
6144 self.writer.gc(None);
6145 tracing::warn!(
6146 target: "ftui.guardrails",
6147 memory_bytes,
6148 "emergency frame drop: memory guardrail tripped; arena rebuilt"
6149 );
6150 return Ok(());
6151 }
6152
6153 if verdict.should_degrade() {
6154 let current = self.budget.degradation();
6156 if verdict.recommended_level > current {
6157 self.budget.set_degradation(verdict.recommended_level);
6158 }
6159
6160 let soft_alert = verdict.alerts.iter().any(|alert| {
6168 alert.kind == GuardrailKind::Memory && alert.severity == AlertSeverity::Warning
6169 });
6170 let cooldown_elapsed = match self.last_soft_trim_frame {
6171 None => true,
6172 Some(last) => self.frame_idx.saturating_sub(last) >= SOFT_TRIM_COOLDOWN_FRAMES,
6173 };
6174 if soft_alert && cooldown_elapsed {
6175 self.frame_arena = FrameArena::default();
6176 self.writer.gc(None);
6177 self.last_soft_trim_frame = Some(self.frame_idx);
6178 tracing::debug!(
6179 target: "ftui.guardrails",
6180 memory_bytes,
6181 "soft memory alert: arena rebuilt to release retained capacity"
6182 );
6183 }
6184 }
6185
6186 let mut conformal_prediction = None;
6188 if let Some(predictor) = self.conformal_predictor.as_ref() {
6189 let baseline_us = self
6190 .last_frame_time_us
6191 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6192 let diff_strategy = self
6193 .writer
6194 .last_diff_strategy()
6195 .unwrap_or(DiffStrategy::Full);
6196 let frame_height_hint = self.writer.render_height_hint().max(1);
6197 let key = BucketKey::from_context(
6198 self.writer.screen_mode(),
6199 diff_strategy,
6200 self.width,
6201 frame_height_hint,
6202 );
6203 let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
6204 let prediction = predictor.predict(key, baseline_us, budget_us);
6205 if prediction.risk {
6206 self.budget.degrade();
6207 info!(
6208 bucket = %prediction.bucket,
6209 upper_us = prediction.upper_us,
6210 budget_us = prediction.budget_us,
6211 fallback_level = prediction.fallback_level,
6212 degradation = self.budget.degradation().as_str(),
6213 "conformal gate triggered strategy downgrade"
6214 );
6215 debug!(
6216 monotonic.counter.conformal_gate_triggers_total = 1_u64,
6217 bucket = %prediction.bucket,
6218 "conformal gate trigger"
6219 );
6220 }
6221 debug!(
6222 bucket = %prediction.bucket,
6223 upper_us = prediction.upper_us,
6224 budget_us = prediction.budget_us,
6225 fallback = prediction.fallback_level,
6226 risk = prediction.risk,
6227 "conformal risk gate"
6228 );
6229 debug!(
6230 monotonic.histogram.conformal_prediction_interval_width_us = prediction.quantile.max(0.0),
6231 bucket = %prediction.bucket,
6232 "conformal prediction interval width"
6233 );
6234 conformal_prediction = Some(prediction);
6235 }
6236
6237 if self.budget.exhausted() {
6239 self.budget.record_frame_time(Duration::ZERO);
6240 let load_snapshot =
6241 self.update_load_governor_snapshot(frame_idx, 0.0, conformal_prediction.as_ref());
6242 self.emit_budget_evidence(
6243 frame_idx,
6244 degradation_start,
6245 0.0,
6246 conformal_prediction.as_ref(),
6247 &load_snapshot,
6248 );
6249 crate::debug_trace!(
6250 "frame skipped: budget exhausted (degradation={})",
6251 self.budget.degradation().as_str()
6252 );
6253 debug!(
6254 degradation = self.budget.degradation().as_str(),
6255 "frame skipped: budget exhausted before render"
6256 );
6257 return Ok(());
6260 }
6261
6262 let auto_bounds = self.writer.inline_auto_bounds();
6263 let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
6264 let mut should_measure = needs_measure;
6265 if auto_bounds.is_some()
6266 && let Some(state) = self.inline_auto_remeasure.as_mut()
6267 {
6268 let decision = state.sampler.decide(Instant::now());
6269 if decision.should_sample {
6270 should_measure = true;
6271 }
6272 } else {
6273 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6274 }
6275
6276 let render_start = Instant::now();
6278 if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
6279 let measure_height = if needs_measure {
6280 self.writer.render_height_hint().max(1)
6281 } else {
6282 max_height.max(1)
6283 };
6284 let (measure_buffer, _) = self.render_measure_buffer(measure_height);
6285 let measured_height = measure_buffer.content_height();
6286 let clamped = measured_height.clamp(min_height, max_height);
6287 let previous_height = self.writer.auto_ui_height();
6288 self.writer.set_auto_ui_height(clamped);
6289 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6290 let threshold = state.config.change_threshold_rows;
6291 let violated = previous_height
6292 .map(|prev| prev.abs_diff(clamped) >= threshold)
6293 .unwrap_or(false);
6294 state.sampler.observe(violated);
6295 }
6296 }
6297 if auto_bounds.is_some()
6298 && let Some(state) = self.inline_auto_remeasure.as_ref()
6299 {
6300 let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
6301 crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
6302 }
6303
6304 let frame_height = self.writer.render_height_hint().max(1);
6305 let _frame_span = info_span!(
6306 "ftui.render.frame",
6307 width = self.width,
6308 height = frame_height,
6309 duration_us = tracing::field::Empty
6310 )
6311 .entered();
6312 let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
6313 self.update_widget_refresh_plan(frame_idx);
6314 let render_elapsed = render_start.elapsed();
6315 let mut present_elapsed = Duration::ZERO;
6316 let mut presented = false;
6317
6318 let render_budget = self.budget.phase_budgets().render;
6320 if render_elapsed > render_budget {
6321 debug!(
6322 render_ms = render_elapsed.as_millis() as u32,
6323 budget_ms = render_budget.as_millis() as u32,
6324 "render phase exceeded budget"
6325 );
6326 if self.budget.controller().is_none() && self.budget.should_degrade(render_budget) {
6330 self.budget.degrade();
6331 }
6332 }
6333
6334 if !self.budget.exhausted() {
6336 let present_start = Instant::now();
6337 {
6338 let _present_span = debug_span!("ftui.render.present").entered();
6339 self.writer
6340 .present_ui_owned(buffer, cursor, cursor_visible)?;
6341 }
6342 presented = true;
6343 present_elapsed = present_start.elapsed();
6344
6345 let present_budget = self.budget.phase_budgets().present;
6346 if present_elapsed > present_budget {
6347 debug!(
6348 present_ms = present_elapsed.as_millis() as u32,
6349 budget_ms = present_budget.as_millis() as u32,
6350 "present phase exceeded budget"
6351 );
6352 }
6353 } else {
6354 debug!(
6355 degradation = self.budget.degradation().as_str(),
6356 elapsed_ms = self.budget.elapsed().as_millis() as u32,
6357 "frame present skipped: budget exhausted after render"
6358 );
6359 }
6360
6361 if let Some(ref frame_timing) = self.frame_timing {
6362 let update_us = self.last_update_us.unwrap_or(0);
6363 let render_us = render_elapsed.as_micros() as u64;
6364 let present_us = present_elapsed.as_micros() as u64;
6365 let diff_us = if presented {
6366 self.writer
6367 .take_last_present_timings()
6368 .map(|timings| timings.diff_us)
6369 .unwrap_or(0)
6370 } else {
6371 let _ = self.writer.take_last_present_timings();
6372 0
6373 };
6374 let total_us = update_us
6375 .saturating_add(render_us)
6376 .saturating_add(present_us);
6377 let timing = FrameTiming {
6378 frame_idx,
6379 update_us,
6380 render_us,
6381 diff_us,
6382 present_us,
6383 total_us,
6384 };
6385 frame_timing.sink.record_frame(&timing);
6386 }
6387
6388 let frame_time = render_elapsed.saturating_add(present_elapsed);
6389 self.budget.record_frame_time(frame_time);
6390 let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
6391
6392 if let (Some(predictor), Some(prediction)) = (
6393 self.conformal_predictor.as_mut(),
6394 conformal_prediction.as_ref(),
6395 ) {
6396 let diff_strategy = self
6397 .writer
6398 .last_diff_strategy()
6399 .unwrap_or(DiffStrategy::Full);
6400 let key = BucketKey::from_context(
6401 self.writer.screen_mode(),
6402 diff_strategy,
6403 self.width,
6404 frame_height,
6405 );
6406 predictor.observe(key, prediction.y_hat, frame_time_us);
6407 }
6408 self.last_frame_time_us = Some(frame_time_us);
6409 let load_snapshot = self.update_load_governor_snapshot(
6410 frame_idx,
6411 frame_time_us,
6412 conformal_prediction.as_ref(),
6413 );
6414 self.emit_budget_evidence(
6415 frame_idx,
6416 degradation_start,
6417 frame_time_us,
6418 conformal_prediction.as_ref(),
6419 &load_snapshot,
6420 );
6421
6422 if presented {
6426 self.dirty = false;
6427 }
6428
6429 Ok(())
6430 }
6431
6432 fn update_load_governor_snapshot(
6433 &mut self,
6434 _frame_idx: u64,
6435 frame_time_us: f64,
6436 conformal_prediction: Option<&ConformalPrediction>,
6437 ) -> LoadGovernorSnapshot {
6438 let budget_us = conformal_prediction
6439 .map(|prediction| prediction.budget_us)
6440 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6441 let resize_stats = self.resize_coalescer.stats();
6442 self.load_governor.observe(LoadGovernorObservation {
6443 frame_time_us,
6444 budget_us,
6445 degradation: self.budget.degradation(),
6446 queue: crate::effect_system::queue_telemetry(),
6447 resize_coalescing_active: self.resize_behavior.uses_coalescer()
6451 && (resize_stats.has_pending
6452 || !matches!(resize_stats.regime, crate::resize_coalescer::Regime::Steady)),
6453 strict_semantics_violation: false,
6454 })
6455 }
6456
6457 fn emit_budget_evidence(
6458 &self,
6459 frame_idx: u64,
6460 degradation_start: DegradationLevel,
6461 frame_time_us: f64,
6462 conformal_prediction: Option<&ConformalPrediction>,
6463 load_snapshot: &LoadGovernorSnapshot,
6464 ) {
6465 let Some(telemetry) = self.budget.telemetry() else {
6466 set_budget_snapshot(None);
6467 return;
6468 };
6469
6470 let budget_us = conformal_prediction
6471 .map(|prediction| prediction.budget_us)
6472 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
6473 let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
6474 let degradation_after = self.budget.degradation();
6475
6476 let evidence = BudgetDecisionEvidence {
6477 frame_idx,
6478 decision: BudgetDecisionEvidence::decision_from_levels(
6479 degradation_start,
6480 degradation_after,
6481 ),
6482 controller_decision: telemetry.last_decision,
6483 degradation_before: degradation_start,
6484 degradation_after,
6485 frame_time_us,
6486 budget_us,
6487 pid_output: telemetry.pid_output,
6488 pid_p: telemetry.pid_p,
6489 pid_i: telemetry.pid_i,
6490 pid_d: telemetry.pid_d,
6491 e_value: telemetry.e_value,
6492 frames_observed: telemetry.frames_observed,
6493 frames_since_change: telemetry.frames_since_change,
6494 in_warmup: telemetry.in_warmup,
6495 controller_reason: telemetry.decision_reason,
6496 load_governor: *load_snapshot,
6497 conformal,
6498 };
6499
6500 let conformal_snapshot = evidence
6501 .conformal
6502 .as_ref()
6503 .map(|snapshot| ConformalSnapshot {
6504 bucket_key: snapshot.bucket_key.clone(),
6505 sample_count: snapshot.n_b,
6506 upper_us: snapshot.upper_us,
6507 risk: snapshot.risk,
6508 });
6509 set_budget_snapshot(Some(BudgetDecisionSnapshot {
6510 frame_idx: evidence.frame_idx,
6511 decision: evidence.decision,
6512 controller_decision: evidence.controller_decision,
6513 degradation_before: evidence.degradation_before,
6514 degradation_after: evidence.degradation_after,
6515 frame_time_us: evidence.frame_time_us,
6516 budget_us: evidence.budget_us,
6517 pid_output: evidence.pid_output,
6518 e_value: evidence.e_value,
6519 frames_observed: evidence.frames_observed,
6520 frames_since_change: evidence.frames_since_change,
6521 in_warmup: evidence.in_warmup,
6522 conformal: conformal_snapshot,
6523 }));
6524
6525 if let Some(ref sink) = self.evidence_sink {
6526 let _ = sink.write_jsonl(&evidence.to_jsonl());
6527 }
6528 }
6529
6530 fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
6531 if !self.widget_refresh_config.enabled {
6532 self.widget_refresh_plan.clear();
6533 return;
6534 }
6535
6536 let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
6537 let degradation = self.budget.degradation();
6538 self.widget_refresh_plan.recompute(
6539 frame_idx,
6540 budget_us,
6541 degradation,
6542 &self.widget_signals,
6543 &self.widget_refresh_config,
6544 );
6545
6546 if let Some(ref sink) = self.evidence_sink {
6547 let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
6548 }
6549 }
6550
6551 fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
6552 self.frame_arena.reset();
6554
6555 let buffer = self.writer.take_render_buffer(self.width, frame_height);
6558 let (pool, links) = self.writer.pool_and_links_mut();
6559 let mut frame = Frame::from_buffer(buffer, pool);
6560 frame.set_degradation(self.budget.degradation());
6561 frame.set_links(links);
6562 frame.set_widget_budget(self.widget_refresh_plan.as_budget());
6563 frame.set_arena(&self.frame_arena);
6564
6565 let view_start = Instant::now();
6566 let _view_span = debug_span!(
6567 "ftui.program.view",
6568 duration_us = tracing::field::Empty,
6569 widget_count = tracing::field::Empty
6570 )
6571 .entered();
6572 self.model.view(&mut frame);
6573 self.widget_signals = frame.take_widget_signals();
6574 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6575 (frame.buffer, frame.cursor_position, frame.cursor_visible)
6578 }
6579
6580 fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
6581 let Some(ref sink) = self.evidence_sink else {
6582 return;
6583 };
6584
6585 let config = self.fairness_guard.config();
6586 if !self.fairness_config_logged {
6587 let config_entry = FairnessConfigEvidence {
6588 enabled: config.enabled,
6589 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6590 dominance_threshold: config.dominance_threshold,
6591 fairness_threshold: config.fairness_threshold,
6592 };
6593 let _ = sink.write_jsonl(&config_entry.to_jsonl());
6594 self.fairness_config_logged = true;
6595 }
6596
6597 let evidence = FairnessDecisionEvidence {
6598 frame_idx: self.frame_idx,
6599 decision: if decision.should_process {
6600 "allow"
6601 } else {
6602 "yield"
6603 },
6604 reason: decision.reason.as_str(),
6605 pending_input_latency_ms: decision
6606 .pending_input_latency
6607 .map(|latency| latency.as_millis() as u64),
6608 jain_index: decision.jain_index,
6609 resize_dominance_count: dominance_count,
6610 dominance_threshold: config.dominance_threshold,
6611 fairness_threshold: config.fairness_threshold,
6612 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
6613 };
6614
6615 let _ = sink.write_jsonl(&evidence.to_jsonl());
6616 }
6617
6618 fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
6619 self.frame_arena.reset();
6621
6622 let pool = self.writer.pool_mut();
6623 let mut frame = Frame::new(self.width, frame_height, pool);
6624 frame.set_degradation(self.budget.degradation());
6625 frame.set_arena(&self.frame_arena);
6626
6627 let view_start = Instant::now();
6628 let _view_span = debug_span!(
6629 "ftui.program.view",
6630 duration_us = tracing::field::Empty,
6631 widget_count = tracing::field::Empty
6632 )
6633 .entered();
6634 self.model.view(&mut frame);
6635 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
6636
6637 (frame.buffer, frame.cursor_position)
6638 }
6639
6640 fn effective_timeout(&self) -> Duration {
6642 if let Some(tick_rate) = self.tick_rate {
6643 let elapsed = self.last_tick.elapsed();
6644 let mut timeout = tick_rate.saturating_sub(elapsed);
6645 if self.resize_behavior.uses_coalescer()
6646 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6647 {
6648 timeout = timeout.min(resize_timeout);
6649 }
6650 timeout
6651 } else {
6652 let mut timeout = self.poll_timeout;
6653 if self.resize_behavior.uses_coalescer()
6654 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
6655 {
6656 timeout = timeout.min(resize_timeout);
6657 }
6658 timeout
6659 }
6660 }
6661
6662 fn should_tick(&mut self) -> bool {
6664 if let Some(tick_rate) = self.tick_rate
6665 && self.last_tick.elapsed() >= tick_rate
6666 {
6667 self.last_tick = Instant::now();
6668 return true;
6669 }
6670 false
6671 }
6672
6673 fn process_resize_coalescer(&mut self) -> io::Result<()> {
6674 if !self.resize_behavior.uses_coalescer() {
6675 return Ok(());
6676 }
6677
6678 let dominance_count = self.fairness_guard.resize_dominance_count();
6681 let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
6682 self.emit_fairness_evidence(&fairness_decision, dominance_count);
6683 if !fairness_decision.should_process {
6684 debug!(
6685 reason = ?fairness_decision.reason,
6686 pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
6687 "Resize yielding to input for fairness"
6688 );
6689 return Ok(());
6691 }
6692
6693 let action = self.resize_coalescer.tick();
6694 let resize_snapshot =
6695 self.resize_coalescer
6696 .logs()
6697 .last()
6698 .map(|entry| ResizeDecisionSnapshot {
6699 event_idx: entry.event_idx,
6700 action: entry.action,
6701 dt_ms: entry.dt_ms,
6702 event_rate: entry.event_rate,
6703 regime: entry.regime,
6704 pending_size: entry.pending_size,
6705 applied_size: entry.applied_size,
6706 time_since_render_ms: entry.time_since_render_ms,
6707 bocpd: self
6708 .resize_coalescer
6709 .bocpd()
6710 .and_then(|detector| detector.last_evidence().cloned()),
6711 });
6712 set_resize_snapshot(resize_snapshot);
6713
6714 match action {
6715 CoalesceAction::ApplyResize {
6716 width,
6717 height,
6718 coalesce_time,
6719 forced_by_deadline,
6720 } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
6721 _ => Ok(()),
6722 }
6723 }
6724
6725 fn apply_resize(
6726 &mut self,
6727 width: u16,
6728 height: u16,
6729 coalesce_time: Duration,
6730 forced_by_deadline: bool,
6731 ) -> io::Result<()> {
6732 let width = width.max(1);
6734 let height = height.max(1);
6735 self.width = width;
6736 self.height = height;
6737 self.writer.set_size(width, height);
6738 info!(
6739 width = width,
6740 height = height,
6741 coalesce_ms = coalesce_time.as_millis() as u64,
6742 forced = forced_by_deadline,
6743 "Resize applied"
6744 );
6745
6746 let msg = M::Message::from(Event::Resize { width, height });
6747 let start = Instant::now();
6748 let cmd = self.model.update(msg);
6749 let elapsed_us = start.elapsed().as_micros() as u64;
6750 self.last_update_us = Some(elapsed_us);
6751 self.mark_dirty();
6752 self.execute_cmd(cmd)?;
6753 if self.running && self.dirty {
6754 self.reconcile_subscriptions();
6755 }
6756 Ok(())
6757 }
6758
6759 pub fn model(&self) -> &M {
6763 &self.model
6764 }
6765
6766 pub fn model_mut(&mut self) -> &mut M {
6768 &mut self.model
6769 }
6770
6771 pub fn is_running(&self) -> bool {
6773 self.running
6774 }
6775
6776 #[must_use]
6778 pub const fn tick_rate(&self) -> Option<Duration> {
6779 self.tick_rate
6780 }
6781
6782 #[must_use]
6784 pub const fn executed_cmd_count(&self) -> usize {
6785 self.executed_cmd_count
6786 }
6787
6788 pub fn quit(&mut self) {
6790 self.running = false;
6791 }
6792
6793 pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
6795 self.state_registry.as_ref()
6796 }
6797
6798 pub fn has_persistence(&self) -> bool {
6800 self.state_registry.is_some()
6801 }
6802
6803 #[must_use]
6809 pub fn tick_strategy_stats(&self) -> Vec<(String, String)> {
6810 self.tick_strategy
6811 .as_ref()
6812 .map(|s| s.debug_stats())
6813 .unwrap_or_default()
6814 }
6815
6816 pub fn trigger_save(&mut self) -> StorageResult<bool> {
6821 if let Some(registry) = &self.state_registry {
6822 registry.flush()
6823 } else {
6824 Ok(false)
6825 }
6826 }
6827
6828 pub fn trigger_load(&mut self) -> StorageResult<usize> {
6833 if let Some(registry) = &self.state_registry {
6834 registry.load()
6835 } else {
6836 Ok(0)
6837 }
6838 }
6839
6840 fn mark_dirty(&mut self) {
6841 self.dirty = true;
6842 }
6843
6844 fn check_locale_change(&mut self) {
6845 let version = self.locale_context.version();
6846 if version != self.locale_version {
6847 self.locale_version = version;
6848 self.mark_dirty();
6849 }
6850 }
6851
6852 pub fn request_redraw(&mut self) {
6854 self.mark_dirty();
6855 }
6856
6857 pub fn request_ui_height_remeasure(&mut self) {
6859 if self.writer.inline_auto_bounds().is_some() {
6860 self.writer.clear_auto_ui_height();
6861 if let Some(state) = self.inline_auto_remeasure.as_mut() {
6862 state.reset();
6863 }
6864 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
6865 self.mark_dirty();
6866 }
6867 }
6868
6869 pub fn start_recording(&mut self, name: impl Into<String>) {
6874 let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
6875 recorder.start();
6876 self.event_recorder = Some(recorder);
6877 }
6878
6879 pub fn stop_recording(&mut self) -> Option<InputMacro> {
6883 self.event_recorder.take().map(EventRecorder::finish)
6884 }
6885
6886 pub fn is_recording(&self) -> bool {
6888 self.event_recorder
6889 .as_ref()
6890 .is_some_and(EventRecorder::is_recording)
6891 }
6892}
6893
6894pub struct App;
6896
6897impl App {
6898 #[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
6901 AppBuilder {
6902 model,
6903 config: ProgramConfig::default(),
6904 }
6905 }
6906
6907 pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
6909 AppBuilder {
6910 model,
6911 config: ProgramConfig::fullscreen(),
6912 }
6913 }
6914
6915 pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
6917 AppBuilder {
6918 model,
6919 config: ProgramConfig::inline(height),
6920 }
6921 }
6922
6923 pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
6925 AppBuilder {
6926 model,
6927 config: ProgramConfig::inline_auto(min_height, max_height),
6928 }
6929 }
6930
6931 pub fn string_model<S: crate::string_model::StringModel>(
6936 model: S,
6937 ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
6938 AppBuilder {
6939 model: crate::string_model::StringModelAdapter::new(model),
6940 config: ProgramConfig::fullscreen(),
6941 }
6942 }
6943}
6944
6945#[must_use]
6947pub struct AppBuilder<M: Model> {
6948 model: M,
6949 config: ProgramConfig,
6950}
6951
6952impl<M: Model> AppBuilder<M> {
6953 pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
6955 self.config.screen_mode = mode;
6956 self
6957 }
6958
6959 pub fn anchor(mut self, anchor: UiAnchor) -> Self {
6961 self.config.ui_anchor = anchor;
6962 self
6963 }
6964
6965 pub fn with_mouse(mut self) -> Self {
6967 self.config.mouse_capture_policy = MouseCapturePolicy::On;
6968 self
6969 }
6970
6971 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
6973 self.config.mouse_capture_policy = policy;
6974 self
6975 }
6976
6977 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
6979 self.config.mouse_capture_policy = if enabled {
6980 MouseCapturePolicy::On
6981 } else {
6982 MouseCapturePolicy::Off
6983 };
6984 self
6985 }
6986
6987 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
6989 self.config.budget = budget;
6990 self
6991 }
6992
6993 pub fn with_load_governor(mut self, config: LoadGovernorConfig) -> Self {
6995 self.config.load_governor = config;
6996 self
6997 }
6998
6999 pub fn without_load_governor(mut self) -> Self {
7001 self.config.load_governor = LoadGovernorConfig::disabled();
7002 self
7003 }
7004
7005 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
7007 self.config.evidence_sink = config;
7008 self
7009 }
7010
7011 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
7013 self.config.render_trace = config;
7014 self
7015 }
7016
7017 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
7019 self.config.widget_refresh = config;
7020 self
7021 }
7022
7023 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
7025 self.config.effect_queue = config;
7026 self
7027 }
7028
7029 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
7031 self.config.inline_auto_remeasure = Some(config);
7032 self
7033 }
7034
7035 pub fn without_inline_auto_remeasure(mut self) -> Self {
7037 self.config.inline_auto_remeasure = None;
7038 self
7039 }
7040
7041 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
7043 self.config.locale_context = locale_context;
7044 self
7045 }
7046
7047 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
7049 self.config.locale_context = LocaleContext::new(locale);
7050 self
7051 }
7052
7053 pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
7055 self.config.resize_coalescer = config;
7056 self
7057 }
7058
7059 pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
7061 self.config.resize_behavior = behavior;
7062 self
7063 }
7064
7065 pub fn legacy_resize(mut self, enabled: bool) -> Self {
7067 if enabled {
7068 self.config.resize_behavior = ResizeBehavior::Immediate;
7069 }
7070 self
7071 }
7072
7073 pub fn tick_strategy(mut self, strategy: crate::tick_strategy::TickStrategyKind) -> Self {
7075 self.config.tick_strategy = Some(strategy);
7076 self
7077 }
7078
7079 #[cfg(feature = "crossterm-compat")]
7081 pub fn run(self) -> io::Result<()>
7082 where
7083 M::Message: Send + 'static,
7084 {
7085 let mut program = Program::with_config(self.model, self.config)?;
7086 let result = program.run();
7087 if let Err(ref err) = result
7088 && let Some(signal) = signal_termination_from_error(err)
7089 {
7090 drop(program);
7091 std::process::exit(128 + signal);
7092 }
7093 result
7094 }
7095
7096 #[cfg(all(feature = "native-backend", unix))]
7098 pub fn run_native(self) -> io::Result<()>
7099 where
7100 M::Message: Send + 'static,
7101 {
7102 let mut program = Program::with_native_backend(self.model, self.config)?;
7103 let result = program.run();
7104 if let Err(ref err) = result
7105 && let Some(signal) = signal_termination_from_error(err)
7106 {
7107 drop(program);
7108 std::process::exit(128 + signal);
7109 }
7110 result
7111 }
7112
7113 #[cfg(not(feature = "crossterm-compat"))]
7115 pub fn run(self) -> io::Result<()>
7116 where
7117 M::Message: Send + 'static,
7118 {
7119 let _ = (self.model, self.config);
7120 Err(io::Error::new(
7121 io::ErrorKind::Unsupported,
7122 "enable `crossterm-compat` feature to use AppBuilder::run()",
7123 ))
7124 }
7125
7126 #[cfg(any(not(feature = "native-backend"), not(unix)))]
7131 pub fn run_native(self) -> io::Result<()>
7132 where
7133 M::Message: Send + 'static,
7134 {
7135 let _ = (self.model, self.config);
7136 #[cfg(not(unix))]
7142 let msg = "AppBuilder::run_native() is Unix-only; use AppBuilder::run() (crossterm-compat) on this platform";
7143 #[cfg(all(unix, not(feature = "native-backend")))]
7144 let msg = "enable `native-backend` feature to use AppBuilder::run_native()";
7145 Err(io::Error::new(io::ErrorKind::Unsupported, msg))
7146 }
7147}
7148
7149#[derive(Debug, Clone)]
7218pub struct BatchController {
7219 ema_inter_arrival_s: f64,
7221 ema_service_s: f64,
7223 alpha: f64,
7225 tau_min_s: f64,
7227 tau_max_s: f64,
7229 headroom: f64,
7231 last_arrival: Option<Instant>,
7233 observations: u64,
7235}
7236
7237impl BatchController {
7238 pub fn new() -> Self {
7245 Self {
7246 ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
7249 tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
7252 last_arrival: None,
7253 observations: 0,
7254 }
7255 }
7256
7257 pub fn observe_arrival(&mut self, now: Instant) {
7259 if let Some(last) = self.last_arrival {
7260 let dt = now.saturating_duration_since(last).as_secs_f64();
7261 if dt > 0.0 && dt < 10.0 {
7262 self.ema_inter_arrival_s =
7264 self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
7265 self.observations += 1;
7266 }
7267 }
7268 self.last_arrival = Some(now);
7269 }
7270
7271 pub fn observe_service(&mut self, duration: Duration) {
7273 let dt = duration.as_secs_f64();
7274 if (0.0..10.0).contains(&dt) {
7275 self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
7276 }
7277 }
7278
7279 #[inline]
7281 pub fn lambda_est(&self) -> f64 {
7282 if self.ema_inter_arrival_s > 0.0 {
7283 1.0 / self.ema_inter_arrival_s
7284 } else {
7285 0.0
7286 }
7287 }
7288
7289 #[inline]
7291 pub fn service_est_s(&self) -> f64 {
7292 self.ema_service_s
7293 }
7294
7295 #[inline]
7297 pub fn rho_est(&self) -> f64 {
7298 self.lambda_est() * self.ema_service_s
7299 }
7300
7301 pub fn tau_s(&self) -> f64 {
7307 let base = self.ema_service_s * self.headroom;
7308 base.clamp(self.tau_min_s, self.tau_max_s)
7309 }
7310
7311 pub fn tau(&self) -> Duration {
7313 Duration::from_secs_f64(self.tau_s())
7314 }
7315
7316 #[inline]
7318 pub fn is_stable(&self) -> bool {
7319 self.rho_est() < 1.0
7320 }
7321
7322 #[inline]
7324 pub fn observations(&self) -> u64 {
7325 self.observations
7326 }
7327}
7328
7329impl Default for BatchController {
7330 fn default() -> Self {
7331 Self::new()
7332 }
7333}
7334
7335#[cfg(test)]
7336mod tests {
7337 use super::*;
7338 use ftui_core::terminal_capabilities::TerminalCapabilities;
7339 use ftui_layout::PaneDragResizeEffect;
7340 use ftui_render::buffer::Buffer;
7341 use ftui_render::cell::Cell;
7342 use ftui_render::diff_strategy::DiffStrategy;
7343 use ftui_render::frame::CostEstimateSource;
7344 use ftui_render::frame_guardrails::{MemoryBudgetConfig, QueueConfig};
7345 use serde_json::Value;
7346 use std::collections::{HashMap, VecDeque};
7347 use std::path::PathBuf;
7348 use std::sync::mpsc;
7349 use std::sync::{
7350 Arc,
7351 atomic::{AtomicUsize, Ordering},
7352 };
7353
7354 struct TestModel {
7356 value: i32,
7357 }
7358
7359 #[derive(Debug)]
7360 enum TestMsg {
7361 Increment,
7362 Decrement,
7363 Quit,
7364 }
7365
7366 impl From<Event> for TestMsg {
7367 fn from(_event: Event) -> Self {
7368 TestMsg::Increment
7369 }
7370 }
7371
7372 impl Model for TestModel {
7373 type Message = TestMsg;
7374
7375 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7376 match msg {
7377 TestMsg::Increment => {
7378 self.value += 1;
7379 Cmd::none()
7380 }
7381 TestMsg::Decrement => {
7382 self.value -= 1;
7383 Cmd::none()
7384 }
7385 TestMsg::Quit => Cmd::quit(),
7386 }
7387 }
7388
7389 fn view(&self, _frame: &mut Frame) {
7390 }
7392 }
7393
7394 #[test]
7395 fn cmd_none() {
7396 let cmd: Cmd<TestMsg> = Cmd::none();
7397 assert!(matches!(cmd, Cmd::None));
7398 }
7399
7400 #[test]
7401 fn cmd_quit() {
7402 let cmd: Cmd<TestMsg> = Cmd::quit();
7403 assert!(matches!(cmd, Cmd::Quit));
7404 }
7405
7406 #[test]
7407 fn cmd_msg() {
7408 let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
7409 assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
7410 }
7411
7412 #[test]
7413 fn cmd_batch_empty() {
7414 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
7415 assert!(matches!(cmd, Cmd::None));
7416 }
7417
7418 #[test]
7419 fn cmd_batch_single() {
7420 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
7421 assert!(matches!(cmd, Cmd::Quit));
7422 }
7423
7424 #[test]
7425 fn cmd_batch_multiple() {
7426 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
7427 assert!(matches!(cmd, Cmd::Batch(_)));
7428 }
7429
7430 #[test]
7431 fn cmd_sequence_empty() {
7432 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
7433 assert!(matches!(cmd, Cmd::None));
7434 }
7435
7436 #[test]
7437 fn cmd_tick() {
7438 let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
7439 assert!(matches!(cmd, Cmd::Tick(_)));
7440 }
7441
7442 #[test]
7443 fn cmd_task() {
7444 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7445 assert!(matches!(cmd, Cmd::Task(..)));
7446 }
7447
7448 #[test]
7449 fn cmd_debug_format() {
7450 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
7451 let debug = format!("{cmd:?}");
7452 assert_eq!(
7453 debug,
7454 "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
7455 );
7456 }
7457
7458 #[test]
7459 fn model_subscriptions_default_empty() {
7460 let model = TestModel { value: 0 };
7461 let subs = model.subscriptions();
7462 assert!(subs.is_empty());
7463 }
7464
7465 #[test]
7466 fn program_config_default() {
7467 let config = ProgramConfig::default();
7468 assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
7469 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7470 assert!(!config.resolved_mouse_capture());
7471 assert!(config.bracketed_paste);
7472 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
7473 assert!(config.inline_auto_remeasure.is_none());
7474 assert!(config.conformal_config.is_none());
7475 assert!(config.diff_config.bayesian_enabled);
7476 assert!(config.diff_config.dirty_rows_enabled);
7477 assert!(!config.resize_coalescer.enable_bocpd);
7478 assert!(!config.effect_queue.enabled);
7479 assert_eq!(config.immediate_drain.max_zero_timeout_polls_per_burst, 64);
7480 assert_eq!(
7481 config.immediate_drain.max_burst_duration,
7482 Duration::from_millis(2)
7483 );
7484 assert_eq!(
7485 config.immediate_drain.backoff_timeout,
7486 Duration::from_millis(1)
7487 );
7488 assert_eq!(
7489 config.resize_coalescer.steady_delay_ms,
7490 CoalescerConfig::default().steady_delay_ms
7491 );
7492 }
7493
7494 #[test]
7495 fn program_config_with_immediate_drain() {
7496 let custom = ImmediateDrainConfig {
7497 max_zero_timeout_polls_per_burst: 7,
7498 max_burst_duration: Duration::from_millis(9),
7499 backoff_timeout: Duration::from_millis(3),
7500 };
7501 let config = ProgramConfig::default().with_immediate_drain(custom.clone());
7502 assert_eq!(
7503 config.immediate_drain.max_zero_timeout_polls_per_burst,
7504 custom.max_zero_timeout_polls_per_burst
7505 );
7506 assert_eq!(
7507 config.immediate_drain.max_burst_duration,
7508 custom.max_burst_duration
7509 );
7510 assert_eq!(
7511 config.immediate_drain.backoff_timeout,
7512 custom.backoff_timeout
7513 );
7514 }
7515
7516 #[test]
7517 fn program_config_fullscreen() {
7518 let config = ProgramConfig::fullscreen();
7519 assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
7520 }
7521
7522 #[test]
7523 fn program_config_inline() {
7524 let config = ProgramConfig::inline(10);
7525 assert!(matches!(
7526 config.screen_mode,
7527 ScreenMode::Inline { ui_height: 10 }
7528 ));
7529 }
7530
7531 #[test]
7532 fn program_config_inline_auto() {
7533 let config = ProgramConfig::inline_auto(3, 9);
7534 assert!(matches!(
7535 config.screen_mode,
7536 ScreenMode::InlineAuto {
7537 min_height: 3,
7538 max_height: 9
7539 }
7540 ));
7541 assert!(config.inline_auto_remeasure.is_some());
7542 }
7543
7544 #[test]
7545 fn program_config_with_mouse() {
7546 let config = ProgramConfig::default().with_mouse();
7547 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7548 assert!(config.resolved_mouse_capture());
7549 }
7550
7551 #[cfg(feature = "native-backend")]
7552 #[test]
7553 fn sanitize_backend_features_disables_unsupported_features() {
7554 let requested = BackendFeatures {
7555 mouse_capture: true,
7556 bracketed_paste: true,
7557 focus_events: true,
7558 kitty_keyboard: true,
7559 };
7560 let sanitized =
7561 sanitize_backend_features_for_capabilities(requested, &TerminalCapabilities::basic());
7562 assert_eq!(sanitized, BackendFeatures::default());
7563 }
7564
7565 #[cfg(feature = "native-backend")]
7566 #[test]
7567 fn sanitize_backend_features_is_conservative_in_wezterm_mux() {
7568 let requested = BackendFeatures {
7569 mouse_capture: true,
7570 bracketed_paste: true,
7571 focus_events: true,
7572 kitty_keyboard: true,
7573 };
7574 let caps = TerminalCapabilities::builder()
7575 .mouse_sgr(true)
7576 .bracketed_paste(true)
7577 .focus_events(true)
7578 .kitty_keyboard(true)
7579 .in_wezterm_mux(true)
7580 .build();
7581 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7582
7583 assert!(sanitized.mouse_capture);
7584 assert!(sanitized.bracketed_paste);
7585 assert!(!sanitized.focus_events);
7586 assert!(!sanitized.kitty_keyboard);
7587 }
7588
7589 #[cfg(feature = "native-backend")]
7590 #[test]
7591 fn sanitize_backend_features_is_conservative_in_tmux() {
7592 let requested = BackendFeatures {
7593 mouse_capture: true,
7594 bracketed_paste: true,
7595 focus_events: true,
7596 kitty_keyboard: true,
7597 };
7598 let caps = TerminalCapabilities::builder()
7599 .mouse_sgr(true)
7600 .bracketed_paste(true)
7601 .focus_events(true)
7602 .kitty_keyboard(true)
7603 .in_tmux(true)
7604 .build();
7605 let sanitized = sanitize_backend_features_for_capabilities(requested, &caps);
7606
7607 assert!(sanitized.mouse_capture);
7608 assert!(sanitized.bracketed_paste);
7609 assert!(!sanitized.focus_events);
7610 assert!(!sanitized.kitty_keyboard);
7611 }
7612
7613 #[test]
7614 fn program_config_mouse_policy_auto_altscreen() {
7615 let config = ProgramConfig::fullscreen();
7616 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
7617 assert!(config.resolved_mouse_capture());
7618 }
7619
7620 #[test]
7621 fn program_config_mouse_policy_force_off() {
7622 let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
7623 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
7624 assert!(!config.resolved_mouse_capture());
7625 }
7626
7627 #[test]
7628 fn program_config_mouse_policy_force_on_inline() {
7629 let config = ProgramConfig::inline(6).with_mouse_enabled(true);
7630 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
7631 assert!(config.resolved_mouse_capture());
7632 }
7633
7634 fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
7635 PaneResizeTarget {
7636 split_id: ftui_layout::PaneId::MIN,
7637 axis,
7638 }
7639 }
7640
7641 fn pane_id(raw: u64) -> ftui_layout::PaneId {
7642 ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
7643 }
7644
7645 fn nested_pane_tree() -> ftui_layout::PaneTree {
7646 let root = pane_id(1);
7647 let left = pane_id(2);
7648 let right_split = pane_id(3);
7649 let right_top = pane_id(4);
7650 let right_bottom = pane_id(5);
7651 let snapshot = ftui_layout::PaneTreeSnapshot {
7652 schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
7653 root,
7654 next_id: pane_id(6),
7655 nodes: vec![
7656 ftui_layout::PaneNodeRecord::split(
7657 root,
7658 None,
7659 ftui_layout::PaneSplit {
7660 axis: SplitAxis::Horizontal,
7661 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7662 first: left,
7663 second: right_split,
7664 },
7665 ),
7666 ftui_layout::PaneNodeRecord::leaf(
7667 left,
7668 Some(root),
7669 ftui_layout::PaneLeaf::new("left"),
7670 ),
7671 ftui_layout::PaneNodeRecord::split(
7672 right_split,
7673 Some(root),
7674 ftui_layout::PaneSplit {
7675 axis: SplitAxis::Vertical,
7676 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
7677 first: right_top,
7678 second: right_bottom,
7679 },
7680 ),
7681 ftui_layout::PaneNodeRecord::leaf(
7682 right_top,
7683 Some(right_split),
7684 ftui_layout::PaneLeaf::new("right_top"),
7685 ),
7686 ftui_layout::PaneNodeRecord::leaf(
7687 right_bottom,
7688 Some(right_split),
7689 ftui_layout::PaneLeaf::new("right_bottom"),
7690 ),
7691 ],
7692 extensions: std::collections::BTreeMap::new(),
7693 };
7694 ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
7695 }
7696
7697 fn root_first_share_bps(tree: &ftui_layout::PaneTree, split: ftui_layout::PaneId) -> u32 {
7699 match &tree.node(split).expect("split node present").kind {
7700 PaneNodeKind::Split(node) => {
7701 node.ratio.numerator() * 10_000
7702 / (node.ratio.numerator() + node.ratio.denominator())
7703 }
7704 other => panic!("expected split node, got {other:?}"),
7705 }
7706 }
7707
7708 fn fixed_neutral_pressure() -> PanePressureSnapProfile {
7710 PanePressureSnapProfile {
7711 strength_bps: 5_000,
7712 hysteresis_bps: 100,
7713 }
7714 }
7715
7716 fn apply_dispatch_fixed(
7724 tree: &mut ftui_layout::PaneTree,
7725 layout: &ftui_layout::PaneLayout,
7726 dispatch: &PaneTerminalDispatch,
7727 pressure: PanePressureSnapProfile,
7728 seed: &mut u64,
7729 ) -> usize {
7730 let Some(transition) = dispatch.primary_transition.as_ref() else {
7731 return 0;
7732 };
7733 let ops = tree.operations_for_transition(transition, layout, pressure);
7734 let applied = ops.len();
7735 for op in ops {
7736 tree.apply_operation(*seed, op).expect("operation applies");
7737 *seed += 1;
7738 }
7739 applied
7740 }
7741
7742 #[allow(clippy::too_many_arguments)]
7749 fn drive_horizontal_drag_fixed(
7750 adapter: &mut PaneTerminalAdapter,
7751 tree: &mut ftui_layout::PaneTree,
7752 layout: &ftui_layout::PaneLayout,
7753 target: PaneResizeTarget,
7754 down_x: u16,
7755 y: u16,
7756 drag_xs: &[u16],
7757 pressure: PanePressureSnapProfile,
7758 seed: &mut u64,
7759 ) -> usize {
7760 let down = Event::Mouse(MouseEvent::new(
7761 MouseEventKind::Down(MouseButton::Left),
7762 down_x,
7763 y,
7764 ));
7765 let mut applied = apply_dispatch_fixed(
7766 tree,
7767 layout,
7768 &adapter.translate(&down, Some(target)),
7769 pressure,
7770 seed,
7771 );
7772
7773 let last = drag_xs.len().saturating_sub(1);
7774 for (idx, &x) in drag_xs.iter().enumerate() {
7775 let kind = if idx == last {
7776 MouseEventKind::Up(MouseButton::Left)
7777 } else {
7778 MouseEventKind::Drag(MouseButton::Left)
7779 };
7780 let event = Event::Mouse(MouseEvent::new(kind, x, y));
7781 applied += apply_dispatch_fixed(
7782 tree,
7783 layout,
7784 &adapter.translate(&event, None),
7785 pressure,
7786 seed,
7787 );
7788 }
7789 applied
7790 }
7791
7792 #[test]
7793 fn pane_terminal_splitter_resolution_is_deterministic() {
7794 let tree = nested_pane_tree();
7795 let layout = tree
7796 .solve_layout(Rect::new(0, 0, 50, 20))
7797 .expect("layout should solve");
7798 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7799 assert_eq!(handles.len(), 2);
7800
7801 let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
7804 .expect("overlap cell should resolve");
7805 assert_eq!(overlap.split_id, pane_id(1));
7806 assert_eq!(overlap.axis, SplitAxis::Horizontal);
7807
7808 let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
7809 .expect("right split should resolve");
7810 assert_eq!(right_only.split_id, pane_id(3));
7811 assert_eq!(right_only.axis, SplitAxis::Vertical);
7812 }
7813
7814 #[test]
7815 fn pane_terminal_splitter_hits_register_and_decode_target() {
7816 let tree = nested_pane_tree();
7817 let layout = tree
7818 .solve_layout(Rect::new(0, 0, 50, 20))
7819 .expect("layout should solve");
7820 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
7821
7822 let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
7823 let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
7824 let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
7825 assert_eq!(registered, handles.len());
7826
7827 let root_hit = frame
7828 .hit_test(25, 2)
7829 .expect("root splitter should be hittable");
7830 assert_eq!(root_hit.1, HitRegion::Handle);
7831 let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
7832 assert_eq!(root_target.split_id, pane_id(1));
7833 assert_eq!(root_target.axis, SplitAxis::Horizontal);
7834
7835 let right_hit = frame
7836 .hit_test(40, 10)
7837 .expect("right splitter should be hittable");
7838 assert_eq!(right_hit.1, HitRegion::Handle);
7839 let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
7840 assert_eq!(right_target.split_id, pane_id(3));
7841 assert_eq!(right_target.axis, SplitAxis::Vertical);
7842 }
7843
7844 #[test]
7845 fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
7846 let mut adapter =
7847 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7848 let target = pane_target(SplitAxis::Horizontal);
7849
7850 let down = Event::Mouse(MouseEvent::new(
7851 MouseEventKind::Down(MouseButton::Left),
7852 10,
7853 4,
7854 ));
7855 let down_dispatch = adapter.translate(&down, Some(target));
7856 let down_event = down_dispatch
7857 .primary_event
7858 .as_ref()
7859 .expect("pointer down semantic event");
7860 assert_eq!(down_event.sequence, 1);
7861 assert!(matches!(
7862 down_event.kind,
7863 PaneSemanticInputEventKind::PointerDown {
7864 target: actual_target,
7865 pointer_id: 1,
7866 button: PanePointerButton::Primary,
7867 position
7868 } if actual_target == target && position == PanePointerPosition::new(10, 4)
7869 ));
7870 assert!(down_event.validate().is_ok());
7871
7872 let drag = Event::Mouse(MouseEvent::new(
7873 MouseEventKind::Drag(MouseButton::Left),
7874 14,
7875 4,
7876 ));
7877 let drag_dispatch = adapter.translate(&drag, None);
7878 let drag_event = drag_dispatch
7879 .primary_event
7880 .as_ref()
7881 .expect("pointer move semantic event");
7882 assert_eq!(drag_event.sequence, 2);
7883 assert!(matches!(
7884 drag_event.kind,
7885 PaneSemanticInputEventKind::PointerMove {
7886 target: actual_target,
7887 pointer_id: 1,
7888 position,
7889 delta_x: 4,
7890 delta_y: 0
7891 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7892 ));
7893 let drag_motion = drag_dispatch
7894 .motion
7895 .expect("drag should emit motion metadata");
7896 assert_eq!(drag_motion.delta_x, 4);
7897 assert_eq!(drag_motion.delta_y, 0);
7898 assert_eq!(drag_motion.direction_changes, 0);
7899 assert!(drag_motion.speed > 0.0);
7900 assert!(drag_dispatch.pressure_snap_profile().is_some());
7901
7902 let up = Event::Mouse(MouseEvent::new(
7903 MouseEventKind::Up(MouseButton::Left),
7904 14,
7905 4,
7906 ));
7907 let up_dispatch = adapter.translate(&up, None);
7908 let up_event = up_dispatch
7909 .primary_event
7910 .as_ref()
7911 .expect("pointer up semantic event");
7912 assert_eq!(up_event.sequence, 3);
7913 assert!(matches!(
7914 up_event.kind,
7915 PaneSemanticInputEventKind::PointerUp {
7916 target: actual_target,
7917 pointer_id: 1,
7918 button: PanePointerButton::Primary,
7919 position
7920 } if actual_target == target && position == PanePointerPosition::new(14, 4)
7921 ));
7922 let up_motion = up_dispatch
7923 .motion
7924 .expect("up should emit final motion metadata");
7925 assert_eq!(up_motion.delta_x, 4);
7926 assert_eq!(up_motion.delta_y, 0);
7927 assert_eq!(up_motion.direction_changes, 0);
7928 let inertial_throw = up_dispatch
7929 .inertial_throw
7930 .expect("up should emit inertial throw metadata");
7931 assert_eq!(
7932 up_dispatch.projected_position,
7933 Some(inertial_throw.projected_pointer(PanePointerPosition::new(14, 4)))
7934 );
7935 assert_eq!(adapter.active_pointer_id(), None);
7936 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
7937 }
7938
7939 #[test]
7940 fn pane_terminal_adapter_drives_live_tree_mutation() {
7941 fn apply_dispatch(
7949 tree: &mut ftui_layout::PaneTree,
7950 layout: &ftui_layout::PaneLayout,
7951 dispatch: &PaneTerminalDispatch,
7952 neutral: PanePressureSnapProfile,
7953 seed: &mut u64,
7954 ) -> usize {
7955 let Some(transition) = dispatch.primary_transition.as_ref() else {
7956 return 0;
7957 };
7958 let pressure = dispatch.pressure_snap_profile().unwrap_or(neutral);
7959 let ops = tree.operations_for_transition(transition, layout, pressure);
7960 let applied = ops.len();
7961 for op in ops {
7962 tree.apply_operation(*seed, op).expect("operation applies");
7963 *seed += 1;
7964 }
7965 applied
7966 }
7967
7968 let mut tree = nested_pane_tree();
7969 let layout = tree
7972 .solve_layout(Rect::new(0, 0, 50, 20))
7973 .expect("layout should solve");
7974 let root_split = pane_id(1);
7975 let target = PaneResizeTarget {
7976 split_id: root_split,
7977 axis: SplitAxis::Horizontal,
7978 };
7979 let neutral = PanePressureSnapProfile {
7980 strength_bps: 5_000,
7981 hysteresis_bps: 100,
7982 };
7983
7984 let before_share = root_first_share_bps(&tree, root_split);
7985
7986 let mut adapter =
7987 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
7988 let mut op_seed = 7_000u64;
7989 let mut geometry_transitions = 0usize;
7990
7991 let down = Event::Mouse(MouseEvent::new(
7993 MouseEventKind::Down(MouseButton::Left),
7994 25,
7995 10,
7996 ));
7997 let down_dispatch = adapter.translate(&down, Some(target));
7998 geometry_transitions +=
7999 apply_dispatch(&mut tree, &layout, &down_dispatch, neutral, &mut op_seed);
8000
8001 for x in [30u16, 36, 42] {
8003 let drag = Event::Mouse(MouseEvent::new(
8004 MouseEventKind::Drag(MouseButton::Left),
8005 x,
8006 10,
8007 ));
8008 let dispatch = adapter.translate(&drag, None);
8009 geometry_transitions +=
8010 apply_dispatch(&mut tree, &layout, &dispatch, neutral, &mut op_seed);
8011 }
8012
8013 let up = Event::Mouse(MouseEvent::new(
8015 MouseEventKind::Up(MouseButton::Left),
8016 42,
8017 10,
8018 ));
8019 let up_dispatch = adapter.translate(&up, None);
8020 geometry_transitions +=
8021 apply_dispatch(&mut tree, &layout, &up_dispatch, neutral, &mut op_seed);
8022
8023 assert!(
8024 geometry_transitions > 0,
8025 "drag lifecycle should yield at least one geometry-bearing transition"
8026 );
8027 let after_share = root_first_share_bps(&tree, root_split);
8028 assert!(
8029 after_share > before_share,
8030 "rightward splitter drag should grow the first child: after={after_share} before={before_share}"
8031 );
8032 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8033 }
8034
8035 #[test]
8036 fn pane_terminal_adapter_resize_interrupt_cancels_drag_and_preserves_tree() {
8037 let mut tree = nested_pane_tree();
8042 let layout = tree
8043 .solve_layout(Rect::new(0, 0, 50, 20))
8044 .expect("layout should solve");
8045 let root_split = pane_id(1);
8046 let target = PaneResizeTarget {
8047 split_id: root_split,
8048 axis: SplitAxis::Horizontal,
8049 };
8050 let pressure = fixed_neutral_pressure();
8051 let mut seed = 4_100u64;
8052
8053 let initial_hash = tree.state_hash();
8054
8055 let mut adapter =
8056 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8057
8058 let _ = apply_dispatch_fixed(
8060 &mut tree,
8061 &layout,
8062 &adapter.translate(
8063 &Event::Mouse(MouseEvent::new(
8064 MouseEventKind::Down(MouseButton::Left),
8065 25,
8066 10,
8067 )),
8068 Some(target),
8069 ),
8070 pressure,
8071 &mut seed,
8072 );
8073 for x in [30u16, 36] {
8074 let _ = apply_dispatch_fixed(
8075 &mut tree,
8076 &layout,
8077 &adapter.translate(
8078 &Event::Mouse(MouseEvent::new(
8079 MouseEventKind::Drag(MouseButton::Left),
8080 x,
8081 10,
8082 )),
8083 None,
8084 ),
8085 pressure,
8086 &mut seed,
8087 );
8088 }
8089 let mid_drag_hash = tree.state_hash();
8090 assert_ne!(
8091 mid_drag_hash, initial_hash,
8092 "drag should have mutated the tree before the interrupt"
8093 );
8094 assert!(tree.validate().is_ok());
8095 assert_eq!(adapter.active_pointer_id(), Some(1));
8096
8097 let resize_dispatch = adapter.translate(
8099 &Event::Resize {
8100 width: 60,
8101 height: 24,
8102 },
8103 None,
8104 );
8105 let cancel = resize_dispatch
8106 .primary_event
8107 .as_ref()
8108 .expect("resize interrupt should emit a cancel semantic event");
8109 assert!(matches!(
8110 cancel.kind,
8111 PaneSemanticInputEventKind::Cancel {
8112 target: Some(actual),
8113 reason: PaneCancelReason::Programmatic
8114 } if actual == target
8115 ));
8116 assert_eq!(
8117 resize_dispatch.log.phase,
8118 PaneTerminalLifecyclePhase::ResizeInterrupt
8119 );
8120 assert_eq!(
8121 resize_dispatch.log.outcome,
8122 PaneTerminalLogOutcome::SemanticForwarded
8123 );
8124
8125 let cancel_transition = resize_dispatch
8128 .primary_transition
8129 .as_ref()
8130 .expect("cancel transition present");
8131 let cancel_ops = tree.operations_for_transition(cancel_transition, &layout, pressure);
8132 assert!(
8133 cancel_ops.is_empty(),
8134 "cancel transition must not mutate the live tree"
8135 );
8136 assert_eq!(tree.state_hash(), mid_drag_hash);
8137 assert!(tree.validate().is_ok());
8138
8139 assert_eq!(adapter.active_pointer_id(), None);
8141 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8142
8143 let new_layout = tree
8145 .solve_layout(Rect::new(0, 0, 60, 24))
8146 .expect("layout should solve at new size");
8147 let before_reuse = tree.state_hash();
8148 let applied = drive_horizontal_drag_fixed(
8149 &mut adapter,
8150 &mut tree,
8151 &new_layout,
8152 target,
8153 30,
8154 12,
8155 &[36, 42, 48],
8156 pressure,
8157 &mut seed,
8158 );
8159 assert!(applied > 0, "fresh gesture should apply operations");
8160 assert_ne!(
8161 tree.state_hash(),
8162 before_reuse,
8163 "adapter must remain usable after a resize interrupt"
8164 );
8165 assert!(tree.validate().is_ok());
8166 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8167 }
8168
8169 #[test]
8170 fn pane_terminal_adapter_survives_resize_storm_and_is_deterministic() {
8171 fn run_storm() -> u64 {
8178 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8179 .expect("valid adapter");
8180 let mut tree = nested_pane_tree();
8181 let root_split = pane_id(1);
8182 let target = PaneResizeTarget {
8183 split_id: root_split,
8184 axis: SplitAxis::Horizontal,
8185 };
8186 let pressure = fixed_neutral_pressure();
8187 let mut seed = 5_200u64;
8188
8189 let sizes: [(u16, u16); 6] =
8191 [(50, 20), (80, 24), (40, 16), (120, 40), (30, 12), (100, 30)];
8192
8193 for (idx, &(w, h)) in sizes.iter().enumerate() {
8194 let layout = tree
8195 .solve_layout(Rect::new(0, 0, w, h))
8196 .expect("layout should solve under storm");
8197
8198 let down_x = (w / 5).max(2);
8201 let drag_x = (3 * w / 5).max(down_x + 2);
8202 let _ = apply_dispatch_fixed(
8203 &mut tree,
8204 &layout,
8205 &adapter.translate(
8206 &Event::Mouse(MouseEvent::new(
8207 MouseEventKind::Down(MouseButton::Left),
8208 down_x,
8209 h / 2,
8210 )),
8211 Some(target),
8212 ),
8213 pressure,
8214 &mut seed,
8215 );
8216 let _ = apply_dispatch_fixed(
8217 &mut tree,
8218 &layout,
8219 &adapter.translate(
8220 &Event::Mouse(MouseEvent::new(
8221 MouseEventKind::Drag(MouseButton::Left),
8222 drag_x,
8223 h / 2,
8224 )),
8225 None,
8226 ),
8227 pressure,
8228 &mut seed,
8229 );
8230 assert!(
8231 tree.validate().is_ok(),
8232 "tree invalid after drag at step {idx}"
8233 );
8234 let share = root_first_share_bps(&tree, root_split);
8235 assert!(
8236 share > 0 && share < 10_000,
8237 "split ratio escaped bounds at step {idx}: {share}"
8238 );
8239
8240 let resize = adapter.translate(
8242 &Event::Resize {
8243 width: w,
8244 height: h,
8245 },
8246 None,
8247 );
8248 if let Some(cancel_transition) = resize.primary_transition.as_ref() {
8249 let cancel_ops =
8250 tree.operations_for_transition(cancel_transition, &layout, pressure);
8251 assert!(
8252 cancel_ops.is_empty(),
8253 "resize cancel must not mutate the tree at step {idx}"
8254 );
8255 }
8256 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8257 assert_eq!(adapter.active_pointer_id(), None);
8258 assert!(
8259 tree.validate().is_ok(),
8260 "tree invalid after resize at step {idx}"
8261 );
8262 }
8263
8264 let (w, h) = (90u16, 30u16);
8266 let layout = tree
8267 .solve_layout(Rect::new(0, 0, w, h))
8268 .expect("final layout should solve");
8269 let _ = drive_horizontal_drag_fixed(
8270 &mut adapter,
8271 &mut tree,
8272 &layout,
8273 target,
8274 (w / 5).max(2),
8275 h / 2,
8276 &[w / 2, 3 * w / 5],
8277 pressure,
8278 &mut seed,
8279 );
8280 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8281 assert!(tree.validate().is_ok());
8282
8283 tree.state_hash()
8284 }
8285
8286 let first = run_storm();
8287 let second = run_storm();
8288 assert_eq!(
8289 first, second,
8290 "resize storm with repeated re-grabs must be deterministic"
8291 );
8292 }
8293
8294 #[test]
8295 fn pane_terminal_adapter_geometry_is_capability_invariant() {
8296 fn drag_under(
8305 over: ftui_core::capability_override::CapabilityOverride,
8306 ) -> (bool, bool, bool, u64) {
8307 ftui_core::capability_override::with_capability_override(over, || {
8308 let caps = ftui_core::capability_override::current_capabilities();
8309 let mut tree = nested_pane_tree();
8310 let layout = tree
8311 .solve_layout(Rect::new(0, 0, 50, 20))
8312 .expect("layout should solve");
8313 let target = PaneResizeTarget {
8314 split_id: pane_id(1),
8315 axis: SplitAxis::Horizontal,
8316 };
8317 let pressure = fixed_neutral_pressure();
8318 let mut seed = 6_300u64;
8319 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8320 .expect("valid adapter");
8321 let applied = drive_horizontal_drag_fixed(
8322 &mut adapter,
8323 &mut tree,
8324 &layout,
8325 target,
8326 25,
8327 10,
8328 &[30, 36, 42],
8329 pressure,
8330 &mut seed,
8331 );
8332 assert!(
8333 applied > 0,
8334 "drag should apply operations under every profile"
8335 );
8336 (
8337 caps.mouse_sgr,
8338 caps.supports_true_color(),
8339 caps.in_tmux,
8340 tree.state_hash(),
8341 )
8342 })
8343 }
8344
8345 let (dumb_sgr, dumb_truecolor, _dumb_tmux, dumb_hash) =
8346 drag_under(ftui_core::capability_override::CapabilityOverride::dumb());
8347 let (modern_sgr, modern_truecolor, modern_tmux, modern_hash) =
8348 drag_under(ftui_core::capability_override::CapabilityOverride::modern());
8349 let (_tmux_sgr, _tmux_truecolor, tmux_tmux, tmux_hash) =
8350 drag_under(ftui_core::capability_override::CapabilityOverride::tmux());
8351
8352 assert!(
8354 dumb_sgr != modern_sgr || dumb_truecolor != modern_truecolor,
8355 "dumb and modern profiles must differ in capabilities"
8356 );
8357 assert!(
8358 modern_tmux != tmux_tmux,
8359 "modern and tmux profiles must differ in the in_tmux flag"
8360 );
8361
8362 assert_eq!(
8364 dumb_hash, modern_hash,
8365 "geometry must not depend on terminal capabilities"
8366 );
8367 assert_eq!(
8368 modern_hash, tmux_hash,
8369 "geometry must not depend on terminal capabilities"
8370 );
8371 }
8372
8373 #[test]
8374 fn pane_terminal_adapter_dispatch_log_traces_routing_and_failures() {
8375 let mut adapter =
8380 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8381 let target = pane_target(SplitAxis::Horizontal);
8382
8383 let wrong_button = adapter.translate(
8385 &Event::Mouse(MouseEvent::new(
8386 MouseEventKind::Down(MouseButton::Right),
8387 5,
8388 5,
8389 )),
8390 Some(target),
8391 );
8392 assert_eq!(
8393 wrong_button.log.phase,
8394 PaneTerminalLifecyclePhase::MouseDown
8395 );
8396 assert_eq!(
8397 wrong_button.log.outcome,
8398 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ActivationButtonRequired)
8399 );
8400 assert!(wrong_button.primary_event.is_none());
8401 assert_eq!(adapter.active_pointer_id(), None);
8402
8403 let down = adapter.translate(
8406 &Event::Mouse(MouseEvent::new(
8407 MouseEventKind::Down(MouseButton::Left),
8408 25,
8409 10,
8410 )),
8411 Some(target),
8412 );
8413 assert_eq!(down.log.phase, PaneTerminalLifecyclePhase::MouseDown);
8414 assert_eq!(down.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8415 assert_eq!(down.log.target, Some(target));
8416 assert_eq!(down.log.pointer_id, Some(1));
8417 let down_seq = down.log.sequence.expect("down sequence");
8418
8419 let drag = adapter.translate(
8420 &Event::Mouse(MouseEvent::new(
8421 MouseEventKind::Drag(MouseButton::Left),
8422 31,
8423 10,
8424 )),
8425 None,
8426 );
8427 assert_eq!(drag.log.phase, PaneTerminalLifecyclePhase::MouseDrag);
8428 assert_eq!(drag.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8429 let drag_seq = drag.log.sequence.expect("drag sequence");
8430 assert!(drag_seq > down_seq, "sequence numbers must be monotonic");
8431
8432 let up = adapter.translate(
8433 &Event::Mouse(MouseEvent::new(
8434 MouseEventKind::Up(MouseButton::Left),
8435 31,
8436 10,
8437 )),
8438 None,
8439 );
8440 assert_eq!(up.log.phase, PaneTerminalLifecyclePhase::MouseUp);
8441 assert_eq!(up.log.outcome, PaneTerminalLogOutcome::SemanticForwarded);
8442 assert!(up.log.sequence.expect("up sequence") > drag_seq);
8443
8444 let idle_resize = adapter.translate(
8447 &Event::Resize {
8448 width: 80,
8449 height: 24,
8450 },
8451 None,
8452 );
8453 assert_eq!(
8454 idle_resize.log.phase,
8455 PaneTerminalLifecyclePhase::ResizeInterrupt
8456 );
8457 assert_eq!(
8458 idle_resize.log.outcome,
8459 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8460 );
8461 assert!(idle_resize.primary_event.is_none());
8462 }
8463
8464 #[test]
8465 fn pane_terminal_adapter_resize_preserves_gesture_when_cancel_disabled() {
8466 let config = PaneTerminalAdapterConfig {
8470 cancel_on_resize: false,
8471 ..PaneTerminalAdapterConfig::default()
8472 };
8473 let mut adapter = PaneTerminalAdapter::new(config).expect("valid adapter");
8474 let mut tree = nested_pane_tree();
8475 let layout = tree
8476 .solve_layout(Rect::new(0, 0, 50, 20))
8477 .expect("layout should solve");
8478 let root_split = pane_id(1);
8479 let target = PaneResizeTarget {
8480 split_id: root_split,
8481 axis: SplitAxis::Horizontal,
8482 };
8483 let pressure = fixed_neutral_pressure();
8484 let mut seed = 7_700u64;
8485
8486 let _ = apply_dispatch_fixed(
8488 &mut tree,
8489 &layout,
8490 &adapter.translate(
8491 &Event::Mouse(MouseEvent::new(
8492 MouseEventKind::Down(MouseButton::Left),
8493 20,
8494 10,
8495 )),
8496 Some(target),
8497 ),
8498 pressure,
8499 &mut seed,
8500 );
8501 let _ = apply_dispatch_fixed(
8502 &mut tree,
8503 &layout,
8504 &adapter.translate(
8505 &Event::Mouse(MouseEvent::new(
8506 MouseEventKind::Drag(MouseButton::Left),
8507 26,
8508 10,
8509 )),
8510 None,
8511 ),
8512 pressure,
8513 &mut seed,
8514 );
8515 assert_eq!(adapter.active_pointer_id(), Some(1));
8516
8517 let resize = adapter.translate(
8519 &Event::Resize {
8520 width: 50,
8521 height: 20,
8522 },
8523 None,
8524 );
8525 assert!(resize.primary_event.is_none());
8526 assert!(resize.primary_transition.is_none());
8527 assert_eq!(
8528 resize.log.outcome,
8529 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::ResizeNoop)
8530 );
8531 assert_eq!(
8532 adapter.active_pointer_id(),
8533 Some(1),
8534 "gesture must survive the resize when cancel_on_resize is disabled"
8535 );
8536
8537 let before_finish = tree.state_hash();
8539 for x in [32u16, 38] {
8540 let _ = apply_dispatch_fixed(
8541 &mut tree,
8542 &layout,
8543 &adapter.translate(
8544 &Event::Mouse(MouseEvent::new(
8545 MouseEventKind::Drag(MouseButton::Left),
8546 x,
8547 10,
8548 )),
8549 None,
8550 ),
8551 pressure,
8552 &mut seed,
8553 );
8554 }
8555 let _ = apply_dispatch_fixed(
8556 &mut tree,
8557 &layout,
8558 &adapter.translate(
8559 &Event::Mouse(MouseEvent::new(
8560 MouseEventKind::Up(MouseButton::Left),
8561 38,
8562 10,
8563 )),
8564 None,
8565 ),
8566 pressure,
8567 &mut seed,
8568 );
8569 assert_ne!(
8570 tree.state_hash(),
8571 before_finish,
8572 "the surviving gesture should keep mutating the tree"
8573 );
8574 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8575 assert!(tree.validate().is_ok());
8576 }
8577
8578 #[test]
8579 fn pane_terminal_adapter_focus_loss_emits_cancel() {
8580 let mut adapter =
8581 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8582 let target = pane_target(SplitAxis::Vertical);
8583
8584 let down = Event::Mouse(MouseEvent::new(
8585 MouseEventKind::Down(MouseButton::Left),
8586 3,
8587 9,
8588 ));
8589 let _ = adapter.translate(&down, Some(target));
8590 assert_eq!(adapter.active_pointer_id(), Some(1));
8591
8592 let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
8593 let cancel_event = cancel_dispatch
8594 .primary_event
8595 .as_ref()
8596 .expect("focus-loss cancel event");
8597 assert!(matches!(
8598 cancel_event.kind,
8599 PaneSemanticInputEventKind::Cancel {
8600 target: Some(actual_target),
8601 reason: PaneCancelReason::FocusLost
8602 } if actual_target == target
8603 ));
8604 assert_eq!(adapter.active_pointer_id(), None);
8605 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
8606 }
8607
8608 #[test]
8609 fn pane_terminal_adapter_recovers_missing_mouse_up() {
8610 let mut adapter =
8611 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8612 let first_target = pane_target(SplitAxis::Horizontal);
8613 let second_target = pane_target(SplitAxis::Vertical);
8614
8615 let first_down = Event::Mouse(MouseEvent::new(
8616 MouseEventKind::Down(MouseButton::Left),
8617 5,
8618 5,
8619 ));
8620 let _ = adapter.translate(&first_down, Some(first_target));
8621
8622 let second_down = Event::Mouse(MouseEvent::new(
8623 MouseEventKind::Down(MouseButton::Left),
8624 8,
8625 11,
8626 ));
8627 let dispatch = adapter.translate(&second_down, Some(second_target));
8628 let recovery = dispatch
8629 .recovery_event
8630 .as_ref()
8631 .expect("recovery cancel expected");
8632 assert!(matches!(
8633 recovery.kind,
8634 PaneSemanticInputEventKind::Cancel {
8635 target: Some(actual_target),
8636 reason: PaneCancelReason::PointerCancel
8637 } if actual_target == first_target
8638 ));
8639 let primary = dispatch
8640 .primary_event
8641 .as_ref()
8642 .expect("second pointer down expected");
8643 assert!(matches!(
8644 primary.kind,
8645 PaneSemanticInputEventKind::PointerDown {
8646 target: actual_target,
8647 pointer_id: 1,
8648 button: PanePointerButton::Primary,
8649 position
8650 } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
8651 ));
8652 assert_eq!(recovery.sequence, 2);
8653 assert_eq!(primary.sequence, 3);
8654 assert!(matches!(
8655 dispatch.log.outcome,
8656 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
8657 ));
8658 assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
8659 }
8660
8661 #[test]
8662 fn pane_terminal_adapter_modifier_parity() {
8663 let mut adapter =
8664 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8665 let target = pane_target(SplitAxis::Horizontal);
8666
8667 let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
8668 .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
8669 let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
8670 let event = dispatch.primary_event.expect("semantic event");
8671 assert!(event.modifiers.shift);
8672 assert!(event.modifiers.alt);
8673 assert!(event.modifiers.ctrl);
8674 assert!(event.modifiers.meta);
8675 }
8676
8677 #[test]
8678 fn pane_terminal_adapter_keyboard_resize_mapping() {
8679 let mut adapter =
8680 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8681 let target = pane_target(SplitAxis::Horizontal);
8682
8683 let key = KeyEvent::new(KeyCode::Right);
8684 let dispatch = adapter.translate(&Event::Key(key), Some(target));
8685 let event = dispatch.primary_event.expect("keyboard resize event");
8686 assert!(matches!(
8687 event.kind,
8688 PaneSemanticInputEventKind::KeyboardResize {
8689 target: actual_target,
8690 direction: PaneResizeDirection::Increase,
8691 units: 1
8692 } if actual_target == target
8693 ));
8694
8695 let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
8696 let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
8697 let shifted_event = shifted_dispatch
8698 .primary_event
8699 .expect("shifted resize event");
8700 assert!(matches!(
8701 shifted_event.kind,
8702 PaneSemanticInputEventKind::KeyboardResize {
8703 direction: PaneResizeDirection::Increase,
8704 units: 5,
8705 ..
8706 }
8707 ));
8708 assert!(shifted_event.modifiers.shift);
8709 }
8710
8711 #[test]
8712 fn pane_terminal_adapter_keyboard_resize_requires_focus() {
8713 let mut adapter =
8714 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8715 let target = pane_target(SplitAxis::Horizontal);
8716
8717 let _ = adapter.translate(&Event::Focus(false), None);
8718 assert!(!adapter.window_focused());
8719
8720 let unfocused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8721 assert!(unfocused.primary_event.is_none());
8722 assert!(matches!(
8723 unfocused.log.outcome,
8724 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::WindowNotFocused)
8725 ));
8726
8727 let _ = adapter.translate(&Event::Focus(true), None);
8728 assert!(adapter.window_focused());
8729
8730 let focused = adapter.translate(&Event::Key(KeyEvent::new(KeyCode::Right)), Some(target));
8731 assert!(focused.primary_event.is_some());
8732 }
8733
8734 #[test]
8735 fn pane_terminal_adapter_drag_updates_are_coalesced() {
8736 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
8737 drag_update_coalesce_distance: 2,
8738 ..PaneTerminalAdapterConfig::default()
8739 })
8740 .expect("valid adapter");
8741 let target = pane_target(SplitAxis::Horizontal);
8742
8743 let down = Event::Mouse(MouseEvent::new(
8744 MouseEventKind::Down(MouseButton::Left),
8745 10,
8746 4,
8747 ));
8748 let _ = adapter.translate(&down, Some(target));
8749
8750 let drag_start = Event::Mouse(MouseEvent::new(
8751 MouseEventKind::Drag(MouseButton::Left),
8752 14,
8753 4,
8754 ));
8755 let started = adapter.translate(&drag_start, None);
8756 assert!(started.primary_event.is_some());
8757 assert!(matches!(
8758 adapter.machine_state(),
8759 PaneDragResizeState::Dragging { .. }
8760 ));
8761
8762 let coalesced = Event::Mouse(MouseEvent::new(
8763 MouseEventKind::Drag(MouseButton::Left),
8764 15,
8765 4,
8766 ));
8767 let coalesced_dispatch = adapter.translate(&coalesced, None);
8768 assert!(coalesced_dispatch.primary_event.is_none());
8769 assert!(matches!(
8770 coalesced_dispatch.log.outcome,
8771 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
8772 ));
8773
8774 let forwarded = Event::Mouse(MouseEvent::new(
8775 MouseEventKind::Drag(MouseButton::Left),
8776 16,
8777 4,
8778 ));
8779 let forwarded_dispatch = adapter.translate(&forwarded, None);
8780 let forwarded_event = forwarded_dispatch
8781 .primary_event
8782 .as_ref()
8783 .expect("coalesced movement should flush once threshold reached");
8784 assert!(matches!(
8785 forwarded_event.kind,
8786 PaneSemanticInputEventKind::PointerMove {
8787 delta_x: 2,
8788 delta_y: 0,
8789 ..
8790 }
8791 ));
8792 }
8793
8794 #[test]
8795 fn pane_terminal_adapter_motion_tracks_direction_changes() {
8796 let mut adapter =
8797 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8798 let target = pane_target(SplitAxis::Horizontal);
8799
8800 let down = Event::Mouse(MouseEvent::new(
8801 MouseEventKind::Down(MouseButton::Left),
8802 10,
8803 4,
8804 ));
8805 let _ = adapter.translate(&down, Some(target));
8806
8807 let drag_forward = Event::Mouse(MouseEvent::new(
8808 MouseEventKind::Drag(MouseButton::Left),
8809 14,
8810 4,
8811 ));
8812 let forward_dispatch = adapter.translate(&drag_forward, None);
8813 let forward_motion = forward_dispatch
8814 .motion
8815 .expect("forward drag should emit motion metadata");
8816 assert_eq!(forward_motion.direction_changes, 0);
8817
8818 let drag_reverse = Event::Mouse(MouseEvent::new(
8819 MouseEventKind::Drag(MouseButton::Left),
8820 12,
8821 4,
8822 ));
8823 let reverse_dispatch = adapter.translate(&drag_reverse, None);
8824 let reverse_motion = reverse_dispatch
8825 .motion
8826 .expect("reverse drag should emit motion metadata");
8827 assert_eq!(reverse_motion.direction_changes, 1);
8828
8829 let up = Event::Mouse(MouseEvent::new(
8830 MouseEventKind::Up(MouseButton::Left),
8831 12,
8832 4,
8833 ));
8834 let up_dispatch = adapter.translate(&up, None);
8835 let up_motion = up_dispatch
8836 .motion
8837 .expect("release should include cumulative motion metadata");
8838 assert_eq!(up_motion.direction_changes, 1);
8839 }
8840
8841 #[test]
8842 fn pane_terminal_adapter_drag_and_moved_share_motion_bookkeeping() {
8843 let run = |moved: bool| {
8849 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default())
8850 .expect("valid adapter");
8851 let target = pane_target(SplitAxis::Horizontal);
8852 let down = Event::Mouse(MouseEvent::new(
8853 MouseEventKind::Down(MouseButton::Left),
8854 10,
8855 4,
8856 ));
8857 let _ = adapter.translate(&down, Some(target));
8858 let mut motions = Vec::new();
8859 for (x, y) in [(14u16, 4u16), (12, 4), (16, 4)] {
8860 let kind = if moved {
8861 MouseEventKind::Moved
8862 } else {
8863 MouseEventKind::Drag(MouseButton::Left)
8864 };
8865 let dispatch = adapter.translate(&Event::Mouse(MouseEvent::new(kind, x, y)), None);
8866 assert!(dispatch.primary_event.is_some());
8867 motions.push(dispatch.motion.expect("motion metadata present"));
8868 }
8869 motions
8870 };
8871
8872 let via_drag = run(false);
8873 let via_moved = run(true);
8874 assert_eq!(via_drag, via_moved);
8875 assert_eq!(via_drag[1].direction_changes, 1);
8877 }
8878
8879 #[test]
8880 fn pane_terminal_adapter_translate_with_handles_resolves_target() {
8881 let tree = nested_pane_tree();
8882 let layout = tree
8883 .solve_layout(Rect::new(0, 0, 50, 20))
8884 .expect("layout should solve");
8885 let handles =
8886 pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
8887 let mut adapter =
8888 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
8889
8890 let down = Event::Mouse(MouseEvent::new(
8891 MouseEventKind::Down(MouseButton::Left),
8892 25,
8893 10,
8894 ));
8895 let dispatch = adapter.translate_with_handles(&down, &handles);
8896 let event = dispatch
8897 .primary_event
8898 .as_ref()
8899 .expect("pointer down should be routed from handles");
8900 assert!(matches!(
8901 event.kind,
8902 PaneSemanticInputEventKind::PointerDown {
8903 target:
8904 PaneResizeTarget {
8905 split_id,
8906 axis: SplitAxis::Horizontal
8907 },
8908 ..
8909 } if split_id == pane_id(1)
8910 ));
8911 }
8912
8913 #[test]
8914 fn model_update() {
8915 let mut model = TestModel { value: 0 };
8916 model.update(TestMsg::Increment);
8917 assert_eq!(model.value, 1);
8918 model.update(TestMsg::Decrement);
8919 assert_eq!(model.value, 0);
8920 assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
8921 }
8922
8923 #[test]
8924 fn model_init_default() {
8925 let mut model = TestModel { value: 0 };
8926 let cmd = model.init();
8927 assert!(matches!(cmd, Cmd::None));
8928 }
8929
8930 #[test]
8937 fn cmd_sequence_executes_in_order() {
8938 use crate::simulator::ProgramSimulator;
8940
8941 struct SeqModel {
8942 trace: Vec<i32>,
8943 }
8944
8945 #[derive(Debug)]
8946 enum SeqMsg {
8947 Append(i32),
8948 TriggerSequence,
8949 }
8950
8951 impl From<Event> for SeqMsg {
8952 fn from(_: Event) -> Self {
8953 SeqMsg::Append(0)
8954 }
8955 }
8956
8957 impl Model for SeqModel {
8958 type Message = SeqMsg;
8959
8960 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8961 match msg {
8962 SeqMsg::Append(n) => {
8963 self.trace.push(n);
8964 Cmd::none()
8965 }
8966 SeqMsg::TriggerSequence => Cmd::sequence(vec![
8967 Cmd::msg(SeqMsg::Append(1)),
8968 Cmd::msg(SeqMsg::Append(2)),
8969 Cmd::msg(SeqMsg::Append(3)),
8970 ]),
8971 }
8972 }
8973
8974 fn view(&self, _frame: &mut Frame) {}
8975 }
8976
8977 let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
8978 sim.init();
8979 sim.send(SeqMsg::TriggerSequence);
8980
8981 assert_eq!(sim.model().trace, vec![1, 2, 3]);
8982 }
8983
8984 #[test]
8985 fn cmd_batch_executes_all_regardless_of_order() {
8986 use crate::simulator::ProgramSimulator;
8988
8989 struct BatchModel {
8990 values: Vec<i32>,
8991 }
8992
8993 #[derive(Debug)]
8994 enum BatchMsg {
8995 Add(i32),
8996 TriggerBatch,
8997 }
8998
8999 impl From<Event> for BatchMsg {
9000 fn from(_: Event) -> Self {
9001 BatchMsg::Add(0)
9002 }
9003 }
9004
9005 impl Model for BatchModel {
9006 type Message = BatchMsg;
9007
9008 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9009 match msg {
9010 BatchMsg::Add(n) => {
9011 self.values.push(n);
9012 Cmd::none()
9013 }
9014 BatchMsg::TriggerBatch => Cmd::batch(vec![
9015 Cmd::msg(BatchMsg::Add(10)),
9016 Cmd::msg(BatchMsg::Add(20)),
9017 Cmd::msg(BatchMsg::Add(30)),
9018 ]),
9019 }
9020 }
9021
9022 fn view(&self, _frame: &mut Frame) {}
9023 }
9024
9025 let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
9026 sim.init();
9027 sim.send(BatchMsg::TriggerBatch);
9028
9029 assert_eq!(sim.model().values.len(), 3);
9031 assert!(sim.model().values.contains(&10));
9032 assert!(sim.model().values.contains(&20));
9033 assert!(sim.model().values.contains(&30));
9034 }
9035
9036 #[test]
9037 fn cmd_sequence_stops_on_quit() {
9038 use crate::simulator::ProgramSimulator;
9040
9041 struct SeqQuitModel {
9042 trace: Vec<i32>,
9043 }
9044
9045 #[derive(Debug)]
9046 enum SeqQuitMsg {
9047 Append(i32),
9048 TriggerSequenceWithQuit,
9049 }
9050
9051 impl From<Event> for SeqQuitMsg {
9052 fn from(_: Event) -> Self {
9053 SeqQuitMsg::Append(0)
9054 }
9055 }
9056
9057 impl Model for SeqQuitModel {
9058 type Message = SeqQuitMsg;
9059
9060 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9061 match msg {
9062 SeqQuitMsg::Append(n) => {
9063 self.trace.push(n);
9064 Cmd::none()
9065 }
9066 SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
9067 Cmd::msg(SeqQuitMsg::Append(1)),
9068 Cmd::quit(),
9069 Cmd::msg(SeqQuitMsg::Append(2)), ]),
9071 }
9072 }
9073
9074 fn view(&self, _frame: &mut Frame) {}
9075 }
9076
9077 let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
9078 sim.init();
9079 sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
9080
9081 assert_eq!(sim.model().trace, vec![1]);
9082 assert!(!sim.is_running());
9083 }
9084
9085 #[test]
9086 fn identical_input_produces_identical_state() {
9087 use crate::simulator::ProgramSimulator;
9089
9090 fn run_scenario() -> Vec<i32> {
9091 struct DetModel {
9092 values: Vec<i32>,
9093 }
9094
9095 #[derive(Debug, Clone)]
9096 enum DetMsg {
9097 Add(i32),
9098 Double,
9099 }
9100
9101 impl From<Event> for DetMsg {
9102 fn from(_: Event) -> Self {
9103 DetMsg::Add(1)
9104 }
9105 }
9106
9107 impl Model for DetModel {
9108 type Message = DetMsg;
9109
9110 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9111 match msg {
9112 DetMsg::Add(n) => {
9113 self.values.push(n);
9114 Cmd::none()
9115 }
9116 DetMsg::Double => {
9117 if let Some(&last) = self.values.last() {
9118 self.values.push(last * 2);
9119 }
9120 Cmd::none()
9121 }
9122 }
9123 }
9124
9125 fn view(&self, _frame: &mut Frame) {}
9126 }
9127
9128 let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
9129 sim.init();
9130 sim.send(DetMsg::Add(5));
9131 sim.send(DetMsg::Double);
9132 sim.send(DetMsg::Add(3));
9133 sim.send(DetMsg::Double);
9134
9135 sim.model().values.clone()
9136 }
9137
9138 let run1 = run_scenario();
9140 let run2 = run_scenario();
9141 let run3 = run_scenario();
9142
9143 assert_eq!(run1, run2);
9144 assert_eq!(run2, run3);
9145 assert_eq!(run1, vec![5, 10, 3, 6]);
9146 }
9147
9148 #[test]
9149 fn identical_state_produces_identical_render() {
9150 use crate::simulator::ProgramSimulator;
9152
9153 struct RenderModel {
9154 counter: i32,
9155 }
9156
9157 #[derive(Debug)]
9158 enum RenderMsg {
9159 Set(i32),
9160 }
9161
9162 impl From<Event> for RenderMsg {
9163 fn from(_: Event) -> Self {
9164 RenderMsg::Set(0)
9165 }
9166 }
9167
9168 impl Model for RenderModel {
9169 type Message = RenderMsg;
9170
9171 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
9172 match msg {
9173 RenderMsg::Set(n) => {
9174 self.counter = n;
9175 Cmd::none()
9176 }
9177 }
9178 }
9179
9180 fn view(&self, frame: &mut Frame) {
9181 let text = format!("Value: {}", self.counter);
9182 for (i, c) in text.chars().enumerate() {
9183 if (i as u16) < frame.width() {
9184 use ftui_render::cell::Cell;
9185 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
9186 }
9187 }
9188 }
9189 }
9190
9191 let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
9193 let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
9194
9195 let buf1 = sim1.capture_frame(80, 24);
9196 let buf2 = sim2.capture_frame(80, 24);
9197
9198 for y in 0..24 {
9200 for x in 0..80 {
9201 let cell1 = buf1.get(x, y).unwrap();
9202 let cell2 = buf2.get(x, y).unwrap();
9203 assert_eq!(
9204 cell1.content.as_char(),
9205 cell2.content.as_char(),
9206 "Mismatch at ({}, {})",
9207 x,
9208 y
9209 );
9210 }
9211 }
9212 }
9213
9214 #[test]
9217 fn cmd_log_creates_log_command() {
9218 let cmd: Cmd<TestMsg> = Cmd::log("test message");
9219 assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
9220 }
9221
9222 #[test]
9223 fn cmd_log_from_string() {
9224 let msg = String::from("dynamic message");
9225 let cmd: Cmd<TestMsg> = Cmd::log(msg);
9226 assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
9227 }
9228
9229 #[test]
9230 fn program_simulator_logs_jsonl_with_seed_and_run_id() {
9231 use crate::simulator::ProgramSimulator;
9233
9234 struct LogModel {
9235 run_id: &'static str,
9236 seed: u64,
9237 }
9238
9239 #[derive(Debug)]
9240 enum LogMsg {
9241 Emit,
9242 }
9243
9244 impl From<Event> for LogMsg {
9245 fn from(_: Event) -> Self {
9246 LogMsg::Emit
9247 }
9248 }
9249
9250 impl Model for LogModel {
9251 type Message = LogMsg;
9252
9253 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9254 let line = format!(
9255 r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
9256 self.run_id, self.seed
9257 );
9258 Cmd::log(line)
9259 }
9260
9261 fn view(&self, _frame: &mut Frame) {}
9262 }
9263
9264 let mut sim = ProgramSimulator::new(LogModel {
9265 run_id: "test-run-001",
9266 seed: 4242,
9267 });
9268 sim.init();
9269 sim.send(LogMsg::Emit);
9270
9271 let logs = sim.logs();
9272 assert_eq!(logs.len(), 1);
9273 assert!(logs[0].contains(r#""run_id":"test-run-001""#));
9274 assert!(logs[0].contains(r#""seed":4242"#));
9275 }
9276
9277 #[test]
9278 fn cmd_sequence_single_unwraps() {
9279 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
9280 assert!(matches!(cmd, Cmd::Quit));
9282 }
9283
9284 #[test]
9285 fn cmd_sequence_multiple() {
9286 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
9287 assert!(matches!(cmd, Cmd::Sequence(_)));
9288 }
9289
9290 #[test]
9291 fn cmd_default_is_none() {
9292 let cmd: Cmd<TestMsg> = Cmd::default();
9293 assert!(matches!(cmd, Cmd::None));
9294 }
9295
9296 #[test]
9297 fn cmd_debug_all_variants() {
9298 let none: Cmd<TestMsg> = Cmd::none();
9300 assert_eq!(format!("{none:?}"), "None");
9301
9302 let quit: Cmd<TestMsg> = Cmd::quit();
9303 assert_eq!(format!("{quit:?}"), "Quit");
9304
9305 let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
9306 assert!(format!("{msg:?}").starts_with("Msg("));
9307
9308 let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
9309 assert!(format!("{batch:?}").starts_with("Batch("));
9310
9311 let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
9312 assert!(format!("{seq:?}").starts_with("Sequence("));
9313
9314 let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
9315 assert!(format!("{tick:?}").starts_with("Tick("));
9316
9317 let log: Cmd<TestMsg> = Cmd::log("test");
9318 assert!(format!("{log:?}").starts_with("Log("));
9319 }
9320
9321 #[test]
9322 fn program_config_with_budget() {
9323 let budget = FrameBudgetConfig {
9324 total: Duration::from_millis(50),
9325 ..Default::default()
9326 };
9327 let config = ProgramConfig::default().with_budget(budget);
9328 assert_eq!(config.budget.total, Duration::from_millis(50));
9329 }
9330
9331 #[test]
9332 fn load_governor_default_is_enabled() {
9333 let config = LoadGovernorConfig::default();
9334 assert!(config.enabled);
9335 assert_eq!(
9336 config.budget_controller.degradation_floor,
9337 DegradationLevel::SimpleBorders
9338 );
9339 }
9340
9341 #[test]
9342 fn program_config_load_governor_builders() {
9343 let governor = LoadGovernorConfig::disabled().with_enabled(true);
9344 let config = ProgramConfig::default().with_load_governor(governor);
9345 assert!(config.load_governor.enabled);
9346
9347 let config = config.without_load_governor();
9348 assert!(!config.load_governor.enabled);
9349 }
9350
9351 fn governor_observation(
9352 frame_time_ms: f64,
9353 in_flight: u64,
9354 dropped: u64,
9355 degradation: DegradationLevel,
9356 resize_coalescing_active: bool,
9357 strict_semantics_violation: bool,
9358 ) -> LoadGovernorObservation {
9359 LoadGovernorObservation {
9360 frame_time_us: frame_time_ms * 1_000.0,
9361 budget_us: 16_000.0,
9362 degradation,
9363 queue: crate::effect_system::QueueTelemetry {
9364 enqueued: in_flight,
9367 processed: 0,
9368 dropped,
9369 high_water: in_flight,
9370 in_flight,
9371 },
9372 resize_coalescing_active,
9373 strict_semantics_violation,
9374 }
9375 }
9376
9377 fn test_load_governor(max_queue_depth: usize, recovery_intervals: u8) -> LoadGovernorState {
9378 let policy = LoadGovernorPolicy {
9379 recovery_intervals,
9380 ..Default::default()
9381 };
9382 LoadGovernorState::new(
9383 LoadGovernorConfig::enabled().with_policy(policy),
9384 max_queue_depth,
9385 )
9386 }
9387
9388 #[test]
9389 fn load_governor_policy_defaults_match_runtime_contract() {
9390 let policy = LoadGovernorPolicy::default().normalized();
9391
9392 assert_eq!(policy.stressed_queue_watermark, 0.5);
9393 assert_eq!(policy.degraded_queue_watermark, 0.8);
9394 assert_eq!(policy.recovery_queue_watermark, 0.25);
9395 assert_eq!(policy.recovery_intervals, 3);
9396 assert_eq!(policy.budget_overrun_soft_ratio, 1.0);
9397 }
9398
9399 #[test]
9400 fn load_governor_classifies_queue_watermarks_and_recovery() {
9401 let mut governor = test_load_governor(100, 2);
9402
9403 let steady = governor.observe(governor_observation(
9404 8.0,
9405 0,
9406 0,
9407 DegradationLevel::Full,
9408 false,
9409 false,
9410 ));
9411 assert_eq!(steady.mode, RuntimeLoadMode::Healthy);
9412 assert_eq!(steady.pressure_class, RuntimePressureClass::SteadyState);
9413 assert_eq!(steady.disposition, RuntimeWorkDisposition::AdmitAll);
9414
9415 let stressed = governor.observe(governor_observation(
9416 8.0,
9417 50,
9418 0,
9419 DegradationLevel::Full,
9420 false,
9421 false,
9422 ));
9423 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9424 assert_eq!(stressed.pressure_class, RuntimePressureClass::SoftOverload);
9425 assert_eq!(
9426 stressed.disposition,
9427 RuntimeWorkDisposition::CoalesceVisibleDeferBackground
9428 );
9429 assert_eq!(stressed.reason_code, "queue_stressed_watermark");
9430
9431 let degraded = governor.observe(governor_observation(
9432 8.0,
9433 80,
9434 0,
9435 DegradationLevel::Full,
9436 false,
9437 false,
9438 ));
9439 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9440 assert_eq!(degraded.pressure_class, RuntimePressureClass::HardOverload);
9441 assert_eq!(
9442 degraded.disposition,
9443 RuntimeWorkDisposition::DeferBackgroundDropBestEffort
9444 );
9445 assert_eq!(degraded.reason_code, "queue_degraded_watermark");
9446
9447 let recovery_pending = governor.observe(governor_observation(
9448 8.0,
9449 10,
9450 0,
9451 DegradationLevel::Full,
9452 false,
9453 false,
9454 ));
9455 assert_eq!(recovery_pending.mode, RuntimeLoadMode::Degraded);
9456 assert_eq!(recovery_pending.reason_code, "recovery_hysteresis_pending");
9457 assert_eq!(recovery_pending.recovery_intervals_observed, 1);
9458
9459 let recovered = governor.observe(governor_observation(
9460 8.0,
9461 10,
9462 0,
9463 DegradationLevel::Full,
9464 false,
9465 false,
9466 ));
9467 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9468 assert_eq!(recovered.reason_code, "recovery_hysteresis_satisfied");
9469 assert_eq!(
9470 recovered.disposition,
9471 RuntimeWorkDisposition::ReadmitAfterHysteresis
9472 );
9473
9474 let healthy = governor.observe(governor_observation(
9475 8.0,
9476 10,
9477 0,
9478 DegradationLevel::Full,
9479 false,
9480 false,
9481 ));
9482 assert_eq!(healthy.mode, RuntimeLoadMode::Healthy);
9483 assert_eq!(healthy.reason_code, "recovered_interval_closed");
9484 }
9485
9486 #[test]
9487 fn load_governor_uses_uncapped_budget_pressure_fallback() {
9488 let mut governor = test_load_governor(0, 2);
9489
9490 let stressed = governor.observe(governor_observation(
9491 20.0,
9492 0,
9493 0,
9494 DegradationLevel::Full,
9495 false,
9496 false,
9497 ));
9498 assert_eq!(stressed.mode, RuntimeLoadMode::Stressed);
9499 assert_eq!(stressed.reason_code, "frame_budget_overrun");
9500 assert_eq!(stressed.queue_max_depth, None);
9501
9502 let degraded = governor.observe(governor_observation(
9503 8.0,
9504 0,
9505 0,
9506 DegradationLevel::SimpleBorders,
9507 false,
9508 false,
9509 ));
9510 assert_eq!(degraded.mode, RuntimeLoadMode::Degraded);
9511 assert_eq!(degraded.reason_code, "budget_degradation_active");
9512 }
9513
9514 #[test]
9515 fn load_governor_strict_semantics_failure_is_terminal() {
9516 let mut governor = test_load_governor(100, 2);
9517
9518 let unsafe_snapshot = governor.observe(governor_observation(
9519 8.0,
9520 0,
9521 0,
9522 DegradationLevel::Full,
9523 false,
9524 true,
9525 ));
9526
9527 assert_eq!(unsafe_snapshot.mode, RuntimeLoadMode::Unsafe);
9528 assert_eq!(unsafe_snapshot.pressure_class, RuntimePressureClass::Unsafe);
9529 assert_eq!(
9530 unsafe_snapshot.disposition,
9531 RuntimeWorkDisposition::FailFastStrictGuarantee
9532 );
9533 assert!(!unsafe_snapshot.strict_semantics_preserved);
9534 assert_eq!(unsafe_snapshot.reason_code, "strict_semantics_violation");
9535 }
9536
9537 #[test]
9538 fn load_governor_unsafe_latches_through_later_pressure() {
9539 let mut governor = test_load_governor(100, 2);
9540
9541 let entered = governor.observe(governor_observation(
9543 8.0,
9544 0,
9545 0,
9546 DegradationLevel::Full,
9547 false,
9548 true,
9549 ));
9550 assert_eq!(entered.mode, RuntimeLoadMode::Unsafe);
9551
9552 let hard = governor.observe(governor_observation(
9555 8.0,
9556 90,
9557 0,
9558 DegradationLevel::SimpleBorders,
9559 false,
9560 false,
9561 ));
9562 assert_eq!(hard.mode, RuntimeLoadMode::Unsafe);
9563 assert_eq!(
9564 hard.disposition,
9565 RuntimeWorkDisposition::FailFastStrictGuarantee
9566 );
9567 assert!(!hard.strict_semantics_preserved);
9568 assert_eq!(hard.reason_code, "strict_semantics_violation");
9569
9570 let soft = governor.observe(governor_observation(
9573 8.0,
9574 60,
9575 0,
9576 DegradationLevel::Full,
9577 true,
9578 false,
9579 ));
9580 assert_eq!(soft.mode, RuntimeLoadMode::Unsafe);
9581
9582 let steady = governor.observe(governor_observation(
9584 8.0,
9585 0,
9586 0,
9587 DegradationLevel::Full,
9588 false,
9589 false,
9590 ));
9591 assert_eq!(steady.mode, RuntimeLoadMode::Unsafe);
9592 assert!(!steady.strict_semantics_preserved);
9593 }
9594
9595 #[test]
9596 fn load_governor_hysteresis_prevents_single_sample_recovery() {
9597 let mut governor = test_load_governor(100, 3);
9598
9599 governor.observe(governor_observation(
9600 8.0,
9601 90,
9602 0,
9603 DegradationLevel::Full,
9604 false,
9605 false,
9606 ));
9607
9608 for expected in 1..=2 {
9609 let snapshot = governor.observe(governor_observation(
9610 8.0,
9611 0,
9612 0,
9613 DegradationLevel::Full,
9614 false,
9615 false,
9616 ));
9617 assert_eq!(snapshot.mode, RuntimeLoadMode::Degraded);
9618 assert_eq!(snapshot.recovery_intervals_observed, expected);
9619 assert_eq!(snapshot.reason_code, "recovery_hysteresis_pending");
9620 }
9621
9622 let recovered = governor.observe(governor_observation(
9623 8.0,
9624 0,
9625 0,
9626 DegradationLevel::Full,
9627 false,
9628 false,
9629 ));
9630 assert_eq!(recovered.mode, RuntimeLoadMode::Recovered);
9631 }
9632
9633 #[test]
9634 fn load_governor_stress_e2e_evidence_shows_degraded_and_recovery() {
9635 let mut governor = test_load_governor(50, 2);
9636 let scenario = [
9637 governor_observation(8.0, 0, 0, DegradationLevel::Full, false, false),
9638 governor_observation(18.0, 25, 0, DegradationLevel::Full, true, false),
9639 governor_observation(22.0, 45, 1, DegradationLevel::SimpleBorders, true, false),
9640 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9641 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9642 governor_observation(8.0, 5, 1, DegradationLevel::Full, false, false),
9643 ];
9644
9645 let mut modes = Vec::new();
9646 for observation in scenario {
9647 let snapshot = governor.observe(observation);
9648 modes.push(snapshot.mode);
9649 println!(
9650 "{{\"test\":\"load_governor_stress_e2e\",\"mode\":\"{}\",\"pressure_class\":\"{}\",\"work_disposition\":\"{}\",\"reason\":\"{}\",\"transition\":{},\"deferred\":{},\"coalesced\":{},\"dropped\":{}}}",
9651 snapshot.mode.as_str(),
9652 snapshot.pressure_class.as_str(),
9653 snapshot.disposition.as_str(),
9654 snapshot.reason_code,
9655 snapshot.transition,
9656 snapshot.deferred_work_total,
9657 snapshot.coalesced_work_total,
9658 snapshot.dropped_work_total
9659 );
9660 }
9661
9662 assert!(modes.contains(&RuntimeLoadMode::Stressed));
9663 assert!(modes.contains(&RuntimeLoadMode::Degraded));
9664 assert!(modes.contains(&RuntimeLoadMode::Recovered));
9665 assert_eq!(modes.last(), Some(&RuntimeLoadMode::Healthy));
9666 }
9667
9668 #[test]
9669 fn headless_program_default_load_governor_attaches_controller() {
9670 let program =
9671 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
9672 assert!(program.budget.controller().is_some());
9673 }
9674
9675 #[test]
9676 fn headless_program_load_governor_target_tracks_frame_budget() {
9677 let config = ProgramConfig::default().with_budget(FrameBudgetConfig {
9678 total: Duration::from_millis(50),
9679 ..Default::default()
9680 });
9681 let program = headless_program_with_config(TestModel { value: 0 }, config);
9682 assert_eq!(
9683 program.budget.controller().unwrap().config().target,
9684 Duration::from_millis(50)
9685 );
9686 }
9687
9688 #[test]
9689 fn headless_program_without_load_governor_uses_legacy_budget() {
9690 let program = headless_program_with_config(
9691 TestModel { value: 0 },
9692 ProgramConfig::default().without_load_governor(),
9693 );
9694 assert!(program.budget.controller().is_none());
9695 }
9696
9697 #[test]
9698 fn app_builder_without_load_governor_sets_config() {
9699 let builder = App::new(TestModel { value: 0 }).without_load_governor();
9700 assert!(!builder.config.load_governor.enabled);
9701 }
9702
9703 #[test]
9704 fn program_config_with_conformal() {
9705 let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
9706 alpha: 0.2,
9707 ..Default::default()
9708 });
9709 assert!(config.conformal_config.is_some());
9710 assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
9711 }
9712
9713 #[test]
9714 fn program_config_forced_size_clamps_minimums() {
9715 let config = ProgramConfig::default().with_forced_size(0, 0);
9716 assert_eq!(config.forced_size, Some((1, 1)));
9717
9718 let cleared = config.without_forced_size();
9719 assert!(cleared.forced_size.is_none());
9720 }
9721
9722 #[test]
9723 fn effect_queue_config_defaults_are_safe() {
9724 let config = EffectQueueConfig::default();
9725 assert!(!config.enabled);
9726 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
9727 assert!(config.scheduler.smith_enabled);
9728 assert!(!config.scheduler.preemptive);
9729 assert_eq!(config.scheduler.aging_factor, 0.0);
9730 assert_eq!(config.scheduler.wait_starve_ms, 0.0);
9731 }
9732
9733 #[test]
9734 fn handle_effect_command_enqueues_or_executes_inline() {
9735 let (result_tx, result_rx) = mpsc::channel::<u32>();
9736 let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
9737 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9738
9739 let ran = Arc::new(AtomicUsize::new(0));
9740 let ran_task = ran.clone();
9741 let cmd = EffectCommand::Enqueue(
9742 TaskSpec::default(),
9743 Box::new(move || {
9744 ran_task.fetch_add(1, Ordering::SeqCst);
9745 7
9746 }),
9747 );
9748
9749 let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx, None, 0);
9750 assert_eq!(shutdown, EffectLoopControl::Continue);
9751 assert_eq!(ran.load(Ordering::SeqCst), 0);
9752 assert_eq!(tasks.len(), 1);
9753 assert!(result_rx.try_recv().is_err());
9754
9755 let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
9756 max_queue_size: 0,
9757 ..Default::default()
9758 });
9759 let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9760 let ran_full = Arc::new(AtomicUsize::new(0));
9761 let ran_full_task = ran_full.clone();
9762 let cmd_full = EffectCommand::Enqueue(
9763 TaskSpec::default(),
9764 Box::new(move || {
9765 ran_full_task.fetch_add(1, Ordering::SeqCst);
9766 42
9767 }),
9768 );
9769
9770 let shutdown_full = handle_effect_command(
9771 cmd_full,
9772 &mut full_scheduler,
9773 &mut full_tasks,
9774 &result_tx,
9775 None,
9776 0,
9777 );
9778 assert_eq!(shutdown_full, EffectLoopControl::Continue);
9779 assert!(full_tasks.is_empty());
9780 assert_eq!(ran_full.load(Ordering::SeqCst), 1);
9781 assert_eq!(
9782 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9783 42
9784 );
9785
9786 let shutdown = handle_effect_command(
9787 EffectCommand::Shutdown,
9788 &mut full_scheduler,
9789 &mut full_tasks,
9790 &result_tx,
9791 None,
9792 0,
9793 );
9794 assert_eq!(shutdown, EffectLoopControl::ShutdownRequested);
9795 }
9796
9797 #[test]
9798 fn handle_effect_command_inline_fallback_writes_backpressure_evidence() {
9799 let evidence_path = temp_evidence_path("task_executor_backpressure");
9800 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
9801 let sink = EvidenceSink::from_config(&sink_config)
9802 .expect("evidence sink config")
9803 .expect("evidence sink enabled");
9804 let (result_tx, result_rx) = mpsc::channel::<u32>();
9805 let mut scheduler = QueueingScheduler::new(SchedulerConfig {
9806 max_queue_size: 0,
9807 ..Default::default()
9808 });
9809 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
9810
9811 let shutdown = handle_effect_command(
9812 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 7)),
9813 &mut scheduler,
9814 &mut tasks,
9815 &result_tx,
9816 Some(&sink),
9817 0,
9818 );
9819
9820 assert_eq!(shutdown, EffectLoopControl::Continue);
9821 assert!(tasks.is_empty());
9822 assert_eq!(
9823 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
9824 7
9825 );
9826
9827 let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
9828 assert_eq!(backpressure_line["backend"], "queued");
9829 assert_eq!(backpressure_line["action"], "inline_fallback");
9830 assert_eq!(backpressure_line["max_queue_size"], 0);
9831 assert_eq!(backpressure_line["total_rejected"], 1);
9832
9833 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
9834 assert_eq!(completion_line["backend"], "queued-inline-fallback");
9835 assert!(completion_line["duration_us"].is_number());
9836 }
9837
9838 #[test]
9839 fn effect_queue_loop_executes_tasks_and_shutdowns() {
9840 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9841 let (result_tx, result_rx) = mpsc::channel::<u32>();
9842 let config = EffectQueueConfig {
9843 enabled: true,
9844 backend: TaskExecutorBackend::EffectQueue,
9845 scheduler: SchedulerConfig {
9846 preemptive: false,
9847 ..Default::default()
9848 },
9849 explicit_backend: true,
9850 ..Default::default()
9851 };
9852
9853 let handle = std::thread::spawn(move || {
9854 effect_queue_loop(config, cmd_rx, result_tx, None);
9855 });
9856
9857 cmd_tx
9858 .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
9859 .unwrap();
9860 cmd_tx
9861 .send(EffectCommand::Enqueue(
9862 TaskSpec::new(2.0, 5.0).with_name("second"),
9863 Box::new(|| 20),
9864 ))
9865 .unwrap();
9866
9867 let mut results = vec![
9868 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9869 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9870 ];
9871 results.sort_unstable();
9872 assert_eq!(results, vec![10, 20]);
9873
9874 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9875 let _ = handle.join();
9876 }
9877
9878 #[test]
9879 fn effect_queue_loop_drains_queued_tasks_after_shutdown_request() {
9880 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9881 let (result_tx, result_rx) = mpsc::channel::<u32>();
9882 let config = EffectQueueConfig {
9883 enabled: true,
9884 backend: TaskExecutorBackend::EffectQueue,
9885 scheduler: SchedulerConfig {
9886 preemptive: false,
9887 ..Default::default()
9888 },
9889 explicit_backend: true,
9890 ..Default::default()
9891 };
9892
9893 let handle = std::thread::spawn(move || {
9894 effect_queue_loop(config, cmd_rx, result_tx, None);
9895 });
9896
9897 cmd_tx
9898 .send(EffectCommand::Enqueue(
9899 TaskSpec::default().with_name("slow"),
9900 Box::new(|| {
9901 std::thread::sleep(Duration::from_millis(20));
9902 10
9903 }),
9904 ))
9905 .unwrap();
9906 cmd_tx
9907 .send(EffectCommand::Enqueue(
9908 TaskSpec::new(2.0, 5.0).with_name("fast"),
9909 Box::new(|| 20),
9910 ))
9911 .unwrap();
9912 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9913
9914 let mut results = vec![
9915 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9916 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9917 ];
9918 results.sort_unstable();
9919 assert_eq!(results, vec![10, 20]);
9920
9921 handle
9922 .join()
9923 .expect("effect queue thread joins after draining");
9924 }
9925
9926 #[test]
9927 fn effect_queue_loop_survives_panicking_task_and_runs_later_work() {
9928 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9929 let (result_tx, result_rx) = mpsc::channel::<u32>();
9930 let config = EffectQueueConfig {
9931 enabled: true,
9932 backend: TaskExecutorBackend::EffectQueue,
9933 scheduler: SchedulerConfig {
9934 preemptive: false,
9935 ..Default::default()
9936 },
9937 explicit_backend: true,
9938 ..Default::default()
9939 };
9940
9941 let handle = std::thread::spawn(move || {
9942 effect_queue_loop(config, cmd_rx, result_tx, None);
9943 });
9944
9945 cmd_tx
9946 .send(EffectCommand::Enqueue(
9947 TaskSpec::new(3.0, 1.0).with_name("panic"),
9948 Box::new(|| panic!("queued panic")),
9949 ))
9950 .unwrap();
9951 cmd_tx
9952 .send(EffectCommand::Enqueue(
9953 TaskSpec::new(1.0, 5.0).with_name("after"),
9954 Box::new(|| 99),
9955 ))
9956 .unwrap();
9957
9958 assert_eq!(
9959 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
9960 99
9961 );
9962
9963 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9964 handle
9965 .join()
9966 .expect("effect queue thread survives task panic");
9967 }
9968
9969 #[test]
9970 fn effect_queue_loop_rejects_tasks_submitted_after_shutdown_request() {
9971 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
9972 let (result_tx, result_rx) = mpsc::channel::<u32>();
9973 let config = EffectQueueConfig {
9974 enabled: true,
9975 backend: TaskExecutorBackend::EffectQueue,
9976 scheduler: SchedulerConfig {
9977 preemptive: false,
9978 ..Default::default()
9979 },
9980 explicit_backend: true,
9981 ..Default::default()
9982 };
9983
9984 let handle = std::thread::spawn(move || {
9985 effect_queue_loop(config, cmd_rx, result_tx, None);
9986 });
9987
9988 cmd_tx
9989 .send(EffectCommand::Enqueue(
9990 TaskSpec::default().with_name("slow"),
9991 Box::new(|| {
9992 std::thread::sleep(Duration::from_millis(20));
9993 10
9994 }),
9995 ))
9996 .unwrap();
9997 cmd_tx.send(EffectCommand::Shutdown).unwrap();
9998 cmd_tx
9999 .send(EffectCommand::Enqueue(
10000 TaskSpec::new(1.0, 1.0).with_name("late"),
10001 Box::new(|| 99),
10002 ))
10003 .unwrap();
10004
10005 assert_eq!(
10006 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
10007 10
10008 );
10009 assert!(
10010 result_rx.recv_timeout(Duration::from_millis(100)).is_err(),
10011 "post-shutdown enqueue should not execute"
10012 );
10013
10014 handle
10015 .join()
10016 .expect("effect queue thread joins after rejecting post-shutdown work");
10017 }
10018
10019 #[test]
10020 fn effect_queue_enqueue_after_shutdown_records_drop() {
10021 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
10022 drop(rx);
10023
10024 let queue = EffectQueue {
10025 sender: tx,
10026 handle: None,
10027 closed: true,
10028 };
10029 let runs = Arc::new(AtomicUsize::new(0));
10030 let before = crate::effect_system::effects_queue_dropped();
10031
10032 queue.enqueue(
10033 TaskSpec::default(),
10034 Box::new({
10035 let runs = Arc::clone(&runs);
10036 move || {
10037 runs.fetch_add(1, Ordering::SeqCst);
10038 7
10039 }
10040 }),
10041 );
10042
10043 let after = crate::effect_system::effects_queue_dropped();
10044 assert_eq!(runs.load(Ordering::SeqCst), 0);
10045 assert!(
10046 after > before,
10047 "enqueue after shutdown should increment dropped counter"
10048 );
10049 }
10050
10051 #[test]
10052 fn effect_queue_enqueue_with_closed_channel_records_drop() {
10053 let (tx, rx) = mpsc::channel::<EffectCommand<u32>>();
10054 drop(rx);
10055
10056 let queue = EffectQueue {
10057 sender: tx,
10058 handle: None,
10059 closed: false,
10060 };
10061 let runs = Arc::new(AtomicUsize::new(0));
10062 let before = crate::effect_system::effects_queue_dropped();
10063
10064 queue.enqueue(
10065 TaskSpec::default(),
10066 Box::new({
10067 let runs = Arc::clone(&runs);
10068 move || {
10069 runs.fetch_add(1, Ordering::SeqCst);
10070 9
10071 }
10072 }),
10073 );
10074
10075 let after = crate::effect_system::effects_queue_dropped();
10076 assert_eq!(runs.load(Ordering::SeqCst), 0);
10077 assert!(
10078 after > before,
10079 "enqueue into a closed queue channel should increment dropped counter"
10080 );
10081 }
10082
10083 #[test]
10088 fn backpressure_drops_tasks_beyond_max_depth() {
10089 let (result_tx, _result_rx) = mpsc::channel::<u32>();
10090 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
10091 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
10092
10093 let r1 = handle_effect_command(
10095 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 1)),
10096 &mut scheduler,
10097 &mut tasks,
10098 &result_tx,
10099 None,
10100 2,
10101 );
10102 assert_eq!(r1, EffectLoopControl::Continue);
10103 assert_eq!(tasks.len(), 1);
10104
10105 let r2 = handle_effect_command(
10106 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 2)),
10107 &mut scheduler,
10108 &mut tasks,
10109 &result_tx,
10110 None,
10111 2,
10112 );
10113 assert_eq!(r2, EffectLoopControl::Continue);
10114 assert_eq!(tasks.len(), 2);
10115
10116 let dropped_before = crate::effect_system::effects_queue_dropped();
10118 let r3 = handle_effect_command(
10119 EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 3)),
10120 &mut scheduler,
10121 &mut tasks,
10122 &result_tx,
10123 None,
10124 2,
10125 );
10126 assert_eq!(r3, EffectLoopControl::Continue);
10127 assert_eq!(
10128 tasks.len(),
10129 2,
10130 "task should have been dropped, not enqueued"
10131 );
10132 assert!(
10133 crate::effect_system::effects_queue_dropped() > dropped_before,
10134 "dropped counter should increment"
10135 );
10136 }
10137
10138 #[test]
10139 fn backpressure_zero_depth_means_unbounded() {
10140 let (result_tx, _result_rx) = mpsc::channel::<u32>();
10141 let mut scheduler = QueueingScheduler::new(SchedulerConfig::default());
10142 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
10143
10144 for i in 0..20 {
10146 let r = handle_effect_command(
10147 EffectCommand::Enqueue(TaskSpec::default(), Box::new(move || i)),
10148 &mut scheduler,
10149 &mut tasks,
10150 &result_tx,
10151 None,
10152 0,
10153 );
10154 assert_eq!(r, EffectLoopControl::Continue);
10155 }
10156 }
10158
10159 #[test]
10160 fn inline_auto_remeasure_reset_clears_decision() {
10161 let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
10162 state.sampler.decide(Instant::now());
10163 assert!(state.sampler.last_decision().is_some());
10164
10165 state.reset();
10166 assert!(state.sampler.last_decision().is_none());
10167 }
10168
10169 #[test]
10170 fn budget_decision_jsonl_contains_required_fields() {
10171 let evidence = BudgetDecisionEvidence {
10172 frame_idx: 7,
10173 decision: BudgetDecision::Degrade,
10174 controller_decision: BudgetDecision::Hold,
10175 degradation_before: DegradationLevel::Full,
10176 degradation_after: DegradationLevel::NoStyling,
10177 frame_time_us: 12_345.678,
10178 budget_us: 16_000.0,
10179 pid_output: 1.25,
10180 pid_p: 0.5,
10181 pid_i: 0.25,
10182 pid_d: 0.5,
10183 e_value: 2.0,
10184 frames_observed: 42,
10185 frames_since_change: 3,
10186 in_warmup: false,
10187 controller_reason: BudgetDecisionReason::OverloadEvidencePassed,
10188 load_governor: LoadGovernorSnapshot {
10189 mode: RuntimeLoadMode::Degraded,
10190 mode_before: RuntimeLoadMode::Stressed,
10191 pressure_class: RuntimePressureClass::HardOverload,
10192 disposition: RuntimeWorkDisposition::DeferBackgroundDropBestEffort,
10193 reason_code: "budget_degradation_active",
10194 transition: true,
10195 strict_semantics_preserved: true,
10196 queue_in_flight: 8,
10197 queue_max_depth: Some(10),
10198 queue_dropped_delta: 0,
10199 resize_coalescing_active: false,
10200 recovery_intervals_observed: 0,
10201 recovery_intervals_required: 3,
10202 deferred_work_total: 2,
10203 coalesced_work_total: 1,
10204 dropped_work_total: 0,
10205 },
10206 conformal: Some(ConformalEvidence {
10207 bucket_key: "inline:dirty:10".to_string(),
10208 n_b: 32,
10209 alpha: 0.05,
10210 q_b: 1000.0,
10211 y_hat: 12_000.0,
10212 upper_us: 13_000.0,
10213 risk: true,
10214 fallback_level: 1,
10215 window_size: 256,
10216 reset_count: 2,
10217 }),
10218 };
10219
10220 let jsonl = evidence.to_jsonl();
10221 assert!(jsonl.contains("\"event\":\"budget_decision\""));
10222 assert!(jsonl.contains("\"decision\":\"degrade\""));
10223 assert!(jsonl.contains("\"decision_controller\":\"stay\""));
10224 assert!(jsonl.contains("\"decision_controller_reason\":\"overload_evidence_passed\""));
10225 assert!(jsonl.contains("\"degradation_before\":\"Full\""));
10226 assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
10227 assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
10228 assert!(jsonl.contains("\"budget_us\":16000.000000"));
10229 assert!(jsonl.contains("\"pid_output\":1.250000"));
10230 assert!(jsonl.contains("\"e_value\":2.000000"));
10231 assert!(jsonl.contains("\"runtime_mode\":\"degraded\""));
10232 assert!(jsonl.contains("\"runtime_mode_before\":\"stressed\""));
10233 assert!(jsonl.contains("\"pressure_class\":\"hard_overload\""));
10234 assert!(jsonl.contains("\"work_disposition\":\"defer_background_drop_best_effort\""));
10235 assert!(jsonl.contains("\"governor_reason\":\"budget_degradation_active\""));
10236 assert!(jsonl.contains("\"governor_transition\":true"));
10237 assert!(jsonl.contains("\"strict_semantics_preserved\":true"));
10238 assert!(jsonl.contains("\"queue_in_flight\":8"));
10239 assert!(jsonl.contains("\"queue_max_depth\":10"));
10240 assert!(jsonl.contains("\"deferred_work_total\":2"));
10241 assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
10242 assert!(jsonl.contains("\"n_b\":32"));
10243 assert!(jsonl.contains("\"alpha\":0.050000"));
10244 assert!(jsonl.contains("\"q_b\":1000.000000"));
10245 assert!(jsonl.contains("\"y_hat\":12000.000000"));
10246 assert!(jsonl.contains("\"upper_us\":13000.000000"));
10247 assert!(jsonl.contains("\"risk\":true"));
10248 assert!(jsonl.contains("\"fallback_level\":1"));
10249 assert!(jsonl.contains("\"window_size\":256"));
10250 assert!(jsonl.contains("\"reset_count\":2"));
10251 }
10252
10253 fn make_signal(
10254 widget_id: u64,
10255 essential: bool,
10256 priority: f32,
10257 staleness_ms: u64,
10258 cost_us: f32,
10259 ) -> WidgetSignal {
10260 WidgetSignal {
10261 widget_id,
10262 essential,
10263 priority,
10264 staleness_ms,
10265 focus_boost: 0.0,
10266 interaction_boost: 0.0,
10267 area_cells: 1,
10268 cost_estimate_us: cost_us,
10269 recent_cost_us: 0.0,
10270 estimate_source: CostEstimateSource::FixedDefault,
10271 }
10272 }
10273
10274 fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
10275 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
10276 let staleness_window = config.staleness_window_ms.max(1) as f32;
10277 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
10278 let mut value = config.weight_priority * signal.priority
10279 + config.weight_staleness * staleness_score
10280 + config.weight_focus * signal.focus_boost
10281 + config.weight_interaction * signal.interaction_boost;
10282 if starved {
10283 value += config.starve_boost;
10284 }
10285 let raw_cost = if signal.recent_cost_us > 0.0 {
10286 signal.recent_cost_us
10287 } else {
10288 signal.cost_estimate_us
10289 };
10290 let cost_us = raw_cost.max(config.min_cost_us);
10291 (value, cost_us, starved)
10292 }
10293
10294 fn fifo_select(
10295 signals: &[WidgetSignal],
10296 budget_us: f64,
10297 config: &WidgetRefreshConfig,
10298 ) -> (Vec<u64>, f64, usize) {
10299 let mut selected = Vec::new();
10300 let mut total_value = 0.0f64;
10301 let mut starved_selected = 0usize;
10302 let mut remaining = budget_us;
10303
10304 for signal in signals {
10305 if !signal.essential {
10306 continue;
10307 }
10308 let (value, cost_us, starved) = signal_value_cost(signal, config);
10309 remaining -= cost_us as f64;
10310 total_value += value as f64;
10311 if starved {
10312 starved_selected = starved_selected.saturating_add(1);
10313 }
10314 selected.push(signal.widget_id);
10315 }
10316 for signal in signals {
10317 if signal.essential {
10318 continue;
10319 }
10320 let (value, cost_us, starved) = signal_value_cost(signal, config);
10321 if remaining >= cost_us as f64 {
10322 remaining -= cost_us as f64;
10323 total_value += value as f64;
10324 if starved {
10325 starved_selected = starved_selected.saturating_add(1);
10326 }
10327 selected.push(signal.widget_id);
10328 }
10329 }
10330
10331 (selected, total_value, starved_selected)
10332 }
10333
10334 fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
10335 if signals.is_empty() {
10336 return Vec::new();
10337 }
10338 let mut rotated = Vec::with_capacity(signals.len());
10339 for idx in 0..signals.len() {
10340 rotated.push(signals[(idx + offset) % signals.len()].clone());
10341 }
10342 rotated
10343 }
10344
10345 #[test]
10346 fn widget_refresh_selects_essentials_first() {
10347 let signals = vec![
10348 make_signal(1, true, 0.6, 0, 5.0),
10349 make_signal(2, false, 0.9, 0, 4.0),
10350 ];
10351 let mut plan = WidgetRefreshPlan::new();
10352 let config = WidgetRefreshConfig::default();
10353 plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
10354 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10355 assert_eq!(selected, vec![1]);
10356 assert!(!plan.over_budget);
10357 }
10358
10359 #[test]
10360 fn widget_refresh_degradation_essential_only_skips_nonessential() {
10361 let signals = vec![
10362 make_signal(1, true, 0.5, 0, 2.0),
10363 make_signal(2, false, 1.0, 0, 1.0),
10364 ];
10365 let mut plan = WidgetRefreshPlan::new();
10366 let config = WidgetRefreshConfig::default();
10367 plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
10368 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10369 assert_eq!(selected, vec![1]);
10370 assert_eq!(plan.skipped_count, 1);
10371 }
10372
10373 #[test]
10374 fn widget_refresh_starvation_guard_forces_one_starved() {
10375 let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
10376 let mut plan = WidgetRefreshPlan::new();
10377 let config = WidgetRefreshConfig {
10378 starve_ms: 1_000,
10379 max_starved_per_frame: 1,
10380 ..Default::default()
10381 };
10382 plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
10383 assert_eq!(plan.selected.len(), 1);
10384 assert!(plan.selected[0].starved);
10385 assert!(plan.over_budget);
10386 }
10387
10388 #[test]
10389 fn widget_refresh_budget_blocks_when_no_selection() {
10390 let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
10391 let mut plan = WidgetRefreshPlan::new();
10392 let config = WidgetRefreshConfig {
10393 starve_ms: 0,
10394 max_starved_per_frame: 0,
10395 ..Default::default()
10396 };
10397 plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
10398 let budget = plan.as_budget();
10399 assert!(!budget.allows(42, false));
10400 }
10401
10402 #[test]
10403 fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
10404 let signals = vec![
10405 make_signal(1, false, 0.4, 0, 10.0),
10406 make_signal(2, false, 0.4, 0, 10.0),
10407 make_signal(3, false, 0.4, 0, 10.0),
10408 make_signal(4, false, 0.4, 0, 10.0),
10409 ];
10410 let mut plan = WidgetRefreshPlan::new();
10411 let config = WidgetRefreshConfig {
10412 starve_ms: 0,
10413 max_starved_per_frame: 0,
10414 max_drop_fraction: 0.5,
10415 ..Default::default()
10416 };
10417 plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
10418 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10419 assert_eq!(selected, vec![1, 2]);
10420 }
10421
10422 #[test]
10423 fn widget_refresh_greedy_beats_fifo_and_round_robin() {
10424 let signals = vec![
10425 make_signal(1, false, 0.1, 0, 6.0),
10426 make_signal(2, false, 0.2, 0, 6.0),
10427 make_signal(3, false, 1.0, 0, 4.0),
10428 make_signal(4, false, 0.9, 0, 3.0),
10429 make_signal(5, false, 0.8, 0, 3.0),
10430 make_signal(6, false, 0.1, 4_000, 2.0),
10431 ];
10432 let budget_us = 10.0;
10433 let config = WidgetRefreshConfig::default();
10434
10435 let mut plan = WidgetRefreshPlan::new();
10436 plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
10437 let greedy_value = plan.selected_value;
10438 let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
10439
10440 let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
10441 let rotated = rotate_signals(&signals, 2);
10442 let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
10443
10444 assert!(
10445 greedy_value > fifo_value,
10446 "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
10447 greedy_selected,
10448 fifo_selected
10449 );
10450 assert!(
10451 greedy_value > rr_value,
10452 "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
10453 greedy_selected,
10454 rr_selected
10455 );
10456 assert!(
10457 plan.starved_selected > 0,
10458 "greedy did not select starved widget; greedy={:?}",
10459 greedy_selected
10460 );
10461 }
10462
10463 #[test]
10464 fn widget_refresh_jsonl_contains_required_fields() {
10465 let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
10466 let mut plan = WidgetRefreshPlan::new();
10467 let config = WidgetRefreshConfig::default();
10468 plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
10469 let jsonl = plan.to_jsonl();
10470 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
10471 assert!(jsonl.contains("\"frame_idx\":9"));
10472 assert!(jsonl.contains("\"selected_count\":1"));
10473 assert!(jsonl.contains("\"id\":7"));
10474 }
10475
10476 #[test]
10477 fn program_config_with_resize_coalescer() {
10478 let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
10479 steady_delay_ms: 8,
10480 burst_delay_ms: 20,
10481 hard_deadline_ms: 80,
10482 burst_enter_rate: 12.0,
10483 burst_exit_rate: 6.0,
10484 cooldown_frames: 2,
10485 rate_window_size: 6,
10486 enable_logging: true,
10487 enable_bocpd: false,
10488 bocpd_config: None,
10489 });
10490 assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
10491 assert!(config.resize_coalescer.enable_logging);
10492 }
10493
10494 #[test]
10495 fn program_config_with_resize_behavior() {
10496 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
10497 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10498 }
10499
10500 #[test]
10501 fn program_config_with_legacy_resize_enabled() {
10502 let config = ProgramConfig::default().with_legacy_resize(true);
10503 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
10504 }
10505
10506 #[test]
10507 fn program_config_with_legacy_resize_disabled_keeps_default() {
10508 let config = ProgramConfig::default().with_legacy_resize(false);
10509 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
10510 }
10511
10512 fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
10513 let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
10514 let mut writer = TerminalWriter::with_diff_config(
10515 Vec::<u8>::new(),
10516 ScreenMode::AltScreen,
10517 UiAnchor::Bottom,
10518 TerminalCapabilities::basic(),
10519 config,
10520 );
10521 writer.set_size(8, 4);
10522
10523 let mut buffer = Buffer::new(8, 4);
10524 let mut trace = Vec::new();
10525
10526 writer.present_ui(&buffer, None, false).unwrap();
10527 trace.push(
10528 writer
10529 .last_diff_strategy()
10530 .unwrap_or(DiffStrategy::FullRedraw),
10531 );
10532
10533 buffer.set_raw(0, 0, Cell::from_char('A'));
10534 writer.present_ui(&buffer, None, false).unwrap();
10535 trace.push(
10536 writer
10537 .last_diff_strategy()
10538 .unwrap_or(DiffStrategy::FullRedraw),
10539 );
10540
10541 buffer.set_raw(1, 1, Cell::from_char('B'));
10542 writer.present_ui(&buffer, None, false).unwrap();
10543 trace.push(
10544 writer
10545 .last_diff_strategy()
10546 .unwrap_or(DiffStrategy::FullRedraw),
10547 );
10548
10549 trace
10550 }
10551
10552 fn coalescer_checksum(enable_bocpd: bool) -> String {
10553 let mut config = CoalescerConfig::default().with_logging(true);
10554 if enable_bocpd {
10555 config = config.with_bocpd();
10556 }
10557
10558 let base = Instant::now();
10559 let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
10560
10561 let events = [
10562 (0_u64, (82_u16, 24_u16)),
10563 (10, (83, 25)),
10564 (20, (84, 26)),
10565 (35, (90, 28)),
10566 (55, (92, 30)),
10567 ];
10568
10569 let mut idx = 0usize;
10570 for t_ms in (0_u64..=160).step_by(8) {
10571 let now = base + Duration::from_millis(t_ms);
10572 while idx < events.len() && events[idx].0 == t_ms {
10573 let (w, h) = events[idx].1;
10574 coalescer.handle_resize_at(w, h, now);
10575 idx += 1;
10576 }
10577 coalescer.tick_at(now);
10578 }
10579
10580 coalescer.decision_checksum_hex()
10581 }
10582
10583 fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
10584 if !enabled {
10585 return Vec::new();
10586 }
10587
10588 let mut predictor = ConformalPredictor::new(ConformalConfig::default());
10589 let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
10590 let mut trace = Vec::new();
10591
10592 for i in 0..30 {
10593 let y_hat = 16_000.0 + (i as f64) * 15.0;
10594 let observed = y_hat + (i % 7) as f64 * 120.0;
10595 predictor.observe(key, y_hat, observed);
10596 let prediction = predictor.predict(key, y_hat, 20_000.0);
10597 trace.push((prediction.upper_us, prediction.risk));
10598 }
10599
10600 trace
10601 }
10602
10603 #[test]
10604 fn policy_toggle_matrix_determinism() {
10605 for &bayesian in &[false, true] {
10606 for &bocpd in &[false, true] {
10607 for &conformal in &[false, true] {
10608 let diff_a = diff_strategy_trace(bayesian);
10609 let diff_b = diff_strategy_trace(bayesian);
10610 assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
10611
10612 let checksum_a = coalescer_checksum(bocpd);
10613 let checksum_b = coalescer_checksum(bocpd);
10614 assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
10615
10616 let conf_a = conformal_trace(conformal);
10617 let conf_b = conformal_trace(conformal);
10618 assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
10619
10620 if conformal {
10621 assert!(!conf_a.is_empty(), "conformal trace should be populated");
10622 } else {
10623 assert!(conf_a.is_empty(), "conformal trace should be empty");
10624 }
10625 }
10626 }
10627 }
10628 }
10629
10630 #[test]
10631 fn resize_behavior_uses_coalescer_flag() {
10632 assert!(ResizeBehavior::Throttled.uses_coalescer());
10633 assert!(!ResizeBehavior::Immediate.uses_coalescer());
10634 }
10635
10636 #[test]
10637 fn nested_cmd_msg_executes_recursively() {
10638 use crate::simulator::ProgramSimulator;
10640
10641 struct NestedModel {
10642 depth: usize,
10643 }
10644
10645 #[derive(Debug)]
10646 enum NestedMsg {
10647 Nest(usize),
10648 }
10649
10650 impl From<Event> for NestedMsg {
10651 fn from(_: Event) -> Self {
10652 NestedMsg::Nest(0)
10653 }
10654 }
10655
10656 impl Model for NestedModel {
10657 type Message = NestedMsg;
10658
10659 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10660 match msg {
10661 NestedMsg::Nest(n) => {
10662 self.depth += 1;
10663 if n > 0 {
10664 Cmd::msg(NestedMsg::Nest(n - 1))
10665 } else {
10666 Cmd::none()
10667 }
10668 }
10669 }
10670 }
10671
10672 fn view(&self, _frame: &mut Frame) {}
10673 }
10674
10675 let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
10676 sim.init();
10677 sim.send(NestedMsg::Nest(3));
10678
10679 assert_eq!(sim.model().depth, 4);
10681 }
10682
10683 #[test]
10684 fn task_executes_synchronously_in_simulator() {
10685 use crate::simulator::ProgramSimulator;
10687
10688 struct TaskModel {
10689 completed: bool,
10690 }
10691
10692 #[derive(Debug)]
10693 enum TaskMsg {
10694 Complete,
10695 SpawnTask,
10696 }
10697
10698 impl From<Event> for TaskMsg {
10699 fn from(_: Event) -> Self {
10700 TaskMsg::Complete
10701 }
10702 }
10703
10704 impl Model for TaskModel {
10705 type Message = TaskMsg;
10706
10707 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10708 match msg {
10709 TaskMsg::Complete => {
10710 self.completed = true;
10711 Cmd::none()
10712 }
10713 TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
10714 }
10715 }
10716
10717 fn view(&self, _frame: &mut Frame) {}
10718 }
10719
10720 let mut sim = ProgramSimulator::new(TaskModel { completed: false });
10721 sim.init();
10722 sim.send(TaskMsg::SpawnTask);
10723
10724 assert!(sim.model().completed);
10726 }
10727
10728 #[test]
10729 fn multiple_updates_accumulate_correctly() {
10730 use crate::simulator::ProgramSimulator;
10732
10733 struct AccumModel {
10734 sum: i32,
10735 }
10736
10737 #[derive(Debug)]
10738 enum AccumMsg {
10739 Add(i32),
10740 Multiply(i32),
10741 }
10742
10743 impl From<Event> for AccumMsg {
10744 fn from(_: Event) -> Self {
10745 AccumMsg::Add(1)
10746 }
10747 }
10748
10749 impl Model for AccumModel {
10750 type Message = AccumMsg;
10751
10752 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10753 match msg {
10754 AccumMsg::Add(n) => {
10755 self.sum += n;
10756 Cmd::none()
10757 }
10758 AccumMsg::Multiply(n) => {
10759 self.sum *= n;
10760 Cmd::none()
10761 }
10762 }
10763 }
10764
10765 fn view(&self, _frame: &mut Frame) {}
10766 }
10767
10768 let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
10769 sim.init();
10770
10771 sim.send(AccumMsg::Add(5));
10773 sim.send(AccumMsg::Multiply(2));
10774 sim.send(AccumMsg::Add(3));
10775
10776 assert_eq!(sim.model().sum, 13);
10777 }
10778
10779 #[test]
10780 fn init_command_executes_before_first_update() {
10781 use crate::simulator::ProgramSimulator;
10783
10784 struct InitModel {
10785 initialized: bool,
10786 updates: usize,
10787 }
10788
10789 #[derive(Debug)]
10790 enum InitMsg {
10791 Update,
10792 MarkInit,
10793 }
10794
10795 impl From<Event> for InitMsg {
10796 fn from(_: Event) -> Self {
10797 InitMsg::Update
10798 }
10799 }
10800
10801 impl Model for InitModel {
10802 type Message = InitMsg;
10803
10804 fn init(&mut self) -> Cmd<Self::Message> {
10805 Cmd::msg(InitMsg::MarkInit)
10806 }
10807
10808 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10809 match msg {
10810 InitMsg::MarkInit => {
10811 self.initialized = true;
10812 Cmd::none()
10813 }
10814 InitMsg::Update => {
10815 self.updates += 1;
10816 Cmd::none()
10817 }
10818 }
10819 }
10820
10821 fn view(&self, _frame: &mut Frame) {}
10822 }
10823
10824 let mut sim = ProgramSimulator::new(InitModel {
10825 initialized: false,
10826 updates: 0,
10827 });
10828 sim.init();
10829
10830 assert!(sim.model().initialized);
10831 sim.send(InitMsg::Update);
10832 assert_eq!(sim.model().updates, 1);
10833 }
10834
10835 #[test]
10840 fn ui_height_returns_correct_value_inline_mode() {
10841 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10843 use ftui_core::terminal_capabilities::TerminalCapabilities;
10844
10845 let output = Vec::new();
10846 let writer = TerminalWriter::new(
10847 output,
10848 ScreenMode::Inline { ui_height: 10 },
10849 UiAnchor::Bottom,
10850 TerminalCapabilities::basic(),
10851 );
10852 assert_eq!(writer.ui_height(), 10);
10853 }
10854
10855 #[test]
10856 fn ui_height_returns_term_height_altscreen_mode() {
10857 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10859 use ftui_core::terminal_capabilities::TerminalCapabilities;
10860
10861 let output = Vec::new();
10862 let mut writer = TerminalWriter::new(
10863 output,
10864 ScreenMode::AltScreen,
10865 UiAnchor::Bottom,
10866 TerminalCapabilities::basic(),
10867 );
10868 writer.set_size(80, 24);
10869 assert_eq!(writer.ui_height(), 24);
10870 }
10871
10872 #[test]
10873 fn inline_mode_frame_uses_ui_height_not_terminal_height() {
10874 use crate::simulator::ProgramSimulator;
10877 use std::cell::Cell as StdCell;
10878
10879 thread_local! {
10880 static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
10881 }
10882
10883 struct FrameSizeTracker;
10884
10885 #[derive(Debug)]
10886 enum SizeMsg {
10887 Check,
10888 }
10889
10890 impl From<Event> for SizeMsg {
10891 fn from(_: Event) -> Self {
10892 SizeMsg::Check
10893 }
10894 }
10895
10896 impl Model for FrameSizeTracker {
10897 type Message = SizeMsg;
10898
10899 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
10900 Cmd::none()
10901 }
10902
10903 fn view(&self, frame: &mut Frame) {
10904 CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
10906 }
10907 }
10908
10909 let mut sim = ProgramSimulator::new(FrameSizeTracker);
10911 sim.init();
10912
10913 let buf = sim.capture_frame(80, 10);
10915 assert_eq!(buf.height(), 10);
10916 assert_eq!(buf.width(), 80);
10917
10918 }
10922
10923 #[test]
10924 fn altscreen_frame_uses_full_terminal_height() {
10925 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10927 use ftui_core::terminal_capabilities::TerminalCapabilities;
10928
10929 let output = Vec::new();
10930 let mut writer = TerminalWriter::new(
10931 output,
10932 ScreenMode::AltScreen,
10933 UiAnchor::Bottom,
10934 TerminalCapabilities::basic(),
10935 );
10936 writer.set_size(80, 40);
10937
10938 assert_eq!(writer.ui_height(), 40);
10940 }
10941
10942 #[test]
10943 fn ui_height_clamped_to_terminal_height() {
10944 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
10947 use ftui_core::terminal_capabilities::TerminalCapabilities;
10948
10949 let output = Vec::new();
10950 let mut writer = TerminalWriter::new(
10951 output,
10952 ScreenMode::Inline { ui_height: 100 },
10953 UiAnchor::Bottom,
10954 TerminalCapabilities::basic(),
10955 );
10956 writer.set_size(80, 10);
10957
10958 assert_eq!(writer.ui_height(), 100);
10964 }
10965
10966 #[test]
10971 fn tick_event_delivered_to_model_update() {
10972 use crate::simulator::ProgramSimulator;
10975
10976 struct TickTracker {
10977 tick_count: usize,
10978 }
10979
10980 #[derive(Debug)]
10981 enum TickMsg {
10982 Tick,
10983 Other,
10984 }
10985
10986 impl From<Event> for TickMsg {
10987 fn from(event: Event) -> Self {
10988 match event {
10989 Event::Tick => TickMsg::Tick,
10990 _ => TickMsg::Other,
10991 }
10992 }
10993 }
10994
10995 impl Model for TickTracker {
10996 type Message = TickMsg;
10997
10998 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
10999 match msg {
11000 TickMsg::Tick => {
11001 self.tick_count += 1;
11002 Cmd::none()
11003 }
11004 TickMsg::Other => Cmd::none(),
11005 }
11006 }
11007
11008 fn view(&self, _frame: &mut Frame) {}
11009 }
11010
11011 let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
11012 sim.init();
11013
11014 sim.inject_event(Event::Tick);
11016 assert_eq!(sim.model().tick_count, 1);
11017
11018 sim.inject_event(Event::Tick);
11019 sim.inject_event(Event::Tick);
11020 assert_eq!(sim.model().tick_count, 3);
11021 }
11022
11023 #[test]
11024 fn tick_command_sets_tick_rate() {
11025 use crate::simulator::{CmdRecord, ProgramSimulator};
11027
11028 struct TickModel;
11029
11030 #[derive(Debug)]
11031 enum Msg {
11032 SetTick,
11033 Noop,
11034 }
11035
11036 impl From<Event> for Msg {
11037 fn from(_: Event) -> Self {
11038 Msg::Noop
11039 }
11040 }
11041
11042 impl Model for TickModel {
11043 type Message = Msg;
11044
11045 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11046 match msg {
11047 Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
11048 Msg::Noop => Cmd::none(),
11049 }
11050 }
11051
11052 fn view(&self, _frame: &mut Frame) {}
11053 }
11054
11055 let mut sim = ProgramSimulator::new(TickModel);
11056 sim.init();
11057 sim.send(Msg::SetTick);
11058
11059 let commands = sim.command_log();
11061 assert!(
11062 commands
11063 .iter()
11064 .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
11065 );
11066 }
11067
11068 #[test]
11069 fn tick_can_trigger_further_commands() {
11070 use crate::simulator::ProgramSimulator;
11072
11073 struct ChainModel {
11074 stage: usize,
11075 }
11076
11077 #[derive(Debug)]
11078 enum ChainMsg {
11079 Tick,
11080 Advance,
11081 Noop,
11082 }
11083
11084 impl From<Event> for ChainMsg {
11085 fn from(event: Event) -> Self {
11086 match event {
11087 Event::Tick => ChainMsg::Tick,
11088 _ => ChainMsg::Noop,
11089 }
11090 }
11091 }
11092
11093 impl Model for ChainModel {
11094 type Message = ChainMsg;
11095
11096 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11097 match msg {
11098 ChainMsg::Tick => {
11099 self.stage += 1;
11100 Cmd::msg(ChainMsg::Advance)
11102 }
11103 ChainMsg::Advance => {
11104 self.stage += 10;
11105 Cmd::none()
11106 }
11107 ChainMsg::Noop => Cmd::none(),
11108 }
11109 }
11110
11111 fn view(&self, _frame: &mut Frame) {}
11112 }
11113
11114 let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
11115 sim.init();
11116 sim.inject_event(Event::Tick);
11117
11118 assert_eq!(sim.model().stage, 11);
11120 }
11121
11122 #[test]
11123 fn tick_disabled_with_zero_duration() {
11124 use crate::simulator::ProgramSimulator;
11126
11127 struct ZeroTickModel {
11128 disabled: bool,
11129 }
11130
11131 #[derive(Debug)]
11132 enum ZeroMsg {
11133 DisableTick,
11134 Noop,
11135 }
11136
11137 impl From<Event> for ZeroMsg {
11138 fn from(_: Event) -> Self {
11139 ZeroMsg::Noop
11140 }
11141 }
11142
11143 impl Model for ZeroTickModel {
11144 type Message = ZeroMsg;
11145
11146 fn init(&mut self) -> Cmd<Self::Message> {
11147 Cmd::tick(Duration::from_millis(100))
11149 }
11150
11151 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11152 match msg {
11153 ZeroMsg::DisableTick => {
11154 self.disabled = true;
11155 Cmd::tick(Duration::ZERO)
11157 }
11158 ZeroMsg::Noop => Cmd::none(),
11159 }
11160 }
11161
11162 fn view(&self, _frame: &mut Frame) {}
11163 }
11164
11165 let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
11166 sim.init();
11167
11168 assert!(sim.tick_rate().is_some());
11170 assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
11171
11172 sim.send(ZeroMsg::DisableTick);
11174 assert!(sim.model().disabled);
11175
11176 assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
11179 }
11180
11181 #[test]
11182 fn tick_event_distinguishable_from_other_events() {
11183 let tick = Event::Tick;
11185 let key = Event::Key(ftui_core::event::KeyEvent::new(
11186 ftui_core::event::KeyCode::Char('a'),
11187 ));
11188
11189 assert!(matches!(tick, Event::Tick));
11190 assert!(!matches!(key, Event::Tick));
11191 }
11192
11193 #[test]
11194 fn tick_event_clone_and_eq() {
11195 let tick1 = Event::Tick;
11197 let tick2 = tick1.clone();
11198 assert_eq!(tick1, tick2);
11199 }
11200
11201 #[test]
11202 fn model_receives_tick_and_input_events() {
11203 use crate::simulator::ProgramSimulator;
11205
11206 struct MixedModel {
11207 ticks: usize,
11208 keys: usize,
11209 }
11210
11211 #[derive(Debug)]
11212 enum MixedMsg {
11213 Tick,
11214 Key,
11215 }
11216
11217 impl From<Event> for MixedMsg {
11218 fn from(event: Event) -> Self {
11219 match event {
11220 Event::Tick => MixedMsg::Tick,
11221 _ => MixedMsg::Key,
11222 }
11223 }
11224 }
11225
11226 impl Model for MixedModel {
11227 type Message = MixedMsg;
11228
11229 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11230 match msg {
11231 MixedMsg::Tick => {
11232 self.ticks += 1;
11233 Cmd::none()
11234 }
11235 MixedMsg::Key => {
11236 self.keys += 1;
11237 Cmd::none()
11238 }
11239 }
11240 }
11241
11242 fn view(&self, _frame: &mut Frame) {}
11243 }
11244
11245 let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
11246 sim.init();
11247
11248 sim.inject_event(Event::Tick);
11250 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11251 ftui_core::event::KeyCode::Char('a'),
11252 )));
11253 sim.inject_event(Event::Tick);
11254 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
11255 ftui_core::event::KeyCode::Char('b'),
11256 )));
11257 sim.inject_event(Event::Tick);
11258
11259 assert_eq!(sim.model().ticks, 3);
11260 assert_eq!(sim.model().keys, 2);
11261 }
11262
11263 fn headless_program_with_resolved_config<M: Model>(
11268 model: M,
11269 config: ProgramConfig,
11270 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11271 where
11272 M::Message: Send + 'static,
11273 {
11274 clear_termination_signal();
11275 let effect_queue_config = config.resolved_effect_queue_config();
11276 let capabilities = TerminalCapabilities::basic();
11277 let mut writer = TerminalWriter::with_diff_config(
11278 Vec::new(),
11279 config.screen_mode,
11280 config.ui_anchor,
11281 capabilities,
11282 config.diff_config.clone(),
11283 );
11284 let frame_timing = config.frame_timing.clone();
11285 writer.set_timing_enabled(frame_timing.is_some());
11286
11287 let (width, height) = config.forced_size.unwrap_or((80, 24));
11288 let width = width.max(1);
11289 let height = height.max(1);
11290 writer.set_size(width, height);
11291
11292 let mouse_capture = config.resolved_mouse_capture();
11293 let initial_features = BackendFeatures {
11294 mouse_capture,
11295 bracketed_paste: config.bracketed_paste,
11296 focus_events: config.focus_reporting,
11297 kitty_keyboard: config.kitty_keyboard,
11298 };
11299 let events = HeadlessEventSource::new(width, height, initial_features);
11300 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)
11301 .expect("headless evidence sink config");
11302
11303 let budget = render_budget_from_program_config(&config);
11304 let load_governor = LoadGovernorState::new(
11305 config.load_governor.clone(),
11306 effect_queue_config.max_queue_depth,
11307 );
11308 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
11309 let locale_context = config.locale_context.clone();
11310 let locale_version = locale_context.version();
11311 let mut resize_coalescer =
11312 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
11313 if let Some(ref sink) = evidence_sink {
11314 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
11315 }
11316 let subscriptions = SubscriptionManager::new();
11317 let (task_sender, task_receiver) = std::sync::mpsc::channel();
11318 let inline_auto_remeasure = config
11319 .inline_auto_remeasure
11320 .clone()
11321 .map(InlineAutoRemeasureState::new);
11322 let guardrails = FrameGuardrails::new(config.guardrails);
11323 let task_executor = TaskExecutor::new(
11324 &effect_queue_config,
11325 task_sender.clone(),
11326 evidence_sink.clone(),
11327 )
11328 .expect("task executor");
11329
11330 Program {
11331 model,
11332 writer,
11333 events,
11334 backend_features: initial_features,
11335 running: true,
11336 shutdown_complete: false,
11337 tick_rate: None,
11338 executed_cmd_count: 0,
11339 last_tick: Instant::now(),
11340 dirty: true,
11341 frame_idx: 0,
11342 tick_count: 0,
11343 widget_signals: Vec::new(),
11344 widget_refresh_config: config.widget_refresh,
11345 widget_refresh_plan: WidgetRefreshPlan::new(),
11346 width,
11347 height,
11348 forced_size: config.forced_size,
11349 poll_timeout: config.poll_timeout,
11350 intercept_signals: config.intercept_signals,
11351 immediate_drain_config: config.immediate_drain,
11352 immediate_drain_stats: ImmediateDrainStats::default(),
11353 budget,
11354 load_governor,
11355 conformal_predictor,
11356 last_frame_time_us: None,
11357 last_update_us: None,
11358 frame_timing,
11359 locale_context,
11360 locale_version,
11361 resize_coalescer,
11362 evidence_sink,
11363 fairness_config_logged: false,
11364 resize_behavior: config.resize_behavior,
11365 fairness_guard: InputFairnessGuard::new(),
11366 event_recorder: None,
11367 subscriptions,
11368 #[cfg(test)]
11369 task_sender,
11370 task_receiver,
11371 task_executor,
11372 state_registry: config.persistence.registry.clone(),
11373 persistence_config: config.persistence,
11374 last_checkpoint: Instant::now(),
11375 inline_auto_remeasure,
11376 frame_arena: FrameArena::default(),
11377 guardrails,
11378 last_soft_trim_frame: None,
11379 tick_strategy: config
11380 .tick_strategy
11381 .map(|strategy| Box::new(strategy) as Box<dyn crate::tick_strategy::TickStrategy>),
11382 last_active_screen_for_strategy: None,
11383 }
11384 }
11385
11386 fn headless_program_with_config<M: Model>(
11387 model: M,
11388 config: ProgramConfig,
11389 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11390 where
11391 M::Message: Send + 'static,
11392 {
11393 headless_program_with_resolved_config(model, config.with_signal_interception(false))
11396 }
11397
11398 fn headless_signal_program_with_config<M: Model>(
11399 model: M,
11400 config: ProgramConfig,
11401 ) -> Program<M, HeadlessEventSource, Vec<u8>>
11402 where
11403 M::Message: Send + 'static,
11404 {
11405 headless_program_with_resolved_config(model, config)
11406 }
11407
11408 fn temp_evidence_path(label: &str) -> PathBuf {
11409 static COUNTER: AtomicUsize = AtomicUsize::new(0);
11410 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
11411 let pid = std::process::id();
11412 let mut path = std::env::temp_dir();
11413 path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
11414 path
11415 }
11416
11417 fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
11418 let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
11419 let needle = format!("\"event\":\"{event}\"");
11420 let missing_msg = format!("missing {event} line");
11421 let line = jsonl
11422 .lines()
11423 .find(|line| line.contains(&needle))
11424 .expect(&missing_msg);
11425 serde_json::from_str(line).expect("valid evidence json")
11426 }
11427
11428 #[test]
11429 fn headless_apply_resize_updates_model_and_dimensions() {
11430 struct ResizeModel {
11431 last_size: Option<(u16, u16)>,
11432 }
11433
11434 #[derive(Debug)]
11435 enum ResizeMsg {
11436 Resize(u16, u16),
11437 Other,
11438 }
11439
11440 impl From<Event> for ResizeMsg {
11441 fn from(event: Event) -> Self {
11442 match event {
11443 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
11444 _ => ResizeMsg::Other,
11445 }
11446 }
11447 }
11448
11449 impl Model for ResizeModel {
11450 type Message = ResizeMsg;
11451
11452 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11453 if let ResizeMsg::Resize(w, h) = msg {
11454 self.last_size = Some((w, h));
11455 }
11456 Cmd::none()
11457 }
11458
11459 fn view(&self, _frame: &mut Frame) {}
11460 }
11461
11462 let mut program =
11463 headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
11464 program.dirty = false;
11465
11466 program
11467 .apply_resize(0, 0, Duration::ZERO, false)
11468 .expect("resize");
11469
11470 assert_eq!(program.width, 1);
11471 assert_eq!(program.height, 1);
11472 assert_eq!(program.model().last_size, Some((1, 1)));
11473 assert!(program.dirty);
11474 }
11475
11476 #[test]
11477 fn headless_apply_resize_reconciles_subscriptions() {
11478 use crate::subscription::{StopSignal, SubId, Subscription};
11479
11480 struct ResizeSubModel {
11481 subscribed: bool,
11482 }
11483
11484 #[derive(Debug)]
11485 enum ResizeSubMsg {
11486 Resize,
11487 Other,
11488 }
11489
11490 impl From<Event> for ResizeSubMsg {
11491 fn from(event: Event) -> Self {
11492 match event {
11493 Event::Resize { .. } => Self::Resize,
11494 _ => Self::Other,
11495 }
11496 }
11497 }
11498
11499 impl Model for ResizeSubModel {
11500 type Message = ResizeSubMsg;
11501
11502 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11503 if matches!(msg, ResizeSubMsg::Resize) {
11504 self.subscribed = true;
11505 }
11506 Cmd::none()
11507 }
11508
11509 fn view(&self, _frame: &mut Frame) {}
11510
11511 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11512 if self.subscribed {
11513 vec![Box::new(ResizeSubscription)]
11514 } else {
11515 vec![]
11516 }
11517 }
11518 }
11519
11520 struct ResizeSubscription;
11521
11522 impl Subscription<ResizeSubMsg> for ResizeSubscription {
11523 fn id(&self) -> SubId {
11524 1
11525 }
11526
11527 fn run(&self, _sender: mpsc::Sender<ResizeSubMsg>, _stop: StopSignal) {}
11528 }
11529
11530 let mut program = headless_program_with_config(
11531 ResizeSubModel { subscribed: false },
11532 ProgramConfig::default(),
11533 );
11534
11535 assert_eq!(program.subscriptions.active_count(), 0);
11536 program
11537 .apply_resize(120, 40, Duration::ZERO, false)
11538 .expect("resize");
11539
11540 assert!(program.model().subscribed);
11541 assert_eq!(program.subscriptions.active_count(), 1);
11542 }
11543
11544 #[test]
11545 fn headless_execute_cmd_log_writes_output() {
11546 let mut program =
11547 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
11548 program.execute_cmd(Cmd::log("hello world")).expect("log");
11549
11550 let bytes = program.writer.into_inner().expect("writer output");
11551 let output = String::from_utf8_lossy(&bytes);
11552 assert!(output.contains("hello world"));
11553 }
11554
11555 #[test]
11556 fn headless_process_task_results_updates_model() {
11557 struct TaskModel {
11558 updates: usize,
11559 }
11560
11561 #[derive(Debug)]
11562 enum TaskMsg {
11563 Done,
11564 }
11565
11566 impl From<Event> for TaskMsg {
11567 fn from(_: Event) -> Self {
11568 TaskMsg::Done
11569 }
11570 }
11571
11572 impl Model for TaskModel {
11573 type Message = TaskMsg;
11574
11575 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
11576 self.updates += 1;
11577 Cmd::none()
11578 }
11579
11580 fn view(&self, _frame: &mut Frame) {}
11581 }
11582
11583 let mut program =
11584 headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
11585 program.dirty = false;
11586 program.task_sender.send(TaskMsg::Done).unwrap();
11587
11588 program
11589 .process_task_results()
11590 .expect("process task results");
11591 assert_eq!(program.model().updates, 1);
11592 assert!(program.dirty);
11593 }
11594
11595 #[test]
11596 fn run_invokes_on_shutdown_after_quit() {
11597 use std::sync::{
11598 Arc,
11599 atomic::{AtomicUsize, Ordering},
11600 };
11601
11602 struct ShutdownModel {
11603 shutdowns: Arc<AtomicUsize>,
11604 }
11605
11606 #[derive(Debug, Clone, Copy)]
11607 enum ShutdownMsg {
11608 Quit,
11609 ShutdownRan,
11610 }
11611
11612 impl From<Event> for ShutdownMsg {
11613 fn from(_: Event) -> Self {
11614 ShutdownMsg::Quit
11615 }
11616 }
11617
11618 impl Model for ShutdownModel {
11619 type Message = ShutdownMsg;
11620
11621 fn init(&mut self) -> Cmd<Self::Message> {
11622 Cmd::quit()
11623 }
11624
11625 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11626 match msg {
11627 ShutdownMsg::Quit => Cmd::quit(),
11628 ShutdownMsg::ShutdownRan => {
11629 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11630 Cmd::none()
11631 }
11632 }
11633 }
11634
11635 fn view(&self, _frame: &mut Frame) {}
11636
11637 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11638 Cmd::msg(ShutdownMsg::ShutdownRan)
11639 }
11640 }
11641
11642 let shutdowns = Arc::new(AtomicUsize::new(0));
11643 let mut program = headless_program_with_config(
11644 ShutdownModel {
11645 shutdowns: Arc::clone(&shutdowns),
11646 },
11647 ProgramConfig::default(),
11648 );
11649
11650 program.run().expect("program run");
11651
11652 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11653 }
11654
11655 #[test]
11656 fn run_processes_shutdown_task_results_before_exit() {
11657 use std::sync::{
11658 Arc,
11659 atomic::{AtomicUsize, Ordering},
11660 };
11661
11662 struct ShutdownTaskModel {
11663 shutdowns: Arc<AtomicUsize>,
11664 }
11665
11666 #[derive(Debug, Clone, Copy)]
11667 enum ShutdownTaskMsg {
11668 Quit,
11669 ShutdownRan,
11670 }
11671
11672 impl From<Event> for ShutdownTaskMsg {
11673 fn from(_: Event) -> Self {
11674 ShutdownTaskMsg::Quit
11675 }
11676 }
11677
11678 impl Model for ShutdownTaskModel {
11679 type Message = ShutdownTaskMsg;
11680
11681 fn init(&mut self) -> Cmd<Self::Message> {
11682 Cmd::quit()
11683 }
11684
11685 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11686 match msg {
11687 ShutdownTaskMsg::Quit => Cmd::quit(),
11688 ShutdownTaskMsg::ShutdownRan => {
11689 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11690 Cmd::none()
11691 }
11692 }
11693 }
11694
11695 fn view(&self, _frame: &mut Frame) {}
11696
11697 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11698 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11699 }
11700 }
11701
11702 let shutdowns = Arc::new(AtomicUsize::new(0));
11703 let mut program = headless_program_with_config(
11704 ShutdownTaskModel {
11705 shutdowns: Arc::clone(&shutdowns),
11706 },
11707 ProgramConfig::default(),
11708 );
11709
11710 program.run().expect("program run");
11711
11712 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11713 }
11714
11715 #[test]
11716 fn run_processes_shutdown_task_results_with_effect_queue_backend() {
11717 use std::sync::{
11718 Arc,
11719 atomic::{AtomicUsize, Ordering},
11720 };
11721
11722 struct ShutdownTaskModel {
11723 shutdowns: Arc<AtomicUsize>,
11724 }
11725
11726 #[derive(Debug, Clone, Copy)]
11727 enum ShutdownTaskMsg {
11728 Quit,
11729 ShutdownRan,
11730 }
11731
11732 impl From<Event> for ShutdownTaskMsg {
11733 fn from(_: Event) -> Self {
11734 ShutdownTaskMsg::Quit
11735 }
11736 }
11737
11738 impl Model for ShutdownTaskModel {
11739 type Message = ShutdownTaskMsg;
11740
11741 fn init(&mut self) -> Cmd<Self::Message> {
11742 Cmd::quit()
11743 }
11744
11745 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11746 match msg {
11747 ShutdownTaskMsg::Quit => Cmd::quit(),
11748 ShutdownTaskMsg::ShutdownRan => {
11749 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11750 Cmd::none()
11751 }
11752 }
11753 }
11754
11755 fn view(&self, _frame: &mut Frame) {}
11756
11757 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11758 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11759 }
11760 }
11761
11762 let shutdowns = Arc::new(AtomicUsize::new(0));
11763 let mut program = headless_program_with_config(
11764 ShutdownTaskModel {
11765 shutdowns: Arc::clone(&shutdowns),
11766 },
11767 ProgramConfig::default().with_effect_queue(
11768 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
11769 ),
11770 );
11771
11772 program.run().expect("program run");
11773
11774 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11775 }
11776
11777 #[test]
11778 fn shutdown_task_results_do_not_spawn_follow_up_tasks_after_executor_shutdown() {
11779 use std::sync::{
11780 Arc,
11781 atomic::{AtomicUsize, Ordering},
11782 };
11783
11784 struct ShutdownTaskModel {
11785 shutdowns: Arc<AtomicUsize>,
11786 follow_up_runs: Arc<AtomicUsize>,
11787 }
11788
11789 #[derive(Debug, Clone, Copy)]
11790 enum ShutdownTaskMsg {
11791 Quit,
11792 ShutdownRan,
11793 FollowUp,
11794 }
11795
11796 impl From<Event> for ShutdownTaskMsg {
11797 fn from(_: Event) -> Self {
11798 ShutdownTaskMsg::Quit
11799 }
11800 }
11801
11802 impl Model for ShutdownTaskModel {
11803 type Message = ShutdownTaskMsg;
11804
11805 fn init(&mut self) -> Cmd<Self::Message> {
11806 Cmd::quit()
11807 }
11808
11809 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11810 match msg {
11811 ShutdownTaskMsg::Quit => Cmd::quit(),
11812 ShutdownTaskMsg::ShutdownRan => {
11813 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11814 let follow_up_runs = Arc::clone(&self.follow_up_runs);
11815 Cmd::task(move || {
11816 follow_up_runs.fetch_add(1, Ordering::SeqCst);
11817 ShutdownTaskMsg::FollowUp
11818 })
11819 }
11820 ShutdownTaskMsg::FollowUp => {
11821 self.follow_up_runs.fetch_add(1, Ordering::SeqCst);
11822 Cmd::none()
11823 }
11824 }
11825 }
11826
11827 fn view(&self, _frame: &mut Frame) {}
11828
11829 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11830 Cmd::task(|| ShutdownTaskMsg::ShutdownRan)
11831 }
11832 }
11833
11834 let shutdowns = Arc::new(AtomicUsize::new(0));
11835 let follow_up_runs = Arc::new(AtomicUsize::new(0));
11836 let mut program = headless_program_with_config(
11837 ShutdownTaskModel {
11838 shutdowns: Arc::clone(&shutdowns),
11839 follow_up_runs: Arc::clone(&follow_up_runs),
11840 },
11841 ProgramConfig::default(),
11842 );
11843
11844 program.run().expect("program run");
11845
11846 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11847 assert_eq!(follow_up_runs.load(Ordering::SeqCst), 0);
11848 }
11849
11850 #[test]
11851 fn run_quit_from_init_skips_initial_render_and_subscription_start() {
11852 use crate::subscription::{StopSignal, SubId, Subscription};
11853
11854 struct InitQuitModel {
11855 render_calls: Arc<AtomicUsize>,
11856 subscription_starts: Arc<AtomicUsize>,
11857 }
11858
11859 #[derive(Debug, Clone, Copy)]
11860 enum InitQuitMsg {
11861 Noop,
11862 }
11863
11864 impl From<Event> for InitQuitMsg {
11865 fn from(_: Event) -> Self {
11866 Self::Noop
11867 }
11868 }
11869
11870 impl Model for InitQuitModel {
11871 type Message = InitQuitMsg;
11872
11873 fn init(&mut self) -> Cmd<Self::Message> {
11874 Cmd::quit()
11875 }
11876
11877 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
11878 Cmd::none()
11879 }
11880
11881 fn view(&self, _frame: &mut Frame) {
11882 self.render_calls.fetch_add(1, Ordering::SeqCst);
11883 }
11884
11885 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
11886 vec![Box::new(InitQuitSubscription {
11887 starts: Arc::clone(&self.subscription_starts),
11888 })]
11889 }
11890 }
11891
11892 struct InitQuitSubscription {
11893 starts: Arc<AtomicUsize>,
11894 }
11895
11896 impl Subscription<InitQuitMsg> for InitQuitSubscription {
11897 fn id(&self) -> SubId {
11898 1
11899 }
11900
11901 fn run(&self, _sender: mpsc::Sender<InitQuitMsg>, stop: StopSignal) {
11902 self.starts.fetch_add(1, Ordering::SeqCst);
11903 let _ = stop.wait_timeout(Duration::from_millis(10));
11904 }
11905 }
11906
11907 let render_calls = Arc::new(AtomicUsize::new(0));
11908 let subscription_starts = Arc::new(AtomicUsize::new(0));
11909 let mut program = headless_program_with_config(
11910 InitQuitModel {
11911 render_calls: Arc::clone(&render_calls),
11912 subscription_starts: Arc::clone(&subscription_starts),
11913 },
11914 ProgramConfig::default(),
11915 );
11916
11917 program.run().expect("program run");
11918
11919 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
11920 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
11921 }
11922
11923 #[test]
11924 fn run_invokes_on_shutdown_before_returning_signal_error() {
11925 use std::sync::{
11926 Arc,
11927 atomic::{AtomicUsize, Ordering},
11928 };
11929
11930 struct ShutdownModel {
11931 shutdowns: Arc<AtomicUsize>,
11932 }
11933
11934 #[derive(Debug, Clone, Copy)]
11935 enum ShutdownMsg {
11936 Noop,
11937 ShutdownRan,
11938 }
11939
11940 impl From<Event> for ShutdownMsg {
11941 fn from(_: Event) -> Self {
11942 ShutdownMsg::Noop
11943 }
11944 }
11945
11946 impl Model for ShutdownModel {
11947 type Message = ShutdownMsg;
11948
11949 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
11950 match msg {
11951 ShutdownMsg::Noop => Cmd::none(),
11952 ShutdownMsg::ShutdownRan => {
11953 self.shutdowns.fetch_add(1, Ordering::SeqCst);
11954 Cmd::none()
11955 }
11956 }
11957 }
11958
11959 fn view(&self, _frame: &mut Frame) {}
11960
11961 fn on_shutdown(&mut self) -> Cmd<Self::Message> {
11962 Cmd::msg(ShutdownMsg::ShutdownRan)
11963 }
11964 }
11965
11966 let shutdowns = Arc::new(AtomicUsize::new(0));
11967 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
11968 let mut program = headless_signal_program_with_config(
11969 ShutdownModel {
11970 shutdowns: Arc::clone(&shutdowns),
11971 },
11972 ProgramConfig::default().with_signal_interception(true),
11973 );
11974
11975 ftui_core::shutdown_signal::record_pending_termination_signal(2);
11976 let err = program.run().expect_err("signal should stop runtime");
11977
11978 assert_eq!(shutdowns.load(Ordering::SeqCst), 1);
11979 assert_eq!(signal_termination_from_error(&err), Some(2));
11980 assert_eq!(check_termination_signal(), None);
11981 });
11982 }
11983
11984 #[test]
11985 fn run_pending_signal_skips_initial_render_and_subscription_start() {
11986 use crate::subscription::{StopSignal, SubId, Subscription};
11987
11988 struct SignalStopModel {
11989 render_calls: Arc<AtomicUsize>,
11990 subscription_starts: Arc<AtomicUsize>,
11991 }
11992
11993 #[derive(Debug, Clone, Copy)]
11994 enum SignalStopMsg {
11995 Noop,
11996 }
11997
11998 impl From<Event> for SignalStopMsg {
11999 fn from(_: Event) -> Self {
12000 Self::Noop
12001 }
12002 }
12003
12004 impl Model for SignalStopModel {
12005 type Message = SignalStopMsg;
12006
12007 fn update(&mut self, _: Self::Message) -> Cmd<Self::Message> {
12008 Cmd::none()
12009 }
12010
12011 fn view(&self, _frame: &mut Frame) {
12012 self.render_calls.fetch_add(1, Ordering::SeqCst);
12013 }
12014
12015 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12016 vec![Box::new(SignalStopSubscription {
12017 starts: Arc::clone(&self.subscription_starts),
12018 })]
12019 }
12020 }
12021
12022 struct SignalStopSubscription {
12023 starts: Arc<AtomicUsize>,
12024 }
12025
12026 impl Subscription<SignalStopMsg> for SignalStopSubscription {
12027 fn id(&self) -> SubId {
12028 11
12029 }
12030
12031 fn run(&self, _sender: mpsc::Sender<SignalStopMsg>, stop: StopSignal) {
12032 self.starts.fetch_add(1, Ordering::SeqCst);
12033 let _ = stop.wait_timeout(Duration::from_millis(10));
12034 }
12035 }
12036
12037 let render_calls = Arc::new(AtomicUsize::new(0));
12038 let subscription_starts = Arc::new(AtomicUsize::new(0));
12039 ftui_core::shutdown_signal::with_test_signal_serialization(|| {
12040 let mut program = headless_signal_program_with_config(
12041 SignalStopModel {
12042 render_calls: Arc::clone(&render_calls),
12043 subscription_starts: Arc::clone(&subscription_starts),
12044 },
12045 ProgramConfig::default().with_signal_interception(true),
12046 );
12047
12048 ftui_core::shutdown_signal::record_pending_termination_signal(15);
12049 let err = program.run().expect_err("signal should stop runtime");
12050
12051 assert_eq!(signal_termination_from_error(&err), Some(15));
12052 assert_eq!(render_calls.load(Ordering::SeqCst), 0);
12053 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12054 assert_eq!(check_termination_signal(), None);
12055 });
12056 }
12057
12058 #[test]
12059 fn headless_should_tick_and_timeout_behaviors() {
12060 let mut program =
12061 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12062 program.tick_rate = Some(Duration::from_millis(5));
12063 program.last_tick = Instant::now() - Duration::from_millis(10);
12064
12065 assert!(program.should_tick());
12066 assert!(!program.should_tick());
12067
12068 let timeout = program.effective_timeout();
12069 assert!(timeout <= Duration::from_millis(5));
12070
12071 program.tick_rate = None;
12072 program.poll_timeout = Duration::from_millis(33);
12073 assert_eq!(program.effective_timeout(), Duration::from_millis(33));
12074 }
12075
12076 #[test]
12077 fn headless_effective_timeout_respects_resize_coalescer() {
12078 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12079 config.resize_coalescer.steady_delay_ms = 0;
12080 config.resize_coalescer.burst_delay_ms = 0;
12081
12082 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12083 program.tick_rate = Some(Duration::from_millis(50));
12084
12085 program.resize_coalescer.handle_resize(120, 40);
12086 assert!(program.resize_coalescer.has_pending());
12087
12088 let timeout = program.effective_timeout();
12089 assert_eq!(timeout, Duration::ZERO);
12090 }
12091
12092 #[test]
12093 fn headless_ui_height_remeasure_clears_auto_height() {
12094 let mut config = ProgramConfig::inline_auto(2, 6);
12095 config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
12096
12097 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12098 program.dirty = false;
12099 program.writer.set_auto_ui_height(5);
12100
12101 assert_eq!(program.writer.auto_ui_height(), Some(5));
12102 program.request_ui_height_remeasure();
12103
12104 assert_eq!(program.writer.auto_ui_height(), None);
12105 assert!(program.dirty);
12106 }
12107
12108 #[test]
12109 fn headless_recording_lifecycle_and_locale_change() {
12110 let mut program =
12111 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12112 program.dirty = false;
12113
12114 program.start_recording("demo");
12115 assert!(program.is_recording());
12116 let recorded = program.stop_recording();
12117 assert!(recorded.is_some());
12118 assert!(!program.is_recording());
12119
12120 let prev_dirty = program.dirty;
12121 program.locale_context.set_locale("fr");
12122 program.check_locale_change();
12123 assert!(program.dirty || prev_dirty);
12124 }
12125
12126 #[test]
12127 fn headless_render_frame_marks_clean_and_sets_diff() {
12128 struct RenderModel;
12129
12130 #[derive(Debug)]
12131 enum RenderMsg {
12132 Noop,
12133 }
12134
12135 impl From<Event> for RenderMsg {
12136 fn from(_: Event) -> Self {
12137 RenderMsg::Noop
12138 }
12139 }
12140
12141 impl Model for RenderModel {
12142 type Message = RenderMsg;
12143
12144 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12145 Cmd::none()
12146 }
12147
12148 fn view(&self, frame: &mut Frame) {
12149 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
12150 }
12151 }
12152
12153 let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
12154 program.render_frame().expect("render frame");
12155
12156 assert!(!program.dirty);
12157 assert!(program.writer.last_diff_strategy().is_some());
12158 assert_eq!(program.frame_idx, 1);
12159 }
12160
12161 #[test]
12166 fn headless_render_frame_emits_guardrail_snapshot_evidence_on_alert() {
12167 struct AlertModel;
12168
12169 #[derive(Debug)]
12170 enum AlertMsg {
12171 Noop,
12172 }
12173
12174 impl From<Event> for AlertMsg {
12175 fn from(_: Event) -> Self {
12176 AlertMsg::Noop
12177 }
12178 }
12179
12180 impl Model for AlertModel {
12181 type Message = AlertMsg;
12182
12183 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12184 Cmd::none()
12185 }
12186
12187 fn view(&self, frame: &mut Frame) {
12188 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
12189 }
12190 }
12191
12192 let evidence_path = temp_evidence_path("guardrail_snapshot");
12193 let config = ProgramConfig {
12194 guardrails: GuardrailsConfig {
12195 memory: MemoryBudgetConfig {
12198 soft_limit_bytes: 1,
12199 ..MemoryBudgetConfig::default()
12200 },
12201 queue: QueueConfig::default(),
12202 },
12203 evidence_sink: EvidenceSinkConfig::enabled_file(&evidence_path),
12204 ..Default::default()
12205 };
12206
12207 let mut program = headless_program_with_config(AlertModel, config);
12208 program.dirty = true;
12209 program.render_frame().expect("render frame with alert");
12210
12211 let contents =
12212 std::fs::read_to_string(&evidence_path).expect("guardrail evidence file written");
12213 let _ = std::fs::remove_file(&evidence_path);
12214 assert!(
12215 contents.contains(r#""event":"guardrail_snapshot""#),
12216 "alerting frame must export a guardrail_snapshot row; got: {contents}"
12217 );
12218 assert!(
12219 contents.contains(r#""mem_soft_violations":"#),
12220 "snapshot row must carry the violation counters"
12221 );
12222 }
12223
12224 #[test]
12228 fn headless_render_frame_soft_alert_trims_retained_capacity() {
12229 struct AlertModel;
12230
12231 #[derive(Debug)]
12232 enum AlertMsg {
12233 Noop,
12234 }
12235
12236 impl From<Event> for AlertMsg {
12237 fn from(_: Event) -> Self {
12238 AlertMsg::Noop
12239 }
12240 }
12241
12242 impl Model for AlertModel {
12243 type Message = AlertMsg;
12244
12245 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12246 Cmd::none()
12247 }
12248
12249 fn view(&self, _frame: &mut Frame) {}
12250 }
12251
12252 let config = ProgramConfig {
12253 guardrails: GuardrailsConfig {
12254 memory: MemoryBudgetConfig {
12255 soft_limit_bytes: 4096,
12256 ..MemoryBudgetConfig::default()
12257 },
12258 queue: QueueConfig::default(),
12259 },
12260 ..Default::default()
12261 };
12262
12263 let mut program = headless_program_with_config(AlertModel, config.clone());
12264
12265 let mut control = headless_program_with_config(AlertModel, config);
12269 control.dirty = true;
12270 control.render_frame().expect("control render");
12271 let control_bytes = control.frame_arena.allocated_bytes();
12272
12273 let filler = vec![0u8; 256 * 1024];
12276 program.frame_arena.alloc_slice(&filler);
12277 assert!(program.frame_arena.allocated_bytes() >= 256 * 1024);
12278
12279 program.dirty = true;
12280 program
12281 .render_frame()
12282 .expect("render frame with soft alert");
12283 assert_eq!(
12284 program.last_soft_trim_frame,
12285 Some(program.frame_idx),
12286 "soft alert must trigger the capacity trim"
12287 );
12288
12289 let after = program.frame_arena.allocated_bytes();
12292 assert!(
12293 after <= control_bytes + 1024,
12294 "soft alert must trim retained capacity: after={after}, control={control_bytes}"
12295 );
12296 }
12297
12298 #[test]
12299 fn headless_render_frame_skips_when_budget_exhausted() {
12300 let config = ProgramConfig {
12301 budget: FrameBudgetConfig::with_total(Duration::ZERO),
12302 ..Default::default()
12303 };
12304
12305 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12306 program.dirty = true;
12307 program.render_frame().expect("render frame");
12308
12309 assert!(program.dirty);
12312 assert_eq!(program.frame_idx, 1);
12313 }
12314
12315 #[test]
12316 fn headless_render_frame_emits_budget_evidence_with_controller() {
12317 use ftui_render::budget::BudgetControllerConfig;
12318
12319 struct RenderModel;
12320
12321 #[derive(Debug)]
12322 enum RenderMsg {
12323 Noop,
12324 }
12325
12326 impl From<Event> for RenderMsg {
12327 fn from(_: Event) -> Self {
12328 RenderMsg::Noop
12329 }
12330 }
12331
12332 impl Model for RenderModel {
12333 type Message = RenderMsg;
12334
12335 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
12336 Cmd::none()
12337 }
12338
12339 fn view(&self, frame: &mut Frame) {
12340 frame.buffer.set_raw(0, 0, Cell::from_char('E'));
12341 }
12342 }
12343
12344 let config =
12345 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
12346 let mut program = headless_program_with_config(RenderModel, config);
12347 program.budget = program
12348 .budget
12349 .with_controller(BudgetControllerConfig::default());
12350
12351 program.render_frame().expect("render frame");
12352 assert!(program.budget.telemetry().is_some());
12353 assert_eq!(program.frame_idx, 1);
12354 }
12355
12356 #[test]
12357 fn headless_handle_event_updates_model() {
12358 struct EventModel {
12359 events: usize,
12360 last_resize: Option<(u16, u16)>,
12361 }
12362
12363 #[derive(Debug)]
12364 enum EventMsg {
12365 Resize(u16, u16),
12366 Other,
12367 }
12368
12369 impl From<Event> for EventMsg {
12370 fn from(event: Event) -> Self {
12371 match event {
12372 Event::Resize { width, height } => EventMsg::Resize(width, height),
12373 _ => EventMsg::Other,
12374 }
12375 }
12376 }
12377
12378 impl Model for EventModel {
12379 type Message = EventMsg;
12380
12381 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12382 self.events += 1;
12383 if let EventMsg::Resize(w, h) = msg {
12384 self.last_resize = Some((w, h));
12385 }
12386 Cmd::none()
12387 }
12388
12389 fn view(&self, _frame: &mut Frame) {}
12390 }
12391
12392 let mut program = headless_program_with_config(
12393 EventModel {
12394 events: 0,
12395 last_resize: None,
12396 },
12397 ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
12398 );
12399
12400 program
12401 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12402 ftui_core::event::KeyCode::Char('x'),
12403 )))
12404 .expect("handle key");
12405 assert_eq!(program.model().events, 1);
12406
12407 program
12408 .handle_event(Event::Resize {
12409 width: 10,
12410 height: 5,
12411 })
12412 .expect("handle resize");
12413 assert_eq!(program.model().events, 2);
12414 assert_eq!(program.model().last_resize, Some((10, 5)));
12415 assert_eq!(program.width, 10);
12416 assert_eq!(program.height, 5);
12417 }
12418
12419 #[test]
12420 fn headless_handle_event_quit_skips_subscription_reconcile() {
12421 use crate::subscription::{StopSignal, SubId, Subscription};
12422
12423 struct QuitSubModel {
12424 quitting: bool,
12425 subscription_starts: Arc<AtomicUsize>,
12426 }
12427
12428 #[derive(Debug)]
12429 enum QuitSubMsg {
12430 Quit,
12431 Other,
12432 }
12433
12434 impl From<Event> for QuitSubMsg {
12435 fn from(event: Event) -> Self {
12436 match event {
12437 Event::Key(_) => Self::Quit,
12438 _ => Self::Other,
12439 }
12440 }
12441 }
12442
12443 impl Model for QuitSubModel {
12444 type Message = QuitSubMsg;
12445
12446 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12447 match msg {
12448 QuitSubMsg::Quit => {
12449 self.quitting = true;
12450 Cmd::quit()
12451 }
12452 QuitSubMsg::Other => Cmd::none(),
12453 }
12454 }
12455
12456 fn view(&self, _frame: &mut Frame) {}
12457
12458 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12459 if self.quitting {
12460 vec![Box::new(QuitSubSubscription {
12461 starts: Arc::clone(&self.subscription_starts),
12462 })]
12463 } else {
12464 vec![]
12465 }
12466 }
12467 }
12468
12469 struct QuitSubSubscription {
12470 starts: Arc<AtomicUsize>,
12471 }
12472
12473 impl Subscription<QuitSubMsg> for QuitSubSubscription {
12474 fn id(&self) -> SubId {
12475 7
12476 }
12477
12478 fn run(&self, _sender: mpsc::Sender<QuitSubMsg>, stop: StopSignal) {
12479 self.starts.fetch_add(1, Ordering::SeqCst);
12480 let _ = stop.wait_timeout(Duration::from_millis(10));
12481 }
12482 }
12483
12484 let subscription_starts = Arc::new(AtomicUsize::new(0));
12485 let mut program = headless_program_with_config(
12486 QuitSubModel {
12487 quitting: false,
12488 subscription_starts: Arc::clone(&subscription_starts),
12489 },
12490 ProgramConfig::default(),
12491 );
12492
12493 program
12494 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
12495 ftui_core::event::KeyCode::Char('q'),
12496 )))
12497 .expect("handle event");
12498
12499 assert!(!program.is_running());
12500 assert_eq!(program.subscriptions.active_count(), 0);
12501 assert_eq!(subscription_starts.load(Ordering::SeqCst), 0);
12502 }
12503
12504 #[test]
12505 fn headless_handle_resize_ignored_when_forced_size() {
12506 struct ResizeModel {
12507 resized: bool,
12508 }
12509
12510 #[derive(Debug)]
12511 enum ResizeMsg {
12512 Resize,
12513 Other,
12514 }
12515
12516 impl From<Event> for ResizeMsg {
12517 fn from(event: Event) -> Self {
12518 match event {
12519 Event::Resize { .. } => ResizeMsg::Resize,
12520 _ => ResizeMsg::Other,
12521 }
12522 }
12523 }
12524
12525 impl Model for ResizeModel {
12526 type Message = ResizeMsg;
12527
12528 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12529 if matches!(msg, ResizeMsg::Resize) {
12530 self.resized = true;
12531 }
12532 Cmd::none()
12533 }
12534
12535 fn view(&self, _frame: &mut Frame) {}
12536 }
12537
12538 let config = ProgramConfig::default().with_forced_size(80, 24);
12539 let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
12540
12541 program
12542 .handle_event(Event::Resize {
12543 width: 120,
12544 height: 40,
12545 })
12546 .expect("handle resize");
12547
12548 assert_eq!(program.width, 80);
12549 assert_eq!(program.height, 24);
12550 assert!(!program.model().resized);
12551 }
12552
12553 #[test]
12554 fn headless_execute_cmd_batch_sequence_and_quit() {
12555 struct BatchModel {
12556 count: usize,
12557 }
12558
12559 #[derive(Debug)]
12560 enum BatchMsg {
12561 Inc,
12562 }
12563
12564 impl From<Event> for BatchMsg {
12565 fn from(_: Event) -> Self {
12566 BatchMsg::Inc
12567 }
12568 }
12569
12570 impl Model for BatchModel {
12571 type Message = BatchMsg;
12572
12573 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12574 match msg {
12575 BatchMsg::Inc => {
12576 self.count += 1;
12577 Cmd::none()
12578 }
12579 }
12580 }
12581
12582 fn view(&self, _frame: &mut Frame) {}
12583 }
12584
12585 let mut program =
12586 headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
12587
12588 program
12589 .execute_cmd(Cmd::Batch(vec![
12590 Cmd::msg(BatchMsg::Inc),
12591 Cmd::Sequence(vec![
12592 Cmd::msg(BatchMsg::Inc),
12593 Cmd::quit(),
12594 Cmd::msg(BatchMsg::Inc),
12595 ]),
12596 ]))
12597 .expect("batch cmd");
12598
12599 assert_eq!(program.model().count, 2);
12600 assert!(!program.running);
12601 }
12602
12603 #[test]
12604 fn headless_process_subscription_messages_updates_model() {
12605 use crate::subscription::{StopSignal, SubId, Subscription};
12606
12607 struct SubModel {
12608 pings: usize,
12609 ready_tx: mpsc::Sender<()>,
12610 }
12611
12612 #[derive(Debug)]
12613 enum SubMsg {
12614 Ping,
12615 Other,
12616 }
12617
12618 impl From<Event> for SubMsg {
12619 fn from(_: Event) -> Self {
12620 SubMsg::Other
12621 }
12622 }
12623
12624 impl Model for SubModel {
12625 type Message = SubMsg;
12626
12627 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12628 if let SubMsg::Ping = msg {
12629 self.pings += 1;
12630 }
12631 Cmd::none()
12632 }
12633
12634 fn view(&self, _frame: &mut Frame) {}
12635
12636 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
12637 vec![Box::new(TestSubscription {
12638 ready_tx: self.ready_tx.clone(),
12639 })]
12640 }
12641 }
12642
12643 struct TestSubscription {
12644 ready_tx: mpsc::Sender<()>,
12645 }
12646
12647 impl Subscription<SubMsg> for TestSubscription {
12648 fn id(&self) -> SubId {
12649 1
12650 }
12651
12652 fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
12653 let _ = sender.send(SubMsg::Ping);
12654 let _ = self.ready_tx.send(());
12655 }
12656 }
12657
12658 let (ready_tx, ready_rx) = mpsc::channel();
12659 let mut program =
12660 headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
12661
12662 program.reconcile_subscriptions();
12663 ready_rx
12664 .recv_timeout(Duration::from_millis(200))
12665 .expect("subscription started");
12666 program
12667 .process_subscription_messages()
12668 .expect("process subscriptions");
12669
12670 assert_eq!(program.model().pings, 1);
12671 }
12672
12673 #[test]
12674 fn headless_execute_cmd_task_spawns_and_reaps() {
12675 struct TaskModel {
12676 done: bool,
12677 }
12678
12679 #[derive(Debug)]
12680 enum TaskMsg {
12681 Done,
12682 }
12683
12684 impl From<Event> for TaskMsg {
12685 fn from(_: Event) -> Self {
12686 TaskMsg::Done
12687 }
12688 }
12689
12690 impl Model for TaskModel {
12691 type Message = TaskMsg;
12692
12693 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12694 match msg {
12695 TaskMsg::Done => {
12696 self.done = true;
12697 Cmd::none()
12698 }
12699 }
12700 }
12701
12702 fn view(&self, _frame: &mut Frame) {}
12703 }
12704
12705 let mut program =
12706 headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
12707 program
12708 .execute_cmd(Cmd::task(|| TaskMsg::Done))
12709 .expect("task cmd");
12710
12711 let deadline = Instant::now() + Duration::from_millis(200);
12712 while !program.model().done && Instant::now() <= deadline {
12713 program
12714 .process_task_results()
12715 .expect("process task results");
12716 program.reap_finished_tasks();
12717 }
12718
12719 assert!(program.model().done, "task result did not arrive in time");
12720 }
12721
12722 #[test]
12723 fn headless_default_task_executor_is_spawned_for_structured_lane() {
12724 let program =
12732 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
12733 assert_eq!(program.task_executor.kind_name(), "spawned");
12734 }
12735
12736 #[test]
12737 fn headless_structured_lane_task_executor_writes_spawned_backend_evidence() {
12738 let evidence_path = temp_evidence_path("task_executor_spawned_backend");
12742 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12743 let config = ProgramConfig::default().with_evidence_sink(sink_config);
12744 let _program = headless_program_with_config(TestModel { value: 0 }, config);
12745
12746 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
12747 assert_eq!(backend_line["backend"], "spawned");
12748 }
12749
12750 #[test]
12751 fn headless_legacy_lane_task_executor_is_spawned() {
12752 let config = ProgramConfig::default().with_lane(RuntimeLane::Legacy);
12753 let program = headless_program_with_config(TestModel { value: 0 }, config);
12754 assert_eq!(program.task_executor.kind_name(), "spawned");
12755 }
12756
12757 #[test]
12758 fn headless_explicit_spawned_backend_overrides_structured_lane_default() {
12759 let config = ProgramConfig::default().with_effect_queue(
12760 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Spawned),
12761 );
12762 let program = headless_program_with_config(TestModel { value: 0 }, config);
12763 assert_eq!(program.task_executor.kind_name(), "spawned");
12764 }
12765
12766 #[cfg(feature = "asupersync-executor")]
12767 #[test]
12768 fn headless_asupersync_task_executor_is_selected() {
12769 let config = ProgramConfig::default().with_effect_queue(
12770 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
12771 );
12772 let program = headless_program_with_config(TestModel { value: 0 }, config);
12773 assert_eq!(program.task_executor.kind_name(), "asupersync");
12774 }
12775
12776 #[test]
12777 fn headless_persistence_commands_with_registry() {
12778 use crate::state_persistence::{MemoryStorage, StateRegistry};
12779 use std::sync::Arc;
12780
12781 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
12782 let config = ProgramConfig::default().with_registry(registry.clone());
12783 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
12784
12785 assert!(program.has_persistence());
12786 assert!(program.state_registry().is_some());
12787
12788 program.execute_cmd(Cmd::save_state()).expect("save");
12789 program.execute_cmd(Cmd::restore_state()).expect("restore");
12790
12791 let saved = program.trigger_save().expect("trigger save");
12792 let loaded = program.trigger_load().expect("trigger load");
12793 assert!(!saved);
12794 assert_eq!(loaded, 0);
12795 }
12796
12797 #[test]
12798 fn headless_process_resize_coalescer_applies_pending_resize() {
12799 struct ResizeModel {
12800 last_size: Option<(u16, u16)>,
12801 }
12802
12803 #[derive(Debug)]
12804 enum ResizeMsg {
12805 Resize(u16, u16),
12806 Other,
12807 }
12808
12809 impl From<Event> for ResizeMsg {
12810 fn from(event: Event) -> Self {
12811 match event {
12812 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12813 _ => ResizeMsg::Other,
12814 }
12815 }
12816 }
12817
12818 impl Model for ResizeModel {
12819 type Message = ResizeMsg;
12820
12821 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12822 if let ResizeMsg::Resize(w, h) = msg {
12823 self.last_size = Some((w, h));
12824 }
12825 Cmd::none()
12826 }
12827
12828 fn view(&self, _frame: &mut Frame) {}
12829 }
12830
12831 let evidence_path = temp_evidence_path("fairness_allow");
12832 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12833 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12834 config.resize_coalescer.steady_delay_ms = 0;
12835 config.resize_coalescer.burst_delay_ms = 0;
12836 config.resize_coalescer.hard_deadline_ms = 1_000;
12837 config.evidence_sink = sink_config.clone();
12838
12839 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12840 let sink = EvidenceSink::from_config(&sink_config)
12841 .expect("evidence sink config")
12842 .expect("evidence sink enabled");
12843 program.evidence_sink = Some(sink);
12844
12845 program.resize_coalescer.handle_resize(120, 40);
12846 assert!(program.resize_coalescer.has_pending());
12847
12848 program
12849 .process_resize_coalescer()
12850 .expect("process resize coalescer");
12851
12852 assert_eq!(program.width, 120);
12853 assert_eq!(program.height, 40);
12854 assert_eq!(program.model().last_size, Some((120, 40)));
12855
12856 let config_line = read_evidence_event(&evidence_path, "fairness_config");
12857 assert_eq!(config_line["event"], "fairness_config");
12858 assert!(config_line["enabled"].is_boolean());
12859 assert!(config_line["input_priority_threshold_ms"].is_number());
12860 assert!(config_line["dominance_threshold"].is_number());
12861 assert!(config_line["fairness_threshold"].is_number());
12862
12863 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12864 assert_eq!(decision_line["event"], "fairness_decision");
12865 assert_eq!(decision_line["decision"], "allow");
12866 assert_eq!(decision_line["reason"], "none");
12867 assert!(decision_line["pending_input_latency_ms"].is_null());
12868 assert!(decision_line["jain_index"].is_number());
12869 assert!(decision_line["resize_dominance_count"].is_number());
12870 assert!(decision_line["dominance_threshold"].is_number());
12871 assert!(decision_line["fairness_threshold"].is_number());
12872 assert!(decision_line["input_priority_threshold_ms"].is_number());
12873 }
12874
12875 #[test]
12876 fn headless_process_resize_coalescer_yields_to_input() {
12877 struct ResizeModel {
12878 last_size: Option<(u16, u16)>,
12879 }
12880
12881 #[derive(Debug)]
12882 enum ResizeMsg {
12883 Resize(u16, u16),
12884 Other,
12885 }
12886
12887 impl From<Event> for ResizeMsg {
12888 fn from(event: Event) -> Self {
12889 match event {
12890 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
12891 _ => ResizeMsg::Other,
12892 }
12893 }
12894 }
12895
12896 impl Model for ResizeModel {
12897 type Message = ResizeMsg;
12898
12899 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12900 if let ResizeMsg::Resize(w, h) = msg {
12901 self.last_size = Some((w, h));
12902 }
12903 Cmd::none()
12904 }
12905
12906 fn view(&self, _frame: &mut Frame) {}
12907 }
12908
12909 let evidence_path = temp_evidence_path("fairness_yield");
12910 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
12911 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
12912 config.resize_coalescer.steady_delay_ms = 0;
12913 config.resize_coalescer.burst_delay_ms = 0;
12914 config.resize_coalescer.hard_deadline_ms = 10_000;
12917 config.evidence_sink = sink_config.clone();
12918
12919 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
12920 let sink = EvidenceSink::from_config(&sink_config)
12921 .expect("evidence sink config")
12922 .expect("evidence sink enabled");
12923 program.evidence_sink = Some(sink);
12924
12925 program.fairness_guard = InputFairnessGuard::with_config(
12926 crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
12927 );
12928 program
12929 .fairness_guard
12930 .input_arrived(Instant::now() - Duration::from_millis(1));
12931
12932 program.resize_coalescer.handle_resize(120, 40);
12933 assert!(program.resize_coalescer.has_pending());
12934
12935 program
12936 .process_resize_coalescer()
12937 .expect("process resize coalescer");
12938
12939 assert_eq!(program.width, 80);
12940 assert_eq!(program.height, 24);
12941 assert_eq!(program.model().last_size, None);
12942 assert!(program.resize_coalescer.has_pending());
12943
12944 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
12945 assert_eq!(decision_line["event"], "fairness_decision");
12946 assert_eq!(decision_line["decision"], "yield");
12947 assert_eq!(decision_line["reason"], "input_latency");
12948 assert!(decision_line["pending_input_latency_ms"].is_number());
12949 assert!(decision_line["jain_index"].is_number());
12950 assert!(decision_line["resize_dominance_count"].is_number());
12951 assert!(decision_line["dominance_threshold"].is_number());
12952 assert!(decision_line["fairness_threshold"].is_number());
12953 assert!(decision_line["input_priority_threshold_ms"].is_number());
12954 }
12955
12956 #[test]
12957 fn headless_execute_cmd_task_with_effect_queue() {
12958 struct TaskModel {
12959 done: bool,
12960 }
12961
12962 #[derive(Debug)]
12963 enum TaskMsg {
12964 Done,
12965 }
12966
12967 impl From<Event> for TaskMsg {
12968 fn from(_: Event) -> Self {
12969 TaskMsg::Done
12970 }
12971 }
12972
12973 impl Model for TaskModel {
12974 type Message = TaskMsg;
12975
12976 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
12977 match msg {
12978 TaskMsg::Done => {
12979 self.done = true;
12980 Cmd::none()
12981 }
12982 }
12983 }
12984
12985 fn view(&self, _frame: &mut Frame) {}
12986 }
12987
12988 let effect_queue = EffectQueueConfig {
12989 enabled: true,
12990 backend: TaskExecutorBackend::EffectQueue,
12991 scheduler: SchedulerConfig {
12992 max_queue_size: 0,
12993 ..Default::default()
12994 },
12995 explicit_backend: true,
12996 ..Default::default()
12997 };
12998 let config = ProgramConfig::default().with_effect_queue(effect_queue);
12999 let mut program = headless_program_with_config(TaskModel { done: false }, config);
13000
13001 program
13002 .execute_cmd(Cmd::task(|| TaskMsg::Done))
13003 .expect("task cmd");
13004
13005 let deadline = Instant::now() + Duration::from_millis(200);
13006 while !program.model().done && Instant::now() <= deadline {
13007 program
13008 .process_task_results()
13009 .expect("process task results");
13010 }
13011
13012 assert!(
13013 program.model().done,
13014 "effect queue task result did not arrive in time"
13015 );
13016 assert_eq!(program.task_executor.kind_name(), "queued");
13017 }
13018
13019 #[test]
13020 fn headless_execute_cmd_task_with_spawned_backend_writes_completion_evidence() {
13021 struct TaskModel {
13022 done: bool,
13023 }
13024
13025 #[derive(Debug)]
13026 enum TaskMsg {
13027 Done,
13028 }
13029
13030 impl From<Event> for TaskMsg {
13031 fn from(_: Event) -> Self {
13032 TaskMsg::Done
13033 }
13034 }
13035
13036 impl Model for TaskModel {
13037 type Message = TaskMsg;
13038
13039 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13040 match msg {
13041 TaskMsg::Done => {
13042 self.done = true;
13043 Cmd::none()
13044 }
13045 }
13046 }
13047
13048 fn view(&self, _frame: &mut Frame) {}
13049 }
13050
13051 let evidence_path = temp_evidence_path("task_executor_spawned_complete");
13052 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13053 let config = ProgramConfig::default()
13054 .with_lane(RuntimeLane::Legacy)
13055 .with_evidence_sink(sink_config);
13056 let mut program = headless_program_with_config(TaskModel { done: false }, config);
13057
13058 program
13059 .execute_cmd(Cmd::task(|| TaskMsg::Done))
13060 .expect("task cmd");
13061
13062 let deadline = Instant::now() + Duration::from_millis(200);
13063 while !program.model().done && Instant::now() <= deadline {
13064 program
13065 .process_task_results()
13066 .expect("process task results");
13067 program.reap_finished_tasks();
13068 }
13069
13070 assert!(
13071 program.model().done,
13072 "spawned task result did not arrive in time"
13073 );
13074
13075 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
13076 assert_eq!(completion_line["backend"], "spawned");
13077 assert!(completion_line["duration_us"].is_number());
13078 }
13079
13080 #[test]
13081 fn headless_effect_queue_task_panic_writes_panic_evidence_and_continues() {
13082 struct TaskModel {
13083 done: bool,
13084 }
13085
13086 #[derive(Debug)]
13087 enum TaskMsg {
13088 Done,
13089 }
13090
13091 impl From<Event> for TaskMsg {
13092 fn from(_: Event) -> Self {
13093 TaskMsg::Done
13094 }
13095 }
13096
13097 impl Model for TaskModel {
13098 type Message = TaskMsg;
13099
13100 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13101 match msg {
13102 TaskMsg::Done => {
13103 self.done = true;
13104 Cmd::none()
13105 }
13106 }
13107 }
13108
13109 fn view(&self, _frame: &mut Frame) {}
13110 }
13111
13112 let evidence_path = temp_evidence_path("task_executor_queued_panic");
13113 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13114 let config = ProgramConfig::default()
13115 .with_evidence_sink(sink_config)
13116 .with_effect_queue(
13117 EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue),
13118 );
13119 let mut program = headless_program_with_config(TaskModel { done: false }, config);
13120
13121 program
13122 .execute_cmd(Cmd::task(|| -> TaskMsg { panic!("queued panic evidence") }))
13123 .expect("panic task cmd");
13124 program
13125 .execute_cmd(Cmd::task(|| TaskMsg::Done))
13126 .expect("follow-up task cmd");
13127
13128 let deadline = Instant::now() + Duration::from_millis(500);
13129 while !program.model().done && Instant::now() <= deadline {
13130 program
13131 .process_task_results()
13132 .expect("process task results");
13133 }
13134
13135 assert!(
13136 program.model().done,
13137 "effect queue should continue after a panicking task"
13138 );
13139
13140 let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13141 assert_eq!(panic_line["backend"], "queued");
13142 assert_eq!(panic_line["panic_msg"], "queued panic evidence");
13143 }
13144
13145 #[cfg(feature = "asupersync-executor")]
13146 #[test]
13147 fn headless_execute_cmd_task_with_asupersync_backend() {
13148 struct TaskModel {
13149 done: bool,
13150 }
13151
13152 #[derive(Debug)]
13153 enum TaskMsg {
13154 Done,
13155 }
13156
13157 impl From<Event> for TaskMsg {
13158 fn from(_: Event) -> Self {
13159 TaskMsg::Done
13160 }
13161 }
13162
13163 impl Model for TaskModel {
13164 type Message = TaskMsg;
13165
13166 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13167 match msg {
13168 TaskMsg::Done => {
13169 self.done = true;
13170 Cmd::none()
13171 }
13172 }
13173 }
13174
13175 fn view(&self, _frame: &mut Frame) {}
13176 }
13177
13178 let config = ProgramConfig::default().with_effect_queue(
13179 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13180 );
13181 let mut program = headless_program_with_config(TaskModel { done: false }, config);
13182
13183 program
13184 .execute_cmd(Cmd::task(|| TaskMsg::Done))
13185 .expect("task cmd");
13186
13187 let deadline = Instant::now() + Duration::from_millis(200);
13188 while !program.model().done && Instant::now() <= deadline {
13189 program
13190 .process_task_results()
13191 .expect("process task results");
13192 program.reap_finished_tasks();
13193 }
13194
13195 assert!(
13196 program.model().done,
13197 "asupersync task result did not arrive in time"
13198 );
13199 assert_eq!(program.task_executor.kind_name(), "asupersync");
13200 }
13201
13202 #[cfg(feature = "asupersync-executor")]
13203 #[test]
13204 fn headless_asupersync_task_executor_writes_backend_and_completion_evidence() {
13205 struct TaskModel {
13206 done: bool,
13207 }
13208
13209 #[derive(Debug)]
13210 enum TaskMsg {
13211 Done,
13212 }
13213
13214 impl From<Event> for TaskMsg {
13215 fn from(_: Event) -> Self {
13216 TaskMsg::Done
13217 }
13218 }
13219
13220 impl Model for TaskModel {
13221 type Message = TaskMsg;
13222
13223 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13224 match msg {
13225 TaskMsg::Done => {
13226 self.done = true;
13227 Cmd::none()
13228 }
13229 }
13230 }
13231
13232 fn view(&self, _frame: &mut Frame) {}
13233 }
13234
13235 let evidence_path = temp_evidence_path("task_executor_asupersync_complete");
13236 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13237 let config = ProgramConfig::default()
13238 .with_evidence_sink(sink_config)
13239 .with_effect_queue(
13240 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13241 );
13242 let mut program = headless_program_with_config(TaskModel { done: false }, config);
13243
13244 let backend_line = read_evidence_event(&evidence_path, "task_executor_backend");
13245 assert_eq!(backend_line["backend"], "asupersync");
13246
13247 program
13248 .execute_cmd(Cmd::task(|| TaskMsg::Done))
13249 .expect("task cmd");
13250
13251 let deadline = Instant::now() + Duration::from_millis(200);
13252 while !program.model().done && Instant::now() <= deadline {
13253 program
13254 .process_task_results()
13255 .expect("process task results");
13256 program.reap_finished_tasks();
13257 }
13258
13259 assert!(
13260 program.model().done,
13261 "asupersync task result did not arrive in time"
13262 );
13263
13264 let completion_line = read_evidence_event(&evidence_path, "task_executor_complete");
13265 assert_eq!(completion_line["backend"], "asupersync");
13266 assert!(completion_line["duration_us"].is_number());
13267 }
13268
13269 #[cfg(feature = "asupersync-executor")]
13277 fn collect_task_batch(backend: TaskExecutorBackend, count: u32) -> Vec<u32> {
13278 struct CollectModel {
13279 got: Vec<u32>,
13280 }
13281 #[derive(Debug)]
13282 enum CollectMsg {
13283 Got(u32),
13284 }
13285 impl From<Event> for CollectMsg {
13286 fn from(_: Event) -> Self {
13287 CollectMsg::Got(u32::MAX)
13288 }
13289 }
13290 impl Model for CollectModel {
13291 type Message = CollectMsg;
13292 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13293 match msg {
13294 CollectMsg::Got(value) => self.got.push(value),
13295 }
13296 Cmd::none()
13297 }
13298 fn view(&self, _frame: &mut Frame) {}
13299 }
13300
13301 let config = ProgramConfig::default()
13302 .with_effect_queue(EffectQueueConfig::default().with_backend(backend));
13303 let mut program = headless_program_with_config(CollectModel { got: Vec::new() }, config);
13304 for index in 0..count {
13305 program
13306 .execute_cmd(Cmd::task(move || CollectMsg::Got(index)))
13307 .expect("task cmd");
13308 }
13309 let deadline = Instant::now() + Duration::from_secs(5);
13310 while program.model().got.len() < count as usize && Instant::now() <= deadline {
13311 program
13312 .process_task_results()
13313 .expect("process task results");
13314 program.reap_finished_tasks();
13315 }
13316 let mut got = program.model().got.clone();
13317 got.sort_unstable();
13318 got
13319 }
13320
13321 #[cfg(feature = "asupersync-executor")]
13322 #[test]
13323 fn asupersync_semantic_parity_with_spawned_backend() {
13324 let expected: Vec<u32> = (0..16).collect();
13328 let spawned = collect_task_batch(TaskExecutorBackend::Spawned, 16);
13329 let asupersync = collect_task_batch(TaskExecutorBackend::Asupersync, 16);
13330 assert_eq!(
13331 spawned, expected,
13332 "spawned backend lost or reordered results"
13333 );
13334 assert_eq!(
13335 asupersync, expected,
13336 "asupersync backend diverged from the expected results"
13337 );
13338 assert_eq!(
13339 asupersync, spawned,
13340 "asupersync diverged from spawned (parity)"
13341 );
13342 }
13343
13344 #[cfg(feature = "asupersync-executor")]
13345 #[test]
13346 fn asupersync_stress_delivers_every_task() {
13347 let count: u32 = 200;
13349 let got = collect_task_batch(TaskExecutorBackend::Asupersync, count);
13350 assert_eq!(
13351 got.len(),
13352 count as usize,
13353 "asupersync dropped tasks under load"
13354 );
13355 assert_eq!(got, (0..count).collect::<Vec<_>>());
13356 }
13357
13358 #[cfg(feature = "asupersync-executor")]
13359 #[test]
13360 fn asupersync_task_panic_is_isolated() {
13361 struct PanicModel {
13362 survivor_seen: bool,
13363 }
13364 #[derive(Debug)]
13365 enum PanicMsg {
13366 Survivor,
13367 }
13368 impl From<Event> for PanicMsg {
13369 fn from(_: Event) -> Self {
13370 PanicMsg::Survivor
13371 }
13372 }
13373 impl Model for PanicModel {
13374 type Message = PanicMsg;
13375 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13376 match msg {
13377 PanicMsg::Survivor => self.survivor_seen = true,
13378 }
13379 Cmd::none()
13380 }
13381 fn view(&self, _frame: &mut Frame) {}
13382 }
13383
13384 let evidence_path = temp_evidence_path("asupersync_panic");
13385 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13386 let config = ProgramConfig::default()
13387 .with_evidence_sink(sink_config)
13388 .with_effect_queue(
13389 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13390 );
13391 let mut program = headless_program_with_config(
13392 PanicModel {
13393 survivor_seen: false,
13394 },
13395 config,
13396 );
13397
13398 program
13400 .execute_cmd(Cmd::task(|| -> PanicMsg { panic!("asupersync boom") }))
13401 .expect("panic task cmd");
13402 program
13404 .execute_cmd(Cmd::task(|| PanicMsg::Survivor))
13405 .expect("survivor task cmd");
13406
13407 let deadline = Instant::now() + Duration::from_secs(5);
13408 while !program.model().survivor_seen && Instant::now() <= deadline {
13409 program
13410 .process_task_results()
13411 .expect("process task results");
13412 program.reap_finished_tasks();
13413 }
13414 assert!(
13415 program.model().survivor_seen,
13416 "executor did not survive a panicking task"
13417 );
13418 let panic_line = read_evidence_event(&evidence_path, "task_executor_panic");
13419 assert_eq!(panic_line["backend"], "asupersync");
13420 }
13421
13422 #[cfg(feature = "asupersync-executor")]
13423 #[test]
13424 fn asupersync_rejects_tasks_after_shutdown() {
13425 struct ShutdownModel {
13426 seen: bool,
13427 }
13428 #[derive(Debug)]
13429 enum ShutdownMsg {
13430 Seen,
13431 }
13432 impl From<Event> for ShutdownMsg {
13433 fn from(_: Event) -> Self {
13434 ShutdownMsg::Seen
13435 }
13436 }
13437 impl Model for ShutdownModel {
13438 type Message = ShutdownMsg;
13439 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13440 match msg {
13441 ShutdownMsg::Seen => self.seen = true,
13442 }
13443 Cmd::none()
13444 }
13445 fn view(&self, _frame: &mut Frame) {}
13446 }
13447
13448 let config = ProgramConfig::default().with_effect_queue(
13449 EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync),
13450 );
13451 let mut program = headless_program_with_config(ShutdownModel { seen: false }, config);
13452
13453 program.task_executor.shutdown();
13456 program
13457 .execute_cmd(Cmd::task(|| ShutdownMsg::Seen))
13458 .expect("post-shutdown task cmd");
13459
13460 let deadline = Instant::now() + Duration::from_millis(200);
13461 while Instant::now() <= deadline {
13462 program
13463 .process_task_results()
13464 .expect("process task results");
13465 program.reap_finished_tasks();
13466 }
13467 assert!(
13468 !program.model().seen,
13469 "a task submitted after shutdown was executed"
13470 );
13471 }
13472
13473 #[test]
13474 fn spawned_executor_enforces_max_queue_depth_and_counts_drops() {
13475 let (result_tx, _result_rx) = mpsc::channel::<u32>();
13476 let (gate_tx, gate_rx) = mpsc::channel::<()>();
13477 let mut executor = SpawnTaskExecutor::new(result_tx, None, 1);
13478
13479 executor.submit(Box::new(move || {
13481 let _ = gate_rx.recv();
13482 1
13483 }));
13484 assert_eq!(executor.handles.len(), 1);
13485
13486 executor.submit(Box::new(|| 2));
13488 assert_eq!(executor.dropped, 1, "backpressure drop not counted");
13489 assert_eq!(executor.handles.len(), 1, "task spawned past the bound");
13490
13491 gate_tx.send(()).expect("release gate");
13493 executor.shutdown();
13494 executor.submit(Box::new(|| 3));
13495 assert_eq!(executor.dropped, 2, "post-shutdown drop not counted");
13496 }
13497
13498 #[cfg(feature = "asupersync-executor")]
13499 #[test]
13500 fn asupersync_backpressure_sheds_excess_in_flight_tasks() {
13501 struct SlowModel {
13502 done: u32,
13503 }
13504 #[derive(Debug)]
13505 enum SlowMsg {
13506 Done,
13507 }
13508 impl From<Event> for SlowMsg {
13509 fn from(_: Event) -> Self {
13510 SlowMsg::Done
13511 }
13512 }
13513 impl Model for SlowModel {
13514 type Message = SlowMsg;
13515 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
13516 match msg {
13517 SlowMsg::Done => self.done += 1,
13518 }
13519 Cmd::none()
13520 }
13521 fn view(&self, _frame: &mut Frame) {}
13522 }
13523
13524 let evidence_path = temp_evidence_path("asupersync_backpressure");
13525 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
13526 let config = ProgramConfig::default()
13527 .with_evidence_sink(sink_config)
13528 .with_effect_queue(
13529 EffectQueueConfig::default()
13530 .with_backend(TaskExecutorBackend::Asupersync)
13531 .with_max_queue_depth(2),
13532 );
13533 let mut program = headless_program_with_config(SlowModel { done: 0 }, config);
13534
13535 for _ in 0..6 {
13538 program
13539 .execute_cmd(Cmd::task(|| {
13540 std::thread::sleep(Duration::from_millis(120));
13541 SlowMsg::Done
13542 }))
13543 .expect("slow task cmd");
13544 }
13545
13546 let backpressure_line = read_evidence_event(&evidence_path, "task_executor_backpressure");
13548 assert_eq!(backpressure_line["backend"], "asupersync");
13549 assert_eq!(backpressure_line["action"], "drop");
13550
13551 let deadline = Instant::now() + Duration::from_secs(5);
13554 while program.model().done < 2 && Instant::now() <= deadline {
13555 program
13556 .process_task_results()
13557 .expect("process task results");
13558 program.reap_finished_tasks();
13559 }
13560 assert!(
13561 program.model().done <= 2,
13562 "more tasks completed than the in-flight depth cap permits"
13563 );
13564 }
13565
13566 #[test]
13571 fn unit_tau_monotone() {
13572 let mut bc = BatchController::new();
13575
13576 bc.observe_service(Duration::from_millis(20));
13578 bc.observe_service(Duration::from_millis(20));
13579 bc.observe_service(Duration::from_millis(20));
13580 let tau_high = bc.tau_s();
13581
13582 for _ in 0..20 {
13584 bc.observe_service(Duration::from_millis(1));
13585 }
13586 let tau_low = bc.tau_s();
13587
13588 assert!(
13589 tau_low <= tau_high,
13590 "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
13591 );
13592 }
13593
13594 #[test]
13595 fn unit_tau_monotone_lambda() {
13596 let mut bc = BatchController::new();
13600 let base = Instant::now();
13601
13602 for i in 0..10 {
13604 bc.observe_arrival(base + Duration::from_millis(i * 10));
13605 }
13606 let rho_fast = bc.rho_est();
13607
13608 for i in 10..20 {
13610 bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
13611 }
13612 let rho_slow = bc.rho_est();
13613
13614 assert!(
13615 rho_slow < rho_fast,
13616 "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
13617 );
13618 }
13619
13620 #[test]
13621 fn unit_stability() {
13622 let mut bc = BatchController::new();
13624 let base = Instant::now();
13625
13626 for i in 0..30 {
13628 bc.observe_arrival(base + Duration::from_millis(i * 33));
13629 bc.observe_service(Duration::from_millis(5)); }
13631
13632 assert!(
13633 bc.is_stable(),
13634 "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
13635 bc.rho_est()
13636 );
13637 assert!(
13638 bc.rho_est() < 1.0,
13639 "utilization should be < 1: ρ={:.4}",
13640 bc.rho_est()
13641 );
13642
13643 assert!(
13645 bc.tau_s() > bc.service_est_s(),
13646 "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
13647 bc.tau_s(),
13648 bc.service_est_s()
13649 );
13650 }
13651
13652 #[test]
13653 fn unit_stability_high_load() {
13654 let mut bc = BatchController::new();
13656 let base = Instant::now();
13657
13658 for i in 0..50 {
13660 bc.observe_arrival(base + Duration::from_millis(i * 10));
13661 bc.observe_service(Duration::from_millis(8));
13662 }
13663
13664 let tau = bc.tau_s();
13666 let rho_eff = bc.service_est_s() / tau;
13667 assert!(
13668 rho_eff < 1.0,
13669 "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
13670 bc.service_est_s()
13671 );
13672 }
13673
13674 #[test]
13675 fn batch_controller_defaults() {
13676 let bc = BatchController::new();
13677 assert!(bc.tau_s() >= bc.tau_min_s);
13678 assert!(bc.tau_s() <= bc.tau_max_s);
13679 assert_eq!(bc.observations(), 0);
13680 assert!(bc.is_stable());
13681 }
13682
13683 #[test]
13684 fn batch_controller_tau_clamped() {
13685 let mut bc = BatchController::new();
13686
13687 for _ in 0..20 {
13689 bc.observe_service(Duration::from_micros(10));
13690 }
13691 assert!(
13692 bc.tau_s() >= bc.tau_min_s,
13693 "τ should be >= tau_min: τ={:.6}, min={:.6}",
13694 bc.tau_s(),
13695 bc.tau_min_s
13696 );
13697
13698 for _ in 0..20 {
13700 bc.observe_service(Duration::from_millis(100));
13701 }
13702 assert!(
13703 bc.tau_s() <= bc.tau_max_s,
13704 "τ should be <= tau_max: τ={:.6}, max={:.6}",
13705 bc.tau_s(),
13706 bc.tau_max_s
13707 );
13708 }
13709
13710 #[test]
13711 fn batch_controller_duration_conversion() {
13712 let bc = BatchController::new();
13713 let tau = bc.tau();
13714 let tau_s = bc.tau_s();
13715 let diff = (tau.as_secs_f64() - tau_s).abs();
13717 assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
13718 }
13719
13720 #[test]
13721 fn batch_controller_lambda_estimation() {
13722 let mut bc = BatchController::new();
13723 let base = Instant::now();
13724
13725 for i in 0..20 {
13727 bc.observe_arrival(base + Duration::from_millis(i * 20));
13728 }
13729
13730 let lambda = bc.lambda_est();
13732 assert!(
13733 lambda > 20.0 && lambda < 100.0,
13734 "λ should be near 50: got {lambda:.1}"
13735 );
13736 }
13737
13738 #[test]
13743 fn cmd_save_state() {
13744 let cmd: Cmd<TestMsg> = Cmd::save_state();
13745 assert!(matches!(cmd, Cmd::SaveState));
13746 }
13747
13748 #[test]
13749 fn cmd_restore_state() {
13750 let cmd: Cmd<TestMsg> = Cmd::restore_state();
13751 assert!(matches!(cmd, Cmd::RestoreState));
13752 }
13753
13754 #[test]
13755 fn persistence_config_default() {
13756 let config = PersistenceConfig::default();
13757 assert!(config.registry.is_none());
13758 assert!(config.checkpoint_interval.is_none());
13759 assert!(config.auto_load);
13760 assert!(config.auto_save);
13761 }
13762
13763 #[test]
13764 fn persistence_config_disabled() {
13765 let config = PersistenceConfig::disabled();
13766 assert!(config.registry.is_none());
13767 }
13768
13769 #[test]
13770 fn persistence_config_with_registry() {
13771 use crate::state_persistence::{MemoryStorage, StateRegistry};
13772 use std::sync::Arc;
13773
13774 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13775 let config = PersistenceConfig::with_registry(registry.clone());
13776
13777 assert!(config.registry.is_some());
13778 assert!(config.auto_load);
13779 assert!(config.auto_save);
13780 }
13781
13782 #[test]
13783 fn persistence_config_checkpoint_interval() {
13784 use crate::state_persistence::{MemoryStorage, StateRegistry};
13785 use std::sync::Arc;
13786
13787 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13788 let config = PersistenceConfig::with_registry(registry)
13789 .checkpoint_every(Duration::from_secs(30))
13790 .auto_load(false)
13791 .auto_save(true);
13792
13793 assert!(config.checkpoint_interval.is_some());
13794 assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
13795 assert!(!config.auto_load);
13796 assert!(config.auto_save);
13797 }
13798
13799 #[test]
13800 fn program_config_with_persistence() {
13801 use crate::state_persistence::{MemoryStorage, StateRegistry};
13802 use std::sync::Arc;
13803
13804 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
13805 let config = ProgramConfig::default().with_registry(registry);
13806
13807 assert!(config.persistence.registry.is_some());
13808 }
13809
13810 #[test]
13815 fn task_spec_default() {
13816 let spec = TaskSpec::default();
13817 assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
13818 assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13819 assert!(spec.name.is_none());
13820 }
13821
13822 #[test]
13823 fn task_spec_new() {
13824 let spec = TaskSpec::new(5.0, 20.0);
13825 assert_eq!(spec.weight, 5.0);
13826 assert_eq!(spec.estimate_ms, 20.0);
13827 assert!(spec.name.is_none());
13828 }
13829
13830 #[test]
13831 fn task_spec_with_name() {
13832 let spec = TaskSpec::default().with_name("fetch_data");
13833 assert_eq!(spec.name.as_deref(), Some("fetch_data"));
13834 }
13835
13836 #[test]
13837 fn task_spec_debug() {
13838 let spec = TaskSpec::new(2.0, 15.0).with_name("test");
13839 let debug = format!("{spec:?}");
13840 assert!(debug.contains("2.0"));
13841 assert!(debug.contains("15.0"));
13842 assert!(debug.contains("test"));
13843 }
13844
13845 #[test]
13850 fn cmd_count_none() {
13851 let cmd: Cmd<TestMsg> = Cmd::none();
13852 assert_eq!(cmd.count(), 0);
13853 }
13854
13855 #[test]
13856 fn cmd_count_atomic() {
13857 assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
13858 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
13859 assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
13860 assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
13861 assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
13862 assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
13863 assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
13864 }
13865
13866 #[test]
13867 fn cmd_count_batch() {
13868 let cmd: Cmd<TestMsg> =
13869 Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
13870 assert_eq!(cmd.count(), 2); }
13872
13873 #[test]
13874 fn cmd_count_nested() {
13875 let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
13876 Cmd::msg(TestMsg::Increment),
13877 Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
13878 ]);
13879 assert_eq!(cmd.count(), 3);
13880 }
13881
13882 #[test]
13887 fn cmd_type_name_all_variants() {
13888 assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
13889 assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
13890 assert_eq!(
13891 Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
13892 "Batch"
13893 );
13894 assert_eq!(
13895 Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
13896 "Sequence"
13897 );
13898 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
13899 assert_eq!(
13900 Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
13901 "Tick"
13902 );
13903 assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
13904 assert_eq!(
13905 Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
13906 "Task"
13907 );
13908 assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
13909 assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
13910 assert_eq!(
13911 Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
13912 "SetMouseCapture"
13913 );
13914 }
13915
13916 #[test]
13921 fn cmd_batch_empty_returns_none() {
13922 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
13923 assert!(matches!(cmd, Cmd::None));
13924 }
13925
13926 #[test]
13927 fn cmd_batch_single_unwraps() {
13928 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
13929 assert!(matches!(cmd, Cmd::Quit));
13930 }
13931
13932 #[test]
13933 fn cmd_batch_multiple_stays_batch() {
13934 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13935 assert!(matches!(cmd, Cmd::Batch(_)));
13936 }
13937
13938 #[test]
13939 fn cmd_sequence_empty_returns_none() {
13940 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
13941 assert!(matches!(cmd, Cmd::None));
13942 }
13943
13944 #[test]
13945 fn cmd_sequence_single_unwraps_to_inner() {
13946 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
13947 assert!(matches!(cmd, Cmd::Quit));
13948 }
13949
13950 #[test]
13951 fn cmd_sequence_multiple_stays_sequence() {
13952 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
13953 assert!(matches!(cmd, Cmd::Sequence(_)));
13954 }
13955
13956 #[test]
13961 fn cmd_task_with_spec() {
13962 let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
13963 let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
13964 match cmd {
13965 Cmd::Task(s, _) => {
13966 assert_eq!(s.weight, 3.0);
13967 assert_eq!(s.estimate_ms, 25.0);
13968 assert_eq!(s.name.as_deref(), Some("my_task"));
13969 }
13970 _ => panic!("expected Task variant"),
13971 }
13972 }
13973
13974 #[test]
13975 fn cmd_task_weighted() {
13976 let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
13977 match cmd {
13978 Cmd::Task(s, _) => {
13979 assert_eq!(s.weight, 2.0);
13980 assert_eq!(s.estimate_ms, 50.0);
13981 assert!(s.name.is_none());
13982 }
13983 _ => panic!("expected Task variant"),
13984 }
13985 }
13986
13987 #[test]
13988 fn cmd_task_named() {
13989 let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
13990 match cmd {
13991 Cmd::Task(s, _) => {
13992 assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
13993 assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
13994 assert_eq!(s.name.as_deref(), Some("background_fetch"));
13995 }
13996 _ => panic!("expected Task variant"),
13997 }
13998 }
13999
14000 #[test]
14005 fn cmd_debug_all_variant_strings() {
14006 assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
14007 assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
14008 assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
14009 assert!(
14010 format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
14011 );
14012 assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
14013 assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
14014 assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
14015 assert_eq!(
14016 format!("{:?}", Cmd::<TestMsg>::restore_state()),
14017 "RestoreState"
14018 );
14019 assert_eq!(
14020 format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
14021 "SetMouseCapture(true)"
14022 );
14023 }
14024
14025 #[test]
14030 fn headless_execute_cmd_set_mouse_capture() {
14031 let mut program =
14032 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14033 assert!(!program.backend_features.mouse_capture);
14034
14035 program
14036 .execute_cmd(Cmd::set_mouse_capture(true))
14037 .expect("set mouse capture true");
14038 assert!(program.backend_features.mouse_capture);
14039
14040 program
14041 .execute_cmd(Cmd::set_mouse_capture(false))
14042 .expect("set mouse capture false");
14043 assert!(!program.backend_features.mouse_capture);
14044 }
14045
14046 #[test]
14051 fn resize_behavior_uses_coalescer() {
14052 assert!(ResizeBehavior::Throttled.uses_coalescer());
14053 assert!(!ResizeBehavior::Immediate.uses_coalescer());
14054 }
14055
14056 #[test]
14057 fn resize_behavior_eq_and_debug() {
14058 assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
14059 assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
14060 let debug = format!("{:?}", ResizeBehavior::Throttled);
14061 assert_eq!(debug, "Throttled");
14062 }
14063
14064 #[test]
14069 fn widget_refresh_config_defaults() {
14070 let config = WidgetRefreshConfig::default();
14071 assert!(config.enabled);
14072 assert_eq!(config.staleness_window_ms, 1_000);
14073 assert_eq!(config.starve_ms, 3_000);
14074 assert_eq!(config.max_starved_per_frame, 2);
14075 assert_eq!(config.max_drop_fraction, 1.0);
14076 assert_eq!(config.weight_priority, 1.0);
14077 assert_eq!(config.weight_staleness, 0.5);
14078 assert_eq!(config.weight_focus, 0.75);
14079 assert_eq!(config.weight_interaction, 0.5);
14080 assert_eq!(config.starve_boost, 1.5);
14081 assert_eq!(config.min_cost_us, 1.0);
14082 }
14083
14084 #[test]
14089 fn effect_queue_config_default() {
14090 let config = EffectQueueConfig::default();
14091 assert!(!config.enabled);
14092 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
14093 assert!(!config.explicit_backend);
14094 assert!(config.scheduler.smith_enabled);
14095 assert!(!config.scheduler.force_fifo);
14096 assert!(!config.scheduler.preemptive);
14097 }
14098
14099 #[test]
14100 fn effect_queue_config_with_enabled() {
14101 let config = EffectQueueConfig::default().with_enabled(true);
14102 assert!(config.enabled);
14103 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
14104 assert!(config.explicit_backend);
14105 }
14106
14107 #[test]
14108 fn effect_queue_config_with_enabled_false_marks_explicit_spawned_backend() {
14109 let config = EffectQueueConfig::default().with_enabled(false);
14110 assert!(!config.enabled);
14111 assert_eq!(config.backend, TaskExecutorBackend::Spawned);
14112 assert!(config.explicit_backend);
14113 }
14114
14115 #[test]
14116 fn effect_queue_config_with_backend() {
14117 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::EffectQueue);
14118 assert!(config.enabled);
14119 assert_eq!(config.backend, TaskExecutorBackend::EffectQueue);
14120 assert!(config.explicit_backend);
14121 }
14122
14123 #[cfg(feature = "asupersync-executor")]
14124 #[test]
14125 fn effect_queue_config_with_asupersync_backend_disables_effect_queue_flag() {
14126 let config = EffectQueueConfig::default().with_backend(TaskExecutorBackend::Asupersync);
14127 assert!(!config.enabled);
14128 assert_eq!(config.backend, TaskExecutorBackend::Asupersync);
14129 }
14130
14131 #[test]
14132 fn effect_queue_config_with_scheduler() {
14133 let sched = SchedulerConfig {
14134 force_fifo: true,
14135 ..Default::default()
14136 };
14137 let config = EffectQueueConfig::default().with_scheduler(sched);
14138 assert!(config.scheduler.force_fifo);
14139 }
14140
14141 #[test]
14146 fn inline_auto_remeasure_config_defaults() {
14147 let config = InlineAutoRemeasureConfig::default();
14148 assert_eq!(config.change_threshold_rows, 1);
14149 assert_eq!(config.voi.prior_alpha, 1.0);
14150 assert_eq!(config.voi.prior_beta, 9.0);
14151 assert_eq!(config.voi.max_interval_ms, 1000);
14152 assert_eq!(config.voi.min_interval_ms, 100);
14153 assert_eq!(config.voi.sample_cost, 0.08);
14154 }
14155
14156 #[test]
14161 fn headless_event_source_size() {
14162 let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
14163 assert_eq!(source.size().unwrap(), (120, 40));
14164 }
14165
14166 #[test]
14167 fn headless_event_source_poll_always_false() {
14168 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14169 assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
14170 }
14171
14172 #[test]
14173 fn headless_event_source_read_always_none() {
14174 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14175 assert!(source.read_event().unwrap().is_none());
14176 }
14177
14178 #[test]
14179 fn headless_event_source_set_features() {
14180 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
14181 let features = BackendFeatures {
14182 mouse_capture: true,
14183 bracketed_paste: true,
14184 focus_events: true,
14185 kitty_keyboard: true,
14186 };
14187 source.set_features(features).unwrap();
14188 assert_eq!(source.features, features);
14189 }
14190
14191 #[test]
14192 fn immediate_drain_budget_adds_backoff_poll_under_burst() {
14193 use ftui_core::event::{KeyCode, KeyEvent};
14194
14195 struct DrainBurstModel {
14196 processed: usize,
14197 quit_after: usize,
14198 }
14199
14200 #[derive(Debug)]
14201 #[allow(dead_code)]
14202 enum DrainBurstMsg {
14203 Event(Event),
14204 }
14205
14206 impl From<Event> for DrainBurstMsg {
14207 fn from(event: Event) -> Self {
14208 DrainBurstMsg::Event(event)
14209 }
14210 }
14211
14212 impl Model for DrainBurstModel {
14213 type Message = DrainBurstMsg;
14214
14215 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14216 match msg {
14217 DrainBurstMsg::Event(_) => {
14218 self.processed = self.processed.saturating_add(1);
14219 if self.processed >= self.quit_after {
14220 Cmd::quit()
14221 } else {
14222 Cmd::none()
14223 }
14224 }
14225 }
14226 }
14227
14228 fn view(&self, _frame: &mut Frame) {}
14229 }
14230
14231 struct DrainBurstEventSource {
14232 queue: VecDeque<Event>,
14233 poll_timeouts: Arc<std::sync::Mutex<Vec<Duration>>>,
14234 size: (u16, u16),
14235 }
14236
14237 impl BackendEventSource for DrainBurstEventSource {
14238 type Error = io::Error;
14239
14240 fn size(&self) -> Result<(u16, u16), Self::Error> {
14241 Ok(self.size)
14242 }
14243
14244 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14245 Ok(())
14246 }
14247
14248 fn poll_event(&mut self, timeout: Duration) -> Result<bool, Self::Error> {
14249 self.poll_timeouts.lock().unwrap().push(timeout);
14250 Ok(!self.queue.is_empty())
14251 }
14252
14253 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14254 Ok(self.queue.pop_front())
14255 }
14256 }
14257
14258 let burst_events = 24usize;
14259 let poll_timeouts = Arc::new(std::sync::Mutex::new(Vec::new()));
14260 let mut queue = VecDeque::new();
14261 for _ in 0..burst_events {
14262 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('x'))));
14263 }
14264
14265 let events = DrainBurstEventSource {
14266 queue,
14267 poll_timeouts: poll_timeouts.clone(),
14268 size: (80, 24),
14269 };
14270 let writer = TerminalWriter::new(
14271 Vec::<u8>::new(),
14272 ScreenMode::AltScreen,
14273 UiAnchor::Bottom,
14274 TerminalCapabilities::dumb(),
14275 );
14276 let config = ProgramConfig::default()
14277 .with_forced_size(80, 24)
14278 .with_signal_interception(false)
14279 .with_immediate_drain(ImmediateDrainConfig {
14280 max_zero_timeout_polls_per_burst: 3,
14281 max_burst_duration: Duration::from_secs(1),
14282 backoff_timeout: Duration::from_millis(1),
14283 });
14284
14285 let model = DrainBurstModel {
14286 processed: 0,
14287 quit_after: burst_events,
14288 };
14289 let mut program =
14290 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14291 .expect("program creation");
14292 program.run().expect("run burst");
14293
14294 assert_eq!(program.model().processed, burst_events);
14295
14296 let stats = program.immediate_drain_stats();
14297 assert_eq!(stats.bursts, 1);
14298 assert!(stats.capped_bursts >= 1);
14299 assert!(stats.backoff_polls >= 1);
14300 assert!(stats.zero_timeout_polls >= 1);
14301 assert!(stats.max_zero_timeout_polls_in_burst <= 3);
14302
14303 let timeouts = poll_timeouts.lock().unwrap();
14304 assert!(timeouts.contains(&Duration::ZERO));
14305 assert!(timeouts.contains(&Duration::from_millis(1)));
14306 }
14307
14308 #[test]
14309 fn immediate_drain_zero_poll_limit_is_clamped() {
14310 use ftui_core::event::{KeyCode, KeyEvent};
14311
14312 struct ClampModel {
14313 processed: usize,
14314 quit_after: usize,
14315 }
14316
14317 #[derive(Debug)]
14318 #[allow(dead_code)]
14319 enum ClampMsg {
14320 Event(Event),
14321 }
14322
14323 impl From<Event> for ClampMsg {
14324 fn from(event: Event) -> Self {
14325 ClampMsg::Event(event)
14326 }
14327 }
14328
14329 impl Model for ClampModel {
14330 type Message = ClampMsg;
14331
14332 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14333 match msg {
14334 ClampMsg::Event(_) => {
14335 self.processed = self.processed.saturating_add(1);
14336 if self.processed >= self.quit_after {
14337 Cmd::quit()
14338 } else {
14339 Cmd::none()
14340 }
14341 }
14342 }
14343 }
14344
14345 fn view(&self, _frame: &mut Frame) {}
14346 }
14347
14348 struct ClampSource {
14349 queue: VecDeque<Event>,
14350 }
14351
14352 impl BackendEventSource for ClampSource {
14353 type Error = io::Error;
14354
14355 fn size(&self) -> Result<(u16, u16), Self::Error> {
14356 Ok((80, 24))
14357 }
14358
14359 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14360 Ok(())
14361 }
14362
14363 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14364 Ok(!self.queue.is_empty())
14365 }
14366
14367 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14368 Ok(self.queue.pop_front())
14369 }
14370 }
14371
14372 let burst_events = 8usize;
14373 let mut queue = VecDeque::new();
14374 for _ in 0..burst_events {
14375 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('z'))));
14376 }
14377 let events = ClampSource { queue };
14378
14379 let writer = TerminalWriter::new(
14380 Vec::<u8>::new(),
14381 ScreenMode::AltScreen,
14382 UiAnchor::Bottom,
14383 TerminalCapabilities::dumb(),
14384 );
14385 let config = ProgramConfig::default()
14386 .with_forced_size(80, 24)
14387 .with_signal_interception(false)
14388 .with_immediate_drain(ImmediateDrainConfig {
14389 max_zero_timeout_polls_per_burst: 0,
14390 max_burst_duration: Duration::from_secs(1),
14391 backoff_timeout: Duration::from_millis(1),
14392 });
14393 let model = ClampModel {
14394 processed: 0,
14395 quit_after: burst_events,
14396 };
14397
14398 let mut program =
14399 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14400 .expect("program creation");
14401 program.run().expect("run clamp");
14402
14403 let stats = program.immediate_drain_stats();
14404 assert!(stats.max_zero_timeout_polls_in_burst <= 1);
14405 }
14406
14407 #[test]
14408 fn quit_stops_draining_remaining_burst_events() {
14409 use ftui_core::event::{KeyCode, KeyEvent};
14410
14411 struct QuitBurstModel {
14412 processed: usize,
14413 quit_after: usize,
14414 }
14415
14416 #[derive(Debug)]
14417 #[allow(dead_code)]
14418 enum QuitBurstMsg {
14419 Event(Event),
14420 }
14421
14422 impl From<Event> for QuitBurstMsg {
14423 fn from(event: Event) -> Self {
14424 Self::Event(event)
14425 }
14426 }
14427
14428 impl Model for QuitBurstModel {
14429 type Message = QuitBurstMsg;
14430
14431 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
14432 match msg {
14433 QuitBurstMsg::Event(_) => {
14434 self.processed = self.processed.saturating_add(1);
14435 if self.processed >= self.quit_after {
14436 Cmd::quit()
14437 } else {
14438 Cmd::none()
14439 }
14440 }
14441 }
14442 }
14443
14444 fn view(&self, _frame: &mut Frame) {}
14445 }
14446
14447 struct QuitBurstSource {
14448 queue: VecDeque<Event>,
14449 }
14450
14451 impl BackendEventSource for QuitBurstSource {
14452 type Error = io::Error;
14453
14454 fn size(&self) -> Result<(u16, u16), Self::Error> {
14455 Ok((80, 24))
14456 }
14457
14458 fn set_features(&mut self, _features: BackendFeatures) -> Result<(), Self::Error> {
14459 Ok(())
14460 }
14461
14462 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, Self::Error> {
14463 Ok(!self.queue.is_empty())
14464 }
14465
14466 fn read_event(&mut self) -> Result<Option<Event>, Self::Error> {
14467 Ok(self.queue.pop_front())
14468 }
14469 }
14470
14471 let total_events = 8usize;
14472 let quit_after = 3usize;
14473 let mut queue = VecDeque::new();
14474 for _ in 0..total_events {
14475 queue.push_back(Event::Key(KeyEvent::new(KeyCode::Char('q'))));
14476 }
14477
14478 let writer = TerminalWriter::new(
14479 Vec::<u8>::new(),
14480 ScreenMode::AltScreen,
14481 UiAnchor::Bottom,
14482 TerminalCapabilities::dumb(),
14483 );
14484 let config = ProgramConfig::default()
14485 .with_forced_size(80, 24)
14486 .with_signal_interception(false)
14487 .with_immediate_drain(ImmediateDrainConfig {
14488 max_zero_timeout_polls_per_burst: 64,
14489 max_burst_duration: Duration::from_secs(1),
14490 backoff_timeout: Duration::from_millis(1),
14491 });
14492 let model = QuitBurstModel {
14493 processed: 0,
14494 quit_after,
14495 };
14496 let events = QuitBurstSource { queue };
14497
14498 let mut program =
14499 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
14500 .expect("program creation");
14501 program.run().expect("run burst quit");
14502
14503 assert_eq!(program.model().processed, quit_after);
14504 }
14505
14506 #[test]
14511 fn headless_program_quit_and_is_running() {
14512 let mut program =
14513 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14514 assert!(program.is_running());
14515
14516 program.quit();
14517 assert!(!program.is_running());
14518 }
14519
14520 #[test]
14521 fn headless_program_model_mut() {
14522 let mut program =
14523 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14524 assert_eq!(program.model().value, 0);
14525
14526 program.model_mut().value = 42;
14527 assert_eq!(program.model().value, 42);
14528 }
14529
14530 #[test]
14531 fn headless_program_request_redraw() {
14532 let mut program =
14533 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14534 program.dirty = false;
14535
14536 program.request_redraw();
14537 assert!(program.dirty);
14538 }
14539
14540 #[test]
14541 fn headless_program_last_widget_signals_initially_empty() {
14542 let program =
14543 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14544 assert!(program.last_widget_signals().is_empty());
14545 }
14546
14547 #[test]
14548 fn headless_program_no_persistence_by_default() {
14549 let program =
14550 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14551 assert!(!program.has_persistence());
14552 assert!(program.state_registry().is_none());
14553 }
14554
14555 #[test]
14560 fn classify_event_fairness_key_is_input() {
14561 let event = Event::Key(ftui_core::event::KeyEvent::new(
14562 ftui_core::event::KeyCode::Char('a'),
14563 ));
14564 let classification =
14565 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14566 assert_eq!(classification, FairnessEventType::Input);
14567 }
14568
14569 #[test]
14570 fn classify_event_fairness_resize_is_resize() {
14571 let event = Event::Resize {
14572 width: 80,
14573 height: 24,
14574 };
14575 let classification =
14576 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14577 assert_eq!(classification, FairnessEventType::Resize);
14578 }
14579
14580 #[test]
14581 fn classify_event_fairness_tick_is_tick() {
14582 let event = Event::Tick;
14583 let classification =
14584 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14585 assert_eq!(classification, FairnessEventType::Tick);
14586 }
14587
14588 #[test]
14589 fn classify_event_fairness_paste_is_input() {
14590 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
14591 let classification =
14592 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14593 assert_eq!(classification, FairnessEventType::Input);
14594 }
14595
14596 #[test]
14597 fn classify_event_fairness_focus_is_input() {
14598 let event = Event::Focus(true);
14599 let classification =
14600 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
14601 assert_eq!(classification, FairnessEventType::Input);
14602 }
14603
14604 #[test]
14609 fn program_config_with_diff_config() {
14610 let diff = RuntimeDiffConfig::default();
14611 let config = ProgramConfig::default().with_diff_config(diff.clone());
14612 let _ = format!("{:?}", config);
14614 }
14615
14616 #[test]
14617 fn program_config_with_evidence_sink() {
14618 let config =
14619 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
14620 let _ = format!("{:?}", config);
14621 }
14622
14623 #[test]
14624 fn program_config_with_render_trace() {
14625 let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
14626 let _ = format!("{:?}", config);
14627 }
14628
14629 #[test]
14630 fn program_config_with_locale() {
14631 let config = ProgramConfig::default().with_locale("fr");
14632 let _ = format!("{:?}", config);
14633 }
14634
14635 #[test]
14636 fn program_config_with_locale_context() {
14637 let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
14638 let _ = format!("{:?}", config);
14639 }
14640
14641 #[test]
14642 fn program_config_without_forced_size() {
14643 let config = ProgramConfig::default()
14644 .with_forced_size(80, 24)
14645 .without_forced_size();
14646 assert!(config.forced_size.is_none());
14647 }
14648
14649 #[test]
14650 fn program_config_forced_size_clamps_min() {
14651 let config = ProgramConfig::default().with_forced_size(0, 0);
14652 assert_eq!(config.forced_size, Some((1, 1)));
14653 }
14654
14655 #[test]
14656 fn program_config_with_widget_refresh() {
14657 let wrc = WidgetRefreshConfig {
14658 enabled: false,
14659 ..Default::default()
14660 };
14661 let config = ProgramConfig::default().with_widget_refresh(wrc);
14662 assert!(!config.widget_refresh.enabled);
14663 }
14664
14665 #[test]
14666 fn program_config_with_effect_queue() {
14667 let eqc = EffectQueueConfig::default().with_enabled(true);
14668 let config = ProgramConfig::default().with_effect_queue(eqc);
14669 assert!(config.effect_queue.enabled);
14670 assert_eq!(
14671 config.effect_queue.backend,
14672 TaskExecutorBackend::EffectQueue
14673 );
14674 }
14675
14676 #[test]
14677 fn program_config_with_resize_coalescer_custom() {
14678 let cc = CoalescerConfig {
14679 steady_delay_ms: 42,
14680 ..Default::default()
14681 };
14682 let config = ProgramConfig::default().with_resize_coalescer(cc);
14683 assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
14684 }
14685
14686 #[test]
14687 fn program_config_with_inline_auto_remeasure() {
14688 let config = ProgramConfig::default()
14689 .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
14690 assert!(config.inline_auto_remeasure.is_some());
14691
14692 let config = config.without_inline_auto_remeasure();
14693 assert!(config.inline_auto_remeasure.is_none());
14694 }
14695
14696 #[test]
14697 fn program_config_with_persistence_full() {
14698 let pc = PersistenceConfig::disabled();
14699 let config = ProgramConfig::default().with_persistence(pc);
14700 assert!(config.persistence.registry.is_none());
14701 }
14702
14703 #[test]
14704 fn program_config_with_conformal_config() {
14705 let config = ProgramConfig::default()
14706 .with_conformal_config(ConformalConfig::default())
14707 .without_conformal();
14708 assert!(config.conformal_config.is_none());
14709 }
14710
14711 #[test]
14716 fn program_config_with_lane() {
14717 let config = ProgramConfig::default().with_lane(RuntimeLane::Asupersync);
14718 assert_eq!(config.runtime_lane, RuntimeLane::Asupersync);
14719 }
14720
14721 #[test]
14722 fn program_config_default_lane_resolves_to_spawned_backend() {
14723 let resolved = ProgramConfig::default().resolved_effect_queue_config();
14731 assert!(!resolved.enabled);
14732 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14733 }
14734
14735 #[test]
14736 fn program_config_legacy_lane_resolves_to_spawned_backend() {
14737 let resolved = ProgramConfig::default()
14738 .with_lane(RuntimeLane::Legacy)
14739 .resolved_effect_queue_config();
14740 assert!(!resolved.enabled);
14741 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14742 }
14743
14744 #[test]
14745 fn program_config_explicit_spawned_backend_is_preserved() {
14746 let resolved = ProgramConfig::default()
14747 .with_effect_queue(EffectQueueConfig::default().with_enabled(false))
14748 .resolved_effect_queue_config();
14749 assert!(!resolved.enabled);
14750 assert_eq!(resolved.backend, TaskExecutorBackend::Spawned);
14751 }
14752
14753 #[test]
14754 fn program_config_with_rollout_policy() {
14755 let config = ProgramConfig::default().with_rollout_policy(RolloutPolicy::Shadow);
14756 assert_eq!(config.rollout_policy, RolloutPolicy::Shadow);
14757 }
14758
14759 #[test]
14760 fn rollout_policy_labels() {
14761 assert_eq!(RolloutPolicy::Off.label(), "off");
14762 assert_eq!(RolloutPolicy::Shadow.label(), "shadow");
14763 assert_eq!(RolloutPolicy::Enabled.label(), "enabled");
14764 assert_eq!(format!("{}", RolloutPolicy::Shadow), "shadow");
14765 }
14766
14767 #[test]
14768 fn rollout_policy_is_shadow() {
14769 assert!(!RolloutPolicy::Off.is_shadow());
14770 assert!(RolloutPolicy::Shadow.is_shadow());
14771 assert!(!RolloutPolicy::Enabled.is_shadow());
14772 }
14773
14774 #[test]
14775 fn rollout_policy_default_is_off() {
14776 assert_eq!(RolloutPolicy::default(), RolloutPolicy::Off);
14777 }
14778
14779 #[test]
14780 fn runtime_lane_parse_legacy() {
14781 assert_eq!(RuntimeLane::parse("legacy"), Some(RuntimeLane::Legacy));
14782 }
14783
14784 #[test]
14785 fn runtime_lane_parse_structured_case_insensitive() {
14786 assert_eq!(
14787 RuntimeLane::parse("Structured"),
14788 Some(RuntimeLane::Structured)
14789 );
14790 }
14791
14792 #[test]
14793 fn runtime_lane_parse_asupersync_uppercase() {
14794 assert_eq!(
14795 RuntimeLane::parse("ASUPERSYNC"),
14796 Some(RuntimeLane::Asupersync)
14797 );
14798 }
14799
14800 #[test]
14801 fn runtime_lane_parse_unrecognized() {
14802 assert_eq!(RuntimeLane::parse("bogus"), None);
14803 }
14804
14805 #[test]
14806 fn rollout_policy_parse_shadow() {
14807 assert_eq!(RolloutPolicy::parse("shadow"), Some(RolloutPolicy::Shadow));
14808 }
14809
14810 #[test]
14811 fn rollout_policy_parse_enabled() {
14812 assert_eq!(
14813 RolloutPolicy::parse("enabled"),
14814 Some(RolloutPolicy::Enabled)
14815 );
14816 }
14817
14818 #[test]
14819 fn rollout_policy_parse_off() {
14820 assert_eq!(RolloutPolicy::parse("off"), Some(RolloutPolicy::Off));
14821 }
14822
14823 #[test]
14824 fn rollout_policy_parse_unrecognized() {
14825 assert_eq!(RolloutPolicy::parse("bogus"), None);
14826 }
14827
14828 #[test]
14833 fn persistence_config_debug() {
14834 let config = PersistenceConfig::default();
14835 let debug = format!("{config:?}");
14836 assert!(debug.contains("PersistenceConfig"));
14837 assert!(debug.contains("auto_load"));
14838 assert!(debug.contains("auto_save"));
14839 }
14840
14841 #[test]
14846 fn frame_timing_config_debug() {
14847 use std::sync::Arc;
14848
14849 struct DummySink;
14850 impl FrameTimingSink for DummySink {
14851 fn record_frame(&self, _timing: &FrameTiming) {}
14852 }
14853
14854 let config = FrameTimingConfig::new(Arc::new(DummySink));
14855 let debug = format!("{config:?}");
14856 assert!(debug.contains("FrameTimingConfig"));
14857 }
14858
14859 #[test]
14860 fn program_config_with_frame_timing() {
14861 use std::sync::Arc;
14862
14863 struct DummySink;
14864 impl FrameTimingSink for DummySink {
14865 fn record_frame(&self, _timing: &FrameTiming) {}
14866 }
14867
14868 let config =
14869 ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
14870 assert!(config.frame_timing.is_some());
14871 }
14872
14873 #[test]
14878 fn budget_decision_evidence_decision_from_levels() {
14879 use ftui_render::budget::DegradationLevel;
14880 assert_eq!(
14882 BudgetDecisionEvidence::decision_from_levels(
14883 DegradationLevel::Full,
14884 DegradationLevel::SimpleBorders
14885 ),
14886 BudgetDecision::Degrade
14887 );
14888 assert_eq!(
14890 BudgetDecisionEvidence::decision_from_levels(
14891 DegradationLevel::SimpleBorders,
14892 DegradationLevel::Full
14893 ),
14894 BudgetDecision::Upgrade
14895 );
14896 assert_eq!(
14898 BudgetDecisionEvidence::decision_from_levels(
14899 DegradationLevel::Full,
14900 DegradationLevel::Full
14901 ),
14902 BudgetDecision::Hold
14903 );
14904 }
14905
14906 #[test]
14911 fn widget_refresh_plan_clear() {
14912 let mut plan = WidgetRefreshPlan::new();
14913 plan.frame_idx = 5;
14914 plan.budget_us = 100.0;
14915 plan.signal_count = 3;
14916 plan.over_budget = true;
14917 plan.clear();
14918 assert_eq!(plan.frame_idx, 0);
14919 assert_eq!(plan.budget_us, 0.0);
14920 assert_eq!(plan.signal_count, 0);
14921 assert!(!plan.over_budget);
14922 }
14923
14924 #[test]
14925 fn widget_refresh_plan_as_budget_empty_signals() {
14926 let plan = WidgetRefreshPlan::new();
14927 let budget = plan.as_budget();
14928 assert!(budget.allows(0, false));
14930 assert!(budget.allows(999, false));
14931 }
14932
14933 #[test]
14934 fn widget_refresh_plan_to_jsonl_structure() {
14935 let plan = WidgetRefreshPlan::new();
14936 let jsonl = plan.to_jsonl();
14937 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
14938 assert!(jsonl.contains("\"frame_idx\":0"));
14939 assert!(jsonl.contains("\"selected\":[]"));
14940 }
14941
14942 #[test]
14947 fn batch_controller_default_trait() {
14948 let bc = BatchController::default();
14949 let bc2 = BatchController::new();
14950 assert_eq!(bc.tau_s(), bc2.tau_s());
14952 assert_eq!(bc.observations(), bc2.observations());
14953 }
14954
14955 #[test]
14956 fn batch_controller_observe_arrival_stale_gap_ignored() {
14957 let mut bc = BatchController::new();
14958 let base = Instant::now();
14959 bc.observe_arrival(base);
14961 bc.observe_arrival(base + Duration::from_secs(15));
14963 assert_eq!(bc.observations(), 0);
14964 }
14965
14966 #[test]
14967 fn batch_controller_observe_service_out_of_range() {
14968 let mut bc = BatchController::new();
14969 let original_service = bc.service_est_s();
14970 bc.observe_service(Duration::from_secs(15));
14972 assert_eq!(bc.service_est_s(), original_service);
14973 }
14974
14975 #[test]
14976 fn batch_controller_lambda_zero_inter_arrival() {
14977 let bc = BatchController {
14979 ema_inter_arrival_s: 0.0,
14980 ..BatchController::new()
14981 };
14982 assert_eq!(bc.lambda_est(), 0.0);
14983 }
14984
14985 #[test]
14990 fn headless_execute_cmd_log_appends_newline_if_missing() {
14991 let mut program =
14992 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
14993 program.execute_cmd(Cmd::log("no newline")).expect("log");
14994
14995 let bytes = program.writer.into_inner().expect("writer output");
14996 let output = String::from_utf8_lossy(&bytes);
14997 assert!(output.contains("no newline"));
14999 }
15000
15001 #[test]
15002 fn headless_execute_cmd_log_preserves_trailing_newline() {
15003 let mut program =
15004 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
15005 program
15006 .execute_cmd(Cmd::log("with newline\n"))
15007 .expect("log");
15008
15009 let bytes = program.writer.into_inner().expect("writer output");
15010 let output = String::from_utf8_lossy(&bytes);
15011 assert!(output.contains("with newline"));
15012 }
15013
15014 #[test]
15019 fn headless_handle_event_immediate_resize() {
15020 struct ResizeModel {
15021 last_size: Option<(u16, u16)>,
15022 }
15023
15024 #[derive(Debug)]
15025 enum ResizeMsg {
15026 Resize(u16, u16),
15027 Other,
15028 }
15029
15030 impl From<Event> for ResizeMsg {
15031 fn from(event: Event) -> Self {
15032 match event {
15033 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
15034 _ => ResizeMsg::Other,
15035 }
15036 }
15037 }
15038
15039 impl Model for ResizeModel {
15040 type Message = ResizeMsg;
15041
15042 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15043 if let ResizeMsg::Resize(w, h) = msg {
15044 self.last_size = Some((w, h));
15045 }
15046 Cmd::none()
15047 }
15048
15049 fn view(&self, _frame: &mut Frame) {}
15050 }
15051
15052 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
15053 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
15054
15055 program
15056 .handle_event(Event::Resize {
15057 width: 120,
15058 height: 40,
15059 })
15060 .expect("handle resize");
15061
15062 assert_eq!(program.width, 120);
15063 assert_eq!(program.height, 40);
15064 assert_eq!(program.model().last_size, Some((120, 40)));
15065 }
15066
15067 #[test]
15072 fn headless_apply_resize_clamps_zero_to_one() {
15073 struct SimpleModel;
15074
15075 #[derive(Debug)]
15076 enum SimpleMsg {
15077 Noop,
15078 }
15079
15080 impl From<Event> for SimpleMsg {
15081 fn from(_: Event) -> Self {
15082 SimpleMsg::Noop
15083 }
15084 }
15085
15086 impl Model for SimpleModel {
15087 type Message = SimpleMsg;
15088
15089 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15090 Cmd::none()
15091 }
15092
15093 fn view(&self, _frame: &mut Frame) {}
15094 }
15095
15096 let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
15097 program
15098 .apply_resize(0, 0, Duration::ZERO, false)
15099 .expect("resize");
15100
15101 assert_eq!(program.width, 1);
15103 assert_eq!(program.height, 1);
15104 }
15105
15106 #[test]
15111 fn force_cancel_all_idle_returns_none() {
15112 let mut adapter =
15113 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15114 assert!(adapter.force_cancel_all().is_none());
15115 }
15116
15117 #[test]
15118 fn force_cancel_all_after_pointer_down_returns_diagnostics() {
15119 let mut adapter =
15120 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15121 let target = pane_target(SplitAxis::Horizontal);
15122
15123 let down = Event::Mouse(MouseEvent::new(
15124 MouseEventKind::Down(MouseButton::Left),
15125 5,
15126 5,
15127 ));
15128 let _ = adapter.translate(&down, Some(target));
15129 assert!(adapter.active_pointer_id().is_some());
15130
15131 let diag = adapter
15132 .force_cancel_all()
15133 .expect("should produce diagnostics");
15134 assert!(diag.had_active_pointer);
15135 assert_eq!(diag.active_pointer_id, Some(1));
15136 assert!(diag.machine_transition.is_some());
15137
15138 assert_eq!(adapter.active_pointer_id(), None);
15140 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
15141 }
15142
15143 #[test]
15144 fn force_cancel_all_during_drag_returns_diagnostics() {
15145 let mut adapter =
15146 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15147 let target = pane_target(SplitAxis::Vertical);
15148
15149 let down = Event::Mouse(MouseEvent::new(
15151 MouseEventKind::Down(MouseButton::Left),
15152 3,
15153 3,
15154 ));
15155 let _ = adapter.translate(&down, Some(target));
15156
15157 let drag = Event::Mouse(MouseEvent::new(
15159 MouseEventKind::Drag(MouseButton::Left),
15160 8,
15161 3,
15162 ));
15163 let _ = adapter.translate(&drag, None);
15164
15165 let diag = adapter
15166 .force_cancel_all()
15167 .expect("should produce diagnostics");
15168 assert!(diag.had_active_pointer);
15169 assert!(diag.machine_transition.is_some());
15170 let transition = diag.machine_transition.unwrap();
15171 assert!(matches!(
15172 transition.effect,
15173 PaneDragResizeEffect::Canceled {
15174 reason: PaneCancelReason::Programmatic,
15175 ..
15176 }
15177 ));
15178 }
15179
15180 #[test]
15181 fn force_cancel_all_is_idempotent() {
15182 let mut adapter =
15183 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15184 let target = pane_target(SplitAxis::Horizontal);
15185
15186 let down = Event::Mouse(MouseEvent::new(
15187 MouseEventKind::Down(MouseButton::Left),
15188 5,
15189 5,
15190 ));
15191 let _ = adapter.translate(&down, Some(target));
15192
15193 let first = adapter.force_cancel_all();
15194 assert!(first.is_some());
15195
15196 let second = adapter.force_cancel_all();
15197 assert!(second.is_none());
15198 }
15199
15200 #[test]
15205 fn pane_interaction_guard_finish_when_idle() {
15206 let mut adapter =
15207 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15208 let guard = PaneInteractionGuard::new(&mut adapter);
15209 let diag = guard.finish();
15210 assert!(diag.is_none());
15211 }
15212
15213 #[test]
15214 fn pane_interaction_guard_finish_returns_diagnostics() {
15215 let mut adapter =
15216 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15217 let target = pane_target(SplitAxis::Horizontal);
15218
15219 let down = Event::Mouse(MouseEvent::new(
15221 MouseEventKind::Down(MouseButton::Left),
15222 5,
15223 5,
15224 ));
15225 let _ = adapter.translate(&down, Some(target));
15226
15227 let guard = PaneInteractionGuard::new(&mut adapter);
15228 let diag = guard.finish().expect("should produce diagnostics");
15229 assert!(diag.had_active_pointer);
15230 assert_eq!(diag.active_pointer_id, Some(1));
15231 }
15232
15233 #[test]
15234 fn pane_interaction_guard_drop_cancels_active_interaction() {
15235 let mut adapter =
15236 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15237 let target = pane_target(SplitAxis::Vertical);
15238
15239 let down = Event::Mouse(MouseEvent::new(
15240 MouseEventKind::Down(MouseButton::Left),
15241 7,
15242 7,
15243 ));
15244 let _ = adapter.translate(&down, Some(target));
15245 assert!(adapter.active_pointer_id().is_some());
15246
15247 {
15248 let _guard = PaneInteractionGuard::new(&mut adapter);
15249 }
15251
15252 assert_eq!(adapter.active_pointer_id(), None);
15254 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
15255 }
15256
15257 #[test]
15258 fn pane_interaction_guard_adapter_access_works() {
15259 let mut adapter =
15260 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15261 let target = pane_target(SplitAxis::Horizontal);
15262
15263 let mut guard = PaneInteractionGuard::new(&mut adapter);
15264
15265 let down = Event::Mouse(MouseEvent::new(
15267 MouseEventKind::Down(MouseButton::Left),
15268 5,
15269 5,
15270 ));
15271 let dispatch = guard.adapter().translate(&down, Some(target));
15272 assert!(dispatch.primary_event.is_some());
15273
15274 let diag = guard.finish().expect("should produce diagnostics");
15276 assert!(diag.had_active_pointer);
15277 }
15278
15279 #[test]
15280 fn pane_interaction_guard_finish_then_drop_is_safe() {
15281 let mut adapter =
15282 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
15283 let target = pane_target(SplitAxis::Horizontal);
15284
15285 let down = Event::Mouse(MouseEvent::new(
15286 MouseEventKind::Down(MouseButton::Left),
15287 5,
15288 5,
15289 ));
15290 let _ = adapter.translate(&down, Some(target));
15291
15292 let guard = PaneInteractionGuard::new(&mut adapter);
15293 let _diag = guard.finish();
15294 assert_eq!(adapter.active_pointer_id(), None);
15297 }
15298
15299 fn caps_modern() -> TerminalCapabilities {
15304 TerminalCapabilities::modern()
15305 }
15306
15307 fn caps_with_mux(
15308 mux: PaneMuxEnvironment,
15309 ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
15310 let mut caps = TerminalCapabilities::modern();
15311 match mux {
15312 PaneMuxEnvironment::Tmux => caps.in_tmux = true,
15313 PaneMuxEnvironment::Screen => caps.in_screen = true,
15314 PaneMuxEnvironment::Zellij => caps.in_zellij = true,
15315 PaneMuxEnvironment::WeztermMux => caps.in_wezterm_mux = true,
15316 PaneMuxEnvironment::None => {}
15317 }
15318 caps
15319 }
15320
15321 #[test]
15322 fn capability_matrix_bare_terminal_modern() {
15323 let caps = caps_modern();
15324 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15325
15326 assert_eq!(mat.mux, PaneMuxEnvironment::None);
15327 assert!(mat.mouse_sgr);
15328 assert!(mat.mouse_drag_reliable);
15329 assert!(mat.mouse_button_discrimination);
15330 assert!(mat.focus_events);
15331 assert!(mat.unicode_box_drawing);
15332 assert!(mat.true_color);
15333 assert!(!mat.degraded);
15334 assert!(mat.drag_enabled());
15335 assert!(mat.focus_cancel_effective());
15336 assert!(mat.limitations().is_empty());
15337 }
15338
15339 #[test]
15340 fn capability_matrix_tmux() {
15341 let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
15342 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15343
15344 assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
15345 assert!(mat.mouse_drag_reliable);
15347 assert!(!mat.focus_events);
15348 assert!(mat.drag_enabled());
15349 assert!(!mat.focus_cancel_effective());
15350 assert!(mat.degraded);
15351 }
15352
15353 #[test]
15354 fn capability_matrix_screen_degrades_drag() {
15355 let caps = caps_with_mux(PaneMuxEnvironment::Screen);
15356 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15357
15358 assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
15359 assert!(!mat.mouse_drag_reliable);
15360 assert!(!mat.focus_events);
15361 assert!(!mat.drag_enabled());
15362 assert!(!mat.focus_cancel_effective());
15363 assert!(mat.degraded);
15364
15365 let lims = mat.limitations();
15366 assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
15367 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15368 }
15369
15370 #[test]
15371 fn capability_matrix_zellij() {
15372 let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
15373 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15374
15375 assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
15376 assert!(mat.mouse_drag_reliable);
15377 assert!(!mat.focus_events);
15378 assert!(mat.drag_enabled());
15379 assert!(!mat.focus_cancel_effective());
15380 assert!(mat.degraded);
15381 }
15382
15383 #[test]
15384 fn capability_matrix_wezterm_mux_disables_focus_cancel_path() {
15385 let caps = caps_with_mux(PaneMuxEnvironment::WeztermMux);
15386 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15387
15388 assert_eq!(mat.mux, PaneMuxEnvironment::WeztermMux);
15389 assert!(mat.mouse_drag_reliable);
15390 assert!(!mat.focus_events);
15391 assert!(mat.drag_enabled());
15392 assert!(!mat.focus_cancel_effective());
15393 assert!(mat.degraded);
15394 }
15395
15396 #[test]
15397 fn capability_matrix_no_sgr_mouse() {
15398 let mut caps = caps_modern();
15399 caps.mouse_sgr = false;
15400 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15401
15402 assert!(!mat.mouse_sgr);
15403 assert!(!mat.mouse_button_discrimination);
15404 assert!(mat.degraded);
15405
15406 let lims = mat.limitations();
15407 assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
15408 assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
15409 }
15410
15411 #[test]
15412 fn capability_matrix_no_focus_events() {
15413 let mut caps = caps_modern();
15414 caps.focus_events = false;
15415 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15416
15417 assert!(!mat.focus_events);
15418 assert!(!mat.focus_cancel_effective());
15419 assert!(mat.degraded);
15420
15421 let lims = mat.limitations();
15422 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
15423 }
15424
15425 #[test]
15426 fn capability_matrix_dumb_terminal() {
15427 let caps = TerminalCapabilities::dumb();
15428 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15429
15430 assert_eq!(mat.mux, PaneMuxEnvironment::None);
15431 assert!(!mat.mouse_sgr);
15432 assert!(!mat.focus_events);
15433 assert!(!mat.unicode_box_drawing);
15434 assert!(!mat.true_color);
15435 assert!(mat.degraded);
15436 assert!(mat.limitations().len() >= 3);
15437 }
15438
15439 #[test]
15440 fn capability_matrix_limitations_have_fallbacks() {
15441 let caps = TerminalCapabilities::dumb();
15442 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
15443
15444 for lim in mat.limitations() {
15445 assert!(!lim.id.is_empty());
15446 assert!(!lim.description.is_empty());
15447 assert!(!lim.fallback.is_empty());
15448 }
15449 }
15450
15451 struct MultiScreenModel {
15458 active: String,
15459 screens: Vec<String>,
15460 ticked_screens: Vec<(String, u64)>,
15461 }
15462
15463 #[derive(Debug)]
15464 enum MultiScreenMsg {
15465 #[expect(dead_code)]
15466 Event(Event),
15467 }
15468
15469 impl From<Event> for MultiScreenMsg {
15470 fn from(event: Event) -> Self {
15471 MultiScreenMsg::Event(event)
15472 }
15473 }
15474
15475 impl Model for MultiScreenModel {
15476 type Message = MultiScreenMsg;
15477
15478 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
15479 match msg {
15480 MultiScreenMsg::Event(_) => Cmd::none(),
15481 }
15482 }
15483
15484 fn view(&self, _frame: &mut Frame) {}
15485
15486 fn as_screen_tick_dispatch(
15487 &mut self,
15488 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15489 Some(self)
15490 }
15491 }
15492
15493 impl crate::tick_strategy::ScreenTickDispatch for MultiScreenModel {
15494 fn screen_ids(&self) -> Vec<String> {
15495 self.screens.clone()
15496 }
15497
15498 fn active_screen_id(&self) -> String {
15499 self.active.clone()
15500 }
15501
15502 fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
15503 self.ticked_screens.push((screen_id.to_owned(), tick_count));
15504 }
15505 }
15506
15507 type TransitionLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
15509
15510 struct RecordingStrategy {
15513 log: TransitionLog,
15514 }
15515
15516 impl RecordingStrategy {
15517 fn new(log: TransitionLog) -> Self {
15518 Self { log }
15519 }
15520 }
15521
15522 impl crate::tick_strategy::TickStrategy for RecordingStrategy {
15523 fn should_tick(
15524 &mut self,
15525 _screen_id: &str,
15526 _tick_count: u64,
15527 _active_screen: &str,
15528 ) -> crate::tick_strategy::TickDecision {
15529 crate::tick_strategy::TickDecision::Skip
15530 }
15531
15532 fn on_screen_transition(&mut self, from: &str, to: &str) {
15533 self.log
15534 .lock()
15535 .unwrap()
15536 .push((from.to_owned(), to.to_owned()));
15537 }
15538
15539 fn name(&self) -> &str {
15540 "Recording"
15541 }
15542
15543 fn debug_stats(&self) -> Vec<(String, String)> {
15544 vec![("strategy".into(), "Recording".into())]
15545 }
15546 }
15547
15548 fn headless_multi_screen_program(
15552 active: &str,
15553 screens: &[&str],
15554 ) -> (
15555 Program<MultiScreenModel, HeadlessEventSource, Vec<u8>>,
15556 TransitionLog,
15557 ) {
15558 let model = MultiScreenModel {
15559 active: active.to_owned(),
15560 screens: screens.iter().map(|s| (*s).to_owned()).collect(),
15561 ticked_screens: Vec::new(),
15562 };
15563 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15564 let writer = TerminalWriter::new(
15565 Vec::<u8>::new(),
15566 ScreenMode::AltScreen,
15567 UiAnchor::Bottom,
15568 TerminalCapabilities::dumb(),
15569 );
15570 let config = ProgramConfig {
15571 forced_size: Some((80, 24)),
15572 tick_strategy: Some(crate::tick_strategy::TickStrategyKind::ActiveOnly),
15573 ..ProgramConfig::default()
15574 };
15575 let mut prog =
15576 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15577 .expect("headless program creation failed");
15578
15579 let log: TransitionLog = Arc::new(std::sync::Mutex::new(Vec::new()));
15581 prog.tick_strategy = Some(Box::new(RecordingStrategy::new(log.clone())));
15582
15583 (prog, log)
15584 }
15585
15586 #[test]
15587 fn check_screen_transition_first_call_records_active() {
15588 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15589
15590 assert!(prog.last_active_screen_for_strategy.is_none());
15591 prog.check_screen_transition();
15592 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15593
15594 assert!(prog.model.ticked_screens.is_empty());
15596 assert!(log.lock().unwrap().is_empty());
15597 }
15598
15599 #[test]
15600 fn check_screen_transition_no_change_is_noop() {
15601 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15602
15603 prog.check_screen_transition();
15605
15606 prog.check_screen_transition();
15608 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("A"));
15609
15610 assert!(prog.model.ticked_screens.is_empty());
15612 assert!(log.lock().unwrap().is_empty());
15613 }
15614
15615 #[test]
15616 fn check_screen_transition_detects_switch_and_force_ticks() {
15617 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15618
15619 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15623 prog.check_screen_transition();
15624
15625 assert_eq!(prog.model.ticked_screens.len(), 1);
15627 assert_eq!(prog.model.ticked_screens[0].0, "B");
15628
15629 let transitions = log.lock().unwrap();
15631 assert_eq!(transitions.len(), 1);
15632 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15633
15634 assert_eq!(prog.last_active_screen_for_strategy.as_deref(), Some("B"));
15636 }
15637
15638 #[test]
15639 fn check_screen_transition_marks_dirty_on_change() {
15640 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15641
15642 prog.check_screen_transition();
15643 prog.dirty = false;
15644
15645 prog.model.active = "B".to_owned();
15646 prog.check_screen_transition();
15647
15648 assert!(prog.dirty);
15649 }
15650
15651 #[test]
15652 fn check_screen_transition_not_dirty_when_unchanged() {
15653 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15654
15655 prog.check_screen_transition();
15656 prog.dirty = false;
15657
15658 prog.check_screen_transition();
15659
15660 assert!(!prog.dirty);
15661 }
15662
15663 #[test]
15664 fn check_screen_transition_noop_without_strategy() {
15665 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15666
15667 prog.tick_strategy = None;
15669
15670 prog.check_screen_transition();
15671 assert!(prog.last_active_screen_for_strategy.is_none());
15672 }
15673
15674 #[test]
15675 fn check_screen_transition_multiple_switches_notifies_strategy() {
15676 let (mut prog, log) = headless_multi_screen_program("A", &["A", "B", "C"]);
15677
15678 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15682 prog.check_screen_transition();
15683 assert_eq!(prog.model.ticked_screens.len(), 1);
15684 assert_eq!(prog.model.ticked_screens[0].0, "B");
15685
15686 prog.model.active = "C".to_owned();
15688 prog.check_screen_transition();
15689 assert_eq!(prog.model.ticked_screens.len(), 2);
15690 assert_eq!(prog.model.ticked_screens[1].0, "C");
15691
15692 prog.model.active = "A".to_owned();
15694 prog.check_screen_transition();
15695 assert_eq!(prog.model.ticked_screens.len(), 3);
15696 assert_eq!(prog.model.ticked_screens[2].0, "A");
15697
15698 let transitions = log.lock().unwrap();
15700 assert_eq!(transitions.len(), 3);
15701 assert_eq!(transitions[0], ("A".to_owned(), "B".to_owned()));
15702 assert_eq!(transitions[1], ("B".to_owned(), "C".to_owned()));
15703 assert_eq!(transitions[2], ("C".to_owned(), "A".to_owned()));
15704 }
15705
15706 #[test]
15707 fn check_screen_transition_uses_current_tick_count() {
15708 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15709 prog.tick_count = 42;
15710
15711 prog.check_screen_transition(); prog.model.active = "B".to_owned();
15714 prog.check_screen_transition();
15715
15716 assert_eq!(prog.model.ticked_screens[0].1, 42);
15718 }
15719
15720 #[test]
15721 fn check_screen_transition_reconciles_subscriptions_after_force_tick() {
15722 use crate::subscription::{StopSignal, SubId, Subscription};
15723
15724 struct TransitionSubModel {
15725 active: String,
15726 screens: Vec<String>,
15727 subscribed: bool,
15728 }
15729
15730 #[derive(Debug)]
15731 #[allow(dead_code)]
15732 enum TransitionSubMsg {
15733 Event(Event),
15734 }
15735
15736 impl From<Event> for TransitionSubMsg {
15737 fn from(event: Event) -> Self {
15738 Self::Event(event)
15739 }
15740 }
15741
15742 impl Model for TransitionSubModel {
15743 type Message = TransitionSubMsg;
15744
15745 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
15746 Cmd::none()
15747 }
15748
15749 fn view(&self, _frame: &mut Frame) {}
15750
15751 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
15752 if self.subscribed {
15753 vec![Box::new(TransitionSubscription)]
15754 } else {
15755 vec![]
15756 }
15757 }
15758
15759 fn as_screen_tick_dispatch(
15760 &mut self,
15761 ) -> Option<&mut dyn crate::tick_strategy::ScreenTickDispatch> {
15762 Some(self)
15763 }
15764 }
15765
15766 impl crate::tick_strategy::ScreenTickDispatch for TransitionSubModel {
15767 fn screen_ids(&self) -> Vec<String> {
15768 self.screens.clone()
15769 }
15770
15771 fn active_screen_id(&self) -> String {
15772 self.active.clone()
15773 }
15774
15775 fn tick_screen(&mut self, screen_id: &str, _tick_count: u64) {
15776 if screen_id == self.active {
15777 self.subscribed = true;
15778 }
15779 }
15780 }
15781
15782 struct TransitionSubscription;
15783
15784 impl Subscription<TransitionSubMsg> for TransitionSubscription {
15785 fn id(&self) -> SubId {
15786 1
15787 }
15788
15789 fn run(&self, _sender: mpsc::Sender<TransitionSubMsg>, _stop: StopSignal) {}
15790 }
15791
15792 struct TransitionStrategy;
15793
15794 impl crate::tick_strategy::TickStrategy for TransitionStrategy {
15795 fn should_tick(
15796 &mut self,
15797 _screen_id: &str,
15798 _tick_count: u64,
15799 _active_screen: &str,
15800 ) -> crate::tick_strategy::TickDecision {
15801 crate::tick_strategy::TickDecision::Skip
15802 }
15803
15804 fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
15805
15806 fn name(&self) -> &str {
15807 "TransitionStrategy"
15808 }
15809
15810 fn debug_stats(&self) -> Vec<(String, String)> {
15811 vec![]
15812 }
15813 }
15814
15815 let model = TransitionSubModel {
15816 active: "A".to_owned(),
15817 screens: vec!["A".to_owned(), "B".to_owned()],
15818 subscribed: false,
15819 };
15820 let events = HeadlessEventSource::new(80, 24, BackendFeatures::default());
15821 let writer = TerminalWriter::new(
15822 Vec::<u8>::new(),
15823 ScreenMode::AltScreen,
15824 UiAnchor::Bottom,
15825 TerminalCapabilities::dumb(),
15826 );
15827 let config = ProgramConfig::default().with_forced_size(80, 24);
15828
15829 let mut program =
15830 Program::with_event_source(model, events, BackendFeatures::default(), writer, config)
15831 .expect("program creation");
15832 program.tick_strategy = Some(Box::new(TransitionStrategy));
15833
15834 program.check_screen_transition();
15835 assert_eq!(program.subscriptions.active_count(), 0);
15836
15837 program.model.active = "B".to_owned();
15838 program.check_screen_transition();
15839
15840 assert!(program.model().subscribed);
15841 assert_eq!(program.subscriptions.active_count(), 1);
15842 }
15843
15844 #[test]
15845 fn tick_strategy_stats_returns_empty_without_strategy() {
15846 let (mut prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15847 prog.tick_strategy = None;
15848 assert!(prog.tick_strategy_stats().is_empty());
15849 }
15850
15851 #[test]
15852 fn tick_strategy_stats_returns_strategy_fields() {
15853 let (prog, _log) = headless_multi_screen_program("A", &["A", "B"]);
15854 let stats = prog.tick_strategy_stats();
15855 assert!(
15857 !stats.is_empty(),
15858 "stats should not be empty when strategy is configured"
15859 );
15860 }
15861}