Skip to main content

ratatui_zonekit/
registry.rs

1//! Zone registry — manages zone allocation and plugin ownership.
2//!
3//! The [`ZoneRegistry`] is the central coordinator. It processes
4//! [`ZoneRequest`]s from plugins, allocates [`ZoneId`]s, and tracks
5//! which plugin owns which zone. The host queries the registry
6//! each frame to determine what to render where.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use ratatui::layout::Rect;
12
13use crate::plugin::ZonePlugin;
14use crate::zone::{ZoneHint, ZoneId, ZoneSpec};
15
16/// Registration result — whether a zone request was granted or denied.
17#[derive(Debug)]
18pub enum RegistrationResult {
19    /// Zone was granted with this ID.
20    Granted(ZoneId),
21    /// Zone was denied with a reason.
22    Denied(String),
23}
24
25/// Manages zone allocation and plugin ownership.
26///
27/// Create one per application. Register plugins at startup, then
28/// query each frame for the current zone layout.
29pub struct ZoneRegistry {
30    next_id: u32,
31    zones: Vec<ZoneSpec>,
32    owners: HashMap<ZoneId, Arc<dyn ZonePlugin>>,
33}
34
35impl ZoneRegistry {
36    /// Creates an empty registry.
37    #[must_use]
38    pub fn new() -> Self {
39        Self {
40            next_id: 1,
41            zones: Vec::new(),
42            owners: HashMap::new(),
43        }
44    }
45
46    /// Registers a plugin and processes its zone requests.
47    ///
48    /// Returns a vec of results (one per request, in order).
49    #[allow(clippy::needless_pass_by_value)] // Arc::clone is cheap, by-value is ergonomic
50    pub fn register(&mut self, plugin: Arc<dyn ZonePlugin>) -> Vec<RegistrationResult> {
51        let requests = plugin.zones();
52        let mut results = Vec::with_capacity(requests.len());
53
54        for request in requests {
55            // Check for duplicate names
56            if self.zones.iter().any(|z| z.name == request.name) {
57                results.push(RegistrationResult::Denied(format!(
58                    "zone '{}' already registered",
59                    request.name
60                )));
61                continue;
62            }
63
64            let id = ZoneId::new(self.next_id);
65            self.next_id += 1;
66
67            self.zones.push(ZoneSpec {
68                id,
69                name: request.name.clone(),
70                label: request.label,
71                hint: request.hint,
72                area: Rect::default(),
73                visible: true,
74                order: request.order,
75            });
76
77            self.owners.insert(id, Arc::clone(&plugin));
78            plugin.on_register(id);
79            results.push(RegistrationResult::Granted(id));
80        }
81
82        results
83    }
84
85    /// Returns all zones matching a hint, sorted by order.
86    #[must_use]
87    pub fn zones_by_hint(&self, hint: ZoneHint) -> Vec<&ZoneSpec> {
88        let mut zones: Vec<&ZoneSpec> = self
89            .zones
90            .iter()
91            .filter(|z| z.hint == hint && z.visible)
92            .collect();
93        zones.sort_by_key(|z| z.order);
94        zones
95    }
96
97    /// Returns all tab zones, sorted by order.
98    #[must_use]
99    pub fn tabs(&self) -> Vec<&ZoneSpec> {
100        self.zones_by_hint(ZoneHint::Tab)
101    }
102
103    /// Returns the plugin that owns a zone.
104    #[must_use]
105    pub fn owner(&self, zone_id: ZoneId) -> Option<&Arc<dyn ZonePlugin>> {
106        self.owners.get(&zone_id)
107    }
108
109    /// Returns a zone spec by ID.
110    #[must_use]
111    pub fn zone(&self, zone_id: ZoneId) -> Option<&ZoneSpec> {
112        self.zones.iter().find(|z| z.id == zone_id)
113    }
114
115    /// Returns a zone spec by name.
116    #[must_use]
117    pub fn zone_by_name(&self, name: &str) -> Option<&ZoneSpec> {
118        self.zones.iter().find(|z| z.name == name)
119    }
120
121    /// Updates the area for a zone (called by the host each frame).
122    pub fn update_area(&mut self, zone_id: ZoneId, area: Rect) {
123        if let Some(zone) = self.zones.iter_mut().find(|z| z.id == zone_id) {
124            zone.area = area;
125        }
126    }
127
128    /// Sets visibility for a zone.
129    pub fn set_visible(&mut self, zone_id: ZoneId, visible: bool) {
130        if let Some(zone) = self.zones.iter_mut().find(|z| z.id == zone_id) {
131            zone.visible = visible;
132        }
133    }
134
135    /// Total number of registered zones.
136    #[must_use]
137    pub fn len(&self) -> usize {
138        self.zones.len()
139    }
140
141    /// Whether the registry has no zones.
142    #[must_use]
143    pub fn is_empty(&self) -> bool {
144        self.zones.is_empty()
145    }
146
147    /// All zones (unfiltered).
148    #[must_use]
149    pub fn all_zones(&self) -> &[ZoneSpec] {
150        &self.zones
151    }
152}
153
154impl Default for ZoneRegistry {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160#[cfg(test)]
161#[allow(clippy::unnecessary_literal_bound)]
162mod tests {
163    use ratatui::buffer::Buffer;
164
165    use super::*;
166    use crate::plugin::RenderContext;
167    use crate::zone::ZoneRequest;
168
169    struct FakePlugin {
170        id: &'static str,
171        requests: Vec<ZoneRequest>,
172    }
173
174    impl ZonePlugin for FakePlugin {
175        fn id(&self) -> &str {
176            self.id
177        }
178
179        fn zones(&self) -> Vec<ZoneRequest> {
180            self.requests.clone()
181        }
182
183        fn render(&self, _: ZoneId, _: &RenderContext, _: Rect, _: &mut Buffer) -> bool {
184            true
185        }
186    }
187
188    fn plugin_with_tab(id: &'static str, name: &str, label: &str) -> Arc<dyn ZonePlugin> {
189        Arc::new(FakePlugin {
190            id,
191            requests: vec![ZoneRequest::tab(name, label)],
192        })
193    }
194
195    fn plugin_with_sidebar(id: &'static str, name: &str, label: &str) -> Arc<dyn ZonePlugin> {
196        Arc::new(FakePlugin {
197            id,
198            requests: vec![ZoneRequest::sidebar(name, label)],
199        })
200    }
201
202    #[test]
203    fn empty_registry() {
204        let reg = ZoneRegistry::new();
205        assert!(reg.is_empty());
206        assert_eq!(reg.len(), 0);
207        assert!(reg.tabs().is_empty());
208    }
209
210    #[test]
211    fn register_plugin_grants_zone() {
212        let mut reg = ZoneRegistry::new();
213        let results = reg.register(plugin_with_tab("bmad", "bmad.sprint", "Sprint"));
214        assert_eq!(results.len(), 1);
215        assert!(matches!(results[0], RegistrationResult::Granted(_)));
216        assert_eq!(reg.len(), 1);
217    }
218
219    #[test]
220    fn duplicate_name_is_denied() {
221        let mut reg = ZoneRegistry::new();
222        reg.register(plugin_with_tab("a", "shared.name", "Tab A"));
223        let results = reg.register(plugin_with_tab("b", "shared.name", "Tab B"));
224        assert!(matches!(results[0], RegistrationResult::Denied(_)));
225        assert_eq!(reg.len(), 1);
226    }
227
228    #[test]
229    fn zones_by_hint_filters_correctly() {
230        let mut reg = ZoneRegistry::new();
231        reg.register(plugin_with_tab("a", "a.tab", "Tab A"));
232        reg.register(plugin_with_sidebar("b", "b.side", "Side B"));
233        assert_eq!(reg.zones_by_hint(ZoneHint::Tab).len(), 1);
234        assert_eq!(reg.zones_by_hint(ZoneHint::Sidebar).len(), 1);
235        assert_eq!(reg.zones_by_hint(ZoneHint::Overlay).len(), 0);
236    }
237
238    #[test]
239    fn tabs_returns_tab_zones_sorted() {
240        let mut reg = ZoneRegistry::new();
241        reg.register(Arc::new(FakePlugin {
242            id: "b",
243            requests: vec![ZoneRequest::tab("b.tab", "B").with_order(20)],
244        }));
245        reg.register(Arc::new(FakePlugin {
246            id: "a",
247            requests: vec![ZoneRequest::tab("a.tab", "A").with_order(10)],
248        }));
249        let tabs = reg.tabs();
250        assert_eq!(tabs[0].label, "A");
251        assert_eq!(tabs[1].label, "B");
252    }
253
254    #[test]
255    fn owner_returns_plugin() {
256        let mut reg = ZoneRegistry::new();
257        let plugin = plugin_with_tab("test", "test.tab", "Test");
258        let results = reg.register(plugin);
259        if let RegistrationResult::Granted(id) = &results[0] {
260            let owner = reg.owner(*id).unwrap();
261            assert_eq!(owner.id(), "test");
262        }
263    }
264
265    #[test]
266    fn zone_by_name() {
267        let mut reg = ZoneRegistry::new();
268        reg.register(plugin_with_tab("x", "x.tab", "X"));
269        assert!(reg.zone_by_name("x.tab").is_some());
270        assert!(reg.zone_by_name("nonexistent").is_none());
271    }
272
273    #[test]
274    fn update_area() {
275        let mut reg = ZoneRegistry::new();
276        let results = reg.register(plugin_with_tab("x", "x.tab", "X"));
277        if let RegistrationResult::Granted(id) = &results[0] {
278            let new_area = Rect::new(10, 20, 80, 40);
279            reg.update_area(*id, new_area);
280            assert_eq!(reg.zone(*id).unwrap().area, new_area);
281        }
282    }
283
284    #[test]
285    fn set_visible_hides_zone() {
286        let mut reg = ZoneRegistry::new();
287        let results = reg.register(plugin_with_tab("x", "x.tab", "X"));
288        if let RegistrationResult::Granted(id) = &results[0] {
289            reg.set_visible(*id, false);
290            assert!(reg.tabs().is_empty(), "hidden tab should not appear");
291        }
292    }
293
294    #[test]
295    fn multiple_plugins_get_unique_ids() {
296        let mut reg = ZoneRegistry::new();
297        let r1 = reg.register(plugin_with_tab("a", "a.tab", "A"));
298        let r2 = reg.register(plugin_with_tab("b", "b.tab", "B"));
299        if let (RegistrationResult::Granted(id1), RegistrationResult::Granted(id2)) =
300            (&r1[0], &r2[0])
301        {
302            assert_ne!(id1, id2);
303        }
304    }
305}