Skip to main content

ftui_runtime/tick_strategy/
active_plus_adjacent.rs

1//! [`ActivePlusAdjacent`] strategy: full-rate for neighbors, divisor for the rest.
2
3use std::collections::HashMap;
4
5use super::{TickDecision, TickStrategy};
6
7/// Tick the active screen's declared neighbors at full rate while all other
8/// inactive screens use a reduced divisor.
9///
10/// Use when navigation between adjacent screens is common (e.g. tab bars)
11/// and you want instant-feeling switches without a learned model.
12#[derive(Debug, Clone)]
13pub struct ActivePlusAdjacent {
14    adjacency: HashMap<String, Vec<String>>,
15    background_divisor: u64,
16}
17
18impl ActivePlusAdjacent {
19    /// Create an empty adjacency strategy with the given background divisor.
20    ///
21    /// A divisor of 0 is clamped to 1.
22    #[must_use]
23    pub fn new(background_divisor: u64) -> Self {
24        Self {
25            adjacency: HashMap::new(),
26            background_divisor: background_divisor.max(1),
27        }
28    }
29
30    /// Declare that `screen` is adjacent to the given `neighbors`.
31    pub fn add_adjacency(&mut self, screen: &str, neighbors: &[&str]) {
32        self.adjacency
33            .entry(screen.to_owned())
34            .or_default()
35            .extend(neighbors.iter().map(|s| (*s).to_owned()));
36    }
37
38    /// Build adjacency from a linear tab order.
39    ///
40    /// Each screen becomes adjacent to its immediate left and right neighbors
41    /// (edges only have one neighbor).
42    ///
43    /// ```text
44    /// from_tab_order(["A", "B", "C"], 10)
45    /// // A <-> B, B <-> C
46    /// ```
47    #[must_use]
48    pub fn from_tab_order(screens: &[&str], background_divisor: u64) -> Self {
49        let mut s = Self::new(background_divisor);
50        for window in screens.windows(2) {
51            let (a, b) = (window[0], window[1]);
52            s.adjacency
53                .entry(a.to_owned())
54                .or_default()
55                .push(b.to_owned());
56            s.adjacency
57                .entry(b.to_owned())
58                .or_default()
59                .push(a.to_owned());
60        }
61        s
62    }
63
64    /// Return the background divisor.
65    #[must_use]
66    pub const fn background_divisor(&self) -> u64 {
67        self.background_divisor
68    }
69
70    /// Return a reference to the adjacency map.
71    #[must_use]
72    pub fn adjacency(&self) -> &HashMap<String, Vec<String>> {
73        &self.adjacency
74    }
75}
76
77impl TickStrategy for ActivePlusAdjacent {
78    fn should_tick(
79        &mut self,
80        screen_id: &str,
81        tick_count: u64,
82        active_screen: &str,
83    ) -> TickDecision {
84        // Check adjacency from the active screen
85        if self
86            .adjacency
87            .get(active_screen)
88            .is_some_and(|adj| adj.iter().any(|a| a == screen_id))
89        {
90            return TickDecision::Tick;
91        }
92
93        // Fallback: background divisor
94        if tick_count.is_multiple_of(self.background_divisor) {
95            TickDecision::Tick
96        } else {
97            TickDecision::Skip
98        }
99    }
100
101    fn name(&self) -> &str {
102        "ActivePlusAdjacent"
103    }
104
105    fn debug_stats(&self) -> Vec<(String, String)> {
106        vec![
107            ("strategy".into(), "ActivePlusAdjacent".into()),
108            (
109                "background_divisor".into(),
110                self.background_divisor.to_string(),
111            ),
112            ("adjacency_entries".into(), self.adjacency.len().to_string()),
113        ]
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn adjacent_screens_always_tick() {
123        let mut s = ActivePlusAdjacent::from_tab_order(&["A", "B", "C"], 100);
124        // B is adjacent to A → ticks when A is active
125        assert_eq!(s.should_tick("B", 1, "A"), TickDecision::Tick);
126        assert_eq!(s.should_tick("B", 7, "A"), TickDecision::Tick);
127    }
128
129    #[test]
130    fn non_adjacent_respects_divisor() {
131        let mut s = ActivePlusAdjacent::from_tab_order(&["A", "B", "C"], 5);
132        // C is not adjacent to A
133        assert_eq!(s.should_tick("C", 1, "A"), TickDecision::Skip);
134        assert_eq!(s.should_tick("C", 5, "A"), TickDecision::Tick);
135    }
136
137    #[test]
138    fn from_tab_order_builds_bidirectional() {
139        let s = ActivePlusAdjacent::from_tab_order(&["X", "Y", "Z"], 10);
140        let adj = s.adjacency();
141        assert!(adj["X"].contains(&"Y".to_owned()));
142        assert!(adj["Y"].contains(&"X".to_owned()));
143        assert!(adj["Y"].contains(&"Z".to_owned()));
144        assert!(adj["Z"].contains(&"Y".to_owned()));
145        // X and Z are not adjacent
146        assert!(!adj["X"].contains(&"Z".to_owned()));
147        assert!(!adj["Z"].contains(&"X".to_owned()));
148    }
149
150    #[test]
151    fn edge_screens_have_one_neighbor() {
152        let s = ActivePlusAdjacent::from_tab_order(&["A", "B", "C", "D"], 10);
153        assert_eq!(s.adjacency()["A"].len(), 1); // only B
154        assert_eq!(s.adjacency()["D"].len(), 1); // only C
155        assert_eq!(s.adjacency()["B"].len(), 2); // A and C
156    }
157
158    #[test]
159    fn unknown_screen_uses_divisor() {
160        let mut s = ActivePlusAdjacent::from_tab_order(&["A", "B"], 4);
161        // "unknown" is not in any adjacency list
162        assert_eq!(s.should_tick("unknown", 3, "A"), TickDecision::Skip);
163        assert_eq!(s.should_tick("unknown", 4, "A"), TickDecision::Tick);
164    }
165
166    #[test]
167    fn add_adjacency_appends() {
168        let mut s = ActivePlusAdjacent::new(10);
169        s.add_adjacency("home", &["settings", "profile"]);
170        assert_eq!(s.adjacency()["home"].len(), 2);
171        s.add_adjacency("home", &["help"]);
172        assert_eq!(s.adjacency()["home"].len(), 3);
173    }
174
175    #[test]
176    fn name_is_stable() {
177        let s = ActivePlusAdjacent::new(5);
178        assert_eq!(s.name(), "ActivePlusAdjacent");
179    }
180
181    #[test]
182    fn debug_stats_reports_fields() {
183        let s = ActivePlusAdjacent::from_tab_order(&["A", "B", "C"], 8);
184        let stats = s.debug_stats();
185        assert!(
186            stats
187                .iter()
188                .any(|(k, v)| k == "background_divisor" && v == "8")
189        );
190        assert!(
191            stats
192                .iter()
193                .any(|(k, v)| k == "adjacency_entries" && v == "3")
194        );
195    }
196
197    #[test]
198    fn divisor_0_clamped() {
199        let s = ActivePlusAdjacent::new(0);
200        assert_eq!(s.background_divisor(), 1);
201    }
202
203    #[test]
204    fn empty_tab_order() {
205        let s = ActivePlusAdjacent::from_tab_order(&[], 5);
206        assert!(s.adjacency().is_empty());
207    }
208
209    #[test]
210    fn single_screen_tab_order() {
211        let s = ActivePlusAdjacent::from_tab_order(&["only"], 5);
212        assert!(s.adjacency().is_empty());
213    }
214}