Skip to main content

teksilo_widgets/tooltip/
registry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tooltip content registry.
5//!
6//! A central map from **tooltip keys** (short identifiers) to
7//! [`TooltipContent`] — the translatable strings + optional shortcut
8//! metadata that rich-tooltip widgets resolve at hover time.
9//!
10//! Two entry points:
11//! - [`TooltipContent::new`] (plus `.with_more` / `.with_shortcut_label`
12//!   / `.for_shortcut`) builds an entry at app boot.
13//! - [`install_tooltip_registry`] freezes a `Vec<TooltipContent>` into a
14//!   thread-local registry that the tooltip widget reads from.
15//!
16//! The registry is populated once by
17//! `TeksiloAppBuilder::register_tooltips(...)` before the first frame
18//! builds and is read-only for the rest of the process lifetime.
19//!
20//! # URL scheme
21//!
22//! Inline links inside tooltip body text address other tooltip entries
23//! via the `:key` URL prefix. A link written as `[2 minutes](:autosave-details)`
24//! in a translated string becomes a hover-trigger for the tooltip
25//! registered under the `"autosave-details"` key. Use
26//! [`TooltipRegistry::parse_url`] to recognize `:key` URLs; every other
27//! URL scheme (`http://`, `mailto:`, …) passes through unmodified.
28
29use std::cell::RefCell;
30use std::collections::HashMap;
31
32use teksilo_i18n::LocalizedString;
33
34/// One tooltip content entry.
35///
36/// Every tooltip may optionally carry a long-form "more" body (revealed
37/// by the Accordion disclosure inside a sticky rich tooltip) and a
38/// keyboard shortcut hint.
39///
40/// A shortcut hint may be supplied two ways: `shortcut_label` is a
41/// literal override used verbatim, while `shortcut_id` binds to a
42/// registered shortcut — the tooltip widget reads the effective primary
43/// keystroke from the tree's
44/// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry) via
45/// `ctx.effective_shortcut(id)`, formats it with `format_keystroke`, and
46/// refreshes on user rebinds via `ctx.shortcut_version()`. A
47/// `shortcut_label` takes precedence over `shortcut_id` when both are set.
48///
49/// [`MenuItem`]: crate::menu_item::MenuItem
50pub struct TooltipContent {
51    /// Stable identifier. Referenced from link targets as `[label](:key)`.
52    pub key: String,
53    /// Primary body — rendered through TextWidget with inline markup
54    /// enabled, so it may contain further `[label](:other-key)` links.
55    pub text: LocalizedString,
56    /// Optional long-form content, revealed by the Accordion disclosure
57    /// inside a sticky tooltip. Same inline-markup pipeline as `text`.
58    pub more: Option<LocalizedString>,
59    /// Manual shortcut label override (e.g. "Ctrl+Shift+S"). Used
60    /// verbatim when set; takes precedence over `shortcut_id`.
61    pub shortcut_label: Option<String>,
62    /// Registered shortcut id — the tooltip renders the effective
63    /// primary keystroke from the tree's
64    /// [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry) and
65    /// tracks user rebinds automatically.
66    pub shortcut_id: Option<&'static str>,
67}
68
69impl std::fmt::Debug for TooltipContent {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("TooltipContent")
72            .field("key", &self.key)
73            .field("has_more", &self.more.is_some())
74            .field("shortcut_label", &self.shortcut_label)
75            .field("shortcut_id", &self.shortcut_id)
76            .finish()
77    }
78}
79
80impl Clone for TooltipContent {
81    fn clone(&self) -> Self {
82        Self {
83            key: self.key.clone(),
84            text: self.text.clone(),
85            more: self.more.clone(),
86            shortcut_label: self.shortcut_label.clone(),
87            shortcut_id: self.shortcut_id,
88        }
89    }
90}
91
92impl TooltipContent {
93    /// Build an entry with a primary body and no extras.
94    pub fn new(key: impl Into<String>, text: LocalizedString) -> Self {
95        Self {
96            key: key.into(),
97            text,
98            more: None,
99            shortcut_label: None,
100            shortcut_id: None,
101        }
102    }
103
104    /// Attach a long-form body revealed by the Accordion disclosure.
105    pub fn with_more(mut self, more: LocalizedString) -> Self {
106        self.more = Some(more);
107        self
108    }
109
110    /// Attach a manual shortcut label override ("Ctrl+Shift+S",
111    /// "Hold ⇧ + drag", …). Takes precedence over
112    /// [`TooltipContent::for_shortcut`].
113    pub fn with_shortcut_label(mut self, s: impl Into<String>) -> Self {
114        self.shortcut_label = Some(s.into());
115        self
116    }
117
118    /// Bind the tooltip's trailing chip to a registered shortcut id.
119    /// The tooltip widget reads the effective primary keystroke from
120    /// the tree's shortcut registry and refreshes on user rebinds.
121    pub fn for_shortcut(mut self, id: &'static str) -> Self {
122        self.shortcut_id = Some(id);
123        self
124    }
125
126    pub fn has_more(&self) -> bool {
127        self.more.is_some()
128    }
129
130    pub fn has_shortcut(&self) -> bool {
131        self.shortcut_label.is_some() || self.shortcut_id.is_some()
132    }
133}
134
135/// Frozen, read-only registry keyed by tooltip id.
136#[derive(Default)]
137pub struct TooltipRegistry {
138    by_key: HashMap<String, TooltipContent>,
139}
140
141impl TooltipRegistry {
142    /// Look up an entry by its stable key.
143    pub fn get(&self, key: &str) -> Option<&TooltipContent> {
144        self.by_key.get(key)
145    }
146
147    /// Parse a link URL as a tooltip key. Returns `Some(key)` when the
148    /// URL starts with the `:` prefix, `None` otherwise (so ordinary
149    /// http / mailto links pass through unmodified).
150    pub fn parse_url(url: &str) -> Option<&str> {
151        url.strip_prefix(':')
152    }
153
154    /// Resolve a link URL straight to a content entry, if the URL is a
155    /// tooltip key and the key is registered.
156    pub fn resolve_url(&self, url: &str) -> Option<&TooltipContent> {
157        Self::parse_url(url).and_then(|k| self.get(k))
158    }
159
160    /// Iterate every registered entry. Useful for diagnostics and tests.
161    pub fn iter(&self) -> impl Iterator<Item = (&String, &TooltipContent)> {
162        self.by_key.iter()
163    }
164
165    pub fn len(&self) -> usize {
166        self.by_key.len()
167    }
168
169    pub fn is_empty(&self) -> bool {
170        self.by_key.is_empty()
171    }
172}
173
174// Thread-local storage. The registry is installed once by
175// `install_tooltip_registry` from `TeksiloAppBuilder::register_tooltips`
176// at app boot and is read-only afterwards. The "install once" invariant
177// is enforced at runtime (debug-build panic on double-install) rather
178// than by the cell type, so tests can reset it without `unsafe` — see
179// `_reset_tooltip_registry`. Mirrors the `RefCell<Option<_>>` pattern
180// used by `teksilo-i18n`'s thread-local manager slot.
181thread_local! {
182    static TOOLTIP_REGISTRY: RefCell<Option<TooltipRegistry>> = const { RefCell::new(None) };
183}
184
185/// Install the tooltip registry for the current thread. Called once
186/// by `TeksiloAppBuilder::register_tooltips` before the first frame
187/// builds. Panics in debug builds on double-install; logs and keeps
188/// the first installation in release.
189///
190/// `contents` may hold several registrations for one key, because
191/// `register_tooltips` accumulates across calls so that plugins,
192/// extensions and sibling crates can each contribute a catalogue.
193/// **The first registration of a key wins**, matching
194/// `I18nConfig::compile_in`: the application registers first, and a
195/// later contributor cannot silently shadow one of its tooltips.
196/// Contributors should namespace their keys (`myext-panel-title`).
197pub fn install_tooltip_registry(contents: Vec<TooltipContent>) {
198    let mut by_key: HashMap<String, TooltipContent> = HashMap::new();
199    for c in contents {
200        // `or_insert_with` rather than `insert`: first-wins. A plain
201        // `collect()` here would be last-wins, which is the same
202        // shadowing hazard the additive builder exists to avoid.
203        by_key.entry(c.key.clone()).or_insert(c);
204    }
205    let reg = TooltipRegistry { by_key };
206    TOOLTIP_REGISTRY.with(|slot| {
207        let mut slot = slot.borrow_mut();
208        if slot.is_some() {
209            // Debug: enforce the install-once invariant. Release: keep
210            // the first installation and warn so the misuse surfaces.
211            debug_assert!(false, "tooltip registry already installed");
212            eprintln!(
213                "[teksilo-widgets::tooltip] install_tooltip_registry called twice — \
214                 keeping the first installation and ignoring the second. \
215                 Check that TeksiloAppBuilder::register_tooltips is only invoked once."
216            );
217            return;
218        }
219        *slot = Some(reg);
220    });
221}
222
223/// Read-side helper used by the tooltip widget. Runs `f` with a
224/// borrowed reference to the installed registry. Returns `None` if no
225/// registry has been installed yet (early bootstrap, headless tests).
226pub fn with_tooltip_registry<R>(f: impl FnOnce(&TooltipRegistry) -> R) -> Option<R> {
227    TOOLTIP_REGISTRY.with(|slot| slot.borrow().as_ref().map(f))
228}
229
230/// Test-only helper: reset the thread-local registry. Not exposed in
231/// release builds — tests clone-install-read then move on.
232#[cfg(test)]
233pub(crate) fn _reset_tooltip_registry() {
234    TOOLTIP_REGISTRY.with(|slot| {
235        *slot.borrow_mut() = None;
236    });
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use teksilo_i18n::lit;
243
244    #[test]
245    fn parse_url_recognizes_colon_prefix() {
246        assert_eq!(TooltipRegistry::parse_url(":foo"), Some("foo"));
247        assert_eq!(
248            TooltipRegistry::parse_url(":autosave-details"),
249            Some("autosave-details")
250        );
251    }
252
253    #[test]
254    fn parse_url_rejects_non_tooltip_schemes() {
255        assert_eq!(TooltipRegistry::parse_url("http://example.com"), None);
256        assert_eq!(TooltipRegistry::parse_url("mailto:foo@bar"), None);
257        assert_eq!(TooltipRegistry::parse_url(""), None);
258        assert_eq!(TooltipRegistry::parse_url("autosave"), None);
259    }
260
261    #[test]
262    fn parse_url_empty_key_is_some_empty() {
263        // Caller is responsible for rejecting empty keys.
264        assert_eq!(TooltipRegistry::parse_url(":"), Some(""));
265    }
266
267    #[test]
268    fn content_builder_chain() {
269        let c = TooltipContent::new("save-as", lit!("Save the file as…"))
270            .with_shortcut_label("Ctrl+Shift+S");
271        assert_eq!(c.key, "save-as");
272        assert!(!c.has_more());
273        assert!(c.has_shortcut());
274        assert_eq!(c.shortcut_label.as_deref(), Some("Ctrl+Shift+S"));
275    }
276
277    #[test]
278    fn content_with_more_sets_more() {
279        let c =
280            TooltipContent::new("autosave", lit!("Autosaves.")).with_more(lit!("Every 2 minutes."));
281        assert!(c.has_more());
282    }
283
284    #[test]
285    fn register_and_lookup_roundtrip() {
286        _reset_tooltip_registry();
287        install_tooltip_registry(vec![
288            TooltipContent::new("foo", lit!("Foo body")),
289            TooltipContent::new("bar", lit!("Bar body")).with_shortcut_label("Ctrl+B"),
290        ]);
291
292        let found = with_tooltip_registry(|r| {
293            assert_eq!(r.len(), 2);
294            let foo = r.get("foo").expect("foo registered");
295            assert_eq!(foo.key, "foo");
296            let bar = r.get("bar").expect("bar registered");
297            assert_eq!(bar.shortcut_label.as_deref(), Some("Ctrl+B"));
298            "ok"
299        });
300        assert_eq!(found, Some("ok"));
301
302        _reset_tooltip_registry();
303    }
304
305    #[test]
306    fn resolve_url_returns_content_for_registered_key() {
307        _reset_tooltip_registry();
308        install_tooltip_registry(vec![TooltipContent::new("docs", lit!("Documentation"))]);
309
310        let body = with_tooltip_registry(|r| r.resolve_url(":docs").map(|c| c.text.resolve_now()))
311            .flatten();
312        assert_eq!(body.as_deref(), Some("Documentation"));
313
314        let missing = with_tooltip_registry(|r| r.resolve_url(":nope").is_some());
315        assert_eq!(missing, Some(false));
316
317        let non_tooltip = with_tooltip_registry(|r| r.resolve_url("http://x").is_some());
318        assert_eq!(non_tooltip, Some(false));
319
320        _reset_tooltip_registry();
321    }
322
323    // NOTE: `with_command_stores_type_erased_ref` test removed along
324    // with the `command` field. Registry-backed shortcut resolution is
325    // exercised in the `RichTooltipWidget` build path (see tooltip/rich.rs),
326    // which reads `ctx.effective_shortcut(id)` and binds `shortcut_version`.
327}
328
329#[cfg(test)]
330mod additive_tests {
331    use super::*;
332    use teksilo_i18n::lit;
333
334    fn content(key: &str, body: &str) -> TooltipContent {
335        TooltipContent::new(key, lit!(body))
336    }
337
338    /// Registering the same key twice keeps the **first**.
339    ///
340    /// The app registers its catalogue first and a contributor's comes after,
341    /// so first-wins is what stops an extension shadowing an application
342    /// tooltip. A plain `collect()` into the map — which is what this used to
343    /// be — is last-wins and would let exactly that happen.
344    #[test]
345    fn the_first_registration_of_a_key_wins() {
346        _reset_tooltip_registry();
347        install_tooltip_registry(vec![
348            content("shared", "from the application"),
349            content("shared", "from a contributor"),
350        ]);
351        // Compare the *body*, not the key — both entries carry the same key, so
352        // asserting on it would pass whichever registration survived.
353        let body = with_tooltip_registry(|r| r.get("shared").map(|c| c.text.resolve_now()));
354        assert_eq!(
355            body,
356            Some(Some("from the application".to_string())),
357            "a later contributor must not shadow an application tooltip"
358        );
359        assert_eq!(
360            with_tooltip_registry(|r| r.len()),
361            Some(1),
362            "a duplicate key must collapse to one entry, not two"
363        );
364        _reset_tooltip_registry();
365    }
366
367    /// Several contributors' catalogues all survive into one registry.
368    ///
369    /// This is the shape `TeksiloAppBuilder::register_tooltips` produces now
370    /// that it accumulates: the application's entries plus every extension's,
371    /// concatenated, installed once.
372    #[test]
373    fn catalogues_from_several_contributors_all_resolve() {
374        _reset_tooltip_registry();
375        install_tooltip_registry(vec![
376            content("app-save", "Save the project"),
377            content("app-quit", "Leave"),
378            content("ext-beats", "Structure beats"),
379            content("other-ext-badge", "Drift"),
380        ]);
381        for key in ["app-save", "app-quit", "ext-beats", "other-ext-badge"] {
382            assert_eq!(
383                with_tooltip_registry(|r| r.get(key).is_some()),
384                Some(true),
385                "`{key}` must be reachable after a merged install"
386            );
387        }
388        _reset_tooltip_registry();
389    }
390}