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