Skip to main content

i_slint_compiler/passes/
lower_tooltips.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! Lowers `Tooltip { text: ... }` to an input-transparent popup overlay.
5//!
6//! For each `Tooltip` child, this pass synthesizes a `PopupWindow` anchored at
7//! the pointer position and contains the tooltip content.
8//! Visibility is driven by runtime behavior in `TooltipArea`:
9//! - hover enters: start/restart internal delay timer
10//! - timer fires: invoke `show()` callback
11//! - hover leaves: stop timer and invoke `hide()` callback
12//!   `TooltipArea` also tracks the last known pointer position
13//!   (`mouse-x`/`mouse-y`) for positioning the popup near the cursor.
14//!
15//! Runtime popup handling marks tooltip popups as input-transparent overlays.
16//!
17//! Tooltip content contract:
18//! - A parent element may have **at most one** `Tooltip` child.
19//! - `Tooltip` supports exactly one content mode:
20//!   - text mode: `text` binding is present, no children
21//!   - custom mode: children are present, no `text` binding
22//! - custom mode expects one root child element which is used directly as tooltip content.
23//!
24//! Placement around `for`/`if`:
25//! - `for ... : Tooltip { ... }` is rejected at compile time.
26//! - `if cond : Tooltip { ... }` is allowed; the condition is forwarded onto the synthesized
27//!   `TooltipArea` (which owns the generated `PopupWindow` as its child), so the area only
28//!   exists while `cond` is true.
29
30use crate::diagnostics::{BuildDiagnostics, Spanned};
31use crate::expression_tree::{BindingExpression, BuiltinFunction, Expression, Unit};
32use crate::langtype::{ElementType, EnumerationValue};
33use crate::namedreference::NamedReference;
34use crate::object_tree::*;
35use crate::typeregister::{BUILTIN, TypeRegister};
36use smol_str::{SmolStr, format_smolstr};
37use std::cell::RefCell;
38use std::rc::Rc;
39
40const TOOLTIP_ELEMENT: &str = "Tooltip";
41const TOOLTIP_IMPL_ELEMENT: &str = "ToolTipImpl";
42const TOOLTIP_AREA_ELEMENT: &str = "TooltipArea";
43const POPUP_WINDOW_ELEMENT: &str = "PopupWindow";
44const TOOLTIP_POPUP_ID_PREFIX: &str = "tooltip-popup-overlay-";
45const LAYOUT_ELEMENTS_DISALLOWING_TOOLTIP: &[&str] =
46    &["GridLayout", "VerticalLayout", "HorizontalLayout", "FlexboxLayout"];
47
48const MOUSE_X: &str = "mouse-x";
49const MOUSE_Y: &str = "mouse-y";
50const WIDTH: &str = "width";
51const HEIGHT: &str = "height";
52const OFFSET: &str = "offset";
53const TEXT: &str = "text";
54
55/// Report an error and replace any named reference that points to the tooltip element itself.
56/// References to the tooltip's *children* are caught later by `lower_popups::check_no_reference_to_popup`
57/// once the children have been moved into the generated PopupWindow component.
58fn check_no_reference_to_tooltip(
59    tooltip_element: &ElementRc,
60    parent_element: &ElementRc,
61    component: &Rc<Component>,
62    diag: &mut BuildDiagnostics,
63) {
64    let dummy_ref = NamedReference::new(parent_element, SmolStr::new_static(WIDTH));
65
66    recurse_elem_including_sub_components_no_borrow(component, &(), &mut |source_elem, _| {
67        if Rc::ptr_eq(source_elem, tooltip_element) {
68            return;
69        }
70        visit_all_named_references_in_element(source_elem, |nr| {
71            if !Rc::ptr_eq(&nr.element(), tooltip_element) {
72                return;
73            }
74            let id = tooltip_element.borrow().id.clone();
75            let prop_name = nr.name();
76            let what = if id.is_empty() {
77                format!("property or callback '{prop_name}'")
78            } else {
79                format!("property or callback '{id}.{prop_name}'")
80            };
81            diag.push_error(
82                format!("Cannot access {what} inside of a Tooltip from enclosing component"),
83                &*tooltip_element.borrow(),
84            );
85            *nr = dummy_ref.clone();
86        });
87    });
88}
89
90fn build_tooltip_content(
91    popup_id: &SmolStr,
92    enclosing_component: &std::rc::Weak<Component>,
93    tooltip_impl_type: &ElementType,
94    tooltip_text: Option<NamedReference>,
95    children: Vec<ElementRc>,
96) -> ElementRc {
97    let mut bindings = std::collections::BTreeMap::new();
98    if let Some(tooltip_text) = tooltip_text {
99        bindings.insert(
100            SmolStr::new_static("text"),
101            RefCell::new(Expression::PropertyReference(tooltip_text).into()),
102        );
103    }
104    Element {
105        id: format_smolstr!("{}-content", popup_id),
106        base_type: tooltip_impl_type.clone(),
107        enclosing_component: enclosing_component.clone(),
108        bindings: bindings.into(),
109        children,
110        ..Default::default()
111    }
112    .make_rc()
113}
114
115fn bind_popup_effective_size_from_content(
116    popup_window_rc: &ElementRc,
117    tooltip_content_rc: &ElementRc,
118) {
119    let content_has_width = tooltip_content_rc.borrow().binding(WIDTH).is_some();
120    let content_has_height = tooltip_content_rc.borrow().binding(HEIGHT).is_some();
121
122    if content_has_width {
123        let explicit_width = NamedReference::new(tooltip_content_rc, SmolStr::new_static(WIDTH));
124        let mut width_binding: BindingExpression =
125            Expression::PropertyReference(explicit_width).into();
126        width_binding.priority = 1;
127        popup_window_rc.borrow_mut().set_binding(SmolStr::new_static(WIDTH), width_binding);
128    } else {
129        let preferred_width =
130            NamedReference::new(tooltip_content_rc, SmolStr::new_static("preferred-width"));
131        let mut width_binding: BindingExpression =
132            Expression::PropertyReference(preferred_width).into();
133        width_binding.priority = 1;
134        popup_window_rc.borrow_mut().set_binding(SmolStr::new_static(WIDTH), width_binding);
135    }
136    if content_has_height {
137        let explicit_height = NamedReference::new(tooltip_content_rc, SmolStr::new_static(HEIGHT));
138        let mut height_binding: BindingExpression =
139            Expression::PropertyReference(explicit_height).into();
140        height_binding.priority = 1;
141        popup_window_rc.borrow_mut().set_binding(SmolStr::new_static(HEIGHT), height_binding);
142    } else {
143        let preferred_height =
144            NamedReference::new(tooltip_content_rc, SmolStr::new_static("preferred-height"));
145        let mut height_binding: BindingExpression =
146            Expression::PropertyReference(preferred_height).into();
147        height_binding.priority = 1;
148        popup_window_rc.borrow_mut().set_binding(SmolStr::new_static(HEIGHT), height_binding);
149    }
150}
151
152fn build_tooltip_area(
153    popup_id: &SmolStr,
154    enclosing_component: &std::rc::Weak<Component>,
155    tooltip_area_type: &ElementType,
156    repeated: Option<RepeatedElementInfo>,
157) -> ElementRc {
158    let mut elem = Element {
159        id: format_smolstr!("{}-area", popup_id),
160        base_type: tooltip_area_type.clone(),
161        enclosing_component: enclosing_component.clone(),
162        bindings: [
163            (
164                SmolStr::new_static("x"),
165                RefCell::new(Expression::NumberLiteral(0., Unit::Percent).into()),
166            ),
167            (
168                SmolStr::new_static("y"),
169                RefCell::new(Expression::NumberLiteral(0., Unit::Percent).into()),
170            ),
171            (
172                SmolStr::new_static(WIDTH),
173                RefCell::new(Expression::NumberLiteral(100., Unit::Percent).into()),
174            ),
175            (
176                SmolStr::new_static(HEIGHT),
177                RefCell::new(Expression::NumberLiteral(100., Unit::Percent).into()),
178            ),
179        ]
180        .into_iter()
181        .collect(),
182        repeated,
183        ..Default::default()
184    };
185    // `Element::from_node` runs `apply_default_type_properties` on user-written elements;
186    // synthesized elements need the same treatment so the builtin defaults (`delay`,
187    // `offset`) declared on `TooltipArea` reach the runtime.
188    crate::object_tree::apply_default_type_properties(&mut elem);
189    elem.make_rc()
190}
191
192fn wire_tooltip_placement(
193    popup_window_rc: &ElementRc,
194    pointer_x: NamedReference,
195    pointer_y: NamedReference,
196    tooltip_offset: NamedReference,
197) {
198    let tooltip_offset_expr = Expression::PropertyReference(tooltip_offset);
199    let x_pointer = Expression::PropertyReference(pointer_x);
200    let y_pointer = Expression::BinaryExpression {
201        lhs: Box::new(Expression::PropertyReference(pointer_y)),
202        rhs: Box::new(tooltip_offset_expr),
203        op: '+',
204        source_location: None,
205    };
206
207    let mut x_binding: BindingExpression = x_pointer.into();
208    x_binding.priority = 1;
209    popup_window_rc.borrow_mut().set_binding(SmolStr::new_static("x"), x_binding);
210
211    let mut y_binding: BindingExpression = y_pointer.into();
212    y_binding.priority = 1;
213    popup_window_rc.borrow_mut().set_binding(SmolStr::new_static("y"), y_binding);
214}
215
216fn wire_tooltip_visibility_behavior(
217    elem: &ElementRc,
218    tooltip_child_index: usize,
219    tooltip_area: &ElementRc,
220    popup_window_rc: ElementRc,
221) {
222    let popup_weak = Rc::downgrade(&popup_window_rc);
223    let show_popup = Expression::FunctionCall {
224        function: BuiltinFunction::ShowPopupWindow.into(),
225        arguments: vec![Expression::ElementReference(popup_weak.clone())],
226        source_location: None,
227    };
228    let close_popup = Expression::FunctionCall {
229        function: BuiltinFunction::ClosePopupWindow.into(),
230        arguments: vec![Expression::ElementReference(popup_weak)],
231        source_location: None,
232    };
233
234    tooltip_area
235        .borrow_mut()
236        .set_binding(SmolStr::new_static("show"), Expression::CodeBlock(vec![show_popup]).into());
237    tooltip_area
238        .borrow_mut()
239        .set_binding(SmolStr::new_static("hide"), Expression::CodeBlock(vec![close_popup]).into());
240
241    // Make the PopupWindow a child of the TooltipArea so that the popup's bindings
242    // (`x`/`y` referring to `TooltipArea.mouse-x`/`mouse-y`) and the conditional
243    // gating (`repeated` on `TooltipArea`) stay in the same scope.
244    tooltip_area.borrow_mut().children.push(popup_window_rc);
245    elem.borrow_mut().children.insert(tooltip_child_index, tooltip_area.clone());
246}
247
248fn lower_tooltips_in_component(
249    component: &Rc<Component>,
250    type_register: &TypeRegister,
251    tooltip_impl_type: &ElementType,
252    diag: &mut BuildDiagnostics,
253) {
254    let tooltip_type = type_register.lookup_builtin_element(TOOLTIP_ELEMENT).unwrap();
255    let tooltip_area_type = type_register.lookup_builtin_element(TOOLTIP_AREA_ELEMENT).unwrap();
256    let popup_window_type = type_register.lookup_builtin_element(POPUP_WINDOW_ELEMENT).unwrap();
257
258    let popup_close_policy_enum = BUILTIN.enums.PopupClosePolicy.clone();
259    let popup_close_policy_no_auto_close = EnumerationValue {
260        value: popup_close_policy_enum.values.iter().position(|v| v == "no-auto-close").unwrap(),
261        enumeration: popup_close_policy_enum,
262    };
263
264    let mut tooltip_popup_id_counter: u32 = 0;
265    recurse_elem_including_sub_components_no_borrow(component, &(), &mut |elem, _| {
266        // Traversal also visits generated children; skip tooltip popups created by this pass.
267        let is_generated_tooltip_popup = {
268            let elem_borrow = elem.borrow();
269            matches!(&elem_borrow.base_type, t if *t == popup_window_type) && elem_borrow.is_tooltip
270        };
271        if is_generated_tooltip_popup {
272            return;
273        }
274
275        let is_tooltip_like =
276            matches!(&elem.borrow().builtin_type(), Some(b) if b.name == TOOLTIP_ELEMENT);
277        let is_direct_tooltip = matches!(&elem.borrow().base_type, t if *t == tooltip_type);
278        if is_tooltip_like && !is_direct_tooltip {
279            diag.push_error("Tooltip cannot be inherited".into(), &*elem.borrow());
280            return;
281        }
282
283        let tooltip_indices: Vec<usize> = elem
284            .borrow()
285            .children
286            .iter()
287            .enumerate()
288            .filter_map(|(idx, child)| {
289                matches!(&child.borrow().base_type, t if *t == tooltip_type).then_some(idx)
290            })
291            .collect();
292        if tooltip_indices.is_empty() {
293            return;
294        }
295        if tooltip_indices.len() > 1 {
296            let children = elem.borrow().children.clone();
297            for idx in tooltip_indices.iter().skip(1) {
298                let child = &children[*idx];
299                diag.push_error(
300                    "Only one Tooltip is allowed as a child of an element".into(),
301                    &*child.borrow(),
302                );
303            }
304            return;
305        }
306        let tooltip_child_index = tooltip_indices[0];
307
308        let tooltip_candidate = elem.borrow().children[tooltip_child_index].clone();
309        // `if cond : Tooltip { ... }` is allowed (the wrapper switches the synthesized
310        // TooltipArea + PopupWindow on/off with the condition); `for ... : Tooltip { ... }`
311        // is not.
312        let tooltip_repeated = tooltip_candidate.borrow_mut().repeated.take();
313        if tooltip_repeated.as_ref().is_some_and(|r| !r.is_conditional_element) {
314            diag.push_error(
315                "Tooltip cannot be in a `for` element".into(),
316                &*tooltip_candidate.borrow(),
317            );
318            return;
319        }
320        let parent_name = elem.borrow().builtin_type().map(|b| b.name.clone());
321        if parent_name
322            .as_ref()
323            .is_some_and(|name| LAYOUT_ELEMENTS_DISALLOWING_TOOLTIP.contains(&name.as_str()))
324        {
325            diag.push_error(
326                format!("Tooltip cannot be added to {}", parent_name.as_ref().unwrap()),
327                &*tooltip_candidate.borrow(),
328            );
329            return;
330        }
331        if elem.borrow().builtin_type().is_some_and(|builtin| {
332            builtin.is_non_item_type || builtin.disallow_global_types_as_child_elements
333        }) {
334            diag.push_error(
335                format!("Tooltip cannot be added to {}", parent_name.as_ref().unwrap()),
336                &*tooltip_candidate.borrow(),
337            );
338            return;
339        }
340
341        let has_custom_content = !tooltip_candidate.borrow().children.is_empty();
342        let has_text_binding = tooltip_candidate.borrow().binding("text").is_some();
343        if has_custom_content && has_text_binding {
344            diag.push_error(
345                "Tooltip cannot have both text and custom content".into(),
346                &*tooltip_candidate.borrow(),
347            );
348            return;
349        }
350        if !has_custom_content && !has_text_binding {
351            diag.push_error(
352                "Tooltip must provide either text or custom content".into(),
353                &*tooltip_candidate.borrow(),
354            );
355            return;
356        }
357        if has_custom_content && tooltip_candidate.borrow().children.len() > 1 {
358            diag.push_error(
359                "Tooltip custom content must have exactly one root child element".into(),
360                &*tooltip_candidate.borrow(),
361            );
362            return;
363        }
364
365        check_no_reference_to_tooltip(&tooltip_candidate, elem, component, diag);
366
367        let (tooltip_config, enclosing_component, popup_id, custom_children) = {
368            let mut elem_borrow = elem.borrow_mut();
369            let tooltip_config = elem_borrow.children.remove(tooltip_child_index);
370            let custom_children = if has_custom_content {
371                std::mem::take(&mut tooltip_config.borrow_mut().children)
372            } else {
373                Vec::new()
374            };
375            let enclosing_component = elem_borrow.enclosing_component.clone();
376            let popup_id =
377                format_smolstr!("{}{}", TOOLTIP_POPUP_ID_PREFIX, tooltip_popup_id_counter);
378            tooltip_popup_id_counter += 1;
379            (tooltip_config, enclosing_component, popup_id, custom_children)
380        };
381
382        let tooltip_area = build_tooltip_area(
383            &popup_id,
384            &enclosing_component,
385            &tooltip_area_type,
386            tooltip_repeated,
387        );
388        // Propagate a user-set binding from the `Tooltip` element onto the synthesized
389        // `TooltipArea`, keyed by the same property name. Currently only `text` is shared,
390        // but the helper is kept so adding more shared properties later is a one-liner.
391        let copy_binding = |property: &str| {
392            if let Some(binding) = tooltip_config.borrow().binding(property) {
393                tooltip_area.borrow_mut().set_binding(SmolStr::new(property), binding.clone());
394            }
395        };
396        if has_text_binding {
397            copy_binding(TEXT);
398        }
399
400        let tooltip_offset = NamedReference::new(&tooltip_area, SmolStr::new_static(OFFSET));
401        let pointer_x = NamedReference::new(&tooltip_area, SmolStr::new_static(MOUSE_X));
402        let pointer_y = NamedReference::new(&tooltip_area, SmolStr::new_static(MOUSE_Y));
403        let tooltip_text = (!has_custom_content)
404            .then(|| NamedReference::new(&tooltip_area, SmolStr::new_static(TEXT)));
405        let tooltip_content = build_tooltip_content(
406            &popup_id,
407            &enclosing_component,
408            tooltip_impl_type,
409            tooltip_text,
410            custom_children,
411        );
412        let popup_children = vec![tooltip_content.clone()];
413
414        let popup_window = Element {
415            id: popup_id,
416            base_type: popup_window_type.clone(),
417            enclosing_component: enclosing_component.clone(),
418            is_tooltip: true,
419            children: popup_children,
420            bindings: [(
421                SmolStr::new_static("close-policy"),
422                RefCell::new(
423                    Expression::EnumerationValue(popup_close_policy_no_auto_close.clone()).into(),
424                ),
425            )]
426            .into_iter()
427            .collect(),
428            // Carry the Tooltip's source location so diagnostics from
429            // lower_popups point back to the original Tooltip element.
430            debug: tooltip_config.borrow().debug.clone(),
431            ..Default::default()
432        };
433        let popup_window_rc = popup_window.make_rc();
434        bind_popup_effective_size_from_content(&popup_window_rc, &tooltip_content);
435        wire_tooltip_placement(&popup_window_rc, pointer_x, pointer_y, tooltip_offset);
436
437        wire_tooltip_visibility_behavior(elem, tooltip_child_index, &tooltip_area, popup_window_rc);
438    });
439}
440
441pub async fn lower_tooltips(
442    doc: &Document,
443    type_loader: &mut crate::typeloader::TypeLoader,
444    diag: &mut BuildDiagnostics,
445) {
446    let mut has_tooltip = false;
447    doc.visit_all_used_components(|component| {
448        recurse_elem_including_sub_components_no_borrow(component, &(), &mut |elem, _| {
449            if matches!(&elem.borrow().builtin_type(), Some(b) if b.name == TOOLTIP_ELEMENT) {
450                has_tooltip = true;
451            }
452        })
453    });
454
455    if !has_tooltip {
456        return;
457    }
458
459    let mut import_diag = BuildDiagnostics::default();
460    let tooltip_component = type_loader
461        .import_component("std-widgets-impl.slint", TOOLTIP_IMPL_ELEMENT, &mut import_diag)
462        .await;
463    for diagnostic in import_diag {
464        diag.push_compiler_error(diagnostic);
465    }
466    let Some(tooltip_component) = tooltip_component else {
467        let generic_location = doc.node.as_ref().map(|n| n.to_source_location());
468        diag.push_error(
469            "`Tooltip` style implementation could not be loaded from std-widgets".into(),
470            &generic_location,
471        );
472        return;
473    };
474    let tooltip_style_type = ElementType::Component(tooltip_component);
475
476    doc.visit_all_used_components(|component| {
477        lower_tooltips_in_component(component, &doc.local_registry, &tooltip_style_type, diag);
478    });
479}