use std::cell::RefCell;
use std::collections::HashMap;
use teksilo_i18n::LocalizedString;
pub struct TooltipContent {
pub key: String,
pub text: LocalizedString,
pub more: Option<LocalizedString>,
pub shortcut_label: Option<String>,
pub shortcut_id: Option<&'static str>,
}
impl std::fmt::Debug for TooltipContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TooltipContent")
.field("key", &self.key)
.field("has_more", &self.more.is_some())
.field("shortcut_label", &self.shortcut_label)
.field("shortcut_id", &self.shortcut_id)
.finish()
}
}
impl Clone for TooltipContent {
fn clone(&self) -> Self {
Self {
key: self.key.clone(),
text: self.text.clone(),
more: self.more.clone(),
shortcut_label: self.shortcut_label.clone(),
shortcut_id: self.shortcut_id,
}
}
}
impl TooltipContent {
pub fn new(key: impl Into<String>, text: LocalizedString) -> Self {
Self {
key: key.into(),
text,
more: None,
shortcut_label: None,
shortcut_id: None,
}
}
pub fn with_more(mut self, more: LocalizedString) -> Self {
self.more = Some(more);
self
}
pub fn with_shortcut_label(mut self, s: impl Into<String>) -> Self {
self.shortcut_label = Some(s.into());
self
}
pub fn for_shortcut(mut self, id: &'static str) -> Self {
self.shortcut_id = Some(id);
self
}
pub fn has_more(&self) -> bool {
self.more.is_some()
}
pub fn has_shortcut(&self) -> bool {
self.shortcut_label.is_some() || self.shortcut_id.is_some()
}
}
#[derive(Default)]
pub struct TooltipRegistry {
by_key: HashMap<String, TooltipContent>,
}
impl TooltipRegistry {
pub fn get(&self, key: &str) -> Option<&TooltipContent> {
self.by_key.get(key)
}
pub fn parse_url(url: &str) -> Option<&str> {
url.strip_prefix(':')
}
pub fn resolve_url(&self, url: &str) -> Option<&TooltipContent> {
Self::parse_url(url).and_then(|k| self.get(k))
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &TooltipContent)> {
self.by_key.iter()
}
pub fn len(&self) -> usize {
self.by_key.len()
}
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
}
thread_local! {
static TOOLTIP_REGISTRY: RefCell<Option<TooltipRegistry>> = const { RefCell::new(None) };
}
pub fn install_tooltip_registry(contents: Vec<TooltipContent>) {
let mut by_key: HashMap<String, TooltipContent> = HashMap::new();
for c in contents {
by_key.entry(c.key.clone()).or_insert(c);
}
let reg = TooltipRegistry { by_key };
TOOLTIP_REGISTRY.with(|slot| {
let mut slot = slot.borrow_mut();
if slot.is_some() {
debug_assert!(false, "tooltip registry already installed");
eprintln!(
"[teksilo-widgets::tooltip] install_tooltip_registry called twice — \
keeping the first installation and ignoring the second. \
Check that TeksiloAppBuilder::register_tooltips is only invoked once."
);
return;
}
*slot = Some(reg);
});
}
pub fn with_tooltip_registry<R>(f: impl FnOnce(&TooltipRegistry) -> R) -> Option<R> {
TOOLTIP_REGISTRY.with(|slot| slot.borrow().as_ref().map(f))
}
#[cfg(test)]
pub(crate) fn _reset_tooltip_registry() {
TOOLTIP_REGISTRY.with(|slot| {
*slot.borrow_mut() = None;
});
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_i18n::lit;
#[test]
fn parse_url_recognizes_colon_prefix() {
assert_eq!(TooltipRegistry::parse_url(":foo"), Some("foo"));
assert_eq!(
TooltipRegistry::parse_url(":autosave-details"),
Some("autosave-details")
);
}
#[test]
fn parse_url_rejects_non_tooltip_schemes() {
assert_eq!(TooltipRegistry::parse_url("http://example.com"), None);
assert_eq!(TooltipRegistry::parse_url("mailto:foo@bar"), None);
assert_eq!(TooltipRegistry::parse_url(""), None);
assert_eq!(TooltipRegistry::parse_url("autosave"), None);
}
#[test]
fn parse_url_empty_key_is_some_empty() {
assert_eq!(TooltipRegistry::parse_url(":"), Some(""));
}
#[test]
fn content_builder_chain() {
let c = TooltipContent::new("save-as", lit!("Save the file as…"))
.with_shortcut_label("Ctrl+Shift+S");
assert_eq!(c.key, "save-as");
assert!(!c.has_more());
assert!(c.has_shortcut());
assert_eq!(c.shortcut_label.as_deref(), Some("Ctrl+Shift+S"));
}
#[test]
fn content_with_more_sets_more() {
let c =
TooltipContent::new("autosave", lit!("Autosaves.")).with_more(lit!("Every 2 minutes."));
assert!(c.has_more());
}
#[test]
fn register_and_lookup_roundtrip() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
TooltipContent::new("foo", lit!("Foo body")),
TooltipContent::new("bar", lit!("Bar body")).with_shortcut_label("Ctrl+B"),
]);
let found = with_tooltip_registry(|r| {
assert_eq!(r.len(), 2);
let foo = r.get("foo").expect("foo registered");
assert_eq!(foo.key, "foo");
let bar = r.get("bar").expect("bar registered");
assert_eq!(bar.shortcut_label.as_deref(), Some("Ctrl+B"));
"ok"
});
assert_eq!(found, Some("ok"));
_reset_tooltip_registry();
}
#[test]
fn resolve_url_returns_content_for_registered_key() {
_reset_tooltip_registry();
install_tooltip_registry(vec![TooltipContent::new("docs", lit!("Documentation"))]);
let body = with_tooltip_registry(|r| r.resolve_url(":docs").map(|c| c.text.resolve_now()))
.flatten();
assert_eq!(body.as_deref(), Some("Documentation"));
let missing = with_tooltip_registry(|r| r.resolve_url(":nope").is_some());
assert_eq!(missing, Some(false));
let non_tooltip = with_tooltip_registry(|r| r.resolve_url("http://x").is_some());
assert_eq!(non_tooltip, Some(false));
_reset_tooltip_registry();
}
}
#[cfg(test)]
mod additive_tests {
use super::*;
use teksilo_i18n::lit;
fn content(key: &str, body: &str) -> TooltipContent {
TooltipContent::new(key, lit!(body))
}
#[test]
fn the_first_registration_of_a_key_wins() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
content("shared", "from the application"),
content("shared", "from a contributor"),
]);
let body = with_tooltip_registry(|r| r.get("shared").map(|c| c.text.resolve_now()));
assert_eq!(
body,
Some(Some("from the application".to_string())),
"a later contributor must not shadow an application tooltip"
);
assert_eq!(
with_tooltip_registry(|r| r.len()),
Some(1),
"a duplicate key must collapse to one entry, not two"
);
_reset_tooltip_registry();
}
#[test]
fn catalogues_from_several_contributors_all_resolve() {
_reset_tooltip_registry();
install_tooltip_registry(vec![
content("app-save", "Save the project"),
content("app-quit", "Leave"),
content("ext-beats", "Structure beats"),
content("other-ext-badge", "Drift"),
]);
for key in ["app-save", "app-quit", "ext-beats", "other-ext-badge"] {
assert_eq!(
with_tooltip_registry(|r| r.get(key).is_some()),
Some(true),
"`{key}` must be reachable after a merged install"
);
}
_reset_tooltip_registry();
}
}