Skip to main content

ftui_widgets/
help_registry.rs

1#![forbid(unsafe_code)]
2
3//! Central registry for contextual help content.
4//!
5//! Maps widget IDs to structured help entries with hierarchical resolution
6//! (widget → container → app). Widgets register their help content via
7//! [`HelpRegistry::register`], and consumers look up help for a given widget
8//! via [`HelpRegistry::get`] or [`HelpRegistry::resolve`] (which walks the
9//! hierarchy).
10//!
11//! # Invariants
12//!
13//! 1. Each `HelpId` maps to at most one [`HelpContent`] at any given time.
14//! 2. [`resolve`](HelpRegistry::resolve) walks parents until it finds content
15//!    or reaches the root; it never cycles (parent chains are acyclic by
16//!    construction—[`set_parent`](HelpRegistry::set_parent) rejects cycles).
17//! 3. Lazy providers are called at most once per lookup; results are cached
18//!    in the registry for subsequent lookups.
19//!
20//! # Example
21//!
22//! ```
23//! use ftui_widgets::help_registry::{HelpRegistry, HelpContent, HelpId, Keybinding};
24//!
25//! let mut reg = HelpRegistry::new();
26//! let widget_id = HelpId(42);
27//!
28//! reg.register(widget_id, HelpContent {
29//!     short: "Save the current file".into(),
30//!     long: Some("Writes the buffer to disk, creating the file if needed.".into()),
31//!     keybindings: vec![Keybinding::new("Ctrl+S", "Save")],
32//!     see_also: vec![],
33//! });
34//!
35//! assert_eq!(reg.get(widget_id).unwrap().short, "Save the current file");
36//! ```
37
38use std::collections::HashMap;
39
40/// Unique identifier for a help-registered widget.
41///
42/// Typically corresponds to a [`FocusId`](crate::focus::FocusId) but is its
43/// own type so the help system can be used independently of focus management.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub struct HelpId(pub u64);
46
47impl core::fmt::Display for HelpId {
48    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
49        write!(f, "help:{}", self.0)
50    }
51}
52
53/// A keyboard shortcut associated with a widget.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Keybinding {
56    /// Human-readable key combo (e.g. "Ctrl+S", "↑/k").
57    pub key: String,
58    /// What the binding does.
59    pub action: String,
60}
61
62impl Keybinding {
63    /// Create a new keybinding.
64    #[must_use]
65    pub fn new(key: impl Into<String>, action: impl Into<String>) -> Self {
66        Self {
67            key: key.into(),
68            action: action.into(),
69        }
70    }
71}
72
73/// Structured help content for a widget.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct HelpContent {
76    /// Short tooltip-length description (one line).
77    pub short: String,
78    /// Optional longer description shown in a detail panel.
79    pub long: Option<String>,
80    /// Keybindings available when this widget is focused.
81    pub keybindings: Vec<Keybinding>,
82    /// Related widget help IDs for "see also" links.
83    pub see_also: Vec<HelpId>,
84}
85
86impl HelpContent {
87    /// Create minimal help content with just a short description.
88    #[must_use]
89    pub fn short(desc: impl Into<String>) -> Self {
90        Self {
91            short: desc.into(),
92            long: None,
93            keybindings: Vec::new(),
94            see_also: Vec::new(),
95        }
96    }
97}
98
99/// A lazy provider that produces [`HelpContent`] on demand.
100///
101/// Providers are called at most once; the result is cached.
102type LazyProvider = Box<dyn FnOnce() -> HelpContent + Send>;
103
104/// Internal entry: either already-loaded content or a lazy provider.
105enum Entry {
106    Loaded(HelpContent),
107    Lazy(LazyProvider),
108}
109
110impl core::fmt::Debug for Entry {
111    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
112        match self {
113            Self::Loaded(c) => f.debug_tuple("Loaded").field(c).finish(),
114            Self::Lazy(_) => f.debug_tuple("Lazy").field(&"<fn>").finish(),
115        }
116    }
117}
118
119/// Central registry mapping widget IDs to help content.
120///
121/// Supports:
122/// - Direct registration via [`register`](Self::register)
123/// - Lazy/deferred registration via [`register_lazy`](Self::register_lazy)
124/// - Hierarchical parent chains via [`set_parent`](Self::set_parent)
125/// - Resolution that walks parent chain via [`resolve`](Self::resolve)
126#[derive(Debug)]
127pub struct HelpRegistry {
128    entries: HashMap<HelpId, Entry>,
129    /// Parent mapping: child → parent. Used by `resolve` to walk up the
130    /// widget tree when a widget has no help of its own.
131    parents: HashMap<HelpId, HelpId>,
132}
133
134impl Default for HelpRegistry {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl HelpRegistry {
141    /// Create an empty registry.
142    #[must_use]
143    pub fn new() -> Self {
144        Self {
145            entries: HashMap::new(),
146            parents: HashMap::new(),
147        }
148    }
149
150    /// Register help content for a widget.
151    ///
152    /// Overwrites any previous content (or lazy provider) for the same ID.
153    pub fn register(&mut self, id: HelpId, content: HelpContent) {
154        self.entries.insert(id, Entry::Loaded(content));
155    }
156
157    /// Register a lazy provider that will be called on first lookup.
158    ///
159    /// The provider is invoked at most once; its result is cached.
160    pub fn register_lazy(
161        &mut self,
162        id: HelpId,
163        provider: impl FnOnce() -> HelpContent + Send + 'static,
164    ) {
165        self.entries.insert(id, Entry::Lazy(Box::new(provider)));
166    }
167
168    /// Remove help content for a widget.
169    ///
170    /// Returns `true` if content was present.
171    pub fn unregister(&mut self, id: HelpId) -> bool {
172        self.entries.remove(&id).is_some()
173    }
174
175    /// Set the parent of a widget in the help hierarchy.
176    ///
177    /// When [`resolve`](Self::resolve) is called for `child` and no content
178    /// is found, the lookup walks to `parent` (and its ancestors).
179    ///
180    /// Returns `false` (and does nothing) if setting this parent would create
181    /// a cycle.
182    pub fn set_parent(&mut self, child: HelpId, parent: HelpId) -> bool {
183        // Cycle check: walk from parent upward; if we reach child, it's a cycle.
184        if child == parent {
185            return false;
186        }
187        let mut cursor = parent;
188        while let Some(&ancestor) = self.parents.get(&cursor) {
189            if ancestor == child {
190                return false;
191            }
192            cursor = ancestor;
193        }
194        self.parents.insert(child, parent);
195        true
196    }
197
198    /// Remove the parent link for a widget.
199    pub fn clear_parent(&mut self, child: HelpId) {
200        self.parents.remove(&child);
201    }
202
203    /// Get help content for a specific widget (no hierarchy walk).
204    ///
205    /// Forces lazy providers if present.
206    pub fn get(&mut self, id: HelpId) -> Option<&HelpContent> {
207        // Force lazy → loaded if needed.
208        if matches!(self.entries.get(&id), Some(Entry::Lazy(_)))
209            && let Some(Entry::Lazy(provider)) = self.entries.remove(&id)
210        {
211            let content = provider();
212            self.entries.insert(id, Entry::Loaded(content));
213        }
214        match self.entries.get(&id) {
215            Some(Entry::Loaded(c)) => Some(c),
216            _ => None,
217        }
218    }
219
220    /// Peek at help content without forcing lazy providers.
221    #[must_use]
222    pub fn peek(&self, id: HelpId) -> Option<&HelpContent> {
223        match self.entries.get(&id) {
224            Some(Entry::Loaded(c)) => Some(c),
225            _ => None,
226        }
227    }
228
229    /// Resolve help content by walking the parent hierarchy.
230    ///
231    /// Returns the first content found starting from `id` and walking up
232    /// through parents. Returns `None` if no content exists in the chain.
233    pub fn resolve(&mut self, id: HelpId) -> Option<&HelpContent> {
234        // Collect the chain of IDs to check (avoid borrow issues).
235        let chain = self.ancestor_chain(id);
236        // Force any lazy entries in the chain.
237        for &cid in &chain {
238            if matches!(self.entries.get(&cid), Some(Entry::Lazy(_)))
239                && let Some(Entry::Lazy(provider)) = self.entries.remove(&cid)
240            {
241                let content = provider();
242                self.entries.insert(cid, Entry::Loaded(content));
243            }
244        }
245        // Now find the first loaded entry.
246        for &cid in &chain {
247            if let Some(Entry::Loaded(c)) = self.entries.get(&cid) {
248                return Some(c);
249            }
250        }
251        None
252    }
253
254    /// Check whether any help content is registered for this ID (including lazy).
255    #[must_use]
256    pub fn contains(&self, id: HelpId) -> bool {
257        self.entries.contains_key(&id)
258    }
259
260    /// Number of registered entries (loaded + lazy).
261    #[must_use]
262    pub fn len(&self) -> usize {
263        self.entries.len()
264    }
265
266    /// Whether the registry is empty.
267    #[must_use]
268    pub fn is_empty(&self) -> bool {
269        self.entries.is_empty()
270    }
271
272    /// Iterate over all registered IDs.
273    pub fn ids(&self) -> impl Iterator<Item = HelpId> + '_ {
274        self.entries.keys().copied()
275    }
276
277    /// Build the ancestor chain starting from `id` (inclusive).
278    fn ancestor_chain(&self, id: HelpId) -> Vec<HelpId> {
279        let mut chain = vec![id];
280        let mut cursor = id;
281        while let Some(&parent) = self.parents.get(&cursor) {
282            chain.push(parent);
283            cursor = parent;
284        }
285        chain
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn sample_content(short: &str) -> HelpContent {
294        HelpContent {
295            short: short.into(),
296            long: None,
297            keybindings: Vec::new(),
298            see_also: Vec::new(),
299        }
300    }
301
302    // ── Registration and lookup ────────────────────────────────────
303
304    #[test]
305    fn register_and_get() {
306        let mut reg = HelpRegistry::new();
307        let id = HelpId(1);
308        reg.register(id, sample_content("tooltip"));
309        assert_eq!(reg.get(id).unwrap().short, "tooltip");
310    }
311
312    #[test]
313    fn missing_key_returns_none() {
314        let mut reg = HelpRegistry::new();
315        assert!(reg.get(HelpId(999)).is_none());
316    }
317
318    #[test]
319    fn register_overwrites() {
320        let mut reg = HelpRegistry::new();
321        let id = HelpId(1);
322        reg.register(id, sample_content("old"));
323        reg.register(id, sample_content("new"));
324        assert_eq!(reg.get(id).unwrap().short, "new");
325    }
326
327    #[test]
328    fn unregister() {
329        let mut reg = HelpRegistry::new();
330        let id = HelpId(1);
331        reg.register(id, sample_content("x"));
332        assert!(reg.unregister(id));
333        assert!(reg.get(id).is_none());
334        assert!(!reg.unregister(id));
335    }
336
337    #[test]
338    fn len_and_is_empty() {
339        let mut reg = HelpRegistry::new();
340        assert!(reg.is_empty());
341        assert_eq!(reg.len(), 0);
342        reg.register(HelpId(1), sample_content("a"));
343        reg.register(HelpId(2), sample_content("b"));
344        assert_eq!(reg.len(), 2);
345        assert!(!reg.is_empty());
346    }
347
348    #[test]
349    fn contains() {
350        let mut reg = HelpRegistry::new();
351        let id = HelpId(1);
352        assert!(!reg.contains(id));
353        reg.register(id, sample_content("x"));
354        assert!(reg.contains(id));
355    }
356
357    #[test]
358    fn ids_iteration() {
359        let mut reg = HelpRegistry::new();
360        reg.register(HelpId(10), sample_content("a"));
361        reg.register(HelpId(20), sample_content("b"));
362        let mut ids: Vec<_> = reg.ids().collect();
363        ids.sort_by_key(|h| h.0);
364        assert_eq!(ids, vec![HelpId(10), HelpId(20)]);
365    }
366
367    // ── Lazy providers ─────────────────────────────────────────────
368
369    #[test]
370    fn lazy_provider_called_on_get() {
371        let mut reg = HelpRegistry::new();
372        let id = HelpId(1);
373        reg.register_lazy(id, || sample_content("lazy"));
374        assert!(reg.peek(id).is_none()); // not yet forced
375        assert_eq!(reg.get(id).unwrap().short, "lazy");
376        assert!(reg.peek(id).is_some()); // now cached
377    }
378
379    #[test]
380    fn lazy_provider_overwritten_by_register() {
381        let mut reg = HelpRegistry::new();
382        let id = HelpId(1);
383        reg.register_lazy(id, || sample_content("lazy"));
384        reg.register(id, sample_content("eager"));
385        assert_eq!(reg.get(id).unwrap().short, "eager");
386    }
387
388    #[test]
389    fn register_overwrites_lazy() {
390        let mut reg = HelpRegistry::new();
391        let id = HelpId(1);
392        reg.register_lazy(id, || sample_content("first"));
393        reg.register_lazy(id, || sample_content("second"));
394        assert_eq!(reg.get(id).unwrap().short, "second");
395    }
396
397    // ── Hierarchy ──────────────────────────────────────────────────
398
399    #[test]
400    fn resolve_walks_parents() {
401        let mut reg = HelpRegistry::new();
402        let child = HelpId(1);
403        let parent = HelpId(2);
404        let grandparent = HelpId(3);
405
406        reg.register(grandparent, sample_content("app help"));
407        reg.set_parent(child, parent);
408        reg.set_parent(parent, grandparent);
409
410        // child has no content; resolve walks to grandparent
411        assert_eq!(reg.resolve(child).unwrap().short, "app help");
412    }
413
414    #[test]
415    fn resolve_prefers_nearest() {
416        let mut reg = HelpRegistry::new();
417        let child = HelpId(1);
418        let parent = HelpId(2);
419        let grandparent = HelpId(3);
420
421        reg.register(parent, sample_content("container help"));
422        reg.register(grandparent, sample_content("app help"));
423        reg.set_parent(child, parent);
424        reg.set_parent(parent, grandparent);
425
426        // child has no content; resolve finds parent first
427        assert_eq!(reg.resolve(child).unwrap().short, "container help");
428    }
429
430    #[test]
431    fn resolve_returns_own_content_first() {
432        let mut reg = HelpRegistry::new();
433        let child = HelpId(1);
434        let parent = HelpId(2);
435
436        reg.register(child, sample_content("widget help"));
437        reg.register(parent, sample_content("container help"));
438        reg.set_parent(child, parent);
439
440        assert_eq!(reg.resolve(child).unwrap().short, "widget help");
441    }
442
443    #[test]
444    fn resolve_no_content_returns_none() {
445        let mut reg = HelpRegistry::new();
446        let child = HelpId(1);
447        let parent = HelpId(2);
448        reg.set_parent(child, parent);
449        assert!(reg.resolve(child).is_none());
450    }
451
452    #[test]
453    fn set_parent_rejects_self_cycle() {
454        let mut reg = HelpRegistry::new();
455        let id = HelpId(1);
456        assert!(!reg.set_parent(id, id));
457    }
458
459    #[test]
460    fn set_parent_rejects_indirect_cycle() {
461        let mut reg = HelpRegistry::new();
462        let a = HelpId(1);
463        let b = HelpId(2);
464        let c = HelpId(3);
465
466        assert!(reg.set_parent(a, b));
467        assert!(reg.set_parent(b, c));
468        // c → a would create cycle c→a→b→c
469        assert!(!reg.set_parent(c, a));
470    }
471
472    #[test]
473    fn clear_parent() {
474        let mut reg = HelpRegistry::new();
475        let child = HelpId(1);
476        let parent = HelpId(2);
477
478        reg.register(parent, sample_content("parent"));
479        reg.set_parent(child, parent);
480        assert!(reg.resolve(child).is_some());
481
482        reg.clear_parent(child);
483        assert!(reg.resolve(child).is_none());
484    }
485
486    // ── Keybindings and structured content ─────────────────────────
487
488    #[test]
489    fn keybindings_stored() {
490        let mut reg = HelpRegistry::new();
491        let id = HelpId(1);
492        reg.register(
493            id,
494            HelpContent {
495                short: "Editor".into(),
496                long: Some("Main text editor".into()),
497                keybindings: vec![
498                    Keybinding::new("Ctrl+S", "Save"),
499                    Keybinding::new("Ctrl+Q", "Quit"),
500                ],
501                see_also: vec![HelpId(2)],
502            },
503        );
504        let content = reg.get(id).unwrap();
505        assert_eq!(content.keybindings.len(), 2);
506        assert_eq!(content.keybindings[0].key, "Ctrl+S");
507        assert_eq!(content.keybindings[0].action, "Save");
508        assert_eq!(content.see_also, vec![HelpId(2)]);
509    }
510
511    #[test]
512    fn help_content_short_constructor() {
513        let c = HelpContent::short("tooltip");
514        assert_eq!(c.short, "tooltip");
515        assert!(c.long.is_none());
516        assert!(c.keybindings.is_empty());
517        assert!(c.see_also.is_empty());
518    }
519
520    #[test]
521    fn help_id_display() {
522        assert_eq!(HelpId(42).to_string(), "help:42");
523    }
524
525    // ── Lazy in hierarchy ──────────────────────────────────────────
526
527    #[test]
528    fn resolve_forces_lazy_in_parent() {
529        let mut reg = HelpRegistry::new();
530        let child = HelpId(1);
531        let parent = HelpId(2);
532
533        reg.register_lazy(parent, || sample_content("lazy parent"));
534        reg.set_parent(child, parent);
535
536        assert_eq!(reg.resolve(child).unwrap().short, "lazy parent");
537        // Now cached
538        assert!(reg.peek(parent).is_some());
539    }
540
541    // ── Edge cases ─────────────────────────────────────────────────
542
543    #[test]
544    fn empty_registry_resolve() {
545        let mut reg = HelpRegistry::new();
546        assert!(reg.resolve(HelpId(1)).is_none());
547    }
548
549    #[test]
550    fn deep_hierarchy() {
551        let mut reg = HelpRegistry::new();
552        // Chain: 0 → 1 → 2 → 3 → 4 (content at 4)
553        for i in 0..4u64 {
554            assert!(reg.set_parent(HelpId(i), HelpId(i + 1)));
555        }
556        reg.register(HelpId(4), sample_content("root"));
557        assert_eq!(reg.resolve(HelpId(0)).unwrap().short, "root");
558    }
559
560    #[test]
561    fn set_parent_allows_reparenting() {
562        let mut reg = HelpRegistry::new();
563        let child = HelpId(1);
564        let p1 = HelpId(2);
565        let p2 = HelpId(3);
566
567        reg.register(p1, sample_content("first parent"));
568        reg.register(p2, sample_content("second parent"));
569
570        reg.set_parent(child, p1);
571        assert_eq!(reg.resolve(child).unwrap().short, "first parent");
572
573        // Reparent
574        reg.set_parent(child, p2);
575        assert_eq!(reg.resolve(child).unwrap().short, "second parent");
576    }
577
578    #[test]
579    fn unregister_does_not_remove_parent_link() {
580        let mut reg = HelpRegistry::new();
581        let child = HelpId(1);
582        let parent = HelpId(2);
583        let grandparent = HelpId(3);
584
585        reg.register(parent, sample_content("parent"));
586        reg.register(grandparent, sample_content("grandparent"));
587        reg.set_parent(child, parent);
588        reg.set_parent(parent, grandparent);
589
590        // Remove parent content; resolve should walk to grandparent
591        reg.unregister(parent);
592        assert_eq!(reg.resolve(child).unwrap().short, "grandparent");
593    }
594}