Skip to main content

material_ui_rs/widget/component/
tooltip.rs

1//! Material 3 tooltip constructors with token-backed layout defaults.
2
3use super::*;
4
5use iced_widget::core::Transformation;
6use iced_widget::renderer::wgpu::primitive;
7
8pub use iced_tooltip::Position;
9
10pub fn plain<'a, Message, Renderer>(
11    content: impl Into<Element<'a, Message, Theme, Renderer>>,
12    supporting_text: impl text::IntoFragment<'a>,
13    position: Position,
14) -> PlainTooltip<'a, Message, Renderer>
15where
16    Message: 'a,
17    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
18{
19    let type_scale = tokens::component::tooltip::PLAIN_SUPPORTING_TEXT;
20
21    let tooltip = Container::new(plain_supporting_text::<Renderer>(
22        supporting_text,
23        type_scale,
24    ))
25    .padding(Padding {
26        top: 0.0,
27        right: PlainTooltipMetrics::horizontal_padding(),
28        bottom: 0.0,
29        left: PlainTooltipMetrics::horizontal_padding(),
30    })
31    .max_width(PlainTooltipMetrics::max_width());
32
33    RichTooltip::new(content, tooltip, position)
34        .gap(tokens::component::tooltip::SPACING_BETWEEN_TOOLTIP_AND_ANCHOR)
35        .padding(tokens::component::tooltip::PLAIN_VERTICAL_SPACE)
36        .interactive_surface(false)
37        .style(tooltip_style::plain)
38}
39
40pub fn rich<'a, Message, Renderer>(
41    content: impl Into<Element<'a, Message, Theme, Renderer>>,
42    supporting_text: impl text::IntoFragment<'a>,
43    position: Position,
44) -> RichTooltip<'a, Message, Renderer>
45where
46    Message: 'a,
47    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
48{
49    rich_surface(content, None, supporting_text, None, position)
50}
51
52pub fn rich_with_title<'a, Message, Renderer>(
53    content: impl Into<Element<'a, Message, Theme, Renderer>>,
54    title: impl text::IntoFragment<'a>,
55    supporting_text: impl text::IntoFragment<'a>,
56    position: Position,
57) -> RichTooltip<'a, Message, Renderer>
58where
59    Message: 'a,
60    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
61{
62    rich_surface(
63        content,
64        Some(title.into_fragment()),
65        supporting_text,
66        None,
67        position,
68    )
69}
70
71pub fn rich_with_title_action<'a, Message, Renderer>(
72    content: impl Into<Element<'a, Message, Theme, Renderer>>,
73    title: impl text::IntoFragment<'a>,
74    supporting_text: impl text::IntoFragment<'a>,
75    action: impl Into<Element<'a, Message, Theme, Renderer>>,
76    position: Position,
77) -> RichTooltip<'a, Message, Renderer>
78where
79    Message: 'a,
80    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
81{
82    rich_surface(
83        content,
84        Some(title.into_fragment()),
85        supporting_text,
86        Some(action.into()),
87        position,
88    )
89}
90
91pub fn rich_action<'a, Message, Renderer>(
92    label: impl text::IntoFragment<'a>,
93) -> button::Button<'a, Message, Renderer>
94where
95    Message: Clone + 'a,
96    Renderer:
97        iced_widget::graphics::geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
98{
99    button::button(label, button::ButtonVariant::Text)
100}
101
102pub fn rich_action_button<'a, Message, Renderer>(
103    label: impl text::IntoFragment<'a>,
104    on_press: Message,
105) -> Element<'a, Message, Theme, Renderer>
106where
107    Message: Clone + 'a,
108    Renderer:
109        iced_widget::graphics::geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
110{
111    rich_action(label).on_press(on_press).into()
112}
113
114fn plain_supporting_text<'a, Renderer>(
115    supporting_text: impl text::IntoFragment<'a>,
116    type_scale: tokens::typography::TypeScale,
117) -> Text<'a, Theme, Renderer>
118where
119    Renderer: core_text::Renderer,
120{
121    text_with_metrics(supporting_text, type_scale.size, type_scale.line_height)
122        .wrapping(text::Wrapping::Word)
123}
124
125fn rich_surface<'a, Message, Renderer>(
126    content: impl Into<Element<'a, Message, Theme, Renderer>>,
127    title: Option<text::Fragment<'a>>,
128    supporting_text: impl text::IntoFragment<'a>,
129    action: Option<Element<'a, Message, Theme, Renderer>>,
130    position: Position,
131) -> RichTooltip<'a, Message, Renderer>
132where
133    Message: 'a,
134    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
135{
136    let has_title = title.is_some();
137    let has_action = action.is_some();
138    let mut tooltip = iced_widget::Column::new()
139        .width(Length::Fill)
140        .padding(Padding {
141            top: 0.0,
142            right: tokens::component::tooltip::RICH_HORIZONTAL_SPACE,
143            bottom: 0.0,
144            left: tokens::component::tooltip::RICH_HORIZONTAL_SPACE,
145        });
146
147    if let Some(title) = title {
148        tooltip = tooltip.push(
149            Container::new(rich_title_text::<Renderer>(title))
150                .padding(Padding {
151                    top: rich_title_top_padding(),
152                    right: 0.0,
153                    bottom: 0.0,
154                    left: 0.0,
155                })
156                .width(Length::Fill),
157        );
158    }
159
160    tooltip = tooltip.push(
161        Container::new(rich_supporting_text::<Renderer>(supporting_text))
162            .padding(rich_supporting_text_padding(has_title, has_action))
163            .width(Length::Fill),
164    );
165
166    if let Some(action) = action {
167        tooltip = tooltip.push(
168            Container::new(action)
169                .padding(Padding {
170                    top: 0.0,
171                    right: 0.0,
172                    bottom: tokens::component::tooltip::RICH_ACTION_LABEL_BOTTOM_PADDING,
173                    left: 0.0,
174                })
175                .width(Length::Fill),
176        );
177    }
178
179    let tooltip = Container::new(tooltip)
180        .width(Length::Fill)
181        .height(Length::Shrink)
182        .max_width(tokens::component::tooltip::RICH_MAX_WIDTH);
183
184    RichTooltip::new(content, tooltip, position)
185        .gap(tokens::component::tooltip::SPACING_BETWEEN_TOOLTIP_AND_ANCHOR)
186        .padding(0.0)
187        .clip_padding(RichTooltipSurface::shadow_padding())
188        .style(tooltip_style::rich)
189}
190
191/// A Material plain tooltip with Android platform tooltip show/hide animation.
192pub type PlainTooltip<'a, Message, Renderer = iced_widget::Renderer> =
193    RichTooltip<'a, Message, Renderer>;
194
195/// A Material rich tooltip that remains interactive while the pointer moves
196/// from its anchor to the tooltip surface.
197pub struct RichTooltip<'a, Message, Renderer = iced_widget::Renderer>
198where
199    Renderer: core_text::Renderer,
200{
201    content: Element<'a, Message, Theme, Renderer>,
202    tooltip: Element<'a, Message, Theme, Renderer>,
203    position: Position,
204    gap: f32,
205    padding: f32,
206    clip_padding: f32,
207    snap_within_viewport: bool,
208    interactive_surface: bool,
209    class: <Theme as iced_container::Catalog>::Class<'a>,
210}
211
212impl<Message, Renderer> std::fmt::Debug for RichTooltip<'_, Message, Renderer>
213where
214    Renderer: core_text::Renderer,
215{
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.debug_struct("RichTooltip")
218            .field("position", &self.position)
219            .field("gap", &self.gap)
220            .field("padding", &self.padding)
221            .field("clip_padding", &self.clip_padding)
222            .field("snap_within_viewport", &self.snap_within_viewport)
223            .field("interactive_surface", &self.interactive_surface)
224            .finish_non_exhaustive()
225    }
226}
227
228impl<'a, Message, Renderer> RichTooltip<'a, Message, Renderer>
229where
230    Message: 'a,
231    Renderer: core_text::Renderer + 'a,
232{
233    pub fn new(
234        content: impl Into<Element<'a, Message, Theme, Renderer>>,
235        tooltip: impl Into<Element<'a, Message, Theme, Renderer>>,
236        position: Position,
237    ) -> Self {
238        Self {
239            content: content.into(),
240            tooltip: tooltip.into(),
241            position,
242            gap: 0.0,
243            padding: 0.0,
244            clip_padding: 0.0,
245            snap_within_viewport: true,
246            interactive_surface: true,
247            class: <Theme as iced_container::Catalog>::default(),
248        }
249    }
250
251    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
252        self.gap = gap.into().0;
253        self
254    }
255
256    pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
257        self.padding = padding.into().0;
258        self
259    }
260
261    fn clip_padding(mut self, padding: impl Into<Pixels>) -> Self {
262        self.clip_padding = padding.into().0;
263        self
264    }
265
266    pub fn snap_within_viewport(mut self, snap: bool) -> Self {
267        self.snap_within_viewport = snap;
268        self
269    }
270
271    fn interactive_surface(mut self, interactive: bool) -> Self {
272        self.interactive_surface = interactive;
273        self
274    }
275
276    #[must_use]
277    pub fn style(mut self, style: impl Fn(&Theme) -> iced_container::Style + 'a) -> Self
278    where
279        <Theme as iced_container::Catalog>::Class<'a>: From<iced_container::StyleFn<'a, Theme>>,
280    {
281        self.class = Box::new(style) as iced_container::StyleFn<'a, Theme>;
282        self
283    }
284}
285
286impl<Message, Renderer> Widget<Message, Theme, Renderer> for RichTooltip<'_, Message, Renderer>
287where
288    Renderer: core_text::Renderer,
289{
290    fn tag(&self) -> tree::Tag {
291        tree::Tag::of::<RichTooltipState>()
292    }
293
294    fn state(&self) -> tree::State {
295        tree::State::new(RichTooltipState::default())
296    }
297
298    fn children(&self) -> Vec<Tree> {
299        vec![Tree::new(&self.content), Tree::new(&self.tooltip)]
300    }
301
302    fn diff(&self, tree: &mut Tree) {
303        tree.diff_children(&[self.content.as_widget(), self.tooltip.as_widget()]);
304    }
305
306    fn size(&self) -> Size<Length> {
307        self.content.as_widget().size()
308    }
309
310    fn size_hint(&self) -> Size<Length> {
311        self.content.as_widget().size_hint()
312    }
313
314    fn layout(
315        &mut self,
316        tree: &mut Tree,
317        renderer: &Renderer,
318        limits: &layout::Limits,
319    ) -> layout::Node {
320        self.content
321            .as_widget_mut()
322            .layout(&mut tree.children[0], renderer, limits)
323    }
324
325    fn update(
326        &mut self,
327        tree: &mut Tree,
328        event: &Event,
329        layout: Layout<'_>,
330        cursor: mouse::Cursor,
331        renderer: &Renderer,
332        clipboard: &mut dyn Clipboard,
333        shell: &mut Shell<'_, Message>,
334        viewport: &Rectangle,
335    ) {
336        if let Event::Mouse(_) | Event::Window(window::Event::RedrawRequested(_)) = event {
337            let state = tree.state.downcast_mut::<RichTooltipState>();
338            let now = tooltip_event_time(event);
339            let was_visible = state.is_visible();
340            let cursor_position = cursor.position_over(layout.bounds());
341
342            state.advance(now);
343
344            if let Some(cursor_position) = cursor_position {
345                state.show(now, cursor_position);
346                shell.request_redraw();
347            } else if RichTooltipHitArea::anchor_exit_dismisses(self.interactive_surface, state) {
348                state.dismiss(now);
349            }
350
351            if was_visible != state.is_visible() {
352                shell.invalidate_layout();
353            }
354
355            request_tooltip_redraw(state, shell);
356        }
357
358        self.content.as_widget_mut().update(
359            &mut tree.children[0],
360            event,
361            layout,
362            cursor,
363            renderer,
364            clipboard,
365            shell,
366            viewport,
367        );
368    }
369
370    fn mouse_interaction(
371        &self,
372        tree: &Tree,
373        layout: Layout<'_>,
374        cursor: mouse::Cursor,
375        viewport: &Rectangle,
376        renderer: &Renderer,
377    ) -> mouse::Interaction {
378        self.content.as_widget().mouse_interaction(
379            &tree.children[0],
380            layout,
381            cursor,
382            viewport,
383            renderer,
384        )
385    }
386
387    fn draw(
388        &self,
389        tree: &Tree,
390        renderer: &mut Renderer,
391        theme: &Theme,
392        defaults: &renderer::Style,
393        layout: Layout<'_>,
394        cursor: mouse::Cursor,
395        viewport: &Rectangle,
396    ) {
397        self.content.as_widget().draw(
398            &tree.children[0],
399            renderer,
400            theme,
401            defaults,
402            layout,
403            cursor,
404            viewport,
405        );
406    }
407
408    fn overlay<'b>(
409        &'b mut self,
410        tree: &'b mut Tree,
411        layout: Layout<'b>,
412        renderer: &Renderer,
413        viewport: &Rectangle,
414        translation: Vector,
415    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
416        let state = tree.state.downcast_mut::<RichTooltipState>();
417        let mut children = tree.children.iter_mut();
418
419        let content = self.content.as_widget_mut().overlay(
420            children.next().unwrap(),
421            layout,
422            renderer,
423            viewport,
424            translation,
425        );
426
427        let content_bounds = translated_bounds(layout.bounds(), translation);
428        let tooltip = if state.is_visible() {
429            Some(overlay::Element::new(Box::new(RichTooltipOverlay {
430                tooltip: &mut self.tooltip,
431                tree: children.next().unwrap(),
432                state,
433                content_bounds,
434                snap_within_viewport: self.snap_within_viewport,
435                position: self.position,
436                gap: self.gap,
437                padding: self.padding,
438                clip_padding: self.clip_padding,
439                interactive_surface: self.interactive_surface,
440                class: &self.class,
441            })))
442        } else {
443            let _ = children.next();
444            None
445        };
446
447        if content.is_some() || tooltip.is_some() {
448            Some(
449                overlay::Group::with_children(content.into_iter().chain(tooltip).collect())
450                    .overlay(),
451            )
452        } else {
453            None
454        }
455    }
456
457    fn operate(
458        &mut self,
459        tree: &mut Tree,
460        layout: Layout<'_>,
461        renderer: &Renderer,
462        operation: &mut dyn core_widget::Operation,
463    ) {
464        operation.container(None, layout.bounds());
465        operation.traverse(&mut |operation| {
466            self.content.as_widget_mut().operate(
467                &mut tree.children[0],
468                layout,
469                renderer,
470                operation,
471            );
472        });
473    }
474}
475
476impl<'a, Message, Renderer> From<RichTooltip<'a, Message, Renderer>>
477    for Element<'a, Message, Theme, Renderer>
478where
479    Message: 'a,
480    Renderer: core_text::Renderer + 'a,
481{
482    fn from(tooltip: RichTooltip<'a, Message, Renderer>) -> Self {
483        Element::new(tooltip)
484    }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum TooltipPhase {
489    Hidden,
490    Showing,
491    Shown,
492    Dismissing,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq)]
496struct RichTooltipState {
497    phase: TooltipPhase,
498    cursor_position: Point,
499    started_at: Option<Instant>,
500    scale_from: f32,
501    alpha_from: f32,
502}
503
504impl Default for RichTooltipState {
505    fn default() -> Self {
506        Self {
507            phase: TooltipPhase::Hidden,
508            cursor_position: Point::ORIGIN,
509            started_at: None,
510            scale_from: tokens::component::tooltip::SCALE_START,
511            alpha_from: 0.0,
512        }
513    }
514}
515
516#[derive(Debug, Clone, Copy, PartialEq)]
517struct TooltipAnimationFrame {
518    scale: f32,
519    alpha: f32,
520}
521
522impl RichTooltipState {
523    fn show(&mut self, now: Instant, cursor_position: Point) {
524        self.cursor_position = cursor_position;
525
526        match self.phase {
527            TooltipPhase::Showing | TooltipPhase::Shown => {}
528            TooltipPhase::Hidden | TooltipPhase::Dismissing => {
529                let frame = self.animation_frame(now);
530
531                self.scale_from = frame.scale;
532                self.alpha_from = frame.alpha;
533                self.phase = TooltipPhase::Showing;
534                self.started_at = Some(now);
535            }
536        }
537    }
538
539    fn dismiss(&mut self, now: Instant) {
540        match self.phase {
541            TooltipPhase::Hidden | TooltipPhase::Dismissing => {}
542            TooltipPhase::Showing | TooltipPhase::Shown => {
543                let frame = self.animation_frame(now);
544
545                self.scale_from = frame.scale;
546                self.alpha_from = frame.alpha;
547                self.phase = TooltipPhase::Dismissing;
548                self.started_at = Some(now);
549            }
550        }
551    }
552
553    fn advance(&mut self, now: Instant) {
554        match self.phase {
555            TooltipPhase::Hidden | TooltipPhase::Shown => {}
556            TooltipPhase::Showing if self.progress(now) >= 1.0 => {
557                self.phase = TooltipPhase::Shown;
558                self.started_at = None;
559                self.scale_from = 1.0;
560                self.alpha_from = 1.0;
561            }
562            TooltipPhase::Showing => {}
563            TooltipPhase::Dismissing if self.progress(now) >= 1.0 => {
564                *self = Self::default();
565            }
566            TooltipPhase::Dismissing => {}
567        }
568    }
569
570    fn is_visible(&self) -> bool {
571        self.phase != TooltipPhase::Hidden
572    }
573
574    fn is_animating(&self) -> bool {
575        matches!(self.phase, TooltipPhase::Showing | TooltipPhase::Dismissing)
576    }
577
578    fn animation_frame(&self, now: Instant) -> TooltipAnimationFrame {
579        match self.phase {
580            TooltipPhase::Hidden => TooltipAnimationFrame {
581                scale: tokens::component::tooltip::SCALE_START,
582                alpha: 0.0,
583            },
584            TooltipPhase::Shown => TooltipAnimationFrame {
585                scale: 1.0,
586                alpha: 1.0,
587            },
588            TooltipPhase::Showing => {
589                let progress = self.progress(now);
590                let scale_progress = tooltip_scale_easing(progress);
591
592                TooltipAnimationFrame {
593                    scale: lerp(self.scale_from, 1.0, scale_progress),
594                    alpha: lerp(self.alpha_from, 1.0, progress),
595                }
596            }
597            TooltipPhase::Dismissing => {
598                let progress = self.progress(now);
599                let scale_progress = tooltip_scale_easing(progress);
600
601                TooltipAnimationFrame {
602                    scale: lerp(
603                        self.scale_from,
604                        tokens::component::tooltip::SCALE_START,
605                        scale_progress,
606                    ),
607                    alpha: lerp(self.alpha_from, 0.0, progress),
608                }
609            }
610        }
611    }
612
613    fn progress(&self, now: Instant) -> f32 {
614        let Some(started_at) = self.started_at else {
615            return match self.phase {
616                TooltipPhase::Showing | TooltipPhase::Dismissing => 1.0,
617                TooltipPhase::Hidden | TooltipPhase::Shown => 0.0,
618            };
619        };
620
621        let duration = duration_ms(match self.phase {
622            TooltipPhase::Showing => tokens::component::tooltip::FADE_IN_DURATION_MS,
623            TooltipPhase::Dismissing => tokens::component::tooltip::FADE_OUT_DURATION_MS,
624            TooltipPhase::Hidden | TooltipPhase::Shown => 0,
625        });
626
627        if duration.is_zero() {
628            return 1.0;
629        }
630
631        (now.saturating_duration_since(started_at).as_secs_f32() / duration.as_secs_f32())
632            .clamp(0.0, 1.0)
633    }
634}
635
636struct RichTooltipOverlay<'a, 'b, Message, Renderer>
637where
638    Renderer: core_text::Renderer,
639{
640    tooltip: &'b mut Element<'a, Message, Theme, Renderer>,
641    tree: &'b mut Tree,
642    state: &'b mut RichTooltipState,
643    content_bounds: Rectangle,
644    snap_within_viewport: bool,
645    position: Position,
646    gap: f32,
647    padding: f32,
648    clip_padding: f32,
649    interactive_surface: bool,
650    class: &'b <Theme as iced_container::Catalog>::Class<'a>,
651}
652
653impl<Message, Renderer> overlay::Overlay<Message, Theme, Renderer>
654    for RichTooltipOverlay<'_, '_, Message, Renderer>
655where
656    Renderer: core_text::Renderer,
657{
658    fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
659        let viewport = Rectangle::with_size(bounds);
660        let total_padding = self.padding + self.clip_padding;
661        let tooltip_layout = self.tooltip.as_widget_mut().layout(
662            self.tree,
663            renderer,
664            &layout::Limits::new(
665                Size::ZERO,
666                if self.snap_within_viewport {
667                    viewport.size()
668                } else {
669                    Size::INFINITE
670                },
671            )
672            .shrink(Padding::new(total_padding)),
673        );
674
675        position_rich_tooltip_layout(
676            tooltip_layout,
677            RichTooltipPlacement {
678                content_bounds: self.content_bounds,
679                viewport,
680                cursor_position: self.state.cursor_position,
681                position: self.position,
682                gap: self.gap,
683                padding: self.padding,
684                clip_padding: self.clip_padding,
685                snap_within_viewport: self.snap_within_viewport,
686            },
687        )
688    }
689
690    fn update(
691        &mut self,
692        event: &Event,
693        layout: Layout<'_>,
694        cursor: mouse::Cursor,
695        renderer: &Renderer,
696        clipboard: &mut dyn Clipboard,
697        shell: &mut Shell<'_, Message>,
698    ) {
699        let tooltip_bounds = layout.bounds();
700        let now = tooltip_event_time(event);
701        let cursor_in_content = cursor.is_over(self.content_bounds);
702        let cursor_in_tooltip = cursor.is_over(tooltip_bounds);
703        let cursor_in_keep_alive = self.interactive_surface
704            && RichTooltipHitArea {
705                content: self.content_bounds,
706                tooltip: tooltip_bounds,
707                position: self.position,
708            }
709            .contains(cursor);
710
711        if cursor_in_tooltip && let Some(child_layout) = layout.children().next() {
712            self.tooltip.as_widget_mut().update(
713                self.tree,
714                event,
715                child_layout,
716                cursor,
717                renderer,
718                clipboard,
719                shell,
720                &Rectangle::with_size(Size::INFINITE),
721            );
722        }
723
724        if let Event::Mouse(_) | Event::Window(window::Event::RedrawRequested(_)) = event {
725            let was_visible = self.state.is_visible();
726
727            if cursor_in_content || cursor_in_keep_alive {
728                if let Some(cursor_position) = cursor.position_over(self.content_bounds) {
729                    self.state.show(now, cursor_position);
730                } else if self.state.phase == TooltipPhase::Dismissing {
731                    self.state.show(now, self.state.cursor_position);
732                }
733            } else {
734                self.state.dismiss(now);
735            }
736
737            self.state.advance(now);
738
739            if was_visible != self.state.is_visible() {
740                shell.invalidate_layout();
741            }
742
743            request_tooltip_redraw(self.state, shell);
744        }
745
746        if self.interactive_surface
747            && (cursor_in_tooltip || cursor_in_keep_alive)
748            && !cursor_in_content
749            && matches!(event, Event::Mouse(_))
750        {
751            shell.capture_event();
752        }
753    }
754
755    fn mouse_interaction(
756        &self,
757        layout: Layout<'_>,
758        cursor: mouse::Cursor,
759        renderer: &Renderer,
760    ) -> mouse::Interaction {
761        let tooltip_bounds = layout.bounds();
762
763        if cursor.is_over(tooltip_bounds)
764            && let Some(child_layout) = layout.children().next()
765        {
766            return self.tooltip.as_widget().mouse_interaction(
767                self.tree,
768                child_layout,
769                cursor,
770                &Rectangle::with_size(Size::INFINITE),
771                renderer,
772            );
773        }
774
775        mouse::Interaction::default()
776    }
777
778    fn draw(
779        &self,
780        renderer: &mut Renderer,
781        theme: &Theme,
782        inherited_style: &renderer::Style,
783        layout: Layout<'_>,
784        cursor: mouse::Cursor,
785    ) {
786        let frame = self.state.animation_frame(Instant::now());
787        let style = tooltip_container_style_alpha(
788            iced_container::Catalog::style(theme, self.class),
789            frame.alpha,
790        );
791        let surface_bounds = RichTooltipSurface {
792            bounds: layout.bounds(),
793            clip_padding: self.clip_padding,
794        }
795        .visual_bounds();
796        let transformation = tooltip_scale_transformation(surface_bounds, frame.scale);
797
798        renderer.with_layer(layout.bounds(), |renderer| {
799            renderer.with_transformation(transformation, |renderer| {
800                iced_container::draw_background(renderer, &style, surface_bounds);
801
802                let defaults = renderer::Style {
803                    text_color: style.text_color.unwrap_or(inherited_style.text_color),
804                };
805
806                self.tooltip.as_widget().draw(
807                    self.tree,
808                    renderer,
809                    theme,
810                    &defaults,
811                    layout.children().next().unwrap(),
812                    cursor,
813                    &Rectangle::with_size(Size::INFINITE),
814                );
815            });
816        });
817    }
818
819    fn overlay<'c>(
820        &'c mut self,
821        layout: Layout<'c>,
822        renderer: &Renderer,
823    ) -> Option<overlay::Element<'c, Message, Theme, Renderer>> {
824        self.tooltip.as_widget_mut().overlay(
825            self.tree,
826            layout.children().next().unwrap(),
827            renderer,
828            &Rectangle::with_size(Size::INFINITE),
829            Vector::ZERO,
830        )
831    }
832}
833
834fn translated_bounds(bounds: Rectangle, translation: Vector) -> Rectangle {
835    Rectangle {
836        x: bounds.x + translation.x,
837        y: bounds.y + translation.y,
838        ..bounds
839    }
840}
841
842#[derive(Debug, Clone, Copy)]
843struct RichTooltipPlacement {
844    content_bounds: Rectangle,
845    viewport: Rectangle,
846    cursor_position: Point,
847    position: Position,
848    gap: f32,
849    padding: f32,
850    clip_padding: f32,
851    snap_within_viewport: bool,
852}
853
854fn position_rich_tooltip_layout(
855    tooltip_layout: layout::Node,
856    placement: RichTooltipPlacement,
857) -> layout::Node {
858    let total_padding = placement.padding + placement.clip_padding;
859    let tooltip_bounds = rich_tooltip_surface_bounds(&tooltip_layout, placement);
860
861    layout::Node::with_children(
862        tooltip_bounds.size(),
863        vec![tooltip_layout.translate(Vector::new(total_padding, total_padding))],
864    )
865    .translate(Vector::new(tooltip_bounds.x, tooltip_bounds.y))
866}
867
868fn rich_tooltip_surface_bounds(
869    tooltip_layout: &layout::Node,
870    placement: RichTooltipPlacement,
871) -> Rectangle {
872    let measured = tooltip_layout.bounds().size();
873    let surface_size = Size::new(
874        measured.width + placement.padding * 2.0,
875        measured.height + placement.padding * 2.0,
876    );
877    let x_center =
878        placement.content_bounds.x + (placement.content_bounds.width - surface_size.width) / 2.0;
879    let y_center =
880        placement.content_bounds.y + (placement.content_bounds.height - surface_size.height) / 2.0;
881
882    let offset = match placement.position {
883        Position::Top => Vector::new(
884            x_center,
885            placement.content_bounds.y - surface_size.height - placement.gap,
886        ),
887        Position::Bottom => Vector::new(
888            x_center,
889            placement.content_bounds.y + placement.content_bounds.height + placement.gap,
890        ),
891        Position::Left => Vector::new(
892            placement.content_bounds.x - surface_size.width - placement.gap,
893            y_center,
894        ),
895        Position::Right => Vector::new(
896            placement.content_bounds.x + placement.content_bounds.width + placement.gap,
897            y_center,
898        ),
899        Position::FollowCursor => Vector::new(
900            placement.cursor_position.x,
901            placement.cursor_position.y - surface_size.height,
902        ),
903    };
904
905    let mut tooltip_bounds = Rectangle {
906        x: offset.x - placement.clip_padding,
907        y: offset.y - placement.clip_padding,
908        width: surface_size.width + placement.clip_padding * 2.0,
909        height: surface_size.height + placement.clip_padding * 2.0,
910    };
911
912    if placement.snap_within_viewport {
913        if tooltip_bounds.x < placement.viewport.x {
914            tooltip_bounds.x = placement.viewport.x;
915        } else if placement.viewport.x + placement.viewport.width
916            < tooltip_bounds.x + tooltip_bounds.width
917        {
918            tooltip_bounds.x =
919                placement.viewport.x + placement.viewport.width - tooltip_bounds.width;
920        }
921
922        if tooltip_bounds.y < placement.viewport.y {
923            tooltip_bounds.y = placement.viewport.y;
924        } else if placement.viewport.y + placement.viewport.height
925            < tooltip_bounds.y + tooltip_bounds.height
926        {
927            tooltip_bounds.y =
928                placement.viewport.y + placement.viewport.height - tooltip_bounds.height;
929        }
930    }
931
932    tooltip_bounds
933}
934
935#[derive(Debug, Clone, Copy)]
936struct RichTooltipSurface {
937    bounds: Rectangle,
938    clip_padding: f32,
939}
940
941impl RichTooltipSurface {
942    fn visual_bounds(self) -> Rectangle {
943        Rectangle {
944            x: self.bounds.x + self.clip_padding,
945            y: self.bounds.y + self.clip_padding,
946            width: (self.bounds.width - self.clip_padding * 2.0).max(0.0),
947            height: (self.bounds.height - self.clip_padding * 2.0).max(0.0),
948        }
949    }
950
951    fn shadow_padding() -> f32 {
952        let shadow =
953            tokens::elevation::shadow(tokens::component::tooltip::RICH_CONTAINER_ELEVATION_LEVEL)
954                .ambient;
955
956        (shadow.blur + shadow.y.abs()).ceil()
957    }
958}
959
960#[derive(Debug, Clone, Copy)]
961struct RichTooltipHitArea {
962    content: Rectangle,
963    tooltip: Rectangle,
964    position: Position,
965}
966
967impl RichTooltipHitArea {
968    fn contains(self, cursor: mouse::Cursor) -> bool {
969        let Some(cursor) = cursor.position() else {
970            return false;
971        };
972
973        if self.content.contains(cursor) || self.tooltip.contains(cursor) {
974            return true;
975        }
976
977        self.corridor()
978            .is_some_and(|bounds| bounds.contains(cursor))
979    }
980
981    fn anchor_exit_dismisses(interactive_surface: bool, state: &RichTooltipState) -> bool {
982        !interactive_surface || !state.is_visible()
983    }
984
985    fn corridor(self) -> Option<Rectangle> {
986        let content_right = self.content.x + self.content.width;
987        let content_bottom = self.content.y + self.content.height;
988        let tooltip_right = self.tooltip.x + self.tooltip.width;
989        let tooltip_bottom = self.tooltip.y + self.tooltip.height;
990
991        match self.position {
992            Position::Top if tooltip_bottom <= self.content.y => Some(Rectangle {
993                x: self.content.x,
994                y: tooltip_bottom,
995                width: self.content.width,
996                height: self.content.y - tooltip_bottom,
997            }),
998            Position::Bottom if content_bottom <= self.tooltip.y => Some(Rectangle {
999                x: self.content.x,
1000                y: content_bottom,
1001                width: self.content.width,
1002                height: self.tooltip.y - content_bottom,
1003            }),
1004            Position::Left if tooltip_right <= self.content.x => Some(Rectangle {
1005                x: tooltip_right,
1006                y: self.content.y,
1007                width: self.content.x - tooltip_right,
1008                height: self.content.height,
1009            }),
1010            Position::Right if content_right <= self.tooltip.x => Some(Rectangle {
1011                x: content_right,
1012                y: self.content.y,
1013                width: self.tooltip.x - content_right,
1014                height: self.content.height,
1015            }),
1016            _ => None,
1017        }
1018    }
1019}
1020
1021fn tooltip_container_style_alpha(
1022    mut style: iced_container::Style,
1023    alpha: f32,
1024) -> iced_container::Style {
1025    style.background = style
1026        .background
1027        .map(|background| background.scale_alpha(alpha));
1028    style.text_color = style.text_color.map(|color| alpha_color(color, alpha));
1029    style.border.color = alpha_color(style.border.color, alpha);
1030    style.shadow.color = alpha_color(style.shadow.color, alpha);
1031
1032    style
1033}
1034
1035fn tooltip_scale_transformation(bounds: Rectangle, scale: f32) -> Transformation {
1036    Transformation::translate(bounds.center_x(), bounds.center_y())
1037        * Transformation::scale(scale)
1038        * Transformation::translate(-bounds.center_x(), -bounds.center_y())
1039}
1040
1041fn tooltip_event_time(event: &Event) -> Instant {
1042    match event {
1043        Event::Window(window::Event::RedrawRequested(now)) => *now,
1044        _ => Instant::now(),
1045    }
1046}
1047
1048fn request_tooltip_redraw<Message>(state: &RichTooltipState, shell: &mut Shell<'_, Message>) {
1049    if state.is_animating() {
1050        shell.request_redraw();
1051    }
1052}
1053
1054fn tooltip_scale_easing(progress: f32) -> f32 {
1055    tokens::motion::EASING_LEGACY_DECELERATE.transform(progress)
1056}
1057
1058fn rich_title_text<'a, Renderer>(title: impl text::IntoFragment<'a>) -> Text<'a, Theme, Renderer>
1059where
1060    Renderer: core_text::Renderer,
1061{
1062    let scale = tokens::component::tooltip::RICH_SUBHEAD_TEXT;
1063
1064    text_with_metrics(title, scale.size, scale.line_height)
1065        .wrapping(text::Wrapping::Word)
1066        .style(rich_title_style)
1067}
1068
1069fn rich_supporting_text<'a, Renderer>(
1070    supporting_text: impl text::IntoFragment<'a>,
1071) -> Text<'a, Theme, Renderer>
1072where
1073    Renderer: core_text::Renderer,
1074{
1075    let scale = tokens::component::tooltip::RICH_SUPPORTING_TEXT;
1076
1077    text_with_metrics(supporting_text, scale.size, scale.line_height)
1078        .wrapping(text::Wrapping::Word)
1079        .style(rich_supporting_text_style)
1080}
1081
1082fn rich_title_top_padding() -> f32 {
1083    (tokens::component::tooltip::RICH_HEIGHT_TO_SUBHEAD_FIRST_LINE
1084        - tokens::component::tooltip::RICH_SUBHEAD_TEXT.line_height)
1085        .max(0.0)
1086}
1087
1088fn rich_supporting_text_padding(has_title: bool, has_action: bool) -> Padding {
1089    if !has_title && !has_action {
1090        return Padding {
1091            top: tokens::component::tooltip::RICH_TEXT_VERTICAL_SPACE_WITHOUT_TITLE_OR_ACTION,
1092            right: 0.0,
1093            bottom: tokens::component::tooltip::RICH_TEXT_VERTICAL_SPACE_WITHOUT_TITLE_OR_ACTION,
1094            left: 0.0,
1095        };
1096    }
1097
1098    Padding {
1099        top: (tokens::component::tooltip::RICH_HEIGHT_FROM_SUBHEAD_TO_TEXT_FIRST_LINE
1100            - tokens::component::tooltip::RICH_SUPPORTING_TEXT.line_height)
1101            .max(0.0),
1102        right: 0.0,
1103        bottom: tokens::component::tooltip::RICH_TEXT_BOTTOM_PADDING,
1104        left: 0.0,
1105    }
1106}
1107
1108fn rich_title_style(theme: &Theme) -> iced_widget::text::Style {
1109    iced_widget::text::Style {
1110        color: Some(theme.colors().surface.text_variant),
1111    }
1112}
1113
1114fn rich_supporting_text_style(theme: &Theme) -> iced_widget::text::Style {
1115    iced_widget::text::Style {
1116        color: Some(theme.colors().surface.text_variant),
1117    }
1118}
1119
1120struct PlainTooltipMetrics;
1121
1122impl PlainTooltipMetrics {
1123    fn horizontal_padding() -> f32 {
1124        (tokens::component::tooltip::PLAIN_HORIZONTAL_SPACE
1125            - tokens::component::tooltip::PLAIN_VERTICAL_SPACE)
1126            .max(0.0)
1127    }
1128
1129    fn max_width() -> f32 {
1130        tokens::component::tooltip::PLAIN_MAX_WIDTH
1131            - tokens::component::tooltip::PLAIN_VERTICAL_SPACE * 2.0
1132    }
1133}
1134
1135#[cfg(test)]
1136#[path = "../../../tests/widget/component/tooltip.rs"]
1137mod tests;