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