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    #[must_use = "use the returned help content (if any)"]
207    pub fn get(&mut self, id: HelpId) -> Option<&HelpContent> {
208        // Force lazy → loaded if needed.
209        if matches!(self.entries.get(&id), Some(Entry::Lazy(_)))
210            && let Some(Entry::Lazy(provider)) = self.entries.remove(&id)
211        {
212            let content = provider();
213            self.entries.insert(id, Entry::Loaded(content));
214        }
215        match self.entries.get(&id) {
216            Some(Entry::Loaded(c)) => Some(c),
217            _ => None,
218        }
219    }
220
221    /// Peek at help content without forcing lazy providers.
222    #[must_use = "use the returned help content (if any)"]
223    pub fn peek(&self, id: HelpId) -> Option<&HelpContent> {
224        match self.entries.get(&id) {
225            Some(Entry::Loaded(c)) => Some(c),
226            _ => None,
227        }
228    }
229
230    /// Resolve help content by walking the parent hierarchy.
231    ///
232    /// Returns the first content found starting from `id` and walking up
233    /// through parents. Returns `None` if no content exists in the chain.
234    #[must_use = "use the returned help content (if any)"]
235    pub fn resolve(&mut self, id: HelpId) -> Option<&HelpContent> {
236        // Collect the chain of IDs to check (avoid borrow issues).
237        let chain = self.ancestor_chain(id);
238        // Force any lazy entries in the chain.
239        for &cid in &chain {
240            if matches!(self.entries.get(&cid), Some(Entry::Lazy(_)))
241                && let Some(Entry::Lazy(provider)) = self.entries.remove(&cid)
242            {
243                let content = provider();
244                self.entries.insert(cid, Entry::Loaded(content));
245            }
246        }
247        // Now find the first loaded entry.
248        for &cid in &chain {
249            if let Some(Entry::Loaded(c)) = self.entries.get(&cid) {
250                return Some(c);
251            }
252        }
253        None
254    }
255
256    /// Check whether any help content is registered for this ID (including lazy).
257    #[must_use]
258    pub fn contains(&self, id: HelpId) -> bool {
259        self.entries.contains_key(&id)
260    }
261
262    /// Number of registered entries (loaded + lazy).
263    #[inline]
264    #[must_use]
265    pub fn len(&self) -> usize {
266        self.entries.len()
267    }
268
269    /// Whether the registry is empty.
270    #[inline]
271    #[must_use]
272    pub fn is_empty(&self) -> bool {
273        self.entries.is_empty()
274    }
275
276    /// Iterate over all registered IDs.
277    pub fn ids(&self) -> impl Iterator<Item = HelpId> + '_ {
278        self.entries.keys().copied()
279    }
280
281    /// Build the ancestor chain starting from `id` (inclusive).
282    fn ancestor_chain(&self, id: HelpId) -> Vec<HelpId> {
283        let mut chain = vec![id];
284        let mut cursor = id;
285        while let Some(&parent) = self.parents.get(&cursor) {
286            chain.push(parent);
287            cursor = parent;
288        }
289        chain
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn sample_content(short: &str) -> HelpContent {
298        HelpContent {
299            short: short.into(),
300            long: None,
301            keybindings: Vec::new(),
302            see_also: Vec::new(),
303        }
304    }
305
306    // ── Registration and lookup ────────────────────────────────────
307
308    #[test]
309    fn register_and_get() {
310        let mut reg = HelpRegistry::new();
311        let id = HelpId(1);
312        reg.register(id, sample_content("tooltip"));
313        assert_eq!(reg.get(id).unwrap().short, "tooltip");
314    }
315
316    #[test]
317    fn missing_key_returns_none() {
318        let mut reg = HelpRegistry::new();
319        assert!(reg.get(HelpId(999)).is_none());
320    }
321
322    #[test]
323    fn register_overwrites() {
324        let mut reg = HelpRegistry::new();
325        let id = HelpId(1);
326        reg.register(id, sample_content("old"));
327        reg.register(id, sample_content("new"));
328        assert_eq!(reg.get(id).unwrap().short, "new");
329    }
330
331    #[test]
332    fn unregister() {
333        let mut reg = HelpRegistry::new();
334        let id = HelpId(1);
335        reg.register(id, sample_content("x"));
336        assert!(reg.unregister(id));
337        assert!(reg.get(id).is_none());
338        assert!(!reg.unregister(id));
339    }
340
341    #[test]
342    fn len_and_is_empty() {
343        let mut reg = HelpRegistry::new();
344        assert!(reg.is_empty());
345        assert_eq!(reg.len(), 0);
346        reg.register(HelpId(1), sample_content("a"));
347        reg.register(HelpId(2), sample_content("b"));
348        assert_eq!(reg.len(), 2);
349        assert!(!reg.is_empty());
350    }
351
352    #[test]
353    fn contains() {
354        let mut reg = HelpRegistry::new();
355        let id = HelpId(1);
356        assert!(!reg.contains(id));
357        reg.register(id, sample_content("x"));
358        assert!(reg.contains(id));
359    }
360
361    #[test]
362    fn ids_iteration() {
363        let mut reg = HelpRegistry::new();
364        reg.register(HelpId(10), sample_content("a"));
365        reg.register(HelpId(20), sample_content("b"));
366        let mut ids: Vec<_> = reg.ids().collect();
367        ids.sort_by_key(|h| h.0);
368        assert_eq!(ids, vec![HelpId(10), HelpId(20)]);
369    }
370
371    // ── Lazy providers ─────────────────────────────────────────────
372
373    #[test]
374    fn lazy_provider_called_on_get() {
375        let mut reg = HelpRegistry::new();
376        let id = HelpId(1);
377        reg.register_lazy(id, || sample_content("lazy"));
378        assert!(reg.peek(id).is_none()); // not yet forced
379        assert_eq!(reg.get(id).unwrap().short, "lazy");
380        assert!(reg.peek(id).is_some()); // now cached
381    }
382
383    #[test]
384    fn lazy_provider_overwritten_by_register() {
385        let mut reg = HelpRegistry::new();
386        let id = HelpId(1);
387        reg.register_lazy(id, || sample_content("lazy"));
388        reg.register(id, sample_content("eager"));
389        assert_eq!(reg.get(id).unwrap().short, "eager");
390    }
391
392    #[test]
393    fn register_overwrites_lazy() {
394        let mut reg = HelpRegistry::new();
395        let id = HelpId(1);
396        reg.register_lazy(id, || sample_content("first"));
397        reg.register_lazy(id, || sample_content("second"));
398        assert_eq!(reg.get(id).unwrap().short, "second");
399    }
400
401    // ── Hierarchy ──────────────────────────────────────────────────
402
403    #[test]
404    fn resolve_walks_parents() {
405        let mut reg = HelpRegistry::new();
406        let child = HelpId(1);
407        let parent = HelpId(2);
408        let grandparent = HelpId(3);
409
410        reg.register(grandparent, sample_content("app help"));
411        reg.set_parent(child, parent);
412        reg.set_parent(parent, grandparent);
413
414        // child has no content; resolve walks to grandparent
415        assert_eq!(reg.resolve(child).unwrap().short, "app help");
416    }
417
418    #[test]
419    fn resolve_prefers_nearest() {
420        let mut reg = HelpRegistry::new();
421        let child = HelpId(1);
422        let parent = HelpId(2);
423        let grandparent = HelpId(3);
424
425        reg.register(parent, sample_content("container help"));
426        reg.register(grandparent, sample_content("app help"));
427        reg.set_parent(child, parent);
428        reg.set_parent(parent, grandparent);
429
430        // child has no content; resolve finds parent first
431        assert_eq!(reg.resolve(child).unwrap().short, "container help");
432    }
433
434    #[test]
435    fn resolve_returns_own_content_first() {
436        let mut reg = HelpRegistry::new();
437        let child = HelpId(1);
438        let parent = HelpId(2);
439
440        reg.register(child, sample_content("widget help"));
441        reg.register(parent, sample_content("container help"));
442        reg.set_parent(child, parent);
443
444        assert_eq!(reg.resolve(child).unwrap().short, "widget help");
445    }
446
447    #[test]
448    fn resolve_no_content_returns_none() {
449        let mut reg = HelpRegistry::new();
450        let child = HelpId(1);
451        let parent = HelpId(2);
452        reg.set_parent(child, parent);
453        assert!(reg.resolve(child).is_none());
454    }
455
456    #[test]
457    fn set_parent_rejects_self_cycle() {
458        let mut reg = HelpRegistry::new();
459        let id = HelpId(1);
460        assert!(!reg.set_parent(id, id));
461    }
462
463    #[test]
464    fn set_parent_rejects_indirect_cycle() {
465        let mut reg = HelpRegistry::new();
466        let a = HelpId(1);
467        let b = HelpId(2);
468        let c = HelpId(3);
469
470        assert!(reg.set_parent(a, b));
471        assert!(reg.set_parent(b, c));
472        // c → a would create cycle c→a→b→c
473        assert!(!reg.set_parent(c, a));
474    }
475
476    #[test]
477    fn clear_parent() {
478        let mut reg = HelpRegistry::new();
479        let child = HelpId(1);
480        let parent = HelpId(2);
481
482        reg.register(parent, sample_content("parent"));
483        reg.set_parent(child, parent);
484        assert!(reg.resolve(child).is_some());
485
486        reg.clear_parent(child);
487        assert!(reg.resolve(child).is_none());
488    }
489
490    // ── Keybindings and structured content ─────────────────────────
491
492    #[test]
493    fn keybindings_stored() {
494        let mut reg = HelpRegistry::new();
495        let id = HelpId(1);
496        reg.register(
497            id,
498            HelpContent {
499                short: "Editor".into(),
500                long: Some("Main text editor".into()),
501                keybindings: vec![
502                    Keybinding::new("Ctrl+S", "Save"),
503                    Keybinding::new("Ctrl+Q", "Quit"),
504                ],
505                see_also: vec![HelpId(2)],
506            },
507        );
508        let content = reg.get(id).unwrap();
509        assert_eq!(content.keybindings.len(), 2);
510        assert_eq!(content.keybindings[0].key, "Ctrl+S");
511        assert_eq!(content.keybindings[0].action, "Save");
512        assert_eq!(content.see_also, vec![HelpId(2)]);
513    }
514
515    #[test]
516    fn help_content_short_constructor() {
517        let c = HelpContent::short("tooltip");
518        assert_eq!(c.short, "tooltip");
519        assert!(c.long.is_none());
520        assert!(c.keybindings.is_empty());
521        assert!(c.see_also.is_empty());
522    }
523
524    #[test]
525    fn help_id_display() {
526        assert_eq!(HelpId(42).to_string(), "help:42");
527    }
528
529    // ── Lazy in hierarchy ──────────────────────────────────────────
530
531    #[test]
532    fn resolve_forces_lazy_in_parent() {
533        let mut reg = HelpRegistry::new();
534        let child = HelpId(1);
535        let parent = HelpId(2);
536
537        reg.register_lazy(parent, || sample_content("lazy parent"));
538        reg.set_parent(child, parent);
539
540        assert_eq!(reg.resolve(child).unwrap().short, "lazy parent");
541        // Now cached
542        assert!(reg.peek(parent).is_some());
543    }
544
545    // ── Edge cases ─────────────────────────────────────────────────
546
547    #[test]
548    fn empty_registry_resolve() {
549        let mut reg = HelpRegistry::new();
550        assert!(reg.resolve(HelpId(1)).is_none());
551    }
552
553    #[test]
554    fn deep_hierarchy() {
555        let mut reg = HelpRegistry::new();
556        // Chain: 0 → 1 → 2 → 3 → 4 (content at 4)
557        for i in 0..4u64 {
558            assert!(reg.set_parent(HelpId(i), HelpId(i + 1)));
559        }
560        reg.register(HelpId(4), sample_content("root"));
561        assert_eq!(reg.resolve(HelpId(0)).unwrap().short, "root");
562    }
563
564    #[test]
565    fn set_parent_allows_reparenting() {
566        let mut reg = HelpRegistry::new();
567        let child = HelpId(1);
568        let p1 = HelpId(2);
569        let p2 = HelpId(3);
570
571        reg.register(p1, sample_content("first parent"));
572        reg.register(p2, sample_content("second parent"));
573
574        reg.set_parent(child, p1);
575        assert_eq!(reg.resolve(child).unwrap().short, "first parent");
576
577        // Reparent
578        reg.set_parent(child, p2);
579        assert_eq!(reg.resolve(child).unwrap().short, "second parent");
580    }
581
582    #[test]
583    fn unregister_does_not_remove_parent_link() {
584        let mut reg = HelpRegistry::new();
585        let child = HelpId(1);
586        let parent = HelpId(2);
587        let grandparent = HelpId(3);
588
589        reg.register(parent, sample_content("parent"));
590        reg.register(grandparent, sample_content("grandparent"));
591        reg.set_parent(child, parent);
592        reg.set_parent(parent, grandparent);
593
594        // Remove parent content; resolve should walk to grandparent
595        reg.unregister(parent);
596        assert_eq!(reg.resolve(child).unwrap().short, "grandparent");
597    }
598}