Skip to main content

ftui_runtime/tick_strategy/
custom.rs

1//! [`Custom`] strategy: closure-based for app-specific tick logic.
2//!
3//! An escape hatch for apps that need custom tick decisions without
4//! implementing the full [`TickStrategy`] trait. Wraps a user-provided
5//! closure `(screen_id, tick_count, active_screen) -> TickDecision`.
6
7use super::{TickDecision, TickStrategy};
8
9/// Type alias for the custom tick decision function.
10type DeciderFn = dyn Fn(&str, u64, &str) -> TickDecision + Send;
11
12/// Closure-based tick strategy for app-specific logic.
13///
14/// # Example
15///
16/// ```
17/// use ftui_runtime::tick_strategy::{Custom, TickDecision};
18///
19/// let strategy = Custom::new("PriorityBased", |screen_id, tick_count, _active| {
20///     let divisor: u64 = match screen_id {
21///         "Dashboard" | "Messages" => 2,
22///         "Analytics" => 10,
23///         _ => 5,
24///     };
25///     if tick_count.is_multiple_of(divisor) {
26///         TickDecision::Tick
27///     } else {
28///         TickDecision::Skip
29///     }
30/// });
31/// ```
32pub struct Custom {
33    decider: Box<DeciderFn>,
34    label: String,
35}
36
37impl Custom {
38    /// Create a custom strategy with the given label and decision function.
39    ///
40    /// The closure receives `(screen_id, tick_count, active_screen)` and
41    /// returns [`TickDecision::Tick`] or [`TickDecision::Skip`].
42    pub fn new<F>(label: impl Into<String>, f: F) -> Self
43    where
44        F: Fn(&str, u64, &str) -> TickDecision + Send + 'static,
45    {
46        Self {
47            decider: Box::new(f),
48            label: label.into(),
49        }
50    }
51}
52
53impl std::fmt::Debug for Custom {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("Custom")
56            .field("label", &self.label)
57            .finish_non_exhaustive()
58    }
59}
60
61impl TickStrategy for Custom {
62    fn should_tick(
63        &mut self,
64        screen_id: &str,
65        tick_count: u64,
66        active_screen: &str,
67    ) -> TickDecision {
68        (self.decider)(screen_id, tick_count, active_screen)
69    }
70
71    fn name(&self) -> &str {
72        &self.label
73    }
74
75    fn debug_stats(&self) -> Vec<(String, String)> {
76        vec![("strategy".into(), self.label.clone())]
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn custom_closure_receives_correct_args() {
86        let mut s = Custom::new("Test", |screen_id, tick_count, active| {
87            assert_eq!(screen_id, "bg");
88            assert_eq!(tick_count, 42);
89            assert_eq!(active, "fg");
90            TickDecision::Tick
91        });
92        assert_eq!(s.should_tick("bg", 42, "fg"), TickDecision::Tick);
93    }
94
95    #[test]
96    fn custom_return_value_is_respected() {
97        let mut always_tick = Custom::new("AlwaysTick", |_, _, _| TickDecision::Tick);
98        assert_eq!(always_tick.should_tick("x", 0, "y"), TickDecision::Tick);
99
100        let mut always_skip = Custom::new("AlwaysSkip", |_, _, _| TickDecision::Skip);
101        assert_eq!(always_skip.should_tick("x", 0, "y"), TickDecision::Skip);
102    }
103
104    #[test]
105    fn name_returns_label() {
106        let s = Custom::new("MyCustom", |_, _, _| TickDecision::Skip);
107        assert_eq!(s.name(), "MyCustom");
108    }
109
110    #[test]
111    fn debug_stats_contains_label() {
112        let s = Custom::new("Labeled", |_, _, _| TickDecision::Skip);
113        let stats = s.debug_stats();
114        assert_eq!(stats.len(), 1);
115        assert_eq!(stats[0], ("strategy".to_owned(), "Labeled".to_owned()));
116    }
117
118    #[test]
119    fn custom_can_be_boxed_as_dyn_tick_strategy() {
120        let s = Custom::new("Boxable", |_, _, _| TickDecision::Tick);
121        let mut boxed: Box<dyn TickStrategy> = Box::new(s);
122        assert_eq!(boxed.should_tick("a", 0, "b"), TickDecision::Tick);
123        assert_eq!(boxed.name(), "Boxable");
124    }
125
126    #[test]
127    fn custom_debug_format() {
128        let s = Custom::new("Dbg", |_, _, _| TickDecision::Skip);
129        let dbg = format!("{s:?}");
130        assert!(dbg.contains("Custom"));
131        assert!(dbg.contains("Dbg"));
132    }
133
134    #[test]
135    fn custom_divisor_logic() {
136        let mut s = Custom::new("DivisorBased", |screen_id, tick_count, _| {
137            let divisor: u64 = match screen_id {
138                "fast" => 2,
139                "slow" => 10,
140                _ => 5,
141            };
142            if tick_count.is_multiple_of(divisor) {
143                TickDecision::Tick
144            } else {
145                TickDecision::Skip
146            }
147        });
148
149        // fast: divisor=2, tick 4 → Tick
150        assert_eq!(s.should_tick("fast", 4, "active"), TickDecision::Tick);
151        // fast: divisor=2, tick 3 → Skip
152        assert_eq!(s.should_tick("fast", 3, "active"), TickDecision::Skip);
153        // slow: divisor=10, tick 10 → Tick
154        assert_eq!(s.should_tick("slow", 10, "active"), TickDecision::Tick);
155        // slow: divisor=10, tick 5 → Skip
156        assert_eq!(s.should_tick("slow", 5, "active"), TickDecision::Skip);
157    }
158}