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_core::event::Event;
72use ftui_core::terminal_capabilities::TerminalCapabilities;
73use ftui_core::terminal_session::{SessionOptions, TerminalSession};
74use ftui_render::budget::{BudgetDecision, DegradationLevel, FrameBudgetConfig, RenderBudget};
75use ftui_render::buffer::Buffer;
76use ftui_render::diff_strategy::DiffStrategy;
77use ftui_render::frame::{Frame, WidgetBudget, WidgetSignal};
78use ftui_render::sanitize::sanitize;
79use std::collections::HashMap;
80use std::io::{self, Stdout, Write};
81use std::sync::Arc;
82use std::sync::mpsc;
83use std::thread::{self, JoinHandle};
84use std::time::{Duration, Instant};
85use tracing::{debug, debug_span, info, info_span};
86
87pub trait Model: Sized {
92 type Message: From<Event> + Send + 'static;
97
98 fn init(&mut self) -> Cmd<Self::Message> {
103 Cmd::none()
104 }
105
106 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
111
112 fn view(&self, frame: &mut Frame);
116
117 fn subscriptions(&self) -> Vec<Box<dyn crate::subscription::Subscription<Self::Message>>> {
127 vec![]
128 }
129}
130
131const DEFAULT_TASK_WEIGHT: f64 = 1.0;
133
134const DEFAULT_TASK_ESTIMATE_MS: f64 = 10.0;
136
137#[derive(Debug, Clone)]
139pub struct TaskSpec {
140 pub weight: f64,
142 pub estimate_ms: f64,
144 pub name: Option<String>,
146}
147
148impl Default for TaskSpec {
149 fn default() -> Self {
150 Self {
151 weight: DEFAULT_TASK_WEIGHT,
152 estimate_ms: DEFAULT_TASK_ESTIMATE_MS,
153 name: None,
154 }
155 }
156}
157
158impl TaskSpec {
159 #[must_use]
161 pub fn new(weight: f64, estimate_ms: f64) -> Self {
162 Self {
163 weight,
164 estimate_ms,
165 name: None,
166 }
167 }
168
169 #[must_use]
171 pub fn with_name(mut self, name: impl Into<String>) -> Self {
172 self.name = Some(name.into());
173 self
174 }
175}
176
177#[derive(Debug, Clone, Copy)]
179pub struct FrameTiming {
180 pub frame_idx: u64,
181 pub update_us: u64,
182 pub render_us: u64,
183 pub diff_us: u64,
184 pub present_us: u64,
185 pub total_us: u64,
186}
187
188pub trait FrameTimingSink: Send + Sync {
190 fn record_frame(&self, timing: &FrameTiming);
191}
192
193#[derive(Clone)]
195pub struct FrameTimingConfig {
196 pub sink: Arc<dyn FrameTimingSink>,
197}
198
199impl FrameTimingConfig {
200 #[must_use]
201 pub fn new(sink: Arc<dyn FrameTimingSink>) -> Self {
202 Self { sink }
203 }
204}
205
206impl std::fmt::Debug for FrameTimingConfig {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 f.debug_struct("FrameTimingConfig")
209 .field("sink", &"<dyn FrameTimingSink>")
210 .finish()
211 }
212}
213
214#[derive(Default)]
219pub enum Cmd<M> {
220 #[default]
222 None,
223 Quit,
225 Batch(Vec<Cmd<M>>),
227 Sequence(Vec<Cmd<M>>),
229 Msg(M),
231 Tick(Duration),
233 Log(String),
238 Task(TaskSpec, Box<dyn FnOnce() -> M + Send>),
245 SaveState,
250 RestoreState,
256}
257
258impl<M: std::fmt::Debug> std::fmt::Debug for Cmd<M> {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 match self {
261 Self::None => write!(f, "None"),
262 Self::Quit => write!(f, "Quit"),
263 Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
264 Self::Sequence(cmds) => f.debug_tuple("Sequence").field(cmds).finish(),
265 Self::Msg(m) => f.debug_tuple("Msg").field(m).finish(),
266 Self::Tick(d) => f.debug_tuple("Tick").field(d).finish(),
267 Self::Log(s) => f.debug_tuple("Log").field(s).finish(),
268 Self::Task(spec, _) => f.debug_struct("Task").field("spec", spec).finish(),
269 Self::SaveState => write!(f, "SaveState"),
270 Self::RestoreState => write!(f, "RestoreState"),
271 }
272 }
273}
274
275impl<M> Cmd<M> {
276 #[inline]
278 pub fn none() -> Self {
279 Self::None
280 }
281
282 #[inline]
284 pub fn quit() -> Self {
285 Self::Quit
286 }
287
288 #[inline]
290 pub fn msg(m: M) -> Self {
291 Self::Msg(m)
292 }
293
294 #[inline]
299 pub fn log(msg: impl Into<String>) -> Self {
300 Self::Log(msg.into())
301 }
302
303 pub fn batch(cmds: Vec<Self>) -> Self {
305 if cmds.is_empty() {
306 Self::None
307 } else if cmds.len() == 1 {
308 cmds.into_iter().next().unwrap()
309 } else {
310 Self::Batch(cmds)
311 }
312 }
313
314 pub fn sequence(cmds: Vec<Self>) -> Self {
316 if cmds.is_empty() {
317 Self::None
318 } else if cmds.len() == 1 {
319 cmds.into_iter().next().unwrap()
320 } else {
321 Self::Sequence(cmds)
322 }
323 }
324
325 #[inline]
327 pub fn type_name(&self) -> &'static str {
328 match self {
329 Self::None => "None",
330 Self::Quit => "Quit",
331 Self::Batch(_) => "Batch",
332 Self::Sequence(_) => "Sequence",
333 Self::Msg(_) => "Msg",
334 Self::Tick(_) => "Tick",
335 Self::Log(_) => "Log",
336 Self::Task(..) => "Task",
337 Self::SaveState => "SaveState",
338 Self::RestoreState => "RestoreState",
339 }
340 }
341
342 #[inline]
344 pub fn tick(duration: Duration) -> Self {
345 Self::Tick(duration)
346 }
347
348 pub fn task<F>(f: F) -> Self
354 where
355 F: FnOnce() -> M + Send + 'static,
356 {
357 Self::Task(TaskSpec::default(), Box::new(f))
358 }
359
360 pub fn task_with_spec<F>(spec: TaskSpec, f: F) -> Self
362 where
363 F: FnOnce() -> M + Send + 'static,
364 {
365 Self::Task(spec, Box::new(f))
366 }
367
368 pub fn task_weighted<F>(weight: f64, estimate_ms: f64, f: F) -> Self
370 where
371 F: FnOnce() -> M + Send + 'static,
372 {
373 Self::Task(TaskSpec::new(weight, estimate_ms), Box::new(f))
374 }
375
376 pub fn task_named<F>(name: impl Into<String>, f: F) -> Self
378 where
379 F: FnOnce() -> M + Send + 'static,
380 {
381 Self::Task(TaskSpec::default().with_name(name), Box::new(f))
382 }
383
384 #[inline]
389 pub fn save_state() -> Self {
390 Self::SaveState
391 }
392
393 #[inline]
398 pub fn restore_state() -> Self {
399 Self::RestoreState
400 }
401
402 pub fn count(&self) -> usize {
406 match self {
407 Self::None => 0,
408 Self::Batch(cmds) | Self::Sequence(cmds) => cmds.iter().map(Self::count).sum(),
409 _ => 1,
410 }
411 }
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum ResizeBehavior {
417 Immediate,
419 Throttled,
421}
422
423impl ResizeBehavior {
424 const fn uses_coalescer(self) -> bool {
425 matches!(self, ResizeBehavior::Throttled)
426 }
427}
428
429#[derive(Clone)]
433pub struct PersistenceConfig {
434 pub registry: Option<std::sync::Arc<StateRegistry>>,
436 pub checkpoint_interval: Option<Duration>,
438 pub auto_load: bool,
440 pub auto_save: bool,
442}
443
444impl Default for PersistenceConfig {
445 fn default() -> Self {
446 Self {
447 registry: None,
448 checkpoint_interval: None,
449 auto_load: true,
450 auto_save: true,
451 }
452 }
453}
454
455impl std::fmt::Debug for PersistenceConfig {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 f.debug_struct("PersistenceConfig")
458 .field(
459 "registry",
460 &self.registry.as_ref().map(|r| r.backend_name()),
461 )
462 .field("checkpoint_interval", &self.checkpoint_interval)
463 .field("auto_load", &self.auto_load)
464 .field("auto_save", &self.auto_save)
465 .finish()
466 }
467}
468
469impl PersistenceConfig {
470 #[must_use]
472 pub fn disabled() -> Self {
473 Self::default()
474 }
475
476 #[must_use]
478 pub fn with_registry(registry: std::sync::Arc<StateRegistry>) -> Self {
479 Self {
480 registry: Some(registry),
481 ..Default::default()
482 }
483 }
484
485 #[must_use]
487 pub fn checkpoint_every(mut self, interval: Duration) -> Self {
488 self.checkpoint_interval = Some(interval);
489 self
490 }
491
492 #[must_use]
494 pub fn auto_load(mut self, enabled: bool) -> Self {
495 self.auto_load = enabled;
496 self
497 }
498
499 #[must_use]
501 pub fn auto_save(mut self, enabled: bool) -> Self {
502 self.auto_save = enabled;
503 self
504 }
505}
506
507#[derive(Debug, Clone)]
519pub struct WidgetRefreshConfig {
520 pub enabled: bool,
522 pub staleness_window_ms: u64,
524 pub starve_ms: u64,
526 pub max_starved_per_frame: usize,
528 pub max_drop_fraction: f32,
531 pub weight_priority: f32,
533 pub weight_staleness: f32,
535 pub weight_focus: f32,
537 pub weight_interaction: f32,
539 pub starve_boost: f32,
541 pub min_cost_us: f32,
543}
544
545impl Default for WidgetRefreshConfig {
546 fn default() -> Self {
547 Self {
548 enabled: true,
549 staleness_window_ms: 1_000,
550 starve_ms: 3_000,
551 max_starved_per_frame: 2,
552 max_drop_fraction: 1.0,
553 weight_priority: 1.0,
554 weight_staleness: 0.5,
555 weight_focus: 0.75,
556 weight_interaction: 0.5,
557 starve_boost: 1.5,
558 min_cost_us: 1.0,
559 }
560 }
561}
562
563#[derive(Debug, Clone)]
565pub struct EffectQueueConfig {
566 pub enabled: bool,
568 pub scheduler: SchedulerConfig,
570}
571
572impl Default for EffectQueueConfig {
573 fn default() -> Self {
574 let scheduler = SchedulerConfig {
575 smith_enabled: true,
576 force_fifo: false,
577 preemptive: false,
578 aging_factor: 0.0,
579 wait_starve_ms: 0.0,
580 enable_logging: false,
581 ..Default::default()
582 };
583 Self {
584 enabled: false,
585 scheduler,
586 }
587 }
588}
589
590impl EffectQueueConfig {
591 #[must_use]
593 pub fn with_enabled(mut self, enabled: bool) -> Self {
594 self.enabled = enabled;
595 self
596 }
597
598 #[must_use]
600 pub fn with_scheduler(mut self, scheduler: SchedulerConfig) -> Self {
601 self.scheduler = scheduler;
602 self
603 }
604}
605
606#[derive(Debug, Clone)]
608pub struct ProgramConfig {
609 pub screen_mode: ScreenMode,
611 pub ui_anchor: UiAnchor,
613 pub budget: FrameBudgetConfig,
615 pub diff_config: RuntimeDiffConfig,
617 pub evidence_sink: EvidenceSinkConfig,
619 pub render_trace: RenderTraceConfig,
621 pub frame_timing: Option<FrameTimingConfig>,
623 pub conformal_config: Option<ConformalConfig>,
625 pub locale_context: LocaleContext,
627 pub poll_timeout: Duration,
629 pub resize_coalescer: CoalescerConfig,
631 pub resize_behavior: ResizeBehavior,
633 pub forced_size: Option<(u16, u16)>,
635 pub mouse: bool,
637 pub bracketed_paste: bool,
639 pub focus_reporting: bool,
641 pub kitty_keyboard: bool,
643 pub persistence: PersistenceConfig,
645 pub inline_auto_remeasure: Option<InlineAutoRemeasureConfig>,
647 pub widget_refresh: WidgetRefreshConfig,
649 pub effect_queue: EffectQueueConfig,
651}
652
653impl Default for ProgramConfig {
654 fn default() -> Self {
655 Self {
656 screen_mode: ScreenMode::Inline { ui_height: 4 },
657 ui_anchor: UiAnchor::Bottom,
658 budget: FrameBudgetConfig::default(),
659 diff_config: RuntimeDiffConfig::default(),
660 evidence_sink: EvidenceSinkConfig::default(),
661 render_trace: RenderTraceConfig::default(),
662 frame_timing: None,
663 conformal_config: None,
664 locale_context: LocaleContext::global(),
665 poll_timeout: Duration::from_millis(100),
666 resize_coalescer: CoalescerConfig::default(),
667 resize_behavior: ResizeBehavior::Throttled,
668 forced_size: None,
669 mouse: false,
670 bracketed_paste: true,
671 focus_reporting: false,
672 kitty_keyboard: false,
673 persistence: PersistenceConfig::default(),
674 inline_auto_remeasure: None,
675 widget_refresh: WidgetRefreshConfig::default(),
676 effect_queue: EffectQueueConfig::default(),
677 }
678 }
679}
680
681impl ProgramConfig {
682 pub fn fullscreen() -> Self {
684 Self {
685 screen_mode: ScreenMode::AltScreen,
686 ..Default::default()
687 }
688 }
689
690 pub fn inline(height: u16) -> Self {
692 Self {
693 screen_mode: ScreenMode::Inline { ui_height: height },
694 ..Default::default()
695 }
696 }
697
698 pub fn inline_auto(min_height: u16, max_height: u16) -> Self {
700 Self {
701 screen_mode: ScreenMode::InlineAuto {
702 min_height,
703 max_height,
704 },
705 inline_auto_remeasure: Some(InlineAutoRemeasureConfig::default()),
706 ..Default::default()
707 }
708 }
709
710 pub fn with_mouse(mut self) -> Self {
712 self.mouse = true;
713 self
714 }
715
716 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
718 self.budget = budget;
719 self
720 }
721
722 pub fn with_diff_config(mut self, diff_config: RuntimeDiffConfig) -> Self {
724 self.diff_config = diff_config;
725 self
726 }
727
728 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
730 self.evidence_sink = config;
731 self
732 }
733
734 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
736 self.render_trace = config;
737 self
738 }
739
740 pub fn with_frame_timing(mut self, config: FrameTimingConfig) -> Self {
742 self.frame_timing = Some(config);
743 self
744 }
745
746 pub fn with_conformal_config(mut self, config: ConformalConfig) -> Self {
748 self.conformal_config = Some(config);
749 self
750 }
751
752 pub fn without_conformal(mut self) -> Self {
754 self.conformal_config = None;
755 self
756 }
757
758 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
760 self.locale_context = locale_context;
761 self
762 }
763
764 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
766 self.locale_context = LocaleContext::new(locale);
767 self
768 }
769
770 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
772 self.widget_refresh = config;
773 self
774 }
775
776 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
778 self.effect_queue = config;
779 self
780 }
781
782 pub fn with_resize_coalescer(mut self, config: CoalescerConfig) -> Self {
784 self.resize_coalescer = config;
785 self
786 }
787
788 pub fn with_resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
790 self.resize_behavior = behavior;
791 self
792 }
793
794 pub fn with_forced_size(mut self, width: u16, height: u16) -> Self {
796 let width = width.max(1);
797 let height = height.max(1);
798 self.forced_size = Some((width, height));
799 self
800 }
801
802 pub fn without_forced_size(mut self) -> Self {
804 self.forced_size = None;
805 self
806 }
807
808 pub fn with_legacy_resize(mut self, enabled: bool) -> Self {
810 if enabled {
811 self.resize_behavior = ResizeBehavior::Immediate;
812 }
813 self
814 }
815
816 pub fn with_persistence(mut self, persistence: PersistenceConfig) -> Self {
818 self.persistence = persistence;
819 self
820 }
821
822 pub fn with_registry(mut self, registry: std::sync::Arc<StateRegistry>) -> Self {
824 self.persistence = PersistenceConfig::with_registry(registry);
825 self
826 }
827
828 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
830 self.inline_auto_remeasure = Some(config);
831 self
832 }
833
834 pub fn without_inline_auto_remeasure(mut self) -> Self {
836 self.inline_auto_remeasure = None;
837 self
838 }
839}
840
841enum EffectCommand<M> {
842 Enqueue(TaskSpec, Box<dyn FnOnce() -> M + Send>),
843 Shutdown,
844}
845
846struct EffectQueue<M: Send + 'static> {
847 sender: mpsc::Sender<EffectCommand<M>>,
848 handle: Option<JoinHandle<()>>,
849}
850
851impl<M: Send + 'static> EffectQueue<M> {
852 fn start(
853 config: EffectQueueConfig,
854 result_sender: mpsc::Sender<M>,
855 evidence_sink: Option<EvidenceSink>,
856 ) -> Self {
857 let (tx, rx) = mpsc::channel::<EffectCommand<M>>();
858 let handle = thread::Builder::new()
859 .name("ftui-effects".into())
860 .spawn(move || effect_queue_loop(config, rx, result_sender, evidence_sink))
861 .expect("failed to spawn effect queue");
862
863 Self {
864 sender: tx,
865 handle: Some(handle),
866 }
867 }
868
869 fn enqueue(&self, spec: TaskSpec, task: Box<dyn FnOnce() -> M + Send>) {
870 let _ = self.sender.send(EffectCommand::Enqueue(spec, task));
871 }
872
873 fn shutdown(&mut self) {
874 let _ = self.sender.send(EffectCommand::Shutdown);
875 if let Some(handle) = self.handle.take() {
876 let _ = handle.join();
877 }
878 }
879}
880
881impl<M: Send + 'static> Drop for EffectQueue<M> {
882 fn drop(&mut self) {
883 self.shutdown();
884 }
885}
886
887fn effect_queue_loop<M: Send + 'static>(
888 config: EffectQueueConfig,
889 rx: mpsc::Receiver<EffectCommand<M>>,
890 result_sender: mpsc::Sender<M>,
891 evidence_sink: Option<EvidenceSink>,
892) {
893 let mut scheduler = QueueingScheduler::new(config.scheduler);
894 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> M + Send>> = HashMap::new();
895
896 loop {
897 if tasks.is_empty() {
898 match rx.recv() {
899 Ok(cmd) => {
900 if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
901 return;
902 }
903 }
904 Err(_) => return,
905 }
906 }
907
908 while let Ok(cmd) = rx.try_recv() {
909 if handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_sender) {
910 return;
911 }
912 }
913
914 if tasks.is_empty() {
915 continue;
916 }
917
918 let Some(job) = scheduler.peek_next().cloned() else {
919 continue;
920 };
921
922 if let Some(ref sink) = evidence_sink {
923 let evidence = scheduler.evidence();
924 let _ = sink.write_jsonl(&evidence.to_jsonl("effect_queue_select"));
925 }
926
927 let completed = scheduler.tick(job.remaining_time);
928 for job_id in completed {
929 if let Some(task) = tasks.remove(&job_id) {
930 let msg = task();
931 let _ = result_sender.send(msg);
932 }
933 }
934 }
935}
936
937fn handle_effect_command<M: Send + 'static>(
938 cmd: EffectCommand<M>,
939 scheduler: &mut QueueingScheduler,
940 tasks: &mut HashMap<u64, Box<dyn FnOnce() -> M + Send>>,
941 result_sender: &mpsc::Sender<M>,
942) -> bool {
943 match cmd {
944 EffectCommand::Enqueue(spec, task) => {
945 let weight_source = if spec.weight == DEFAULT_TASK_WEIGHT {
946 WeightSource::Default
947 } else {
948 WeightSource::Explicit
949 };
950 let estimate_source = if spec.estimate_ms == DEFAULT_TASK_ESTIMATE_MS {
951 EstimateSource::Default
952 } else {
953 EstimateSource::Explicit
954 };
955 let id = scheduler.submit_with_sources(
956 spec.weight,
957 spec.estimate_ms,
958 weight_source,
959 estimate_source,
960 spec.name,
961 );
962 if let Some(id) = id {
963 tasks.insert(id, task);
964 } else {
965 let msg = task();
966 let _ = result_sender.send(msg);
967 }
968 false
969 }
970 EffectCommand::Shutdown => true,
971 }
972}
973
974#[derive(Debug, Clone)]
982pub struct InlineAutoRemeasureConfig {
983 pub voi: VoiConfig,
985 pub change_threshold_rows: u16,
987}
988
989impl Default for InlineAutoRemeasureConfig {
990 fn default() -> Self {
991 Self {
992 voi: VoiConfig {
993 prior_alpha: 1.0,
995 prior_beta: 9.0,
996 max_interval_ms: 1000,
998 min_interval_ms: 100,
1000 max_interval_events: 0,
1002 min_interval_events: 0,
1003 sample_cost: 0.08,
1005 ..VoiConfig::default()
1006 },
1007 change_threshold_rows: 1,
1008 }
1009 }
1010}
1011
1012#[derive(Debug)]
1013struct InlineAutoRemeasureState {
1014 config: InlineAutoRemeasureConfig,
1015 sampler: VoiSampler,
1016}
1017
1018impl InlineAutoRemeasureState {
1019 fn new(config: InlineAutoRemeasureConfig) -> Self {
1020 let sampler = VoiSampler::new(config.voi.clone());
1021 Self { config, sampler }
1022 }
1023
1024 fn reset(&mut self) {
1025 self.sampler = VoiSampler::new(self.config.voi.clone());
1026 }
1027}
1028
1029#[derive(Debug, Clone)]
1030struct ConformalEvidence {
1031 bucket_key: String,
1032 n_b: usize,
1033 alpha: f64,
1034 q_b: f64,
1035 y_hat: f64,
1036 upper_us: f64,
1037 risk: bool,
1038 fallback_level: u8,
1039 window_size: usize,
1040 reset_count: u64,
1041}
1042
1043impl ConformalEvidence {
1044 fn from_prediction(prediction: &ConformalPrediction) -> Self {
1045 let alpha = (1.0 - prediction.confidence).clamp(0.0, 1.0);
1046 Self {
1047 bucket_key: prediction.bucket.to_string(),
1048 n_b: prediction.sample_count,
1049 alpha,
1050 q_b: prediction.quantile,
1051 y_hat: prediction.y_hat,
1052 upper_us: prediction.upper_us,
1053 risk: prediction.risk,
1054 fallback_level: prediction.fallback_level,
1055 window_size: prediction.window_size,
1056 reset_count: prediction.reset_count,
1057 }
1058 }
1059}
1060
1061#[derive(Debug, Clone)]
1062struct BudgetDecisionEvidence {
1063 frame_idx: u64,
1064 decision: BudgetDecision,
1065 controller_decision: BudgetDecision,
1066 degradation_before: DegradationLevel,
1067 degradation_after: DegradationLevel,
1068 frame_time_us: f64,
1069 budget_us: f64,
1070 pid_output: f64,
1071 pid_p: f64,
1072 pid_i: f64,
1073 pid_d: f64,
1074 e_value: f64,
1075 frames_observed: u32,
1076 frames_since_change: u32,
1077 in_warmup: bool,
1078 conformal: Option<ConformalEvidence>,
1079}
1080
1081impl BudgetDecisionEvidence {
1082 fn decision_from_levels(before: DegradationLevel, after: DegradationLevel) -> BudgetDecision {
1083 if after > before {
1084 BudgetDecision::Degrade
1085 } else if after < before {
1086 BudgetDecision::Upgrade
1087 } else {
1088 BudgetDecision::Hold
1089 }
1090 }
1091
1092 #[must_use]
1093 fn to_jsonl(&self) -> String {
1094 let conformal = self.conformal.as_ref();
1095 let bucket_key = Self::opt_str(conformal.map(|c| c.bucket_key.as_str()));
1096 let n_b = Self::opt_usize(conformal.map(|c| c.n_b));
1097 let alpha = Self::opt_f64(conformal.map(|c| c.alpha));
1098 let q_b = Self::opt_f64(conformal.map(|c| c.q_b));
1099 let y_hat = Self::opt_f64(conformal.map(|c| c.y_hat));
1100 let upper_us = Self::opt_f64(conformal.map(|c| c.upper_us));
1101 let risk = Self::opt_bool(conformal.map(|c| c.risk));
1102 let fallback_level = Self::opt_u8(conformal.map(|c| c.fallback_level));
1103 let window_size = Self::opt_usize(conformal.map(|c| c.window_size));
1104 let reset_count = Self::opt_u64(conformal.map(|c| c.reset_count));
1105
1106 format!(
1107 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":{}}}"#,
1108 self.frame_idx,
1109 self.decision.as_str(),
1110 self.controller_decision.as_str(),
1111 self.degradation_before.as_str(),
1112 self.degradation_after.as_str(),
1113 self.frame_time_us,
1114 self.budget_us,
1115 self.pid_output,
1116 self.pid_p,
1117 self.pid_i,
1118 self.pid_d,
1119 self.e_value,
1120 self.frames_observed,
1121 self.frames_since_change,
1122 self.in_warmup,
1123 bucket_key,
1124 n_b,
1125 alpha,
1126 q_b,
1127 y_hat,
1128 upper_us,
1129 risk,
1130 fallback_level,
1131 window_size,
1132 reset_count
1133 )
1134 }
1135
1136 fn opt_f64(value: Option<f64>) -> String {
1137 value
1138 .map(|v| format!("{v:.6}"))
1139 .unwrap_or_else(|| "null".to_string())
1140 }
1141
1142 fn opt_u64(value: Option<u64>) -> String {
1143 value
1144 .map(|v| v.to_string())
1145 .unwrap_or_else(|| "null".to_string())
1146 }
1147
1148 fn opt_u8(value: Option<u8>) -> String {
1149 value
1150 .map(|v| v.to_string())
1151 .unwrap_or_else(|| "null".to_string())
1152 }
1153
1154 fn opt_usize(value: Option<usize>) -> String {
1155 value
1156 .map(|v| v.to_string())
1157 .unwrap_or_else(|| "null".to_string())
1158 }
1159
1160 fn opt_bool(value: Option<bool>) -> String {
1161 value
1162 .map(|v| v.to_string())
1163 .unwrap_or_else(|| "null".to_string())
1164 }
1165
1166 fn opt_str(value: Option<&str>) -> String {
1167 value
1168 .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
1169 .unwrap_or_else(|| "null".to_string())
1170 }
1171}
1172
1173#[derive(Debug, Clone)]
1174struct FairnessConfigEvidence {
1175 enabled: bool,
1176 input_priority_threshold_ms: u64,
1177 dominance_threshold: u32,
1178 fairness_threshold: f64,
1179}
1180
1181impl FairnessConfigEvidence {
1182 #[must_use]
1183 fn to_jsonl(&self) -> String {
1184 format!(
1185 r#"{{"event":"fairness_config","enabled":{},"input_priority_threshold_ms":{},"dominance_threshold":{},"fairness_threshold":{:.6}}}"#,
1186 self.enabled,
1187 self.input_priority_threshold_ms,
1188 self.dominance_threshold,
1189 self.fairness_threshold
1190 )
1191 }
1192}
1193
1194#[derive(Debug, Clone)]
1195struct FairnessDecisionEvidence {
1196 frame_idx: u64,
1197 decision: &'static str,
1198 reason: &'static str,
1199 pending_input_latency_ms: Option<u64>,
1200 jain_index: f64,
1201 resize_dominance_count: u32,
1202 dominance_threshold: u32,
1203 fairness_threshold: f64,
1204 input_priority_threshold_ms: u64,
1205}
1206
1207impl FairnessDecisionEvidence {
1208 #[must_use]
1209 fn to_jsonl(&self) -> String {
1210 let pending_latency = self
1211 .pending_input_latency_ms
1212 .map(|v| v.to_string())
1213 .unwrap_or_else(|| "null".to_string());
1214 format!(
1215 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":{}}}"#,
1216 self.frame_idx,
1217 self.decision,
1218 self.reason,
1219 pending_latency,
1220 self.jain_index,
1221 self.resize_dominance_count,
1222 self.dominance_threshold,
1223 self.fairness_threshold,
1224 self.input_priority_threshold_ms
1225 )
1226 }
1227}
1228
1229#[derive(Debug, Clone)]
1230struct WidgetRefreshEntry {
1231 widget_id: u64,
1232 essential: bool,
1233 starved: bool,
1234 value: f32,
1235 cost_us: f32,
1236 score: f32,
1237 staleness_ms: u64,
1238}
1239
1240impl WidgetRefreshEntry {
1241 fn to_json(&self) -> String {
1242 format!(
1243 r#"{{"id":{},"cost_us":{:.3},"value":{:.4},"score":{:.4},"essential":{},"starved":{},"staleness_ms":{}}}"#,
1244 self.widget_id,
1245 self.cost_us,
1246 self.value,
1247 self.score,
1248 self.essential,
1249 self.starved,
1250 self.staleness_ms
1251 )
1252 }
1253}
1254
1255#[derive(Debug, Clone)]
1256struct WidgetRefreshPlan {
1257 frame_idx: u64,
1258 budget_us: f64,
1259 degradation: DegradationLevel,
1260 essentials_cost_us: f64,
1261 selected_cost_us: f64,
1262 selected_value: f64,
1263 signal_count: usize,
1264 selected: Vec<WidgetRefreshEntry>,
1265 skipped_count: usize,
1266 skipped_starved: usize,
1267 starved_selected: usize,
1268 over_budget: bool,
1269}
1270
1271impl WidgetRefreshPlan {
1272 fn new() -> Self {
1273 Self {
1274 frame_idx: 0,
1275 budget_us: 0.0,
1276 degradation: DegradationLevel::Full,
1277 essentials_cost_us: 0.0,
1278 selected_cost_us: 0.0,
1279 selected_value: 0.0,
1280 signal_count: 0,
1281 selected: Vec::new(),
1282 skipped_count: 0,
1283 skipped_starved: 0,
1284 starved_selected: 0,
1285 over_budget: false,
1286 }
1287 }
1288
1289 fn clear(&mut self) {
1290 self.frame_idx = 0;
1291 self.budget_us = 0.0;
1292 self.degradation = DegradationLevel::Full;
1293 self.essentials_cost_us = 0.0;
1294 self.selected_cost_us = 0.0;
1295 self.selected_value = 0.0;
1296 self.signal_count = 0;
1297 self.selected.clear();
1298 self.skipped_count = 0;
1299 self.skipped_starved = 0;
1300 self.starved_selected = 0;
1301 self.over_budget = false;
1302 }
1303
1304 fn as_budget(&self) -> WidgetBudget {
1305 if self.signal_count == 0 {
1306 return WidgetBudget::allow_all();
1307 }
1308 let ids = self.selected.iter().map(|entry| entry.widget_id).collect();
1309 WidgetBudget::allow_only(ids)
1310 }
1311
1312 fn recompute(
1313 &mut self,
1314 frame_idx: u64,
1315 budget_us: f64,
1316 degradation: DegradationLevel,
1317 signals: &[WidgetSignal],
1318 config: &WidgetRefreshConfig,
1319 ) {
1320 self.clear();
1321 self.frame_idx = frame_idx;
1322 self.budget_us = budget_us;
1323 self.degradation = degradation;
1324
1325 if !config.enabled || signals.is_empty() {
1326 return;
1327 }
1328
1329 self.signal_count = signals.len();
1330 let mut essentials_cost = 0.0f64;
1331 let mut selected_cost = 0.0f64;
1332 let mut selected_value = 0.0f64;
1333
1334 let staleness_window = config.staleness_window_ms.max(1) as f32;
1335 let mut candidates: Vec<WidgetRefreshEntry> = Vec::with_capacity(signals.len());
1336
1337 for signal in signals {
1338 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
1339 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
1340 let mut value = config.weight_priority * signal.priority
1341 + config.weight_staleness * staleness_score
1342 + config.weight_focus * signal.focus_boost
1343 + config.weight_interaction * signal.interaction_boost;
1344 if starved {
1345 value += config.starve_boost;
1346 }
1347 let raw_cost = if signal.recent_cost_us > 0.0 {
1348 signal.recent_cost_us
1349 } else {
1350 signal.cost_estimate_us
1351 };
1352 let cost_us = raw_cost.max(config.min_cost_us);
1353 let score = if cost_us > 0.0 {
1354 value / cost_us
1355 } else {
1356 value
1357 };
1358
1359 let entry = WidgetRefreshEntry {
1360 widget_id: signal.widget_id,
1361 essential: signal.essential,
1362 starved,
1363 value,
1364 cost_us,
1365 score,
1366 staleness_ms: signal.staleness_ms,
1367 };
1368
1369 if degradation >= DegradationLevel::EssentialOnly && !signal.essential {
1370 self.skipped_count += 1;
1371 if starved {
1372 self.skipped_starved = self.skipped_starved.saturating_add(1);
1373 }
1374 continue;
1375 }
1376
1377 if signal.essential {
1378 essentials_cost += cost_us as f64;
1379 selected_cost += cost_us as f64;
1380 selected_value += value as f64;
1381 if starved {
1382 self.starved_selected = self.starved_selected.saturating_add(1);
1383 }
1384 self.selected.push(entry);
1385 } else {
1386 candidates.push(entry);
1387 }
1388 }
1389
1390 let mut remaining = budget_us - selected_cost;
1391
1392 if degradation < DegradationLevel::EssentialOnly {
1393 let nonessential_total = candidates.len();
1394 let max_drop_fraction = config.max_drop_fraction.clamp(0.0, 1.0);
1395 let enforce_drop_rate = max_drop_fraction < 1.0 && nonessential_total > 0;
1396 let min_nonessential_selected = if enforce_drop_rate {
1397 let min_fraction = (1.0 - max_drop_fraction).max(0.0);
1398 ((min_fraction * nonessential_total as f32).ceil() as usize).min(nonessential_total)
1399 } else {
1400 0
1401 };
1402
1403 candidates.sort_by(|a, b| {
1404 b.starved
1405 .cmp(&a.starved)
1406 .then_with(|| b.score.total_cmp(&a.score))
1407 .then_with(|| b.value.total_cmp(&a.value))
1408 .then_with(|| a.cost_us.total_cmp(&b.cost_us))
1409 .then_with(|| a.widget_id.cmp(&b.widget_id))
1410 });
1411
1412 let mut forced_starved = 0usize;
1413 let mut nonessential_selected = 0usize;
1414 let mut skipped_candidates = if enforce_drop_rate {
1415 Vec::with_capacity(candidates.len())
1416 } else {
1417 Vec::new()
1418 };
1419
1420 for entry in candidates.into_iter() {
1421 if entry.starved && forced_starved >= config.max_starved_per_frame {
1422 self.skipped_count += 1;
1423 self.skipped_starved = self.skipped_starved.saturating_add(1);
1424 if enforce_drop_rate {
1425 skipped_candidates.push(entry);
1426 }
1427 continue;
1428 }
1429
1430 if remaining >= entry.cost_us as f64 {
1431 remaining -= entry.cost_us as f64;
1432 selected_cost += entry.cost_us as f64;
1433 selected_value += entry.value as f64;
1434 if entry.starved {
1435 self.starved_selected = self.starved_selected.saturating_add(1);
1436 forced_starved += 1;
1437 }
1438 nonessential_selected += 1;
1439 self.selected.push(entry);
1440 } else if entry.starved
1441 && forced_starved < config.max_starved_per_frame
1442 && nonessential_selected == 0
1443 {
1444 selected_cost += entry.cost_us as f64;
1446 selected_value += entry.value as f64;
1447 self.starved_selected = self.starved_selected.saturating_add(1);
1448 forced_starved += 1;
1449 nonessential_selected += 1;
1450 self.selected.push(entry);
1451 } else {
1452 self.skipped_count += 1;
1453 if entry.starved {
1454 self.skipped_starved = self.skipped_starved.saturating_add(1);
1455 }
1456 if enforce_drop_rate {
1457 skipped_candidates.push(entry);
1458 }
1459 }
1460 }
1461
1462 if enforce_drop_rate && nonessential_selected < min_nonessential_selected {
1463 for entry in skipped_candidates.into_iter() {
1464 if nonessential_selected >= min_nonessential_selected {
1465 break;
1466 }
1467 if entry.starved && forced_starved >= config.max_starved_per_frame {
1468 continue;
1469 }
1470 selected_cost += entry.cost_us as f64;
1471 selected_value += entry.value as f64;
1472 if entry.starved {
1473 self.starved_selected = self.starved_selected.saturating_add(1);
1474 forced_starved += 1;
1475 self.skipped_starved = self.skipped_starved.saturating_sub(1);
1476 }
1477 self.skipped_count = self.skipped_count.saturating_sub(1);
1478 nonessential_selected += 1;
1479 self.selected.push(entry);
1480 }
1481 }
1482 }
1483
1484 self.essentials_cost_us = essentials_cost;
1485 self.selected_cost_us = selected_cost;
1486 self.selected_value = selected_value;
1487 self.over_budget = selected_cost > budget_us;
1488 }
1489
1490 #[must_use]
1491 fn to_jsonl(&self) -> String {
1492 let mut out = String::with_capacity(256 + self.selected.len() * 96);
1493 out.push_str(r#"{"event":"widget_refresh""#);
1494 out.push_str(&format!(
1495 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":{}"#,
1496 self.frame_idx,
1497 self.budget_us,
1498 self.degradation.as_str(),
1499 self.essentials_cost_us,
1500 self.selected_cost_us,
1501 self.selected_value,
1502 self.selected.len(),
1503 self.skipped_count,
1504 self.starved_selected,
1505 self.skipped_starved,
1506 self.over_budget
1507 ));
1508 out.push_str(r#","selected":["#);
1509 for (i, entry) in self.selected.iter().enumerate() {
1510 if i > 0 {
1511 out.push(',');
1512 }
1513 out.push_str(&entry.to_json());
1514 }
1515 out.push_str("]}");
1516 out
1517 }
1518}
1519
1520pub struct Program<M: Model, W: Write + Send = Stdout> {
1522 model: M,
1524 writer: TerminalWriter<W>,
1526 session: TerminalSession,
1528 running: bool,
1530 tick_rate: Option<Duration>,
1532 last_tick: Instant,
1534 dirty: bool,
1536 frame_idx: u64,
1538 widget_signals: Vec<WidgetSignal>,
1540 widget_refresh_config: WidgetRefreshConfig,
1542 widget_refresh_plan: WidgetRefreshPlan,
1544 width: u16,
1546 height: u16,
1548 forced_size: Option<(u16, u16)>,
1550 poll_timeout: Duration,
1552 budget: RenderBudget,
1554 conformal_predictor: Option<ConformalPredictor>,
1556 last_frame_time_us: Option<f64>,
1558 last_update_us: Option<u64>,
1560 frame_timing: Option<FrameTimingConfig>,
1562 locale_context: LocaleContext,
1564 locale_version: u64,
1566 resize_coalescer: ResizeCoalescer,
1568 evidence_sink: Option<EvidenceSink>,
1570 fairness_config_logged: bool,
1572 resize_behavior: ResizeBehavior,
1574 fairness_guard: InputFairnessGuard,
1576 event_recorder: Option<EventRecorder>,
1578 subscriptions: SubscriptionManager<M::Message>,
1580 task_sender: std::sync::mpsc::Sender<M::Message>,
1582 task_receiver: std::sync::mpsc::Receiver<M::Message>,
1584 task_handles: Vec<std::thread::JoinHandle<()>>,
1586 effect_queue: Option<EffectQueue<M::Message>>,
1588 state_registry: Option<std::sync::Arc<StateRegistry>>,
1590 persistence_config: PersistenceConfig,
1592 last_checkpoint: Instant,
1594 inline_auto_remeasure: Option<InlineAutoRemeasureState>,
1596}
1597
1598impl<M: Model> Program<M, Stdout> {
1599 pub fn new(model: M) -> io::Result<Self>
1601 where
1602 M::Message: Send + 'static,
1603 {
1604 Self::with_config(model, ProgramConfig::default())
1605 }
1606
1607 pub fn with_config(model: M, config: ProgramConfig) -> io::Result<Self>
1609 where
1610 M::Message: Send + 'static,
1611 {
1612 let capabilities = TerminalCapabilities::with_overrides();
1613 let session = TerminalSession::new(SessionOptions {
1614 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
1615 mouse_capture: config.mouse,
1616 bracketed_paste: config.bracketed_paste,
1617 focus_events: config.focus_reporting,
1618 kitty_keyboard: config.kitty_keyboard,
1619 })?;
1620
1621 let mut writer = TerminalWriter::with_diff_config(
1622 io::stdout(),
1623 config.screen_mode,
1624 config.ui_anchor,
1625 capabilities,
1626 config.diff_config.clone(),
1627 );
1628
1629 let frame_timing = config.frame_timing.clone();
1630 writer.set_timing_enabled(frame_timing.is_some());
1631
1632 let evidence_sink = EvidenceSink::from_config(&config.evidence_sink)?;
1633 if let Some(ref sink) = evidence_sink {
1634 writer = writer.with_evidence_sink(sink.clone());
1635 }
1636
1637 let render_trace = crate::RenderTraceRecorder::from_config(
1638 &config.render_trace,
1639 crate::RenderTraceContext {
1640 capabilities: writer.capabilities(),
1641 diff_config: config.diff_config.clone(),
1642 resize_config: config.resize_coalescer.clone(),
1643 conformal_config: config.conformal_config.clone(),
1644 },
1645 )?;
1646 if let Some(recorder) = render_trace {
1647 writer = writer.with_render_trace(recorder);
1648 }
1649
1650 let (w, h) = config
1652 .forced_size
1653 .unwrap_or_else(|| session.size().unwrap_or((80, 24)));
1654 let width = w.max(1);
1655 let height = h.max(1);
1656 writer.set_size(width, height);
1657
1658 let budget = RenderBudget::from_config(&config.budget);
1659 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
1660 let locale_context = config.locale_context.clone();
1661 let locale_version = locale_context.version();
1662 let mut resize_coalescer =
1663 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height))
1664 .with_screen_mode(config.screen_mode);
1665 if let Some(ref sink) = evidence_sink {
1666 resize_coalescer = resize_coalescer.with_evidence_sink(sink.clone());
1667 }
1668 let subscriptions = SubscriptionManager::new();
1669 let (task_sender, task_receiver) = std::sync::mpsc::channel();
1670 let inline_auto_remeasure = config
1671 .inline_auto_remeasure
1672 .clone()
1673 .map(InlineAutoRemeasureState::new);
1674 let effect_queue = if config.effect_queue.enabled {
1675 Some(EffectQueue::start(
1676 config.effect_queue.clone(),
1677 task_sender.clone(),
1678 evidence_sink.clone(),
1679 ))
1680 } else {
1681 None
1682 };
1683
1684 Ok(Self {
1685 model,
1686 writer,
1687 session,
1688 running: true,
1689 tick_rate: None,
1690 last_tick: Instant::now(),
1691 dirty: true,
1692 frame_idx: 0,
1693 widget_signals: Vec::new(),
1694 widget_refresh_config: config.widget_refresh,
1695 widget_refresh_plan: WidgetRefreshPlan::new(),
1696 width,
1697 height,
1698 forced_size: config.forced_size,
1699 poll_timeout: config.poll_timeout,
1700 budget,
1701 conformal_predictor,
1702 last_frame_time_us: None,
1703 last_update_us: None,
1704 frame_timing,
1705 locale_context,
1706 locale_version,
1707 resize_coalescer,
1708 evidence_sink,
1709 fairness_config_logged: false,
1710 resize_behavior: config.resize_behavior,
1711 fairness_guard: InputFairnessGuard::new(),
1712 event_recorder: None,
1713 subscriptions,
1714 task_sender,
1715 task_receiver,
1716 task_handles: Vec::new(),
1717 effect_queue,
1718 state_registry: config.persistence.registry.clone(),
1719 persistence_config: config.persistence,
1720 last_checkpoint: Instant::now(),
1721 inline_auto_remeasure,
1722 })
1723 }
1724}
1725
1726impl<M: Model, W: Write + Send> Program<M, W> {
1727 pub fn run(&mut self) -> io::Result<()> {
1735 self.run_event_loop()
1736 }
1737
1738 #[inline]
1740 pub fn last_widget_signals(&self) -> &[WidgetSignal] {
1741 &self.widget_signals
1742 }
1743
1744 fn run_event_loop(&mut self) -> io::Result<()> {
1746 if self.persistence_config.auto_load {
1748 self.load_state();
1749 }
1750
1751 let cmd = {
1753 let _span = info_span!("ftui.program.init").entered();
1754 self.model.init()
1755 };
1756 self.execute_cmd(cmd)?;
1757
1758 self.reconcile_subscriptions();
1760
1761 self.render_frame()?;
1763
1764 let mut loop_count: u64 = 0;
1766 while self.running {
1767 loop_count += 1;
1768 if loop_count.is_multiple_of(100) {
1770 crate::debug_trace!("main loop heartbeat: iteration {}", loop_count);
1771 }
1772
1773 let timeout = self.effective_timeout();
1775
1776 if self.session.poll_event(timeout)? {
1778 loop {
1780 if let Some(event) = self.session.read_event()? {
1782 self.handle_event(event)?;
1783 }
1784 if !self.session.poll_event(Duration::from_millis(0))? {
1785 break;
1786 }
1787 }
1788 }
1789
1790 self.process_subscription_messages()?;
1792
1793 self.process_task_results()?;
1795 self.reap_finished_tasks();
1796
1797 self.process_resize_coalescer()?;
1798
1799 if self.should_tick() {
1801 let msg = M::Message::from(Event::Tick);
1802 let cmd = {
1803 let _span = debug_span!(
1804 "ftui.program.update",
1805 msg_type = "Tick",
1806 duration_us = tracing::field::Empty,
1807 cmd_type = tracing::field::Empty
1808 )
1809 .entered();
1810 let start = Instant::now();
1811 let cmd = self.model.update(msg);
1812 tracing::Span::current()
1813 .record("duration_us", start.elapsed().as_micros() as u64);
1814 tracing::Span::current()
1815 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
1816 cmd
1817 };
1818 self.mark_dirty();
1819 self.execute_cmd(cmd)?;
1820 self.reconcile_subscriptions();
1821 }
1822
1823 self.check_checkpoint_save();
1825
1826 self.check_locale_change();
1828
1829 if self.dirty {
1831 self.render_frame()?;
1832 }
1833
1834 if loop_count.is_multiple_of(1000) {
1836 self.writer.gc();
1837 }
1838 }
1839
1840 if self.persistence_config.auto_save {
1842 self.save_state();
1843 }
1844
1845 self.subscriptions.stop_all();
1847 self.reap_finished_tasks();
1848
1849 Ok(())
1850 }
1851
1852 fn load_state(&mut self) {
1854 if let Some(registry) = &self.state_registry {
1855 match registry.load() {
1856 Ok(count) => {
1857 info!(count, "loaded widget state from persistence");
1858 }
1859 Err(e) => {
1860 tracing::warn!(error = %e, "failed to load widget state");
1861 }
1862 }
1863 }
1864 }
1865
1866 fn save_state(&mut self) {
1868 if let Some(registry) = &self.state_registry {
1869 match registry.flush() {
1870 Ok(true) => {
1871 debug!("saved widget state to persistence");
1872 }
1873 Ok(false) => {
1874 }
1876 Err(e) => {
1877 tracing::warn!(error = %e, "failed to save widget state");
1878 }
1879 }
1880 }
1881 }
1882
1883 fn check_checkpoint_save(&mut self) {
1885 if let Some(interval) = self.persistence_config.checkpoint_interval
1886 && self.last_checkpoint.elapsed() >= interval
1887 {
1888 self.save_state();
1889 self.last_checkpoint = Instant::now();
1890 }
1891 }
1892
1893 fn handle_event(&mut self, event: Event) -> io::Result<()> {
1894 let event_start = Instant::now();
1896 let fairness_event_type = Self::classify_event_for_fairness(&event);
1897 if fairness_event_type == FairnessEventType::Input {
1898 self.fairness_guard.input_arrived(event_start);
1899 }
1900
1901 if let Some(recorder) = &mut self.event_recorder {
1903 recorder.record(&event);
1904 }
1905
1906 let event = match event {
1907 Event::Resize { width, height } => {
1908 debug!(
1909 width,
1910 height,
1911 behavior = ?self.resize_behavior,
1912 "Resize event received"
1913 );
1914 if let Some((forced_width, forced_height)) = self.forced_size {
1915 debug!(
1916 forced_width,
1917 forced_height, "Resize ignored due to forced size override"
1918 );
1919 self.fairness_guard.event_processed(
1920 fairness_event_type,
1921 event_start.elapsed(),
1922 Instant::now(),
1923 );
1924 return Ok(());
1925 }
1926 let width = width.max(1);
1928 let height = height.max(1);
1929 match self.resize_behavior {
1930 ResizeBehavior::Immediate => {
1931 self.resize_coalescer
1932 .record_external_apply(width, height, Instant::now());
1933 let result = self.apply_resize(width, height, Duration::ZERO, false);
1934 self.fairness_guard.event_processed(
1935 fairness_event_type,
1936 event_start.elapsed(),
1937 Instant::now(),
1938 );
1939 return result;
1940 }
1941 ResizeBehavior::Throttled => {
1942 let action = self.resize_coalescer.handle_resize(width, height);
1943 if let CoalesceAction::ApplyResize {
1944 width,
1945 height,
1946 coalesce_time,
1947 forced_by_deadline,
1948 } = action
1949 {
1950 let result =
1951 self.apply_resize(width, height, coalesce_time, forced_by_deadline);
1952 self.fairness_guard.event_processed(
1953 fairness_event_type,
1954 event_start.elapsed(),
1955 Instant::now(),
1956 );
1957 return result;
1958 }
1959
1960 self.fairness_guard.event_processed(
1961 fairness_event_type,
1962 event_start.elapsed(),
1963 Instant::now(),
1964 );
1965 return Ok(());
1966 }
1967 }
1968 }
1969 other => other,
1970 };
1971
1972 let msg = M::Message::from(event);
1973 let cmd = {
1974 let _span = debug_span!(
1975 "ftui.program.update",
1976 msg_type = "event",
1977 duration_us = tracing::field::Empty,
1978 cmd_type = tracing::field::Empty
1979 )
1980 .entered();
1981 let start = Instant::now();
1982 let cmd = self.model.update(msg);
1983 let elapsed_us = start.elapsed().as_micros() as u64;
1984 self.last_update_us = Some(elapsed_us);
1985 tracing::Span::current().record("duration_us", elapsed_us);
1986 tracing::Span::current()
1987 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
1988 cmd
1989 };
1990 self.mark_dirty();
1991 self.execute_cmd(cmd)?;
1992 self.reconcile_subscriptions();
1993
1994 self.fairness_guard.event_processed(
1996 fairness_event_type,
1997 event_start.elapsed(),
1998 Instant::now(),
1999 );
2000
2001 Ok(())
2002 }
2003
2004 fn classify_event_for_fairness(event: &Event) -> FairnessEventType {
2006 match event {
2007 Event::Key(_)
2008 | Event::Mouse(_)
2009 | Event::Paste(_)
2010 | Event::Focus(_)
2011 | Event::Clipboard(_) => FairnessEventType::Input,
2012 Event::Resize { .. } => FairnessEventType::Resize,
2013 Event::Tick => FairnessEventType::Tick,
2014 }
2015 }
2016
2017 fn reconcile_subscriptions(&mut self) {
2019 let _span = debug_span!(
2020 "ftui.program.subscriptions",
2021 active_count = tracing::field::Empty,
2022 started = tracing::field::Empty,
2023 stopped = tracing::field::Empty
2024 )
2025 .entered();
2026 let subs = self.model.subscriptions();
2027 let before_count = self.subscriptions.active_count();
2028 self.subscriptions.reconcile(subs);
2029 let after_count = self.subscriptions.active_count();
2030 let started = after_count.saturating_sub(before_count);
2031 let stopped = before_count.saturating_sub(after_count);
2032 crate::debug_trace!(
2033 "subscriptions reconcile: before={}, after={}, started={}, stopped={}",
2034 before_count,
2035 after_count,
2036 started,
2037 stopped
2038 );
2039 if after_count == 0 {
2040 crate::debug_trace!("subscriptions reconcile: no active subscriptions");
2041 }
2042 let current = tracing::Span::current();
2043 current.record("active_count", after_count);
2044 current.record("started", started);
2046 current.record("stopped", stopped);
2047 }
2048
2049 fn process_subscription_messages(&mut self) -> io::Result<()> {
2051 let messages = self.subscriptions.drain_messages();
2052 let msg_count = messages.len();
2053 if msg_count > 0 {
2054 crate::debug_trace!("processing {} subscription message(s)", msg_count);
2055 }
2056 for msg in messages {
2057 let cmd = {
2058 let _span = debug_span!(
2059 "ftui.program.update",
2060 msg_type = "subscription",
2061 duration_us = tracing::field::Empty,
2062 cmd_type = tracing::field::Empty
2063 )
2064 .entered();
2065 let start = Instant::now();
2066 let cmd = self.model.update(msg);
2067 let elapsed_us = start.elapsed().as_micros() as u64;
2068 self.last_update_us = Some(elapsed_us);
2069 tracing::Span::current().record("duration_us", elapsed_us);
2070 tracing::Span::current()
2071 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
2072 cmd
2073 };
2074 self.mark_dirty();
2075 self.execute_cmd(cmd)?;
2076 }
2077 if self.dirty {
2078 self.reconcile_subscriptions();
2079 }
2080 Ok(())
2081 }
2082
2083 fn process_task_results(&mut self) -> io::Result<()> {
2085 while let Ok(msg) = self.task_receiver.try_recv() {
2086 let cmd = {
2087 let _span = debug_span!(
2088 "ftui.program.update",
2089 msg_type = "task",
2090 duration_us = tracing::field::Empty,
2091 cmd_type = tracing::field::Empty
2092 )
2093 .entered();
2094 let start = Instant::now();
2095 let cmd = self.model.update(msg);
2096 let elapsed_us = start.elapsed().as_micros() as u64;
2097 self.last_update_us = Some(elapsed_us);
2098 tracing::Span::current().record("duration_us", elapsed_us);
2099 tracing::Span::current()
2100 .record("cmd_type", format!("{:?}", std::mem::discriminant(&cmd)));
2101 cmd
2102 };
2103 self.mark_dirty();
2104 self.execute_cmd(cmd)?;
2105 }
2106 if self.dirty {
2107 self.reconcile_subscriptions();
2108 }
2109 Ok(())
2110 }
2111
2112 fn execute_cmd(&mut self, cmd: Cmd<M::Message>) -> io::Result<()> {
2114 match cmd {
2115 Cmd::None => {}
2116 Cmd::Quit => self.running = false,
2117 Cmd::Msg(m) => {
2118 let start = Instant::now();
2119 let cmd = self.model.update(m);
2120 let elapsed_us = start.elapsed().as_micros() as u64;
2121 self.last_update_us = Some(elapsed_us);
2122 self.mark_dirty();
2123 self.execute_cmd(cmd)?;
2124 }
2125 Cmd::Batch(cmds) => {
2126 for c in cmds {
2129 self.execute_cmd(c)?;
2130 if !self.running {
2131 break;
2132 }
2133 }
2134 }
2135 Cmd::Sequence(cmds) => {
2136 for c in cmds {
2137 self.execute_cmd(c)?;
2138 if !self.running {
2139 break;
2140 }
2141 }
2142 }
2143 Cmd::Tick(duration) => {
2144 self.tick_rate = Some(duration);
2145 self.last_tick = Instant::now();
2146 }
2147 Cmd::Log(text) => {
2148 let sanitized = sanitize(&text);
2149 if sanitized.ends_with('\n') {
2150 self.writer.write_log(&sanitized)?;
2151 } else {
2152 let mut owned = sanitized.into_owned();
2153 owned.push('\n');
2154 self.writer.write_log(&owned)?;
2155 }
2156 }
2157 Cmd::Task(spec, f) => {
2158 if let Some(ref queue) = self.effect_queue {
2159 queue.enqueue(spec, f);
2160 } else {
2161 let sender = self.task_sender.clone();
2162 let handle = std::thread::spawn(move || {
2163 let msg = f();
2164 let _ = sender.send(msg);
2165 });
2166 self.task_handles.push(handle);
2167 }
2168 }
2169 Cmd::SaveState => {
2170 self.save_state();
2171 }
2172 Cmd::RestoreState => {
2173 self.load_state();
2174 }
2175 }
2176 Ok(())
2177 }
2178
2179 fn reap_finished_tasks(&mut self) {
2180 if self.task_handles.is_empty() {
2181 return;
2182 }
2183
2184 let mut remaining = Vec::with_capacity(self.task_handles.len());
2185 for handle in self.task_handles.drain(..) {
2186 if handle.is_finished() {
2187 let _ = handle.join();
2188 } else {
2189 remaining.push(handle);
2190 }
2191 }
2192 self.task_handles = remaining;
2193 }
2194
2195 fn render_frame(&mut self) -> io::Result<()> {
2197 crate::debug_trace!("render_frame: {}x{}", self.width, self.height);
2198
2199 self.frame_idx = self.frame_idx.wrapping_add(1);
2200 let frame_idx = self.frame_idx;
2201 let degradation_start = self.budget.degradation();
2202
2203 self.budget.next_frame();
2205
2206 let mut conformal_prediction = None;
2208 if let Some(predictor) = self.conformal_predictor.as_ref() {
2209 let baseline_us = self
2210 .last_frame_time_us
2211 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
2212 let diff_strategy = self
2213 .writer
2214 .last_diff_strategy()
2215 .unwrap_or(DiffStrategy::Full);
2216 let frame_height_hint = self.writer.render_height_hint().max(1);
2217 let key = BucketKey::from_context(
2218 self.writer.screen_mode(),
2219 diff_strategy,
2220 self.width,
2221 frame_height_hint,
2222 );
2223 let budget_us = self.budget.total().as_secs_f64() * 1_000_000.0;
2224 let prediction = predictor.predict(key, baseline_us, budget_us);
2225 if prediction.risk {
2226 self.budget.degrade();
2227 }
2228 debug!(
2229 bucket = %prediction.bucket,
2230 upper_us = prediction.upper_us,
2231 budget_us = prediction.budget_us,
2232 fallback = prediction.fallback_level,
2233 risk = prediction.risk,
2234 "conformal risk gate"
2235 );
2236 conformal_prediction = Some(prediction);
2237 }
2238
2239 if self.budget.exhausted() {
2241 self.budget.record_frame_time(Duration::ZERO);
2242 self.emit_budget_evidence(
2243 frame_idx,
2244 degradation_start,
2245 0.0,
2246 conformal_prediction.as_ref(),
2247 );
2248 crate::debug_trace!(
2249 "frame skipped: budget exhausted (degradation={})",
2250 self.budget.degradation().as_str()
2251 );
2252 debug!(
2253 degradation = self.budget.degradation().as_str(),
2254 "frame skipped: budget exhausted before render"
2255 );
2256 self.dirty = false;
2257 return Ok(());
2258 }
2259
2260 let auto_bounds = self.writer.inline_auto_bounds();
2261 let needs_measure = auto_bounds.is_some() && self.writer.auto_ui_height().is_none();
2262 let mut should_measure = needs_measure;
2263 if auto_bounds.is_some()
2264 && let Some(state) = self.inline_auto_remeasure.as_mut()
2265 {
2266 let decision = state.sampler.decide(Instant::now());
2267 if decision.should_sample {
2268 should_measure = true;
2269 }
2270 } else {
2271 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
2272 }
2273
2274 let render_start = Instant::now();
2276 if let (Some((min_height, max_height)), true) = (auto_bounds, should_measure) {
2277 let measure_height = if needs_measure {
2278 self.writer.render_height_hint().max(1)
2279 } else {
2280 max_height.max(1)
2281 };
2282 let (measure_buffer, _) = self.render_measure_buffer(measure_height);
2283 let measured_height = measure_buffer.content_height();
2284 let clamped = measured_height.clamp(min_height, max_height);
2285 let previous_height = self.writer.auto_ui_height();
2286 self.writer.set_auto_ui_height(clamped);
2287 if let Some(state) = self.inline_auto_remeasure.as_mut() {
2288 let threshold = state.config.change_threshold_rows;
2289 let violated = previous_height
2290 .map(|prev| prev.abs_diff(clamped) >= threshold)
2291 .unwrap_or(false);
2292 state.sampler.observe(violated);
2293 }
2294 }
2295 if auto_bounds.is_some()
2296 && let Some(state) = self.inline_auto_remeasure.as_ref()
2297 {
2298 let snapshot = state.sampler.snapshot(8, crate::debug_trace::elapsed_ms());
2299 crate::voi_telemetry::set_inline_auto_voi_snapshot(Some(snapshot));
2300 }
2301
2302 let frame_height = self.writer.render_height_hint().max(1);
2303 let _frame_span = info_span!(
2304 "ftui.render.frame",
2305 width = self.width,
2306 height = frame_height,
2307 duration_us = tracing::field::Empty
2308 )
2309 .entered();
2310 let (buffer, cursor, cursor_visible) = self.render_buffer(frame_height);
2311 self.update_widget_refresh_plan(frame_idx);
2312 let render_elapsed = render_start.elapsed();
2313 let mut present_elapsed = Duration::ZERO;
2314 let mut presented = false;
2315
2316 let render_budget = self.budget.phase_budgets().render;
2318 if render_elapsed > render_budget {
2319 debug!(
2320 render_ms = render_elapsed.as_millis() as u32,
2321 budget_ms = render_budget.as_millis() as u32,
2322 "render phase exceeded budget"
2323 );
2324 if self.budget.should_degrade(render_budget) {
2326 self.budget.degrade();
2327 }
2328 }
2329
2330 if !self.budget.exhausted() {
2332 let present_start = Instant::now();
2333 {
2334 let _present_span = debug_span!("ftui.render.present").entered();
2335 self.writer
2336 .present_ui_owned(buffer, cursor, cursor_visible)?;
2337 }
2338 presented = true;
2339 present_elapsed = present_start.elapsed();
2340
2341 let present_budget = self.budget.phase_budgets().present;
2342 if present_elapsed > present_budget {
2343 debug!(
2344 present_ms = present_elapsed.as_millis() as u32,
2345 budget_ms = present_budget.as_millis() as u32,
2346 "present phase exceeded budget"
2347 );
2348 }
2349 } else {
2350 debug!(
2351 degradation = self.budget.degradation().as_str(),
2352 elapsed_ms = self.budget.elapsed().as_millis() as u32,
2353 "frame present skipped: budget exhausted after render"
2354 );
2355 }
2356
2357 if let Some(ref frame_timing) = self.frame_timing {
2358 let update_us = self.last_update_us.unwrap_or(0);
2359 let render_us = render_elapsed.as_micros() as u64;
2360 let present_us = present_elapsed.as_micros() as u64;
2361 let diff_us = if presented {
2362 self.writer
2363 .take_last_present_timings()
2364 .map(|timings| timings.diff_us)
2365 .unwrap_or(0)
2366 } else {
2367 let _ = self.writer.take_last_present_timings();
2368 0
2369 };
2370 let total_us = update_us
2371 .saturating_add(render_us)
2372 .saturating_add(present_us);
2373 let timing = FrameTiming {
2374 frame_idx,
2375 update_us,
2376 render_us,
2377 diff_us,
2378 present_us,
2379 total_us,
2380 };
2381 frame_timing.sink.record_frame(&timing);
2382 }
2383
2384 let frame_time = render_elapsed.saturating_add(present_elapsed);
2385 self.budget.record_frame_time(frame_time);
2386 let frame_time_us = frame_time.as_secs_f64() * 1_000_000.0;
2387
2388 if let (Some(predictor), Some(prediction)) = (
2389 self.conformal_predictor.as_mut(),
2390 conformal_prediction.as_ref(),
2391 ) {
2392 let diff_strategy = self
2393 .writer
2394 .last_diff_strategy()
2395 .unwrap_or(DiffStrategy::Full);
2396 let key = BucketKey::from_context(
2397 self.writer.screen_mode(),
2398 diff_strategy,
2399 self.width,
2400 frame_height,
2401 );
2402 predictor.observe(key, prediction.y_hat, frame_time_us);
2403 }
2404 self.last_frame_time_us = Some(frame_time_us);
2405 self.emit_budget_evidence(
2406 frame_idx,
2407 degradation_start,
2408 frame_time_us,
2409 conformal_prediction.as_ref(),
2410 );
2411 self.dirty = false;
2412
2413 Ok(())
2414 }
2415
2416 fn emit_budget_evidence(
2417 &self,
2418 frame_idx: u64,
2419 degradation_start: DegradationLevel,
2420 frame_time_us: f64,
2421 conformal_prediction: Option<&ConformalPrediction>,
2422 ) {
2423 let Some(telemetry) = self.budget.telemetry() else {
2424 set_budget_snapshot(None);
2425 return;
2426 };
2427
2428 let budget_us = conformal_prediction
2429 .map(|prediction| prediction.budget_us)
2430 .unwrap_or_else(|| self.budget.total().as_secs_f64() * 1_000_000.0);
2431 let conformal = conformal_prediction.map(ConformalEvidence::from_prediction);
2432 let degradation_after = self.budget.degradation();
2433
2434 let evidence = BudgetDecisionEvidence {
2435 frame_idx,
2436 decision: BudgetDecisionEvidence::decision_from_levels(
2437 degradation_start,
2438 degradation_after,
2439 ),
2440 controller_decision: telemetry.last_decision,
2441 degradation_before: degradation_start,
2442 degradation_after,
2443 frame_time_us,
2444 budget_us,
2445 pid_output: telemetry.pid_output,
2446 pid_p: telemetry.pid_p,
2447 pid_i: telemetry.pid_i,
2448 pid_d: telemetry.pid_d,
2449 e_value: telemetry.e_value,
2450 frames_observed: telemetry.frames_observed,
2451 frames_since_change: telemetry.frames_since_change,
2452 in_warmup: telemetry.in_warmup,
2453 conformal,
2454 };
2455
2456 let conformal_snapshot = evidence
2457 .conformal
2458 .as_ref()
2459 .map(|snapshot| ConformalSnapshot {
2460 bucket_key: snapshot.bucket_key.clone(),
2461 sample_count: snapshot.n_b,
2462 upper_us: snapshot.upper_us,
2463 risk: snapshot.risk,
2464 });
2465 set_budget_snapshot(Some(BudgetDecisionSnapshot {
2466 frame_idx: evidence.frame_idx,
2467 decision: evidence.decision,
2468 controller_decision: evidence.controller_decision,
2469 degradation_before: evidence.degradation_before,
2470 degradation_after: evidence.degradation_after,
2471 frame_time_us: evidence.frame_time_us,
2472 budget_us: evidence.budget_us,
2473 pid_output: evidence.pid_output,
2474 e_value: evidence.e_value,
2475 frames_observed: evidence.frames_observed,
2476 frames_since_change: evidence.frames_since_change,
2477 in_warmup: evidence.in_warmup,
2478 conformal: conformal_snapshot,
2479 }));
2480
2481 if let Some(ref sink) = self.evidence_sink {
2482 let _ = sink.write_jsonl(&evidence.to_jsonl());
2483 }
2484 }
2485
2486 fn update_widget_refresh_plan(&mut self, frame_idx: u64) {
2487 if !self.widget_refresh_config.enabled {
2488 self.widget_refresh_plan.clear();
2489 return;
2490 }
2491
2492 let budget_us = self.budget.phase_budgets().render.as_secs_f64() * 1_000_000.0;
2493 let degradation = self.budget.degradation();
2494 self.widget_refresh_plan.recompute(
2495 frame_idx,
2496 budget_us,
2497 degradation,
2498 &self.widget_signals,
2499 &self.widget_refresh_config,
2500 );
2501
2502 if let Some(ref sink) = self.evidence_sink {
2503 let _ = sink.write_jsonl(&self.widget_refresh_plan.to_jsonl());
2504 }
2505 }
2506
2507 fn render_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>, bool) {
2508 let buffer = self.writer.take_render_buffer(self.width, frame_height);
2511 let (pool, links) = self.writer.pool_and_links_mut();
2512 let mut frame = Frame::from_buffer(buffer, pool);
2513 frame.set_degradation(self.budget.degradation());
2514 frame.set_links(links);
2515 frame.set_widget_budget(self.widget_refresh_plan.as_budget());
2516
2517 let view_start = Instant::now();
2518 let _view_span = debug_span!(
2519 "ftui.program.view",
2520 duration_us = tracing::field::Empty,
2521 widget_count = tracing::field::Empty
2522 )
2523 .entered();
2524 self.model.view(&mut frame);
2525 self.widget_signals = frame.take_widget_signals();
2526 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
2527 (frame.buffer, frame.cursor_position, frame.cursor_visible)
2530 }
2531
2532 fn emit_fairness_evidence(&mut self, decision: &FairnessDecision, dominance_count: u32) {
2533 let Some(ref sink) = self.evidence_sink else {
2534 return;
2535 };
2536
2537 let config = self.fairness_guard.config();
2538 if !self.fairness_config_logged {
2539 let config_entry = FairnessConfigEvidence {
2540 enabled: config.enabled,
2541 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
2542 dominance_threshold: config.dominance_threshold,
2543 fairness_threshold: config.fairness_threshold,
2544 };
2545 let _ = sink.write_jsonl(&config_entry.to_jsonl());
2546 self.fairness_config_logged = true;
2547 }
2548
2549 let evidence = FairnessDecisionEvidence {
2550 frame_idx: self.frame_idx,
2551 decision: if decision.should_process {
2552 "allow"
2553 } else {
2554 "yield"
2555 },
2556 reason: decision.reason.as_str(),
2557 pending_input_latency_ms: decision
2558 .pending_input_latency
2559 .map(|latency| latency.as_millis() as u64),
2560 jain_index: decision.jain_index,
2561 resize_dominance_count: dominance_count,
2562 dominance_threshold: config.dominance_threshold,
2563 fairness_threshold: config.fairness_threshold,
2564 input_priority_threshold_ms: config.input_priority_threshold.as_millis() as u64,
2565 };
2566
2567 let _ = sink.write_jsonl(&evidence.to_jsonl());
2568 }
2569
2570 fn render_measure_buffer(&mut self, frame_height: u16) -> (Buffer, Option<(u16, u16)>) {
2571 let pool = self.writer.pool_mut();
2572 let mut frame = Frame::new(self.width, frame_height, pool);
2573 frame.set_degradation(self.budget.degradation());
2574
2575 let view_start = Instant::now();
2576 let _view_span = debug_span!(
2577 "ftui.program.view",
2578 duration_us = tracing::field::Empty,
2579 widget_count = tracing::field::Empty
2580 )
2581 .entered();
2582 self.model.view(&mut frame);
2583 tracing::Span::current().record("duration_us", view_start.elapsed().as_micros() as u64);
2584
2585 (frame.buffer, frame.cursor_position)
2586 }
2587
2588 fn effective_timeout(&self) -> Duration {
2590 if let Some(tick_rate) = self.tick_rate {
2591 let elapsed = self.last_tick.elapsed();
2592 let mut timeout = tick_rate.saturating_sub(elapsed);
2593 if self.resize_behavior.uses_coalescer()
2594 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
2595 {
2596 timeout = timeout.min(resize_timeout);
2597 }
2598 timeout
2599 } else {
2600 let mut timeout = self.poll_timeout;
2601 if self.resize_behavior.uses_coalescer()
2602 && let Some(resize_timeout) = self.resize_coalescer.time_until_apply(Instant::now())
2603 {
2604 timeout = timeout.min(resize_timeout);
2605 }
2606 timeout
2607 }
2608 }
2609
2610 fn should_tick(&mut self) -> bool {
2612 if let Some(tick_rate) = self.tick_rate
2613 && self.last_tick.elapsed() >= tick_rate
2614 {
2615 self.last_tick = Instant::now();
2616 return true;
2617 }
2618 false
2619 }
2620
2621 fn process_resize_coalescer(&mut self) -> io::Result<()> {
2622 if !self.resize_behavior.uses_coalescer() {
2623 return Ok(());
2624 }
2625
2626 let dominance_count = self.fairness_guard.resize_dominance_count();
2629 let fairness_decision = self.fairness_guard.check_fairness(Instant::now());
2630 self.emit_fairness_evidence(&fairness_decision, dominance_count);
2631 if !fairness_decision.should_process {
2632 debug!(
2633 reason = ?fairness_decision.reason,
2634 pending_latency_ms = fairness_decision.pending_input_latency.map(|d| d.as_millis() as u64),
2635 "Resize yielding to input for fairness"
2636 );
2637 return Ok(());
2639 }
2640
2641 let action = self.resize_coalescer.tick();
2642 let resize_snapshot =
2643 self.resize_coalescer
2644 .logs()
2645 .last()
2646 .map(|entry| ResizeDecisionSnapshot {
2647 event_idx: entry.event_idx,
2648 action: entry.action,
2649 dt_ms: entry.dt_ms,
2650 event_rate: entry.event_rate,
2651 regime: entry.regime,
2652 pending_size: entry.pending_size,
2653 applied_size: entry.applied_size,
2654 time_since_render_ms: entry.time_since_render_ms,
2655 bocpd: self
2656 .resize_coalescer
2657 .bocpd()
2658 .and_then(|detector| detector.last_evidence().cloned()),
2659 });
2660 set_resize_snapshot(resize_snapshot);
2661
2662 match action {
2663 CoalesceAction::ApplyResize {
2664 width,
2665 height,
2666 coalesce_time,
2667 forced_by_deadline,
2668 } => self.apply_resize(width, height, coalesce_time, forced_by_deadline),
2669 _ => Ok(()),
2670 }
2671 }
2672
2673 fn apply_resize(
2674 &mut self,
2675 width: u16,
2676 height: u16,
2677 coalesce_time: Duration,
2678 forced_by_deadline: bool,
2679 ) -> io::Result<()> {
2680 let width = width.max(1);
2682 let height = height.max(1);
2683 self.width = width;
2684 self.height = height;
2685 self.writer.set_size(width, height);
2686 info!(
2687 width = width,
2688 height = height,
2689 coalesce_ms = coalesce_time.as_millis() as u64,
2690 forced = forced_by_deadline,
2691 "Resize applied"
2692 );
2693
2694 let msg = M::Message::from(Event::Resize { width, height });
2695 let start = Instant::now();
2696 let cmd = self.model.update(msg);
2697 let elapsed_us = start.elapsed().as_micros() as u64;
2698 self.last_update_us = Some(elapsed_us);
2699 self.mark_dirty();
2700 self.execute_cmd(cmd)
2701 }
2702
2703 pub fn model(&self) -> &M {
2707 &self.model
2708 }
2709
2710 pub fn model_mut(&mut self) -> &mut M {
2712 &mut self.model
2713 }
2714
2715 pub fn is_running(&self) -> bool {
2717 self.running
2718 }
2719
2720 pub fn quit(&mut self) {
2722 self.running = false;
2723 }
2724
2725 pub fn state_registry(&self) -> Option<&std::sync::Arc<StateRegistry>> {
2727 self.state_registry.as_ref()
2728 }
2729
2730 pub fn has_persistence(&self) -> bool {
2732 self.state_registry.is_some()
2733 }
2734
2735 pub fn trigger_save(&mut self) -> StorageResult<bool> {
2740 if let Some(registry) = &self.state_registry {
2741 registry.flush()
2742 } else {
2743 Ok(false)
2744 }
2745 }
2746
2747 pub fn trigger_load(&mut self) -> StorageResult<usize> {
2752 if let Some(registry) = &self.state_registry {
2753 registry.load()
2754 } else {
2755 Ok(0)
2756 }
2757 }
2758
2759 fn mark_dirty(&mut self) {
2760 self.dirty = true;
2761 }
2762
2763 fn check_locale_change(&mut self) {
2764 let version = self.locale_context.version();
2765 if version != self.locale_version {
2766 self.locale_version = version;
2767 self.mark_dirty();
2768 }
2769 }
2770
2771 pub fn request_redraw(&mut self) {
2773 self.mark_dirty();
2774 }
2775
2776 pub fn request_ui_height_remeasure(&mut self) {
2778 if self.writer.inline_auto_bounds().is_some() {
2779 self.writer.clear_auto_ui_height();
2780 if let Some(state) = self.inline_auto_remeasure.as_mut() {
2781 state.reset();
2782 }
2783 crate::voi_telemetry::clear_inline_auto_voi_snapshot();
2784 self.mark_dirty();
2785 }
2786 }
2787
2788 pub fn start_recording(&mut self, name: impl Into<String>) {
2793 let mut recorder = EventRecorder::new(name).with_terminal_size(self.width, self.height);
2794 recorder.start();
2795 self.event_recorder = Some(recorder);
2796 }
2797
2798 pub fn stop_recording(&mut self) -> Option<InputMacro> {
2802 self.event_recorder.take().map(EventRecorder::finish)
2803 }
2804
2805 pub fn is_recording(&self) -> bool {
2807 self.event_recorder
2808 .as_ref()
2809 .is_some_and(EventRecorder::is_recording)
2810 }
2811}
2812
2813pub struct App;
2815
2816impl App {
2817 #[allow(clippy::new_ret_no_self)] pub fn new<M: Model>(model: M) -> AppBuilder<M> {
2820 AppBuilder {
2821 model,
2822 config: ProgramConfig::default(),
2823 }
2824 }
2825
2826 pub fn fullscreen<M: Model>(model: M) -> AppBuilder<M> {
2828 AppBuilder {
2829 model,
2830 config: ProgramConfig::fullscreen(),
2831 }
2832 }
2833
2834 pub fn inline<M: Model>(model: M, height: u16) -> AppBuilder<M> {
2836 AppBuilder {
2837 model,
2838 config: ProgramConfig::inline(height),
2839 }
2840 }
2841
2842 pub fn inline_auto<M: Model>(model: M, min_height: u16, max_height: u16) -> AppBuilder<M> {
2844 AppBuilder {
2845 model,
2846 config: ProgramConfig::inline_auto(min_height, max_height),
2847 }
2848 }
2849
2850 pub fn string_model<S: crate::string_model::StringModel>(
2855 model: S,
2856 ) -> AppBuilder<crate::string_model::StringModelAdapter<S>> {
2857 AppBuilder {
2858 model: crate::string_model::StringModelAdapter::new(model),
2859 config: ProgramConfig::fullscreen(),
2860 }
2861 }
2862}
2863
2864pub struct AppBuilder<M: Model> {
2866 model: M,
2867 config: ProgramConfig,
2868}
2869
2870impl<M: Model> AppBuilder<M> {
2871 pub fn screen_mode(mut self, mode: ScreenMode) -> Self {
2873 self.config.screen_mode = mode;
2874 self
2875 }
2876
2877 pub fn anchor(mut self, anchor: UiAnchor) -> Self {
2879 self.config.ui_anchor = anchor;
2880 self
2881 }
2882
2883 pub fn with_mouse(mut self) -> Self {
2885 self.config.mouse = true;
2886 self
2887 }
2888
2889 pub fn with_budget(mut self, budget: FrameBudgetConfig) -> Self {
2891 self.config.budget = budget;
2892 self
2893 }
2894
2895 pub fn with_evidence_sink(mut self, config: EvidenceSinkConfig) -> Self {
2897 self.config.evidence_sink = config;
2898 self
2899 }
2900
2901 pub fn with_render_trace(mut self, config: RenderTraceConfig) -> Self {
2903 self.config.render_trace = config;
2904 self
2905 }
2906
2907 pub fn with_widget_refresh(mut self, config: WidgetRefreshConfig) -> Self {
2909 self.config.widget_refresh = config;
2910 self
2911 }
2912
2913 pub fn with_effect_queue(mut self, config: EffectQueueConfig) -> Self {
2915 self.config.effect_queue = config;
2916 self
2917 }
2918
2919 pub fn with_inline_auto_remeasure(mut self, config: InlineAutoRemeasureConfig) -> Self {
2921 self.config.inline_auto_remeasure = Some(config);
2922 self
2923 }
2924
2925 pub fn without_inline_auto_remeasure(mut self) -> Self {
2927 self.config.inline_auto_remeasure = None;
2928 self
2929 }
2930
2931 pub fn with_locale_context(mut self, locale_context: LocaleContext) -> Self {
2933 self.config.locale_context = locale_context;
2934 self
2935 }
2936
2937 pub fn with_locale(mut self, locale: impl Into<crate::locale::Locale>) -> Self {
2939 self.config.locale_context = LocaleContext::new(locale);
2940 self
2941 }
2942
2943 pub fn resize_coalescer(mut self, config: CoalescerConfig) -> Self {
2945 self.config.resize_coalescer = config;
2946 self
2947 }
2948
2949 pub fn resize_behavior(mut self, behavior: ResizeBehavior) -> Self {
2951 self.config.resize_behavior = behavior;
2952 self
2953 }
2954
2955 pub fn legacy_resize(mut self, enabled: bool) -> Self {
2957 if enabled {
2958 self.config.resize_behavior = ResizeBehavior::Immediate;
2959 }
2960 self
2961 }
2962
2963 pub fn run(self) -> io::Result<()>
2965 where
2966 M::Message: Send + 'static,
2967 {
2968 let mut program = Program::with_config(self.model, self.config)?;
2969 program.run()
2970 }
2971}
2972
2973#[derive(Debug, Clone)]
3042pub struct BatchController {
3043 ema_inter_arrival_s: f64,
3045 ema_service_s: f64,
3047 alpha: f64,
3049 tau_min_s: f64,
3051 tau_max_s: f64,
3053 headroom: f64,
3055 last_arrival: Option<std::time::Instant>,
3057 observations: u64,
3059}
3060
3061impl BatchController {
3062 pub fn new() -> Self {
3069 Self {
3070 ema_inter_arrival_s: 0.1, ema_service_s: 0.002, alpha: 0.2,
3073 tau_min_s: 0.001, tau_max_s: 0.050, headroom: 2.0,
3076 last_arrival: None,
3077 observations: 0,
3078 }
3079 }
3080
3081 pub fn observe_arrival(&mut self, now: std::time::Instant) {
3083 if let Some(last) = self.last_arrival {
3084 let dt = now.duration_since(last).as_secs_f64();
3085 if dt > 0.0 && dt < 10.0 {
3086 self.ema_inter_arrival_s =
3088 self.alpha * dt + (1.0 - self.alpha) * self.ema_inter_arrival_s;
3089 self.observations += 1;
3090 }
3091 }
3092 self.last_arrival = Some(now);
3093 }
3094
3095 pub fn observe_service(&mut self, duration: std::time::Duration) {
3097 let dt = duration.as_secs_f64();
3098 if (0.0..10.0).contains(&dt) {
3099 self.ema_service_s = self.alpha * dt + (1.0 - self.alpha) * self.ema_service_s;
3100 }
3101 }
3102
3103 #[inline]
3105 pub fn lambda_est(&self) -> f64 {
3106 if self.ema_inter_arrival_s > 0.0 {
3107 1.0 / self.ema_inter_arrival_s
3108 } else {
3109 0.0
3110 }
3111 }
3112
3113 #[inline]
3115 pub fn service_est_s(&self) -> f64 {
3116 self.ema_service_s
3117 }
3118
3119 #[inline]
3121 pub fn rho_est(&self) -> f64 {
3122 self.lambda_est() * self.ema_service_s
3123 }
3124
3125 pub fn tau_s(&self) -> f64 {
3131 let base = self.ema_service_s * self.headroom;
3132 base.clamp(self.tau_min_s, self.tau_max_s)
3133 }
3134
3135 pub fn tau(&self) -> std::time::Duration {
3137 std::time::Duration::from_secs_f64(self.tau_s())
3138 }
3139
3140 #[inline]
3142 pub fn is_stable(&self) -> bool {
3143 self.rho_est() < 1.0
3144 }
3145
3146 #[inline]
3148 pub fn observations(&self) -> u64 {
3149 self.observations
3150 }
3151}
3152
3153impl Default for BatchController {
3154 fn default() -> Self {
3155 Self::new()
3156 }
3157}
3158
3159#[cfg(test)]
3160mod tests {
3161 use super::*;
3162 use ftui_core::terminal_capabilities::TerminalCapabilities;
3163 use ftui_core::terminal_session::{SessionOptions, TerminalSession};
3164 use ftui_render::buffer::Buffer;
3165 use ftui_render::cell::Cell;
3166 use ftui_render::diff_strategy::DiffStrategy;
3167 use ftui_render::frame::CostEstimateSource;
3168 use serde_json::Value;
3169 use std::collections::HashMap;
3170 use std::path::PathBuf;
3171 use std::sync::mpsc;
3172 use std::sync::{
3173 Arc,
3174 atomic::{AtomicUsize, Ordering},
3175 };
3176
3177 struct TestModel {
3179 value: i32,
3180 }
3181
3182 #[derive(Debug)]
3183 enum TestMsg {
3184 Increment,
3185 Decrement,
3186 Quit,
3187 }
3188
3189 impl From<Event> for TestMsg {
3190 fn from(_event: Event) -> Self {
3191 TestMsg::Increment
3192 }
3193 }
3194
3195 impl Model for TestModel {
3196 type Message = TestMsg;
3197
3198 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3199 match msg {
3200 TestMsg::Increment => {
3201 self.value += 1;
3202 Cmd::none()
3203 }
3204 TestMsg::Decrement => {
3205 self.value -= 1;
3206 Cmd::none()
3207 }
3208 TestMsg::Quit => Cmd::quit(),
3209 }
3210 }
3211
3212 fn view(&self, _frame: &mut Frame) {
3213 }
3215 }
3216
3217 #[test]
3218 fn cmd_none() {
3219 let cmd: Cmd<TestMsg> = Cmd::none();
3220 assert!(matches!(cmd, Cmd::None));
3221 }
3222
3223 #[test]
3224 fn cmd_quit() {
3225 let cmd: Cmd<TestMsg> = Cmd::quit();
3226 assert!(matches!(cmd, Cmd::Quit));
3227 }
3228
3229 #[test]
3230 fn cmd_msg() {
3231 let cmd: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
3232 assert!(matches!(cmd, Cmd::Msg(TestMsg::Increment)));
3233 }
3234
3235 #[test]
3236 fn cmd_batch_empty() {
3237 let cmd: Cmd<TestMsg> = Cmd::batch(vec![]);
3238 assert!(matches!(cmd, Cmd::None));
3239 }
3240
3241 #[test]
3242 fn cmd_batch_single() {
3243 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::quit()]);
3244 assert!(matches!(cmd, Cmd::Quit));
3245 }
3246
3247 #[test]
3248 fn cmd_batch_multiple() {
3249 let cmd: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::quit()]);
3250 assert!(matches!(cmd, Cmd::Batch(_)));
3251 }
3252
3253 #[test]
3254 fn cmd_sequence_empty() {
3255 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![]);
3256 assert!(matches!(cmd, Cmd::None));
3257 }
3258
3259 #[test]
3260 fn cmd_tick() {
3261 let cmd: Cmd<TestMsg> = Cmd::tick(Duration::from_millis(100));
3262 assert!(matches!(cmd, Cmd::Tick(_)));
3263 }
3264
3265 #[test]
3266 fn cmd_task() {
3267 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
3268 assert!(matches!(cmd, Cmd::Task(..)));
3269 }
3270
3271 #[test]
3272 fn cmd_debug_format() {
3273 let cmd: Cmd<TestMsg> = Cmd::task(|| TestMsg::Increment);
3274 let debug = format!("{cmd:?}");
3275 assert_eq!(
3276 debug,
3277 "Task { spec: TaskSpec { weight: 1.0, estimate_ms: 10.0, name: None } }"
3278 );
3279 }
3280
3281 #[test]
3282 fn model_subscriptions_default_empty() {
3283 let model = TestModel { value: 0 };
3284 let subs = model.subscriptions();
3285 assert!(subs.is_empty());
3286 }
3287
3288 #[test]
3289 fn program_config_default() {
3290 let config = ProgramConfig::default();
3291 assert!(matches!(config.screen_mode, ScreenMode::Inline { .. }));
3292 assert!(!config.mouse);
3293 assert!(config.bracketed_paste);
3294 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
3295 assert!(config.inline_auto_remeasure.is_none());
3296 assert!(config.conformal_config.is_none());
3297 assert!(config.diff_config.bayesian_enabled);
3298 assert!(config.diff_config.dirty_rows_enabled);
3299 assert!(!config.resize_coalescer.enable_bocpd);
3300 assert!(!config.effect_queue.enabled);
3301 assert_eq!(
3302 config.resize_coalescer.steady_delay_ms,
3303 CoalescerConfig::default().steady_delay_ms
3304 );
3305 }
3306
3307 #[test]
3308 fn program_config_fullscreen() {
3309 let config = ProgramConfig::fullscreen();
3310 assert!(matches!(config.screen_mode, ScreenMode::AltScreen));
3311 }
3312
3313 #[test]
3314 fn program_config_inline() {
3315 let config = ProgramConfig::inline(10);
3316 assert!(matches!(
3317 config.screen_mode,
3318 ScreenMode::Inline { ui_height: 10 }
3319 ));
3320 }
3321
3322 #[test]
3323 fn program_config_inline_auto() {
3324 let config = ProgramConfig::inline_auto(3, 9);
3325 assert!(matches!(
3326 config.screen_mode,
3327 ScreenMode::InlineAuto {
3328 min_height: 3,
3329 max_height: 9
3330 }
3331 ));
3332 assert!(config.inline_auto_remeasure.is_some());
3333 }
3334
3335 #[test]
3336 fn program_config_with_mouse() {
3337 let config = ProgramConfig::default().with_mouse();
3338 assert!(config.mouse);
3339 }
3340
3341 #[test]
3342 fn model_update() {
3343 let mut model = TestModel { value: 0 };
3344 model.update(TestMsg::Increment);
3345 assert_eq!(model.value, 1);
3346 model.update(TestMsg::Decrement);
3347 assert_eq!(model.value, 0);
3348 assert!(matches!(model.update(TestMsg::Quit), Cmd::Quit));
3349 }
3350
3351 #[test]
3352 fn model_init_default() {
3353 let mut model = TestModel { value: 0 };
3354 let cmd = model.init();
3355 assert!(matches!(cmd, Cmd::None));
3356 }
3357
3358 #[test]
3365 fn cmd_sequence_executes_in_order() {
3366 use crate::simulator::ProgramSimulator;
3368
3369 struct SeqModel {
3370 trace: Vec<i32>,
3371 }
3372
3373 #[derive(Debug)]
3374 enum SeqMsg {
3375 Append(i32),
3376 TriggerSequence,
3377 }
3378
3379 impl From<Event> for SeqMsg {
3380 fn from(_: Event) -> Self {
3381 SeqMsg::Append(0)
3382 }
3383 }
3384
3385 impl Model for SeqModel {
3386 type Message = SeqMsg;
3387
3388 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3389 match msg {
3390 SeqMsg::Append(n) => {
3391 self.trace.push(n);
3392 Cmd::none()
3393 }
3394 SeqMsg::TriggerSequence => Cmd::sequence(vec![
3395 Cmd::msg(SeqMsg::Append(1)),
3396 Cmd::msg(SeqMsg::Append(2)),
3397 Cmd::msg(SeqMsg::Append(3)),
3398 ]),
3399 }
3400 }
3401
3402 fn view(&self, _frame: &mut Frame) {}
3403 }
3404
3405 let mut sim = ProgramSimulator::new(SeqModel { trace: vec![] });
3406 sim.init();
3407 sim.send(SeqMsg::TriggerSequence);
3408
3409 assert_eq!(sim.model().trace, vec![1, 2, 3]);
3410 }
3411
3412 #[test]
3413 fn cmd_batch_executes_all_regardless_of_order() {
3414 use crate::simulator::ProgramSimulator;
3416
3417 struct BatchModel {
3418 values: Vec<i32>,
3419 }
3420
3421 #[derive(Debug)]
3422 enum BatchMsg {
3423 Add(i32),
3424 TriggerBatch,
3425 }
3426
3427 impl From<Event> for BatchMsg {
3428 fn from(_: Event) -> Self {
3429 BatchMsg::Add(0)
3430 }
3431 }
3432
3433 impl Model for BatchModel {
3434 type Message = BatchMsg;
3435
3436 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3437 match msg {
3438 BatchMsg::Add(n) => {
3439 self.values.push(n);
3440 Cmd::none()
3441 }
3442 BatchMsg::TriggerBatch => Cmd::batch(vec![
3443 Cmd::msg(BatchMsg::Add(10)),
3444 Cmd::msg(BatchMsg::Add(20)),
3445 Cmd::msg(BatchMsg::Add(30)),
3446 ]),
3447 }
3448 }
3449
3450 fn view(&self, _frame: &mut Frame) {}
3451 }
3452
3453 let mut sim = ProgramSimulator::new(BatchModel { values: vec![] });
3454 sim.init();
3455 sim.send(BatchMsg::TriggerBatch);
3456
3457 assert_eq!(sim.model().values.len(), 3);
3459 assert!(sim.model().values.contains(&10));
3460 assert!(sim.model().values.contains(&20));
3461 assert!(sim.model().values.contains(&30));
3462 }
3463
3464 #[test]
3465 fn cmd_sequence_stops_on_quit() {
3466 use crate::simulator::ProgramSimulator;
3468
3469 struct SeqQuitModel {
3470 trace: Vec<i32>,
3471 }
3472
3473 #[derive(Debug)]
3474 enum SeqQuitMsg {
3475 Append(i32),
3476 TriggerSequenceWithQuit,
3477 }
3478
3479 impl From<Event> for SeqQuitMsg {
3480 fn from(_: Event) -> Self {
3481 SeqQuitMsg::Append(0)
3482 }
3483 }
3484
3485 impl Model for SeqQuitModel {
3486 type Message = SeqQuitMsg;
3487
3488 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3489 match msg {
3490 SeqQuitMsg::Append(n) => {
3491 self.trace.push(n);
3492 Cmd::none()
3493 }
3494 SeqQuitMsg::TriggerSequenceWithQuit => Cmd::sequence(vec![
3495 Cmd::msg(SeqQuitMsg::Append(1)),
3496 Cmd::quit(),
3497 Cmd::msg(SeqQuitMsg::Append(2)), ]),
3499 }
3500 }
3501
3502 fn view(&self, _frame: &mut Frame) {}
3503 }
3504
3505 let mut sim = ProgramSimulator::new(SeqQuitModel { trace: vec![] });
3506 sim.init();
3507 sim.send(SeqQuitMsg::TriggerSequenceWithQuit);
3508
3509 assert_eq!(sim.model().trace, vec![1]);
3510 assert!(!sim.is_running());
3511 }
3512
3513 #[test]
3514 fn identical_input_produces_identical_state() {
3515 use crate::simulator::ProgramSimulator;
3517
3518 fn run_scenario() -> Vec<i32> {
3519 struct DetModel {
3520 values: Vec<i32>,
3521 }
3522
3523 #[derive(Debug, Clone)]
3524 enum DetMsg {
3525 Add(i32),
3526 Double,
3527 }
3528
3529 impl From<Event> for DetMsg {
3530 fn from(_: Event) -> Self {
3531 DetMsg::Add(1)
3532 }
3533 }
3534
3535 impl Model for DetModel {
3536 type Message = DetMsg;
3537
3538 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3539 match msg {
3540 DetMsg::Add(n) => {
3541 self.values.push(n);
3542 Cmd::none()
3543 }
3544 DetMsg::Double => {
3545 if let Some(&last) = self.values.last() {
3546 self.values.push(last * 2);
3547 }
3548 Cmd::none()
3549 }
3550 }
3551 }
3552
3553 fn view(&self, _frame: &mut Frame) {}
3554 }
3555
3556 let mut sim = ProgramSimulator::new(DetModel { values: vec![] });
3557 sim.init();
3558 sim.send(DetMsg::Add(5));
3559 sim.send(DetMsg::Double);
3560 sim.send(DetMsg::Add(3));
3561 sim.send(DetMsg::Double);
3562
3563 sim.model().values.clone()
3564 }
3565
3566 let run1 = run_scenario();
3568 let run2 = run_scenario();
3569 let run3 = run_scenario();
3570
3571 assert_eq!(run1, run2);
3572 assert_eq!(run2, run3);
3573 assert_eq!(run1, vec![5, 10, 3, 6]);
3574 }
3575
3576 #[test]
3577 fn identical_state_produces_identical_render() {
3578 use crate::simulator::ProgramSimulator;
3580
3581 struct RenderModel {
3582 counter: i32,
3583 }
3584
3585 #[derive(Debug)]
3586 enum RenderMsg {
3587 Set(i32),
3588 }
3589
3590 impl From<Event> for RenderMsg {
3591 fn from(_: Event) -> Self {
3592 RenderMsg::Set(0)
3593 }
3594 }
3595
3596 impl Model for RenderModel {
3597 type Message = RenderMsg;
3598
3599 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
3600 match msg {
3601 RenderMsg::Set(n) => {
3602 self.counter = n;
3603 Cmd::none()
3604 }
3605 }
3606 }
3607
3608 fn view(&self, frame: &mut Frame) {
3609 let text = format!("Value: {}", self.counter);
3610 for (i, c) in text.chars().enumerate() {
3611 if (i as u16) < frame.width() {
3612 use ftui_render::cell::Cell;
3613 frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
3614 }
3615 }
3616 }
3617 }
3618
3619 let mut sim1 = ProgramSimulator::new(RenderModel { counter: 42 });
3621 let mut sim2 = ProgramSimulator::new(RenderModel { counter: 42 });
3622
3623 let buf1 = sim1.capture_frame(80, 24);
3624 let buf2 = sim2.capture_frame(80, 24);
3625
3626 for y in 0..24 {
3628 for x in 0..80 {
3629 let cell1 = buf1.get(x, y).unwrap();
3630 let cell2 = buf2.get(x, y).unwrap();
3631 assert_eq!(
3632 cell1.content.as_char(),
3633 cell2.content.as_char(),
3634 "Mismatch at ({}, {})",
3635 x,
3636 y
3637 );
3638 }
3639 }
3640 }
3641
3642 #[test]
3645 fn cmd_log_creates_log_command() {
3646 let cmd: Cmd<TestMsg> = Cmd::log("test message");
3647 assert!(matches!(cmd, Cmd::Log(s) if s == "test message"));
3648 }
3649
3650 #[test]
3651 fn cmd_log_from_string() {
3652 let msg = String::from("dynamic message");
3653 let cmd: Cmd<TestMsg> = Cmd::log(msg);
3654 assert!(matches!(cmd, Cmd::Log(s) if s == "dynamic message"));
3655 }
3656
3657 #[test]
3658 fn program_simulator_logs_jsonl_with_seed_and_run_id() {
3659 use crate::simulator::ProgramSimulator;
3661
3662 struct LogModel {
3663 run_id: &'static str,
3664 seed: u64,
3665 }
3666
3667 #[derive(Debug)]
3668 enum LogMsg {
3669 Emit,
3670 }
3671
3672 impl From<Event> for LogMsg {
3673 fn from(_: Event) -> Self {
3674 LogMsg::Emit
3675 }
3676 }
3677
3678 impl Model for LogModel {
3679 type Message = LogMsg;
3680
3681 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
3682 let line = format!(
3683 r#"{{"event":"test","run_id":"{}","seed":{}}}"#,
3684 self.run_id, self.seed
3685 );
3686 Cmd::log(line)
3687 }
3688
3689 fn view(&self, _frame: &mut Frame) {}
3690 }
3691
3692 let mut sim = ProgramSimulator::new(LogModel {
3693 run_id: "test-run-001",
3694 seed: 4242,
3695 });
3696 sim.init();
3697 sim.send(LogMsg::Emit);
3698
3699 let logs = sim.logs();
3700 assert_eq!(logs.len(), 1);
3701 assert!(logs[0].contains(r#""run_id":"test-run-001""#));
3702 assert!(logs[0].contains(r#""seed":4242"#));
3703 }
3704
3705 #[test]
3706 fn cmd_sequence_single_unwraps() {
3707 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::quit()]);
3708 assert!(matches!(cmd, Cmd::Quit));
3710 }
3711
3712 #[test]
3713 fn cmd_sequence_multiple() {
3714 let cmd: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::quit()]);
3715 assert!(matches!(cmd, Cmd::Sequence(_)));
3716 }
3717
3718 #[test]
3719 fn cmd_default_is_none() {
3720 let cmd: Cmd<TestMsg> = Cmd::default();
3721 assert!(matches!(cmd, Cmd::None));
3722 }
3723
3724 #[test]
3725 fn cmd_debug_all_variants() {
3726 let none: Cmd<TestMsg> = Cmd::none();
3728 assert_eq!(format!("{none:?}"), "None");
3729
3730 let quit: Cmd<TestMsg> = Cmd::quit();
3731 assert_eq!(format!("{quit:?}"), "Quit");
3732
3733 let msg: Cmd<TestMsg> = Cmd::msg(TestMsg::Increment);
3734 assert!(format!("{msg:?}").starts_with("Msg("));
3735
3736 let batch: Cmd<TestMsg> = Cmd::batch(vec![Cmd::none(), Cmd::none()]);
3737 assert!(format!("{batch:?}").starts_with("Batch("));
3738
3739 let seq: Cmd<TestMsg> = Cmd::sequence(vec![Cmd::none(), Cmd::none()]);
3740 assert!(format!("{seq:?}").starts_with("Sequence("));
3741
3742 let tick: Cmd<TestMsg> = Cmd::tick(Duration::from_secs(1));
3743 assert!(format!("{tick:?}").starts_with("Tick("));
3744
3745 let log: Cmd<TestMsg> = Cmd::log("test");
3746 assert!(format!("{log:?}").starts_with("Log("));
3747 }
3748
3749 #[test]
3750 fn program_config_with_budget() {
3751 let budget = FrameBudgetConfig {
3752 total: Duration::from_millis(50),
3753 ..Default::default()
3754 };
3755 let config = ProgramConfig::default().with_budget(budget);
3756 assert_eq!(config.budget.total, Duration::from_millis(50));
3757 }
3758
3759 #[test]
3760 fn program_config_with_conformal() {
3761 let config = ProgramConfig::default().with_conformal_config(ConformalConfig {
3762 alpha: 0.2,
3763 ..Default::default()
3764 });
3765 assert!(config.conformal_config.is_some());
3766 assert!((config.conformal_config.as_ref().unwrap().alpha - 0.2).abs() < 1e-6);
3767 }
3768
3769 #[test]
3770 fn program_config_forced_size_clamps_minimums() {
3771 let config = ProgramConfig::default().with_forced_size(0, 0);
3772 assert_eq!(config.forced_size, Some((1, 1)));
3773
3774 let cleared = config.without_forced_size();
3775 assert!(cleared.forced_size.is_none());
3776 }
3777
3778 #[test]
3779 fn effect_queue_config_defaults_are_safe() {
3780 let config = EffectQueueConfig::default();
3781 assert!(!config.enabled);
3782 assert!(config.scheduler.smith_enabled);
3783 assert!(!config.scheduler.preemptive);
3784 assert_eq!(config.scheduler.aging_factor, 0.0);
3785 assert_eq!(config.scheduler.wait_starve_ms, 0.0);
3786 }
3787
3788 #[test]
3789 fn handle_effect_command_enqueues_or_executes_inline() {
3790 let (result_tx, result_rx) = mpsc::channel::<u32>();
3791 let mut scheduler = QueueingScheduler::new(EffectQueueConfig::default().scheduler);
3792 let mut tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
3793
3794 let ran = Arc::new(AtomicUsize::new(0));
3795 let ran_task = ran.clone();
3796 let cmd = EffectCommand::Enqueue(
3797 TaskSpec::default(),
3798 Box::new(move || {
3799 ran_task.fetch_add(1, Ordering::SeqCst);
3800 7
3801 }),
3802 );
3803
3804 let shutdown = handle_effect_command(cmd, &mut scheduler, &mut tasks, &result_tx);
3805 assert!(!shutdown);
3806 assert_eq!(ran.load(Ordering::SeqCst), 0);
3807 assert_eq!(tasks.len(), 1);
3808 assert!(result_rx.try_recv().is_err());
3809
3810 let mut full_scheduler = QueueingScheduler::new(SchedulerConfig {
3811 max_queue_size: 0,
3812 ..Default::default()
3813 });
3814 let mut full_tasks: HashMap<u64, Box<dyn FnOnce() -> u32 + Send>> = HashMap::new();
3815 let ran_full = Arc::new(AtomicUsize::new(0));
3816 let ran_full_task = ran_full.clone();
3817 let cmd_full = EffectCommand::Enqueue(
3818 TaskSpec::default(),
3819 Box::new(move || {
3820 ran_full_task.fetch_add(1, Ordering::SeqCst);
3821 42
3822 }),
3823 );
3824
3825 let shutdown_full =
3826 handle_effect_command(cmd_full, &mut full_scheduler, &mut full_tasks, &result_tx);
3827 assert!(!shutdown_full);
3828 assert!(full_tasks.is_empty());
3829 assert_eq!(ran_full.load(Ordering::SeqCst), 1);
3830 assert_eq!(
3831 result_rx.recv_timeout(Duration::from_millis(200)).unwrap(),
3832 42
3833 );
3834
3835 let shutdown = handle_effect_command(
3836 EffectCommand::Shutdown,
3837 &mut full_scheduler,
3838 &mut full_tasks,
3839 &result_tx,
3840 );
3841 assert!(shutdown);
3842 }
3843
3844 #[test]
3845 fn effect_queue_loop_executes_tasks_and_shutdowns() {
3846 let (cmd_tx, cmd_rx) = mpsc::channel::<EffectCommand<u32>>();
3847 let (result_tx, result_rx) = mpsc::channel::<u32>();
3848 let config = EffectQueueConfig {
3849 enabled: true,
3850 scheduler: SchedulerConfig {
3851 preemptive: false,
3852 ..Default::default()
3853 },
3854 };
3855
3856 let handle = std::thread::spawn(move || {
3857 effect_queue_loop(config, cmd_rx, result_tx, None);
3858 });
3859
3860 cmd_tx
3861 .send(EffectCommand::Enqueue(TaskSpec::default(), Box::new(|| 10)))
3862 .unwrap();
3863 cmd_tx
3864 .send(EffectCommand::Enqueue(
3865 TaskSpec::new(2.0, 5.0).with_name("second"),
3866 Box::new(|| 20),
3867 ))
3868 .unwrap();
3869
3870 let mut results = vec![
3871 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
3872 result_rx.recv_timeout(Duration::from_millis(500)).unwrap(),
3873 ];
3874 results.sort_unstable();
3875 assert_eq!(results, vec![10, 20]);
3876
3877 cmd_tx.send(EffectCommand::Shutdown).unwrap();
3878 let _ = handle.join();
3879 }
3880
3881 #[test]
3882 fn inline_auto_remeasure_reset_clears_decision() {
3883 let mut state = InlineAutoRemeasureState::new(InlineAutoRemeasureConfig::default());
3884 state.sampler.decide(Instant::now());
3885 assert!(state.sampler.last_decision().is_some());
3886
3887 state.reset();
3888 assert!(state.sampler.last_decision().is_none());
3889 }
3890
3891 #[test]
3892 fn budget_decision_jsonl_contains_required_fields() {
3893 let evidence = BudgetDecisionEvidence {
3894 frame_idx: 7,
3895 decision: BudgetDecision::Degrade,
3896 controller_decision: BudgetDecision::Hold,
3897 degradation_before: DegradationLevel::Full,
3898 degradation_after: DegradationLevel::NoStyling,
3899 frame_time_us: 12_345.678,
3900 budget_us: 16_000.0,
3901 pid_output: 1.25,
3902 pid_p: 0.5,
3903 pid_i: 0.25,
3904 pid_d: 0.5,
3905 e_value: 2.0,
3906 frames_observed: 42,
3907 frames_since_change: 3,
3908 in_warmup: false,
3909 conformal: Some(ConformalEvidence {
3910 bucket_key: "inline:dirty:10".to_string(),
3911 n_b: 32,
3912 alpha: 0.05,
3913 q_b: 1000.0,
3914 y_hat: 12_000.0,
3915 upper_us: 13_000.0,
3916 risk: true,
3917 fallback_level: 1,
3918 window_size: 256,
3919 reset_count: 2,
3920 }),
3921 };
3922
3923 let jsonl = evidence.to_jsonl();
3924 assert!(jsonl.contains("\"event\":\"budget_decision\""));
3925 assert!(jsonl.contains("\"decision\":\"degrade\""));
3926 assert!(jsonl.contains("\"decision_controller\":\"stay\""));
3927 assert!(jsonl.contains("\"degradation_before\":\"Full\""));
3928 assert!(jsonl.contains("\"degradation_after\":\"NoStyling\""));
3929 assert!(jsonl.contains("\"frame_time_us\":12345.678000"));
3930 assert!(jsonl.contains("\"budget_us\":16000.000000"));
3931 assert!(jsonl.contains("\"pid_output\":1.250000"));
3932 assert!(jsonl.contains("\"e_value\":2.000000"));
3933 assert!(jsonl.contains("\"bucket_key\":\"inline:dirty:10\""));
3934 assert!(jsonl.contains("\"n_b\":32"));
3935 assert!(jsonl.contains("\"alpha\":0.050000"));
3936 assert!(jsonl.contains("\"q_b\":1000.000000"));
3937 assert!(jsonl.contains("\"y_hat\":12000.000000"));
3938 assert!(jsonl.contains("\"upper_us\":13000.000000"));
3939 assert!(jsonl.contains("\"risk\":true"));
3940 assert!(jsonl.contains("\"fallback_level\":1"));
3941 assert!(jsonl.contains("\"window_size\":256"));
3942 assert!(jsonl.contains("\"reset_count\":2"));
3943 }
3944
3945 fn make_signal(
3946 widget_id: u64,
3947 essential: bool,
3948 priority: f32,
3949 staleness_ms: u64,
3950 cost_us: f32,
3951 ) -> WidgetSignal {
3952 WidgetSignal {
3953 widget_id,
3954 essential,
3955 priority,
3956 staleness_ms,
3957 focus_boost: 0.0,
3958 interaction_boost: 0.0,
3959 area_cells: 1,
3960 cost_estimate_us: cost_us,
3961 recent_cost_us: 0.0,
3962 estimate_source: CostEstimateSource::FixedDefault,
3963 }
3964 }
3965
3966 fn signal_value_cost(signal: &WidgetSignal, config: &WidgetRefreshConfig) -> (f32, f32, bool) {
3967 let starved = config.starve_ms > 0 && signal.staleness_ms >= config.starve_ms;
3968 let staleness_window = config.staleness_window_ms.max(1) as f32;
3969 let staleness_score = (signal.staleness_ms as f32 / staleness_window).min(1.0);
3970 let mut value = config.weight_priority * signal.priority
3971 + config.weight_staleness * staleness_score
3972 + config.weight_focus * signal.focus_boost
3973 + config.weight_interaction * signal.interaction_boost;
3974 if starved {
3975 value += config.starve_boost;
3976 }
3977 let raw_cost = if signal.recent_cost_us > 0.0 {
3978 signal.recent_cost_us
3979 } else {
3980 signal.cost_estimate_us
3981 };
3982 let cost_us = raw_cost.max(config.min_cost_us);
3983 (value, cost_us, starved)
3984 }
3985
3986 fn fifo_select(
3987 signals: &[WidgetSignal],
3988 budget_us: f64,
3989 config: &WidgetRefreshConfig,
3990 ) -> (Vec<u64>, f64, usize) {
3991 let mut selected = Vec::new();
3992 let mut total_value = 0.0f64;
3993 let mut starved_selected = 0usize;
3994 let mut remaining = budget_us;
3995
3996 for signal in signals {
3997 if !signal.essential {
3998 continue;
3999 }
4000 let (value, cost_us, starved) = signal_value_cost(signal, config);
4001 remaining -= cost_us as f64;
4002 total_value += value as f64;
4003 if starved {
4004 starved_selected = starved_selected.saturating_add(1);
4005 }
4006 selected.push(signal.widget_id);
4007 }
4008 for signal in signals {
4009 if signal.essential {
4010 continue;
4011 }
4012 let (value, cost_us, starved) = signal_value_cost(signal, config);
4013 if remaining >= cost_us as f64 {
4014 remaining -= cost_us as f64;
4015 total_value += value as f64;
4016 if starved {
4017 starved_selected = starved_selected.saturating_add(1);
4018 }
4019 selected.push(signal.widget_id);
4020 }
4021 }
4022
4023 (selected, total_value, starved_selected)
4024 }
4025
4026 fn rotate_signals(signals: &[WidgetSignal], offset: usize) -> Vec<WidgetSignal> {
4027 if signals.is_empty() {
4028 return Vec::new();
4029 }
4030 let mut rotated = Vec::with_capacity(signals.len());
4031 for idx in 0..signals.len() {
4032 rotated.push(signals[(idx + offset) % signals.len()].clone());
4033 }
4034 rotated
4035 }
4036
4037 #[test]
4038 fn widget_refresh_selects_essentials_first() {
4039 let signals = vec![
4040 make_signal(1, true, 0.6, 0, 5.0),
4041 make_signal(2, false, 0.9, 0, 4.0),
4042 ];
4043 let mut plan = WidgetRefreshPlan::new();
4044 let config = WidgetRefreshConfig::default();
4045 plan.recompute(1, 6.0, DegradationLevel::Full, &signals, &config);
4046 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4047 assert_eq!(selected, vec![1]);
4048 assert!(!plan.over_budget);
4049 }
4050
4051 #[test]
4052 fn widget_refresh_degradation_essential_only_skips_nonessential() {
4053 let signals = vec![
4054 make_signal(1, true, 0.5, 0, 2.0),
4055 make_signal(2, false, 1.0, 0, 1.0),
4056 ];
4057 let mut plan = WidgetRefreshPlan::new();
4058 let config = WidgetRefreshConfig::default();
4059 plan.recompute(3, 10.0, DegradationLevel::EssentialOnly, &signals, &config);
4060 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4061 assert_eq!(selected, vec![1]);
4062 assert_eq!(plan.skipped_count, 1);
4063 }
4064
4065 #[test]
4066 fn widget_refresh_starvation_guard_forces_one_starved() {
4067 let signals = vec![make_signal(7, false, 0.1, 10_000, 8.0)];
4068 let mut plan = WidgetRefreshPlan::new();
4069 let config = WidgetRefreshConfig {
4070 starve_ms: 1_000,
4071 max_starved_per_frame: 1,
4072 ..Default::default()
4073 };
4074 plan.recompute(5, 0.0, DegradationLevel::Full, &signals, &config);
4075 assert_eq!(plan.selected.len(), 1);
4076 assert!(plan.selected[0].starved);
4077 assert!(plan.over_budget);
4078 }
4079
4080 #[test]
4081 fn widget_refresh_budget_blocks_when_no_selection() {
4082 let signals = vec![make_signal(42, false, 0.2, 0, 10.0)];
4083 let mut plan = WidgetRefreshPlan::new();
4084 let config = WidgetRefreshConfig {
4085 starve_ms: 0,
4086 max_starved_per_frame: 0,
4087 ..Default::default()
4088 };
4089 plan.recompute(8, 0.0, DegradationLevel::Full, &signals, &config);
4090 let budget = plan.as_budget();
4091 assert!(!budget.allows(42, false));
4092 }
4093
4094 #[test]
4095 fn widget_refresh_max_drop_fraction_forces_minimum_refresh() {
4096 let signals = vec![
4097 make_signal(1, false, 0.4, 0, 10.0),
4098 make_signal(2, false, 0.4, 0, 10.0),
4099 make_signal(3, false, 0.4, 0, 10.0),
4100 make_signal(4, false, 0.4, 0, 10.0),
4101 ];
4102 let mut plan = WidgetRefreshPlan::new();
4103 let config = WidgetRefreshConfig {
4104 starve_ms: 0,
4105 max_starved_per_frame: 0,
4106 max_drop_fraction: 0.5,
4107 ..Default::default()
4108 };
4109 plan.recompute(12, 0.0, DegradationLevel::Full, &signals, &config);
4110 let selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4111 assert_eq!(selected, vec![1, 2]);
4112 }
4113
4114 #[test]
4115 fn widget_refresh_greedy_beats_fifo_and_round_robin() {
4116 let signals = vec![
4117 make_signal(1, false, 0.1, 0, 6.0),
4118 make_signal(2, false, 0.2, 0, 6.0),
4119 make_signal(3, false, 1.0, 0, 4.0),
4120 make_signal(4, false, 0.9, 0, 3.0),
4121 make_signal(5, false, 0.8, 0, 3.0),
4122 make_signal(6, false, 0.1, 4_000, 2.0),
4123 ];
4124 let budget_us = 10.0;
4125 let config = WidgetRefreshConfig::default();
4126
4127 let mut plan = WidgetRefreshPlan::new();
4128 plan.recompute(21, budget_us, DegradationLevel::Full, &signals, &config);
4129 let greedy_value = plan.selected_value;
4130 let greedy_selected: Vec<u64> = plan.selected.iter().map(|e| e.widget_id).collect();
4131
4132 let (fifo_selected, fifo_value, _fifo_starved) = fifo_select(&signals, budget_us, &config);
4133 let rotated = rotate_signals(&signals, 2);
4134 let (rr_selected, rr_value, _rr_starved) = fifo_select(&rotated, budget_us, &config);
4135
4136 assert!(
4137 greedy_value > fifo_value,
4138 "greedy_value={greedy_value:.3} <= fifo_value={fifo_value:.3}; greedy={:?}, fifo={:?}",
4139 greedy_selected,
4140 fifo_selected
4141 );
4142 assert!(
4143 greedy_value > rr_value,
4144 "greedy_value={greedy_value:.3} <= rr_value={rr_value:.3}; greedy={:?}, rr={:?}",
4145 greedy_selected,
4146 rr_selected
4147 );
4148 assert!(
4149 plan.starved_selected > 0,
4150 "greedy did not select starved widget; greedy={:?}",
4151 greedy_selected
4152 );
4153 }
4154
4155 #[test]
4156 fn widget_refresh_jsonl_contains_required_fields() {
4157 let signals = vec![make_signal(7, true, 0.2, 0, 2.0)];
4158 let mut plan = WidgetRefreshPlan::new();
4159 let config = WidgetRefreshConfig::default();
4160 plan.recompute(9, 4.0, DegradationLevel::Full, &signals, &config);
4161 let jsonl = plan.to_jsonl();
4162 assert!(jsonl.contains("\"event\":\"widget_refresh\""));
4163 assert!(jsonl.contains("\"frame_idx\":9"));
4164 assert!(jsonl.contains("\"selected_count\":1"));
4165 assert!(jsonl.contains("\"id\":7"));
4166 }
4167
4168 #[test]
4169 fn program_config_with_resize_coalescer() {
4170 let config = ProgramConfig::default().with_resize_coalescer(CoalescerConfig {
4171 steady_delay_ms: 8,
4172 burst_delay_ms: 20,
4173 hard_deadline_ms: 80,
4174 burst_enter_rate: 12.0,
4175 burst_exit_rate: 6.0,
4176 cooldown_frames: 2,
4177 rate_window_size: 6,
4178 enable_logging: true,
4179 enable_bocpd: false,
4180 bocpd_config: None,
4181 });
4182 assert_eq!(config.resize_coalescer.steady_delay_ms, 8);
4183 assert!(config.resize_coalescer.enable_logging);
4184 }
4185
4186 #[test]
4187 fn program_config_with_resize_behavior() {
4188 let config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate);
4189 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
4190 }
4191
4192 #[test]
4193 fn program_config_with_legacy_resize_enabled() {
4194 let config = ProgramConfig::default().with_legacy_resize(true);
4195 assert_eq!(config.resize_behavior, ResizeBehavior::Immediate);
4196 }
4197
4198 #[test]
4199 fn program_config_with_legacy_resize_disabled_keeps_default() {
4200 let config = ProgramConfig::default().with_legacy_resize(false);
4201 assert_eq!(config.resize_behavior, ResizeBehavior::Throttled);
4202 }
4203
4204 fn diff_strategy_trace(bayesian_enabled: bool) -> Vec<DiffStrategy> {
4205 let config = RuntimeDiffConfig::default().with_bayesian_enabled(bayesian_enabled);
4206 let mut writer = TerminalWriter::with_diff_config(
4207 Vec::<u8>::new(),
4208 ScreenMode::AltScreen,
4209 UiAnchor::Bottom,
4210 TerminalCapabilities::basic(),
4211 config,
4212 );
4213 writer.set_size(8, 4);
4214
4215 let mut buffer = Buffer::new(8, 4);
4216 let mut trace = Vec::new();
4217
4218 writer.present_ui(&buffer, None, false).unwrap();
4219 trace.push(
4220 writer
4221 .last_diff_strategy()
4222 .unwrap_or(DiffStrategy::FullRedraw),
4223 );
4224
4225 buffer.set_raw(0, 0, Cell::from_char('A'));
4226 writer.present_ui(&buffer, None, false).unwrap();
4227 trace.push(
4228 writer
4229 .last_diff_strategy()
4230 .unwrap_or(DiffStrategy::FullRedraw),
4231 );
4232
4233 buffer.set_raw(1, 1, Cell::from_char('B'));
4234 writer.present_ui(&buffer, None, false).unwrap();
4235 trace.push(
4236 writer
4237 .last_diff_strategy()
4238 .unwrap_or(DiffStrategy::FullRedraw),
4239 );
4240
4241 trace
4242 }
4243
4244 fn coalescer_checksum(enable_bocpd: bool) -> String {
4245 let mut config = CoalescerConfig::default().with_logging(true);
4246 if enable_bocpd {
4247 config = config.with_bocpd();
4248 }
4249
4250 let base = Instant::now();
4251 let mut coalescer = ResizeCoalescer::new(config, (80, 24)).with_last_render(base);
4252
4253 let events = [
4254 (0_u64, (82_u16, 24_u16)),
4255 (10, (83, 25)),
4256 (20, (84, 26)),
4257 (35, (90, 28)),
4258 (55, (92, 30)),
4259 ];
4260
4261 let mut idx = 0usize;
4262 for t_ms in (0_u64..=160).step_by(8) {
4263 let now = base + Duration::from_millis(t_ms);
4264 while idx < events.len() && events[idx].0 == t_ms {
4265 let (w, h) = events[idx].1;
4266 coalescer.handle_resize_at(w, h, now);
4267 idx += 1;
4268 }
4269 coalescer.tick_at(now);
4270 }
4271
4272 coalescer.decision_checksum_hex()
4273 }
4274
4275 fn conformal_trace(enabled: bool) -> Vec<(f64, bool)> {
4276 if !enabled {
4277 return Vec::new();
4278 }
4279
4280 let mut predictor = ConformalPredictor::new(ConformalConfig::default());
4281 let key = BucketKey::from_context(ScreenMode::AltScreen, DiffStrategy::Full, 80, 24);
4282 let mut trace = Vec::new();
4283
4284 for i in 0..30 {
4285 let y_hat = 16_000.0 + (i as f64) * 15.0;
4286 let observed = y_hat + (i % 7) as f64 * 120.0;
4287 predictor.observe(key, y_hat, observed);
4288 let prediction = predictor.predict(key, y_hat, 20_000.0);
4289 trace.push((prediction.upper_us, prediction.risk));
4290 }
4291
4292 trace
4293 }
4294
4295 #[test]
4296 fn policy_toggle_matrix_determinism() {
4297 for &bayesian in &[false, true] {
4298 for &bocpd in &[false, true] {
4299 for &conformal in &[false, true] {
4300 let diff_a = diff_strategy_trace(bayesian);
4301 let diff_b = diff_strategy_trace(bayesian);
4302 assert_eq!(diff_a, diff_b, "diff strategy not deterministic");
4303
4304 let checksum_a = coalescer_checksum(bocpd);
4305 let checksum_b = coalescer_checksum(bocpd);
4306 assert_eq!(checksum_a, checksum_b, "coalescer checksum mismatch");
4307
4308 let conf_a = conformal_trace(conformal);
4309 let conf_b = conformal_trace(conformal);
4310 assert_eq!(conf_a, conf_b, "conformal predictor not deterministic");
4311
4312 if conformal {
4313 assert!(!conf_a.is_empty(), "conformal trace should be populated");
4314 } else {
4315 assert!(conf_a.is_empty(), "conformal trace should be empty");
4316 }
4317 }
4318 }
4319 }
4320 }
4321
4322 #[test]
4323 fn resize_behavior_uses_coalescer_flag() {
4324 assert!(ResizeBehavior::Throttled.uses_coalescer());
4325 assert!(!ResizeBehavior::Immediate.uses_coalescer());
4326 }
4327
4328 #[test]
4329 fn nested_cmd_msg_executes_recursively() {
4330 use crate::simulator::ProgramSimulator;
4332
4333 struct NestedModel {
4334 depth: usize,
4335 }
4336
4337 #[derive(Debug)]
4338 enum NestedMsg {
4339 Nest(usize),
4340 }
4341
4342 impl From<Event> for NestedMsg {
4343 fn from(_: Event) -> Self {
4344 NestedMsg::Nest(0)
4345 }
4346 }
4347
4348 impl Model for NestedModel {
4349 type Message = NestedMsg;
4350
4351 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4352 match msg {
4353 NestedMsg::Nest(n) => {
4354 self.depth += 1;
4355 if n > 0 {
4356 Cmd::msg(NestedMsg::Nest(n - 1))
4357 } else {
4358 Cmd::none()
4359 }
4360 }
4361 }
4362 }
4363
4364 fn view(&self, _frame: &mut Frame) {}
4365 }
4366
4367 let mut sim = ProgramSimulator::new(NestedModel { depth: 0 });
4368 sim.init();
4369 sim.send(NestedMsg::Nest(3));
4370
4371 assert_eq!(sim.model().depth, 4);
4373 }
4374
4375 #[test]
4376 fn task_executes_synchronously_in_simulator() {
4377 use crate::simulator::ProgramSimulator;
4379
4380 struct TaskModel {
4381 completed: bool,
4382 }
4383
4384 #[derive(Debug)]
4385 enum TaskMsg {
4386 Complete,
4387 SpawnTask,
4388 }
4389
4390 impl From<Event> for TaskMsg {
4391 fn from(_: Event) -> Self {
4392 TaskMsg::Complete
4393 }
4394 }
4395
4396 impl Model for TaskModel {
4397 type Message = TaskMsg;
4398
4399 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4400 match msg {
4401 TaskMsg::Complete => {
4402 self.completed = true;
4403 Cmd::none()
4404 }
4405 TaskMsg::SpawnTask => Cmd::task(|| TaskMsg::Complete),
4406 }
4407 }
4408
4409 fn view(&self, _frame: &mut Frame) {}
4410 }
4411
4412 let mut sim = ProgramSimulator::new(TaskModel { completed: false });
4413 sim.init();
4414 sim.send(TaskMsg::SpawnTask);
4415
4416 assert!(sim.model().completed);
4418 }
4419
4420 #[test]
4421 fn multiple_updates_accumulate_correctly() {
4422 use crate::simulator::ProgramSimulator;
4424
4425 struct AccumModel {
4426 sum: i32,
4427 }
4428
4429 #[derive(Debug)]
4430 enum AccumMsg {
4431 Add(i32),
4432 Multiply(i32),
4433 }
4434
4435 impl From<Event> for AccumMsg {
4436 fn from(_: Event) -> Self {
4437 AccumMsg::Add(1)
4438 }
4439 }
4440
4441 impl Model for AccumModel {
4442 type Message = AccumMsg;
4443
4444 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4445 match msg {
4446 AccumMsg::Add(n) => {
4447 self.sum += n;
4448 Cmd::none()
4449 }
4450 AccumMsg::Multiply(n) => {
4451 self.sum *= n;
4452 Cmd::none()
4453 }
4454 }
4455 }
4456
4457 fn view(&self, _frame: &mut Frame) {}
4458 }
4459
4460 let mut sim = ProgramSimulator::new(AccumModel { sum: 0 });
4461 sim.init();
4462
4463 sim.send(AccumMsg::Add(5));
4465 sim.send(AccumMsg::Multiply(2));
4466 sim.send(AccumMsg::Add(3));
4467
4468 assert_eq!(sim.model().sum, 13);
4469 }
4470
4471 #[test]
4472 fn init_command_executes_before_first_update() {
4473 use crate::simulator::ProgramSimulator;
4475
4476 struct InitModel {
4477 initialized: bool,
4478 updates: usize,
4479 }
4480
4481 #[derive(Debug)]
4482 enum InitMsg {
4483 Update,
4484 MarkInit,
4485 }
4486
4487 impl From<Event> for InitMsg {
4488 fn from(_: Event) -> Self {
4489 InitMsg::Update
4490 }
4491 }
4492
4493 impl Model for InitModel {
4494 type Message = InitMsg;
4495
4496 fn init(&mut self) -> Cmd<Self::Message> {
4497 Cmd::msg(InitMsg::MarkInit)
4498 }
4499
4500 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4501 match msg {
4502 InitMsg::MarkInit => {
4503 self.initialized = true;
4504 Cmd::none()
4505 }
4506 InitMsg::Update => {
4507 self.updates += 1;
4508 Cmd::none()
4509 }
4510 }
4511 }
4512
4513 fn view(&self, _frame: &mut Frame) {}
4514 }
4515
4516 let mut sim = ProgramSimulator::new(InitModel {
4517 initialized: false,
4518 updates: 0,
4519 });
4520 sim.init();
4521
4522 assert!(sim.model().initialized);
4523 sim.send(InitMsg::Update);
4524 assert_eq!(sim.model().updates, 1);
4525 }
4526
4527 #[test]
4532 fn ui_height_returns_correct_value_inline_mode() {
4533 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4535 use ftui_core::terminal_capabilities::TerminalCapabilities;
4536
4537 let output = Vec::new();
4538 let writer = TerminalWriter::new(
4539 output,
4540 ScreenMode::Inline { ui_height: 10 },
4541 UiAnchor::Bottom,
4542 TerminalCapabilities::basic(),
4543 );
4544 assert_eq!(writer.ui_height(), 10);
4545 }
4546
4547 #[test]
4548 fn ui_height_returns_term_height_altscreen_mode() {
4549 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4551 use ftui_core::terminal_capabilities::TerminalCapabilities;
4552
4553 let output = Vec::new();
4554 let mut writer = TerminalWriter::new(
4555 output,
4556 ScreenMode::AltScreen,
4557 UiAnchor::Bottom,
4558 TerminalCapabilities::basic(),
4559 );
4560 writer.set_size(80, 24);
4561 assert_eq!(writer.ui_height(), 24);
4562 }
4563
4564 #[test]
4565 fn inline_mode_frame_uses_ui_height_not_terminal_height() {
4566 use crate::simulator::ProgramSimulator;
4569 use std::cell::Cell as StdCell;
4570
4571 thread_local! {
4572 static CAPTURED_HEIGHT: StdCell<u16> = const { StdCell::new(0) };
4573 }
4574
4575 struct FrameSizeTracker;
4576
4577 #[derive(Debug)]
4578 enum SizeMsg {
4579 Check,
4580 }
4581
4582 impl From<Event> for SizeMsg {
4583 fn from(_: Event) -> Self {
4584 SizeMsg::Check
4585 }
4586 }
4587
4588 impl Model for FrameSizeTracker {
4589 type Message = SizeMsg;
4590
4591 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
4592 Cmd::none()
4593 }
4594
4595 fn view(&self, frame: &mut Frame) {
4596 CAPTURED_HEIGHT.with(|h| h.set(frame.height()));
4598 }
4599 }
4600
4601 let mut sim = ProgramSimulator::new(FrameSizeTracker);
4603 sim.init();
4604
4605 let buf = sim.capture_frame(80, 10);
4607 assert_eq!(buf.height(), 10);
4608 assert_eq!(buf.width(), 80);
4609
4610 }
4614
4615 #[test]
4616 fn altscreen_frame_uses_full_terminal_height() {
4617 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4619 use ftui_core::terminal_capabilities::TerminalCapabilities;
4620
4621 let output = Vec::new();
4622 let mut writer = TerminalWriter::new(
4623 output,
4624 ScreenMode::AltScreen,
4625 UiAnchor::Bottom,
4626 TerminalCapabilities::basic(),
4627 );
4628 writer.set_size(80, 40);
4629
4630 assert_eq!(writer.ui_height(), 40);
4632 }
4633
4634 #[test]
4635 fn ui_height_clamped_to_terminal_height() {
4636 use crate::terminal_writer::{ScreenMode, TerminalWriter, UiAnchor};
4639 use ftui_core::terminal_capabilities::TerminalCapabilities;
4640
4641 let output = Vec::new();
4642 let mut writer = TerminalWriter::new(
4643 output,
4644 ScreenMode::Inline { ui_height: 100 },
4645 UiAnchor::Bottom,
4646 TerminalCapabilities::basic(),
4647 );
4648 writer.set_size(80, 10);
4649
4650 assert_eq!(writer.ui_height(), 100);
4656 }
4657
4658 #[test]
4663 fn tick_event_delivered_to_model_update() {
4664 use crate::simulator::ProgramSimulator;
4667
4668 struct TickTracker {
4669 tick_count: usize,
4670 }
4671
4672 #[derive(Debug)]
4673 enum TickMsg {
4674 Tick,
4675 Other,
4676 }
4677
4678 impl From<Event> for TickMsg {
4679 fn from(event: Event) -> Self {
4680 match event {
4681 Event::Tick => TickMsg::Tick,
4682 _ => TickMsg::Other,
4683 }
4684 }
4685 }
4686
4687 impl Model for TickTracker {
4688 type Message = TickMsg;
4689
4690 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4691 match msg {
4692 TickMsg::Tick => {
4693 self.tick_count += 1;
4694 Cmd::none()
4695 }
4696 TickMsg::Other => Cmd::none(),
4697 }
4698 }
4699
4700 fn view(&self, _frame: &mut Frame) {}
4701 }
4702
4703 let mut sim = ProgramSimulator::new(TickTracker { tick_count: 0 });
4704 sim.init();
4705
4706 sim.inject_event(Event::Tick);
4708 assert_eq!(sim.model().tick_count, 1);
4709
4710 sim.inject_event(Event::Tick);
4711 sim.inject_event(Event::Tick);
4712 assert_eq!(sim.model().tick_count, 3);
4713 }
4714
4715 #[test]
4716 fn tick_command_sets_tick_rate() {
4717 use crate::simulator::{CmdRecord, ProgramSimulator};
4719
4720 struct TickModel;
4721
4722 #[derive(Debug)]
4723 enum Msg {
4724 SetTick,
4725 Noop,
4726 }
4727
4728 impl From<Event> for Msg {
4729 fn from(_: Event) -> Self {
4730 Msg::Noop
4731 }
4732 }
4733
4734 impl Model for TickModel {
4735 type Message = Msg;
4736
4737 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4738 match msg {
4739 Msg::SetTick => Cmd::tick(Duration::from_millis(100)),
4740 Msg::Noop => Cmd::none(),
4741 }
4742 }
4743
4744 fn view(&self, _frame: &mut Frame) {}
4745 }
4746
4747 let mut sim = ProgramSimulator::new(TickModel);
4748 sim.init();
4749 sim.send(Msg::SetTick);
4750
4751 let commands = sim.command_log();
4753 assert!(
4754 commands
4755 .iter()
4756 .any(|c| matches!(c, CmdRecord::Tick(d) if *d == Duration::from_millis(100)))
4757 );
4758 }
4759
4760 #[test]
4761 fn tick_can_trigger_further_commands() {
4762 use crate::simulator::ProgramSimulator;
4764
4765 struct ChainModel {
4766 stage: usize,
4767 }
4768
4769 #[derive(Debug)]
4770 enum ChainMsg {
4771 Tick,
4772 Advance,
4773 Noop,
4774 }
4775
4776 impl From<Event> for ChainMsg {
4777 fn from(event: Event) -> Self {
4778 match event {
4779 Event::Tick => ChainMsg::Tick,
4780 _ => ChainMsg::Noop,
4781 }
4782 }
4783 }
4784
4785 impl Model for ChainModel {
4786 type Message = ChainMsg;
4787
4788 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4789 match msg {
4790 ChainMsg::Tick => {
4791 self.stage += 1;
4792 Cmd::msg(ChainMsg::Advance)
4794 }
4795 ChainMsg::Advance => {
4796 self.stage += 10;
4797 Cmd::none()
4798 }
4799 ChainMsg::Noop => Cmd::none(),
4800 }
4801 }
4802
4803 fn view(&self, _frame: &mut Frame) {}
4804 }
4805
4806 let mut sim = ProgramSimulator::new(ChainModel { stage: 0 });
4807 sim.init();
4808 sim.inject_event(Event::Tick);
4809
4810 assert_eq!(sim.model().stage, 11);
4812 }
4813
4814 #[test]
4815 fn tick_disabled_with_zero_duration() {
4816 use crate::simulator::ProgramSimulator;
4818
4819 struct ZeroTickModel {
4820 disabled: bool,
4821 }
4822
4823 #[derive(Debug)]
4824 enum ZeroMsg {
4825 DisableTick,
4826 Noop,
4827 }
4828
4829 impl From<Event> for ZeroMsg {
4830 fn from(_: Event) -> Self {
4831 ZeroMsg::Noop
4832 }
4833 }
4834
4835 impl Model for ZeroTickModel {
4836 type Message = ZeroMsg;
4837
4838 fn init(&mut self) -> Cmd<Self::Message> {
4839 Cmd::tick(Duration::from_millis(100))
4841 }
4842
4843 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4844 match msg {
4845 ZeroMsg::DisableTick => {
4846 self.disabled = true;
4847 Cmd::tick(Duration::ZERO)
4849 }
4850 ZeroMsg::Noop => Cmd::none(),
4851 }
4852 }
4853
4854 fn view(&self, _frame: &mut Frame) {}
4855 }
4856
4857 let mut sim = ProgramSimulator::new(ZeroTickModel { disabled: false });
4858 sim.init();
4859
4860 assert!(sim.tick_rate().is_some());
4862 assert_eq!(sim.tick_rate(), Some(Duration::from_millis(100)));
4863
4864 sim.send(ZeroMsg::DisableTick);
4866 assert!(sim.model().disabled);
4867
4868 assert_eq!(sim.tick_rate(), Some(Duration::ZERO));
4871 }
4872
4873 #[test]
4874 fn tick_event_distinguishable_from_other_events() {
4875 let tick = Event::Tick;
4877 let key = Event::Key(ftui_core::event::KeyEvent::new(
4878 ftui_core::event::KeyCode::Char('a'),
4879 ));
4880
4881 assert!(matches!(tick, Event::Tick));
4882 assert!(!matches!(key, Event::Tick));
4883 }
4884
4885 #[test]
4886 fn tick_event_clone_and_eq() {
4887 let tick1 = Event::Tick;
4889 let tick2 = tick1.clone();
4890 assert_eq!(tick1, tick2);
4891 }
4892
4893 #[test]
4894 fn model_receives_tick_and_input_events() {
4895 use crate::simulator::ProgramSimulator;
4897
4898 struct MixedModel {
4899 ticks: usize,
4900 keys: usize,
4901 }
4902
4903 #[derive(Debug)]
4904 enum MixedMsg {
4905 Tick,
4906 Key,
4907 }
4908
4909 impl From<Event> for MixedMsg {
4910 fn from(event: Event) -> Self {
4911 match event {
4912 Event::Tick => MixedMsg::Tick,
4913 _ => MixedMsg::Key,
4914 }
4915 }
4916 }
4917
4918 impl Model for MixedModel {
4919 type Message = MixedMsg;
4920
4921 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
4922 match msg {
4923 MixedMsg::Tick => {
4924 self.ticks += 1;
4925 Cmd::none()
4926 }
4927 MixedMsg::Key => {
4928 self.keys += 1;
4929 Cmd::none()
4930 }
4931 }
4932 }
4933
4934 fn view(&self, _frame: &mut Frame) {}
4935 }
4936
4937 let mut sim = ProgramSimulator::new(MixedModel { ticks: 0, keys: 0 });
4938 sim.init();
4939
4940 sim.inject_event(Event::Tick);
4942 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
4943 ftui_core::event::KeyCode::Char('a'),
4944 )));
4945 sim.inject_event(Event::Tick);
4946 sim.inject_event(Event::Key(ftui_core::event::KeyEvent::new(
4947 ftui_core::event::KeyCode::Char('b'),
4948 )));
4949 sim.inject_event(Event::Tick);
4950
4951 assert_eq!(sim.model().ticks, 3);
4952 assert_eq!(sim.model().keys, 2);
4953 }
4954
4955 fn headless_program_with_config<M: Model>(
4960 model: M,
4961 config: ProgramConfig,
4962 ) -> Program<M, Vec<u8>>
4963 where
4964 M::Message: Send + 'static,
4965 {
4966 let capabilities = TerminalCapabilities::basic();
4967 let mut writer = TerminalWriter::with_diff_config(
4968 Vec::new(),
4969 config.screen_mode,
4970 config.ui_anchor,
4971 capabilities,
4972 config.diff_config.clone(),
4973 );
4974 let frame_timing = config.frame_timing.clone();
4975 writer.set_timing_enabled(frame_timing.is_some());
4976
4977 let (width, height) = config.forced_size.unwrap_or((80, 24));
4978 let width = width.max(1);
4979 let height = height.max(1);
4980 writer.set_size(width, height);
4981
4982 let session = TerminalSession::new_for_tests(SessionOptions {
4983 alternate_screen: matches!(config.screen_mode, ScreenMode::AltScreen),
4984 mouse_capture: config.mouse,
4985 bracketed_paste: config.bracketed_paste,
4986 focus_events: config.focus_reporting,
4987 kitty_keyboard: config.kitty_keyboard,
4988 })
4989 .expect("headless test session");
4990
4991 let budget = RenderBudget::from_config(&config.budget);
4992 let conformal_predictor = config.conformal_config.clone().map(ConformalPredictor::new);
4993 let locale_context = config.locale_context.clone();
4994 let locale_version = locale_context.version();
4995 let resize_coalescer =
4996 ResizeCoalescer::new(config.resize_coalescer.clone(), (width, height));
4997 let subscriptions = SubscriptionManager::new();
4998 let (task_sender, task_receiver) = std::sync::mpsc::channel();
4999 let inline_auto_remeasure = config
5000 .inline_auto_remeasure
5001 .clone()
5002 .map(InlineAutoRemeasureState::new);
5003
5004 Program {
5005 model,
5006 writer,
5007 session,
5008 running: true,
5009 tick_rate: None,
5010 last_tick: Instant::now(),
5011 dirty: true,
5012 frame_idx: 0,
5013 widget_signals: Vec::new(),
5014 widget_refresh_config: config.widget_refresh,
5015 widget_refresh_plan: WidgetRefreshPlan::new(),
5016 width,
5017 height,
5018 forced_size: config.forced_size,
5019 poll_timeout: config.poll_timeout,
5020 budget,
5021 conformal_predictor,
5022 last_frame_time_us: None,
5023 last_update_us: None,
5024 frame_timing,
5025 locale_context,
5026 locale_version,
5027 resize_coalescer,
5028 evidence_sink: None,
5029 fairness_config_logged: false,
5030 resize_behavior: config.resize_behavior,
5031 fairness_guard: InputFairnessGuard::new(),
5032 event_recorder: None,
5033 subscriptions,
5034 task_sender,
5035 task_receiver,
5036 task_handles: Vec::new(),
5037 effect_queue: None,
5038 state_registry: config.persistence.registry.clone(),
5039 persistence_config: config.persistence,
5040 last_checkpoint: Instant::now(),
5041 inline_auto_remeasure,
5042 }
5043 }
5044
5045 fn temp_evidence_path(label: &str) -> PathBuf {
5046 static COUNTER: AtomicUsize = AtomicUsize::new(0);
5047 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
5048 let pid = std::process::id();
5049 let mut path = std::env::temp_dir();
5050 path.push(format!("ftui_evidence_{label}_{pid}_{seq}.jsonl"));
5051 path
5052 }
5053
5054 fn read_evidence_event(path: &PathBuf, event: &str) -> Value {
5055 let jsonl = std::fs::read_to_string(path).expect("read evidence jsonl");
5056 let needle = format!("\"event\":\"{event}\"");
5057 let line = jsonl
5058 .lines()
5059 .find(|line| line.contains(&needle))
5060 .unwrap_or_else(|| panic!("missing {event} line"));
5061 serde_json::from_str(line).expect("valid evidence json")
5062 }
5063
5064 #[test]
5065 fn headless_apply_resize_updates_model_and_dimensions() {
5066 struct ResizeModel {
5067 last_size: Option<(u16, u16)>,
5068 }
5069
5070 #[derive(Debug)]
5071 enum ResizeMsg {
5072 Resize(u16, u16),
5073 Other,
5074 }
5075
5076 impl From<Event> for ResizeMsg {
5077 fn from(event: Event) -> Self {
5078 match event {
5079 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5080 _ => ResizeMsg::Other,
5081 }
5082 }
5083 }
5084
5085 impl Model for ResizeModel {
5086 type Message = ResizeMsg;
5087
5088 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5089 if let ResizeMsg::Resize(w, h) = msg {
5090 self.last_size = Some((w, h));
5091 }
5092 Cmd::none()
5093 }
5094
5095 fn view(&self, _frame: &mut Frame) {}
5096 }
5097
5098 let mut program =
5099 headless_program_with_config(ResizeModel { last_size: None }, ProgramConfig::default());
5100 program.dirty = false;
5101
5102 program
5103 .apply_resize(0, 0, Duration::ZERO, false)
5104 .expect("resize");
5105
5106 assert_eq!(program.width, 1);
5107 assert_eq!(program.height, 1);
5108 assert_eq!(program.model().last_size, Some((1, 1)));
5109 assert!(program.dirty);
5110 }
5111
5112 #[test]
5113 fn headless_execute_cmd_log_writes_output() {
5114 let mut program =
5115 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5116 program.execute_cmd(Cmd::log("hello world")).expect("log");
5117
5118 let bytes = program.writer.into_inner().expect("writer output");
5119 let output = String::from_utf8_lossy(&bytes);
5120 assert!(output.contains("hello world"));
5121 }
5122
5123 #[test]
5124 fn headless_process_task_results_updates_model() {
5125 struct TaskModel {
5126 updates: usize,
5127 }
5128
5129 #[derive(Debug)]
5130 enum TaskMsg {
5131 Done,
5132 }
5133
5134 impl From<Event> for TaskMsg {
5135 fn from(_: Event) -> Self {
5136 TaskMsg::Done
5137 }
5138 }
5139
5140 impl Model for TaskModel {
5141 type Message = TaskMsg;
5142
5143 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5144 self.updates += 1;
5145 Cmd::none()
5146 }
5147
5148 fn view(&self, _frame: &mut Frame) {}
5149 }
5150
5151 let mut program =
5152 headless_program_with_config(TaskModel { updates: 0 }, ProgramConfig::default());
5153 program.dirty = false;
5154 program.task_sender.send(TaskMsg::Done).unwrap();
5155
5156 program
5157 .process_task_results()
5158 .expect("process task results");
5159 assert_eq!(program.model().updates, 1);
5160 assert!(program.dirty);
5161 }
5162
5163 #[test]
5164 fn headless_should_tick_and_timeout_behaviors() {
5165 let mut program =
5166 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5167 program.tick_rate = Some(Duration::from_millis(5));
5168 program.last_tick = Instant::now() - Duration::from_millis(10);
5169
5170 assert!(program.should_tick());
5171 assert!(!program.should_tick());
5172
5173 let timeout = program.effective_timeout();
5174 assert!(timeout <= Duration::from_millis(5));
5175
5176 program.tick_rate = None;
5177 program.poll_timeout = Duration::from_millis(33);
5178 assert_eq!(program.effective_timeout(), Duration::from_millis(33));
5179 }
5180
5181 #[test]
5182 fn headless_effective_timeout_respects_resize_coalescer() {
5183 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5184 config.resize_coalescer.steady_delay_ms = 0;
5185 config.resize_coalescer.burst_delay_ms = 0;
5186
5187 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5188 program.tick_rate = Some(Duration::from_millis(50));
5189
5190 program.resize_coalescer.handle_resize(120, 40);
5191 assert!(program.resize_coalescer.has_pending());
5192
5193 let timeout = program.effective_timeout();
5194 assert_eq!(timeout, Duration::ZERO);
5195 }
5196
5197 #[test]
5198 fn headless_ui_height_remeasure_clears_auto_height() {
5199 let mut config = ProgramConfig::inline_auto(2, 6);
5200 config.inline_auto_remeasure = Some(InlineAutoRemeasureConfig::default());
5201
5202 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5203 program.dirty = false;
5204 program.writer.set_auto_ui_height(5);
5205
5206 assert_eq!(program.writer.auto_ui_height(), Some(5));
5207 program.request_ui_height_remeasure();
5208
5209 assert_eq!(program.writer.auto_ui_height(), None);
5210 assert!(program.dirty);
5211 }
5212
5213 #[test]
5214 fn headless_recording_lifecycle_and_locale_change() {
5215 let mut program =
5216 headless_program_with_config(TestModel { value: 0 }, ProgramConfig::default());
5217 program.dirty = false;
5218
5219 program.start_recording("demo");
5220 assert!(program.is_recording());
5221 let recorded = program.stop_recording();
5222 assert!(recorded.is_some());
5223 assert!(!program.is_recording());
5224
5225 let prev_dirty = program.dirty;
5226 program.locale_context.set_locale("fr");
5227 program.check_locale_change();
5228 assert!(program.dirty || prev_dirty);
5229 }
5230
5231 #[test]
5232 fn headless_render_frame_marks_clean_and_sets_diff() {
5233 struct RenderModel;
5234
5235 #[derive(Debug)]
5236 enum RenderMsg {
5237 Noop,
5238 }
5239
5240 impl From<Event> for RenderMsg {
5241 fn from(_: Event) -> Self {
5242 RenderMsg::Noop
5243 }
5244 }
5245
5246 impl Model for RenderModel {
5247 type Message = RenderMsg;
5248
5249 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5250 Cmd::none()
5251 }
5252
5253 fn view(&self, frame: &mut Frame) {
5254 frame.buffer.set_raw(0, 0, Cell::from_char('X'));
5255 }
5256 }
5257
5258 let mut program = headless_program_with_config(RenderModel, ProgramConfig::default());
5259 program.render_frame().expect("render frame");
5260
5261 assert!(!program.dirty);
5262 assert!(program.writer.last_diff_strategy().is_some());
5263 assert_eq!(program.frame_idx, 1);
5264 }
5265
5266 #[test]
5267 fn headless_render_frame_skips_when_budget_exhausted() {
5268 let config = ProgramConfig {
5269 budget: FrameBudgetConfig::with_total(Duration::ZERO),
5270 ..Default::default()
5271 };
5272
5273 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5274 program.render_frame().expect("render frame");
5275
5276 assert!(!program.dirty);
5277 assert_eq!(program.frame_idx, 1);
5278 }
5279
5280 #[test]
5281 fn headless_render_frame_emits_budget_evidence_with_controller() {
5282 use ftui_render::budget::BudgetControllerConfig;
5283
5284 struct RenderModel;
5285
5286 #[derive(Debug)]
5287 enum RenderMsg {
5288 Noop,
5289 }
5290
5291 impl From<Event> for RenderMsg {
5292 fn from(_: Event) -> Self {
5293 RenderMsg::Noop
5294 }
5295 }
5296
5297 impl Model for RenderModel {
5298 type Message = RenderMsg;
5299
5300 fn update(&mut self, _msg: Self::Message) -> Cmd<Self::Message> {
5301 Cmd::none()
5302 }
5303
5304 fn view(&self, frame: &mut Frame) {
5305 frame.buffer.set_raw(0, 0, Cell::from_char('E'));
5306 }
5307 }
5308
5309 let config =
5310 ProgramConfig::default().with_evidence_sink(EvidenceSinkConfig::enabled_stdout());
5311 let mut program = headless_program_with_config(RenderModel, config);
5312 program.budget = program
5313 .budget
5314 .with_controller(BudgetControllerConfig::default());
5315
5316 program.render_frame().expect("render frame");
5317 assert!(program.budget.telemetry().is_some());
5318 assert_eq!(program.frame_idx, 1);
5319 }
5320
5321 #[test]
5322 fn headless_handle_event_updates_model() {
5323 struct EventModel {
5324 events: usize,
5325 last_resize: Option<(u16, u16)>,
5326 }
5327
5328 #[derive(Debug)]
5329 enum EventMsg {
5330 Resize(u16, u16),
5331 Other,
5332 }
5333
5334 impl From<Event> for EventMsg {
5335 fn from(event: Event) -> Self {
5336 match event {
5337 Event::Resize { width, height } => EventMsg::Resize(width, height),
5338 _ => EventMsg::Other,
5339 }
5340 }
5341 }
5342
5343 impl Model for EventModel {
5344 type Message = EventMsg;
5345
5346 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5347 self.events += 1;
5348 if let EventMsg::Resize(w, h) = msg {
5349 self.last_resize = Some((w, h));
5350 }
5351 Cmd::none()
5352 }
5353
5354 fn view(&self, _frame: &mut Frame) {}
5355 }
5356
5357 let mut program = headless_program_with_config(
5358 EventModel {
5359 events: 0,
5360 last_resize: None,
5361 },
5362 ProgramConfig::default().with_resize_behavior(ResizeBehavior::Immediate),
5363 );
5364
5365 program
5366 .handle_event(Event::Key(ftui_core::event::KeyEvent::new(
5367 ftui_core::event::KeyCode::Char('x'),
5368 )))
5369 .expect("handle key");
5370 assert_eq!(program.model().events, 1);
5371
5372 program
5373 .handle_event(Event::Resize {
5374 width: 10,
5375 height: 5,
5376 })
5377 .expect("handle resize");
5378 assert_eq!(program.model().events, 2);
5379 assert_eq!(program.model().last_resize, Some((10, 5)));
5380 assert_eq!(program.width, 10);
5381 assert_eq!(program.height, 5);
5382 }
5383
5384 #[test]
5385 fn headless_handle_resize_ignored_when_forced_size() {
5386 struct ResizeModel {
5387 resized: bool,
5388 }
5389
5390 #[derive(Debug)]
5391 enum ResizeMsg {
5392 Resize,
5393 Other,
5394 }
5395
5396 impl From<Event> for ResizeMsg {
5397 fn from(event: Event) -> Self {
5398 match event {
5399 Event::Resize { .. } => ResizeMsg::Resize,
5400 _ => ResizeMsg::Other,
5401 }
5402 }
5403 }
5404
5405 impl Model for ResizeModel {
5406 type Message = ResizeMsg;
5407
5408 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5409 if matches!(msg, ResizeMsg::Resize) {
5410 self.resized = true;
5411 }
5412 Cmd::none()
5413 }
5414
5415 fn view(&self, _frame: &mut Frame) {}
5416 }
5417
5418 let config = ProgramConfig::default().with_forced_size(80, 24);
5419 let mut program = headless_program_with_config(ResizeModel { resized: false }, config);
5420
5421 program
5422 .handle_event(Event::Resize {
5423 width: 120,
5424 height: 40,
5425 })
5426 .expect("handle resize");
5427
5428 assert_eq!(program.width, 80);
5429 assert_eq!(program.height, 24);
5430 assert!(!program.model().resized);
5431 }
5432
5433 #[test]
5434 fn headless_execute_cmd_batch_sequence_and_quit() {
5435 struct BatchModel {
5436 count: usize,
5437 }
5438
5439 #[derive(Debug)]
5440 enum BatchMsg {
5441 Inc,
5442 }
5443
5444 impl From<Event> for BatchMsg {
5445 fn from(_: Event) -> Self {
5446 BatchMsg::Inc
5447 }
5448 }
5449
5450 impl Model for BatchModel {
5451 type Message = BatchMsg;
5452
5453 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5454 match msg {
5455 BatchMsg::Inc => {
5456 self.count += 1;
5457 Cmd::none()
5458 }
5459 }
5460 }
5461
5462 fn view(&self, _frame: &mut Frame) {}
5463 }
5464
5465 let mut program =
5466 headless_program_with_config(BatchModel { count: 0 }, ProgramConfig::default());
5467
5468 program
5469 .execute_cmd(Cmd::Batch(vec![
5470 Cmd::msg(BatchMsg::Inc),
5471 Cmd::Sequence(vec![
5472 Cmd::msg(BatchMsg::Inc),
5473 Cmd::quit(),
5474 Cmd::msg(BatchMsg::Inc),
5475 ]),
5476 ]))
5477 .expect("batch cmd");
5478
5479 assert_eq!(program.model().count, 2);
5480 assert!(!program.running);
5481 }
5482
5483 #[test]
5484 fn headless_process_subscription_messages_updates_model() {
5485 use crate::subscription::{StopSignal, SubId, Subscription};
5486
5487 struct SubModel {
5488 pings: usize,
5489 ready_tx: mpsc::Sender<()>,
5490 }
5491
5492 #[derive(Debug)]
5493 enum SubMsg {
5494 Ping,
5495 Other,
5496 }
5497
5498 impl From<Event> for SubMsg {
5499 fn from(_: Event) -> Self {
5500 SubMsg::Other
5501 }
5502 }
5503
5504 impl Model for SubModel {
5505 type Message = SubMsg;
5506
5507 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5508 if let SubMsg::Ping = msg {
5509 self.pings += 1;
5510 }
5511 Cmd::none()
5512 }
5513
5514 fn view(&self, _frame: &mut Frame) {}
5515
5516 fn subscriptions(&self) -> Vec<Box<dyn Subscription<Self::Message>>> {
5517 vec![Box::new(TestSubscription {
5518 ready_tx: self.ready_tx.clone(),
5519 })]
5520 }
5521 }
5522
5523 struct TestSubscription {
5524 ready_tx: mpsc::Sender<()>,
5525 }
5526
5527 impl Subscription<SubMsg> for TestSubscription {
5528 fn id(&self) -> SubId {
5529 1
5530 }
5531
5532 fn run(&self, sender: mpsc::Sender<SubMsg>, _stop: StopSignal) {
5533 let _ = sender.send(SubMsg::Ping);
5534 let _ = self.ready_tx.send(());
5535 }
5536 }
5537
5538 let (ready_tx, ready_rx) = mpsc::channel();
5539 let mut program =
5540 headless_program_with_config(SubModel { pings: 0, ready_tx }, ProgramConfig::default());
5541
5542 program.reconcile_subscriptions();
5543 ready_rx
5544 .recv_timeout(Duration::from_millis(200))
5545 .expect("subscription started");
5546 program
5547 .process_subscription_messages()
5548 .expect("process subscriptions");
5549
5550 assert_eq!(program.model().pings, 1);
5551 }
5552
5553 #[test]
5554 fn headless_execute_cmd_task_spawns_and_reaps() {
5555 struct TaskModel {
5556 done: bool,
5557 }
5558
5559 #[derive(Debug)]
5560 enum TaskMsg {
5561 Done,
5562 }
5563
5564 impl From<Event> for TaskMsg {
5565 fn from(_: Event) -> Self {
5566 TaskMsg::Done
5567 }
5568 }
5569
5570 impl Model for TaskModel {
5571 type Message = TaskMsg;
5572
5573 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5574 match msg {
5575 TaskMsg::Done => {
5576 self.done = true;
5577 Cmd::none()
5578 }
5579 }
5580 }
5581
5582 fn view(&self, _frame: &mut Frame) {}
5583 }
5584
5585 let mut program =
5586 headless_program_with_config(TaskModel { done: false }, ProgramConfig::default());
5587 program
5588 .execute_cmd(Cmd::task(|| TaskMsg::Done))
5589 .expect("task cmd");
5590
5591 let deadline = Instant::now() + Duration::from_millis(200);
5592 while !program.model().done {
5593 program
5594 .process_task_results()
5595 .expect("process task results");
5596 program.reap_finished_tasks();
5597 if Instant::now() > deadline {
5598 panic!("task result did not arrive in time");
5599 }
5600 }
5601
5602 assert!(program.model().done);
5603 }
5604
5605 #[test]
5606 fn headless_persistence_commands_with_registry() {
5607 use crate::state_persistence::{MemoryStorage, StateRegistry};
5608 use std::sync::Arc;
5609
5610 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
5611 let config = ProgramConfig::default().with_registry(registry.clone());
5612 let mut program = headless_program_with_config(TestModel { value: 0 }, config);
5613
5614 assert!(program.has_persistence());
5615 assert!(program.state_registry().is_some());
5616
5617 program.execute_cmd(Cmd::save_state()).expect("save");
5618 program.execute_cmd(Cmd::restore_state()).expect("restore");
5619
5620 let saved = program.trigger_save().expect("trigger save");
5621 let loaded = program.trigger_load().expect("trigger load");
5622 assert!(!saved);
5623 assert_eq!(loaded, 0);
5624 }
5625
5626 #[test]
5627 fn headless_process_resize_coalescer_applies_pending_resize() {
5628 struct ResizeModel {
5629 last_size: Option<(u16, u16)>,
5630 }
5631
5632 #[derive(Debug)]
5633 enum ResizeMsg {
5634 Resize(u16, u16),
5635 Other,
5636 }
5637
5638 impl From<Event> for ResizeMsg {
5639 fn from(event: Event) -> Self {
5640 match event {
5641 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5642 _ => ResizeMsg::Other,
5643 }
5644 }
5645 }
5646
5647 impl Model for ResizeModel {
5648 type Message = ResizeMsg;
5649
5650 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5651 if let ResizeMsg::Resize(w, h) = msg {
5652 self.last_size = Some((w, h));
5653 }
5654 Cmd::none()
5655 }
5656
5657 fn view(&self, _frame: &mut Frame) {}
5658 }
5659
5660 let evidence_path = temp_evidence_path("fairness_allow");
5661 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
5662 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5663 config.resize_coalescer.steady_delay_ms = 0;
5664 config.resize_coalescer.burst_delay_ms = 0;
5665 config.resize_coalescer.hard_deadline_ms = 1_000;
5666 config.evidence_sink = sink_config.clone();
5667
5668 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
5669 let sink = EvidenceSink::from_config(&sink_config)
5670 .expect("evidence sink config")
5671 .expect("evidence sink enabled");
5672 program.evidence_sink = Some(sink);
5673
5674 program.resize_coalescer.handle_resize(120, 40);
5675 assert!(program.resize_coalescer.has_pending());
5676
5677 program
5678 .process_resize_coalescer()
5679 .expect("process resize coalescer");
5680
5681 assert_eq!(program.width, 120);
5682 assert_eq!(program.height, 40);
5683 assert_eq!(program.model().last_size, Some((120, 40)));
5684
5685 let config_line = read_evidence_event(&evidence_path, "fairness_config");
5686 assert_eq!(config_line["event"], "fairness_config");
5687 assert!(config_line["enabled"].is_boolean());
5688 assert!(config_line["input_priority_threshold_ms"].is_number());
5689 assert!(config_line["dominance_threshold"].is_number());
5690 assert!(config_line["fairness_threshold"].is_number());
5691
5692 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
5693 assert_eq!(decision_line["event"], "fairness_decision");
5694 assert_eq!(decision_line["decision"], "allow");
5695 assert_eq!(decision_line["reason"], "none");
5696 assert!(decision_line["pending_input_latency_ms"].is_null());
5697 assert!(decision_line["jain_index"].is_number());
5698 assert!(decision_line["resize_dominance_count"].is_number());
5699 assert!(decision_line["dominance_threshold"].is_number());
5700 assert!(decision_line["fairness_threshold"].is_number());
5701 assert!(decision_line["input_priority_threshold_ms"].is_number());
5702 }
5703
5704 #[test]
5705 fn headless_process_resize_coalescer_yields_to_input() {
5706 struct ResizeModel {
5707 last_size: Option<(u16, u16)>,
5708 }
5709
5710 #[derive(Debug)]
5711 enum ResizeMsg {
5712 Resize(u16, u16),
5713 Other,
5714 }
5715
5716 impl From<Event> for ResizeMsg {
5717 fn from(event: Event) -> Self {
5718 match event {
5719 Event::Resize { width, height } => ResizeMsg::Resize(width, height),
5720 _ => ResizeMsg::Other,
5721 }
5722 }
5723 }
5724
5725 impl Model for ResizeModel {
5726 type Message = ResizeMsg;
5727
5728 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5729 if let ResizeMsg::Resize(w, h) = msg {
5730 self.last_size = Some((w, h));
5731 }
5732 Cmd::none()
5733 }
5734
5735 fn view(&self, _frame: &mut Frame) {}
5736 }
5737
5738 let evidence_path = temp_evidence_path("fairness_yield");
5739 let sink_config = EvidenceSinkConfig::enabled_file(&evidence_path);
5740 let mut config = ProgramConfig::default().with_resize_behavior(ResizeBehavior::Throttled);
5741 config.resize_coalescer.steady_delay_ms = 0;
5742 config.resize_coalescer.burst_delay_ms = 0;
5743 config.evidence_sink = sink_config.clone();
5744
5745 let mut program = headless_program_with_config(ResizeModel { last_size: None }, config);
5746 let sink = EvidenceSink::from_config(&sink_config)
5747 .expect("evidence sink config")
5748 .expect("evidence sink enabled");
5749 program.evidence_sink = Some(sink);
5750
5751 program.fairness_guard = InputFairnessGuard::with_config(
5752 crate::input_fairness::FairnessConfig::default().with_max_latency(Duration::ZERO),
5753 );
5754 program
5755 .fairness_guard
5756 .input_arrived(Instant::now() - Duration::from_millis(1));
5757
5758 program.resize_coalescer.handle_resize(120, 40);
5759 assert!(program.resize_coalescer.has_pending());
5760
5761 program
5762 .process_resize_coalescer()
5763 .expect("process resize coalescer");
5764
5765 assert_eq!(program.width, 80);
5766 assert_eq!(program.height, 24);
5767 assert_eq!(program.model().last_size, None);
5768 assert!(program.resize_coalescer.has_pending());
5769
5770 let decision_line = read_evidence_event(&evidence_path, "fairness_decision");
5771 assert_eq!(decision_line["event"], "fairness_decision");
5772 assert_eq!(decision_line["decision"], "yield");
5773 assert_eq!(decision_line["reason"], "input_latency");
5774 assert!(decision_line["pending_input_latency_ms"].is_number());
5775 assert!(decision_line["jain_index"].is_number());
5776 assert!(decision_line["resize_dominance_count"].is_number());
5777 assert!(decision_line["dominance_threshold"].is_number());
5778 assert!(decision_line["fairness_threshold"].is_number());
5779 assert!(decision_line["input_priority_threshold_ms"].is_number());
5780 }
5781
5782 #[test]
5783 fn headless_execute_cmd_task_with_effect_queue() {
5784 struct TaskModel {
5785 done: bool,
5786 }
5787
5788 #[derive(Debug)]
5789 enum TaskMsg {
5790 Done,
5791 }
5792
5793 impl From<Event> for TaskMsg {
5794 fn from(_: Event) -> Self {
5795 TaskMsg::Done
5796 }
5797 }
5798
5799 impl Model for TaskModel {
5800 type Message = TaskMsg;
5801
5802 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
5803 match msg {
5804 TaskMsg::Done => {
5805 self.done = true;
5806 Cmd::none()
5807 }
5808 }
5809 }
5810
5811 fn view(&self, _frame: &mut Frame) {}
5812 }
5813
5814 let effect_queue = EffectQueueConfig {
5815 enabled: true,
5816 scheduler: SchedulerConfig {
5817 max_queue_size: 0,
5818 ..Default::default()
5819 },
5820 };
5821 let config = ProgramConfig::default().with_effect_queue(effect_queue);
5822 let mut program = headless_program_with_config(TaskModel { done: false }, config);
5823
5824 program
5825 .execute_cmd(Cmd::task(|| TaskMsg::Done))
5826 .expect("task cmd");
5827
5828 let deadline = Instant::now() + Duration::from_millis(200);
5829 while !program.model().done {
5830 program
5831 .process_task_results()
5832 .expect("process task results");
5833 if Instant::now() > deadline {
5834 panic!("effect queue task result did not arrive in time");
5835 }
5836 }
5837
5838 assert!(program.model().done);
5839 }
5840
5841 #[test]
5846 fn unit_tau_monotone() {
5847 let mut bc = BatchController::new();
5850
5851 bc.observe_service(Duration::from_millis(20));
5853 bc.observe_service(Duration::from_millis(20));
5854 bc.observe_service(Duration::from_millis(20));
5855 let tau_high = bc.tau_s();
5856
5857 for _ in 0..20 {
5859 bc.observe_service(Duration::from_millis(1));
5860 }
5861 let tau_low = bc.tau_s();
5862
5863 assert!(
5864 tau_low <= tau_high,
5865 "τ should decrease with lower service time: tau_low={tau_low:.6}, tau_high={tau_high:.6}"
5866 );
5867 }
5868
5869 #[test]
5870 fn unit_tau_monotone_lambda() {
5871 let mut bc = BatchController::new();
5875 let base = Instant::now();
5876
5877 for i in 0..10 {
5879 bc.observe_arrival(base + Duration::from_millis(i * 10));
5880 }
5881 let rho_fast = bc.rho_est();
5882
5883 for i in 10..20 {
5885 bc.observe_arrival(base + Duration::from_millis(100 + i * 100));
5886 }
5887 let rho_slow = bc.rho_est();
5888
5889 assert!(
5890 rho_slow < rho_fast,
5891 "ρ should decrease with slower arrivals: rho_slow={rho_slow:.4}, rho_fast={rho_fast:.4}"
5892 );
5893 }
5894
5895 #[test]
5896 fn unit_stability() {
5897 let mut bc = BatchController::new();
5899 let base = Instant::now();
5900
5901 for i in 0..30 {
5903 bc.observe_arrival(base + Duration::from_millis(i * 33));
5904 bc.observe_service(Duration::from_millis(5)); }
5906
5907 assert!(
5908 bc.is_stable(),
5909 "should be stable at 30 events/sec with 5ms service: ρ={:.4}",
5910 bc.rho_est()
5911 );
5912 assert!(
5913 bc.rho_est() < 1.0,
5914 "utilization should be < 1: ρ={:.4}",
5915 bc.rho_est()
5916 );
5917
5918 assert!(
5920 bc.tau_s() > bc.service_est_s(),
5921 "τ ({:.6}) must exceed E[S] ({:.6}) for stability",
5922 bc.tau_s(),
5923 bc.service_est_s()
5924 );
5925 }
5926
5927 #[test]
5928 fn unit_stability_high_load() {
5929 let mut bc = BatchController::new();
5931 let base = Instant::now();
5932
5933 for i in 0..50 {
5935 bc.observe_arrival(base + Duration::from_millis(i * 10));
5936 bc.observe_service(Duration::from_millis(8));
5937 }
5938
5939 let tau = bc.tau_s();
5941 let rho_eff = bc.service_est_s() / tau;
5942 assert!(
5943 rho_eff < 1.0,
5944 "effective utilization should be < 1: ρ_eff={rho_eff:.4}, τ={tau:.6}, E[S]={:.6}",
5945 bc.service_est_s()
5946 );
5947 }
5948
5949 #[test]
5950 fn batch_controller_defaults() {
5951 let bc = BatchController::new();
5952 assert!(bc.tau_s() >= bc.tau_min_s);
5953 assert!(bc.tau_s() <= bc.tau_max_s);
5954 assert_eq!(bc.observations(), 0);
5955 assert!(bc.is_stable());
5956 }
5957
5958 #[test]
5959 fn batch_controller_tau_clamped() {
5960 let mut bc = BatchController::new();
5961
5962 for _ in 0..20 {
5964 bc.observe_service(Duration::from_micros(10));
5965 }
5966 assert!(
5967 bc.tau_s() >= bc.tau_min_s,
5968 "τ should be >= tau_min: τ={:.6}, min={:.6}",
5969 bc.tau_s(),
5970 bc.tau_min_s
5971 );
5972
5973 for _ in 0..20 {
5975 bc.observe_service(Duration::from_millis(100));
5976 }
5977 assert!(
5978 bc.tau_s() <= bc.tau_max_s,
5979 "τ should be <= tau_max: τ={:.6}, max={:.6}",
5980 bc.tau_s(),
5981 bc.tau_max_s
5982 );
5983 }
5984
5985 #[test]
5986 fn batch_controller_duration_conversion() {
5987 let bc = BatchController::new();
5988 let tau = bc.tau();
5989 let tau_s = bc.tau_s();
5990 let diff = (tau.as_secs_f64() - tau_s).abs();
5992 assert!(diff < 1e-9, "Duration conversion mismatch: {diff}");
5993 }
5994
5995 #[test]
5996 fn batch_controller_lambda_estimation() {
5997 let mut bc = BatchController::new();
5998 let base = Instant::now();
5999
6000 for i in 0..20 {
6002 bc.observe_arrival(base + Duration::from_millis(i * 20));
6003 }
6004
6005 let lambda = bc.lambda_est();
6007 assert!(
6008 lambda > 20.0 && lambda < 100.0,
6009 "λ should be near 50: got {lambda:.1}"
6010 );
6011 }
6012
6013 #[test]
6018 fn cmd_save_state() {
6019 let cmd: Cmd<TestMsg> = Cmd::save_state();
6020 assert!(matches!(cmd, Cmd::SaveState));
6021 }
6022
6023 #[test]
6024 fn cmd_restore_state() {
6025 let cmd: Cmd<TestMsg> = Cmd::restore_state();
6026 assert!(matches!(cmd, Cmd::RestoreState));
6027 }
6028
6029 #[test]
6030 fn persistence_config_default() {
6031 let config = PersistenceConfig::default();
6032 assert!(config.registry.is_none());
6033 assert!(config.checkpoint_interval.is_none());
6034 assert!(config.auto_load);
6035 assert!(config.auto_save);
6036 }
6037
6038 #[test]
6039 fn persistence_config_disabled() {
6040 let config = PersistenceConfig::disabled();
6041 assert!(config.registry.is_none());
6042 }
6043
6044 #[test]
6045 fn persistence_config_with_registry() {
6046 use crate::state_persistence::{MemoryStorage, StateRegistry};
6047 use std::sync::Arc;
6048
6049 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6050 let config = PersistenceConfig::with_registry(registry.clone());
6051
6052 assert!(config.registry.is_some());
6053 assert!(config.auto_load);
6054 assert!(config.auto_save);
6055 }
6056
6057 #[test]
6058 fn persistence_config_checkpoint_interval() {
6059 use crate::state_persistence::{MemoryStorage, StateRegistry};
6060 use std::sync::Arc;
6061
6062 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6063 let config = PersistenceConfig::with_registry(registry)
6064 .checkpoint_every(Duration::from_secs(30))
6065 .auto_load(false)
6066 .auto_save(true);
6067
6068 assert!(config.checkpoint_interval.is_some());
6069 assert_eq!(config.checkpoint_interval.unwrap(), Duration::from_secs(30));
6070 assert!(!config.auto_load);
6071 assert!(config.auto_save);
6072 }
6073
6074 #[test]
6075 fn program_config_with_persistence() {
6076 use crate::state_persistence::{MemoryStorage, StateRegistry};
6077 use std::sync::Arc;
6078
6079 let registry = Arc::new(StateRegistry::new(Box::new(MemoryStorage::new())));
6080 let config = ProgramConfig::default().with_registry(registry);
6081
6082 assert!(config.persistence.registry.is_some());
6083 }
6084}