1use 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#[derive(Debug)]
18pub enum RegistrationResult {
19 Granted(ZoneId),
21 Denied(String),
23}
24
25pub struct ZoneRegistry {
30 next_id: u32,
31 zones: Vec<ZoneSpec>,
32 owners: HashMap<ZoneId, Arc<dyn ZonePlugin>>,
33}
34
35impl ZoneRegistry {
36 #[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 #[allow(clippy::needless_pass_by_value)] 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 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 #[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 #[must_use]
99 pub fn tabs(&self) -> Vec<&ZoneSpec> {
100 self.zones_by_hint(ZoneHint::Tab)
101 }
102
103 #[must_use]
105 pub fn owner(&self, zone_id: ZoneId) -> Option<&Arc<dyn ZonePlugin>> {
106 self.owners.get(&zone_id)
107 }
108
109 #[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 #[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 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 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 #[must_use]
137 pub fn len(&self) -> usize {
138 self.zones.len()
139 }
140
141 #[must_use]
143 pub fn is_empty(&self) -> bool {
144 self.zones.is_empty()
145 }
146
147 #[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}