Skip to main content

ftui_runtime/tick_strategy/
mod.rs

1//! Tick strategy primitives for selective background ticking.
2//!
3//! The runtime always ticks the active screen. A [`TickStrategy`] decides
4//! whether each inactive screen should receive a tick on a given frame.
5//!
6//! # Standalone structs vs. convenience enum
7//!
8//! Each built-in strategy has a standalone struct ([`ActiveOnly`], [`Uniform`],
9//! [`ActivePlusAdjacent`]) that implements [`TickStrategy`] directly. For
10//! quick selection among built-ins, use [`TickStrategyKind`] which delegates
11//! to the same logic.
12
13mod active_only;
14mod active_plus_adjacent;
15mod custom;
16mod markov_predictor;
17#[cfg(any(feature = "state-persistence", test))]
18pub mod persistence;
19mod predictive;
20mod tick_allocation;
21mod transition_counter;
22mod transition_history;
23mod uniform;
24
25pub use active_only::ActiveOnly;
26pub use active_plus_adjacent::ActivePlusAdjacent;
27pub use custom::Custom;
28pub use markov_predictor::{DecayConfig, MarkovPredictor, ScreenPrediction};
29#[cfg(feature = "state-persistence")]
30pub use persistence::{load_transitions, save_transitions};
31// Note: persistence module also compiles under #[cfg(test)] since serde is in dev-deps.
32pub use predictive::{Predictive, PredictiveStrategyConfig};
33pub use tick_allocation::{AllocationCurve, TickAllocation};
34pub use transition_counter::TransitionCounter;
35pub use transition_history::{TransitionEntry, TransitionHistory};
36pub use uniform::Uniform;
37
38/// Decision returned by a [`TickStrategy`] for an inactive screen.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TickDecision {
41    /// Tick this screen on this frame.
42    Tick,
43    /// Skip this screen on this frame.
44    Skip,
45}
46
47/// Controls which inactive screens get ticked on each frame.
48///
49/// The runtime owns the invariant that the active screen is always ticked.
50/// Implementations should assume `should_tick` is only called for inactive
51/// screens that are still eligible for work.
52pub trait TickStrategy: Send {
53    /// Decide whether to tick an inactive screen on this frame.
54    fn should_tick(
55        &mut self,
56        screen_id: &str,
57        tick_count: u64,
58        active_screen: &str,
59    ) -> TickDecision;
60
61    /// Called when the runtime observes a screen transition.
62    fn on_screen_transition(&mut self, _from: &str, _to: &str) {}
63
64    /// Called periodically for maintenance work.
65    fn maintenance_tick(&mut self, _tick_count: u64) {}
66
67    /// Called during clean shutdown.
68    fn shutdown(&mut self) {}
69
70    /// Human-readable strategy name for logs/debugging.
71    fn name(&self) -> &str;
72
73    /// Optional key-value debug stats.
74    fn debug_stats(&self) -> Vec<(String, String)> {
75        Vec::new()
76    }
77}
78
79/// Minimal predictive strategy config used by [`TickStrategyKind`].
80///
81/// Full predictive tuning fields are added by follow-up tasks.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct PredictiveConfig {
84    /// Fallback divisor when no predictive signal is available.
85    pub fallback_divisor: u64,
86}
87
88impl PredictiveConfig {
89    /// Construct a predictive config with an explicit fallback divisor.
90    #[must_use]
91    pub const fn new(fallback_divisor: u64) -> Self {
92        Self { fallback_divisor }
93    }
94
95    #[must_use]
96    const fn normalized_fallback_divisor(self) -> u64 {
97        if self.fallback_divisor == 0 {
98            1
99        } else {
100            self.fallback_divisor
101        }
102    }
103}
104
105impl Default for PredictiveConfig {
106    fn default() -> Self {
107        Self {
108            fallback_divisor: 5,
109        }
110    }
111}
112
113/// Built-in strategy selection convenience enum.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum TickStrategyKind {
116    /// Tick only the active screen; all inactive screens are skipped.
117    ActiveOnly,
118    /// Tick all inactive screens every `divisor` frames.
119    Uniform { divisor: u64 },
120    /// Tick declared adjacent screens each frame; all others use a divisor.
121    ActivePlusAdjacent {
122        /// Screen ids adjacent to the active screen.
123        screens: Vec<String>,
124        /// Divisor for non-adjacent inactive screens.
125        background_divisor: u64,
126    },
127    /// Predictive strategy using current config.
128    Predictive { config: PredictiveConfig },
129}
130
131impl TickStrategyKind {
132    #[must_use]
133    const fn normalized_divisor(divisor: u64) -> u64 {
134        if divisor == 0 { 1 } else { divisor }
135    }
136}
137
138impl TickStrategy for TickStrategyKind {
139    fn should_tick(
140        &mut self,
141        screen_id: &str,
142        tick_count: u64,
143        _active_screen: &str,
144    ) -> TickDecision {
145        match self {
146            Self::ActiveOnly => TickDecision::Skip,
147            Self::Uniform { divisor } => {
148                if tick_count.is_multiple_of(Self::normalized_divisor(*divisor)) {
149                    TickDecision::Tick
150                } else {
151                    TickDecision::Skip
152                }
153            }
154            Self::ActivePlusAdjacent {
155                screens,
156                background_divisor,
157            } => {
158                if screens.iter().any(|adjacent| adjacent == screen_id)
159                    || tick_count.is_multiple_of(Self::normalized_divisor(*background_divisor))
160                {
161                    TickDecision::Tick
162                } else {
163                    TickDecision::Skip
164                }
165            }
166            Self::Predictive { config } => {
167                if tick_count.is_multiple_of(config.normalized_fallback_divisor()) {
168                    TickDecision::Tick
169                } else {
170                    TickDecision::Skip
171                }
172            }
173        }
174    }
175
176    fn name(&self) -> &str {
177        match self {
178            Self::ActiveOnly => "ActiveOnly",
179            Self::Uniform { .. } => "Uniform",
180            Self::ActivePlusAdjacent { .. } => "ActivePlusAdjacent",
181            Self::Predictive { .. } => "Predictive",
182        }
183    }
184
185    fn debug_stats(&self) -> Vec<(String, String)> {
186        match self {
187            Self::ActiveOnly => vec![("strategy".into(), "ActiveOnly".into())],
188            Self::Uniform { divisor } => vec![
189                ("strategy".into(), "Uniform".into()),
190                (
191                    "divisor".into(),
192                    Self::normalized_divisor(*divisor).to_string(),
193                ),
194            ],
195            Self::ActivePlusAdjacent {
196                screens,
197                background_divisor,
198            } => vec![
199                ("strategy".into(), "ActivePlusAdjacent".into()),
200                (
201                    "background_divisor".into(),
202                    Self::normalized_divisor(*background_divisor).to_string(),
203                ),
204                ("adjacent_screen_count".into(), screens.len().to_string()),
205            ],
206            Self::Predictive { config } => vec![
207                ("strategy".into(), "Predictive".into()),
208                (
209                    "fallback_divisor".into(),
210                    config.normalized_fallback_divisor().to_string(),
211                ),
212            ],
213        }
214    }
215}
216
217/// Implemented by [`Model`](crate::program::Model)s that manage multiple
218/// screens and want per-screen tick control via [`TickStrategy`].
219///
220/// The runtime checks for this trait via
221/// [`Model::as_screen_tick_dispatch`](crate::program::Model::as_screen_tick_dispatch).
222/// When present, the runtime ticks individual screens instead of calling a
223/// monolithic `update(Tick)`.
224pub trait ScreenTickDispatch {
225    /// Returns IDs of all currently registered screens.
226    fn screen_ids(&self) -> Vec<String>;
227
228    /// Returns the ID of the currently active/visible screen.
229    fn active_screen_id(&self) -> String;
230
231    /// Tick a specific screen by ID.
232    ///
233    /// Called by the runtime for each screen the [`TickStrategy`] approves.
234    /// Unknown screen IDs should be silently ignored.
235    fn tick_screen(&mut self, screen_id: &str, tick_count: u64);
236}
237
238#[cfg(test)]
239mod tests {
240    use super::{PredictiveConfig, TickDecision, TickStrategy, TickStrategyKind};
241
242    struct NoopStrategy;
243
244    impl TickStrategy for NoopStrategy {
245        fn should_tick(
246            &mut self,
247            _screen_id: &str,
248            _tick_count: u64,
249            _active_screen: &str,
250        ) -> TickDecision {
251            TickDecision::Skip
252        }
253
254        fn name(&self) -> &str {
255            "Noop"
256        }
257    }
258
259    #[test]
260    fn tick_decision_copy_and_eq() {
261        let decision = TickDecision::Tick;
262        let copied = decision;
263        assert_eq!(copied, TickDecision::Tick);
264        assert_ne!(TickDecision::Tick, TickDecision::Skip);
265        assert!(format!("{decision:?}").contains("Tick"));
266    }
267
268    #[test]
269    fn default_trait_hooks_are_noops() {
270        let mut strategy = NoopStrategy;
271        strategy.on_screen_transition("A", "B");
272        strategy.maintenance_tick(123);
273        strategy.shutdown();
274        assert!(strategy.debug_stats().is_empty());
275    }
276
277    #[test]
278    fn tick_strategy_kind_delegates_should_tick() {
279        let mut active_only = TickStrategyKind::ActiveOnly;
280        assert_eq!(
281            active_only.should_tick("ScreenA", 10, "ScreenB"),
282            TickDecision::Skip
283        );
284
285        let mut uniform = TickStrategyKind::Uniform { divisor: 5 };
286        assert_eq!(
287            uniform.should_tick("ScreenA", 10, "ScreenB"),
288            TickDecision::Tick
289        );
290        assert_eq!(
291            uniform.should_tick("ScreenA", 11, "ScreenB"),
292            TickDecision::Skip
293        );
294
295        let mut uniform_zero = TickStrategyKind::Uniform { divisor: 0 };
296        assert_eq!(
297            uniform_zero.should_tick("ScreenA", 3, "ScreenB"),
298            TickDecision::Tick
299        );
300
301        let mut active_plus_adjacent = TickStrategyKind::ActivePlusAdjacent {
302            screens: vec!["Messages".into(), "Threads".into()],
303            background_divisor: 4,
304        };
305        assert_eq!(
306            active_plus_adjacent.should_tick("Messages", 1, "Dashboard"),
307            TickDecision::Tick
308        );
309        assert_eq!(
310            active_plus_adjacent.should_tick("Settings", 4, "Dashboard"),
311            TickDecision::Tick
312        );
313        assert_eq!(
314            active_plus_adjacent.should_tick("Settings", 5, "Dashboard"),
315            TickDecision::Skip
316        );
317
318        let mut predictive = TickStrategyKind::Predictive {
319            config: PredictiveConfig::new(3),
320        };
321        assert_eq!(
322            predictive.should_tick("ScreenA", 6, "ScreenB"),
323            TickDecision::Tick
324        );
325        assert_eq!(
326            predictive.should_tick("ScreenA", 7, "ScreenB"),
327            TickDecision::Skip
328        );
329    }
330
331    #[test]
332    fn tick_strategy_kind_names_are_stable() {
333        assert_eq!(TickStrategyKind::ActiveOnly.name(), "ActiveOnly");
334        assert_eq!(TickStrategyKind::Uniform { divisor: 5 }.name(), "Uniform");
335        assert_eq!(
336            TickStrategyKind::ActivePlusAdjacent {
337                screens: vec![],
338                background_divisor: 5,
339            }
340            .name(),
341            "ActivePlusAdjacent"
342        );
343        assert_eq!(
344            TickStrategyKind::Predictive {
345                config: PredictiveConfig::default(),
346            }
347            .name(),
348            "Predictive"
349        );
350    }
351
352    #[test]
353    fn predictive_default_config_matches_design() {
354        assert_eq!(PredictiveConfig::default().fallback_divisor, 5);
355    }
356
357    // ========================================================================
358    // ScreenTickDispatch tests
359    // ========================================================================
360
361    use super::ScreenTickDispatch;
362
363    struct MockMultiScreen {
364        active: String,
365        screens: Vec<String>,
366        ticked: Vec<(String, u64)>,
367    }
368
369    impl MockMultiScreen {
370        fn new(active: &str, screens: &[&str]) -> Self {
371            Self {
372                active: active.to_owned(),
373                screens: screens.iter().map(|s| (*s).to_owned()).collect(),
374                ticked: Vec::new(),
375            }
376        }
377    }
378
379    impl ScreenTickDispatch for MockMultiScreen {
380        fn screen_ids(&self) -> Vec<String> {
381            self.screens.clone()
382        }
383
384        fn active_screen_id(&self) -> String {
385            self.active.clone()
386        }
387
388        fn tick_screen(&mut self, screen_id: &str, tick_count: u64) {
389            self.ticked.push((screen_id.to_owned(), tick_count));
390        }
391    }
392
393    #[test]
394    fn screen_tick_dispatch_returns_all_screens() {
395        let mock = MockMultiScreen::new("A", &["A", "B", "C"]);
396        assert_eq!(mock.screen_ids(), vec!["A", "B", "C"]);
397    }
398
399    #[test]
400    fn screen_tick_dispatch_reports_active() {
401        let mock = MockMultiScreen::new("B", &["A", "B", "C"]);
402        assert_eq!(mock.active_screen_id(), "B");
403    }
404
405    #[test]
406    fn screen_tick_dispatch_records_ticks() {
407        let mut mock = MockMultiScreen::new("A", &["A", "B", "C"]);
408        mock.tick_screen("B", 5);
409        mock.tick_screen("C", 5);
410        assert_eq!(mock.ticked.len(), 2);
411        assert_eq!(mock.ticked[0], ("B".to_owned(), 5));
412        assert_eq!(mock.ticked[1], ("C".to_owned(), 5));
413    }
414
415    #[test]
416    fn screen_tick_dispatch_unknown_id_is_noop() {
417        let mut mock = MockMultiScreen::new("A", &["A", "B"]);
418        mock.tick_screen("UNKNOWN", 10);
419        // Implementation records it; the trait contract says "silently ignore"
420        // which the concrete impl decides. Our mock doesn't filter.
421        assert_eq!(mock.ticked.len(), 1);
422    }
423
424    // ========================================================================
425    // Cross-strategy invariant tests (I.4 coverage)
426    // ========================================================================
427
428    use super::{
429        ActiveOnly, ActivePlusAdjacent, Custom, Predictive, PredictiveStrategyConfig, Uniform,
430    };
431
432    /// Compile-time assertion: all strategies implement Send (required by the
433    /// `TickStrategy: Send` supertrait bound).
434    #[test]
435    fn all_strategies_implement_send() {
436        fn assert_send<T: Send>() {}
437
438        assert_send::<ActiveOnly>();
439        assert_send::<Uniform>();
440        assert_send::<ActivePlusAdjacent>();
441        assert_send::<Predictive>();
442        assert_send::<Custom>();
443        assert_send::<TickStrategyKind>();
444    }
445
446    /// All strategies can be boxed as `dyn TickStrategy`.
447    #[test]
448    fn all_strategies_boxable_as_dyn_tick_strategy() {
449        let strategies: Vec<Box<dyn TickStrategy>> = vec![
450            Box::new(ActiveOnly),
451            Box::new(Uniform::new(5)),
452            Box::new(ActivePlusAdjacent::new(5)),
453            Box::new(Predictive::new(PredictiveStrategyConfig::default())),
454            Box::new(Custom::new("test", |_, _, _| TickDecision::Skip)),
455            Box::new(TickStrategyKind::ActiveOnly),
456        ];
457
458        for mut s in strategies {
459            // should_tick is callable through the trait object
460            let _ = s.should_tick("screen", 0, "active");
461            // name() is callable
462            assert!(!s.name().is_empty());
463        }
464    }
465
466    /// Default lifecycle hooks (on_screen_transition, maintenance_tick,
467    /// shutdown) are no-ops for strategies that don't override them.
468    #[test]
469    fn lifecycle_hooks_are_safe_for_all_strategies() {
470        let mut strategies: Vec<Box<dyn TickStrategy>> = vec![
471            Box::new(ActiveOnly),
472            Box::new(Uniform::new(5)),
473            Box::new(ActivePlusAdjacent::new(5)),
474            Box::new(Custom::new("test", |_, _, _| TickDecision::Skip)),
475            Box::new(TickStrategyKind::Uniform { divisor: 5 }),
476        ];
477
478        for s in &mut strategies {
479            s.on_screen_transition("A", "B");
480            s.maintenance_tick(100);
481            s.shutdown();
482            // No panics = success
483        }
484    }
485}