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};
71use ftui_backend::{BackendEventSource, BackendFeatures};
72use ftui_core::event::{
73 Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
74};
75#[cfg(feature = "crossterm-compat")]
76use ftui_core::terminal_capabilities::TerminalCapabilities;
77#[cfg(feature = "crossterm-compat")]
78use ftui_core::terminal_session::{SessionOptions, TerminalSession};
79use ftui_layout::{
80 PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
81 PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
82 PaneDragResizeTransition, PaneLayout, PaneModifierSnapshot, PaneNodeKind, PanePointerButton,
83 PanePointerPosition, PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent,
84 PaneSemanticInputEventKind, PaneTree, Rect, SplitAxis,
85};
86use ftui_render::arena::FrameArena;
87use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
88use ftui_render::buffer::Buffer;
89use ftui_render::diff_strategy::DiffStrategy;
90use ftui_render::frame::{Frame, HitData, HitId, HitRegion, WidgetBudget, WidgetSignal};
91use ftui_render::sanitize::sanitize;
92use std::collections::HashMap;
93use std::io::{self, Stdout, Write};
94use std::sync::Arc;
95use std::sync::mpsc;
96use std::thread::{self, JoinHandle};
97use tracing::{debug, debug_span, info, info_span};
98use web_time::{Duration, Instant};
99
100pub trait Model: Sized {
105 type Message: From<Event> + Send + 'static;
110
111 fn init(&mut self) -> Cmd<Self::Message> {
116 Cmd::none()
117 }
118
119 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
124
125 fn view(&self, frame: &mut Frame);
129
130 fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
140 vec![]
141 }
142}
143
144const DEFAULT_TASK_WEIGHT: f64 = 1.0;
146
147const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
149
150#[derive(Debug, Clone)]
152pub struct TaskSpec {
153 pub weight: f64,
155 pub estimate_ms: f64,
157 pub name: Option<String>,
159}
160
161impl Default for TaskSpec {
162 fn default() -> Self {
163 Self {
164 weight: DEFAULT_TASK_WEIGHT,
165 estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
166 name: None,
167 }
168 }
169}
170
171impl TaskSpec {
172 #[must_use]
174 pub fn new(weight: f64, estimate_ms: f64) -> Self {
175 Self {
176 weight,
177 estimate_ms,
178 name: None,
179 }
180 }
181
182 #[must_use]
184 pub fn with_name(mut self, name: impl Into<String>) -> Self {
185 self.name = Some(name.into());
186 self
187 }
188}
189
190#[derive(Debug, Clone, Copy)]
192pub struct FrameTiming {
193 pub frame_idx: u64,
194 pub update_us: u64,
195 pub render_us: u64,
196 pub diff_us: u64,
197 pub present_us: u64,
198 pub total_us: u64,
199}
200
201pub trait FrameTimingSink: Send + Sync {
203 fn record_frame(&self, timing: &FrameTiming);
204}
205
206#[derive(Clone)]
208pub struct FrameTimingConfig {
209 pub sink: Arc<dyn FrameTimingSink>,
210}
211
212impl FrameTimingConfig {
213 #[must_use]
214 pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
215 Self { sink }
216 }
217}
218
219impl std::fmt::Debug for FrameTimingConfig {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("FrameTimingConfig")
222 .field("sink", &"<dyn FrameTimingSink>")
223 .finish()
224 }
225}
226
227#[derive(Default)]
232pub enum Cmd<M> {
233 #[default]
235 None,
236 Quit,
238 Batch(Vec<Cmd<M>>),
240 Sequence(Vec<Cmd<M>>),
242 Msg(M),
244 Tick(Duration),
246 Log(String),
251 Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
258 SaveState,
263 RestoreState,
269 SetMouseCapture(bool),
274}
275
276impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 match self {
279 Self::None => write!(f, "None"),
280 Self::Quit => write!(f, "Quit"),
281 Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
282 Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
283 Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
284 Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
285 Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
286 Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
287 Self::SaveState => write!(f, "SaveState"),
288 Self::RestoreState => write!(f, "RestoreState"),
289 Self::SetMouseCapture(b) => write!(f, "SetMouseCapture({b})"),
290 }
291 }
292}
293
294impl<M> Cmd<M> {
295 #[inline]
297 pub fn none() -> Self {
298 Self::None
299 }
300
301 #[inline]
303 pub fn quit() -> Self {
304 Self::Quit
305 }
306
307 #[inline]
309 pub fn msg(m: M) -> Self {
310 Self::Msg(m)
311 }
312
313 #[inline]
318 pub fn log(msg: impl Into<String>) -> Self {
319 Self::Log(msg.into())
320 }
321
322 pub fn batch(cmds: Vec<Self>) -> Self {
324 if cmds.is_empty() {
325 Self::None
326 } else if cmds.len() == 1 {
327 cmds.into_iter().next().unwrap_or(Self::None)
328 } else {
329 Self::Batch(cmds)
330 }
331 }
332
333 pub fn sequence(cmds: Vec<Self>) -> Self {
335 if cmds.is_empty() {
336 Self::None
337 } else if cmds.len() == 1 {
338 cmds.into_iter().next().unwrap_or(Self::None)
339 } else {
340 Self::Sequence(cmds)
341 }
342 }
343
344 #[inline]
346 pub fn type_name(&self) -> &'static str {
347 match self {
348 Self::None => "None",
349 Self::Quit => "Quit",
350 Self::Batch(_) => "Batch",
351 Self::Sequence(_) => "Sequence",
352 Self::Msg(_) => "Msg",
353 Self::Tick(_) => "Tick",
354 Self::Log(_) => "Log",
355 Self::Task(..) => "Task",
356 Self::SaveState => "SaveState",
357 Self::RestoreState => "RestoreState",
358 Self::SetMouseCapture(_) => "SetMouseCapture",
359 }
360 }
361
362 #[inline]
364 pub fn tick(duration: Duration) -> Self {
365 Self::Tick(duration)
366 }
367
368 pub fn task<F>(f: F) -> Self
374 where
375 F: FnOnce() -> M + Send + 'static,
376 {
377 Self::Task(TaskSpec::default(), Box::new(f))
378 }
379
380 pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
382 where
383 F: FnOnce() -> M + Send + 'static,
384 {
385 Self::Task(spec, Box::new(f))
386 }
387
388 pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
390 where
391 F: FnOnce() -> M + Send + 'static,
392 {
393 Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
394 }
395
396 pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
398 where
399 F: FnOnce() -> M + Send + 'static,
400 {
401 Self::Task(TaskSpec::default().with_name(name), Box::new(f))
402 }
403
404 #[inline]
409 pub fn save_state() -> Self {
410 Self::SaveState
411 }
412
413 #[inline]
418 pub fn restore_state() -> Self {
419 Self::RestoreState
420 }
421
422 #[inline]
427 pub fn set_mouse_capture(enabled: bool) -> Self {
428 Self::SetMouseCapture(enabled)
429 }
430
431 pub fn count(&self) -> usize {
435 match self {
436 Self::None => 0,
437 Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
438 _ => 1,
439 }
440 }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum ResizeBehavior {
446 Immediate,
448 Throttled,
450}
451
452impl ResizeBehavior {
453 const fn uses_coalescer(self) -> bool {
454 matches!(self, ResizeBehavior::Throttled)
455 }
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
464pub enum MouseCapturePolicy {
465 #[default]
467 Auto,
468 On,
470 Off,
472}
473
474impl MouseCapturePolicy {
475 #[must_use]
477 pub const fn resolve(self, screen_mode: ScreenMode) -> bool {
478 match self {
479 Self::Auto => matches!(screen_mode, ScreenMode::AltScreen),
480 Self::On => true,
481 Self::Off => false,
482 }
483 }
484}
485
486const PANE_TERMINAL_DEFAULT_HIT_THICKNESS: u16 = 3;
487const PANE_TERMINAL_TARGET_AXIS_MASK: u64 = 0b1;
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub struct PaneTerminalSplitterHandle {
492 pub target: PaneResizeTarget,
494 pub rect: Rect,
496 pub boundary: i32,
498}
499
500#[must_use]
504pub fn pane_terminal_splitter_handles(
505 tree: &PaneTree,
506 layout: &PaneLayout,
507 hit_thickness: u16,
508) -> Vec<PaneTerminalSplitterHandle> {
509 let thickness = if hit_thickness == 0 {
510 PANE_TERMINAL_DEFAULT_HIT_THICKNESS
511 } else {
512 hit_thickness
513 };
514 let mut handles = Vec::new();
515 for node in tree.nodes() {
516 let PaneNodeKind::Split(split) = &node.kind else {
517 continue;
518 };
519 let Some(split_rect) = layout.rect(node.id) else {
520 continue;
521 };
522 if split_rect.is_empty() {
523 continue;
524 }
525 let Some(first_rect) = layout.rect(split.first) else {
526 continue;
527 };
528 let Some(second_rect) = layout.rect(split.second) else {
529 continue;
530 };
531
532 let boundary_u16 = match split.axis {
533 SplitAxis::Horizontal => {
534 if second_rect.x == split_rect.x {
536 first_rect.right()
537 } else {
538 second_rect.x
539 }
540 }
541 SplitAxis::Vertical => {
542 if second_rect.y == split_rect.y {
544 first_rect.bottom()
545 } else {
546 second_rect.y
547 }
548 }
549 };
550 let Some(rect) = splitter_hit_rect(split.axis, split_rect, boundary_u16, thickness) else {
551 continue;
552 };
553 handles.push(PaneTerminalSplitterHandle {
554 target: PaneResizeTarget {
555 split_id: node.id,
556 axis: split.axis,
557 },
558 rect,
559 boundary: i32::from(boundary_u16),
560 });
561 }
562 handles
563}
564
565#[must_use]
572pub fn pane_terminal_resolve_splitter_target(
573 handles: &[PaneTerminalSplitterHandle],
574 x: u16,
575 y: u16,
576) -> Option<PaneResizeTarget> {
577 let px = i32::from(x);
578 let py = i32::from(y);
579 let mut best: Option<((u32, u64, u8), PaneResizeTarget)> = None;
580
581 for handle in handles {
582 if !rect_contains_cell(handle.rect, x, y) {
583 continue;
584 }
585 let distance = match handle.target.axis {
586 SplitAxis::Horizontal => px.abs_diff(handle.boundary),
587 SplitAxis::Vertical => py.abs_diff(handle.boundary),
588 };
589 let axis_rank = match handle.target.axis {
590 SplitAxis::Horizontal => 0,
591 SplitAxis::Vertical => 1,
592 };
593 let key = (distance, handle.target.split_id.get(), axis_rank);
594 if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
595 best = Some((key, handle.target));
596 }
597 }
598
599 best.map(|(_, target)| target)
600}
601
602pub fn register_pane_terminal_splitter_hits(
607 frame: &mut Frame,
608 handles: &[PaneTerminalSplitterHandle],
609 hit_id_base: u32,
610) -> usize {
611 let mut registered = 0usize;
612 for (idx, handle) in handles.iter().enumerate() {
613 let Ok(offset) = u32::try_from(idx) else {
614 break;
615 };
616 let hit_id = HitId::new(hit_id_base.saturating_add(offset));
617 if frame.register_hit(
618 handle.rect,
619 hit_id,
620 HitRegion::Handle,
621 encode_pane_resize_target(handle.target),
622 ) {
623 registered = registered.saturating_add(1);
624 }
625 }
626 registered
627}
628
629#[must_use]
631pub fn pane_terminal_target_from_hit(hit: (HitId, HitRegion, HitData)) -> Option<PaneResizeTarget> {
632 let (_, region, data) = hit;
633 if region != HitRegion::Handle {
634 return None;
635 }
636 decode_pane_resize_target(data)
637}
638
639fn splitter_hit_rect(
640 axis: SplitAxis,
641 split_rect: Rect,
642 boundary: u16,
643 thickness: u16,
644) -> Option<Rect> {
645 let half = thickness.saturating_sub(1) / 2;
646 match axis {
647 SplitAxis::Horizontal => {
648 let start = boundary.saturating_sub(half).max(split_rect.x);
649 let end = boundary
650 .saturating_add(thickness.saturating_sub(half))
651 .min(split_rect.right());
652 let width = end.saturating_sub(start);
653 (width > 0 && split_rect.height > 0).then_some(Rect::new(
654 start,
655 split_rect.y,
656 width,
657 split_rect.height,
658 ))
659 }
660 SplitAxis::Vertical => {
661 let start = boundary.saturating_sub(half).max(split_rect.y);
662 let end = boundary
663 .saturating_add(thickness.saturating_sub(half))
664 .min(split_rect.bottom());
665 let height = end.saturating_sub(start);
666 (height > 0 && split_rect.width > 0).then_some(Rect::new(
667 split_rect.x,
668 start,
669 split_rect.width,
670 height,
671 ))
672 }
673 }
674}
675
676fn rect_contains_cell(rect: Rect, x: u16, y: u16) -> bool {
677 x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom()
678}
679
680fn encode_pane_resize_target(target: PaneResizeTarget) -> HitData {
681 let axis = match target.axis {
682 SplitAxis::Horizontal => 0_u64,
683 SplitAxis::Vertical => PANE_TERMINAL_TARGET_AXIS_MASK,
684 };
685 (target.split_id.get() << 1) | axis
686}
687
688fn decode_pane_resize_target(data: HitData) -> Option<PaneResizeTarget> {
689 let axis = if data & PANE_TERMINAL_TARGET_AXIS_MASK == 0 {
690 SplitAxis::Horizontal
691 } else {
692 SplitAxis::Vertical
693 };
694 let split_id = ftui_layout::PaneId::new(data >> 1).ok()?;
695 Some(PaneResizeTarget { split_id, axis })
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
704pub enum PaneMuxEnvironment {
705 None,
707 Tmux,
709 Screen,
711 Zellij,
713}
714
715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub struct PaneCapabilityMatrix {
723 pub mux: PaneMuxEnvironment,
725
726 pub mouse_sgr: bool,
730 pub mouse_drag_reliable: bool,
733 pub mouse_button_discrimination: bool,
736
737 pub focus_events: bool,
740 pub bracketed_paste: bool,
742
743 pub unicode_box_drawing: bool,
746 pub true_color: bool,
748
749 pub degraded: bool,
752}
753
754#[derive(Debug, Clone, PartialEq, Eq)]
756pub struct PaneCapabilityLimitation {
757 pub id: &'static str,
759 pub description: &'static str,
761 pub fallback: &'static str,
763}
764
765impl PaneCapabilityMatrix {
766 #[must_use]
771 pub fn from_capabilities(
772 caps: &ftui_core::terminal_capabilities::TerminalCapabilities,
773 ) -> Self {
774 let mux = if caps.in_tmux {
775 PaneMuxEnvironment::Tmux
776 } else if caps.in_screen {
777 PaneMuxEnvironment::Screen
778 } else if caps.in_zellij {
779 PaneMuxEnvironment::Zellij
780 } else {
781 PaneMuxEnvironment::None
782 };
783
784 let mouse_sgr = caps.mouse_sgr;
785
786 let mouse_drag_reliable = !matches!(mux, PaneMuxEnvironment::Screen);
789
790 let mouse_button_discrimination = mouse_sgr;
793
794 let focus_events = match mux {
797 PaneMuxEnvironment::Screen => false,
798 _ => caps.focus_events,
799 };
800
801 let bracketed_paste = caps.bracketed_paste;
802 let unicode_box_drawing = caps.unicode_box_drawing;
803 let true_color = caps.true_color;
804
805 let degraded =
806 !mouse_sgr || !mouse_drag_reliable || !mouse_button_discrimination || !focus_events;
807
808 Self {
809 mux,
810 mouse_sgr,
811 mouse_drag_reliable,
812 mouse_button_discrimination,
813 focus_events,
814 bracketed_paste,
815 unicode_box_drawing,
816 true_color,
817 degraded,
818 }
819 }
820
821 #[must_use]
827 pub const fn drag_enabled(&self) -> bool {
828 self.mouse_drag_reliable
829 }
830
831 #[must_use]
837 pub const fn focus_cancel_effective(&self) -> bool {
838 self.focus_events
839 }
840
841 #[must_use]
843 pub fn limitations(&self) -> Vec<PaneCapabilityLimitation> {
844 let mut out = Vec::new();
845
846 if !self.mouse_sgr {
847 out.push(PaneCapabilityLimitation {
848 id: "no_sgr_mouse",
849 description: "SGR mouse protocol not available; coordinates limited to 223 columns/rows",
850 fallback: "Pane splitters beyond column 223 are unreachable by mouse; use keyboard resize",
851 });
852 }
853
854 if !self.mouse_drag_reliable {
855 out.push(PaneCapabilityLimitation {
856 id: "mouse_drag_unreliable",
857 description: "Mouse drag events are unreliably delivered (e.g. GNU Screen)",
858 fallback: "Mouse drag disabled; use keyboard arrow keys to resize panes",
859 });
860 }
861
862 if !self.mouse_button_discrimination {
863 out.push(PaneCapabilityLimitation {
864 id: "no_button_discrimination",
865 description: "Mouse release events do not identify which button was released",
866 fallback: "Any mouse release cancels the active drag; multi-button interactions unavailable",
867 });
868 }
869
870 if !self.focus_events {
871 out.push(PaneCapabilityLimitation {
872 id: "no_focus_events",
873 description: "Terminal does not deliver focus-in/focus-out events",
874 fallback: "Focus-loss auto-cancel disabled; use Escape key to cancel active drag",
875 });
876 }
877
878 out
879 }
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq)]
887pub struct PaneTerminalAdapterConfig {
888 pub drag_threshold: u16,
890 pub update_hysteresis: u16,
892 pub activation_button: PanePointerButton,
894 pub drag_update_coalesce_distance: u16,
897 pub cancel_on_focus_lost: bool,
899 pub cancel_on_resize: bool,
901}
902
903impl Default for PaneTerminalAdapterConfig {
904 fn default() -> Self {
905 Self {
906 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
907 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
908 activation_button: PanePointerButton::Primary,
909 drag_update_coalesce_distance: 2,
910 cancel_on_focus_lost: true,
911 cancel_on_resize: true,
912 }
913 }
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
917struct PaneTerminalActivePointer {
918 pointer_id: u32,
919 target: PaneResizeTarget,
920 button: PanePointerButton,
921 last_position: PanePointerPosition,
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
926pub enum PaneTerminalLifecyclePhase {
927 MouseDown,
928 MouseDrag,
929 MouseMove,
930 MouseUp,
931 MouseScroll,
932 KeyResize,
933 KeyCancel,
934 FocusLoss,
935 ResizeInterrupt,
936 Other,
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941pub enum PaneTerminalIgnoredReason {
942 MissingTarget,
943 NoActivePointer,
944 PointerButtonMismatch,
945 ActivationButtonRequired,
946 UnsupportedKey,
947 FocusGainNoop,
948 ResizeNoop,
949 DragCoalesced,
950 NonSemanticEvent,
951 MachineRejectedEvent,
952}
953
954#[derive(Debug, Clone, Copy, PartialEq, Eq)]
956pub enum PaneTerminalLogOutcome {
957 SemanticForwarded,
958 SemanticForwardedAfterRecovery,
959 Ignored(PaneTerminalIgnoredReason),
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub struct PaneTerminalLogEntry {
965 pub phase: PaneTerminalLifecyclePhase,
966 pub sequence: Option<u64>,
967 pub pointer_id: Option<u32>,
968 pub target: Option<PaneResizeTarget>,
969 pub recovery_cancel_sequence: Option<u64>,
970 pub outcome: PaneTerminalLogOutcome,
971}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
979pub struct PaneTerminalDispatch {
980 pub primary_event: Option<PaneSemanticInputEvent>,
981 pub primary_transition: Option<PaneDragResizeTransition>,
982 pub recovery_event: Option<PaneSemanticInputEvent>,
983 pub recovery_transition: Option<PaneDragResizeTransition>,
984 pub log: PaneTerminalLogEntry,
985}
986
987impl PaneTerminalDispatch {
988 fn ignored(
989 phase: PaneTerminalLifecyclePhase,
990 reason: PaneTerminalIgnoredReason,
991 pointer_id: Option<u32>,
992 target: Option<PaneResizeTarget>,
993 ) -> Self {
994 Self {
995 primary_event: None,
996 primary_transition: None,
997 recovery_event: None,
998 recovery_transition: None,
999 log: PaneTerminalLogEntry {
1000 phase,
1001 sequence: None,
1002 pointer_id,
1003 target,
1004 recovery_cancel_sequence: None,
1005 outcome: PaneTerminalLogOutcome::Ignored(reason),
1006 },
1007 }
1008 }
1009
1010 fn forwarded(
1011 phase: PaneTerminalLifecyclePhase,
1012 pointer_id: Option<u32>,
1013 target: Option<PaneResizeTarget>,
1014 event: PaneSemanticInputEvent,
1015 transition: PaneDragResizeTransition,
1016 ) -> Self {
1017 let sequence = Some(event.sequence);
1018 Self {
1019 primary_event: Some(event),
1020 primary_transition: Some(transition),
1021 recovery_event: None,
1022 recovery_transition: None,
1023 log: PaneTerminalLogEntry {
1024 phase,
1025 sequence,
1026 pointer_id,
1027 target,
1028 recovery_cancel_sequence: None,
1029 outcome: PaneTerminalLogOutcome::SemanticForwarded,
1030 },
1031 }
1032 }
1033}
1034
1035#[derive(Debug, Clone)]
1038pub struct PaneTerminalAdapter {
1039 machine: PaneDragResizeMachine,
1040 config: PaneTerminalAdapterConfig,
1041 active: Option<PaneTerminalActivePointer>,
1042 next_sequence: u64,
1043}
1044
1045impl PaneTerminalAdapter {
1046 pub fn new(config: PaneTerminalAdapterConfig) -> Result<Self, PaneDragResizeMachineError> {
1048 let config = PaneTerminalAdapterConfig {
1049 drag_update_coalesce_distance: config.drag_update_coalesce_distance.max(1),
1050 ..config
1051 };
1052 let machine = PaneDragResizeMachine::new_with_hysteresis(
1053 config.drag_threshold,
1054 config.update_hysteresis,
1055 )?;
1056 Ok(Self {
1057 machine,
1058 config,
1059 active: None,
1060 next_sequence: 1,
1061 })
1062 }
1063
1064 #[must_use]
1066 pub const fn config(&self) -> PaneTerminalAdapterConfig {
1067 self.config
1068 }
1069
1070 #[must_use]
1072 pub fn active_pointer_id(&self) -> Option<u32> {
1073 self.active.map(|active| active.pointer_id)
1074 }
1075
1076 #[must_use]
1078 pub const fn machine_state(&self) -> PaneDragResizeState {
1079 self.machine.state()
1080 }
1081
1082 pub fn translate(
1087 &mut self,
1088 event: &Event,
1089 target_hint: Option<PaneResizeTarget>,
1090 ) -> PaneTerminalDispatch {
1091 match event {
1092 Event::Mouse(mouse) => self.translate_mouse(*mouse, target_hint),
1093 Event::Key(key) => self.translate_key(*key, target_hint),
1094 Event::Focus(focused) => self.translate_focus(*focused),
1095 Event::Resize { .. } => self.translate_resize(),
1096 _ => PaneTerminalDispatch::ignored(
1097 PaneTerminalLifecyclePhase::Other,
1098 PaneTerminalIgnoredReason::NonSemanticEvent,
1099 None,
1100 target_hint,
1101 ),
1102 }
1103 }
1104
1105 pub fn translate_with_handles(
1111 &mut self,
1112 event: &Event,
1113 handles: &[PaneTerminalSplitterHandle],
1114 ) -> PaneTerminalDispatch {
1115 let active_target = self.active.map(|active| active.target);
1116 let target_hint = match event {
1117 Event::Mouse(mouse) => {
1118 let resolved = pane_terminal_resolve_splitter_target(handles, mouse.x, mouse.y);
1119 match mouse.kind {
1120 MouseEventKind::Down(_)
1121 | MouseEventKind::ScrollUp
1122 | MouseEventKind::ScrollDown
1123 | MouseEventKind::ScrollLeft
1124 | MouseEventKind::ScrollRight => resolved,
1125 MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => {
1126 resolved.or(active_target)
1127 }
1128 }
1129 }
1130 Event::Key(_) => active_target,
1131 _ => None,
1132 };
1133 self.translate(event, target_hint)
1134 }
1135
1136 fn translate_mouse(
1137 &mut self,
1138 mouse: MouseEvent,
1139 target_hint: Option<PaneResizeTarget>,
1140 ) -> PaneTerminalDispatch {
1141 let position = mouse_position(mouse);
1142 let modifiers = pane_modifiers(mouse.modifiers);
1143 match mouse.kind {
1144 MouseEventKind::Down(button) => {
1145 let pane_button = pane_button(button);
1146 if pane_button != self.config.activation_button {
1147 return PaneTerminalDispatch::ignored(
1148 PaneTerminalLifecyclePhase::MouseDown,
1149 PaneTerminalIgnoredReason::ActivationButtonRequired,
1150 Some(pointer_id_for_button(pane_button)),
1151 target_hint,
1152 );
1153 }
1154 let Some(target) = target_hint else {
1155 return PaneTerminalDispatch::ignored(
1156 PaneTerminalLifecyclePhase::MouseDown,
1157 PaneTerminalIgnoredReason::MissingTarget,
1158 Some(pointer_id_for_button(pane_button)),
1159 None,
1160 );
1161 };
1162
1163 let recovery = self.cancel_active_internal(PaneCancelReason::PointerCancel);
1164 let pointer_id = pointer_id_for_button(pane_button);
1165 let kind = PaneSemanticInputEventKind::PointerDown {
1166 target,
1167 pointer_id,
1168 button: pane_button,
1169 position,
1170 };
1171 let mut dispatch = self.forward_semantic(
1172 PaneTerminalLifecyclePhase::MouseDown,
1173 Some(pointer_id),
1174 Some(target),
1175 kind,
1176 modifiers,
1177 );
1178 if dispatch.primary_transition.is_some() {
1179 self.active = Some(PaneTerminalActivePointer {
1180 pointer_id,
1181 target,
1182 button: pane_button,
1183 last_position: position,
1184 });
1185 }
1186 if let Some((cancel_event, cancel_transition)) = recovery {
1187 dispatch.recovery_event = Some(cancel_event);
1188 dispatch.recovery_transition = Some(cancel_transition);
1189 dispatch.log.recovery_cancel_sequence =
1190 dispatch.recovery_event.as_ref().map(|event| event.sequence);
1191 if matches!(
1192 dispatch.log.outcome,
1193 PaneTerminalLogOutcome::SemanticForwarded
1194 ) {
1195 dispatch.log.outcome =
1196 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery;
1197 }
1198 }
1199 dispatch
1200 }
1201 MouseEventKind::Drag(button) => {
1202 let pane_button = pane_button(button);
1203 let Some(mut active) = self.active else {
1204 return PaneTerminalDispatch::ignored(
1205 PaneTerminalLifecyclePhase::MouseDrag,
1206 PaneTerminalIgnoredReason::NoActivePointer,
1207 Some(pointer_id_for_button(pane_button)),
1208 target_hint,
1209 );
1210 };
1211 if active.button != pane_button {
1212 return PaneTerminalDispatch::ignored(
1213 PaneTerminalLifecyclePhase::MouseDrag,
1214 PaneTerminalIgnoredReason::PointerButtonMismatch,
1215 Some(pointer_id_for_button(pane_button)),
1216 Some(active.target),
1217 );
1218 }
1219 let delta_x = position.x.saturating_sub(active.last_position.x);
1220 let delta_y = position.y.saturating_sub(active.last_position.y);
1221 if self.should_coalesce_drag(delta_x, delta_y) {
1222 return PaneTerminalDispatch::ignored(
1223 PaneTerminalLifecyclePhase::MouseDrag,
1224 PaneTerminalIgnoredReason::DragCoalesced,
1225 Some(active.pointer_id),
1226 Some(active.target),
1227 );
1228 }
1229 let kind = PaneSemanticInputEventKind::PointerMove {
1230 target: active.target,
1231 pointer_id: active.pointer_id,
1232 position,
1233 delta_x,
1234 delta_y,
1235 };
1236 let dispatch = self.forward_semantic(
1237 PaneTerminalLifecyclePhase::MouseDrag,
1238 Some(active.pointer_id),
1239 Some(active.target),
1240 kind,
1241 modifiers,
1242 );
1243 if dispatch.primary_transition.is_some() {
1244 active.last_position = position;
1245 self.active = Some(active);
1246 }
1247 dispatch
1248 }
1249 MouseEventKind::Moved => {
1250 let Some(mut active) = self.active else {
1251 return PaneTerminalDispatch::ignored(
1252 PaneTerminalLifecyclePhase::MouseMove,
1253 PaneTerminalIgnoredReason::NoActivePointer,
1254 None,
1255 target_hint,
1256 );
1257 };
1258 let delta_x = position.x.saturating_sub(active.last_position.x);
1259 let delta_y = position.y.saturating_sub(active.last_position.y);
1260 if self.should_coalesce_drag(delta_x, delta_y) {
1261 return PaneTerminalDispatch::ignored(
1262 PaneTerminalLifecyclePhase::MouseMove,
1263 PaneTerminalIgnoredReason::DragCoalesced,
1264 Some(active.pointer_id),
1265 Some(active.target),
1266 );
1267 }
1268 let kind = PaneSemanticInputEventKind::PointerMove {
1269 target: active.target,
1270 pointer_id: active.pointer_id,
1271 position,
1272 delta_x,
1273 delta_y,
1274 };
1275 let dispatch = self.forward_semantic(
1276 PaneTerminalLifecyclePhase::MouseMove,
1277 Some(active.pointer_id),
1278 Some(active.target),
1279 kind,
1280 modifiers,
1281 );
1282 if dispatch.primary_transition.is_some() {
1283 active.last_position = position;
1284 self.active = Some(active);
1285 }
1286 dispatch
1287 }
1288 MouseEventKind::Up(button) => {
1289 let pane_button = pane_button(button);
1290 let Some(active) = self.active else {
1291 return PaneTerminalDispatch::ignored(
1292 PaneTerminalLifecyclePhase::MouseUp,
1293 PaneTerminalIgnoredReason::NoActivePointer,
1294 Some(pointer_id_for_button(pane_button)),
1295 target_hint,
1296 );
1297 };
1298 if active.button != pane_button {
1299 return PaneTerminalDispatch::ignored(
1300 PaneTerminalLifecyclePhase::MouseUp,
1301 PaneTerminalIgnoredReason::PointerButtonMismatch,
1302 Some(pointer_id_for_button(pane_button)),
1303 Some(active.target),
1304 );
1305 }
1306 let kind = PaneSemanticInputEventKind::PointerUp {
1307 target: active.target,
1308 pointer_id: active.pointer_id,
1309 button: active.button,
1310 position,
1311 };
1312 let dispatch = self.forward_semantic(
1313 PaneTerminalLifecyclePhase::MouseUp,
1314 Some(active.pointer_id),
1315 Some(active.target),
1316 kind,
1317 modifiers,
1318 );
1319 if dispatch.primary_transition.is_some() {
1320 self.active = None;
1321 }
1322 dispatch
1323 }
1324 MouseEventKind::ScrollUp
1325 | MouseEventKind::ScrollDown
1326 | MouseEventKind::ScrollLeft
1327 | MouseEventKind::ScrollRight => {
1328 let target = target_hint.or(self.active.map(|active| active.target));
1329 let Some(target) = target else {
1330 return PaneTerminalDispatch::ignored(
1331 PaneTerminalLifecyclePhase::MouseScroll,
1332 PaneTerminalIgnoredReason::MissingTarget,
1333 None,
1334 None,
1335 );
1336 };
1337 let lines = match mouse.kind {
1338 MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => -1,
1339 MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => 1,
1340 _ => unreachable!("handled by outer match"),
1341 };
1342 let kind = PaneSemanticInputEventKind::WheelNudge { target, lines };
1343 self.forward_semantic(
1344 PaneTerminalLifecyclePhase::MouseScroll,
1345 None,
1346 Some(target),
1347 kind,
1348 modifiers,
1349 )
1350 }
1351 }
1352 }
1353
1354 fn translate_key(
1355 &mut self,
1356 key: KeyEvent,
1357 target_hint: Option<PaneResizeTarget>,
1358 ) -> PaneTerminalDispatch {
1359 if key.kind == KeyEventKind::Release {
1360 return PaneTerminalDispatch::ignored(
1361 PaneTerminalLifecyclePhase::Other,
1362 PaneTerminalIgnoredReason::UnsupportedKey,
1363 None,
1364 target_hint,
1365 );
1366 }
1367 if matches!(key.code, KeyCode::Escape) {
1368 return self.cancel_active_dispatch(
1369 PaneTerminalLifecyclePhase::KeyCancel,
1370 PaneCancelReason::EscapeKey,
1371 PaneTerminalIgnoredReason::NoActivePointer,
1372 );
1373 }
1374 let target = target_hint.or(self.active.map(|active| active.target));
1375 let Some(target) = target else {
1376 return PaneTerminalDispatch::ignored(
1377 PaneTerminalLifecyclePhase::KeyResize,
1378 PaneTerminalIgnoredReason::MissingTarget,
1379 None,
1380 None,
1381 );
1382 };
1383 let Some(direction) = keyboard_resize_direction(key.code, target.axis) else {
1384 return PaneTerminalDispatch::ignored(
1385 PaneTerminalLifecyclePhase::KeyResize,
1386 PaneTerminalIgnoredReason::UnsupportedKey,
1387 None,
1388 Some(target),
1389 );
1390 };
1391 let units = keyboard_resize_units(key.modifiers);
1392 let kind = PaneSemanticInputEventKind::KeyboardResize {
1393 target,
1394 direction,
1395 units,
1396 };
1397 self.forward_semantic(
1398 PaneTerminalLifecyclePhase::KeyResize,
1399 self.active_pointer_id(),
1400 Some(target),
1401 kind,
1402 pane_modifiers(key.modifiers),
1403 )
1404 }
1405
1406 fn translate_focus(&mut self, focused: bool) -> PaneTerminalDispatch {
1407 if focused {
1408 return PaneTerminalDispatch::ignored(
1409 PaneTerminalLifecyclePhase::Other,
1410 PaneTerminalIgnoredReason::FocusGainNoop,
1411 self.active_pointer_id(),
1412 self.active.map(|active| active.target),
1413 );
1414 }
1415 if !self.config.cancel_on_focus_lost {
1416 return PaneTerminalDispatch::ignored(
1417 PaneTerminalLifecyclePhase::FocusLoss,
1418 PaneTerminalIgnoredReason::ResizeNoop,
1419 self.active_pointer_id(),
1420 self.active.map(|active| active.target),
1421 );
1422 }
1423 self.cancel_active_dispatch(
1424 PaneTerminalLifecyclePhase::FocusLoss,
1425 PaneCancelReason::FocusLost,
1426 PaneTerminalIgnoredReason::NoActivePointer,
1427 )
1428 }
1429
1430 fn translate_resize(&mut self) -> PaneTerminalDispatch {
1431 if !self.config.cancel_on_resize {
1432 return PaneTerminalDispatch::ignored(
1433 PaneTerminalLifecyclePhase::ResizeInterrupt,
1434 PaneTerminalIgnoredReason::ResizeNoop,
1435 self.active_pointer_id(),
1436 self.active.map(|active| active.target),
1437 );
1438 }
1439 self.cancel_active_dispatch(
1440 PaneTerminalLifecyclePhase::ResizeInterrupt,
1441 PaneCancelReason::Programmatic,
1442 PaneTerminalIgnoredReason::ResizeNoop,
1443 )
1444 }
1445
1446 fn cancel_active_dispatch(
1447 &mut self,
1448 phase: PaneTerminalLifecyclePhase,
1449 reason: PaneCancelReason,
1450 no_active_reason: PaneTerminalIgnoredReason,
1451 ) -> PaneTerminalDispatch {
1452 let Some(active) = self.active else {
1453 return PaneTerminalDispatch::ignored(phase, no_active_reason, None, None);
1454 };
1455 let kind = PaneSemanticInputEventKind::Cancel {
1456 target: Some(active.target),
1457 reason,
1458 };
1459 let dispatch = self.forward_semantic(
1460 phase,
1461 Some(active.pointer_id),
1462 Some(active.target),
1463 kind,
1464 PaneModifierSnapshot::default(),
1465 );
1466 if dispatch.primary_transition.is_some() {
1467 self.active = None;
1468 }
1469 dispatch
1470 }
1471
1472 fn cancel_active_internal(
1473 &mut self,
1474 reason: PaneCancelReason,
1475 ) -> Option<(PaneSemanticInputEvent, PaneDragResizeTransition)> {
1476 let active = self.active?;
1477 let kind = PaneSemanticInputEventKind::Cancel {
1478 target: Some(active.target),
1479 reason,
1480 };
1481 let result = self
1482 .apply_semantic(kind, PaneModifierSnapshot::default())
1483 .ok();
1484 if result.is_some() {
1485 self.active = None;
1486 }
1487 result
1488 }
1489
1490 fn forward_semantic(
1491 &mut self,
1492 phase: PaneTerminalLifecyclePhase,
1493 pointer_id: Option<u32>,
1494 target: Option<PaneResizeTarget>,
1495 kind: PaneSemanticInputEventKind,
1496 modifiers: PaneModifierSnapshot,
1497 ) -> PaneTerminalDispatch {
1498 match self.apply_semantic(kind, modifiers) {
1499 Ok((event, transition)) => {
1500 PaneTerminalDispatch::forwarded(phase, pointer_id, target, event, transition)
1501 }
1502 Err(_) => PaneTerminalDispatch::ignored(
1503 phase,
1504 PaneTerminalIgnoredReason::MachineRejectedEvent,
1505 pointer_id,
1506 target,
1507 ),
1508 }
1509 }
1510
1511 fn apply_semantic(
1512 &mut self,
1513 kind: PaneSemanticInputEventKind,
1514 modifiers: PaneModifierSnapshot,
1515 ) -> Result<(PaneSemanticInputEvent, PaneDragResizeTransition), PaneDragResizeMachineError>
1516 {
1517 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
1518 event.modifiers = modifiers;
1519 let transition = self.machine.apply_event(&event)?;
1520 Ok((event, transition))
1521 }
1522
1523 fn next_sequence(&mut self) -> u64 {
1524 let sequence = self.next_sequence;
1525 self.next_sequence = self.next_sequence.saturating_add(1);
1526 sequence
1527 }
1528
1529 fn should_coalesce_drag(&self, delta_x: i32, delta_y: i32) -> bool {
1530 if !matches!(self.machine.state(), PaneDragResizeState::Dragging { .. }) {
1531 return false;
1532 }
1533 let movement = delta_x
1534 .unsigned_abs()
1535 .saturating_add(delta_y.unsigned_abs());
1536 movement < u32::from(self.config.drag_update_coalesce_distance)
1537 }
1538
1539 pub fn force_cancel_all(&mut self) -> Option<PaneCleanupDiagnostics> {
1548 let was_active = self.active.is_some();
1549 let machine_state_before = self.machine.state();
1550 let machine_transition = self.machine.force_cancel();
1551 let active_pointer = self.active.take();
1552 if !was_active && machine_transition.is_none() {
1553 return None;
1554 }
1555 Some(PaneCleanupDiagnostics {
1556 had_active_pointer: was_active,
1557 active_pointer_id: active_pointer.map(|a| a.pointer_id),
1558 machine_state_before,
1559 machine_transition,
1560 })
1561 }
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Eq)]
1569pub struct PaneCleanupDiagnostics {
1570 pub had_active_pointer: bool,
1572 pub active_pointer_id: Option<u32>,
1574 pub machine_state_before: PaneDragResizeState,
1576 pub machine_transition: Option<PaneDragResizeTransition>,
1579}
1580
1581pub struct PaneInteractionGuard<'a> {
1596 adapter: &'a mut PaneTerminalAdapter,
1597 finished: bool,
1598 diagnostics: Option<PaneCleanupDiagnostics>,
1599}
1600
1601impl<'a> PaneInteractionGuard<'a> {
1602 pub fn new(adapter: &'a mut PaneTerminalAdapter) -> Self {
1604 Self {
1605 adapter,
1606 finished: false,
1607 diagnostics: None,
1608 }
1609 }
1610
1611 pub fn adapter(&mut self) -> &mut PaneTerminalAdapter {
1613 self.adapter
1614 }
1615
1616 pub fn finish(mut self) -> Option<PaneCleanupDiagnostics> {
1621 self.finished = true;
1622 let diagnostics = self.adapter.force_cancel_all();
1623 self.diagnostics = diagnostics.clone();
1624 diagnostics
1625 }
1626}
1627
1628impl Drop for PaneInteractionGuard<'_> {
1629 fn drop(&mut self) {
1630 if !self.finished {
1631 self.diagnostics = self.adapter.force_cancel_all();
1632 }
1633 }
1634}
1635
1636fn pane_button(button: MouseButton) -> PanePointerButton {
1637 match button {
1638 MouseButton::Left => PanePointerButton::Primary,
1639 MouseButton::Right => PanePointerButton::Secondary,
1640 MouseButton::Middle => PanePointerButton::Middle,
1641 }
1642}
1643
1644fn pointer_id_for_button(button: PanePointerButton) -> u32 {
1645 match button {
1646 PanePointerButton::Primary => 1,
1647 PanePointerButton::Secondary => 2,
1648 PanePointerButton::Middle => 3,
1649 }
1650}
1651
1652fn mouse_position(mouse: MouseEvent) -> PanePointerPosition {
1653 PanePointerPosition::new(i32::from(mouse.x), i32::from(mouse.y))
1654}
1655
1656fn pane_modifiers(modifiers: Modifiers) -> PaneModifierSnapshot {
1657 PaneModifierSnapshot {
1658 shift: modifiers.contains(Modifiers::SHIFT),
1659 alt: modifiers.contains(Modifiers::ALT),
1660 ctrl: modifiers.contains(Modifiers::CTRL),
1661 meta: modifiers.contains(Modifiers::SUPER),
1662 }
1663}
1664
1665fn keyboard_resize_direction(code: KeyCode, axis: SplitAxis) -> Option<PaneResizeDirection> {
1666 match (axis, code) {
1667 (SplitAxis::Horizontal, KeyCode::Left) => Some(PaneResizeDirection::Decrease),
1668 (SplitAxis::Horizontal, KeyCode::Right) => Some(PaneResizeDirection::Increase),
1669 (SplitAxis::Vertical, KeyCode::Up) => Some(PaneResizeDirection::Decrease),
1670 (SplitAxis::Vertical, KeyCode::Down) => Some(PaneResizeDirection::Increase),
1671 (_, KeyCode::Char('-')) => Some(PaneResizeDirection::Decrease),
1672 (_, KeyCode::Char('+') | KeyCode::Char('=')) => Some(PaneResizeDirection::Increase),
1673 _ => None,
1674 }
1675}
1676
1677fn keyboard_resize_units(modifiers: Modifiers) -> u16 {
1678 if modifiers.contains(Modifiers::SHIFT) {
1679 5
1680 } else {
1681 1
1682 }
1683}
1684
1685#[derive(Clone)]
1689pub struct PersistenceConfig {
1690 pub registry: Option<std::sync::Arc<StateRegistry>>,
1692 pub checkpoint_interval: Option<Duration>,
1694 pub auto_load: bool,
1696 pub auto_save: bool,
1698}
1699
1700impl Default for PersistenceConfig {
1701 fn default() -> Self {
1702 Self {
1703 registry: None,
1704 checkpoint_interval: None,
1705 auto_load: true,
1706 auto_save: true,
1707 }
1708 }
1709}
1710
1711impl std::fmt::Debug for PersistenceConfig {
1712 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1713 f.debug_struct("PersistenceConfig")
1714 .field(
1715 "registry",
1716 &self.registry.as_ref().map(|r| r.backend_name()),
1717 )
1718 .field("checkpoint_interval", &self.checkpoint_interval)
1719 .field("auto_load", &self.auto_load)
1720 .field("auto_save", &self.auto_save)
1721 .finish()
1722 }
1723}
1724
1725impl PersistenceConfig {
1726 #[must_use]
1728 pub fn disabled() -> Self {
1729 Self::default()
1730 }
1731
1732 #[must_use]
1734 pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
1735 Self {
1736 registry: Some(registry),
1737 ..Default::default()
1738 }
1739 }
1740
1741 #[must_use]
1743 pub fn checkpoint_every(mut self, interval: Duration) -> Self {
1744 self.checkpoint_interval = Some(interval);
1745 self
1746 }
1747
1748 #[must_use]
1750 pub fn auto_load(mut self, enabled: bool) -> Self {
1751 self.auto_load = enabled;
1752 self
1753 }
1754
1755 #[must_use]
1757 pub fn auto_save(mut self, enabled: bool) -> Self {
1758 self.auto_save = enabled;
1759 self
1760 }
1761}
1762
1763#[derive(Debug, Clone)]
1775pub struct WidgetRefreshConfig {
1776 pub enabled: bool,
1778 pub staleness_window_ms: u64,
1780 pub starve_ms: u64,
1782 pub max_starved_per_frame: usize,
1784 pub max_drop_fraction: f32,
1787 pub weight_priority: f32,
1789 pub weight_staleness: f32,
1791 pub weight_focus: f32,
1793 pub weight_interaction: f32,
1795 pub starve_boost: f32,
1797 pub min_cost_us: f32,
1799}
1800
1801impl Default for WidgetRefreshConfig {
1802 fn default() -> Self {
1803 Self {
1804 enabled: true,
1805 staleness_window_ms: 1_000,
1806 starve_ms: 3_000,
1807 max_starved_per_frame: 2,
1808 max_drop_fraction: 1.0,
1809 weight_priority: 1.0,
1810 weight_staleness: 0.5,
1811 weight_focus: 0.75,
1812 weight_interaction: 0.5,
1813 starve_boost: 1.5,
1814 min_cost_us: 1.0,
1815 }
1816 }
1817}
1818
1819#[derive(Debug, Clone)]
1821pub struct EffectQueueConfig {
1822 pub enabled: bool,
1824 pub scheduler: SchedulerConfig,
1826}
1827
1828impl Default for EffectQueueConfig {
1829 fn default() -> Self {
1830 let scheduler = SchedulerConfig {
1831 smith_enabled: true,
1832 force_fifo: false,
1833 preemptive: false,
1834 aging_factor: 0.0,
1835 wait_starve_ms: 0.0,
1836 enable_logging: false,
1837 ..Default::default()
1838 };
1839 Self {
1840 enabled: false,
1841 scheduler,
1842 }
1843 }
1844}
1845
1846impl EffectQueueConfig {
1847 #[must_use]
1849 pub fn with_enabled(mut self, enabled: bool) -> Self {
1850 self.enabled = enabled;
1851 self
1852 }
1853
1854 #[must_use]
1856 pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
1857 self.scheduler = scheduler;
1858 self
1859 }
1860}
1861
1862#[derive(Debug, Clone)]
1864pub struct ProgramConfig {
1865 pub screen_mode: ScreenMode,
1867 pub ui_anchor: UiAnchor,
1869 pub budget: FrameBudgetConfig,
1871 pub diff_config: RuntimeDiffConfig,
1873 pub evidence_sink: EvidenceSinkConfig,
1875 pub render_trace: RenderTraceConfig,
1877 pub frame_timing: Option<FrameTimingConfig>,
1879 pub conformal_config: Option<ConformalConfig>,
1881 pub locale_context: LocaleContext,
1883 pub poll_timeout: Duration,
1885 pub resize_coalescer: CoalescerConfig,
1887 pub resize_behavior: ResizeBehavior,
1889 pub forced_size: Option<(u16, u16)>,
1891 pub mouse_capture_policy: MouseCapturePolicy,
1895 pub bracketed_paste: bool,
1897 pub focus_reporting: bool,
1899 pub kitty_keyboard: bool,
1901 pub persistence: PersistenceConfig,
1903 pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
1905 pub widget_refresh: WidgetRefreshConfig,
1907 pub effect_queue: EffectQueueConfig,
1909}
1910
1911impl Default for ProgramConfig {
1912 fn default() -> Self {
1913 Self {
1914 screen_mode: ScreenMode::Inline { ui_height: 4 },
1915 ui_anchor: UiAnchor::Bottom,
1916 budget: FrameBudgetConfig::default(),
1917 diff_config: RuntimeDiffConfig::default(),
1918 evidence_sink: EvidenceSinkConfig::default(),
1919 render_trace: RenderTraceConfig::default(),
1920 frame_timing: None,
1921 conformal_config: None,
1922 locale_context: LocaleContext::global(),
1923 poll_timeout: Duration::from_millis(100),
1924 resize_coalescer: CoalescerConfig::default(),
1925 resize_behavior: ResizeBehavior::Throttled,
1926 forced_size: None,
1927 mouse_capture_policy: MouseCapturePolicy::Auto,
1928 bracketed_paste: true,
1929 focus_reporting: false,
1930 kitty_keyboard: false,
1931 persistence: PersistenceConfig::default(),
1932 inline_auto_remeasure: None,
1933 widget_refresh: WidgetRefreshConfig::default(),
1934 effect_queue: EffectQueueConfig::default(),
1935 }
1936 }
1937}
1938
1939impl ProgramConfig {
1940 pub fn fullscreen() -> Self {
1942 Self {
1943 screen_mode: ScreenMode::AltScreen,
1944 ..Default::default()
1945 }
1946 }
1947
1948 pub fn inline(height: u16) -> Self {
1950 Self {
1951 screen_mode: ScreenMode::Inline { ui_height: height },
1952 ..Default::default()
1953 }
1954 }
1955
1956 pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
1958 Self {
1959 screen_mode: ScreenMode::InlineAuto {
1960 min_height,
1961 max_height,
1962 },
1963 inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
1964 ..Default::default()
1965 }
1966 }
1967
1968 #[must_use]
1970 pub fn with_mouse(mut self) -> Self {
1971 self.mouse_capture_policy = MouseCapturePolicy::On;
1972 self
1973 }
1974
1975 #[must_use]
1977 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
1978 self.mouse_capture_policy = policy;
1979 self
1980 }
1981
1982 #[must_use]
1984 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
1985 self.mouse_capture_policy = if enabled {
1986 MouseCapturePolicy::On
1987 } else {
1988 MouseCapturePolicy::Off
1989 };
1990 self
1991 }
1992
1993 #[must_use]
1995 pub const fn resolved_mouse_capture(&self) -> bool {
1996 self.mouse_capture_policy.resolve(self.screen_mode)
1997 }
1998
1999 #[must_use]
2001 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2002 self.budget = budget;
2003 self
2004 }
2005
2006 #[must_use]
2008 pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
2009 self.diff_config = diff_config;
2010 self
2011 }
2012
2013 #[must_use]
2015 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2016 self.evidence_sink = config;
2017 self
2018 }
2019
2020 #[must_use]
2022 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2023 self.render_trace = config;
2024 self
2025 }
2026
2027 #[must_use]
2029 pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
2030 self.frame_timing = Some(config);
2031 self
2032 }
2033
2034 #[must_use]
2036 pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
2037 self.conformal_config = Some(config);
2038 self
2039 }
2040
2041 #[must_use]
2043 pub fn without_conformal(mut self) -> Self {
2044 self.conformal_config = None;
2045 self
2046 }
2047
2048 #[must_use]
2050 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2051 self.locale_context = locale_context;
2052 self
2053 }
2054
2055 #[must_use]
2057 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2058 self.locale_context = LocaleContext::new(locale);
2059 self
2060 }
2061
2062 #[must_use]
2064 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2065 self.widget_refresh = config;
2066 self
2067 }
2068
2069 #[must_use]
2071 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2072 self.effect_queue = config;
2073 self
2074 }
2075
2076 #[must_use]
2078 pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2079 self.resize_coalescer = config;
2080 self
2081 }
2082
2083 #[must_use]
2085 pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2086 self.resize_behavior = behavior;
2087 self
2088 }
2089
2090 #[must_use]
2092 pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
2093 let width = width.max(1);
2094 let height = height.max(1);
2095 self.forced_size = Some((width, height));
2096 self
2097 }
2098
2099 #[must_use]
2101 pub fn without_forced_size(mut self) -> Self {
2102 self.forced_size = None;
2103 self
2104 }
2105
2106 #[must_use]
2108 pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
2109 if enabled {
2110 self.resize_behavior = ResizeBehavior::Immediate;
2111 }
2112 self
2113 }
2114
2115 #[must_use]
2117 pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
2118 self.persistence = persistence;
2119 self
2120 }
2121
2122 #[must_use]
2124 pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
2125 self.persistence = PersistenceConfig::with_registry(registry);
2126 self
2127 }
2128
2129 #[must_use]
2131 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2132 self.inline_auto_remeasure = Some(config);
2133 self
2134 }
2135
2136 #[must_use]
2138 pub fn without_inline_auto_remeasure(mut self) -> Self {
2139 self.inline_auto_remeasure = None;
2140 self
2141 }
2142}
2143
2144enum EffectCommand<M> {
2145 Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
2146 Shutdown,
2147}
2148
2149struct EffectQueue<M: Send + 'static> {
2150 sender: mpsc::Sender<EffectCommand<M>>,
2151 handle: Option<JoinHandle<()>>,
2152}
2153
2154impl<M: Send + 'static> EffectQueue<M> {
2155 fn start(
2156 config: EffectQueueConfig,
2157 result_sender: mpsc::Sender<M>,
2158 evidence_sink: Option<EvidenceSink>,
2159 ) -> io::Result<Self> {
2160 let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
2161 let handle = thread::Builder::new()
2162 .name("ftui-effects".into())
2163 .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))?;
2164
2165 Ok(Self {
2166 sender: tx,
2167 handle: Some(handle),
2168 })
2169 }
2170
2171 fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
2172 let _ = self.sender.send(EffectCommand::Enqueue(spec, task));
2173 }
2174
2175 fn shutdown(&mut self) {
2176 let _ = self.sender.send(EffectCommand::Shutdown);
2177 if let Some(handle) = self.handle.take() {
2178 let _ = handle.join();
2179 }
2180 }
2181}
2182
2183impl<M: Send + 'static> Drop for EffectQueue<M> {
2184 fn drop(&mut self) {
2185 self.shutdown();
2186 }
2187}
2188
2189fn effect_queue_loop<M: Send + 'static>(
2190 config: EffectQueueConfig,
2191 rx: mpsc::Receiver<EffectCommand<M>>,
2192 result_sender: mpsc::Sender<M>,
2193 evidence_sink: Option<EvidenceSink>,
2194) {
2195 let mut scheduler = QueueingScheduler::new(config.scheduler);
2196 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
2197
2198 loop {
2199 if tasks.is_empty() {
2200 match rx.recv() {
2201 Ok(cmd) => {
2202 if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
2203 return;
2204 }
2205 }
2206 Err(_) => return,
2207 }
2208 }
2209
2210 while let Ok(cmd) = rx.try_recv() {
2211 if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
2212 return;
2213 }
2214 }
2215
2216 if tasks.is_empty() {
2217 continue;
2218 }
2219
2220 let Some(job) = scheduler.peek_next().cloned() else {
2221 continue;
2222 };
2223
2224 if let Some(ref sink) = evidence_sink {
2225 let evidence = scheduler.evidence();
2226 let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
2227 }
2228
2229 let completed = scheduler.tick(job.remaining_time);
2230 for job_id in completed {
2231 if let Some(task) = tasks.remove(&job_id) {
2232 let msg = task();
2233 let _ = result_sender.send(msg);
2234 }
2235 }
2236 }
2237}
2238
2239fn handle_effect_command<M: Send + 'static>(
2240 cmd: EffectCommand<M>,
2241 scheduler: &mut QueueingScheduler,
2242 tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
2243 result_sender: &mpsc::Sender<M>,
2244) -> bool {
2245 match cmd {
2246 EffectCommand::Enqueue(spec, task) => {
2247 let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
2248 WeightSource::Default
2249 } else {
2250 WeightSource::Explicit
2251 };
2252 let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
2253 EstimateSource::Default
2254 } else {
2255 EstimateSource::Explicit
2256 };
2257 let id = scheduler.submit_with_sources(
2258 spec.weight,
2259 spec.estimate_ms,
2260 weight_source,
2261 estimate_source,
2262 spec.name,
2263 );
2264 if let Some(id) = id {
2265 tasks.insert(id, task);
2266 } else {
2267 let msg = task();
2268 let _ = result_sender.send(msg);
2269 }
2270 false
2271 }
2272 EffectCommand::Shutdown => true,
2273 }
2274}
2275
2276#[derive(Debug, Clone)]
2284pub struct InlineAutoRemeasureConfig {
2285 pub voi: VoiConfig,
2287 pub change_threshold_rows: u16,
2289}
2290
2291impl Default for InlineAutoRemeasureConfig {
2292 fn default() -> Self {
2293 Self {
2294 voi: VoiConfig {
2295 prior_alpha: 1.0,
2297 prior_beta: 9.0,
2298 max_interval_ms: 1000,
2300 min_interval_ms: 100,
2302 max_interval_events: 0,
2304 min_interval_events: 0,
2305 sample_cost: 0.08,
2307 ..VoiConfig::default()
2308 },
2309 change_threshold_rows: 1,
2310 }
2311 }
2312}
2313
2314#[derive(Debug)]
2315struct InlineAutoRemeasureState {
2316 config: InlineAutoRemeasureConfig,
2317 sampler: VoiSampler,
2318}
2319
2320impl InlineAutoRemeasureState {
2321 fn new(config: InlineAutoRemeasureConfig) -> Self {
2322 let sampler = VoiSampler::new(config.voi.clone());
2323 Self { config, sampler }
2324 }
2325
2326 fn reset(&mut self) {
2327 self.sampler = VoiSampler::new(self.config.voi.clone());
2328 }
2329}
2330
2331#[derive(Debug, Clone)]
2332struct ConformalEvidence {
2333 bucket_key: String,
2334 n_b: usize,
2335 alpha: f64,
2336 q_b: f64,
2337 y_hat: f64,
2338 upper_us: f64,
2339 risk: bool,
2340 fallback_level: u8,
2341 window_size: usize,
2342 reset_count: u64,
2343}
2344
2345impl ConformalEvidence {
2346 fn from_prediction(prediction: &ConformalPrediction) -> Self {
2347 let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
2348 Self {
2349 bucket_key: prediction.bucket.to_string(),
2350 n_b: prediction.sample_count,
2351 alpha,
2352 q_b: prediction.quantile,
2353 y_hat: prediction.y_hat,
2354 upper_us: prediction.upper_us,
2355 risk: prediction.risk,
2356 fallback_level: prediction.fallback_level,
2357 window_size: prediction.window_size,
2358 reset_count: prediction.reset_count,
2359 }
2360 }
2361}
2362
2363#[derive(Debug, Clone)]
2364struct BudgetDecisionEvidence {
2365 frame_idx: u64,
2366 decision: BudgetDecision,
2367 controller_decision: BudgetDecision,
2368 degradation_before: DegradationLevel,
2369 degradation_after: DegradationLevel,
2370 frame_time_us: f64,
2371 budget_us: f64,
2372 pid_output: f64,
2373 pid_p: f64,
2374 pid_i: f64,
2375 pid_d: f64,
2376 e_value: f64,
2377 frames_observed: u32,
2378 frames_since_change: u32,
2379 in_warmup: bool,
2380 conformal: Option<ConformalEvidence>,
2381}
2382
2383impl BudgetDecisionEvidence {
2384 fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
2385 if after > before {
2386 BudgetDecision::Degrade
2387 } else if after < before {
2388 BudgetDecision::Upgrade
2389 } else {
2390 BudgetDecision::Hold
2391 }
2392 }
2393
2394 #[must_use]
2395 fn to_jsonl(&self) -> String {
2396 let conformal = self.conformal.as_ref();
2397 let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
2398 let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
2399 let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
2400 let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
2401 let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
2402 let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
2403 let risk = Self::opt_bool(conformal.map(|c| c.risk));
2404 let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
2405 let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
2406 let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
2407
2408 format!(
2409 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":{}}}"#,
2410 self.frame_idx,
2411 self.decision.as_str(),
2412 self.controller_decision.as_str(),
2413 self.degradation_before.as_str(),
2414 self.degradation_after.as_str(),
2415 self.frame_time_us,
2416 self.budget_us,
2417 self.pid_output,
2418 self.pid_p,
2419 self.pid_i,
2420 self.pid_d,
2421 self.e_value,
2422 self.frames_observed,
2423 self.frames_since_change,
2424 self.in_warmup,
2425 bucket_key,
2426 n_b,
2427 alpha,
2428 q_b,
2429 y_hat,
2430 upper_us,
2431 risk,
2432 fallback_level,
2433 window_size,
2434 reset_count
2435 )
2436 }
2437
2438 fn opt_f64(value: Option<f64>) -> String {
2439 value
2440 .map(|v| format!("{v:.6}"))
2441 .unwrap_or_else(|| "null".to_string())
2442 }
2443
2444 fn opt_u64(value: Option<u64>) -> String {
2445 value
2446 .map(|v| v.to_string())
2447 .unwrap_or_else(|| "null".to_string())
2448 }
2449
2450 fn opt_u8(value: Option<u8>) -> String {
2451 value
2452 .map(|v| v.to_string())
2453 .unwrap_or_else(|| "null".to_string())
2454 }
2455
2456 fn opt_usize(value: Option<usize>) -> String {
2457 value
2458 .map(|v| v.to_string())
2459 .unwrap_or_else(|| "null".to_string())
2460 }
2461
2462 fn opt_bool(value: Option<bool>) -> String {
2463 value
2464 .map(|v| v.to_string())
2465 .unwrap_or_else(|| "null".to_string())
2466 }
2467
2468 fn opt_str(value: Option<&str>) -> String {
2469 value
2470 .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
2471 .unwrap_or_else(|| "null".to_string())
2472 }
2473}
2474
2475#[derive(Debug, Clone)]
2476struct FairnessConfigEvidence {
2477 enabled: bool,
2478 input_priority_threshold_ms: u64,
2479 dominance_threshold: u32,
2480 fairness_threshold: f64,
2481}
2482
2483impl FairnessConfigEvidence {
2484 #[must_use]
2485 fn to_jsonl(&self) -> String {
2486 format!(
2487 r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
2488 self.enabled,
2489 self.input_priority_threshold_ms,
2490 self.dominance_threshold,
2491 self.fairness_threshold
2492 )
2493 }
2494}
2495
2496#[derive(Debug, Clone)]
2497struct FairnessDecisionEvidence {
2498 frame_idx: u64,
2499 decision: &'static str,
2500 reason: &'static str,
2501 pending_input_latency_ms: Option<u64>,
2502 jain_index: f64,
2503 resize_dominance_count: u32,
2504 dominance_threshold: u32,
2505 fairness_threshold: f64,
2506 input_priority_threshold_ms: u64,
2507}
2508
2509impl FairnessDecisionEvidence {
2510 #[must_use]
2511 fn to_jsonl(&self) -> String {
2512 let pending_latency = self
2513 .pending_input_latency_ms
2514 .map(|v| v.to_string())
2515 .unwrap_or_else(|| "null".to_string());
2516 format!(
2517 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":{}}}"#,
2518 self.frame_idx,
2519 self.decision,
2520 self.reason,
2521 pending_latency,
2522 self.jain_index,
2523 self.resize_dominance_count,
2524 self.dominance_threshold,
2525 self.fairness_threshold,
2526 self.input_priority_threshold_ms
2527 )
2528 }
2529}
2530
2531#[derive(Debug, Clone)]
2532struct WidgetRefreshEntry {
2533 widget_id: u64,
2534 essential: bool,
2535 starved: bool,
2536 value: f32,
2537 cost_us: f32,
2538 score: f32,
2539 staleness_ms: u64,
2540}
2541
2542impl WidgetRefreshEntry {
2543 fn to_json(&self) -> String {
2544 format!(
2545 r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
2546 self.widget_id,
2547 self.cost_us,
2548 self.value,
2549 self.score,
2550 self.essential,
2551 self.starved,
2552 self.staleness_ms
2553 )
2554 }
2555}
2556
2557#[derive(Debug, Clone)]
2558struct WidgetRefreshPlan {
2559 frame_idx: u64,
2560 budget_us: f64,
2561 degradation: DegradationLevel,
2562 essentials_cost_us: f64,
2563 selected_cost_us: f64,
2564 selected_value: f64,
2565 signal_count: usize,
2566 selected: Vec<WidgetRefreshEntry>,
2567 skipped_count: usize,
2568 skipped_starved: usize,
2569 starved_selected: usize,
2570 over_budget: bool,
2571}
2572
2573impl WidgetRefreshPlan {
2574 fn new() -> Self {
2575 Self {
2576 frame_idx: 0,
2577 budget_us: 0.0,
2578 degradation: DegradationLevel::Full,
2579 essentials_cost_us: 0.0,
2580 selected_cost_us: 0.0,
2581 selected_value: 0.0,
2582 signal_count: 0,
2583 selected: Vec::new(),
2584 skipped_count: 0,
2585 skipped_starved: 0,
2586 starved_selected: 0,
2587 over_budget: false,
2588 }
2589 }
2590
2591 fn clear(&mut self) {
2592 self.frame_idx = 0;
2593 self.budget_us = 0.0;
2594 self.degradation = DegradationLevel::Full;
2595 self.essentials_cost_us = 0.0;
2596 self.selected_cost_us = 0.0;
2597 self.selected_value = 0.0;
2598 self.signal_count = 0;
2599 self.selected.clear();
2600 self.skipped_count = 0;
2601 self.skipped_starved = 0;
2602 self.starved_selected = 0;
2603 self.over_budget = false;
2604 }
2605
2606 fn as_budget(&self) -> WidgetBudget {
2607 if self.signal_count == 0 {
2608 return WidgetBudget::allow_all();
2609 }
2610 let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
2611 WidgetBudget::allow_only(ids)
2612 }
2613
2614 fn recompute(
2615 &mut self,
2616 frame_idx: u64,
2617 budget_us: f64,
2618 degradation: DegradationLevel,
2619 signals: &[WidgetSignal],
2620 config: &WidgetRefreshConfig,
2621 ) {
2622 self.clear();
2623 self.frame_idx = frame_idx;
2624 self.budget_us = budget_us;
2625 self.degradation = degradation;
2626
2627 if !config.enabled || signals.is_empty() {
2628 return;
2629 }
2630
2631 self.signal_count = signals.len();
2632 let mut essentials_cost = 0.0f64;
2633 let mut selected_cost = 0.0f64;
2634 let mut selected_value = 0.0f64;
2635
2636 let staleness_window = config.staleness_window_ms.max(1) as f32;
2637 let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
2638
2639 for signal in signals {
2640 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
2641 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
2642 let mut value = config.weight_priority * signal.priority
2643 + config.weight_staleness * staleness_score
2644 + config.weight_focus * signal.focus_boost
2645 + config.weight_interaction * signal.interaction_boost;
2646 if starved {
2647 value += config.starve_boost;
2648 }
2649 let raw_cost = if signal.recent_cost_us > 0.0 {
2650 signal.recent_cost_us
2651 } else {
2652 signal.cost_estimate_us
2653 };
2654 let cost_us = raw_cost.max(config.min_cost_us);
2655 let score = if cost_us > 0.0 {
2656 value / cost_us
2657 } else {
2658 value
2659 };
2660
2661 let entry = WidgetRefreshEntry {
2662 widget_id: signal.widget_id,
2663 essential: signal.essential,
2664 starved,
2665 value,
2666 cost_us,
2667 score,
2668 staleness_ms: signal.staleness_ms,
2669 };
2670
2671 if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
2672 self.skipped_count += 1;
2673 if starved {
2674 self.skipped_starved = self.skipped_starved.saturating_add(1);
2675 }
2676 continue;
2677 }
2678
2679 if signal.essential {
2680 essentials_cost += cost_us as f64;
2681 selected_cost += cost_us as f64;
2682 selected_value += value as f64;
2683 if starved {
2684 self.starved_selected = self.starved_selected.saturating_add(1);
2685 }
2686 self.selected.push(entry);
2687 } else {
2688 candidates.push(entry);
2689 }
2690 }
2691
2692 let mut remaining = budget_us - selected_cost;
2693
2694 if degradation < DegradationLevel::EssentialOnly {
2695 let nonessential_total = candidates.len();
2696 let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
2697 let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
2698 let min_nonessential_selected = if enforce_drop_rate {
2699 let min_fraction = (1.0 - max_drop_fraction).max(0.0);
2700 ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
2701 } else {
2702 0
2703 };
2704
2705 candidates.sort_by(|a, b| {
2706 b.starved
2707 .cmp(&a.starved)
2708 .then_with(|| b.score.total_cmp(&a.score))
2709 .then_with(|| b.value.total_cmp(&a.value))
2710 .then_with(|| a.cost_us.total_cmp(&b.cost_us))
2711 .then_with(|| a.widget_id.cmp(&b.widget_id))
2712 });
2713
2714 let mut forced_starved = 0usize;
2715 let mut nonessential_selected = 0usize;
2716 let mut skipped_candidates = if enforce_drop_rate {
2717 Vec::with_capacity(candidates.len())
2718 } else {
2719 Vec::new()
2720 };
2721
2722 for entry in candidates.into_iter() {
2723 if entry.starved && forced_starved >= config.max_starved_per_frame {
2724 self.skipped_count += 1;
2725 self.skipped_starved = self.skipped_starved.saturating_add(1);
2726 if enforce_drop_rate {
2727 skipped_candidates.push(entry);
2728 }
2729 continue;
2730 }
2731
2732 if remaining >= entry.cost_us as f64 {
2733 remaining -= entry.cost_us as f64;
2734 selected_cost += entry.cost_us as f64;
2735 selected_value += entry.value as f64;
2736 if entry.starved {
2737 self.starved_selected = self.starved_selected.saturating_add(1);
2738 forced_starved += 1;
2739 }
2740 nonessential_selected += 1;
2741 self.selected.push(entry);
2742 } else if entry.starved
2743 && forced_starved < config.max_starved_per_frame
2744 && nonessential_selected == 0
2745 {
2746 selected_cost += entry.cost_us as f64;
2748 selected_value += entry.value as f64;
2749 self.starved_selected = self.starved_selected.saturating_add(1);
2750 forced_starved += 1;
2751 nonessential_selected += 1;
2752 self.selected.push(entry);
2753 } else {
2754 self.skipped_count += 1;
2755 if entry.starved {
2756 self.skipped_starved = self.skipped_starved.saturating_add(1);
2757 }
2758 if enforce_drop_rate {
2759 skipped_candidates.push(entry);
2760 }
2761 }
2762 }
2763
2764 if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
2765 for entry in skipped_candidates.into_iter() {
2766 if nonessential_selected >= min_nonessential_selected {
2767 break;
2768 }
2769 if entry.starved && forced_starved >= config.max_starved_per_frame {
2770 continue;
2771 }
2772 selected_cost += entry.cost_us as f64;
2773 selected_value += entry.value as f64;
2774 if entry.starved {
2775 self.starved_selected = self.starved_selected.saturating_add(1);
2776 forced_starved += 1;
2777 self.skipped_starved = self.skipped_starved.saturating_sub(1);
2778 }
2779 self.skipped_count = self.skipped_count.saturating_sub(1);
2780 nonessential_selected += 1;
2781 self.selected.push(entry);
2782 }
2783 }
2784 }
2785
2786 self.essentials_cost_us = essentials_cost;
2787 self.selected_cost_us = selected_cost;
2788 self.selected_value = selected_value;
2789 self.over_budget = selected_cost > budget_us;
2790 }
2791
2792 #[must_use]
2793 fn to_jsonl(&self) -> String {
2794 let mut out = String::with_capacity(256 + self.selected.len() * 96);
2795 out.push_str(r#"{"event":"widget_refresh""#);
2796 out.push_str(&format!(
2797 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":{}"#,
2798 self.frame_idx,
2799 self.budget_us,
2800 self.degradation.as_str(),
2801 self.essentials_cost_us,
2802 self.selected_cost_us,
2803 self.selected_value,
2804 self.selected.len(),
2805 self.skipped_count,
2806 self.starved_selected,
2807 self.skipped_starved,
2808 self.over_budget
2809 ));
2810 out.push_str(r#","selected":["#);
2811 for (i, entry) in self.selected.iter().enumerate() {
2812 if i > 0 {
2813 out.push(',');
2814 }
2815 out.push_str(&entry.to_json());
2816 }
2817 out.push_str("]}");
2818 out
2819 }
2820}
2821
2822#[cfg(feature = "crossterm-compat")]
2827pub struct CrosstermEventSource {
2833 session: TerminalSession,
2834 features: BackendFeatures,
2835}
2836
2837#[cfg(feature = "crossterm-compat")]
2838impl CrosstermEventSource {
2839 pub fn new(session: TerminalSession, initial_features: BackendFeatures) -> Self {
2841 Self {
2842 session,
2843 features: initial_features,
2844 }
2845 }
2846}
2847
2848#[cfg(feature = "crossterm-compat")]
2849impl BackendEventSource for CrosstermEventSource {
2850 type Error = io::Error;
2851
2852 fn size(&self) -> Result<(u16, u16), io::Error> {
2853 self.session.size()
2854 }
2855
2856 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
2857 if features.mouse_capture != self.features.mouse_capture {
2858 self.session.set_mouse_capture(features.mouse_capture)?;
2859 }
2860 self.features = features;
2864 Ok(())
2865 }
2866
2867 fn poll_event(&mut self, timeout: Duration) -> Result<bool, io::Error> {
2868 self.session.poll_event(timeout)
2869 }
2870
2871 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
2872 self.session.read_event()
2873 }
2874}
2875
2876pub struct HeadlessEventSource {
2886 width: u16,
2887 height: u16,
2888 features: BackendFeatures,
2889}
2890
2891impl HeadlessEventSource {
2892 pub fn new(width: u16, height: u16, features: BackendFeatures) -> Self {
2894 Self {
2895 width,
2896 height,
2897 features,
2898 }
2899 }
2900}
2901
2902impl BackendEventSource for HeadlessEventSource {
2903 type Error = io::Error;
2904
2905 fn size(&self) -> Result<(u16, u16), io::Error> {
2906 Ok((self.width, self.height))
2907 }
2908
2909 fn set_features(&mut self, features: BackendFeatures) -> Result<(), io::Error> {
2910 self.features = features;
2911 Ok(())
2912 }
2913
2914 fn poll_event(&mut self, _timeout: Duration) -> Result<bool, io::Error> {
2915 Ok(false)
2916 }
2917
2918 fn read_event(&mut self) -> Result<Option<Event>, io::Error> {
2919 Ok(None)
2920 }
2921}
2922
2923pub struct Program<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send = Stdout> {
2929 model: M,
2931 writer: TerminalWriter<W>,
2933 events: E,
2935 backend_features: BackendFeatures,
2937 running: bool,
2939 tick_rate: Option<Duration>,
2941 last_tick: Instant,
2943 dirty: bool,
2945 frame_idx: u64,
2947 widget_signals: Vec<WidgetSignal>,
2949 widget_refresh_config: WidgetRefreshConfig,
2951 widget_refresh_plan: WidgetRefreshPlan,
2953 width: u16,
2955 height: u16,
2957 forced_size: Option<(u16, u16)>,
2959 poll_timeout: Duration,
2961 budget: RenderBudget,
2963 conformal_predictor: Option<ConformalPredictor>,
2965 last_frame_time_us: Option<f64>,
2967 last_update_us: Option<u64>,
2969 frame_timing: Option<FrameTimingConfig>,
2971 locale_context: LocaleContext,
2973 locale_version: u64,
2975 resize_coalescer: ResizeCoalescer,
2977 evidence_sink: Option<EvidenceSink>,
2979 fairness_config_logged: bool,
2981 resize_behavior: ResizeBehavior,
2983 fairness_guard: InputFairnessGuard,
2985 event_recorder: Option<EventRecorder>,
2987 subscriptions: SubscriptionManager<M::Message>,
2989 task_sender: std::sync::mpsc::Sender<M::Message>,
2991 task_receiver: std::sync::mpsc::Receiver<M::Message>,
2993 task_handles: Vec<std::thread::JoinHandle<()>>,
2995 effect_queue: Option<EffectQueue<M::Message>>,
2997 state_registry: Option<std::sync::Arc<StateRegistry>>,
2999 persistence_config: PersistenceConfig,
3001 last_checkpoint: Instant,
3003 inline_auto_remeasure: Option<InlineAutoRemeasureState>,
3005 frame_arena: FrameArena,
3007}
3008
3009#[cfg(feature = "crossterm-compat")]
3010impl<M: Model> Program<M, CrosstermEventSource, Stdout> {
3011 pub fn new(model: M) -> io::Result<Self>
3013 where
3014 M::Message: Send + 'static,
3015 {
3016 Self::with_config(model, ProgramConfig::default())
3017 }
3018
3019 pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
3021 where
3022 M::Message: Send + 'static,
3023 {
3024 let capabilities = TerminalCapabilities::with_overrides();
3025 let mouse_capture = config.resolved_mouse_capture();
3026 let initial_features = BackendFeatures {
3027 mouse_capture,
3028 bracketed_paste: config.bracketed_paste,
3029 focus_events: config.focus_reporting,
3030 kitty_keyboard: config.kitty_keyboard,
3031 };
3032 let session = TerminalSession::new(SessionOptions {
3033 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
3034 mouse_capture: initial_features.mouse_capture,
3035 bracketed_paste: initial_features.bracketed_paste,
3036 focus_events: initial_features.focus_events,
3037 kitty_keyboard: initial_features.kitty_keyboard,
3038 })?;
3039 let events = CrosstermEventSource::new(session, initial_features);
3040
3041 let mut writer = TerminalWriter::with_diff_config(
3042 io::stdout(),
3043 config.screen_mode,
3044 config.ui_anchor,
3045 capabilities,
3046 config.diff_config.clone(),
3047 );
3048
3049 let frame_timing = config.frame_timing.clone();
3050 writer.set_timing_enabled(frame_timing.is_some());
3051
3052 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
3053 if let Some(ref sink) = evidence_sink {
3054 writer = writer.with_evidence_sink(sink.clone());
3055 }
3056
3057 let render_trace = crate::RenderTraceRecorder::from_config(
3058 &config.render_trace,
3059 crate::RenderTraceContext {
3060 capabilities: writer.capabilities(),
3061 diff_config: config.diff_config.clone(),
3062 resize_config: config.resize_coalescer.clone(),
3063 conformal_config: config.conformal_config.clone(),
3064 },
3065 )?;
3066 if let Some(recorder) = render_trace {
3067 writer = writer.with_render_trace(recorder);
3068 }
3069
3070 let (w, h) = config
3072 .forced_size
3073 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
3074 let width = w.max(1);
3075 let height = h.max(1);
3076 writer.set_size(width, height);
3077
3078 let budget = RenderBudget::from_config(&config.budget);
3079 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
3080 let locale_context = config.locale_context.clone();
3081 let locale_version = locale_context.version();
3082 let mut resize_coalescer =
3083 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
3084 .with_screen_mode(config.screen_mode);
3085 if let Some(ref sink) = evidence_sink {
3086 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
3087 }
3088 let subscriptions = SubscriptionManager::new();
3089 let (task_sender, task_receiver) = std::sync::mpsc::channel();
3090 let inline_auto_remeasure = config
3091 .inline_auto_remeasure
3092 .clone()
3093 .map(InlineAutoRemeasureState::new);
3094 let effect_queue = if config.effect_queue.enabled {
3095 Some(EffectQueue::start(
3096 config.effect_queue.clone(),
3097 task_sender.clone(),
3098 evidence_sink.clone(),
3099 )?)
3100 } else {
3101 None
3102 };
3103
3104 Ok(Self {
3105 model,
3106 writer,
3107 events,
3108 backend_features: initial_features,
3109 running: true,
3110 tick_rate: None,
3111 last_tick: Instant::now(),
3112 dirty: true,
3113 frame_idx: 0,
3114 widget_signals: Vec::new(),
3115 widget_refresh_config: config.widget_refresh,
3116 widget_refresh_plan: WidgetRefreshPlan::new(),
3117 width,
3118 height,
3119 forced_size: config.forced_size,
3120 poll_timeout: config.poll_timeout,
3121 budget,
3122 conformal_predictor,
3123 last_frame_time_us: None,
3124 last_update_us: None,
3125 frame_timing,
3126 locale_context,
3127 locale_version,
3128 resize_coalescer,
3129 evidence_sink,
3130 fairness_config_logged: false,
3131 resize_behavior: config.resize_behavior,
3132 fairness_guard: InputFairnessGuard::new(),
3133 event_recorder: None,
3134 subscriptions,
3135 task_sender,
3136 task_receiver,
3137 task_handles: Vec::new(),
3138 effect_queue,
3139 state_registry: config.persistence.registry.clone(),
3140 persistence_config: config.persistence,
3141 last_checkpoint: Instant::now(),
3142 inline_auto_remeasure,
3143 frame_arena: FrameArena::default(),
3144 })
3145 }
3146}
3147
3148impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
3149 pub fn with_event_source(
3156 model: M,
3157 events: E,
3158 backend_features: BackendFeatures,
3159 writer: TerminalWriter<W>,
3160 config: ProgramConfig,
3161 ) -> io::Result<Self>
3162 where
3163 M::Message: Send + 'static,
3164 {
3165 let (width, height) = config
3166 .forced_size
3167 .unwrap_or_else(|| events.size().unwrap_or((80, 24)));
3168 let width = width.max(1);
3169 let height = height.max(1);
3170
3171 let mut writer = writer;
3172 writer.set_size(width, height);
3173
3174 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
3175 if let Some(ref sink) = evidence_sink {
3176 writer = writer.with_evidence_sink(sink.clone());
3177 }
3178
3179 let render_trace = crate::RenderTraceRecorder::from_config(
3180 &config.render_trace,
3181 crate::RenderTraceContext {
3182 capabilities: writer.capabilities(),
3183 diff_config: config.diff_config.clone(),
3184 resize_config: config.resize_coalescer.clone(),
3185 conformal_config: config.conformal_config.clone(),
3186 },
3187 )?;
3188 if let Some(recorder) = render_trace {
3189 writer = writer.with_render_trace(recorder);
3190 }
3191
3192 let frame_timing = config.frame_timing.clone();
3193 writer.set_timing_enabled(frame_timing.is_some());
3194
3195 let budget = RenderBudget::from_config(&config.budget);
3196 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
3197 let locale_context = config.locale_context.clone();
3198 let locale_version = locale_context.version();
3199 let mut resize_coalescer =
3200 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
3201 .with_screen_mode(config.screen_mode);
3202 if let Some(ref sink) = evidence_sink {
3203 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
3204 }
3205 let subscriptions = SubscriptionManager::new();
3206 let (task_sender, task_receiver) = std::sync::mpsc::channel();
3207 let inline_auto_remeasure = config
3208 .inline_auto_remeasure
3209 .clone()
3210 .map(InlineAutoRemeasureState::new);
3211 let effect_queue = if config.effect_queue.enabled {
3212 Some(EffectQueue::start(
3213 config.effect_queue.clone(),
3214 task_sender.clone(),
3215 evidence_sink.clone(),
3216 )?)
3217 } else {
3218 None
3219 };
3220
3221 Ok(Self {
3222 model,
3223 writer,
3224 events,
3225 backend_features,
3226 running: true,
3227 tick_rate: None,
3228 last_tick: Instant::now(),
3229 dirty: true,
3230 frame_idx: 0,
3231 widget_signals: Vec::new(),
3232 widget_refresh_config: config.widget_refresh,
3233 widget_refresh_plan: WidgetRefreshPlan::new(),
3234 width,
3235 height,
3236 forced_size: config.forced_size,
3237 poll_timeout: config.poll_timeout,
3238 budget,
3239 conformal_predictor,
3240 last_frame_time_us: None,
3241 last_update_us: None,
3242 frame_timing,
3243 locale_context,
3244 locale_version,
3245 resize_coalescer,
3246 evidence_sink,
3247 fairness_config_logged: false,
3248 resize_behavior: config.resize_behavior,
3249 fairness_guard: InputFairnessGuard::new(),
3250 event_recorder: None,
3251 subscriptions,
3252 task_sender,
3253 task_receiver,
3254 task_handles: Vec::new(),
3255 effect_queue,
3256 state_registry: config.persistence.registry.clone(),
3257 persistence_config: config.persistence,
3258 last_checkpoint: Instant::now(),
3259 inline_auto_remeasure,
3260 frame_arena: FrameArena::default(),
3261 })
3262 }
3263}
3264
3265#[cfg(feature = "native-backend")]
3270impl<M: Model> Program<M, ftui_tty::TtyBackend, Stdout> {
3271 pub fn with_native_backend(model: M, config: ProgramConfig) -> io::Result<Self>
3277 where
3278 M::Message: Send + 'static,
3279 {
3280 let mouse_capture = config.resolved_mouse_capture();
3281 let features = BackendFeatures {
3282 mouse_capture,
3283 bracketed_paste: config.bracketed_paste,
3284 focus_events: config.focus_reporting,
3285 kitty_keyboard: config.kitty_keyboard,
3286 };
3287 let options = ftui_tty::TtySessionOptions {
3288 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
3289 features,
3290 };
3291 let backend = ftui_tty::TtyBackend::open(0, 0, options)?;
3292
3293 let capabilities = ftui_core::terminal_capabilities::TerminalCapabilities::detect();
3294 let writer = TerminalWriter::with_diff_config(
3295 io::stdout(),
3296 config.screen_mode,
3297 config.ui_anchor,
3298 capabilities,
3299 config.diff_config.clone(),
3300 );
3301
3302 Self::with_event_source(model, backend, features, writer, config)
3303 }
3304}
3305
3306impl<M: Model, E: BackendEventSource<Error = io::Error>, W: Write + Send> Program<M, E, W> {
3307 pub fn run(&mut self) -> io::Result<()> {
3315 self.run_event_loop()
3316 }
3317
3318 #[inline]
3320 pub fn last_widget_signals(&self) -> &[WidgetSignal] {
3321 &self.widget_signals
3322 }
3323
3324 fn run_event_loop(&mut self) -> io::Result<()> {
3326 if self.persistence_config.auto_load {
3328 self.load_state();
3329 }
3330
3331 let cmd = {
3333 let _span = info_span!("ftui.program.init").entered();
3334 self.model.init()
3335 };
3336 self.execute_cmd(cmd)?;
3337
3338 self.reconcile_subscriptions();
3340
3341 self.render_frame()?;
3343
3344 let mut loop_count: u64 = 0;
3346 while self.running {
3347 loop_count += 1;
3348 if loop_count.is_multiple_of(100) {
3350 crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
3351 }
3352
3353 let timeout = self.effective_timeout();
3355
3356 if self.events.poll_event(timeout)? {
3358 loop {
3360 if let Some(event) = self.events.read_event()? {
3362 self.handle_event(event)?;
3363 }
3364 if !self.events.poll_event(Duration::from_millis(0))? {
3365 break;
3366 }
3367 }
3368 }
3369
3370 self.process_subscription_messages()?;
3372
3373 self.process_task_results()?;
3375 self.reap_finished_tasks();
3376
3377 self.process_resize_coalescer()?;
3378
3379 if self.should_tick() {
3381 let msg = M::Message::from(Event::Tick);
3382 let cmd = {
3383 let _span = debug_span!(
3384 "ftui.program.update",
3385 msg_type = "Tick",
3386 duration_us = tracing::field::Empty,
3387 cmd_type = tracing::field::Empty
3388 )
3389 .entered();
3390 let start = Instant::now();
3391 let cmd = self.model.update(msg);
3392 tracing::Span::current()
3393 .record("duration_us", start.elapsed().as_micros() as u64);
3394 tracing::Span::current()
3395 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3396 cmd
3397 };
3398 self.mark_dirty();
3399 self.execute_cmd(cmd)?;
3400 self.reconcile_subscriptions();
3401 }
3402
3403 self.check_checkpoint_save();
3405
3406 self.check_locale_change();
3408
3409 if self.dirty {
3411 self.render_frame()?;
3412 }
3413
3414 if loop_count.is_multiple_of(1000) {
3416 self.writer.gc();
3417 }
3418 }
3419
3420 if self.persistence_config.auto_save {
3422 self.save_state();
3423 }
3424
3425 self.subscriptions.stop_all();
3427 self.reap_finished_tasks();
3428
3429 Ok(())
3430 }
3431
3432 fn load_state(&mut self) {
3434 if let Some(registry) = &self.state_registry {
3435 match registry.load() {
3436 Ok(count) => {
3437 info!(count, "loaded widget state from persistence");
3438 }
3439 Err(e) => {
3440 tracing::warn!(error = %e, "failed to load widget state");
3441 }
3442 }
3443 }
3444 }
3445
3446 fn save_state(&mut self) {
3448 if let Some(registry) = &self.state_registry {
3449 match registry.flush() {
3450 Ok(true) => {
3451 debug!("saved widget state to persistence");
3452 }
3453 Ok(false) => {
3454 }
3456 Err(e) => {
3457 tracing::warn!(error = %e, "failed to save widget state");
3458 }
3459 }
3460 }
3461 }
3462
3463 fn check_checkpoint_save(&mut self) {
3465 if let Some(interval) = self.persistence_config.checkpoint_interval
3466 && self.last_checkpoint.elapsed() >= interval
3467 {
3468 self.save_state();
3469 self.last_checkpoint = Instant::now();
3470 }
3471 }
3472
3473 fn handle_event(&mut self, event: Event) -> io::Result<()> {
3474 let event_start = Instant::now();
3476 let fairness_event_type = Self::classify_event_for_fairness(&event);
3477 if fairness_event_type == FairnessEventType::Input {
3478 self.fairness_guard.input_arrived(event_start);
3479 }
3480
3481 if let Some(recorder) = &mut self.event_recorder {
3483 recorder.record(&event);
3484 }
3485
3486 let event = match event {
3487 Event::Resize { width, height } => {
3488 debug!(
3489 width,
3490 height,
3491 behavior = ?self.resize_behavior,
3492 "Resize event received"
3493 );
3494 if let Some((forced_width, forced_height)) = self.forced_size {
3495 debug!(
3496 forced_width,
3497 forced_height, "Resize ignored due to forced size override"
3498 );
3499 self.fairness_guard.event_processed(
3500 fairness_event_type,
3501 event_start.elapsed(),
3502 Instant::now(),
3503 );
3504 return Ok(());
3505 }
3506 let width = width.max(1);
3508 let height = height.max(1);
3509 match self.resize_behavior {
3510 ResizeBehavior::Immediate => {
3511 self.resize_coalescer
3512 .record_external_apply(width, height, Instant::now());
3513 let result = self.apply_resize(width, height, Duration::ZERO, false);
3514 self.fairness_guard.event_processed(
3515 fairness_event_type,
3516 event_start.elapsed(),
3517 Instant::now(),
3518 );
3519 return result;
3520 }
3521 ResizeBehavior::Throttled => {
3522 let action = self.resize_coalescer.handle_resize(width, height);
3523 if let CoalesceAction::ApplyResize {
3524 width,
3525 height,
3526 coalesce_time,
3527 forced_by_deadline,
3528 } = action
3529 {
3530 let result =
3531 self.apply_resize(width, height, coalesce_time, forced_by_deadline);
3532 self.fairness_guard.event_processed(
3533 fairness_event_type,
3534 event_start.elapsed(),
3535 Instant::now(),
3536 );
3537 return result;
3538 }
3539
3540 self.fairness_guard.event_processed(
3541 fairness_event_type,
3542 event_start.elapsed(),
3543 Instant::now(),
3544 );
3545 return Ok(());
3546 }
3547 }
3548 }
3549 other => other,
3550 };
3551
3552 let msg = M::Message::from(event);
3553 let cmd = {
3554 let _span = debug_span!(
3555 "ftui.program.update",
3556 msg_type = "event",
3557 duration_us = tracing::field::Empty,
3558 cmd_type = tracing::field::Empty
3559 )
3560 .entered();
3561 let start = Instant::now();
3562 let cmd = self.model.update(msg);
3563 let elapsed_us = start.elapsed().as_micros() as u64;
3564 self.last_update_us = Some(elapsed_us);
3565 tracing::Span::current().record("duration_us", elapsed_us);
3566 tracing::Span::current()
3567 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3568 cmd
3569 };
3570 self.mark_dirty();
3571 self.execute_cmd(cmd)?;
3572 self.reconcile_subscriptions();
3573
3574 self.fairness_guard.event_processed(
3576 fairness_event_type,
3577 event_start.elapsed(),
3578 Instant::now(),
3579 );
3580
3581 Ok(())
3582 }
3583
3584 fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
3586 match event {
3587 Event::Key(_)
3588 | Event::Mouse(_)
3589 | Event::Paste(_)
3590 | Event::Focus(_)
3591 | Event::Clipboard(_) => FairnessEventType::Input,
3592 Event::Resize { .. } => FairnessEventType::Resize,
3593 Event::Tick => FairnessEventType::Tick,
3594 }
3595 }
3596
3597 fn reconcile_subscriptions(&mut self) {
3599 let _span = debug_span!(
3600 "ftui.program.subscriptions",
3601 active_count = tracing::field::Empty,
3602 started = tracing::field::Empty,
3603 stopped = tracing::field::Empty
3604 )
3605 .entered();
3606 let subs = self.model.subscriptions();
3607 let before_count = self.subscriptions.active_count();
3608 self.subscriptions.reconcile(subs);
3609 let after_count = self.subscriptions.active_count();
3610 let started = after_count.saturating_sub(before_count);
3611 let stopped = before_count.saturating_sub(after_count);
3612 crate::debug_trace!(
3613 "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
3614 before_count,
3615 after_count,
3616 started,
3617 stopped
3618 );
3619 if after_count == 0 {
3620 crate::debug_trace!("subscriptions reconcile: no active subscriptions");
3621 }
3622 let current = tracing::Span::current();
3623 current.record("active_count", after_count);
3624 current.record("started", started);
3626 current.record("stopped", stopped);
3627 }
3628
3629 fn process_subscription_messages(&mut self) -> io::Result<()> {
3631 let messages = self.subscriptions.drain_messages();
3632 let msg_count = messages.len();
3633 if msg_count > 0 {
3634 crate::debug_trace!("processing {} subscription message(s)", msg_count);
3635 }
3636 for msg in messages {
3637 let cmd = {
3638 let _span = debug_span!(
3639 "ftui.program.update",
3640 msg_type = "subscription",
3641 duration_us = tracing::field::Empty,
3642 cmd_type = tracing::field::Empty
3643 )
3644 .entered();
3645 let start = Instant::now();
3646 let cmd = self.model.update(msg);
3647 let elapsed_us = start.elapsed().as_micros() as u64;
3648 self.last_update_us = Some(elapsed_us);
3649 tracing::Span::current().record("duration_us", elapsed_us);
3650 tracing::Span::current()
3651 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3652 cmd
3653 };
3654 self.mark_dirty();
3655 self.execute_cmd(cmd)?;
3656 }
3657 if self.dirty {
3658 self.reconcile_subscriptions();
3659 }
3660 Ok(())
3661 }
3662
3663 fn process_task_results(&mut self) -> io::Result<()> {
3665 while let Ok(msg) = self.task_receiver.try_recv() {
3666 let cmd = {
3667 let _span = debug_span!(
3668 "ftui.program.update",
3669 msg_type = "task",
3670 duration_us = tracing::field::Empty,
3671 cmd_type = tracing::field::Empty
3672 )
3673 .entered();
3674 let start = Instant::now();
3675 let cmd = self.model.update(msg);
3676 let elapsed_us = start.elapsed().as_micros() as u64;
3677 self.last_update_us = Some(elapsed_us);
3678 tracing::Span::current().record("duration_us", elapsed_us);
3679 tracing::Span::current()
3680 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
3681 cmd
3682 };
3683 self.mark_dirty();
3684 self.execute_cmd(cmd)?;
3685 }
3686 if self.dirty {
3687 self.reconcile_subscriptions();
3688 }
3689 Ok(())
3690 }
3691
3692 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
3694 match cmd {
3695 Cmd::None => {}
3696 Cmd::Quit => self.running = false,
3697 Cmd::Msg(m) => {
3698 let start = Instant::now();
3699 let cmd = self.model.update(m);
3700 let elapsed_us = start.elapsed().as_micros() as u64;
3701 self.last_update_us = Some(elapsed_us);
3702 self.mark_dirty();
3703 self.execute_cmd(cmd)?;
3704 }
3705 Cmd::Batch(cmds) => {
3706 for c in cmds {
3709 self.execute_cmd(c)?;
3710 if !self.running {
3711 break;
3712 }
3713 }
3714 }
3715 Cmd::Sequence(cmds) => {
3716 for c in cmds {
3717 self.execute_cmd(c)?;
3718 if !self.running {
3719 break;
3720 }
3721 }
3722 }
3723 Cmd::Tick(duration) => {
3724 self.tick_rate = Some(duration);
3725 self.last_tick = Instant::now();
3726 }
3727 Cmd::Log(text) => {
3728 let sanitized = sanitize(&text);
3729 if sanitized.ends_with('\n') {
3730 self.writer.write_log(&sanitized)?;
3731 } else {
3732 let mut owned = sanitized.into_owned();
3733 owned.push('\n');
3734 self.writer.write_log(&owned)?;
3735 }
3736 }
3737 Cmd::Task(spec, f) => {
3738 if let Some(ref queue) = self.effect_queue {
3739 queue.enqueue(spec, f);
3740 } else {
3741 let sender = self.task_sender.clone();
3742 let handle = std::thread::spawn(move || {
3743 let msg = f();
3744 let _ = sender.send(msg);
3745 });
3746 self.task_handles.push(handle);
3747 }
3748 }
3749 Cmd::SaveState => {
3750 self.save_state();
3751 }
3752 Cmd::RestoreState => {
3753 self.load_state();
3754 }
3755 Cmd::SetMouseCapture(enabled) => {
3756 self.backend_features.mouse_capture = enabled;
3757 self.events.set_features(self.backend_features)?;
3758 }
3759 }
3760 Ok(())
3761 }
3762
3763 fn reap_finished_tasks(&mut self) {
3764 if self.task_handles.is_empty() {
3765 return;
3766 }
3767
3768 let mut remaining = Vec::with_capacity(self.task_handles.len());
3769 for handle in self.task_handles.drain(..) {
3770 if handle.is_finished() {
3771 if let Err(payload) = handle.join() {
3772 let msg = if let Some(s) = payload.downcast_ref::<&str>() {
3773 (*s).to_owned()
3774 } else if let Some(s) = payload.downcast_ref::<String>() {
3775 s.clone()
3776 } else {
3777 "unknown panic payload".to_owned()
3778 };
3779 #[cfg(feature = "tracing")]
3780 tracing::error!("spawned task panicked: {msg}");
3781 #[cfg(not(feature = "tracing"))]
3782 eprintln!("ftui: spawned task panicked: {msg}");
3783 }
3784 } else {
3785 remaining.push(handle);
3786 }
3787 }
3788 self.task_handles = remaining;
3789 }
3790
3791 fn render_frame(&mut self) -> io::Result<()> {
3793 crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
3794
3795 self.frame_idx = self.frame_idx.wrapping_add(1);
3796 let frame_idx = self.frame_idx;
3797 let degradation_start = self.budget.degradation();
3798
3799 self.budget.next_frame();
3801
3802 let mut conformal_prediction = None;
3804 if let Some(predictor) = self.conformal_predictor.as_ref() {
3805 let baseline_us = self
3806 .last_frame_time_us
3807 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
3808 let diff_strategy = self
3809 .writer
3810 .last_diff_strategy()
3811 .unwrap_or(DiffStrategy::Full);
3812 let frame_height_hint = self.writer.render_height_hint().max(1);
3813 let key = BucketKey::from_context(
3814 self.writer.screen_mode(),
3815 diff_strategy,
3816 self.width,
3817 frame_height_hint,
3818 );
3819 let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
3820 let prediction = predictor.predict(key, baseline_us, budget_us);
3821 if prediction.risk {
3822 self.budget.degrade();
3823 }
3824 debug!(
3825 bucket = %prediction.bucket,
3826 upper_us = prediction.upper_us,
3827 budget_us = prediction.budget_us,
3828 fallback = prediction.fallback_level,
3829 risk = prediction.risk,
3830 "conformal risk gate"
3831 );
3832 conformal_prediction = Some(prediction);
3833 }
3834
3835 if self.budget.exhausted() {
3837 self.budget.record_frame_time(Duration::ZERO);
3838 self.emit_budget_evidence(
3839 frame_idx,
3840 degradation_start,
3841 0.0,
3842 conformal_prediction.as_ref(),
3843 );
3844 crate::debug_trace!(
3845 "frame skipped: budget exhausted (degradation={})",
3846 self.budget.degradation().as_str()
3847 );
3848 debug!(
3849 degradation = self.budget.degradation().as_str(),
3850 "frame skipped: budget exhausted before render"
3851 );
3852 self.dirty = false;
3853 return Ok(());
3854 }
3855
3856 let auto_bounds = self.writer.inline_auto_bounds();
3857 let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
3858 let mut should_measure = needs_measure;
3859 if auto_bounds.is_some()
3860 && let Some(state) = self.inline_auto_remeasure.as_mut()
3861 {
3862 let decision = state.sampler.decide(Instant::now());
3863 if decision.should_sample {
3864 should_measure = true;
3865 }
3866 } else {
3867 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
3868 }
3869
3870 let render_start = Instant::now();
3872 if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
3873 let measure_height = if needs_measure {
3874 self.writer.render_height_hint().max(1)
3875 } else {
3876 max_height.max(1)
3877 };
3878 let (measure_buffer, _) = self.render_measure_buffer(measure_height);
3879 let measured_height = measure_buffer.content_height();
3880 let clamped = measured_height.clamp(min_height, max_height);
3881 let previous_height = self.writer.auto_ui_height();
3882 self.writer.set_auto_ui_height(clamped);
3883 if let Some(state) = self.inline_auto_remeasure.as_mut() {
3884 let threshold = state.config.change_threshold_rows;
3885 let violated = previous_height
3886 .map(|prev| prev.abs_diff(clamped) >= threshold)
3887 .unwrap_or(false);
3888 state.sampler.observe(violated);
3889 }
3890 }
3891 if auto_bounds.is_some()
3892 && let Some(state) = self.inline_auto_remeasure.as_ref()
3893 {
3894 let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
3895 crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
3896 }
3897
3898 let frame_height = self.writer.render_height_hint().max(1);
3899 let _frame_span = info_span!(
3900 "ftui.render.frame",
3901 width = self.width,
3902 height = frame_height,
3903 duration_us = tracing::field::Empty
3904 )
3905 .entered();
3906 let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
3907 self.update_widget_refresh_plan(frame_idx);
3908 let render_elapsed = render_start.elapsed();
3909 let mut present_elapsed = Duration::ZERO;
3910 let mut presented = false;
3911
3912 let render_budget = self.budget.phase_budgets().render;
3914 if render_elapsed > render_budget {
3915 debug!(
3916 render_ms = render_elapsed.as_millis() as u32,
3917 budget_ms = render_budget.as_millis() as u32,
3918 "render phase exceeded budget"
3919 );
3920 if self.budget.should_degrade(render_budget) {
3922 self.budget.degrade();
3923 }
3924 }
3925
3926 if !self.budget.exhausted() {
3928 let present_start = Instant::now();
3929 {
3930 let _present_span = debug_span!("ftui.render.present").entered();
3931 self.writer
3932 .present_ui_owned(buffer, cursor, cursor_visible)?;
3933 }
3934 presented = true;
3935 present_elapsed = present_start.elapsed();
3936
3937 let present_budget = self.budget.phase_budgets().present;
3938 if present_elapsed > present_budget {
3939 debug!(
3940 present_ms = present_elapsed.as_millis() as u32,
3941 budget_ms = present_budget.as_millis() as u32,
3942 "present phase exceeded budget"
3943 );
3944 }
3945 } else {
3946 debug!(
3947 degradation = self.budget.degradation().as_str(),
3948 elapsed_ms = self.budget.elapsed().as_millis() as u32,
3949 "frame present skipped: budget exhausted after render"
3950 );
3951 }
3952
3953 if let Some(ref frame_timing) = self.frame_timing {
3954 let update_us = self.last_update_us.unwrap_or(0);
3955 let render_us = render_elapsed.as_micros() as u64;
3956 let present_us = present_elapsed.as_micros() as u64;
3957 let diff_us = if presented {
3958 self.writer
3959 .take_last_present_timings()
3960 .map(|timings| timings.diff_us)
3961 .unwrap_or(0)
3962 } else {
3963 let _ = self.writer.take_last_present_timings();
3964 0
3965 };
3966 let total_us = update_us
3967 .saturating_add(render_us)
3968 .saturating_add(present_us);
3969 let timing = FrameTiming {
3970 frame_idx,
3971 update_us,
3972 render_us,
3973 diff_us,
3974 present_us,
3975 total_us,
3976 };
3977 frame_timing.sink.record_frame(&timing);
3978 }
3979
3980 let frame_time = render_elapsed.saturating_add(present_elapsed);
3981 self.budget.record_frame_time(frame_time);
3982 let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
3983
3984 if let (Some(predictor), Some(prediction)) = (
3985 self.conformal_predictor.as_mut(),
3986 conformal_prediction.as_ref(),
3987 ) {
3988 let diff_strategy = self
3989 .writer
3990 .last_diff_strategy()
3991 .unwrap_or(DiffStrategy::Full);
3992 let key = BucketKey::from_context(
3993 self.writer.screen_mode(),
3994 diff_strategy,
3995 self.width,
3996 frame_height,
3997 );
3998 predictor.observe(key, prediction.y_hat, frame_time_us);
3999 }
4000 self.last_frame_time_us = Some(frame_time_us);
4001 self.emit_budget_evidence(
4002 frame_idx,
4003 degradation_start,
4004 frame_time_us,
4005 conformal_prediction.as_ref(),
4006 );
4007 self.dirty = false;
4008
4009 Ok(())
4010 }
4011
4012 fn emit_budget_evidence(
4013 &self,
4014 frame_idx: u64,
4015 degradation_start: DegradationLevel,
4016 frame_time_us: f64,
4017 conformal_prediction: Option<&ConformalPrediction>,
4018 ) {
4019 let Some(telemetry) = self.budget.telemetry() else {
4020 set_budget_snapshot(None);
4021 return;
4022 };
4023
4024 let budget_us = conformal_prediction
4025 .map(|prediction| prediction.budget_us)
4026 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
4027 let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
4028 let degradation_after = self.budget.degradation();
4029
4030 let evidence = BudgetDecisionEvidence {
4031 frame_idx,
4032 decision: BudgetDecisionEvidence::decision_from_levels(
4033 degradation_start,
4034 degradation_after,
4035 ),
4036 controller_decision: telemetry.last_decision,
4037 degradation_before: degradation_start,
4038 degradation_after,
4039 frame_time_us,
4040 budget_us,
4041 pid_output: telemetry.pid_output,
4042 pid_p: telemetry.pid_p,
4043 pid_i: telemetry.pid_i,
4044 pid_d: telemetry.pid_d,
4045 e_value: telemetry.e_value,
4046 frames_observed: telemetry.frames_observed,
4047 frames_since_change: telemetry.frames_since_change,
4048 in_warmup: telemetry.in_warmup,
4049 conformal,
4050 };
4051
4052 let conformal_snapshot = evidence
4053 .conformal
4054 .as_ref()
4055 .map(|snapshot| ConformalSnapshot {
4056 bucket_key: snapshot.bucket_key.clone(),
4057 sample_count: snapshot.n_b,
4058 upper_us: snapshot.upper_us,
4059 risk: snapshot.risk,
4060 });
4061 set_budget_snapshot(Some(BudgetDecisionSnapshot {
4062 frame_idx: evidence.frame_idx,
4063 decision: evidence.decision,
4064 controller_decision: evidence.controller_decision,
4065 degradation_before: evidence.degradation_before,
4066 degradation_after: evidence.degradation_after,
4067 frame_time_us: evidence.frame_time_us,
4068 budget_us: evidence.budget_us,
4069 pid_output: evidence.pid_output,
4070 e_value: evidence.e_value,
4071 frames_observed: evidence.frames_observed,
4072 frames_since_change: evidence.frames_since_change,
4073 in_warmup: evidence.in_warmup,
4074 conformal: conformal_snapshot,
4075 }));
4076
4077 if let Some(ref sink) = self.evidence_sink {
4078 let _ = sink.write_jsonl(&evidence.to_jsonl());
4079 }
4080 }
4081
4082 fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
4083 if !self.widget_refresh_config.enabled {
4084 self.widget_refresh_plan.clear();
4085 return;
4086 }
4087
4088 let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
4089 let degradation = self.budget.degradation();
4090 self.widget_refresh_plan.recompute(
4091 frame_idx,
4092 budget_us,
4093 degradation,
4094 &self.widget_signals,
4095 &self.widget_refresh_config,
4096 );
4097
4098 if let Some(ref sink) = self.evidence_sink {
4099 let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
4100 }
4101 }
4102
4103 fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
4104 self.frame_arena.reset();
4106
4107 let buffer = self.writer.take_render_buffer(self.width, frame_height);
4110 let (pool, links) = self.writer.pool_and_links_mut();
4111 let mut frame = Frame::from_buffer(buffer, pool);
4112 frame.set_degradation(self.budget.degradation());
4113 frame.set_links(links);
4114 frame.set_widget_budget(self.widget_refresh_plan.as_budget());
4115 frame.set_arena(&self.frame_arena);
4116
4117 let view_start = Instant::now();
4118 let _view_span = debug_span!(
4119 "ftui.program.view",
4120 duration_us = tracing::field::Empty,
4121 widget_count = tracing::field::Empty
4122 )
4123 .entered();
4124 self.model.view(&mut frame);
4125 self.widget_signals = frame.take_widget_signals();
4126 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
4127 (frame.buffer, frame.cursor_position, frame.cursor_visible)
4130 }
4131
4132 fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
4133 let Some(ref sink) = self.evidence_sink else {
4134 return;
4135 };
4136
4137 let config = self.fairness_guard.config();
4138 if !self.fairness_config_logged {
4139 let config_entry = FairnessConfigEvidence {
4140 enabled: config.enabled,
4141 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
4142 dominance_threshold: config.dominance_threshold,
4143 fairness_threshold: config.fairness_threshold,
4144 };
4145 let _ = sink.write_jsonl(&config_entry.to_jsonl());
4146 self.fairness_config_logged = true;
4147 }
4148
4149 let evidence = FairnessDecisionEvidence {
4150 frame_idx: self.frame_idx,
4151 decision: if decision.should_process {
4152 "allow"
4153 } else {
4154 "yield"
4155 },
4156 reason: decision.reason.as_str(),
4157 pending_input_latency_ms: decision
4158 .pending_input_latency
4159 .map(|latency| latency.as_millis() as u64),
4160 jain_index: decision.jain_index,
4161 resize_dominance_count: dominance_count,
4162 dominance_threshold: config.dominance_threshold,
4163 fairness_threshold: config.fairness_threshold,
4164 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
4165 };
4166
4167 let _ = sink.write_jsonl(&evidence.to_jsonl());
4168 }
4169
4170 fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
4171 self.frame_arena.reset();
4173
4174 let pool = self.writer.pool_mut();
4175 let mut frame = Frame::new(self.width, frame_height, pool);
4176 frame.set_degradation(self.budget.degradation());
4177 frame.set_arena(&self.frame_arena);
4178
4179 let view_start = Instant::now();
4180 let _view_span = debug_span!(
4181 "ftui.program.view",
4182 duration_us = tracing::field::Empty,
4183 widget_count = tracing::field::Empty
4184 )
4185 .entered();
4186 self.model.view(&mut frame);
4187 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
4188
4189 (frame.buffer, frame.cursor_position)
4190 }
4191
4192 fn effective_timeout(&self) -> Duration {
4194 if let Some(tick_rate) = self.tick_rate {
4195 let elapsed = self.last_tick.elapsed();
4196 let mut timeout = tick_rate.saturating_sub(elapsed);
4197 if self.resize_behavior.uses_coalescer()
4198 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
4199 {
4200 timeout = timeout.min(resize_timeout);
4201 }
4202 timeout
4203 } else {
4204 let mut timeout = self.poll_timeout;
4205 if self.resize_behavior.uses_coalescer()
4206 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
4207 {
4208 timeout = timeout.min(resize_timeout);
4209 }
4210 timeout
4211 }
4212 }
4213
4214 fn should_tick(&mut self) -> bool {
4216 if let Some(tick_rate) = self.tick_rate
4217 && self.last_tick.elapsed() >= tick_rate
4218 {
4219 self.last_tick = Instant::now();
4220 return true;
4221 }
4222 false
4223 }
4224
4225 fn process_resize_coalescer(&mut self) -> io::Result<()> {
4226 if !self.resize_behavior.uses_coalescer() {
4227 return Ok(());
4228 }
4229
4230 let dominance_count = self.fairness_guard.resize_dominance_count();
4233 let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
4234 self.emit_fairness_evidence(&fairness_decision, dominance_count);
4235 if !fairness_decision.should_process {
4236 debug!(
4237 reason = ?fairness_decision.reason,
4238 pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
4239 "Resize yielding to input for fairness"
4240 );
4241 return Ok(());
4243 }
4244
4245 let action = self.resize_coalescer.tick();
4246 let resize_snapshot =
4247 self.resize_coalescer
4248 .logs()
4249 .last()
4250 .map(|entry| ResizeDecisionSnapshot {
4251 event_idx: entry.event_idx,
4252 action: entry.action,
4253 dt_ms: entry.dt_ms,
4254 event_rate: entry.event_rate,
4255 regime: entry.regime,
4256 pending_size: entry.pending_size,
4257 applied_size: entry.applied_size,
4258 time_since_render_ms: entry.time_since_render_ms,
4259 bocpd: self
4260 .resize_coalescer
4261 .bocpd()
4262 .and_then(|detector| detector.last_evidence().cloned()),
4263 });
4264 set_resize_snapshot(resize_snapshot);
4265
4266 match action {
4267 CoalesceAction::ApplyResize {
4268 width,
4269 height,
4270 coalesce_time,
4271 forced_by_deadline,
4272 } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
4273 _ => Ok(()),
4274 }
4275 }
4276
4277 fn apply_resize(
4278 &mut self,
4279 width: u16,
4280 height: u16,
4281 coalesce_time: Duration,
4282 forced_by_deadline: bool,
4283 ) -> io::Result<()> {
4284 let width = width.max(1);
4286 let height = height.max(1);
4287 self.width = width;
4288 self.height = height;
4289 self.writer.set_size(width, height);
4290 info!(
4291 width = width,
4292 height = height,
4293 coalesce_ms = coalesce_time.as_millis() as u64,
4294 forced = forced_by_deadline,
4295 "Resize applied"
4296 );
4297
4298 let msg = M::Message::from(Event::Resize { width, height });
4299 let start = Instant::now();
4300 let cmd = self.model.update(msg);
4301 let elapsed_us = start.elapsed().as_micros() as u64;
4302 self.last_update_us = Some(elapsed_us);
4303 self.mark_dirty();
4304 self.execute_cmd(cmd)
4305 }
4306
4307 pub fn model(&self) -> &M {
4311 &self.model
4312 }
4313
4314 pub fn model_mut(&mut self) -> &mut M {
4316 &mut self.model
4317 }
4318
4319 pub fn is_running(&self) -> bool {
4321 self.running
4322 }
4323
4324 pub fn quit(&mut self) {
4326 self.running = false;
4327 }
4328
4329 pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
4331 self.state_registry.as_ref()
4332 }
4333
4334 pub fn has_persistence(&self) -> bool {
4336 self.state_registry.is_some()
4337 }
4338
4339 pub fn trigger_save(&mut self) -> StorageResult<bool> {
4344 if let Some(registry) = &self.state_registry {
4345 registry.flush()
4346 } else {
4347 Ok(false)
4348 }
4349 }
4350
4351 pub fn trigger_load(&mut self) -> StorageResult<usize> {
4356 if let Some(registry) = &self.state_registry {
4357 registry.load()
4358 } else {
4359 Ok(0)
4360 }
4361 }
4362
4363 fn mark_dirty(&mut self) {
4364 self.dirty = true;
4365 }
4366
4367 fn check_locale_change(&mut self) {
4368 let version = self.locale_context.version();
4369 if version != self.locale_version {
4370 self.locale_version = version;
4371 self.mark_dirty();
4372 }
4373 }
4374
4375 pub fn request_redraw(&mut self) {
4377 self.mark_dirty();
4378 }
4379
4380 pub fn request_ui_height_remeasure(&mut self) {
4382 if self.writer.inline_auto_bounds().is_some() {
4383 self.writer.clear_auto_ui_height();
4384 if let Some(state) = self.inline_auto_remeasure.as_mut() {
4385 state.reset();
4386 }
4387 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
4388 self.mark_dirty();
4389 }
4390 }
4391
4392 pub fn start_recording(&mut self, name: impl Into<String>) {
4397 let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
4398 recorder.start();
4399 self.event_recorder = Some(recorder);
4400 }
4401
4402 pub fn stop_recording(&mut self) -> Option<InputMacro> {
4406 self.event_recorder.take().map(EventRecorder::finish)
4407 }
4408
4409 pub fn is_recording(&self) -> bool {
4411 self.event_recorder
4412 .as_ref()
4413 .is_some_and(EventRecorder::is_recording)
4414 }
4415}
4416
4417pub struct App;
4419
4420impl App {
4421 #[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
4424 AppBuilder {
4425 model,
4426 config: ProgramConfig::default(),
4427 }
4428 }
4429
4430 pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
4432 AppBuilder {
4433 model,
4434 config: ProgramConfig::fullscreen(),
4435 }
4436 }
4437
4438 pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
4440 AppBuilder {
4441 model,
4442 config: ProgramConfig::inline(height),
4443 }
4444 }
4445
4446 pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
4448 AppBuilder {
4449 model,
4450 config: ProgramConfig::inline_auto(min_height, max_height),
4451 }
4452 }
4453
4454 pub fn string_model<S: crate::string_model::StringModel>(
4459 model: S,
4460 ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
4461 AppBuilder {
4462 model: crate::string_model::StringModelAdapter::new(model),
4463 config: ProgramConfig::fullscreen(),
4464 }
4465 }
4466}
4467
4468#[must_use]
4470pub struct AppBuilder<M: Model> {
4471 model: M,
4472 config: ProgramConfig,
4473}
4474
4475impl<M: Model> AppBuilder<M> {
4476 pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
4478 self.config.screen_mode = mode;
4479 self
4480 }
4481
4482 pub fn anchor(mut self, anchor: UiAnchor) -> Self {
4484 self.config.ui_anchor = anchor;
4485 self
4486 }
4487
4488 pub fn with_mouse(mut self) -> Self {
4490 self.config.mouse_capture_policy = MouseCapturePolicy::On;
4491 self
4492 }
4493
4494 pub fn with_mouse_capture_policy(mut self, policy: MouseCapturePolicy) -> Self {
4496 self.config.mouse_capture_policy = policy;
4497 self
4498 }
4499
4500 pub fn with_mouse_enabled(mut self, enabled: bool) -> Self {
4502 self.config.mouse_capture_policy = if enabled {
4503 MouseCapturePolicy::On
4504 } else {
4505 MouseCapturePolicy::Off
4506 };
4507 self
4508 }
4509
4510 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
4512 self.config.budget = budget;
4513 self
4514 }
4515
4516 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
4518 self.config.evidence_sink = config;
4519 self
4520 }
4521
4522 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
4524 self.config.render_trace = config;
4525 self
4526 }
4527
4528 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
4530 self.config.widget_refresh = config;
4531 self
4532 }
4533
4534 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
4536 self.config.effect_queue = config;
4537 self
4538 }
4539
4540 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
4542 self.config.inline_auto_remeasure = Some(config);
4543 self
4544 }
4545
4546 pub fn without_inline_auto_remeasure(mut self) -> Self {
4548 self.config.inline_auto_remeasure = None;
4549 self
4550 }
4551
4552 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
4554 self.config.locale_context = locale_context;
4555 self
4556 }
4557
4558 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
4560 self.config.locale_context = LocaleContext::new(locale);
4561 self
4562 }
4563
4564 pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
4566 self.config.resize_coalescer = config;
4567 self
4568 }
4569
4570 pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
4572 self.config.resize_behavior = behavior;
4573 self
4574 }
4575
4576 pub fn legacy_resize(mut self, enabled: bool) -> Self {
4578 if enabled {
4579 self.config.resize_behavior = ResizeBehavior::Immediate;
4580 }
4581 self
4582 }
4583
4584 #[cfg(feature = "crossterm-compat")]
4586 pub fn run(self) -> io::Result<()>
4587 where
4588 M::Message: Send + 'static,
4589 {
4590 let mut program = Program::with_config(self.model, self.config)?;
4591 program.run()
4592 }
4593
4594 #[cfg(feature = "native-backend")]
4596 pub fn run_native(self) -> io::Result<()>
4597 where
4598 M::Message: Send + 'static,
4599 {
4600 let mut program = Program::with_native_backend(self.model, self.config)?;
4601 program.run()
4602 }
4603
4604 #[cfg(not(feature = "crossterm-compat"))]
4606 pub fn run(self) -> io::Result<()>
4607 where
4608 M::Message: Send + 'static,
4609 {
4610 let _ = (self.model, self.config);
4611 Err(io::Error::new(
4612 io::ErrorKind::Unsupported,
4613 "enable `crossterm-compat` feature to use AppBuilder::run()",
4614 ))
4615 }
4616
4617 #[cfg(not(feature = "native-backend"))]
4619 pub fn run_native(self) -> io::Result<()>
4620 where
4621 M::Message: Send + 'static,
4622 {
4623 let _ = (self.model, self.config);
4624 Err(io::Error::new(
4625 io::ErrorKind::Unsupported,
4626 "enable `native-backend` feature to use AppBuilder::run_native()",
4627 ))
4628 }
4629}
4630
4631#[derive(Debug, Clone)]
4700pub struct BatchController {
4701 ema_inter_arrival_s: f64,
4703 ema_service_s: f64,
4705 alpha: f64,
4707 tau_min_s: f64,
4709 tau_max_s: f64,
4711 headroom: f64,
4713 last_arrival: Option<Instant>,
4715 observations: u64,
4717}
4718
4719impl BatchController {
4720 pub fn new() -> Self {
4727 Self {
4728 ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
4731 tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
4734 last_arrival: None,
4735 observations: 0,
4736 }
4737 }
4738
4739 pub fn observe_arrival(&mut self, now: Instant) {
4741 if let Some(last) = self.last_arrival {
4742 let dt = now.duration_since(last).as_secs_f64();
4743 if dt > 0.0 && dt < 10.0 {
4744 self.ema_inter_arrival_s =
4746 self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
4747 self.observations += 1;
4748 }
4749 }
4750 self.last_arrival = Some(now);
4751 }
4752
4753 pub fn observe_service(&mut self, duration: Duration) {
4755 let dt = duration.as_secs_f64();
4756 if (0.0..10.0).contains(&dt) {
4757 self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
4758 }
4759 }
4760
4761 #[inline]
4763 pub fn lambda_est(&self) -> f64 {
4764 if self.ema_inter_arrival_s > 0.0 {
4765 1.0 / self.ema_inter_arrival_s
4766 } else {
4767 0.0
4768 }
4769 }
4770
4771 #[inline]
4773 pub fn service_est_s(&self) -> f64 {
4774 self.ema_service_s
4775 }
4776
4777 #[inline]
4779 pub fn rho_est(&self) -> f64 {
4780 self.lambda_est() * self.ema_service_s
4781 }
4782
4783 pub fn tau_s(&self) -> f64 {
4789 let base = self.ema_service_s * self.headroom;
4790 base.clamp(self.tau_min_s, self.tau_max_s)
4791 }
4792
4793 pub fn tau(&self) -> Duration {
4795 Duration::from_secs_f64(self.tau_s())
4796 }
4797
4798 #[inline]
4800 pub fn is_stable(&self) -> bool {
4801 self.rho_est() < 1.0
4802 }
4803
4804 #[inline]
4806 pub fn observations(&self) -> u64 {
4807 self.observations
4808 }
4809}
4810
4811impl Default for BatchController {
4812 fn default() -> Self {
4813 Self::new()
4814 }
4815}
4816
4817#[cfg(test)]
4818mod tests {
4819 use super::*;
4820 use ftui_core::terminal_capabilities::TerminalCapabilities;
4821 use ftui_layout::PaneDragResizeEffect;
4822 use ftui_render::buffer::Buffer;
4823 use ftui_render::cell::Cell;
4824 use ftui_render::diff_strategy::DiffStrategy;
4825 use ftui_render::frame::CostEstimateSource;
4826 use serde_json::Value;
4827 use std::collections::HashMap;
4828 use std::path::PathBuf;
4829 use std::sync::mpsc;
4830 use std::sync::{
4831 Arc,
4832 atomic::{AtomicUsize, Ordering},
4833 };
4834
4835 struct TestModel {
4837 value: i32,
4838 }
4839
4840 #[derive(Debug)]
4841 enum TestMsg {
4842 Increment,
4843 Decrement,
4844 Quit,
4845 }
4846
4847 impl From<Event> for TestMsg {
4848 fn from(_event: Event) -> Self {
4849 TestMsg::Increment
4850 }
4851 }
4852
4853 impl Model for TestModel {
4854 type Message = TestMsg;
4855
4856 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4857 match msg {
4858 TestMsg::Increment => {
4859 self.value += 1;
4860 Cmd::none()
4861 }
4862 TestMsg::Decrement => {
4863 self.value -= 1;
4864 Cmd::none()
4865 }
4866 TestMsg::Quit => Cmd::quit(),
4867 }
4868 }
4869
4870 fn view(&self, _frame: &mut Frame) {
4871 }
4873 }
4874
4875 #[test]
4876 fn cmd_none() {
4877 let cmd: Cmd<TestMsg> = Cmd::none();
4878 assert!(matches!(cmd, Cmd::None));
4879 }
4880
4881 #[test]
4882 fn cmd_quit() {
4883 let cmd: Cmd<TestMsg> = Cmd::quit();
4884 assert!(matches!(cmd, Cmd::Quit));
4885 }
4886
4887 #[test]
4888 fn cmd_msg() {
4889 let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
4890 assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
4891 }
4892
4893 #[test]
4894 fn cmd_batch_empty() {
4895 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
4896 assert!(matches!(cmd, Cmd::None));
4897 }
4898
4899 #[test]
4900 fn cmd_batch_single() {
4901 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
4902 assert!(matches!(cmd, Cmd::Quit));
4903 }
4904
4905 #[test]
4906 fn cmd_batch_multiple() {
4907 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
4908 assert!(matches!(cmd, Cmd::Batch(_)));
4909 }
4910
4911 #[test]
4912 fn cmd_sequence_empty() {
4913 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
4914 assert!(matches!(cmd, Cmd::None));
4915 }
4916
4917 #[test]
4918 fn cmd_tick() {
4919 let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
4920 assert!(matches!(cmd, Cmd::Tick(_)));
4921 }
4922
4923 #[test]
4924 fn cmd_task() {
4925 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
4926 assert!(matches!(cmd, Cmd::Task(..)));
4927 }
4928
4929 #[test]
4930 fn cmd_debug_format() {
4931 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
4932 let debug = format!("{cmd:?}");
4933 assert_eq!(
4934 debug,
4935 "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
4936 );
4937 }
4938
4939 #[test]
4940 fn model_subscriptions_default_empty() {
4941 let model = TestModel { value: 0 };
4942 let subs = model.subscriptions();
4943 assert!(subs.is_empty());
4944 }
4945
4946 #[test]
4947 fn program_config_default() {
4948 let config = ProgramConfig::default();
4949 assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
4950 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
4951 assert!(!config.resolved_mouse_capture());
4952 assert!(config.bracketed_paste);
4953 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
4954 assert!(config.inline_auto_remeasure.is_none());
4955 assert!(config.conformal_config.is_none());
4956 assert!(config.diff_config.bayesian_enabled);
4957 assert!(config.diff_config.dirty_rows_enabled);
4958 assert!(!config.resize_coalescer.enable_bocpd);
4959 assert!(!config.effect_queue.enabled);
4960 assert_eq!(
4961 config.resize_coalescer.steady_delay_ms,
4962 CoalescerConfig::default().steady_delay_ms
4963 );
4964 }
4965
4966 #[test]
4967 fn program_config_fullscreen() {
4968 let config = ProgramConfig::fullscreen();
4969 assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
4970 }
4971
4972 #[test]
4973 fn program_config_inline() {
4974 let config = ProgramConfig::inline(10);
4975 assert!(matches!(
4976 config.screen_mode,
4977 ScreenMode::Inline { ui_height: 10 }
4978 ));
4979 }
4980
4981 #[test]
4982 fn program_config_inline_auto() {
4983 let config = ProgramConfig::inline_auto(3, 9);
4984 assert!(matches!(
4985 config.screen_mode,
4986 ScreenMode::InlineAuto {
4987 min_height: 3,
4988 max_height: 9
4989 }
4990 ));
4991 assert!(config.inline_auto_remeasure.is_some());
4992 }
4993
4994 #[test]
4995 fn program_config_with_mouse() {
4996 let config = ProgramConfig::default().with_mouse();
4997 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
4998 assert!(config.resolved_mouse_capture());
4999 }
5000
5001 #[test]
5002 fn program_config_mouse_policy_auto_altscreen() {
5003 let config = ProgramConfig::fullscreen();
5004 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Auto);
5005 assert!(config.resolved_mouse_capture());
5006 }
5007
5008 #[test]
5009 fn program_config_mouse_policy_force_off() {
5010 let config = ProgramConfig::fullscreen().with_mouse_capture_policy(MouseCapturePolicy::Off);
5011 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::Off);
5012 assert!(!config.resolved_mouse_capture());
5013 }
5014
5015 #[test]
5016 fn program_config_mouse_policy_force_on_inline() {
5017 let config = ProgramConfig::inline(6).with_mouse_enabled(true);
5018 assert_eq!(config.mouse_capture_policy, MouseCapturePolicy::On);
5019 assert!(config.resolved_mouse_capture());
5020 }
5021
5022 fn pane_target(axis: SplitAxis) -> PaneResizeTarget {
5023 PaneResizeTarget {
5024 split_id: ftui_layout::PaneId::MIN,
5025 axis,
5026 }
5027 }
5028
5029 fn pane_id(raw: u64) -> ftui_layout::PaneId {
5030 ftui_layout::PaneId::new(raw).expect("test pane id must be non-zero")
5031 }
5032
5033 fn nested_pane_tree() -> ftui_layout::PaneTree {
5034 let root = pane_id(1);
5035 let left = pane_id(2);
5036 let right_split = pane_id(3);
5037 let right_top = pane_id(4);
5038 let right_bottom = pane_id(5);
5039 let snapshot = ftui_layout::PaneTreeSnapshot {
5040 schema_version: ftui_layout::PANE_TREE_SCHEMA_VERSION,
5041 root,
5042 next_id: pane_id(6),
5043 nodes: vec![
5044 ftui_layout::PaneNodeRecord::split(
5045 root,
5046 None,
5047 ftui_layout::PaneSplit {
5048 axis: SplitAxis::Horizontal,
5049 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
5050 first: left,
5051 second: right_split,
5052 },
5053 ),
5054 ftui_layout::PaneNodeRecord::leaf(
5055 left,
5056 Some(root),
5057 ftui_layout::PaneLeaf::new("left"),
5058 ),
5059 ftui_layout::PaneNodeRecord::split(
5060 right_split,
5061 Some(root),
5062 ftui_layout::PaneSplit {
5063 axis: SplitAxis::Vertical,
5064 ratio: ftui_layout::PaneSplitRatio::new(1, 1).expect("valid ratio"),
5065 first: right_top,
5066 second: right_bottom,
5067 },
5068 ),
5069 ftui_layout::PaneNodeRecord::leaf(
5070 right_top,
5071 Some(right_split),
5072 ftui_layout::PaneLeaf::new("right_top"),
5073 ),
5074 ftui_layout::PaneNodeRecord::leaf(
5075 right_bottom,
5076 Some(right_split),
5077 ftui_layout::PaneLeaf::new("right_bottom"),
5078 ),
5079 ],
5080 extensions: std::collections::BTreeMap::new(),
5081 };
5082 ftui_layout::PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
5083 }
5084
5085 #[test]
5086 fn pane_terminal_splitter_resolution_is_deterministic() {
5087 let tree = nested_pane_tree();
5088 let layout = tree
5089 .solve_layout(Rect::new(0, 0, 50, 20))
5090 .expect("layout should solve");
5091 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
5092 assert_eq!(handles.len(), 2);
5093
5094 let overlap = pane_terminal_resolve_splitter_target(&handles, 25, 10)
5097 .expect("overlap cell should resolve");
5098 assert_eq!(overlap.split_id, pane_id(1));
5099 assert_eq!(overlap.axis, SplitAxis::Horizontal);
5100
5101 let right_only = pane_terminal_resolve_splitter_target(&handles, 40, 10)
5102 .expect("right split should resolve");
5103 assert_eq!(right_only.split_id, pane_id(3));
5104 assert_eq!(right_only.axis, SplitAxis::Vertical);
5105 }
5106
5107 #[test]
5108 fn pane_terminal_splitter_hits_register_and_decode_target() {
5109 let tree = nested_pane_tree();
5110 let layout = tree
5111 .solve_layout(Rect::new(0, 0, 50, 20))
5112 .expect("layout should solve");
5113 let handles = pane_terminal_splitter_handles(&tree, &layout, 3);
5114
5115 let mut pool = ftui_render::grapheme_pool::GraphemePool::new();
5116 let mut frame = Frame::with_hit_grid(50, 20, &mut pool);
5117 let registered = register_pane_terminal_splitter_hits(&mut frame, &handles, 9_000);
5118 assert_eq!(registered, handles.len());
5119
5120 let root_hit = frame
5121 .hit_test(25, 2)
5122 .expect("root splitter should be hittable");
5123 assert_eq!(root_hit.1, HitRegion::Handle);
5124 let root_target = pane_terminal_target_from_hit(root_hit).expect("target from hit");
5125 assert_eq!(root_target.split_id, pane_id(1));
5126 assert_eq!(root_target.axis, SplitAxis::Horizontal);
5127
5128 let right_hit = frame
5129 .hit_test(40, 10)
5130 .expect("right splitter should be hittable");
5131 assert_eq!(right_hit.1, HitRegion::Handle);
5132 let right_target = pane_terminal_target_from_hit(right_hit).expect("target from hit");
5133 assert_eq!(right_target.split_id, pane_id(3));
5134 assert_eq!(right_target.axis, SplitAxis::Vertical);
5135 }
5136
5137 #[test]
5138 fn pane_terminal_adapter_maps_basic_drag_lifecycle() {
5139 let mut adapter =
5140 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5141 let target = pane_target(SplitAxis::Horizontal);
5142
5143 let down = Event::Mouse(MouseEvent::new(
5144 MouseEventKind::Down(MouseButton::Left),
5145 10,
5146 4,
5147 ));
5148 let down_dispatch = adapter.translate(&down, Some(target));
5149 let down_event = down_dispatch
5150 .primary_event
5151 .as_ref()
5152 .expect("pointer down semantic event");
5153 assert_eq!(down_event.sequence, 1);
5154 assert!(matches!(
5155 down_event.kind,
5156 PaneSemanticInputEventKind::PointerDown {
5157 target: actual_target,
5158 pointer_id: 1,
5159 button: PanePointerButton::Primary,
5160 position
5161 } if actual_target == target && position == PanePointerPosition::new(10, 4)
5162 ));
5163 assert!(down_event.validate().is_ok());
5164
5165 let drag = Event::Mouse(MouseEvent::new(
5166 MouseEventKind::Drag(MouseButton::Left),
5167 14,
5168 4,
5169 ));
5170 let drag_dispatch = adapter.translate(&drag, None);
5171 let drag_event = drag_dispatch
5172 .primary_event
5173 .as_ref()
5174 .expect("pointer move semantic event");
5175 assert_eq!(drag_event.sequence, 2);
5176 assert!(matches!(
5177 drag_event.kind,
5178 PaneSemanticInputEventKind::PointerMove {
5179 target: actual_target,
5180 pointer_id: 1,
5181 position,
5182 delta_x: 4,
5183 delta_y: 0
5184 } if actual_target == target && position == PanePointerPosition::new(14, 4)
5185 ));
5186
5187 let up = Event::Mouse(MouseEvent::new(
5188 MouseEventKind::Up(MouseButton::Left),
5189 14,
5190 4,
5191 ));
5192 let up_dispatch = adapter.translate(&up, None);
5193 let up_event = up_dispatch
5194 .primary_event
5195 .as_ref()
5196 .expect("pointer up semantic event");
5197 assert_eq!(up_event.sequence, 3);
5198 assert!(matches!(
5199 up_event.kind,
5200 PaneSemanticInputEventKind::PointerUp {
5201 target: actual_target,
5202 pointer_id: 1,
5203 button: PanePointerButton::Primary,
5204 position
5205 } if actual_target == target && position == PanePointerPosition::new(14, 4)
5206 ));
5207 assert_eq!(adapter.active_pointer_id(), None);
5208 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
5209 }
5210
5211 #[test]
5212 fn pane_terminal_adapter_focus_loss_emits_cancel() {
5213 let mut adapter =
5214 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5215 let target = pane_target(SplitAxis::Vertical);
5216
5217 let down = Event::Mouse(MouseEvent::new(
5218 MouseEventKind::Down(MouseButton::Left),
5219 3,
5220 9,
5221 ));
5222 let _ = adapter.translate(&down, Some(target));
5223 assert_eq!(adapter.active_pointer_id(), Some(1));
5224
5225 let cancel_dispatch = adapter.translate(&Event::Focus(false), None);
5226 let cancel_event = cancel_dispatch
5227 .primary_event
5228 .as_ref()
5229 .expect("focus-loss cancel event");
5230 assert!(matches!(
5231 cancel_event.kind,
5232 PaneSemanticInputEventKind::Cancel {
5233 target: Some(actual_target),
5234 reason: PaneCancelReason::FocusLost
5235 } if actual_target == target
5236 ));
5237 assert_eq!(adapter.active_pointer_id(), None);
5238 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
5239 }
5240
5241 #[test]
5242 fn pane_terminal_adapter_recovers_missing_mouse_up() {
5243 let mut adapter =
5244 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5245 let first_target = pane_target(SplitAxis::Horizontal);
5246 let second_target = pane_target(SplitAxis::Vertical);
5247
5248 let first_down = Event::Mouse(MouseEvent::new(
5249 MouseEventKind::Down(MouseButton::Left),
5250 5,
5251 5,
5252 ));
5253 let _ = adapter.translate(&first_down, Some(first_target));
5254
5255 let second_down = Event::Mouse(MouseEvent::new(
5256 MouseEventKind::Down(MouseButton::Left),
5257 8,
5258 11,
5259 ));
5260 let dispatch = adapter.translate(&second_down, Some(second_target));
5261 let recovery = dispatch
5262 .recovery_event
5263 .as_ref()
5264 .expect("recovery cancel expected");
5265 assert!(matches!(
5266 recovery.kind,
5267 PaneSemanticInputEventKind::Cancel {
5268 target: Some(actual_target),
5269 reason: PaneCancelReason::PointerCancel
5270 } if actual_target == first_target
5271 ));
5272 let primary = dispatch
5273 .primary_event
5274 .as_ref()
5275 .expect("second pointer down expected");
5276 assert!(matches!(
5277 primary.kind,
5278 PaneSemanticInputEventKind::PointerDown {
5279 target: actual_target,
5280 pointer_id: 1,
5281 button: PanePointerButton::Primary,
5282 position
5283 } if actual_target == second_target && position == PanePointerPosition::new(8, 11)
5284 ));
5285 assert_eq!(recovery.sequence, 2);
5286 assert_eq!(primary.sequence, 3);
5287 assert!(matches!(
5288 dispatch.log.outcome,
5289 PaneTerminalLogOutcome::SemanticForwardedAfterRecovery
5290 ));
5291 assert_eq!(dispatch.log.recovery_cancel_sequence, Some(2));
5292 }
5293
5294 #[test]
5295 fn pane_terminal_adapter_modifier_parity() {
5296 let mut adapter =
5297 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5298 let target = pane_target(SplitAxis::Horizontal);
5299
5300 let mouse = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 1, 2)
5301 .with_modifiers(Modifiers::SHIFT | Modifiers::ALT | Modifiers::CTRL | Modifiers::SUPER);
5302 let dispatch = adapter.translate(&Event::Mouse(mouse), Some(target));
5303 let event = dispatch.primary_event.expect("semantic event");
5304 assert!(event.modifiers.shift);
5305 assert!(event.modifiers.alt);
5306 assert!(event.modifiers.ctrl);
5307 assert!(event.modifiers.meta);
5308 }
5309
5310 #[test]
5311 fn pane_terminal_adapter_keyboard_resize_mapping() {
5312 let mut adapter =
5313 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5314 let target = pane_target(SplitAxis::Horizontal);
5315
5316 let key = KeyEvent::new(KeyCode::Right);
5317 let dispatch = adapter.translate(&Event::Key(key), Some(target));
5318 let event = dispatch.primary_event.expect("keyboard resize event");
5319 assert!(matches!(
5320 event.kind,
5321 PaneSemanticInputEventKind::KeyboardResize {
5322 target: actual_target,
5323 direction: PaneResizeDirection::Increase,
5324 units: 1
5325 } if actual_target == target
5326 ));
5327
5328 let shifted = KeyEvent::new(KeyCode::Right).with_modifiers(Modifiers::SHIFT);
5329 let shifted_dispatch = adapter.translate(&Event::Key(shifted), Some(target));
5330 let shifted_event = shifted_dispatch
5331 .primary_event
5332 .expect("shifted resize event");
5333 assert!(matches!(
5334 shifted_event.kind,
5335 PaneSemanticInputEventKind::KeyboardResize {
5336 direction: PaneResizeDirection::Increase,
5337 units: 5,
5338 ..
5339 }
5340 ));
5341 assert!(shifted_event.modifiers.shift);
5342 }
5343
5344 #[test]
5345 fn pane_terminal_adapter_drag_updates_are_coalesced() {
5346 let mut adapter = PaneTerminalAdapter::new(PaneTerminalAdapterConfig {
5347 drag_update_coalesce_distance: 2,
5348 ..PaneTerminalAdapterConfig::default()
5349 })
5350 .expect("valid adapter");
5351 let target = pane_target(SplitAxis::Horizontal);
5352
5353 let down = Event::Mouse(MouseEvent::new(
5354 MouseEventKind::Down(MouseButton::Left),
5355 10,
5356 4,
5357 ));
5358 let _ = adapter.translate(&down, Some(target));
5359
5360 let drag_start = Event::Mouse(MouseEvent::new(
5361 MouseEventKind::Drag(MouseButton::Left),
5362 14,
5363 4,
5364 ));
5365 let started = adapter.translate(&drag_start, None);
5366 assert!(started.primary_event.is_some());
5367 assert!(matches!(
5368 adapter.machine_state(),
5369 PaneDragResizeState::Dragging { .. }
5370 ));
5371
5372 let coalesced = Event::Mouse(MouseEvent::new(
5373 MouseEventKind::Drag(MouseButton::Left),
5374 15,
5375 4,
5376 ));
5377 let coalesced_dispatch = adapter.translate(&coalesced, None);
5378 assert!(coalesced_dispatch.primary_event.is_none());
5379 assert!(matches!(
5380 coalesced_dispatch.log.outcome,
5381 PaneTerminalLogOutcome::Ignored(PaneTerminalIgnoredReason::DragCoalesced)
5382 ));
5383
5384 let forwarded = Event::Mouse(MouseEvent::new(
5385 MouseEventKind::Drag(MouseButton::Left),
5386 16,
5387 4,
5388 ));
5389 let forwarded_dispatch = adapter.translate(&forwarded, None);
5390 let forwarded_event = forwarded_dispatch
5391 .primary_event
5392 .as_ref()
5393 .expect("coalesced movement should flush once threshold reached");
5394 assert!(matches!(
5395 forwarded_event.kind,
5396 PaneSemanticInputEventKind::PointerMove {
5397 delta_x: 2,
5398 delta_y: 0,
5399 ..
5400 }
5401 ));
5402 }
5403
5404 #[test]
5405 fn pane_terminal_adapter_translate_with_handles_resolves_target() {
5406 let tree = nested_pane_tree();
5407 let layout = tree
5408 .solve_layout(Rect::new(0, 0, 50, 20))
5409 .expect("layout should solve");
5410 let handles =
5411 pane_terminal_splitter_handles(&tree, &layout, PANE_TERMINAL_DEFAULT_HIT_THICKNESS);
5412 let mut adapter =
5413 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
5414
5415 let down = Event::Mouse(MouseEvent::new(
5416 MouseEventKind::Down(MouseButton::Left),
5417 25,
5418 10,
5419 ));
5420 let dispatch = adapter.translate_with_handles(&down, &handles);
5421 let event = dispatch
5422 .primary_event
5423 .as_ref()
5424 .expect("pointer down should be routed from handles");
5425 assert!(matches!(
5426 event.kind,
5427 PaneSemanticInputEventKind::PointerDown {
5428 target:
5429 PaneResizeTarget {
5430 split_id,
5431 axis: SplitAxis::Horizontal
5432 },
5433 ..
5434 } if split_id == pane_id(1)
5435 ));
5436 }
5437
5438 #[test]
5439 fn model_update() {
5440 let mut model = TestModel { value: 0 };
5441 model.update(TestMsg::Increment);
5442 assert_eq!(model.value, 1);
5443 model.update(TestMsg::Decrement);
5444 assert_eq!(model.value, 0);
5445 assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
5446 }
5447
5448 #[test]
5449 fn model_init_default() {
5450 let mut model = TestModel { value: 0 };
5451 let cmd = model.init();
5452 assert!(matches!(cmd, Cmd::None));
5453 }
5454
5455 #[test]
5462 fn cmd_sequence_executes_in_order() {
5463 use crate::simulator::ProgramSimulator;
5465
5466 struct SeqModel {
5467 trace: Vec<i32>,
5468 }
5469
5470 #[derive(Debug)]
5471 enum SeqMsg {
5472 Append(i32),
5473 TriggerSequence,
5474 }
5475
5476 impl From<Event> for SeqMsg {
5477 fn from(_: Event) -> Self {
5478 SeqMsg::Append(0)
5479 }
5480 }
5481
5482 impl Model for SeqModel {
5483 type Message = SeqMsg;
5484
5485 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5486 match msg {
5487 SeqMsg::Append(n) => {
5488 self.trace.push(n);
5489 Cmd::none()
5490 }
5491 SeqMsg::TriggerSequence => Cmd::sequence(vec![
5492 Cmd::msg(SeqMsg::Append(1)),
5493 Cmd::msg(SeqMsg::Append(2)),
5494 Cmd::msg(SeqMsg::Append(3)),
5495 ]),
5496 }
5497 }
5498
5499 fn view(&self, _frame: &mut Frame) {}
5500 }
5501
5502 let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
5503 sim.init();
5504 sim.send(SeqMsg::TriggerSequence);
5505
5506 assert_eq!(sim.model().trace, vec![1, 2, 3]);
5507 }
5508
5509 #[test]
5510 fn cmd_batch_executes_all_regardless_of_order() {
5511 use crate::simulator::ProgramSimulator;
5513
5514 struct BatchModel {
5515 values: Vec<i32>,
5516 }
5517
5518 #[derive(Debug)]
5519 enum BatchMsg {
5520 Add(i32),
5521 TriggerBatch,
5522 }
5523
5524 impl From<Event> for BatchMsg {
5525 fn from(_: Event) -> Self {
5526 BatchMsg::Add(0)
5527 }
5528 }
5529
5530 impl Model for BatchModel {
5531 type Message = BatchMsg;
5532
5533 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5534 match msg {
5535 BatchMsg::Add(n) => {
5536 self.values.push(n);
5537 Cmd::none()
5538 }
5539 BatchMsg::TriggerBatch => Cmd::batch(vec![
5540 Cmd::msg(BatchMsg::Add(10)),
5541 Cmd::msg(BatchMsg::Add(20)),
5542 Cmd::msg(BatchMsg::Add(30)),
5543 ]),
5544 }
5545 }
5546
5547 fn view(&self, _frame: &mut Frame) {}
5548 }
5549
5550 let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
5551 sim.init();
5552 sim.send(BatchMsg::TriggerBatch);
5553
5554 assert_eq!(sim.model().values.len(), 3);
5556 assert!(sim.model().values.contains(&10));
5557 assert!(sim.model().values.contains(&20));
5558 assert!(sim.model().values.contains(&30));
5559 }
5560
5561 #[test]
5562 fn cmd_sequence_stops_on_quit() {
5563 use crate::simulator::ProgramSimulator;
5565
5566 struct SeqQuitModel {
5567 trace: Vec<i32>,
5568 }
5569
5570 #[derive(Debug)]
5571 enum SeqQuitMsg {
5572 Append(i32),
5573 TriggerSequenceWithQuit,
5574 }
5575
5576 impl From<Event> for SeqQuitMsg {
5577 fn from(_: Event) -> Self {
5578 SeqQuitMsg::Append(0)
5579 }
5580 }
5581
5582 impl Model for SeqQuitModel {
5583 type Message = SeqQuitMsg;
5584
5585 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5586 match msg {
5587 SeqQuitMsg::Append(n) => {
5588 self.trace.push(n);
5589 Cmd::none()
5590 }
5591 SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
5592 Cmd::msg(SeqQuitMsg::Append(1)),
5593 Cmd::quit(),
5594 Cmd::msg(SeqQuitMsg::Append(2)), ]),
5596 }
5597 }
5598
5599 fn view(&self, _frame: &mut Frame) {}
5600 }
5601
5602 let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
5603 sim.init();
5604 sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
5605
5606 assert_eq!(sim.model().trace, vec![1]);
5607 assert!(!sim.is_running());
5608 }
5609
5610 #[test]
5611 fn identical_input_produces_identical_state() {
5612 use crate::simulator::ProgramSimulator;
5614
5615 fn run_scenario() -> Vec<i32> {
5616 struct DetModel {
5617 values: Vec<i32>,
5618 }
5619
5620 #[derive(Debug, Clone)]
5621 enum DetMsg {
5622 Add(i32),
5623 Double,
5624 }
5625
5626 impl From<Event> for DetMsg {
5627 fn from(_: Event) -> Self {
5628 DetMsg::Add(1)
5629 }
5630 }
5631
5632 impl Model for DetModel {
5633 type Message = DetMsg;
5634
5635 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5636 match msg {
5637 DetMsg::Add(n) => {
5638 self.values.push(n);
5639 Cmd::none()
5640 }
5641 DetMsg::Double => {
5642 if let Some(&last) = self.values.last() {
5643 self.values.push(last * 2);
5644 }
5645 Cmd::none()
5646 }
5647 }
5648 }
5649
5650 fn view(&self, _frame: &mut Frame) {}
5651 }
5652
5653 let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
5654 sim.init();
5655 sim.send(DetMsg::Add(5));
5656 sim.send(DetMsg::Double);
5657 sim.send(DetMsg::Add(3));
5658 sim.send(DetMsg::Double);
5659
5660 sim.model().values.clone()
5661 }
5662
5663 let run1 = run_scenario();
5665 let run2 = run_scenario();
5666 let run3 = run_scenario();
5667
5668 assert_eq!(run1, run2);
5669 assert_eq!(run2, run3);
5670 assert_eq!(run1, vec![5, 10, 3, 6]);
5671 }
5672
5673 #[test]
5674 fn identical_state_produces_identical_render() {
5675 use crate::simulator::ProgramSimulator;
5677
5678 struct RenderModel {
5679 counter: i32,
5680 }
5681
5682 #[derive(Debug)]
5683 enum RenderMsg {
5684 Set(i32),
5685 }
5686
5687 impl From<Event> for RenderMsg {
5688 fn from(_: Event) -> Self {
5689 RenderMsg::Set(0)
5690 }
5691 }
5692
5693 impl Model for RenderModel {
5694 type Message = RenderMsg;
5695
5696 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5697 match msg {
5698 RenderMsg::Set(n) => {
5699 self.counter = n;
5700 Cmd::none()
5701 }
5702 }
5703 }
5704
5705 fn view(&self, frame: &mut Frame) {
5706 let text = format!("Value: {}", self.counter);
5707 for (i, c) in text.chars().enumerate() {
5708 if (i as u16) < frame.width() {
5709 use ftui_render::cell::Cell;
5710 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
5711 }
5712 }
5713 }
5714 }
5715
5716 let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
5718 let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
5719
5720 let buf1 = sim1.capture_frame(80, 24);
5721 let buf2 = sim2.capture_frame(80, 24);
5722
5723 for y in 0..24 {
5725 for x in 0..80 {
5726 let cell1 = buf1.get(x, y).unwrap();
5727 let cell2 = buf2.get(x, y).unwrap();
5728 assert_eq!(
5729 cell1.content.as_char(),
5730 cell2.content.as_char(),
5731 "Mismatch at ({}, {})",
5732 x,
5733 y
5734 );
5735 }
5736 }
5737 }
5738
5739 #[test]
5742 fn cmd_log_creates_log_command() {
5743 let cmd: Cmd<TestMsg> = Cmd::log("test message");
5744 assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
5745 }
5746
5747 #[test]
5748 fn cmd_log_from_string() {
5749 let msg = String::from("dynamic message");
5750 let cmd: Cmd<TestMsg> = Cmd::log(msg);
5751 assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
5752 }
5753
5754 #[test]
5755 fn program_simulator_logs_jsonl_with_seed_and_run_id() {
5756 use crate::simulator::ProgramSimulator;
5758
5759 struct LogModel {
5760 run_id: &'static str,
5761 seed: u64,
5762 }
5763
5764 #[derive(Debug)]
5765 enum LogMsg {
5766 Emit,
5767 }
5768
5769 impl From<Event> for LogMsg {
5770 fn from(_: Event) -> Self {
5771 LogMsg::Emit
5772 }
5773 }
5774
5775 impl Model for LogModel {
5776 type Message = LogMsg;
5777
5778 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5779 let line = format!(
5780 r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
5781 self.run_id, self.seed
5782 );
5783 Cmd::log(line)
5784 }
5785
5786 fn view(&self, _frame: &mut Frame) {}
5787 }
5788
5789 let mut sim = ProgramSimulator::new(LogModel {
5790 run_id: "test-run-001",
5791 seed: 4242,
5792 });
5793 sim.init();
5794 sim.send(LogMsg::Emit);
5795
5796 let logs = sim.logs();
5797 assert_eq!(logs.len(), 1);
5798 assert!(logs[0].contains(r#""run_id":"test-run-001""#));
5799 assert!(logs[0].contains(r#""seed":4242"#));
5800 }
5801
5802 #[test]
5803 fn cmd_sequence_single_unwraps() {
5804 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
5805 assert!(matches!(cmd, Cmd::Quit));
5807 }
5808
5809 #[test]
5810 fn cmd_sequence_multiple() {
5811 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
5812 assert!(matches!(cmd, Cmd::Sequence(_)));
5813 }
5814
5815 #[test]
5816 fn cmd_default_is_none() {
5817 let cmd: Cmd<TestMsg> = Cmd::default();
5818 assert!(matches!(cmd, Cmd::None));
5819 }
5820
5821 #[test]
5822 fn cmd_debug_all_variants() {
5823 let none: Cmd<TestMsg> = Cmd::none();
5825 assert_eq!(format!("{none:?}"), "None");
5826
5827 let quit: Cmd<TestMsg> = Cmd::quit();
5828 assert_eq!(format!("{quit:?}"), "Quit");
5829
5830 let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
5831 assert!(format!("{msg:?}").starts_with("Msg("));
5832
5833 let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
5834 assert!(format!("{batch:?}").starts_with("Batch("));
5835
5836 let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
5837 assert!(format!("{seq:?}").starts_with("Sequence("));
5838
5839 let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
5840 assert!(format!("{tick:?}").starts_with("Tick("));
5841
5842 let log: Cmd<TestMsg> = Cmd::log("test");
5843 assert!(format!("{log:?}").starts_with("Log("));
5844 }
5845
5846 #[test]
5847 fn program_config_with_budget() {
5848 let budget = FrameBudgetConfig {
5849 total: Duration::from_millis(50),
5850 ..Default::default()
5851 };
5852 let config = ProgramConfig::default().with_budget(budget);
5853 assert_eq!(config.budget.total, Duration::from_millis(50));
5854 }
5855
5856 #[test]
5857 fn program_config_with_conformal() {
5858 let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
5859 alpha: 0.2,
5860 ..Default::default()
5861 });
5862 assert!(config.conformal_config.is_some());
5863 assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
5864 }
5865
5866 #[test]
5867 fn program_config_forced_size_clamps_minimums() {
5868 let config = ProgramConfig::default().with_forced_size(0, 0);
5869 assert_eq!(config.forced_size, Some((1, 1)));
5870
5871 let cleared = config.without_forced_size();
5872 assert!(cleared.forced_size.is_none());
5873 }
5874
5875 #[test]
5876 fn effect_queue_config_defaults_are_safe() {
5877 let config = EffectQueueConfig::default();
5878 assert!(!config.enabled);
5879 assert!(config.scheduler.smith_enabled);
5880 assert!(!config.scheduler.preemptive);
5881 assert_eq!(config.scheduler.aging_factor, 0.0);
5882 assert_eq!(config.scheduler.wait_starve_ms, 0.0);
5883 }
5884
5885 #[test]
5886 fn handle_effect_command_enqueues_or_executes_inline() {
5887 let (result_tx, result_rx) = mpsc::channel::<u32>();
5888 let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
5889 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
5890
5891 let ran = Arc::new(AtomicUsize::new(0));
5892 let ran_task = ran.clone();
5893 let cmd = EffectCommand::Enqueue(
5894 TaskSpec::default(),
5895 Box::new(move || {
5896 ran_task.fetch_add(1, Ordering::SeqCst);
5897 7
5898 }),
5899 );
5900
5901 let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx);
5902 assert!(!shutdown);
5903 assert_eq!(ran.load(Ordering::SeqCst), 0);
5904 assert_eq!(tasks.len(), 1);
5905 assert!(result_rx.try_recv().is_err());
5906
5907 let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
5908 max_queue_size: 0,
5909 ..Default::default()
5910 });
5911 let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
5912 let ran_full = Arc::new(AtomicUsize::new(0));
5913 let ran_full_task = ran_full.clone();
5914 let cmd_full = EffectCommand::Enqueue(
5915 TaskSpec::default(),
5916 Box::new(move || {
5917 ran_full_task.fetch_add(1, Ordering::SeqCst);
5918 42
5919 }),
5920 );
5921
5922 let shutdown_full =
5923 handle_effect_command(cmd_full, &mut full_scheduler, &mut full_tasks, &result_tx);
5924 assert!(!shutdown_full);
5925 assert!(full_tasks.is_empty());
5926 assert_eq!(ran_full.load(Ordering::SeqCst), 1);
5927 assert_eq!(
5928 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
5929 42
5930 );
5931
5932 let shutdown = handle_effect_command(
5933 EffectCommand::Shutdown,
5934 &mut full_scheduler,
5935 &mut full_tasks,
5936 &result_tx,
5937 );
5938 assert!(shutdown);
5939 }
5940
5941 #[test]
5942 fn effect_queue_loop_executes_tasks_and_shutdowns() {
5943 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
5944 let (result_tx, result_rx) = mpsc::channel::<u32>();
5945 let config = EffectQueueConfig {
5946 enabled: true,
5947 scheduler: SchedulerConfig {
5948 preemptive: false,
5949 ..Default::default()
5950 },
5951 };
5952
5953 let handle = std::thread::spawn(move || {
5954 effect_queue_loop(config, cmd_rx, result_tx, None);
5955 });
5956
5957 cmd_tx
5958 .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
5959 .unwrap();
5960 cmd_tx
5961 .send(EffectCommand::Enqueue(
5962 TaskSpec::new(2.0, 5.0).with_name("second"),
5963 Box::new(|| 20),
5964 ))
5965 .unwrap();
5966
5967 let mut results = vec![
5968 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
5969 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
5970 ];
5971 results.sort_unstable();
5972 assert_eq!(results, vec![10, 20]);
5973
5974 cmd_tx.send(EffectCommand::Shutdown).unwrap();
5975 let _ = handle.join();
5976 }
5977
5978 #[test]
5979 fn inline_auto_remeasure_reset_clears_decision() {
5980 let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
5981 state.sampler.decide(Instant::now());
5982 assert!(state.sampler.last_decision().is_some());
5983
5984 state.reset();
5985 assert!(state.sampler.last_decision().is_none());
5986 }
5987
5988 #[test]
5989 fn budget_decision_jsonl_contains_required_fields() {
5990 let evidence = BudgetDecisionEvidence {
5991 frame_idx: 7,
5992 decision: BudgetDecision::Degrade,
5993 controller_decision: BudgetDecision::Hold,
5994 degradation_before: DegradationLevel::Full,
5995 degradation_after: DegradationLevel::NoStyling,
5996 frame_time_us: 12_345.678,
5997 budget_us: 16_000.0,
5998 pid_output: 1.25,
5999 pid_p: 0.5,
6000 pid_i: 0.25,
6001 pid_d: 0.5,
6002 e_value: 2.0,
6003 frames_observed: 42,
6004 frames_since_change: 3,
6005 in_warmup: false,
6006 conformal: Some(ConformalEvidence {
6007 bucket_key: "inline:dirty:10".to_string(),
6008 n_b: 32,
6009 alpha: 0.05,
6010 q_b: 1000.0,
6011 y_hat: 12_000.0,
6012 upper_us: 13_000.0,
6013 risk: true,
6014 fallback_level: 1,
6015 window_size: 256,
6016 reset_count: 2,
6017 }),
6018 };
6019
6020 let jsonl = evidence.to_jsonl();
6021 assert!(jsonl.contains("\"event\":\"budget_decision\""));
6022 assert!(jsonl.contains("\"decision\":\"degrade\""));
6023 assert!(jsonl.contains("\"decision_controller\":\"stay\""));
6024 assert!(jsonl.contains("\"degradation_before\":\"Full\""));
6025 assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
6026 assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
6027 assert!(jsonl.contains("\"budget_us\":16000.000000"));
6028 assert!(jsonl.contains("\"pid_output\":1.250000"));
6029 assert!(jsonl.contains("\"e_value\":2.000000"));
6030 assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
6031 assert!(jsonl.contains("\"n_b\":32"));
6032 assert!(jsonl.contains("\"alpha\":0.050000"));
6033 assert!(jsonl.contains("\"q_b\":1000.000000"));
6034 assert!(jsonl.contains("\"y_hat\":12000.000000"));
6035 assert!(jsonl.contains("\"upper_us\":13000.000000"));
6036 assert!(jsonl.contains("\"risk\":true"));
6037 assert!(jsonl.contains("\"fallback_level\":1"));
6038 assert!(jsonl.contains("\"window_size\":256"));
6039 assert!(jsonl.contains("\"reset_count\":2"));
6040 }
6041
6042 fn make_signal(
6043 widget_id: u64,
6044 essential: bool,
6045 priority: f32,
6046 staleness_ms: u64,
6047 cost_us: f32,
6048 ) -> WidgetSignal {
6049 WidgetSignal {
6050 widget_id,
6051 essential,
6052 priority,
6053 staleness_ms,
6054 focus_boost: 0.0,
6055 interaction_boost: 0.0,
6056 area_cells: 1,
6057 cost_estimate_us: cost_us,
6058 recent_cost_us: 0.0,
6059 estimate_source: CostEstimateSource::FixedDefault,
6060 }
6061 }
6062
6063 fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
6064 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
6065 let staleness_window = config.staleness_window_ms.max(1) as f32;
6066 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
6067 let mut value = config.weight_priority * signal.priority
6068 + config.weight_staleness * staleness_score
6069 + config.weight_focus * signal.focus_boost
6070 + config.weight_interaction * signal.interaction_boost;
6071 if starved {
6072 value += config.starve_boost;
6073 }
6074 let raw_cost = if signal.recent_cost_us > 0.0 {
6075 signal.recent_cost_us
6076 } else {
6077 signal.cost_estimate_us
6078 };
6079 let cost_us = raw_cost.max(config.min_cost_us);
6080 (value, cost_us, starved)
6081 }
6082
6083 fn fifo_select(
6084 signals: &[WidgetSignal],
6085 budget_us: f64,
6086 config: &WidgetRefreshConfig,
6087 ) -> (Vec<u64>, f64, usize) {
6088 let mut selected = Vec::new();
6089 let mut total_value = 0.0f64;
6090 let mut starved_selected = 0usize;
6091 let mut remaining = budget_us;
6092
6093 for signal in signals {
6094 if !signal.essential {
6095 continue;
6096 }
6097 let (value, cost_us, starved) = signal_value_cost(signal, config);
6098 remaining -= cost_us as f64;
6099 total_value += value as f64;
6100 if starved {
6101 starved_selected = starved_selected.saturating_add(1);
6102 }
6103 selected.push(signal.widget_id);
6104 }
6105 for signal in signals {
6106 if signal.essential {
6107 continue;
6108 }
6109 let (value, cost_us, starved) = signal_value_cost(signal, config);
6110 if remaining >= cost_us as f64 {
6111 remaining -= cost_us as f64;
6112 total_value += value as f64;
6113 if starved {
6114 starved_selected = starved_selected.saturating_add(1);
6115 }
6116 selected.push(signal.widget_id);
6117 }
6118 }
6119
6120 (selected, total_value, starved_selected)
6121 }
6122
6123 fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
6124 if signals.is_empty() {
6125 return Vec::new();
6126 }
6127 let mut rotated = Vec::with_capacity(signals.len());
6128 for idx in 0..signals.len() {
6129 rotated.push(signals[(idx + offset) % signals.len()].clone());
6130 }
6131 rotated
6132 }
6133
6134 #[test]
6135 fn widget_refresh_selects_essentials_first() {
6136 let signals = vec![
6137 make_signal(1, true, 0.6, 0, 5.0),
6138 make_signal(2, false, 0.9, 0, 4.0),
6139 ];
6140 let mut plan = WidgetRefreshPlan::new();
6141 let config = WidgetRefreshConfig::default();
6142 plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
6143 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6144 assert_eq!(selected, vec![1]);
6145 assert!(!plan.over_budget);
6146 }
6147
6148 #[test]
6149 fn widget_refresh_degradation_essential_only_skips_nonessential() {
6150 let signals = vec![
6151 make_signal(1, true, 0.5, 0, 2.0),
6152 make_signal(2, false, 1.0, 0, 1.0),
6153 ];
6154 let mut plan = WidgetRefreshPlan::new();
6155 let config = WidgetRefreshConfig::default();
6156 plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
6157 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6158 assert_eq!(selected, vec![1]);
6159 assert_eq!(plan.skipped_count, 1);
6160 }
6161
6162 #[test]
6163 fn widget_refresh_starvation_guard_forces_one_starved() {
6164 let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
6165 let mut plan = WidgetRefreshPlan::new();
6166 let config = WidgetRefreshConfig {
6167 starve_ms: 1_000,
6168 max_starved_per_frame: 1,
6169 ..Default::default()
6170 };
6171 plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
6172 assert_eq!(plan.selected.len(), 1);
6173 assert!(plan.selected[0].starved);
6174 assert!(plan.over_budget);
6175 }
6176
6177 #[test]
6178 fn widget_refresh_budget_blocks_when_no_selection() {
6179 let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
6180 let mut plan = WidgetRefreshPlan::new();
6181 let config = WidgetRefreshConfig {
6182 starve_ms: 0,
6183 max_starved_per_frame: 0,
6184 ..Default::default()
6185 };
6186 plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
6187 let budget = plan.as_budget();
6188 assert!(!budget.allows(42, false));
6189 }
6190
6191 #[test]
6192 fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
6193 let signals = vec![
6194 make_signal(1, false, 0.4, 0, 10.0),
6195 make_signal(2, false, 0.4, 0, 10.0),
6196 make_signal(3, false, 0.4, 0, 10.0),
6197 make_signal(4, false, 0.4, 0, 10.0),
6198 ];
6199 let mut plan = WidgetRefreshPlan::new();
6200 let config = WidgetRefreshConfig {
6201 starve_ms: 0,
6202 max_starved_per_frame: 0,
6203 max_drop_fraction: 0.5,
6204 ..Default::default()
6205 };
6206 plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
6207 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6208 assert_eq!(selected, vec![1, 2]);
6209 }
6210
6211 #[test]
6212 fn widget_refresh_greedy_beats_fifo_and_round_robin() {
6213 let signals = vec![
6214 make_signal(1, false, 0.1, 0, 6.0),
6215 make_signal(2, false, 0.2, 0, 6.0),
6216 make_signal(3, false, 1.0, 0, 4.0),
6217 make_signal(4, false, 0.9, 0, 3.0),
6218 make_signal(5, false, 0.8, 0, 3.0),
6219 make_signal(6, false, 0.1, 4_000, 2.0),
6220 ];
6221 let budget_us = 10.0;
6222 let config = WidgetRefreshConfig::default();
6223
6224 let mut plan = WidgetRefreshPlan::new();
6225 plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
6226 let greedy_value = plan.selected_value;
6227 let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
6228
6229 let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
6230 let rotated = rotate_signals(&signals, 2);
6231 let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
6232
6233 assert!(
6234 greedy_value > fifo_value,
6235 "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
6236 greedy_selected,
6237 fifo_selected
6238 );
6239 assert!(
6240 greedy_value > rr_value,
6241 "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
6242 greedy_selected,
6243 rr_selected
6244 );
6245 assert!(
6246 plan.starved_selected > 0,
6247 "greedy did not select starved widget; greedy={:?}",
6248 greedy_selected
6249 );
6250 }
6251
6252 #[test]
6253 fn widget_refresh_jsonl_contains_required_fields() {
6254 let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
6255 let mut plan = WidgetRefreshPlan::new();
6256 let config = WidgetRefreshConfig::default();
6257 plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
6258 let jsonl = plan.to_jsonl();
6259 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
6260 assert!(jsonl.contains("\"frame_idx\":9"));
6261 assert!(jsonl.contains("\"selected_count\":1"));
6262 assert!(jsonl.contains("\"id\":7"));
6263 }
6264
6265 #[test]
6266 fn program_config_with_resize_coalescer() {
6267 let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
6268 steady_delay_ms: 8,
6269 burst_delay_ms: 20,
6270 hard_deadline_ms: 80,
6271 burst_enter_rate: 12.0,
6272 burst_exit_rate: 6.0,
6273 cooldown_frames: 2,
6274 rate_window_size: 6,
6275 enable_logging: true,
6276 enable_bocpd: false,
6277 bocpd_config: None,
6278 });
6279 assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
6280 assert!(config.resize_coalescer.enable_logging);
6281 }
6282
6283 #[test]
6284 fn program_config_with_resize_behavior() {
6285 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
6286 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
6287 }
6288
6289 #[test]
6290 fn program_config_with_legacy_resize_enabled() {
6291 let config = ProgramConfig::default().with_legacy_resize(true);
6292 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
6293 }
6294
6295 #[test]
6296 fn program_config_with_legacy_resize_disabled_keeps_default() {
6297 let config = ProgramConfig::default().with_legacy_resize(false);
6298 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
6299 }
6300
6301 fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
6302 let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
6303 let mut writer = TerminalWriter::with_diff_config(
6304 Vec::<u8>::new(),
6305 ScreenMode::AltScreen,
6306 UiAnchor::Bottom,
6307 TerminalCapabilities::basic(),
6308 config,
6309 );
6310 writer.set_size(8, 4);
6311
6312 let mut buffer = Buffer::new(8, 4);
6313 let mut trace = Vec::new();
6314
6315 writer.present_ui(&buffer, None, false).unwrap();
6316 trace.push(
6317 writer
6318 .last_diff_strategy()
6319 .unwrap_or(DiffStrategy::FullRedraw),
6320 );
6321
6322 buffer.set_raw(0, 0, Cell::from_char('A'));
6323 writer.present_ui(&buffer, None, false).unwrap();
6324 trace.push(
6325 writer
6326 .last_diff_strategy()
6327 .unwrap_or(DiffStrategy::FullRedraw),
6328 );
6329
6330 buffer.set_raw(1, 1, Cell::from_char('B'));
6331 writer.present_ui(&buffer, None, false).unwrap();
6332 trace.push(
6333 writer
6334 .last_diff_strategy()
6335 .unwrap_or(DiffStrategy::FullRedraw),
6336 );
6337
6338 trace
6339 }
6340
6341 fn coalescer_checksum(enable_bocpd: bool) -> String {
6342 let mut config = CoalescerConfig::default().with_logging(true);
6343 if enable_bocpd {
6344 config = config.with_bocpd();
6345 }
6346
6347 let base = Instant::now();
6348 let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
6349
6350 let events = [
6351 (0_u64, (82_u16, 24_u16)),
6352 (10, (83, 25)),
6353 (20, (84, 26)),
6354 (35, (90, 28)),
6355 (55, (92, 30)),
6356 ];
6357
6358 let mut idx = 0usize;
6359 for t_ms in (0_u64..=160).step_by(8) {
6360 let now = base + Duration::from_millis(t_ms);
6361 while idx < events.len() && events[idx].0 == t_ms {
6362 let (w, h) = events[idx].1;
6363 coalescer.handle_resize_at(w, h, now);
6364 idx += 1;
6365 }
6366 coalescer.tick_at(now);
6367 }
6368
6369 coalescer.decision_checksum_hex()
6370 }
6371
6372 fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
6373 if !enabled {
6374 return Vec::new();
6375 }
6376
6377 let mut predictor = ConformalPredictor::new(ConformalConfig::default());
6378 let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
6379 let mut trace = Vec::new();
6380
6381 for i in 0..30 {
6382 let y_hat = 16_000.0 + (i as f64) * 15.0;
6383 let observed = y_hat + (i % 7) as f64 * 120.0;
6384 predictor.observe(key, y_hat, observed);
6385 let prediction = predictor.predict(key, y_hat, 20_000.0);
6386 trace.push((prediction.upper_us, prediction.risk));
6387 }
6388
6389 trace
6390 }
6391
6392 #[test]
6393 fn policy_toggle_matrix_determinism() {
6394 for &bayesian in &[false, true] {
6395 for &bocpd in &[false, true] {
6396 for &conformal in &[false, true] {
6397 let diff_a = diff_strategy_trace(bayesian);
6398 let diff_b = diff_strategy_trace(bayesian);
6399 assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
6400
6401 let checksum_a = coalescer_checksum(bocpd);
6402 let checksum_b = coalescer_checksum(bocpd);
6403 assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
6404
6405 let conf_a = conformal_trace(conformal);
6406 let conf_b = conformal_trace(conformal);
6407 assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
6408
6409 if conformal {
6410 assert!(!conf_a.is_empty(), "conformal trace should be populated");
6411 } else {
6412 assert!(conf_a.is_empty(), "conformal trace should be empty");
6413 }
6414 }
6415 }
6416 }
6417 }
6418
6419 #[test]
6420 fn resize_behavior_uses_coalescer_flag() {
6421 assert!(ResizeBehavior::Throttled.uses_coalescer());
6422 assert!(!ResizeBehavior::Immediate.uses_coalescer());
6423 }
6424
6425 #[test]
6426 fn nested_cmd_msg_executes_recursively() {
6427 use crate::simulator::ProgramSimulator;
6429
6430 struct NestedModel {
6431 depth: usize,
6432 }
6433
6434 #[derive(Debug)]
6435 enum NestedMsg {
6436 Nest(usize),
6437 }
6438
6439 impl From<Event> for NestedMsg {
6440 fn from(_: Event) -> Self {
6441 NestedMsg::Nest(0)
6442 }
6443 }
6444
6445 impl Model for NestedModel {
6446 type Message = NestedMsg;
6447
6448 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6449 match msg {
6450 NestedMsg::Nest(n) => {
6451 self.depth += 1;
6452 if n > 0 {
6453 Cmd::msg(NestedMsg::Nest(n - 1))
6454 } else {
6455 Cmd::none()
6456 }
6457 }
6458 }
6459 }
6460
6461 fn view(&self, _frame: &mut Frame) {}
6462 }
6463
6464 let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
6465 sim.init();
6466 sim.send(NestedMsg::Nest(3));
6467
6468 assert_eq!(sim.model().depth, 4);
6470 }
6471
6472 #[test]
6473 fn task_executes_synchronously_in_simulator() {
6474 use crate::simulator::ProgramSimulator;
6476
6477 struct TaskModel {
6478 completed: bool,
6479 }
6480
6481 #[derive(Debug)]
6482 enum TaskMsg {
6483 Complete,
6484 SpawnTask,
6485 }
6486
6487 impl From<Event> for TaskMsg {
6488 fn from(_: Event) -> Self {
6489 TaskMsg::Complete
6490 }
6491 }
6492
6493 impl Model for TaskModel {
6494 type Message = TaskMsg;
6495
6496 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6497 match msg {
6498 TaskMsg::Complete => {
6499 self.completed = true;
6500 Cmd::none()
6501 }
6502 TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
6503 }
6504 }
6505
6506 fn view(&self, _frame: &mut Frame) {}
6507 }
6508
6509 let mut sim = ProgramSimulator::new(TaskModel { completed: false });
6510 sim.init();
6511 sim.send(TaskMsg::SpawnTask);
6512
6513 assert!(sim.model().completed);
6515 }
6516
6517 #[test]
6518 fn multiple_updates_accumulate_correctly() {
6519 use crate::simulator::ProgramSimulator;
6521
6522 struct AccumModel {
6523 sum: i32,
6524 }
6525
6526 #[derive(Debug)]
6527 enum AccumMsg {
6528 Add(i32),
6529 Multiply(i32),
6530 }
6531
6532 impl From<Event> for AccumMsg {
6533 fn from(_: Event) -> Self {
6534 AccumMsg::Add(1)
6535 }
6536 }
6537
6538 impl Model for AccumModel {
6539 type Message = AccumMsg;
6540
6541 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6542 match msg {
6543 AccumMsg::Add(n) => {
6544 self.sum += n;
6545 Cmd::none()
6546 }
6547 AccumMsg::Multiply(n) => {
6548 self.sum *= n;
6549 Cmd::none()
6550 }
6551 }
6552 }
6553
6554 fn view(&self, _frame: &mut Frame) {}
6555 }
6556
6557 let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
6558 sim.init();
6559
6560 sim.send(AccumMsg::Add(5));
6562 sim.send(AccumMsg::Multiply(2));
6563 sim.send(AccumMsg::Add(3));
6564
6565 assert_eq!(sim.model().sum, 13);
6566 }
6567
6568 #[test]
6569 fn init_command_executes_before_first_update() {
6570 use crate::simulator::ProgramSimulator;
6572
6573 struct InitModel {
6574 initialized: bool,
6575 updates: usize,
6576 }
6577
6578 #[derive(Debug)]
6579 enum InitMsg {
6580 Update,
6581 MarkInit,
6582 }
6583
6584 impl From<Event> for InitMsg {
6585 fn from(_: Event) -> Self {
6586 InitMsg::Update
6587 }
6588 }
6589
6590 impl Model for InitModel {
6591 type Message = InitMsg;
6592
6593 fn init(&mut self) -> Cmd<Self::Message> {
6594 Cmd::msg(InitMsg::MarkInit)
6595 }
6596
6597 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6598 match msg {
6599 InitMsg::MarkInit => {
6600 self.initialized = true;
6601 Cmd::none()
6602 }
6603 InitMsg::Update => {
6604 self.updates += 1;
6605 Cmd::none()
6606 }
6607 }
6608 }
6609
6610 fn view(&self, _frame: &mut Frame) {}
6611 }
6612
6613 let mut sim = ProgramSimulator::new(InitModel {
6614 initialized: false,
6615 updates: 0,
6616 });
6617 sim.init();
6618
6619 assert!(sim.model().initialized);
6620 sim.send(InitMsg::Update);
6621 assert_eq!(sim.model().updates, 1);
6622 }
6623
6624 #[test]
6629 fn ui_height_returns_correct_value_inline_mode() {
6630 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6632 use ftui_core::terminal_capabilities::TerminalCapabilities;
6633
6634 let output = Vec::new();
6635 let writer = TerminalWriter::new(
6636 output,
6637 ScreenMode::Inline { ui_height: 10 },
6638 UiAnchor::Bottom,
6639 TerminalCapabilities::basic(),
6640 );
6641 assert_eq!(writer.ui_height(), 10);
6642 }
6643
6644 #[test]
6645 fn ui_height_returns_term_height_altscreen_mode() {
6646 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6648 use ftui_core::terminal_capabilities::TerminalCapabilities;
6649
6650 let output = Vec::new();
6651 let mut writer = TerminalWriter::new(
6652 output,
6653 ScreenMode::AltScreen,
6654 UiAnchor::Bottom,
6655 TerminalCapabilities::basic(),
6656 );
6657 writer.set_size(80, 24);
6658 assert_eq!(writer.ui_height(), 24);
6659 }
6660
6661 #[test]
6662 fn inline_mode_frame_uses_ui_height_not_terminal_height() {
6663 use crate::simulator::ProgramSimulator;
6666 use std::cell::Cell as StdCell;
6667
6668 thread_local! {
6669 static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
6670 }
6671
6672 struct FrameSizeTracker;
6673
6674 #[derive(Debug)]
6675 enum SizeMsg {
6676 Check,
6677 }
6678
6679 impl From<Event> for SizeMsg {
6680 fn from(_: Event) -> Self {
6681 SizeMsg::Check
6682 }
6683 }
6684
6685 impl Model for FrameSizeTracker {
6686 type Message = SizeMsg;
6687
6688 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
6689 Cmd::none()
6690 }
6691
6692 fn view(&self, frame: &mut Frame) {
6693 CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
6695 }
6696 }
6697
6698 let mut sim = ProgramSimulator::new(FrameSizeTracker);
6700 sim.init();
6701
6702 let buf = sim.capture_frame(80, 10);
6704 assert_eq!(buf.height(), 10);
6705 assert_eq!(buf.width(), 80);
6706
6707 }
6711
6712 #[test]
6713 fn altscreen_frame_uses_full_terminal_height() {
6714 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6716 use ftui_core::terminal_capabilities::TerminalCapabilities;
6717
6718 let output = Vec::new();
6719 let mut writer = TerminalWriter::new(
6720 output,
6721 ScreenMode::AltScreen,
6722 UiAnchor::Bottom,
6723 TerminalCapabilities::basic(),
6724 );
6725 writer.set_size(80, 40);
6726
6727 assert_eq!(writer.ui_height(), 40);
6729 }
6730
6731 #[test]
6732 fn ui_height_clamped_to_terminal_height() {
6733 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
6736 use ftui_core::terminal_capabilities::TerminalCapabilities;
6737
6738 let output = Vec::new();
6739 let mut writer = TerminalWriter::new(
6740 output,
6741 ScreenMode::Inline { ui_height: 100 },
6742 UiAnchor::Bottom,
6743 TerminalCapabilities::basic(),
6744 );
6745 writer.set_size(80, 10);
6746
6747 assert_eq!(writer.ui_height(), 100);
6753 }
6754
6755 #[test]
6760 fn tick_event_delivered_to_model_update() {
6761 use crate::simulator::ProgramSimulator;
6764
6765 struct TickTracker {
6766 tick_count: usize,
6767 }
6768
6769 #[derive(Debug)]
6770 enum TickMsg {
6771 Tick,
6772 Other,
6773 }
6774
6775 impl From<Event> for TickMsg {
6776 fn from(event: Event) -> Self {
6777 match event {
6778 Event::Tick => TickMsg::Tick,
6779 _ => TickMsg::Other,
6780 }
6781 }
6782 }
6783
6784 impl Model for TickTracker {
6785 type Message = TickMsg;
6786
6787 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6788 match msg {
6789 TickMsg::Tick => {
6790 self.tick_count += 1;
6791 Cmd::none()
6792 }
6793 TickMsg::Other => Cmd::none(),
6794 }
6795 }
6796
6797 fn view(&self, _frame: &mut Frame) {}
6798 }
6799
6800 let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
6801 sim.init();
6802
6803 sim.inject_event(Event::Tick);
6805 assert_eq!(sim.model().tick_count, 1);
6806
6807 sim.inject_event(Event::Tick);
6808 sim.inject_event(Event::Tick);
6809 assert_eq!(sim.model().tick_count, 3);
6810 }
6811
6812 #[test]
6813 fn tick_command_sets_tick_rate() {
6814 use crate::simulator::{CmdRecord, ProgramSimulator};
6816
6817 struct TickModel;
6818
6819 #[derive(Debug)]
6820 enum Msg {
6821 SetTick,
6822 Noop,
6823 }
6824
6825 impl From<Event> for Msg {
6826 fn from(_: Event) -> Self {
6827 Msg::Noop
6828 }
6829 }
6830
6831 impl Model for TickModel {
6832 type Message = Msg;
6833
6834 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6835 match msg {
6836 Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
6837 Msg::Noop => Cmd::none(),
6838 }
6839 }
6840
6841 fn view(&self, _frame: &mut Frame) {}
6842 }
6843
6844 let mut sim = ProgramSimulator::new(TickModel);
6845 sim.init();
6846 sim.send(Msg::SetTick);
6847
6848 let commands = sim.command_log();
6850 assert!(
6851 commands
6852 .iter()
6853 .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
6854 );
6855 }
6856
6857 #[test]
6858 fn tick_can_trigger_further_commands() {
6859 use crate::simulator::ProgramSimulator;
6861
6862 struct ChainModel {
6863 stage: usize,
6864 }
6865
6866 #[derive(Debug)]
6867 enum ChainMsg {
6868 Tick,
6869 Advance,
6870 Noop,
6871 }
6872
6873 impl From<Event> for ChainMsg {
6874 fn from(event: Event) -> Self {
6875 match event {
6876 Event::Tick => ChainMsg::Tick,
6877 _ => ChainMsg::Noop,
6878 }
6879 }
6880 }
6881
6882 impl Model for ChainModel {
6883 type Message = ChainMsg;
6884
6885 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6886 match msg {
6887 ChainMsg::Tick => {
6888 self.stage += 1;
6889 Cmd::msg(ChainMsg::Advance)
6891 }
6892 ChainMsg::Advance => {
6893 self.stage += 10;
6894 Cmd::none()
6895 }
6896 ChainMsg::Noop => Cmd::none(),
6897 }
6898 }
6899
6900 fn view(&self, _frame: &mut Frame) {}
6901 }
6902
6903 let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
6904 sim.init();
6905 sim.inject_event(Event::Tick);
6906
6907 assert_eq!(sim.model().stage, 11);
6909 }
6910
6911 #[test]
6912 fn tick_disabled_with_zero_duration() {
6913 use crate::simulator::ProgramSimulator;
6915
6916 struct ZeroTickModel {
6917 disabled: bool,
6918 }
6919
6920 #[derive(Debug)]
6921 enum ZeroMsg {
6922 DisableTick,
6923 Noop,
6924 }
6925
6926 impl From<Event> for ZeroMsg {
6927 fn from(_: Event) -> Self {
6928 ZeroMsg::Noop
6929 }
6930 }
6931
6932 impl Model for ZeroTickModel {
6933 type Message = ZeroMsg;
6934
6935 fn init(&mut self) -> Cmd<Self::Message> {
6936 Cmd::tick(Duration::from_millis(100))
6938 }
6939
6940 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
6941 match msg {
6942 ZeroMsg::DisableTick => {
6943 self.disabled = true;
6944 Cmd::tick(Duration::ZERO)
6946 }
6947 ZeroMsg::Noop => Cmd::none(),
6948 }
6949 }
6950
6951 fn view(&self, _frame: &mut Frame) {}
6952 }
6953
6954 let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
6955 sim.init();
6956
6957 assert!(sim.tick_rate().is_some());
6959 assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
6960
6961 sim.send(ZeroMsg::DisableTick);
6963 assert!(sim.model().disabled);
6964
6965 assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
6968 }
6969
6970 #[test]
6971 fn tick_event_distinguishable_from_other_events() {
6972 let tick = Event::Tick;
6974 let key = Event::Key(ftui_core::event::KeyEvent::new(
6975 ftui_core::event::KeyCode::Char('a'),
6976 ));
6977
6978 assert!(matches!(tick, Event::Tick));
6979 assert!(!matches!(key, Event::Tick));
6980 }
6981
6982 #[test]
6983 fn tick_event_clone_and_eq() {
6984 let tick1 = Event::Tick;
6986 let tick2 = tick1.clone();
6987 assert_eq!(tick1, tick2);
6988 }
6989
6990 #[test]
6991 fn model_receives_tick_and_input_events() {
6992 use crate::simulator::ProgramSimulator;
6994
6995 struct MixedModel {
6996 ticks: usize,
6997 keys: usize,
6998 }
6999
7000 #[derive(Debug)]
7001 enum MixedMsg {
7002 Tick,
7003 Key,
7004 }
7005
7006 impl From<Event> for MixedMsg {
7007 fn from(event: Event) -> Self {
7008 match event {
7009 Event::Tick => MixedMsg::Tick,
7010 _ => MixedMsg::Key,
7011 }
7012 }
7013 }
7014
7015 impl Model for MixedModel {
7016 type Message = MixedMsg;
7017
7018 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7019 match msg {
7020 MixedMsg::Tick => {
7021 self.ticks += 1;
7022 Cmd::none()
7023 }
7024 MixedMsg::Key => {
7025 self.keys += 1;
7026 Cmd::none()
7027 }
7028 }
7029 }
7030
7031 fn view(&self, _frame: &mut Frame) {}
7032 }
7033
7034 let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
7035 sim.init();
7036
7037 sim.inject_event(Event::Tick);
7039 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
7040 ftui_core::event::KeyCode::Char('a'),
7041 )));
7042 sim.inject_event(Event::Tick);
7043 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
7044 ftui_core::event::KeyCode::Char('b'),
7045 )));
7046 sim.inject_event(Event::Tick);
7047
7048 assert_eq!(sim.model().ticks, 3);
7049 assert_eq!(sim.model().keys, 2);
7050 }
7051
7052 fn headless_program_with_config<M: Model>(
7057 model: M,
7058 config: ProgramConfig,
7059 ) -> Program<M, HeadlessEventSource, Vec<u8>>
7060 where
7061 M::Message: Send + 'static,
7062 {
7063 let capabilities = TerminalCapabilities::basic();
7064 let mut writer = TerminalWriter::with_diff_config(
7065 Vec::new(),
7066 config.screen_mode,
7067 config.ui_anchor,
7068 capabilities,
7069 config.diff_config.clone(),
7070 );
7071 let frame_timing = config.frame_timing.clone();
7072 writer.set_timing_enabled(frame_timing.is_some());
7073
7074 let (width, height) = config.forced_size.unwrap_or((80, 24));
7075 let width = width.max(1);
7076 let height = height.max(1);
7077 writer.set_size(width, height);
7078
7079 let mouse_capture = config.resolved_mouse_capture();
7080 let initial_features = BackendFeatures {
7081 mouse_capture,
7082 bracketed_paste: config.bracketed_paste,
7083 focus_events: config.focus_reporting,
7084 kitty_keyboard: config.kitty_keyboard,
7085 };
7086 let events = HeadlessEventSource::new(width, height, initial_features);
7087
7088 let budget = RenderBudget::from_config(&config.budget);
7089 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
7090 let locale_context = config.locale_context.clone();
7091 let locale_version = locale_context.version();
7092 let resize_coalescer =
7093 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
7094 let subscriptions = SubscriptionManager::new();
7095 let (task_sender, task_receiver) = std::sync::mpsc::channel();
7096 let inline_auto_remeasure = config
7097 .inline_auto_remeasure
7098 .clone()
7099 .map(InlineAutoRemeasureState::new);
7100
7101 Program {
7102 model,
7103 writer,
7104 events,
7105 backend_features: initial_features,
7106 running: true,
7107 tick_rate: None,
7108 last_tick: Instant::now(),
7109 dirty: true,
7110 frame_idx: 0,
7111 widget_signals: Vec::new(),
7112 widget_refresh_config: config.widget_refresh,
7113 widget_refresh_plan: WidgetRefreshPlan::new(),
7114 width,
7115 height,
7116 forced_size: config.forced_size,
7117 poll_timeout: config.poll_timeout,
7118 budget,
7119 conformal_predictor,
7120 last_frame_time_us: None,
7121 last_update_us: None,
7122 frame_timing,
7123 locale_context,
7124 locale_version,
7125 resize_coalescer,
7126 evidence_sink: None,
7127 fairness_config_logged: false,
7128 resize_behavior: config.resize_behavior,
7129 fairness_guard: InputFairnessGuard::new(),
7130 event_recorder: None,
7131 subscriptions,
7132 task_sender,
7133 task_receiver,
7134 task_handles: Vec::new(),
7135 effect_queue: None,
7136 state_registry: config.persistence.registry.clone(),
7137 persistence_config: config.persistence,
7138 last_checkpoint: Instant::now(),
7139 inline_auto_remeasure,
7140 frame_arena: FrameArena::default(),
7141 }
7142 }
7143
7144 fn temp_evidence_path(label: &str) -> PathBuf {
7145 static COUNTER: AtomicUsize = AtomicUsize::new(0);
7146 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
7147 let pid = std::process::id();
7148 let mut path = std::env::temp_dir();
7149 path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
7150 path
7151 }
7152
7153 fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
7154 let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
7155 let needle = format!("\"event\":\"{event}\"");
7156 let missing_msg = format!("missing {event} line");
7157 let line = jsonl
7158 .lines()
7159 .find(|line| line.contains(&needle))
7160 .expect(&missing_msg);
7161 serde_json::from_str(line).expect("valid evidence json")
7162 }
7163
7164 #[test]
7165 fn headless_apply_resize_updates_model_and_dimensions() {
7166 struct ResizeModel {
7167 last_size: Option<(u16, u16)>,
7168 }
7169
7170 #[derive(Debug)]
7171 enum ResizeMsg {
7172 Resize(u16, u16),
7173 Other,
7174 }
7175
7176 impl From<Event> for ResizeMsg {
7177 fn from(event: Event) -> Self {
7178 match event {
7179 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
7180 _ => ResizeMsg::Other,
7181 }
7182 }
7183 }
7184
7185 impl Model for ResizeModel {
7186 type Message = ResizeMsg;
7187
7188 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7189 if let ResizeMsg::Resize(w, h) = msg {
7190 self.last_size = Some((w, h));
7191 }
7192 Cmd::none()
7193 }
7194
7195 fn view(&self, _frame: &mut Frame) {}
7196 }
7197
7198 let mut program =
7199 headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
7200 program.dirty = false;
7201
7202 program
7203 .apply_resize(0, 0, Duration::ZERO, false)
7204 .expect("resize");
7205
7206 assert_eq!(program.width, 1);
7207 assert_eq!(program.height, 1);
7208 assert_eq!(program.model().last_size, Some((1, 1)));
7209 assert!(program.dirty);
7210 }
7211
7212 #[test]
7213 fn headless_execute_cmd_log_writes_output() {
7214 let mut program =
7215 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7216 program.execute_cmd(Cmd::log("hello world")).expect("log");
7217
7218 let bytes = program.writer.into_inner().expect("writer output");
7219 let output = String::from_utf8_lossy(&bytes);
7220 assert!(output.contains("hello world"));
7221 }
7222
7223 #[test]
7224 fn headless_process_task_results_updates_model() {
7225 struct TaskModel {
7226 updates: usize,
7227 }
7228
7229 #[derive(Debug)]
7230 enum TaskMsg {
7231 Done,
7232 }
7233
7234 impl From<Event> for TaskMsg {
7235 fn from(_: Event) -> Self {
7236 TaskMsg::Done
7237 }
7238 }
7239
7240 impl Model for TaskModel {
7241 type Message = TaskMsg;
7242
7243 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7244 self.updates += 1;
7245 Cmd::none()
7246 }
7247
7248 fn view(&self, _frame: &mut Frame) {}
7249 }
7250
7251 let mut program =
7252 headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
7253 program.dirty = false;
7254 program.task_sender.send(TaskMsg::Done).unwrap();
7255
7256 program
7257 .process_task_results()
7258 .expect("process task results");
7259 assert_eq!(program.model().updates, 1);
7260 assert!(program.dirty);
7261 }
7262
7263 #[test]
7264 fn headless_should_tick_and_timeout_behaviors() {
7265 let mut program =
7266 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7267 program.tick_rate = Some(Duration::from_millis(5));
7268 program.last_tick = Instant::now() - Duration::from_millis(10);
7269
7270 assert!(program.should_tick());
7271 assert!(!program.should_tick());
7272
7273 let timeout = program.effective_timeout();
7274 assert!(timeout <= Duration::from_millis(5));
7275
7276 program.tick_rate = None;
7277 program.poll_timeout = Duration::from_millis(33);
7278 assert_eq!(program.effective_timeout(), Duration::from_millis(33));
7279 }
7280
7281 #[test]
7282 fn headless_effective_timeout_respects_resize_coalescer() {
7283 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
7284 config.resize_coalescer.steady_delay_ms = 0;
7285 config.resize_coalescer.burst_delay_ms = 0;
7286
7287 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7288 program.tick_rate = Some(Duration::from_millis(50));
7289
7290 program.resize_coalescer.handle_resize(120, 40);
7291 assert!(program.resize_coalescer.has_pending());
7292
7293 let timeout = program.effective_timeout();
7294 assert_eq!(timeout, Duration::ZERO);
7295 }
7296
7297 #[test]
7298 fn headless_ui_height_remeasure_clears_auto_height() {
7299 let mut config = ProgramConfig::inline_auto(2, 6);
7300 config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
7301
7302 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7303 program.dirty = false;
7304 program.writer.set_auto_ui_height(5);
7305
7306 assert_eq!(program.writer.auto_ui_height(), Some(5));
7307 program.request_ui_height_remeasure();
7308
7309 assert_eq!(program.writer.auto_ui_height(), None);
7310 assert!(program.dirty);
7311 }
7312
7313 #[test]
7314 fn headless_recording_lifecycle_and_locale_change() {
7315 let mut program =
7316 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
7317 program.dirty = false;
7318
7319 program.start_recording("demo");
7320 assert!(program.is_recording());
7321 let recorded = program.stop_recording();
7322 assert!(recorded.is_some());
7323 assert!(!program.is_recording());
7324
7325 let prev_dirty = program.dirty;
7326 program.locale_context.set_locale("fr");
7327 program.check_locale_change();
7328 assert!(program.dirty || prev_dirty);
7329 }
7330
7331 #[test]
7332 fn headless_render_frame_marks_clean_and_sets_diff() {
7333 struct RenderModel;
7334
7335 #[derive(Debug)]
7336 enum RenderMsg {
7337 Noop,
7338 }
7339
7340 impl From<Event> for RenderMsg {
7341 fn from(_: Event) -> Self {
7342 RenderMsg::Noop
7343 }
7344 }
7345
7346 impl Model for RenderModel {
7347 type Message = RenderMsg;
7348
7349 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7350 Cmd::none()
7351 }
7352
7353 fn view(&self, frame: &mut Frame) {
7354 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
7355 }
7356 }
7357
7358 let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
7359 program.render_frame().expect("render frame");
7360
7361 assert!(!program.dirty);
7362 assert!(program.writer.last_diff_strategy().is_some());
7363 assert_eq!(program.frame_idx, 1);
7364 }
7365
7366 #[test]
7367 fn headless_render_frame_skips_when_budget_exhausted() {
7368 let config = ProgramConfig {
7369 budget: FrameBudgetConfig::with_total(Duration::ZERO),
7370 ..Default::default()
7371 };
7372
7373 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7374 program.render_frame().expect("render frame");
7375
7376 assert!(!program.dirty);
7377 assert_eq!(program.frame_idx, 1);
7378 }
7379
7380 #[test]
7381 fn headless_render_frame_emits_budget_evidence_with_controller() {
7382 use ftui_render::budget::BudgetControllerConfig;
7383
7384 struct RenderModel;
7385
7386 #[derive(Debug)]
7387 enum RenderMsg {
7388 Noop,
7389 }
7390
7391 impl From<Event> for RenderMsg {
7392 fn from(_: Event) -> Self {
7393 RenderMsg::Noop
7394 }
7395 }
7396
7397 impl Model for RenderModel {
7398 type Message = RenderMsg;
7399
7400 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
7401 Cmd::none()
7402 }
7403
7404 fn view(&self, frame: &mut Frame) {
7405 frame.buffer.set_raw(0, 0, Cell::from_char('E'));
7406 }
7407 }
7408
7409 let config =
7410 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
7411 let mut program = headless_program_with_config(RenderModel, config);
7412 program.budget = program
7413 .budget
7414 .with_controller(BudgetControllerConfig::default());
7415
7416 program.render_frame().expect("render frame");
7417 assert!(program.budget.telemetry().is_some());
7418 assert_eq!(program.frame_idx, 1);
7419 }
7420
7421 #[test]
7422 fn headless_handle_event_updates_model() {
7423 struct EventModel {
7424 events: usize,
7425 last_resize: Option<(u16, u16)>,
7426 }
7427
7428 #[derive(Debug)]
7429 enum EventMsg {
7430 Resize(u16, u16),
7431 Other,
7432 }
7433
7434 impl From<Event> for EventMsg {
7435 fn from(event: Event) -> Self {
7436 match event {
7437 Event::Resize { width, height } => EventMsg::Resize(width, height),
7438 _ => EventMsg::Other,
7439 }
7440 }
7441 }
7442
7443 impl Model for EventModel {
7444 type Message = EventMsg;
7445
7446 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7447 self.events += 1;
7448 if let EventMsg::Resize(w, h) = msg {
7449 self.last_resize = Some((w, h));
7450 }
7451 Cmd::none()
7452 }
7453
7454 fn view(&self, _frame: &mut Frame) {}
7455 }
7456
7457 let mut program = headless_program_with_config(
7458 EventModel {
7459 events: 0,
7460 last_resize: None,
7461 },
7462 ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
7463 );
7464
7465 program
7466 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
7467 ftui_core::event::KeyCode::Char('x'),
7468 )))
7469 .expect("handle key");
7470 assert_eq!(program.model().events, 1);
7471
7472 program
7473 .handle_event(Event::Resize {
7474 width: 10,
7475 height: 5,
7476 })
7477 .expect("handle resize");
7478 assert_eq!(program.model().events, 2);
7479 assert_eq!(program.model().last_resize, Some((10, 5)));
7480 assert_eq!(program.width, 10);
7481 assert_eq!(program.height, 5);
7482 }
7483
7484 #[test]
7485 fn headless_handle_resize_ignored_when_forced_size() {
7486 struct ResizeModel {
7487 resized: bool,
7488 }
7489
7490 #[derive(Debug)]
7491 enum ResizeMsg {
7492 Resize,
7493 Other,
7494 }
7495
7496 impl From<Event> for ResizeMsg {
7497 fn from(event: Event) -> Self {
7498 match event {
7499 Event::Resize { .. } => ResizeMsg::Resize,
7500 _ => ResizeMsg::Other,
7501 }
7502 }
7503 }
7504
7505 impl Model for ResizeModel {
7506 type Message = ResizeMsg;
7507
7508 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7509 if matches!(msg, ResizeMsg::Resize) {
7510 self.resized = true;
7511 }
7512 Cmd::none()
7513 }
7514
7515 fn view(&self, _frame: &mut Frame) {}
7516 }
7517
7518 let config = ProgramConfig::default().with_forced_size(80, 24);
7519 let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
7520
7521 program
7522 .handle_event(Event::Resize {
7523 width: 120,
7524 height: 40,
7525 })
7526 .expect("handle resize");
7527
7528 assert_eq!(program.width, 80);
7529 assert_eq!(program.height, 24);
7530 assert!(!program.model().resized);
7531 }
7532
7533 #[test]
7534 fn headless_execute_cmd_batch_sequence_and_quit() {
7535 struct BatchModel {
7536 count: usize,
7537 }
7538
7539 #[derive(Debug)]
7540 enum BatchMsg {
7541 Inc,
7542 }
7543
7544 impl From<Event> for BatchMsg {
7545 fn from(_: Event) -> Self {
7546 BatchMsg::Inc
7547 }
7548 }
7549
7550 impl Model for BatchModel {
7551 type Message = BatchMsg;
7552
7553 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7554 match msg {
7555 BatchMsg::Inc => {
7556 self.count += 1;
7557 Cmd::none()
7558 }
7559 }
7560 }
7561
7562 fn view(&self, _frame: &mut Frame) {}
7563 }
7564
7565 let mut program =
7566 headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
7567
7568 program
7569 .execute_cmd(Cmd::Batch(vec![
7570 Cmd::msg(BatchMsg::Inc),
7571 Cmd::Sequence(vec![
7572 Cmd::msg(BatchMsg::Inc),
7573 Cmd::quit(),
7574 Cmd::msg(BatchMsg::Inc),
7575 ]),
7576 ]))
7577 .expect("batch cmd");
7578
7579 assert_eq!(program.model().count, 2);
7580 assert!(!program.running);
7581 }
7582
7583 #[test]
7584 fn headless_process_subscription_messages_updates_model() {
7585 use crate::subscription::{StopSignal, SubId, Subscription};
7586
7587 struct SubModel {
7588 pings: usize,
7589 ready_tx: mpsc::Sender<()>,
7590 }
7591
7592 #[derive(Debug)]
7593 enum SubMsg {
7594 Ping,
7595 Other,
7596 }
7597
7598 impl From<Event> for SubMsg {
7599 fn from(_: Event) -> Self {
7600 SubMsg::Other
7601 }
7602 }
7603
7604 impl Model for SubModel {
7605 type Message = SubMsg;
7606
7607 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7608 if let SubMsg::Ping = msg {
7609 self.pings += 1;
7610 }
7611 Cmd::none()
7612 }
7613
7614 fn view(&self, _frame: &mut Frame) {}
7615
7616 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
7617 vec![Box::new(TestSubscription {
7618 ready_tx: self.ready_tx.clone(),
7619 })]
7620 }
7621 }
7622
7623 struct TestSubscription {
7624 ready_tx: mpsc::Sender<()>,
7625 }
7626
7627 impl Subscription<SubMsg> for TestSubscription {
7628 fn id(&self) -> SubId {
7629 1
7630 }
7631
7632 fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
7633 let _ = sender.send(SubMsg::Ping);
7634 let _ = self.ready_tx.send(());
7635 }
7636 }
7637
7638 let (ready_tx, ready_rx) = mpsc::channel();
7639 let mut program =
7640 headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
7641
7642 program.reconcile_subscriptions();
7643 ready_rx
7644 .recv_timeout(Duration::from_millis(200))
7645 .expect("subscription started");
7646 program
7647 .process_subscription_messages()
7648 .expect("process subscriptions");
7649
7650 assert_eq!(program.model().pings, 1);
7651 }
7652
7653 #[test]
7654 fn headless_execute_cmd_task_spawns_and_reaps() {
7655 struct TaskModel {
7656 done: bool,
7657 }
7658
7659 #[derive(Debug)]
7660 enum TaskMsg {
7661 Done,
7662 }
7663
7664 impl From<Event> for TaskMsg {
7665 fn from(_: Event) -> Self {
7666 TaskMsg::Done
7667 }
7668 }
7669
7670 impl Model for TaskModel {
7671 type Message = TaskMsg;
7672
7673 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7674 match msg {
7675 TaskMsg::Done => {
7676 self.done = true;
7677 Cmd::none()
7678 }
7679 }
7680 }
7681
7682 fn view(&self, _frame: &mut Frame) {}
7683 }
7684
7685 let mut program =
7686 headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
7687 program
7688 .execute_cmd(Cmd::task(|| TaskMsg::Done))
7689 .expect("task cmd");
7690
7691 let deadline = Instant::now() + Duration::from_millis(200);
7692 while !program.model().done && Instant::now() <= deadline {
7693 program
7694 .process_task_results()
7695 .expect("process task results");
7696 program.reap_finished_tasks();
7697 }
7698
7699 assert!(program.model().done, "task result did not arrive in time");
7700 }
7701
7702 #[test]
7703 fn headless_persistence_commands_with_registry() {
7704 use crate::state_persistence::{MemoryStorage, StateRegistry};
7705 use std::sync::Arc;
7706
7707 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
7708 let config = ProgramConfig::default().with_registry(registry.clone());
7709 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
7710
7711 assert!(program.has_persistence());
7712 assert!(program.state_registry().is_some());
7713
7714 program.execute_cmd(Cmd::save_state()).expect("save");
7715 program.execute_cmd(Cmd::restore_state()).expect("restore");
7716
7717 let saved = program.trigger_save().expect("trigger save");
7718 let loaded = program.trigger_load().expect("trigger load");
7719 assert!(!saved);
7720 assert_eq!(loaded, 0);
7721 }
7722
7723 #[test]
7724 fn headless_process_resize_coalescer_applies_pending_resize() {
7725 struct ResizeModel {
7726 last_size: Option<(u16, u16)>,
7727 }
7728
7729 #[derive(Debug)]
7730 enum ResizeMsg {
7731 Resize(u16, u16),
7732 Other,
7733 }
7734
7735 impl From<Event> for ResizeMsg {
7736 fn from(event: Event) -> Self {
7737 match event {
7738 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
7739 _ => ResizeMsg::Other,
7740 }
7741 }
7742 }
7743
7744 impl Model for ResizeModel {
7745 type Message = ResizeMsg;
7746
7747 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7748 if let ResizeMsg::Resize(w, h) = msg {
7749 self.last_size = Some((w, h));
7750 }
7751 Cmd::none()
7752 }
7753
7754 fn view(&self, _frame: &mut Frame) {}
7755 }
7756
7757 let evidence_path = temp_evidence_path("fairness_allow");
7758 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
7759 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
7760 config.resize_coalescer.steady_delay_ms = 0;
7761 config.resize_coalescer.burst_delay_ms = 0;
7762 config.resize_coalescer.hard_deadline_ms = 1_000;
7763 config.evidence_sink = sink_config.clone();
7764
7765 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
7766 let sink = EvidenceSink::from_config(&sink_config)
7767 .expect("evidence sink config")
7768 .expect("evidence sink enabled");
7769 program.evidence_sink = Some(sink);
7770
7771 program.resize_coalescer.handle_resize(120, 40);
7772 assert!(program.resize_coalescer.has_pending());
7773
7774 program
7775 .process_resize_coalescer()
7776 .expect("process resize coalescer");
7777
7778 assert_eq!(program.width, 120);
7779 assert_eq!(program.height, 40);
7780 assert_eq!(program.model().last_size, Some((120, 40)));
7781
7782 let config_line = read_evidence_event(&evidence_path, "fairness_config");
7783 assert_eq!(config_line["event"], "fairness_config");
7784 assert!(config_line["enabled"].is_boolean());
7785 assert!(config_line["input_priority_threshold_ms"].is_number());
7786 assert!(config_line["dominance_threshold"].is_number());
7787 assert!(config_line["fairness_threshold"].is_number());
7788
7789 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
7790 assert_eq!(decision_line["event"], "fairness_decision");
7791 assert_eq!(decision_line["decision"], "allow");
7792 assert_eq!(decision_line["reason"], "none");
7793 assert!(decision_line["pending_input_latency_ms"].is_null());
7794 assert!(decision_line["jain_index"].is_number());
7795 assert!(decision_line["resize_dominance_count"].is_number());
7796 assert!(decision_line["dominance_threshold"].is_number());
7797 assert!(decision_line["fairness_threshold"].is_number());
7798 assert!(decision_line["input_priority_threshold_ms"].is_number());
7799 }
7800
7801 #[test]
7802 fn headless_process_resize_coalescer_yields_to_input() {
7803 struct ResizeModel {
7804 last_size: Option<(u16, u16)>,
7805 }
7806
7807 #[derive(Debug)]
7808 enum ResizeMsg {
7809 Resize(u16, u16),
7810 Other,
7811 }
7812
7813 impl From<Event> for ResizeMsg {
7814 fn from(event: Event) -> Self {
7815 match event {
7816 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
7817 _ => ResizeMsg::Other,
7818 }
7819 }
7820 }
7821
7822 impl Model for ResizeModel {
7823 type Message = ResizeMsg;
7824
7825 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7826 if let ResizeMsg::Resize(w, h) = msg {
7827 self.last_size = Some((w, h));
7828 }
7829 Cmd::none()
7830 }
7831
7832 fn view(&self, _frame: &mut Frame) {}
7833 }
7834
7835 let evidence_path = temp_evidence_path("fairness_yield");
7836 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
7837 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
7838 config.resize_coalescer.steady_delay_ms = 0;
7839 config.resize_coalescer.burst_delay_ms = 0;
7840 config.resize_coalescer.hard_deadline_ms = 10_000;
7843 config.evidence_sink = sink_config.clone();
7844
7845 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
7846 let sink = EvidenceSink::from_config(&sink_config)
7847 .expect("evidence sink config")
7848 .expect("evidence sink enabled");
7849 program.evidence_sink = Some(sink);
7850
7851 program.fairness_guard = InputFairnessGuard::with_config(
7852 crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
7853 );
7854 program
7855 .fairness_guard
7856 .input_arrived(Instant::now() - Duration::from_millis(1));
7857
7858 program.resize_coalescer.handle_resize(120, 40);
7859 assert!(program.resize_coalescer.has_pending());
7860
7861 program
7862 .process_resize_coalescer()
7863 .expect("process resize coalescer");
7864
7865 assert_eq!(program.width, 80);
7866 assert_eq!(program.height, 24);
7867 assert_eq!(program.model().last_size, None);
7868 assert!(program.resize_coalescer.has_pending());
7869
7870 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
7871 assert_eq!(decision_line["event"], "fairness_decision");
7872 assert_eq!(decision_line["decision"], "yield");
7873 assert_eq!(decision_line["reason"], "input_latency");
7874 assert!(decision_line["pending_input_latency_ms"].is_number());
7875 assert!(decision_line["jain_index"].is_number());
7876 assert!(decision_line["resize_dominance_count"].is_number());
7877 assert!(decision_line["dominance_threshold"].is_number());
7878 assert!(decision_line["fairness_threshold"].is_number());
7879 assert!(decision_line["input_priority_threshold_ms"].is_number());
7880 }
7881
7882 #[test]
7883 fn headless_execute_cmd_task_with_effect_queue() {
7884 struct TaskModel {
7885 done: bool,
7886 }
7887
7888 #[derive(Debug)]
7889 enum TaskMsg {
7890 Done,
7891 }
7892
7893 impl From<Event> for TaskMsg {
7894 fn from(_: Event) -> Self {
7895 TaskMsg::Done
7896 }
7897 }
7898
7899 impl Model for TaskModel {
7900 type Message = TaskMsg;
7901
7902 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
7903 match msg {
7904 TaskMsg::Done => {
7905 self.done = true;
7906 Cmd::none()
7907 }
7908 }
7909 }
7910
7911 fn view(&self, _frame: &mut Frame) {}
7912 }
7913
7914 let effect_queue = EffectQueueConfig {
7915 enabled: true,
7916 scheduler: SchedulerConfig {
7917 max_queue_size: 0,
7918 ..Default::default()
7919 },
7920 };
7921 let config = ProgramConfig::default().with_effect_queue(effect_queue);
7922 let mut program = headless_program_with_config(TaskModel { done: false }, config);
7923
7924 program
7925 .execute_cmd(Cmd::task(|| TaskMsg::Done))
7926 .expect("task cmd");
7927
7928 let deadline = Instant::now() + Duration::from_millis(200);
7929 while !program.model().done && Instant::now() <= deadline {
7930 program
7931 .process_task_results()
7932 .expect("process task results");
7933 }
7934
7935 assert!(
7936 program.model().done,
7937 "effect queue task result did not arrive in time"
7938 );
7939 }
7940
7941 #[test]
7946 fn unit_tau_monotone() {
7947 let mut bc = BatchController::new();
7950
7951 bc.observe_service(Duration::from_millis(20));
7953 bc.observe_service(Duration::from_millis(20));
7954 bc.observe_service(Duration::from_millis(20));
7955 let tau_high = bc.tau_s();
7956
7957 for _ in 0..20 {
7959 bc.observe_service(Duration::from_millis(1));
7960 }
7961 let tau_low = bc.tau_s();
7962
7963 assert!(
7964 tau_low <= tau_high,
7965 "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
7966 );
7967 }
7968
7969 #[test]
7970 fn unit_tau_monotone_lambda() {
7971 let mut bc = BatchController::new();
7975 let base = Instant::now();
7976
7977 for i in 0..10 {
7979 bc.observe_arrival(base + Duration::from_millis(i * 10));
7980 }
7981 let rho_fast = bc.rho_est();
7982
7983 for i in 10..20 {
7985 bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
7986 }
7987 let rho_slow = bc.rho_est();
7988
7989 assert!(
7990 rho_slow < rho_fast,
7991 "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
7992 );
7993 }
7994
7995 #[test]
7996 fn unit_stability() {
7997 let mut bc = BatchController::new();
7999 let base = Instant::now();
8000
8001 for i in 0..30 {
8003 bc.observe_arrival(base + Duration::from_millis(i * 33));
8004 bc.observe_service(Duration::from_millis(5)); }
8006
8007 assert!(
8008 bc.is_stable(),
8009 "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
8010 bc.rho_est()
8011 );
8012 assert!(
8013 bc.rho_est() < 1.0,
8014 "utilization should be < 1: ρ={:.4}",
8015 bc.rho_est()
8016 );
8017
8018 assert!(
8020 bc.tau_s() > bc.service_est_s(),
8021 "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
8022 bc.tau_s(),
8023 bc.service_est_s()
8024 );
8025 }
8026
8027 #[test]
8028 fn unit_stability_high_load() {
8029 let mut bc = BatchController::new();
8031 let base = Instant::now();
8032
8033 for i in 0..50 {
8035 bc.observe_arrival(base + Duration::from_millis(i * 10));
8036 bc.observe_service(Duration::from_millis(8));
8037 }
8038
8039 let tau = bc.tau_s();
8041 let rho_eff = bc.service_est_s() / tau;
8042 assert!(
8043 rho_eff < 1.0,
8044 "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
8045 bc.service_est_s()
8046 );
8047 }
8048
8049 #[test]
8050 fn batch_controller_defaults() {
8051 let bc = BatchController::new();
8052 assert!(bc.tau_s() >= bc.tau_min_s);
8053 assert!(bc.tau_s() <= bc.tau_max_s);
8054 assert_eq!(bc.observations(), 0);
8055 assert!(bc.is_stable());
8056 }
8057
8058 #[test]
8059 fn batch_controller_tau_clamped() {
8060 let mut bc = BatchController::new();
8061
8062 for _ in 0..20 {
8064 bc.observe_service(Duration::from_micros(10));
8065 }
8066 assert!(
8067 bc.tau_s() >= bc.tau_min_s,
8068 "τ should be >= tau_min: τ={:.6}, min={:.6}",
8069 bc.tau_s(),
8070 bc.tau_min_s
8071 );
8072
8073 for _ in 0..20 {
8075 bc.observe_service(Duration::from_millis(100));
8076 }
8077 assert!(
8078 bc.tau_s() <= bc.tau_max_s,
8079 "τ should be <= tau_max: τ={:.6}, max={:.6}",
8080 bc.tau_s(),
8081 bc.tau_max_s
8082 );
8083 }
8084
8085 #[test]
8086 fn batch_controller_duration_conversion() {
8087 let bc = BatchController::new();
8088 let tau = bc.tau();
8089 let tau_s = bc.tau_s();
8090 let diff = (tau.as_secs_f64() - tau_s).abs();
8092 assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
8093 }
8094
8095 #[test]
8096 fn batch_controller_lambda_estimation() {
8097 let mut bc = BatchController::new();
8098 let base = Instant::now();
8099
8100 for i in 0..20 {
8102 bc.observe_arrival(base + Duration::from_millis(i * 20));
8103 }
8104
8105 let lambda = bc.lambda_est();
8107 assert!(
8108 lambda > 20.0 && lambda < 100.0,
8109 "λ should be near 50: got {lambda:.1}"
8110 );
8111 }
8112
8113 #[test]
8118 fn cmd_save_state() {
8119 let cmd: Cmd<TestMsg> = Cmd::save_state();
8120 assert!(matches!(cmd, Cmd::SaveState));
8121 }
8122
8123 #[test]
8124 fn cmd_restore_state() {
8125 let cmd: Cmd<TestMsg> = Cmd::restore_state();
8126 assert!(matches!(cmd, Cmd::RestoreState));
8127 }
8128
8129 #[test]
8130 fn persistence_config_default() {
8131 let config = PersistenceConfig::default();
8132 assert!(config.registry.is_none());
8133 assert!(config.checkpoint_interval.is_none());
8134 assert!(config.auto_load);
8135 assert!(config.auto_save);
8136 }
8137
8138 #[test]
8139 fn persistence_config_disabled() {
8140 let config = PersistenceConfig::disabled();
8141 assert!(config.registry.is_none());
8142 }
8143
8144 #[test]
8145 fn persistence_config_with_registry() {
8146 use crate::state_persistence::{MemoryStorage, StateRegistry};
8147 use std::sync::Arc;
8148
8149 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8150 let config = PersistenceConfig::with_registry(registry.clone());
8151
8152 assert!(config.registry.is_some());
8153 assert!(config.auto_load);
8154 assert!(config.auto_save);
8155 }
8156
8157 #[test]
8158 fn persistence_config_checkpoint_interval() {
8159 use crate::state_persistence::{MemoryStorage, StateRegistry};
8160 use std::sync::Arc;
8161
8162 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8163 let config = PersistenceConfig::with_registry(registry)
8164 .checkpoint_every(Duration::from_secs(30))
8165 .auto_load(false)
8166 .auto_save(true);
8167
8168 assert!(config.checkpoint_interval.is_some());
8169 assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
8170 assert!(!config.auto_load);
8171 assert!(config.auto_save);
8172 }
8173
8174 #[test]
8175 fn program_config_with_persistence() {
8176 use crate::state_persistence::{MemoryStorage, StateRegistry};
8177 use std::sync::Arc;
8178
8179 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
8180 let config = ProgramConfig::default().with_registry(registry);
8181
8182 assert!(config.persistence.registry.is_some());
8183 }
8184
8185 #[test]
8190 fn task_spec_default() {
8191 let spec = TaskSpec::default();
8192 assert_eq!(spec.weight, DEFAULT_TASK_WEIGHT);
8193 assert_eq!(spec.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
8194 assert!(spec.name.is_none());
8195 }
8196
8197 #[test]
8198 fn task_spec_new() {
8199 let spec = TaskSpec::new(5.0, 20.0);
8200 assert_eq!(spec.weight, 5.0);
8201 assert_eq!(spec.estimate_ms, 20.0);
8202 assert!(spec.name.is_none());
8203 }
8204
8205 #[test]
8206 fn task_spec_with_name() {
8207 let spec = TaskSpec::default().with_name("fetch_data");
8208 assert_eq!(spec.name.as_deref(), Some("fetch_data"));
8209 }
8210
8211 #[test]
8212 fn task_spec_debug() {
8213 let spec = TaskSpec::new(2.0, 15.0).with_name("test");
8214 let debug = format!("{spec:?}");
8215 assert!(debug.contains("2.0"));
8216 assert!(debug.contains("15.0"));
8217 assert!(debug.contains("test"));
8218 }
8219
8220 #[test]
8225 fn cmd_count_none() {
8226 let cmd: Cmd<TestMsg> = Cmd::none();
8227 assert_eq!(cmd.count(), 0);
8228 }
8229
8230 #[test]
8231 fn cmd_count_atomic() {
8232 assert_eq!(Cmd::<TestMsg>::quit().count(), 1);
8233 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).count(), 1);
8234 assert_eq!(Cmd::<TestMsg>::tick(Duration::from_millis(100)).count(), 1);
8235 assert_eq!(Cmd::<TestMsg>::log("hello").count(), 1);
8236 assert_eq!(Cmd::<TestMsg>::save_state().count(), 1);
8237 assert_eq!(Cmd::<TestMsg>::restore_state().count(), 1);
8238 assert_eq!(Cmd::<TestMsg>::set_mouse_capture(true).count(), 1);
8239 }
8240
8241 #[test]
8242 fn cmd_count_batch() {
8243 let cmd: Cmd<TestMsg> =
8244 Cmd::Batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment), Cmd::none()]);
8245 assert_eq!(cmd.count(), 2); }
8247
8248 #[test]
8249 fn cmd_count_nested() {
8250 let cmd: Cmd<TestMsg> = Cmd::Batch(vec![
8251 Cmd::msg(TestMsg::Increment),
8252 Cmd::Sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]),
8253 ]);
8254 assert_eq!(cmd.count(), 3);
8255 }
8256
8257 #[test]
8262 fn cmd_type_name_all_variants() {
8263 assert_eq!(Cmd::<TestMsg>::none().type_name(), "None");
8264 assert_eq!(Cmd::<TestMsg>::quit().type_name(), "Quit");
8265 assert_eq!(
8266 Cmd::<TestMsg>::Batch(vec![Cmd::none()]).type_name(),
8267 "Batch"
8268 );
8269 assert_eq!(
8270 Cmd::<TestMsg>::Sequence(vec![Cmd::none()]).type_name(),
8271 "Sequence"
8272 );
8273 assert_eq!(Cmd::<TestMsg>::msg(TestMsg::Increment).type_name(), "Msg");
8274 assert_eq!(
8275 Cmd::<TestMsg>::tick(Duration::from_millis(1)).type_name(),
8276 "Tick"
8277 );
8278 assert_eq!(Cmd::<TestMsg>::log("x").type_name(), "Log");
8279 assert_eq!(
8280 Cmd::<TestMsg>::task(|| TestMsg::Increment).type_name(),
8281 "Task"
8282 );
8283 assert_eq!(Cmd::<TestMsg>::save_state().type_name(), "SaveState");
8284 assert_eq!(Cmd::<TestMsg>::restore_state().type_name(), "RestoreState");
8285 assert_eq!(
8286 Cmd::<TestMsg>::set_mouse_capture(true).type_name(),
8287 "SetMouseCapture"
8288 );
8289 }
8290
8291 #[test]
8296 fn cmd_batch_empty_returns_none() {
8297 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
8298 assert!(matches!(cmd, Cmd::None));
8299 }
8300
8301 #[test]
8302 fn cmd_batch_single_unwraps() {
8303 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
8304 assert!(matches!(cmd, Cmd::Quit));
8305 }
8306
8307 #[test]
8308 fn cmd_batch_multiple_stays_batch() {
8309 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
8310 assert!(matches!(cmd, Cmd::Batch(_)));
8311 }
8312
8313 #[test]
8314 fn cmd_sequence_empty_returns_none() {
8315 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
8316 assert!(matches!(cmd, Cmd::None));
8317 }
8318
8319 #[test]
8320 fn cmd_sequence_single_unwraps_to_inner() {
8321 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
8322 assert!(matches!(cmd, Cmd::Quit));
8323 }
8324
8325 #[test]
8326 fn cmd_sequence_multiple_stays_sequence() {
8327 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit(), Cmd::msg(TestMsg::Increment)]);
8328 assert!(matches!(cmd, Cmd::Sequence(_)));
8329 }
8330
8331 #[test]
8336 fn cmd_task_with_spec() {
8337 let spec = TaskSpec::new(3.0, 25.0).with_name("my_task");
8338 let cmd: Cmd<TestMsg> = Cmd::task_with_spec(spec, || TestMsg::Increment);
8339 match cmd {
8340 Cmd::Task(s, _) => {
8341 assert_eq!(s.weight, 3.0);
8342 assert_eq!(s.estimate_ms, 25.0);
8343 assert_eq!(s.name.as_deref(), Some("my_task"));
8344 }
8345 _ => panic!("expected Task variant"),
8346 }
8347 }
8348
8349 #[test]
8350 fn cmd_task_weighted() {
8351 let cmd: Cmd<TestMsg> = Cmd::task_weighted(2.0, 50.0, || TestMsg::Increment);
8352 match cmd {
8353 Cmd::Task(s, _) => {
8354 assert_eq!(s.weight, 2.0);
8355 assert_eq!(s.estimate_ms, 50.0);
8356 assert!(s.name.is_none());
8357 }
8358 _ => panic!("expected Task variant"),
8359 }
8360 }
8361
8362 #[test]
8363 fn cmd_task_named() {
8364 let cmd: Cmd<TestMsg> = Cmd::task_named("background_fetch", || TestMsg::Increment);
8365 match cmd {
8366 Cmd::Task(s, _) => {
8367 assert_eq!(s.weight, DEFAULT_TASK_WEIGHT);
8368 assert_eq!(s.estimate_ms, DEFAULT_TASK_ESTIMATE_MS);
8369 assert_eq!(s.name.as_deref(), Some("background_fetch"));
8370 }
8371 _ => panic!("expected Task variant"),
8372 }
8373 }
8374
8375 #[test]
8380 fn cmd_debug_all_variant_strings() {
8381 assert_eq!(format!("{:?}", Cmd::<TestMsg>::none()), "None");
8382 assert_eq!(format!("{:?}", Cmd::<TestMsg>::quit()), "Quit");
8383 assert!(format!("{:?}", Cmd::<TestMsg>::msg(TestMsg::Increment)).starts_with("Msg("));
8384 assert!(
8385 format!("{:?}", Cmd::<TestMsg>::tick(Duration::from_millis(100))).starts_with("Tick(")
8386 );
8387 assert!(format!("{:?}", Cmd::<TestMsg>::log("hi")).starts_with("Log("));
8388 assert!(format!("{:?}", Cmd::<TestMsg>::task(|| TestMsg::Increment)).starts_with("Task"));
8389 assert_eq!(format!("{:?}", Cmd::<TestMsg>::save_state()), "SaveState");
8390 assert_eq!(
8391 format!("{:?}", Cmd::<TestMsg>::restore_state()),
8392 "RestoreState"
8393 );
8394 assert_eq!(
8395 format!("{:?}", Cmd::<TestMsg>::set_mouse_capture(true)),
8396 "SetMouseCapture(true)"
8397 );
8398 }
8399
8400 #[test]
8405 fn headless_execute_cmd_set_mouse_capture() {
8406 let mut program =
8407 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8408 assert!(!program.backend_features.mouse_capture);
8409
8410 program
8411 .execute_cmd(Cmd::set_mouse_capture(true))
8412 .expect("set mouse capture true");
8413 assert!(program.backend_features.mouse_capture);
8414
8415 program
8416 .execute_cmd(Cmd::set_mouse_capture(false))
8417 .expect("set mouse capture false");
8418 assert!(!program.backend_features.mouse_capture);
8419 }
8420
8421 #[test]
8426 fn resize_behavior_uses_coalescer() {
8427 assert!(ResizeBehavior::Throttled.uses_coalescer());
8428 assert!(!ResizeBehavior::Immediate.uses_coalescer());
8429 }
8430
8431 #[test]
8432 fn resize_behavior_eq_and_debug() {
8433 assert_eq!(ResizeBehavior::Immediate, ResizeBehavior::Immediate);
8434 assert_ne!(ResizeBehavior::Immediate, ResizeBehavior::Throttled);
8435 let debug = format!("{:?}", ResizeBehavior::Throttled);
8436 assert_eq!(debug, "Throttled");
8437 }
8438
8439 #[test]
8444 fn widget_refresh_config_defaults() {
8445 let config = WidgetRefreshConfig::default();
8446 assert!(config.enabled);
8447 assert_eq!(config.staleness_window_ms, 1_000);
8448 assert_eq!(config.starve_ms, 3_000);
8449 assert_eq!(config.max_starved_per_frame, 2);
8450 assert_eq!(config.max_drop_fraction, 1.0);
8451 assert_eq!(config.weight_priority, 1.0);
8452 assert_eq!(config.weight_staleness, 0.5);
8453 assert_eq!(config.weight_focus, 0.75);
8454 assert_eq!(config.weight_interaction, 0.5);
8455 assert_eq!(config.starve_boost, 1.5);
8456 assert_eq!(config.min_cost_us, 1.0);
8457 }
8458
8459 #[test]
8464 fn effect_queue_config_default() {
8465 let config = EffectQueueConfig::default();
8466 assert!(!config.enabled);
8467 assert!(config.scheduler.smith_enabled);
8468 assert!(!config.scheduler.force_fifo);
8469 assert!(!config.scheduler.preemptive);
8470 }
8471
8472 #[test]
8473 fn effect_queue_config_with_enabled() {
8474 let config = EffectQueueConfig::default().with_enabled(true);
8475 assert!(config.enabled);
8476 }
8477
8478 #[test]
8479 fn effect_queue_config_with_scheduler() {
8480 let sched = SchedulerConfig {
8481 force_fifo: true,
8482 ..Default::default()
8483 };
8484 let config = EffectQueueConfig::default().with_scheduler(sched);
8485 assert!(config.scheduler.force_fifo);
8486 }
8487
8488 #[test]
8493 fn inline_auto_remeasure_config_defaults() {
8494 let config = InlineAutoRemeasureConfig::default();
8495 assert_eq!(config.change_threshold_rows, 1);
8496 assert_eq!(config.voi.prior_alpha, 1.0);
8497 assert_eq!(config.voi.prior_beta, 9.0);
8498 assert_eq!(config.voi.max_interval_ms, 1000);
8499 assert_eq!(config.voi.min_interval_ms, 100);
8500 assert_eq!(config.voi.sample_cost, 0.08);
8501 }
8502
8503 #[test]
8508 fn headless_event_source_size() {
8509 let source = HeadlessEventSource::new(120, 40, BackendFeatures::default());
8510 assert_eq!(source.size().unwrap(), (120, 40));
8511 }
8512
8513 #[test]
8514 fn headless_event_source_poll_always_false() {
8515 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8516 assert!(!source.poll_event(Duration::from_millis(100)).unwrap());
8517 }
8518
8519 #[test]
8520 fn headless_event_source_read_always_none() {
8521 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8522 assert!(source.read_event().unwrap().is_none());
8523 }
8524
8525 #[test]
8526 fn headless_event_source_set_features() {
8527 let mut source = HeadlessEventSource::new(80, 24, BackendFeatures::default());
8528 let features = BackendFeatures {
8529 mouse_capture: true,
8530 bracketed_paste: true,
8531 focus_events: true,
8532 kitty_keyboard: true,
8533 };
8534 source.set_features(features).unwrap();
8535 assert_eq!(source.features, features);
8536 }
8537
8538 #[test]
8543 fn headless_program_quit_and_is_running() {
8544 let mut program =
8545 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8546 assert!(program.is_running());
8547
8548 program.quit();
8549 assert!(!program.is_running());
8550 }
8551
8552 #[test]
8553 fn headless_program_model_mut() {
8554 let mut program =
8555 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8556 assert_eq!(program.model().value, 0);
8557
8558 program.model_mut().value = 42;
8559 assert_eq!(program.model().value, 42);
8560 }
8561
8562 #[test]
8563 fn headless_program_request_redraw() {
8564 let mut program =
8565 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8566 program.dirty = false;
8567
8568 program.request_redraw();
8569 assert!(program.dirty);
8570 }
8571
8572 #[test]
8573 fn headless_program_last_widget_signals_initially_empty() {
8574 let program =
8575 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8576 assert!(program.last_widget_signals().is_empty());
8577 }
8578
8579 #[test]
8580 fn headless_program_no_persistence_by_default() {
8581 let program =
8582 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8583 assert!(!program.has_persistence());
8584 assert!(program.state_registry().is_none());
8585 }
8586
8587 #[test]
8592 fn classify_event_fairness_key_is_input() {
8593 let event = Event::Key(ftui_core::event::KeyEvent::new(
8594 ftui_core::event::KeyCode::Char('a'),
8595 ));
8596 let classification =
8597 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8598 assert_eq!(classification, FairnessEventType::Input);
8599 }
8600
8601 #[test]
8602 fn classify_event_fairness_resize_is_resize() {
8603 let event = Event::Resize {
8604 width: 80,
8605 height: 24,
8606 };
8607 let classification =
8608 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8609 assert_eq!(classification, FairnessEventType::Resize);
8610 }
8611
8612 #[test]
8613 fn classify_event_fairness_tick_is_tick() {
8614 let event = Event::Tick;
8615 let classification =
8616 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8617 assert_eq!(classification, FairnessEventType::Tick);
8618 }
8619
8620 #[test]
8621 fn classify_event_fairness_paste_is_input() {
8622 let event = Event::Paste(ftui_core::event::PasteEvent::bracketed("hello"));
8623 let classification =
8624 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8625 assert_eq!(classification, FairnessEventType::Input);
8626 }
8627
8628 #[test]
8629 fn classify_event_fairness_focus_is_input() {
8630 let event = Event::Focus(true);
8631 let classification =
8632 Program::<TestModel, HeadlessEventSource, Vec<u8>>::classify_event_for_fairness(&event);
8633 assert_eq!(classification, FairnessEventType::Input);
8634 }
8635
8636 #[test]
8641 fn program_config_with_diff_config() {
8642 let diff = RuntimeDiffConfig::default();
8643 let config = ProgramConfig::default().with_diff_config(diff.clone());
8644 let _ = format!("{:?}", config);
8646 }
8647
8648 #[test]
8649 fn program_config_with_evidence_sink() {
8650 let config =
8651 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
8652 let _ = format!("{:?}", config);
8653 }
8654
8655 #[test]
8656 fn program_config_with_render_trace() {
8657 let config = ProgramConfig::default().with_render_trace(RenderTraceConfig::default());
8658 let _ = format!("{:?}", config);
8659 }
8660
8661 #[test]
8662 fn program_config_with_locale() {
8663 let config = ProgramConfig::default().with_locale("fr");
8664 let _ = format!("{:?}", config);
8665 }
8666
8667 #[test]
8668 fn program_config_with_locale_context() {
8669 let config = ProgramConfig::default().with_locale_context(LocaleContext::new("de"));
8670 let _ = format!("{:?}", config);
8671 }
8672
8673 #[test]
8674 fn program_config_without_forced_size() {
8675 let config = ProgramConfig::default()
8676 .with_forced_size(80, 24)
8677 .without_forced_size();
8678 assert!(config.forced_size.is_none());
8679 }
8680
8681 #[test]
8682 fn program_config_forced_size_clamps_min() {
8683 let config = ProgramConfig::default().with_forced_size(0, 0);
8684 assert_eq!(config.forced_size, Some((1, 1)));
8685 }
8686
8687 #[test]
8688 fn program_config_with_widget_refresh() {
8689 let wrc = WidgetRefreshConfig {
8690 enabled: false,
8691 ..Default::default()
8692 };
8693 let config = ProgramConfig::default().with_widget_refresh(wrc);
8694 assert!(!config.widget_refresh.enabled);
8695 }
8696
8697 #[test]
8698 fn program_config_with_effect_queue() {
8699 let eqc = EffectQueueConfig::default().with_enabled(true);
8700 let config = ProgramConfig::default().with_effect_queue(eqc);
8701 assert!(config.effect_queue.enabled);
8702 }
8703
8704 #[test]
8705 fn program_config_with_resize_coalescer_custom() {
8706 let cc = CoalescerConfig {
8707 steady_delay_ms: 42,
8708 ..Default::default()
8709 };
8710 let config = ProgramConfig::default().with_resize_coalescer(cc);
8711 assert_eq!(config.resize_coalescer.steady_delay_ms, 42);
8712 }
8713
8714 #[test]
8715 fn program_config_with_inline_auto_remeasure() {
8716 let config = ProgramConfig::default()
8717 .with_inline_auto_remeasure(InlineAutoRemeasureConfig::default());
8718 assert!(config.inline_auto_remeasure.is_some());
8719
8720 let config = config.without_inline_auto_remeasure();
8721 assert!(config.inline_auto_remeasure.is_none());
8722 }
8723
8724 #[test]
8725 fn program_config_with_persistence_full() {
8726 let pc = PersistenceConfig::disabled();
8727 let config = ProgramConfig::default().with_persistence(pc);
8728 assert!(config.persistence.registry.is_none());
8729 }
8730
8731 #[test]
8732 fn program_config_with_conformal_config() {
8733 let config = ProgramConfig::default()
8734 .with_conformal_config(ConformalConfig::default())
8735 .without_conformal();
8736 assert!(config.conformal_config.is_none());
8737 }
8738
8739 #[test]
8744 fn persistence_config_debug() {
8745 let config = PersistenceConfig::default();
8746 let debug = format!("{config:?}");
8747 assert!(debug.contains("PersistenceConfig"));
8748 assert!(debug.contains("auto_load"));
8749 assert!(debug.contains("auto_save"));
8750 }
8751
8752 #[test]
8757 fn frame_timing_config_debug() {
8758 use std::sync::Arc;
8759
8760 struct DummySink;
8761 impl FrameTimingSink for DummySink {
8762 fn record_frame(&self, _timing: &FrameTiming) {}
8763 }
8764
8765 let config = FrameTimingConfig::new(Arc::new(DummySink));
8766 let debug = format!("{config:?}");
8767 assert!(debug.contains("FrameTimingConfig"));
8768 }
8769
8770 #[test]
8771 fn program_config_with_frame_timing() {
8772 use std::sync::Arc;
8773
8774 struct DummySink;
8775 impl FrameTimingSink for DummySink {
8776 fn record_frame(&self, _timing: &FrameTiming) {}
8777 }
8778
8779 let config =
8780 ProgramConfig::default().with_frame_timing(FrameTimingConfig::new(Arc::new(DummySink)));
8781 assert!(config.frame_timing.is_some());
8782 }
8783
8784 #[test]
8789 fn budget_decision_evidence_decision_from_levels() {
8790 use ftui_render::budget::DegradationLevel;
8791 assert_eq!(
8793 BudgetDecisionEvidence::decision_from_levels(
8794 DegradationLevel::Full,
8795 DegradationLevel::SimpleBorders
8796 ),
8797 BudgetDecision::Degrade
8798 );
8799 assert_eq!(
8801 BudgetDecisionEvidence::decision_from_levels(
8802 DegradationLevel::SimpleBorders,
8803 DegradationLevel::Full
8804 ),
8805 BudgetDecision::Upgrade
8806 );
8807 assert_eq!(
8809 BudgetDecisionEvidence::decision_from_levels(
8810 DegradationLevel::Full,
8811 DegradationLevel::Full
8812 ),
8813 BudgetDecision::Hold
8814 );
8815 }
8816
8817 #[test]
8822 fn widget_refresh_plan_clear() {
8823 let mut plan = WidgetRefreshPlan::new();
8824 plan.frame_idx = 5;
8825 plan.budget_us = 100.0;
8826 plan.signal_count = 3;
8827 plan.over_budget = true;
8828 plan.clear();
8829 assert_eq!(plan.frame_idx, 0);
8830 assert_eq!(plan.budget_us, 0.0);
8831 assert_eq!(plan.signal_count, 0);
8832 assert!(!plan.over_budget);
8833 }
8834
8835 #[test]
8836 fn widget_refresh_plan_as_budget_empty_signals() {
8837 let plan = WidgetRefreshPlan::new();
8838 let budget = plan.as_budget();
8839 assert!(budget.allows(0, false));
8841 assert!(budget.allows(999, false));
8842 }
8843
8844 #[test]
8845 fn widget_refresh_plan_to_jsonl_structure() {
8846 let plan = WidgetRefreshPlan::new();
8847 let jsonl = plan.to_jsonl();
8848 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
8849 assert!(jsonl.contains("\"frame_idx\":0"));
8850 assert!(jsonl.contains("\"selected\":[]"));
8851 }
8852
8853 #[test]
8858 fn batch_controller_default_trait() {
8859 let bc = BatchController::default();
8860 let bc2 = BatchController::new();
8861 assert_eq!(bc.tau_s(), bc2.tau_s());
8863 assert_eq!(bc.observations(), bc2.observations());
8864 }
8865
8866 #[test]
8867 fn batch_controller_observe_arrival_stale_gap_ignored() {
8868 let mut bc = BatchController::new();
8869 let base = Instant::now();
8870 bc.observe_arrival(base);
8872 bc.observe_arrival(base + Duration::from_secs(15));
8874 assert_eq!(bc.observations(), 0);
8875 }
8876
8877 #[test]
8878 fn batch_controller_observe_service_out_of_range() {
8879 let mut bc = BatchController::new();
8880 let original_service = bc.service_est_s();
8881 bc.observe_service(Duration::from_secs(15));
8883 assert_eq!(bc.service_est_s(), original_service);
8884 }
8885
8886 #[test]
8887 fn batch_controller_lambda_zero_inter_arrival() {
8888 let bc = BatchController {
8890 ema_inter_arrival_s: 0.0,
8891 ..BatchController::new()
8892 };
8893 assert_eq!(bc.lambda_est(), 0.0);
8894 }
8895
8896 #[test]
8901 fn headless_execute_cmd_log_appends_newline_if_missing() {
8902 let mut program =
8903 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8904 program.execute_cmd(Cmd::log("no newline")).expect("log");
8905
8906 let bytes = program.writer.into_inner().expect("writer output");
8907 let output = String::from_utf8_lossy(&bytes);
8908 assert!(output.contains("no newline"));
8910 }
8911
8912 #[test]
8913 fn headless_execute_cmd_log_preserves_trailing_newline() {
8914 let mut program =
8915 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
8916 program
8917 .execute_cmd(Cmd::log("with newline\n"))
8918 .expect("log");
8919
8920 let bytes = program.writer.into_inner().expect("writer output");
8921 let output = String::from_utf8_lossy(&bytes);
8922 assert!(output.contains("with newline"));
8923 }
8924
8925 #[test]
8930 fn headless_handle_event_immediate_resize() {
8931 struct ResizeModel {
8932 last_size: Option<(u16, u16)>,
8933 }
8934
8935 #[derive(Debug)]
8936 enum ResizeMsg {
8937 Resize(u16, u16),
8938 Other,
8939 }
8940
8941 impl From<Event> for ResizeMsg {
8942 fn from(event: Event) -> Self {
8943 match event {
8944 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
8945 _ => ResizeMsg::Other,
8946 }
8947 }
8948 }
8949
8950 impl Model for ResizeModel {
8951 type Message = ResizeMsg;
8952
8953 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
8954 if let ResizeMsg::Resize(w, h) = msg {
8955 self.last_size = Some((w, h));
8956 }
8957 Cmd::none()
8958 }
8959
8960 fn view(&self, _frame: &mut Frame) {}
8961 }
8962
8963 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
8964 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
8965
8966 program
8967 .handle_event(Event::Resize {
8968 width: 120,
8969 height: 40,
8970 })
8971 .expect("handle resize");
8972
8973 assert_eq!(program.width, 120);
8974 assert_eq!(program.height, 40);
8975 assert_eq!(program.model().last_size, Some((120, 40)));
8976 }
8977
8978 #[test]
8983 fn headless_apply_resize_clamps_zero_to_one() {
8984 struct SimpleModel;
8985
8986 #[derive(Debug)]
8987 enum SimpleMsg {
8988 Noop,
8989 }
8990
8991 impl From<Event> for SimpleMsg {
8992 fn from(_: Event) -> Self {
8993 SimpleMsg::Noop
8994 }
8995 }
8996
8997 impl Model for SimpleModel {
8998 type Message = SimpleMsg;
8999
9000 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
9001 Cmd::none()
9002 }
9003
9004 fn view(&self, _frame: &mut Frame) {}
9005 }
9006
9007 let mut program = headless_program_with_config(SimpleModel, ProgramConfig::default());
9008 program
9009 .apply_resize(0, 0, Duration::ZERO, false)
9010 .expect("resize");
9011
9012 assert_eq!(program.width, 1);
9014 assert_eq!(program.height, 1);
9015 }
9016
9017 #[test]
9022 fn force_cancel_all_idle_returns_none() {
9023 let mut adapter =
9024 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9025 assert!(adapter.force_cancel_all().is_none());
9026 }
9027
9028 #[test]
9029 fn force_cancel_all_after_pointer_down_returns_diagnostics() {
9030 let mut adapter =
9031 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9032 let target = pane_target(SplitAxis::Horizontal);
9033
9034 let down = Event::Mouse(MouseEvent::new(
9035 MouseEventKind::Down(MouseButton::Left),
9036 5,
9037 5,
9038 ));
9039 let _ = adapter.translate(&down, Some(target));
9040 assert!(adapter.active_pointer_id().is_some());
9041
9042 let diag = adapter
9043 .force_cancel_all()
9044 .expect("should produce diagnostics");
9045 assert!(diag.had_active_pointer);
9046 assert_eq!(diag.active_pointer_id, Some(1));
9047 assert!(diag.machine_transition.is_some());
9048
9049 assert_eq!(adapter.active_pointer_id(), None);
9051 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
9052 }
9053
9054 #[test]
9055 fn force_cancel_all_during_drag_returns_diagnostics() {
9056 let mut adapter =
9057 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9058 let target = pane_target(SplitAxis::Vertical);
9059
9060 let down = Event::Mouse(MouseEvent::new(
9062 MouseEventKind::Down(MouseButton::Left),
9063 3,
9064 3,
9065 ));
9066 let _ = adapter.translate(&down, Some(target));
9067
9068 let drag = Event::Mouse(MouseEvent::new(
9070 MouseEventKind::Drag(MouseButton::Left),
9071 8,
9072 3,
9073 ));
9074 let _ = adapter.translate(&drag, None);
9075
9076 let diag = adapter
9077 .force_cancel_all()
9078 .expect("should produce diagnostics");
9079 assert!(diag.had_active_pointer);
9080 assert!(diag.machine_transition.is_some());
9081 let transition = diag.machine_transition.unwrap();
9082 assert!(matches!(
9083 transition.effect,
9084 PaneDragResizeEffect::Canceled {
9085 reason: PaneCancelReason::Programmatic,
9086 ..
9087 }
9088 ));
9089 }
9090
9091 #[test]
9092 fn force_cancel_all_is_idempotent() {
9093 let mut adapter =
9094 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9095 let target = pane_target(SplitAxis::Horizontal);
9096
9097 let down = Event::Mouse(MouseEvent::new(
9098 MouseEventKind::Down(MouseButton::Left),
9099 5,
9100 5,
9101 ));
9102 let _ = adapter.translate(&down, Some(target));
9103
9104 let first = adapter.force_cancel_all();
9105 assert!(first.is_some());
9106
9107 let second = adapter.force_cancel_all();
9108 assert!(second.is_none());
9109 }
9110
9111 #[test]
9116 fn pane_interaction_guard_finish_when_idle() {
9117 let mut adapter =
9118 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9119 let guard = PaneInteractionGuard::new(&mut adapter);
9120 let diag = guard.finish();
9121 assert!(diag.is_none());
9122 }
9123
9124 #[test]
9125 fn pane_interaction_guard_finish_returns_diagnostics() {
9126 let mut adapter =
9127 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9128 let target = pane_target(SplitAxis::Horizontal);
9129
9130 let down = Event::Mouse(MouseEvent::new(
9132 MouseEventKind::Down(MouseButton::Left),
9133 5,
9134 5,
9135 ));
9136 let _ = adapter.translate(&down, Some(target));
9137
9138 let guard = PaneInteractionGuard::new(&mut adapter);
9139 let diag = guard.finish().expect("should produce diagnostics");
9140 assert!(diag.had_active_pointer);
9141 assert_eq!(diag.active_pointer_id, Some(1));
9142 }
9143
9144 #[test]
9145 fn pane_interaction_guard_drop_cancels_active_interaction() {
9146 let mut adapter =
9147 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9148 let target = pane_target(SplitAxis::Vertical);
9149
9150 let down = Event::Mouse(MouseEvent::new(
9151 MouseEventKind::Down(MouseButton::Left),
9152 7,
9153 7,
9154 ));
9155 let _ = adapter.translate(&down, Some(target));
9156 assert!(adapter.active_pointer_id().is_some());
9157
9158 {
9159 let _guard = PaneInteractionGuard::new(&mut adapter);
9160 }
9162
9163 assert_eq!(adapter.active_pointer_id(), None);
9165 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
9166 }
9167
9168 #[test]
9169 fn pane_interaction_guard_adapter_access_works() {
9170 let mut adapter =
9171 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9172 let target = pane_target(SplitAxis::Horizontal);
9173
9174 let mut guard = PaneInteractionGuard::new(&mut adapter);
9175
9176 let down = Event::Mouse(MouseEvent::new(
9178 MouseEventKind::Down(MouseButton::Left),
9179 5,
9180 5,
9181 ));
9182 let dispatch = guard.adapter().translate(&down, Some(target));
9183 assert!(dispatch.primary_event.is_some());
9184
9185 let diag = guard.finish().expect("should produce diagnostics");
9187 assert!(diag.had_active_pointer);
9188 }
9189
9190 #[test]
9191 fn pane_interaction_guard_finish_then_drop_is_safe() {
9192 let mut adapter =
9193 PaneTerminalAdapter::new(PaneTerminalAdapterConfig::default()).expect("valid adapter");
9194 let target = pane_target(SplitAxis::Horizontal);
9195
9196 let down = Event::Mouse(MouseEvent::new(
9197 MouseEventKind::Down(MouseButton::Left),
9198 5,
9199 5,
9200 ));
9201 let _ = adapter.translate(&down, Some(target));
9202
9203 let guard = PaneInteractionGuard::new(&mut adapter);
9204 let _diag = guard.finish();
9205 assert_eq!(adapter.active_pointer_id(), None);
9208 }
9209
9210 fn caps_modern() -> TerminalCapabilities {
9215 TerminalCapabilities::modern()
9216 }
9217
9218 fn caps_with_mux(
9219 mux: PaneMuxEnvironment,
9220 ) -> ftui_core::terminal_capabilities::TerminalCapabilities {
9221 let mut caps = TerminalCapabilities::modern();
9222 match mux {
9223 PaneMuxEnvironment::Tmux => caps.in_tmux = true,
9224 PaneMuxEnvironment::Screen => caps.in_screen = true,
9225 PaneMuxEnvironment::Zellij => caps.in_zellij = true,
9226 PaneMuxEnvironment::None => {}
9227 }
9228 caps
9229 }
9230
9231 #[test]
9232 fn capability_matrix_bare_terminal_modern() {
9233 let caps = caps_modern();
9234 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9235
9236 assert_eq!(mat.mux, PaneMuxEnvironment::None);
9237 assert!(mat.mouse_sgr);
9238 assert!(mat.mouse_drag_reliable);
9239 assert!(mat.mouse_button_discrimination);
9240 assert!(mat.focus_events);
9241 assert!(mat.unicode_box_drawing);
9242 assert!(mat.true_color);
9243 assert!(!mat.degraded);
9244 assert!(mat.drag_enabled());
9245 assert!(mat.focus_cancel_effective());
9246 assert!(mat.limitations().is_empty());
9247 }
9248
9249 #[test]
9250 fn capability_matrix_tmux() {
9251 let caps = caps_with_mux(PaneMuxEnvironment::Tmux);
9252 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9253
9254 assert_eq!(mat.mux, PaneMuxEnvironment::Tmux);
9255 assert!(mat.mouse_drag_reliable);
9257 assert!(mat.focus_events);
9258 assert!(mat.drag_enabled());
9259 assert!(mat.focus_cancel_effective());
9260 }
9261
9262 #[test]
9263 fn capability_matrix_screen_degrades_drag() {
9264 let caps = caps_with_mux(PaneMuxEnvironment::Screen);
9265 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9266
9267 assert_eq!(mat.mux, PaneMuxEnvironment::Screen);
9268 assert!(!mat.mouse_drag_reliable);
9269 assert!(!mat.focus_events);
9270 assert!(!mat.drag_enabled());
9271 assert!(!mat.focus_cancel_effective());
9272 assert!(mat.degraded);
9273
9274 let lims = mat.limitations();
9275 assert!(lims.iter().any(|l| l.id == "mouse_drag_unreliable"));
9276 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
9277 }
9278
9279 #[test]
9280 fn capability_matrix_zellij() {
9281 let caps = caps_with_mux(PaneMuxEnvironment::Zellij);
9282 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9283
9284 assert_eq!(mat.mux, PaneMuxEnvironment::Zellij);
9285 assert!(mat.mouse_drag_reliable);
9286 assert!(mat.focus_events);
9287 assert!(mat.drag_enabled());
9288 }
9289
9290 #[test]
9291 fn capability_matrix_no_sgr_mouse() {
9292 let mut caps = caps_modern();
9293 caps.mouse_sgr = false;
9294 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9295
9296 assert!(!mat.mouse_sgr);
9297 assert!(!mat.mouse_button_discrimination);
9298 assert!(mat.degraded);
9299
9300 let lims = mat.limitations();
9301 assert!(lims.iter().any(|l| l.id == "no_sgr_mouse"));
9302 assert!(lims.iter().any(|l| l.id == "no_button_discrimination"));
9303 }
9304
9305 #[test]
9306 fn capability_matrix_no_focus_events() {
9307 let mut caps = caps_modern();
9308 caps.focus_events = false;
9309 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9310
9311 assert!(!mat.focus_events);
9312 assert!(!mat.focus_cancel_effective());
9313 assert!(mat.degraded);
9314
9315 let lims = mat.limitations();
9316 assert!(lims.iter().any(|l| l.id == "no_focus_events"));
9317 }
9318
9319 #[test]
9320 fn capability_matrix_dumb_terminal() {
9321 let caps = TerminalCapabilities::dumb();
9322 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9323
9324 assert_eq!(mat.mux, PaneMuxEnvironment::None);
9325 assert!(!mat.mouse_sgr);
9326 assert!(!mat.focus_events);
9327 assert!(!mat.unicode_box_drawing);
9328 assert!(!mat.true_color);
9329 assert!(mat.degraded);
9330 assert!(mat.limitations().len() >= 3);
9331 }
9332
9333 #[test]
9334 fn capability_matrix_limitations_have_fallbacks() {
9335 let caps = TerminalCapabilities::dumb();
9336 let mat = PaneCapabilityMatrix::from_capabilities(&caps);
9337
9338 for lim in mat.limitations() {
9339 assert!(!lim.id.is_empty());
9340 assert!(!lim.description.is_empty());
9341 assert!(!lim.fallback.is_empty());
9342 }
9343 }
9344}