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 let text_id = ctx.add(text);
199
200 let style: SharedTooltipStyle = self
201 .style_override
202 .clone()
203 .or_else(|| ctx.theme().style_slots.tooltip.clone())
204 .unwrap_or_else(|| Rc::new(crate::styles::RecipeTooltipStyle::default()));
205 let cfg = TooltipStyleConfig { content: text_id };
206 let root_id = style.make_body(&cfg, ctx);
207 self.root_child_id = Some(root_id);
208 vec![root_id]
209 }
210
211 fn layout_response(
212 &self,
213 proposal: SizeProposal,
214 ctx: &LayoutContext,
215 ) -> teksilo_core::widget::LayoutResponse {
216 let max_w = crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH;
222 let clamped = SizeProposal {
223 width: Some(proposal.width.map(|w| w.min(max_w)).unwrap_or(max_w)),
224 height: proposal.height,
225 };
226 if let Some(root) = self.root_child_id
227 && let Some(size) = ctx.child_size(root, clamped)
228 {
229 return size.into();
230 }
231 proposal.resolve(0.0, 0.0).into()
232 }
233
234 fn place_children(
235 &self,
236 bounds: Rect,
237 _proposal: SizeProposal,
238 children: &mut [WidgetPlacement],
239 _ctx: &LayoutContext,
240 ) {
241 for child in children.iter_mut() {
242 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
243 child.size = Size::new(bounds.width, bounds.height);
244 }
245 }
246
247 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
248 builder.set_role(teksilo_core::accesskit::Role::Tooltip);
249 builder.set_name(self.text.get());
253 }
254
255 fn children(&self) -> Vec<WidgetId> {
256 self.root_child_id.into_iter().collect()
257 }
258
259 fn tooltip_has_content(&self) -> bool {
264 !self.text.get().trim().is_empty()
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use teksilo_canvas::SizeProposal;
272 use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
273 use teksilo_core::widget_tree::WidgetTree;
274
275 const LONG_BODY: &str = "This tooltip body is deliberately long enough that \
278 it must wrap onto several lines instead of stretching the surface into \
279 one endless ribbon that runs straight off the edge of the window.";
280
281 fn shown_tooltip_bounds(text: &str) -> teksilo_canvas::Rect {
287 let mut tree = WidgetTree::new()
288 .with_theme(teksilo_core::presets::intui::light())
289 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
290 teksilo_canvas::MockTextBackend::new(),
291 )));
292 let anchor = tree.add(crate::button::Button::new(lit!("Anchor")).tooltip(lit!(text)));
293 tree.layout(SizeProposal::exact(2000.0, 600.0));
294 tree.pointer_move(tree.bounds(anchor).center());
295 tree.advance_time(std::time::Duration::from_secs(1));
296 tree.layout(SizeProposal::exact(2000.0, 600.0));
297 let overlay = *tree
298 .active_overlays()
299 .first()
300 .expect("the tooltip is shown");
301 tree.overlay_content_bounds(overlay)
302 .expect("the shown overlay has content bounds")
303 }
304
305 #[test]
306 fn a_long_plain_tooltip_wraps_at_the_max_width() {
307 let long = shown_tooltip_bounds(LONG_BODY);
313 assert!(
314 long.width <= crate::styles::recipe_tooltip_style::TOOLTIP_MAX_WIDTH + 0.5,
315 "a long tooltip must wrap at TOOLTIP_MAX_WIDTH, got {}",
316 long.width
317 );
318
319 let short = shown_tooltip_bounds("short");
321 assert!(
322 long.height > short.height,
323 "the wrapped body must occupy more than one line ({} vs {})",
324 long.height,
325 short.height
326 );
327 }
328
329 #[test]
330 fn an_empty_tooltip_has_no_content_to_show() {
331 assert!(!TooltipWidget::new(lit!("")).tooltip_has_content());
333 assert!(!TooltipWidget::new(lit!(" ")).tooltip_has_content());
334 assert!(TooltipWidget::new(lit!("real")).tooltip_has_content());
335 }
336
337 #[test]
338 fn an_empty_tooltip_never_opens_an_overlay() {
339 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
340 let anchor = tree.add(crate::button::Button::new(lit!("Go")).tooltip(lit!(" ")));
341 tree.layout(SizeProposal::exact(400.0, 200.0));
342
343 tree.pointer_move(tree.bounds(anchor).center());
344 tree.advance_time(std::time::Duration::from_secs(1));
345 assert!(
346 tree.active_overlays().is_empty(),
347 "a whitespace-only tooltip must not open an empty bubble"
348 );
349 }
350
351 #[test]
352 fn tooltip_widget_emits_shadow() {
353 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
354 let _ = tree.add(TooltipWidget::new(lit!("hello")));
355 tree.layout(SizeProposal::exact(200.0, 80.0));
356 let frame = tree.render();
357 assert!(
358 !frame.shadows.is_empty(),
359 "tooltip should emit at least one shadow"
360 );
361 }
362
363 #[test]
364 fn tooltip_overlay_emits_shadow_through_fade() {
365 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
369 let anchor = tree.add(TooltipWidget::new(lit!("anchor")));
370 let tip = tree.add(TooltipWidget::new(lit!("hello")));
371 tree.set_dormant(tip);
372 tree.layout(SizeProposal::exact(800.0, 600.0));
373
374 tree.show_overlay(OverlayRequest {
375 content_id: tip,
376 anchor,
377 placement: OverlayPlacement::NearAnchor {
378 offset: teksilo_canvas::Vec2::new(0.0, 8.0),
379 },
380 dismiss: DismissBehavior::PointerLeave {
381 delay: std::time::Duration::from_millis(100),
382 },
383 layer: OverlayLayer::InTree,
384 parent_overlay: None,
385 on_dismiss: None,
386 fade_duration: Some(std::time::Duration::from_millis(120)),
387 });
388 tree.layout(SizeProposal::exact(800.0, 600.0));
389 let frame = tree.render();
390 assert!(
391 !frame.shadows.is_empty(),
392 "tooltip overlay should emit at least one shadow even under fade scope"
393 );
394 }
395}