1pub mod attach;
41pub mod composite;
42pub(crate) mod dwell_indicator;
43pub mod registry;
44pub mod rich;
45
46pub use attach::{
47 RichTooltipSource, attach_composite_tooltip, attach_composite_tooltip_boxed,
48 attach_composite_tooltip_boxed_with_placement, attach_composite_tooltip_widget_with_placement,
49 attach_plain_tooltip, attach_plain_tooltip_with_placement, attach_rich_tooltip,
50 attach_rich_tooltip_content, attach_rich_tooltip_content_with_placement,
51 attach_rich_tooltip_source, attach_rich_tooltip_source_with_placement,
52 attach_rich_tooltip_with_placement,
53};
54pub use composite::CompositeTooltipWidget;
55pub use registry::{
56 TooltipContent, TooltipRegistry, install_tooltip_registry, with_tooltip_registry,
57};
58pub use rich::RichTooltipWidget;
59pub use teksilo_core::overlay::TooltipPlacement;
63
64use std::rc::Rc;
65use teksilo_i18n::lit;
66
67use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
68use teksilo_core::accessibility::AccessNodeBuilder;
69use teksilo_core::build_context::BuildContext;
70use teksilo_core::signal::Prop;
71use teksilo_core::styles::{SharedTooltipStyle, TooltipStyleConfig};
72use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
73use teksilo_core::widget_id::WidgetId;
74use teksilo_tokens::{CornerRadius, TextRole, TextStyleRole};
75
76use crate::primitives::TextWidget;
77use crate::shadow::paint_layered_shadow;
78use teksilo_i18n::LocalizedString;
79
80pub(crate) fn paint_tooltip_shadows(
84 canvas: &mut Canvas,
85 bounds: Rect,
86 radius: CornerRadius,
87 ctx: &PaintContext,
88) {
89 paint_layered_shadow(
90 canvas,
91 bounds,
92 radius,
93 &ctx.theme.shape.shadow_xs,
94 &ctx.theme.shape.shadow_inner_xs,
95 crate::styles::recipe_tooltip_style::TOOLTIP_SHADOW_DENSITY,
96 None,
97 );
98}
99
100pub(crate) fn paint_composite_tooltip_shadows(
104 canvas: &mut Canvas,
105 bounds: Rect,
106 radius: CornerRadius,
107 ctx: &PaintContext,
108) {
109 paint_layered_shadow(
110 canvas,
111 bounds,
112 radius,
113 &ctx.theme.shape.shadow_md,
114 &ctx.theme.shape.shadow_inner_md,
115 crate::styles::recipe_tooltip_style::COMPOSITE_TOOLTIP_SHADOW_DENSITY,
116 None,
117 );
118}
119
120pub struct TooltipWidget {
129 text: Prop<String>,
138 style_override: Option<SharedTooltipStyle>,
139 root_child_id: Option<WidgetId>,
140}
141
142impl std::fmt::Debug for TooltipWidget {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("TooltipWidget")
145 .field("text", &self.text.get())
146 .finish()
147 }
148}
149
150impl TooltipWidget {
151 pub fn new(text: impl Into<LocalizedString>) -> Self {
155 let ls: LocalizedString = text.into();
156 Self {
157 text: Prop::from(ls),
158 style_override: None,
159 root_child_id: None,
160 }
161 }
162
163 pub fn bound(text: impl Into<Prop<String>>) -> Self {
171 Self {
172 text: text.into(),
173 style_override: None,
174 root_child_id: None,
175 }
176 }
177
178 pub fn style(mut self, style: impl teksilo_core::styles::TooltipStyle) -> Self {
181 self.style_override = Some(Rc::new(style));
182 self
183 }
184}
185
186impl Widget for TooltipWidget {
187 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
188 let text = TextWidget::new(lit!(""))
195 .text(self.text.clone())
196 .style(TextStyleRole::Small)
197 .color(TextRole::TooltipText)
198 .a11y_hidden();
202 let text_id = ctx.add(text);
203
204 let style: SharedTooltipStyle = self
205 .style_override
206 .clone()
207 .or_else(|| ctx.theme().style_slots.tooltip.clone())
208 .unwrap_or_else(|| {
209 Rc::new(crate::styles::RecipeTooltipStyle::for_tokens(
210 &ctx.theme().input,
211 ))
212 });
213 let cfg = TooltipStyleConfig { content: text_id };
214 let root_id = style.make_body(&cfg, ctx);
215 self.root_child_id = Some(root_id);
216 vec![root_id]
217 }
218
219 fn layout_response(
220 &self,
221 proposal: SizeProposal,
222 ctx: &LayoutContext,
223 ) -> teksilo_core::widget::LayoutResponse {
224 let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
230 let clamped = SizeProposal {
231 width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
232 height: proposal.height,
233 };
234 if let Some(root) = self.root_child_id
235 && let Some(size) = ctx.child_size(root, clamped)
236 {
237 return size.into();
238 }
239 proposal.resolve(0.0, 0.0).into()
240 }
241
242 fn place_children(
243 &self,
244 bounds: Rect,
245 _proposal: SizeProposal,
246 children: &mut [WidgetPlacement],
247 _ctx: &LayoutContext,
248 ) {
249 for child in children.iter_mut() {
250 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
251 child.size = Size::new(bounds.width, bounds.height);
252 }
253 }
254
255 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
256 builder.set_role(teksilo_core::accesskit::Role::Tooltip);
257 builder.set_name(self.text.get());
261 }
262
263 fn children(&self) -> Vec<WidgetId> {
264 self.root_child_id.into_iter().collect()
265 }
266
267 fn tooltip_has_content(&self) -> bool {
272 !self.text.get().trim().is_empty()
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use teksilo_canvas::SizeProposal;
280 use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
281 use teksilo_core::widget_tree::WidgetTree;
282
283 const LONG_BODY: &str = "This tooltip body is deliberately long enough that \
286 it must wrap onto several lines instead of stretching the surface into \
287 one endless ribbon that runs straight off the edge of the window.";
288
289 fn shown_tooltip_bounds(text: &str) -> teksilo_canvas::Rect {
295 let mut tree = WidgetTree::new()
296 .with_theme(teksilo_core::presets::intui::light())
297 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
298 teksilo_canvas::MockTextBackend::new(),
299 )));
300 let anchor = tree.add(crate::button::Button::new(lit!("Anchor")).tooltip(lit!(text)));
301 tree.layout(SizeProposal::exact(2000.0, 600.0));
302 tree.pointer_move(tree.bounds(anchor).center());
303 tree.advance_time(std::time::Duration::from_secs(1));
304 tree.layout(SizeProposal::exact(2000.0, 600.0));
305 let overlay = *tree
306 .active_overlays()
307 .first()
308 .expect("the tooltip is shown");
309 tree.overlay_content_bounds(overlay)
310 .expect("the shown overlay has content bounds")
311 }
312
313 #[test]
314 fn a_long_plain_tooltip_wraps_at_the_max_width() {
315 let long = shown_tooltip_bounds(LONG_BODY);
321 assert!(
322 long.width <= crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH + 0.5,
323 "a long tooltip must wrap at TOOLTIP_MAX_WIDTH, got {}",
324 long.width
325 );
326
327 let short = shown_tooltip_bounds("short");
329 assert!(
330 long.height > short.height,
331 "the wrapped body must occupy more than one line ({} vs {})",
332 long.height,
333 short.height
334 );
335 }
336
337 #[test]
338 fn an_empty_tooltip_has_no_content_to_show() {
339 assert!(!TooltipWidget::new(lit!("")).tooltip_has_content());
341 assert!(!TooltipWidget::new(lit!(" ")).tooltip_has_content());
342 assert!(TooltipWidget::new(lit!("real")).tooltip_has_content());
343 }
344
345 #[test]
346 fn an_empty_tooltip_never_opens_an_overlay() {
347 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
348 let anchor = tree.add(crate::button::Button::new(lit!("Go")).tooltip(lit!(" ")));
349 tree.layout(SizeProposal::exact(400.0, 200.0));
350
351 tree.pointer_move(tree.bounds(anchor).center());
352 tree.advance_time(std::time::Duration::from_secs(1));
353 assert!(
354 tree.active_overlays().is_empty(),
355 "a whitespace-only tooltip must not open an empty bubble"
356 );
357 }
358
359 #[test]
360 fn tooltip_widget_emits_shadow() {
361 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
362 let _ = tree.add(TooltipWidget::new(lit!("hello")));
363 tree.layout(SizeProposal::exact(200.0, 80.0));
364 let frame = tree.render();
365 assert!(
366 !frame.shadows.is_empty(),
367 "tooltip should emit at least one shadow"
368 );
369 }
370
371 #[test]
372 fn tooltip_overlay_emits_shadow_through_fade() {
373 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
377 let anchor = tree.add(TooltipWidget::new(lit!("anchor")));
378 let tip = tree.add(TooltipWidget::new(lit!("hello")));
379 tree.set_dormant(tip);
380 tree.layout(SizeProposal::exact(800.0, 600.0));
381
382 tree.show_overlay(
383 OverlayRequest::new(
384 tip,
385 anchor,
386 OverlayPlacement::NearAnchor {
387 offset: teksilo_canvas::Vec2::new(0.0, 8.0),
388 },
389 DismissBehavior::PointerLeave {
390 delay: std::time::Duration::from_millis(100),
391 },
392 )
393 .with_fade(std::time::Duration::from_millis(120)),
394 );
395 tree.layout(SizeProposal::exact(800.0, 600.0));
396 let frame = tree.render();
397 assert!(
398 !frame.shadows.is_empty(),
399 "tooltip overlay should emit at least one shadow even under fade scope"
400 );
401 }
402
403 #[test]
404 fn tooltip_body_label_is_hidden_from_at_since_the_tooltip_node_owns_the_name() {
405 use teksilo_core::accessibility::widget_id_to_node_id;
410 use teksilo_core::accesskit::Role;
411 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
412 let id = tree.add(TooltipWidget::new(lit!("Save the current file")));
413 tree.layout(SizeProposal::exact(400.0, 200.0));
414 let update = tree.sync_accessibility();
415
416 let (_, node) = update
417 .nodes
418 .iter()
419 .find(|(nid, _)| *nid == widget_id_to_node_id(id))
420 .expect("tooltip node present");
421 assert_eq!(node.role(), Role::Tooltip);
422 assert_eq!(
423 node.label().unwrap_or_default(),
424 "Save the current file",
425 "hiding the body Label must not take the tooltip's own name away with it"
426 );
427
428 assert!(
429 !update.nodes.iter().any(|(_, n)| n.role() == Role::Label
430 && n.label().unwrap_or_default() == "Save the current file"),
431 "no separate Label node should duplicate the tooltip's own announced name"
432 );
433 }
434}