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,
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().bindings.contains_key(WIDTH);
120    let content_has_height = tooltip_content_rc.borrow().bindings.contains_key(HEIGHT);
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
128            .borrow_mut()
129            .bindings
130            .insert(SmolStr::new_static(WIDTH), RefCell::new(width_binding));
131    } else {
132        let preferred_width =
133            NamedReference::new(tooltip_content_rc, SmolStr::new_static("preferred-width"));
134        let mut width_binding: BindingExpression =
135            Expression::PropertyReference(preferred_width).into();
136        width_binding.priority = 1;
137        popup_window_rc
138            .borrow_mut()
139            .bindings
140            .insert(SmolStr::new_static(WIDTH), RefCell::new(width_binding));
141    }
142    if content_has_height {
143        let explicit_height = NamedReference::new(tooltip_content_rc, SmolStr::new_static(HEIGHT));
144        let mut height_binding: BindingExpression =
145            Expression::PropertyReference(explicit_height).into();
146        height_binding.priority = 1;
147        popup_window_rc
148            .borrow_mut()
149            .bindings
150            .insert(SmolStr::new_static(HEIGHT), RefCell::new(height_binding));
151    } else {
152        let preferred_height =
153            NamedReference::new(tooltip_content_rc, SmolStr::new_static("preferred-height"));
154        let mut height_binding: BindingExpression =
155            Expression::PropertyReference(preferred_height).into();
156        height_binding.priority = 1;
157        popup_window_rc
158            .borrow_mut()
159            .bindings
160            .insert(SmolStr::new_static(HEIGHT), RefCell::new(height_binding));
161    }
162}
163
164fn build_tooltip_area(
165    popup_id: &SmolStr,
166    enclosing_component: &std::rc::Weak<Component>,
167    tooltip_area_type: &ElementType,
168    repeated: Option<RepeatedElementInfo>,
169) -> ElementRc {
170    let mut elem = Element {
171        id: format_smolstr!("{}-area", popup_id),
172        base_type: tooltip_area_type.clone(),
173        enclosing_component: enclosing_component.clone(),
174        bindings: [
175            (
176                SmolStr::new_static("x"),
177                RefCell::new(Expression::NumberLiteral(0., Unit::Percent).into()),
178            ),
179            (
180                SmolStr::new_static("y"),
181                RefCell::new(Expression::NumberLiteral(0., Unit::Percent).into()),
182            ),
183            (
184                SmolStr::new_static(WIDTH),
185                RefCell::new(Expression::NumberLiteral(100., Unit::Percent).into()),
186            ),
187            (
188                SmolStr::new_static(HEIGHT),
189                RefCell::new(Expression::NumberLiteral(100., Unit::Percent).into()),
190            ),
191        ]
192        .into_iter()
193        .collect(),
194        repeated,
195        ..Default::default()
196    };
197    // `Element::from_node` runs `apply_default_type_properties` on user-written elements;
198    // synthesized elements need the same treatment so the builtin defaults (`delay`,
199    // `offset`) declared on `TooltipArea` reach the runtime.
200    crate::object_tree::apply_default_type_properties(&mut elem);
201    elem.make_rc()
202}
203
204fn wire_tooltip_placement(
205    popup_window_rc: &ElementRc,
206    pointer_x: NamedReference,
207    pointer_y: NamedReference,
208    tooltip_offset: NamedReference,
209) {
210    let tooltip_offset_expr = Expression::PropertyReference(tooltip_offset);
211    let x_pointer = Expression::PropertyReference(pointer_x);
212    let y_pointer = Expression::BinaryExpression {
213        lhs: Box::new(Expression::PropertyReference(pointer_y)),
214        rhs: Box::new(tooltip_offset_expr),
215        op: '+',
216    };
217
218    let mut x_binding: BindingExpression = x_pointer.into();
219    x_binding.priority = 1;
220    popup_window_rc.borrow_mut().bindings.insert(SmolStr::new_static("x"), RefCell::new(x_binding));
221
222    let mut y_binding: BindingExpression = y_pointer.into();
223    y_binding.priority = 1;
224    popup_window_rc.borrow_mut().bindings.insert(SmolStr::new_static("y"), RefCell::new(y_binding));
225}
226
227fn wire_tooltip_visibility_behavior(
228    elem: &ElementRc,
229    tooltip_child_index: usize,
230    tooltip_area: &ElementRc,
231    popup_window_rc: ElementRc,
232) {
233    let popup_weak = Rc::downgrade(&popup_window_rc);
234    let show_popup = Expression::FunctionCall {
235        function: BuiltinFunction::ShowPopupWindow.into(),
236        arguments: vec![Expression::ElementReference(popup_weak.clone())],
237        source_location: None,
238    };
239    let close_popup = Expression::FunctionCall {
240        function: BuiltinFunction::ClosePopupWindow.into(),
241        arguments: vec![Expression::ElementReference(popup_weak)],
242        source_location: None,
243    };
244
245    tooltip_area.borrow_mut().bindings.insert(
246        SmolStr::new_static("show"),
247        RefCell::new(Expression::CodeBlock(vec![show_popup]).into()),
248    );
249    tooltip_area.borrow_mut().bindings.insert(
250        SmolStr::new_static("hide"),
251        RefCell::new(Expression::CodeBlock(vec![close_popup]).into()),
252    );
253
254    // Make the PopupWindow a child of the TooltipArea so that the popup's bindings
255    // (`x`/`y` referring to `TooltipArea.mouse-x`/`mouse-y`) and the conditional
256    // gating (`repeated` on `TooltipArea`) stay in the same scope.
257    tooltip_area.borrow_mut().children.push(popup_window_rc);
258    elem.borrow_mut().children.insert(tooltip_child_index, tooltip_area.clone());
259}
260
261fn lower_tooltips_in_component(
262    component: &Rc<Component>,
263    type_register: &TypeRegister,
264    tooltip_impl_type: &ElementType,
265    diag: &mut BuildDiagnostics,
266) {
267    let tooltip_type = type_register.lookup_builtin_element(TOOLTIP_ELEMENT).unwrap();
268    let tooltip_area_type = type_register.lookup_builtin_element(TOOLTIP_AREA_ELEMENT).unwrap();
269    let popup_window_type = type_register.lookup_builtin_element(POPUP_WINDOW_ELEMENT).unwrap();
270
271    let popup_close_policy_enum = BUILTIN.with(|e| e.enums.PopupClosePolicy.clone());
272    let popup_close_policy_no_auto_close = EnumerationValue {
273        value: popup_close_policy_enum.values.iter().position(|v| v == "no-auto-close").unwrap(),
274        enumeration: popup_close_policy_enum,
275    };
276
277    let mut tooltip_popup_id_counter: u32 = 0;
278    recurse_elem_including_sub_components_no_borrow(component, &(), &mut |elem, _| {
279        // Traversal also visits generated children; skip tooltip popups created by this pass.
280        let is_generated_tooltip_popup = {
281            let elem_borrow = elem.borrow();
282            matches!(&elem_borrow.base_type, t if *t == popup_window_type) && elem_borrow.is_tooltip
283        };
284        if is_generated_tooltip_popup {
285            return;
286        }
287
288        let is_tooltip_like =
289            matches!(&elem.borrow().builtin_type(), Some(b) if b.name == TOOLTIP_ELEMENT);
290        let is_direct_tooltip = matches!(&elem.borrow().base_type, t if *t == tooltip_type);
291        if is_tooltip_like && !is_direct_tooltip {
292            diag.push_error("Tooltip cannot be inherited".into(), &*elem.borrow());
293            return;
294        }
295
296        let tooltip_indices: Vec<usize> = elem
297            .borrow()
298            .children
299            .iter()
300            .enumerate()
301            .filter_map(|(idx, child)| {
302                matches!(&child.borrow().base_type, t if *t == tooltip_type).then_some(idx)
303            })
304            .collect();
305        if tooltip_indices.is_empty() {
306            return;
307        }
308        if tooltip_indices.len() > 1 {
309            let children = elem.borrow().children.clone();
310            for idx in tooltip_indices.iter().skip(1) {
311                let child = &children[*idx];
312                diag.push_error(
313                    "Only one Tooltip is allowed as a child of an element".into(),
314                    &*child.borrow(),
315                );
316            }
317            return;
318        }
319        let tooltip_child_index = tooltip_indices[0];
320
321        let tooltip_candidate = elem.borrow().children[tooltip_child_index].clone();
322        // `if cond : Tooltip { ... }` is allowed (the wrapper switches the synthesized
323        // TooltipArea + PopupWindow on/off with the condition); `for ... : Tooltip { ... }`
324        // is not.
325        let tooltip_repeated = tooltip_candidate.borrow_mut().repeated.take();
326        if tooltip_repeated.as_ref().is_some_and(|r| !r.is_conditional_element) {
327            diag.push_error(
328                "Tooltip cannot be in a `for` element".into(),
329                &*tooltip_candidate.borrow(),
330            );
331            return;
332        }
333        let parent_name = elem.borrow().builtin_type().map(|b| b.name.clone());
334        if parent_name
335            .as_ref()
336            .is_some_and(|name| LAYOUT_ELEMENTS_DISALLOWING_TOOLTIP.contains(&name.as_str()))
337        {
338            diag.push_error(
339                format!("Tooltip cannot be added to {}", parent_name.as_ref().unwrap()),
340                &*tooltip_candidate.borrow(),
341            );
342            return;
343        }
344        if elem.borrow().builtin_type().is_some_and(|builtin| {
345            builtin.is_non_item_type || builtin.disallow_global_types_as_child_elements
346        }) {
347            diag.push_error(
348                format!("Tooltip cannot be added to {}", parent_name.as_ref().unwrap()),
349                &*tooltip_candidate.borrow(),
350            );
351            return;
352        }
353
354        let has_custom_content = !tooltip_candidate.borrow().children.is_empty();
355        let has_text_binding = tooltip_candidate.borrow().bindings.contains_key("text");
356        if has_custom_content && has_text_binding {
357            diag.push_error(
358                "Tooltip cannot have both text and custom content".into(),
359                &*tooltip_candidate.borrow(),
360            );
361            return;
362        }
363        if !has_custom_content && !has_text_binding {
364            diag.push_error(
365                "Tooltip must provide either text or custom content".into(),
366                &*tooltip_candidate.borrow(),
367            );
368            return;
369        }
370        if has_custom_content && tooltip_candidate.borrow().children.len() > 1 {
371            diag.push_error(
372                "Tooltip custom content must have exactly one root child element".into(),
373                &*tooltip_candidate.borrow(),
374            );
375            return;
376        }
377
378        check_no_reference_to_tooltip(&tooltip_candidate, elem, component, diag);
379
380        let (tooltip_config, enclosing_component, popup_id, custom_children) = {
381            let mut elem_borrow = elem.borrow_mut();
382            let tooltip_config = elem_borrow.children.remove(tooltip_child_index);
383            let custom_children = if has_custom_content {
384                std::mem::take(&mut tooltip_config.borrow_mut().children)
385            } else {
386                Vec::new()
387            };
388            let enclosing_component = elem_borrow.enclosing_component.clone();
389            let popup_id =
390                format_smolstr!("{}{}", TOOLTIP_POPUP_ID_PREFIX, tooltip_popup_id_counter);
391            tooltip_popup_id_counter += 1;
392            (tooltip_config, enclosing_component, popup_id, custom_children)
393        };
394
395        let tooltip_area = build_tooltip_area(
396            &popup_id,
397            &enclosing_component,
398            &tooltip_area_type,
399            tooltip_repeated,
400        );
401        // Propagate a user-set binding from the `Tooltip` element onto the synthesized
402        // `TooltipArea`, keyed by the same property name. Currently only `text` is shared,
403        // but the helper is kept so adding more shared properties later is a one-liner.
404        let copy_binding = |property: &str| {
405            if let Some(binding) = tooltip_config.borrow().bindings.get(property) {
406                tooltip_area
407                    .borrow_mut()
408                    .bindings
409                    .insert(SmolStr::new(property), RefCell::new(binding.borrow().clone()));
410            }
411        };
412        if has_text_binding {
413            copy_binding(TEXT);
414        }
415
416        let tooltip_offset = NamedReference::new(&tooltip_area, SmolStr::new_static(OFFSET));
417        let pointer_x = NamedReference::new(&tooltip_area, SmolStr::new_static(MOUSE_X));
418        let pointer_y = NamedReference::new(&tooltip_area, SmolStr::new_static(MOUSE_Y));
419        let tooltip_text = (!has_custom_content)
420            .then(|| NamedReference::new(&tooltip_area, SmolStr::new_static(TEXT)));
421        let tooltip_content = build_tooltip_content(
422            &popup_id,
423            &enclosing_component,
424            tooltip_impl_type,
425            tooltip_text,
426            custom_children,
427        );
428        let popup_children = vec![tooltip_content.clone()];
429
430        let popup_window = Element {
431            id: popup_id,
432            base_type: popup_window_type.clone(),
433            enclosing_component: enclosing_component.clone(),
434            is_tooltip: true,
435            children: popup_children,
436            bindings: [(
437                SmolStr::new_static("close-policy"),
438                RefCell::new(
439                    Expression::EnumerationValue(popup_close_policy_no_auto_close.clone()).into(),
440                ),
441            )]
442            .into_iter()
443            .collect(),
444            // Carry the Tooltip's source location so diagnostics from
445            // lower_popups point back to the original Tooltip element.
446            debug: tooltip_config.borrow().debug.clone(),
447            ..Default::default()
448        };
449        let popup_window_rc = popup_window.make_rc();
450        bind_popup_effective_size_from_content(&popup_window_rc, &tooltip_content);
451        wire_tooltip_placement(&popup_window_rc, pointer_x, pointer_y, tooltip_offset);
452
453        wire_tooltip_visibility_behavior(elem, tooltip_child_index, &tooltip_area, popup_window_rc);
454    });
455}
456
457pub async fn lower_tooltips(
458    doc: &Document,
459    type_loader: &mut crate::typeloader::TypeLoader,
460    diag: &mut BuildDiagnostics,
461) {
462    let mut has_tooltip = false;
463    doc.visit_all_used_components(|component| {
464        recurse_elem_including_sub_components_no_borrow(component, &(), &mut |elem, _| {
465            if matches!(&elem.borrow().builtin_type(), Some(b) if b.name == TOOLTIP_ELEMENT) {
466                has_tooltip = true;
467            }
468        })
469    });
470
471    if !has_tooltip {
472        return;
473    }
474
475    let mut import_diag = BuildDiagnostics::default();
476    let tooltip_component = type_loader
477        .import_component("std-widgets-impl.slint", TOOLTIP_IMPL_ELEMENT, &mut import_diag)
478        .await;
479    for diagnostic in import_diag {
480        diag.push_compiler_error(diagnostic);
481    }
482    let Some(tooltip_component) = tooltip_component else {
483        let generic_location = doc.node.as_ref().map(|n| n.to_source_location());
484        diag.push_error(
485            "`Tooltip` style implementation could not be loaded from std-widgets".into(),
486            &generic_location,
487        );
488        return;
489    };
490    let tooltip_style_type = ElementType::Component(tooltip_component);
491
492    doc.visit_all_used_components(|component| {
493        lower_tooltips_in_component(component, &doc.local_registry, &tooltip_style_type, diag);
494    });
495}