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