Skip to main content

slint_interpreter/
highlight.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//! Highlight support for running component instances.
5//!
6//! Walks the LLR `debug_info` side table to map either a source location
7//! or an object-tree `ElementRc` back to runtime flat item indices, then
8//! reads geometries via `ItemRc::geometry()` and transforms them through
9//! `map_to_item_tree`.
10
11use crate::instance::{Instance, SubComponentInstance};
12use i_slint_compiler::llr::{ItemInstanceIdx, SubComponentIdx, SubComponentInstanceIdx};
13use i_slint_compiler::object_tree::ElementRc;
14use i_slint_core::graphics::euclid;
15use i_slint_core::item_tree::ItemTreeVTable;
16use i_slint_core::items::ItemRc;
17use i_slint_core::lengths::{LogicalPoint, LogicalRect};
18use std::path::Path;
19use std::pin::Pin;
20use std::rc::Rc;
21use vtable::VRc;
22
23/// The rectangle of an element, which may be rotated around its center.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct HighlightedRect {
26    /// The element's geometry.
27    pub rect: LogicalRect,
28    /// In degrees, around the center of the element.
29    pub angle: f32,
30    /// Absolute origin of this instance's parent coordinate system (in root coordinates).
31    ///
32    /// `rect.origin - parent_origin` yields the element's position relative to its parent,
33    /// which matches the `x`/`y` properties written to the source. This is computed from the
34    /// instance's own ancestors, so it stays correct even if the element is positioned outside
35    /// of (or with a negative offset relative to) its parent.
36    ///
37    /// Both values are in root coordinates, so the subtraction only recovers the source `x`/`y`
38    /// while the parent frame is axis-aligned and unscaled — recovering it under a rotated or
39    /// scaled ancestor would additionally need to map the delta through the inverse ancestor
40    /// transform.
41    pub parent_origin: LogicalPoint,
42    /// Absolute rotation (in degrees) of this instance's parent coordinate system.
43    ///
44    /// `angle - parent_rotation` yields the element's own rotation relative to its parent, which
45    /// matches the `rotation-angle`/`transform-rotation` property written to the source.
46    pub parent_rotation: f32,
47}
48impl HighlightedRect {
49    /// Returns true if `position` lies inside the (potentially rotated) rectangle.
50    pub fn contains(&self, position: LogicalPoint) -> bool {
51        let center = self.rect.center();
52        let rotation = euclid::Rotation2D::radians((-self.angle).to_radians());
53        let transformed = center + rotation.transform_vector(position - center);
54        self.rect.contains(transformed)
55    }
56}
57
58/// Argument to filter the elements returned by the highlight helpers.
59#[derive(Copy, Clone, Eq, PartialEq)]
60pub enum ElementPositionFilter {
61    /// Include all elements.
62    IncludeClipped,
63    /// Exclude elements clipped by an ancestor `Clip` / `Flickable`.
64    ExcludeClipped,
65}
66
67/// Return the screen rectangles of every runtime item matching the
68/// given `ElementRc`, optionally filtering out those clipped by an
69/// ancestor. Public for downstream tooling such as the LSP element
70/// selection, whose hit-testing needs the `ExcludeClipped` filter.
71pub fn element_positions(
72    instance: &VRc<ItemTreeVTable, Instance>,
73    element: &ElementRc,
74    filter: ElementPositionFilter,
75) -> Vec<HighlightedRect> {
76    // Match by source location: the LLR copies the element's
77    // `source_location` onto every item it lowers, and the object-tree
78    // element keeps the original node. `element_hash` would be more
79    // compact, but passes that run after `inject_debug_hooks` (layout
80    // lowering, property hoisting) create elements without a hash.
81    let target = walk_to_native_root(element);
82    let Some(target_loc) = source_location_of(&target) else {
83        return Vec::new();
84    };
85    // A component use (`Button { }`) resolves to the definition's root
86    // element, whose location matches every instantiation of the component.
87    // Constrain the matches to item-table paths that descend through this
88    // specific use site.
89    let use_site = if Rc::ptr_eq(&target, element) { None } else { source_location_of(element) };
90    positions_by_source(
91        instance,
92        &target_loc.0,
93        target_loc.1,
94        use_site.as_ref().map(|(p, o)| (p.as_path(), *o)),
95        filter,
96    )
97}
98
99/// The `(path, offset)` key under which the LLR debug info records
100/// `element` — `Spanned::to_source_location` semantics (the qualified
101/// name's start).
102fn source_location_of(element: &ElementRc) -> Option<(std::path::PathBuf, u32)> {
103    use i_slint_compiler::diagnostics::Spanned;
104    let e = element.borrow();
105    let path = e.source_file()?.path().to_path_buf();
106    Some((path, e.span().offset as u32))
107}
108
109/// Descend into `base_type = Component(_)` wrappers until the element
110/// has its own native item. For a component use like `Button { }`, the
111/// runtime items belong to the wrapped component's root element, not to
112/// the use-site element itself.
113fn walk_to_native_root(element: &ElementRc) -> ElementRc {
114    let mut current = element.clone();
115    loop {
116        let next = {
117            let b = current.borrow();
118            if let i_slint_compiler::langtype::ElementType::Component(c) = &b.base_type {
119                Some(c.root_element.clone())
120            } else {
121                None
122            }
123        };
124        match next {
125            Some(n) => current = n,
126            None => return current,
127        }
128    }
129}
130
131/// Return the geometry of every runtime item whose source location covers
132/// the given `(path, offset)` pair.
133pub(crate) fn component_positions(
134    instance: &VRc<ItemTreeVTable, Instance>,
135    path: &Path,
136    offset: u32,
137) -> Vec<HighlightedRect> {
138    element_node_at_source_code_position(instance, path, offset)
139        .into_iter()
140        .flat_map(|(element, _)| {
141            element_positions(instance, &element, ElementPositionFilter::IncludeClipped)
142        })
143        .collect()
144}
145
146/// Look up the `(ElementRc, index)` tuples whose `debug` entries cover
147/// the given source offset. Uses the `TypeLoader` stored on the instance
148/// (if available) to walk the original object-tree `Document`.
149pub(crate) fn element_node_at_source_code_position(
150    instance: &VRc<ItemTreeVTable, Instance>,
151    path: &Path,
152    offset: u32,
153) -> Vec<(ElementRc, usize)> {
154    let Some(type_loader) = instance.type_loaders.type_loader.as_ref() else {
155        return Vec::new();
156    };
157    let Some(doc) = type_loader.get_document(path) else {
158        return Vec::new();
159    };
160    let mut result = Vec::new();
161    // `inner_components` lists every component defined in the file,
162    // exported or not.
163    for component in &doc.inner_components {
164        visit_element_for_position(&component.root_element, path, offset, &mut result);
165    }
166    result
167}
168
169fn visit_element_for_position(
170    element: &ElementRc,
171    path: &Path,
172    offset: u32,
173    result: &mut Vec<(ElementRc, usize)>,
174) {
175    if element.borrow().repeated.is_some() {
176        // The children of a repeated element live in the component the
177        // repeater pass wrapped around it, which is not part of
178        // `inner_components` — descend explicitly. The wrapper's root
179        // element carries the same source node as the repeated element.
180        let base = match &element.borrow().base_type {
181            i_slint_compiler::langtype::ElementType::Component(c) => Some(c.root_element.clone()),
182            _ => None,
183        };
184        if let Some(root) = base {
185            visit_element_for_position(&root, path, offset, result);
186        }
187        return;
188    }
189    for (index, node_path, node_range) in element.borrow().debug.iter().enumerate().map(|(i, n)| {
190        let text_range = n
191            .node
192            .QualifiedName()
193            .map(|n| n.text_range())
194            .or_else(|| {
195                n.node
196                    .child_token(i_slint_compiler::parser::SyntaxKind::LBrace)
197                    .map(|n| n.text_range())
198            })
199            .expect("An Element must contain a LBrace somewhere");
200        (i, n.node.source_file.path(), text_range)
201    }) {
202        if node_path == path && node_range.contains(offset.into()) {
203            result.push((element.clone(), index));
204        }
205    }
206    let children = element.borrow().children.clone();
207    for child in &children {
208        visit_element_for_position(child, path, offset, result);
209    }
210}
211
212/// Scan the instance's flat `item_table` and return every flat index
213/// whose entry points at `(sub_component_path → target_sc_idx, target_local)`.
214/// With `use_site` set, only paths descending through a sub-component
215/// instance whose use-site element sits at that `(path, offset)` match.
216fn find_flat_indices_for_item(
217    instance: &VRc<ItemTreeVTable, Instance>,
218    target_sc_idx: SubComponentIdx,
219    target_local: ItemInstanceIdx,
220    use_site: Option<(&Path, u32)>,
221) -> Vec<usize> {
222    let cu = &instance.root_sub_component.compilation_unit;
223    let root_ty = instance.root_sub_component.sub_component_idx;
224    let mut out = Vec::new();
225    for (flat, entry) in instance.item_table.iter().enumerate() {
226        let Some((path, local_idx)) = entry.as_ref() else { continue };
227        if *local_idx != target_local {
228            continue;
229        }
230        if sub_component_idx_at_path(cu, root_ty, path) != target_sc_idx {
231            continue;
232        }
233        if let Some((us_path, us_offset)) = use_site
234            && !path_passes_use_site(cu, root_ty, path, us_path, us_offset)
235        {
236            continue;
237        }
238        out.push(flat);
239    }
240    out
241}
242
243/// Whether any step of `path` descends through a sub-component instance
244/// whose use-site element is recorded at `(us_path, us_offset)`.
245fn path_passes_use_site(
246    cu: &i_slint_compiler::llr::CompilationUnit,
247    mut current: SubComponentIdx,
248    path: &[SubComponentInstanceIdx],
249    us_path: &Path,
250    us_offset: u32,
251) -> bool {
252    for &instance_idx in path {
253        if let Some(debug) = cu.sub_components[current].debug_info.as_ref()
254            && let Some(loc) = debug.sub_component_use_sites.get(instance_idx)
255            && loc.source_file.as_ref().is_some_and(|f| f.path() == us_path)
256            && loc.span.offset as u32 == us_offset
257        {
258            return true;
259        }
260        current = cu.sub_components[current].sub_components[instance_idx].ty;
261    }
262    false
263}
264
265/// `root` plus every instantiated repeated / conditional row instance
266/// below it, recursively.
267fn all_instances(root: &VRc<ItemTreeVTable, Instance>) -> Vec<VRc<ItemTreeVTable, Instance>> {
268    let mut out = Vec::new();
269    collect_instances(root, &mut out);
270    out
271}
272
273fn collect_instances(
274    inst: &VRc<ItemTreeVTable, Instance>,
275    out: &mut Vec<VRc<ItemTreeVTable, Instance>>,
276) {
277    out.push(inst.clone());
278    collect_row_instances(&inst.root_sub_component, out);
279}
280
281fn collect_row_instances(
282    sub: &Pin<Rc<SubComponentInstance>>,
283    out: &mut Vec<VRc<ItemTreeVTable, Instance>>,
284) {
285    for repeater in sub.repeaters.iter() {
286        repeater.track_instance_changes();
287        for row in repeater.instances_vec() {
288            collect_instances(&row, out);
289        }
290    }
291    for nested in sub.sub_components.iter() {
292        collect_row_instances(nested, out);
293    }
294}
295
296/// Walk the LLR sub_components tree to resolve `path` into its concrete
297/// [`SubComponentIdx`].
298fn sub_component_idx_at_path(
299    cu: &i_slint_compiler::llr::CompilationUnit,
300    root_idx: SubComponentIdx,
301    path: &[SubComponentInstanceIdx],
302) -> SubComponentIdx {
303    let mut current = root_idx;
304    for &instance_idx in path {
305        let nested = &cu.sub_components[current].sub_components[instance_idx];
306        current = nested.ty;
307    }
308    current
309}
310
311/// Whether the item's LLR debug info marks it as an injected geometry
312/// wrapper (`Element::is_injected_wrapper_element`).
313fn is_injected_wrapper_element(instance: &VRc<ItemTreeVTable, Instance>, flat_idx: usize) -> bool {
314    let cu = &instance.root_sub_component.compilation_unit;
315    let root_ty = instance.root_sub_component.sub_component_idx;
316    let Some(Some((path, local_idx))) = instance.item_table.get(flat_idx) else {
317        return false;
318    };
319    let sc_idx = sub_component_idx_at_path(cu, root_ty, path);
320    cu.sub_components[sc_idx]
321        .debug_info
322        .as_ref()
323        .and_then(|debug| debug.items.get(*local_idx))
324        .is_some_and(|item_debug| item_debug.is_injected_wrapper_element)
325}
326
327fn item_flat_index_to_rect(
328    instance: &VRc<ItemTreeVTable, Instance>,
329    root: &VRc<ItemTreeVTable, Instance>,
330    flat_idx: usize,
331) -> Option<HighlightedRect> {
332    let vrc = VRc::into_dyn(instance.clone());
333    let root_vrc = VRc::into_dyn(root.clone());
334    let item_rc = ItemRc::new(vrc.clone(), flat_idx as u32);
335    let geometry = item_rc.geometry();
336    if geometry.size.is_empty() {
337        return None;
338    }
339    // Injected geometry wrappers (opacity/transform/clip/... created by
340    // `lower_property_to_element`) take over the element's geometry and lay the element
341    // out at (0,0) inside themselves, so measuring the parent frame from the element
342    // directly would collapse `rect.origin - parent_origin` to ~0.
343    let mut anchor = item_rc.clone();
344    while let Some(parent) =
345        anchor.parent_item(i_slint_core::item_tree::ParentItemTraversalMode::StopAtPopups)
346    {
347        if !VRc::ptr_eq(parent.item_tree(), &vrc) {
348            break; // crossed into another component instance's item tree
349        }
350        if !is_injected_wrapper_element(instance, parent.index() as usize) {
351            break;
352        }
353        anchor = parent;
354    }
355
356    let origin = item_rc.map_to_item_tree(geometry.origin, &root_vrc);
357    // `map_to_item_tree` does not add the item's own x/y, so mapping the zero point of
358    // the anchor yields the absolute origin of the element's source-parent coordinate
359    // system.
360    let parent_origin = anchor.map_to_item_tree(LogicalPoint::default(), &root_vrc);
361    // The source parent's absolute rotation: map a unit x-vector of the anchor's frame.
362    // `map_to_item_tree` applies the ancestors' transforms but not the anchor's own, so
363    // this excludes the element's own rotation (applied by its injected `Transform`).
364    let parent_rotation = {
365        let frame_x_axis = anchor.map_to_item_tree(LogicalPoint::new(1.0, 0.0), &root_vrc);
366        let delta = frame_x_axis - parent_origin;
367        delta.y.atan2(delta.x).to_degrees()
368    };
369    let top_right = item_rc
370        .map_to_item_tree(geometry.origin + euclid::vec2(geometry.size.width, 0.), &root_vrc);
371    let delta = top_right - origin;
372    let width = delta.length();
373    let height = if geometry.size.width == 0.0 {
374        0.0
375    } else {
376        geometry.size.height * width / geometry.size.width
377    };
378    let angle_rad = delta.y.atan2(delta.x);
379    let (sin, cos) = angle_rad.sin_cos();
380    let center = euclid::point2(
381        origin.x + (width / 2.0) * cos - (height / 2.0) * sin,
382        origin.y + (width / 2.0) * sin + (height / 2.0) * cos,
383    );
384    Some(HighlightedRect {
385        rect: LogicalRect {
386            origin: center - euclid::vec2(width / 2.0, height / 2.0),
387            size: euclid::size2(width, height),
388        },
389        angle: angle_rad.to_degrees(),
390        parent_origin,
391        parent_rotation,
392    })
393}
394
395fn positions_by_source(
396    root: &VRc<ItemTreeVTable, Instance>,
397    target_path: &Path,
398    target_offset: u32,
399    use_site: Option<(&Path, u32)>,
400    filter: ElementPositionFilter,
401) -> Vec<HighlightedRect> {
402    let cu = root.root_sub_component.compilation_unit.clone();
403    let mut results = Vec::new();
404    // Repeated / conditional rows are separate instances with their own
405    // item tables, so search all of them, mapping geometry back into the
406    // root instance's coordinates.
407    for instance in all_instances(root) {
408        for sc_idx in 0..cu.sub_components.len() {
409            let sc_idx: SubComponentIdx = sc_idx.into();
410            let sc = &cu.sub_components[sc_idx];
411            let Some(debug) = sc.debug_info.as_ref() else { continue };
412            for (local_idx, item_dbg) in debug.items.iter_enumerated() {
413                let Some(source_file) = item_dbg.source_location.source_file.as_ref() else {
414                    continue;
415                };
416                if source_file.path() != target_path {
417                    continue;
418                }
419                if item_dbg.source_location.span.offset as u32 != target_offset {
420                    continue;
421                }
422                for flat_idx in find_flat_indices_for_item(&instance, sc_idx, local_idx, use_site) {
423                    if filter == ElementPositionFilter::ExcludeClipped {
424                        let dyn_rc = vtable::VRc::into_dyn(instance.clone());
425                        let item_rc = i_slint_core::items::ItemRc::new(dyn_rc, flat_idx as u32);
426                        if !item_rc.is_visible() {
427                            continue;
428                        }
429                    }
430                    if let Some(rect) = item_flat_index_to_rect(&instance, root, flat_idx) {
431                        results.push(rect);
432                    }
433                }
434            }
435        }
436    }
437    results
438}
439
440#[cfg(test)]
441mod tests {
442    use crate::{
443        ComponentInstance,
444        debug_hook::tests::{compile_with_debug_hooks, test_path},
445    };
446
447    fn geometry_of(
448        instance: &ComponentInstance,
449        code: &str,
450        id: &str,
451    ) -> crate::highlight::HighlightedRect {
452        let id_position = code.find(id).unwrap_or_else(|| panic!("{id} not found"));
453        let offset = id_position + code[id_position..].find("Rectangle").unwrap();
454        let (element, _) = instance
455            .element_node_at_source_code_position(&test_path(), offset as u32)
456            .first()
457            .cloned()
458            .unwrap_or_else(|| panic!("element {id} not resolved"));
459        *instance.element_positions(&element).first().expect("geometry")
460    }
461
462    // With debug_hooks enabled every element is wrapped in injected geometry wrappers
463    // (`Transform`, plus `Opacity` etc. when those props are set), which take over the element's
464    // geometry. `element_positions` must still report a `parent_origin` from which the element's
465    // own `x`/`y` can be recovered (`rect.origin - parent_origin == x/y`), otherwise the editor
466    // commits wrong coordinates when repositioning. This must hold through stacked wrappers and
467    // for elements nested below a non-root parent.
468    #[test]
469    fn debug_hooks_parent_origin() {
470        let code = r#"
471export component Win inherits Window {
472    width: 300px;
473    height: 200px;
474    plain := Rectangle {
475        x: 30px;
476        y: 40px;
477        width: 50px;
478        height: 60px;
479    }
480    faded := Rectangle {
481        // extra Opacity and visibility-Clip wrappers stacked around the Transform wrapper
482        opacity: 0.5;
483        visible: true;
484        x: 70px;
485        y: 80px;
486        width: 40px;
487        height: 30px;
488    }
489    outer := Rectangle {
490        x: 10px;
491        y: 20px;
492        width: 120px;
493        height: 100px;
494        nested := Rectangle {
495            x: 5px;
496            y: 7px;
497            width: 20px;
498            height: 20px;
499        }
500    }
501}"#;
502        let instance = compile_with_debug_hooks(code);
503
504        let check = |id: &str, expected: (f32, f32)| {
505            let geometry = geometry_of(&instance, code, id);
506            let x = geometry.rect.origin.x - geometry.parent_origin.x;
507            let y = geometry.rect.origin.y - geometry.parent_origin.y;
508            assert!(
509                (x - expected.0).abs() < 0.5 && (y - expected.1).abs() < 0.5,
510                "{id}: source-relative position ({x}, {y}) should be {expected:?}"
511            );
512        };
513
514        check("plain", (30.0, 40.0));
515        check("faded", (70.0, 80.0));
516        check("nested", (5.0, 7.0));
517    }
518
519    #[test]
520    fn debug_hooks_parent_rotation() {
521        let code = r#"
522export component Win inherits Window {
523    width: 300px;
524    height: 300px;
525    outer := Rectangle {
526        x: 50px;
527        y: 50px;
528        width: 160px;
529        height: 160px;
530        transform-rotation: 30deg;
531        inner := Rectangle {
532            x: 20px;
533            y: 20px;
534            width: 40px;
535            height: 40px;
536            transform-rotation: 15deg;
537        }
538    }
539}"#;
540        let instance = compile_with_debug_hooks(code);
541
542        let check = |id: &str, expected: f32| {
543            let geometry = geometry_of(&instance, code, id);
544            let rotation = geometry.angle - geometry.parent_rotation;
545            assert!(
546                (rotation - expected).abs() < 0.5,
547                "{id}: source-relative rotation {rotation} should be {expected}"
548            );
549        };
550
551        check("outer", 30.0);
552        check("inner", 15.0);
553    }
554}