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}