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::{ItemTransform, LogicalPoint, LogicalRect, LogicalVector};
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    /// Whether `rect` and `angle` describe the element's rendered shape.
31    ///
32    /// The two of them describe a rotated rectangle.
33    /// An element renders as one while its own and its ancestors' transforms keep its two axes
34    /// at a right angle.
35    /// A non-uniform scale combined with a rotation shears it into a parallelogram instead,
36    /// which only `local_rect` still describes.
37    pub renders_as_rectangle: bool,
38    /// The element's rectangle in its parent's coordinate system.
39    ///
40    /// This is what the source `x`, `y`, `width` and `height` describe.
41    /// Unlike `rect`, no transform of the element or of any of its ancestors applies to it.
42    pub local_rect: LogicalRect,
43    /// Maps this instance's parent coordinate system to root coordinates.
44    ///
45    /// Invert it to turn a position picked in root coordinates into one that can be written to
46    /// the source.
47    /// It is composed from the instance's own ancestors,
48    /// so it stays correct even if the element is positioned outside of
49    /// (or with a negative offset relative to) its parent.
50    pub parent_transform: ItemTransform,
51    /// Evaluated parent-relative rotation in degrees, including complete turns.
52    pub transform_rotation: f32,
53    /// Evaluated corner radii in logical pixels.
54    pub corner_radii: CornerRadii,
55}
56/// Evaluated rectangle corner radii in logical pixels.
57#[derive(Clone, Copy, Debug, Default)]
58pub struct CornerRadii {
59    /// Top-left radius.
60    pub top_left: f32,
61    /// Top-right radius.
62    pub top_right: f32,
63    /// Bottom-left radius.
64    pub bottom_left: f32,
65    /// Bottom-right radius.
66    pub bottom_right: f32,
67}
68
69impl HighlightedRect {
70    /// Absolute origin of this instance's parent coordinate system, in root coordinates.
71    pub fn parent_origin(&self) -> LogicalPoint {
72        self.parent_transform.transform_point(LogicalPoint::default().cast()).cast()
73    }
74
75    /// Absolute rotation (in degrees) of this instance's parent coordinate system.
76    ///
77    /// `angle - parent_rotation()` yields the element's own rotation relative to its parent,
78    /// which matches `transform-rotation` modulo complete turns.
79    /// Use `transform_rotation` to retain those turns.
80    pub fn parent_rotation(&self) -> f32 {
81        self.parent_transform.m12.atan2(self.parent_transform.m11).to_degrees()
82    }
83
84    /// Returns true if `position` lies inside the (potentially rotated) rectangle.
85    pub fn contains(&self, position: LogicalPoint) -> bool {
86        let center = self.rect.center();
87        let rotation = euclid::Rotation2D::radians((-self.angle).to_radians());
88        let transformed = center + rotation.transform_vector(position - center);
89        self.rect.contains(transformed)
90    }
91}
92
93/// Argument to filter the elements returned by the highlight helpers.
94#[derive(Copy, Clone, Eq, PartialEq)]
95pub enum ElementPositionFilter {
96    /// Include all elements.
97    IncludeClipped,
98    /// Exclude elements clipped by an ancestor `Clip` / `Flickable`.
99    ExcludeClipped,
100}
101
102/// Return the screen rectangles of every runtime item matching the
103/// given `ElementRc`, optionally filtering out those clipped by an
104/// ancestor. Public for downstream tooling such as the LSP element
105/// selection, whose hit-testing needs the `ExcludeClipped` filter.
106pub fn element_positions(
107    instance: &VRc<ItemTreeVTable, Instance>,
108    element: &ElementRc,
109    filter: ElementPositionFilter,
110) -> Vec<HighlightedRect> {
111    // Match by source location: the LLR copies the element's
112    // `source_location` onto every item it lowers, and the object-tree
113    // element keeps the original node. `element_hash` would be more
114    // compact, but passes that run after `inject_debug_hooks` (layout
115    // lowering, property hoisting) create elements without a hash.
116    let target = walk_to_native_root(element);
117    let Some(target_loc) = source_location_of(&target) else {
118        return Vec::new();
119    };
120    // A component use (`Button { }`) resolves to the definition's root
121    // element, whose location matches every instantiation of the component.
122    // Constrain the matches to item-table paths that descend through this
123    // specific use site.
124    let use_site = if Rc::ptr_eq(&target, element) { None } else { source_location_of(element) };
125    positions_by_source(
126        instance,
127        &target_loc.0,
128        target_loc.1,
129        use_site.as_ref().map(|(p, o)| (p.as_path(), *o)),
130        filter,
131    )
132}
133
134/// The `(path, offset)` key under which the LLR debug info records
135/// `element` — `Spanned::to_source_location` semantics (the qualified
136/// name's start).
137fn source_location_of(element: &ElementRc) -> Option<(std::path::PathBuf, u32)> {
138    use i_slint_compiler::diagnostics::Spanned;
139    let e = element.borrow();
140    let path = e.source_file()?.path().to_path_buf();
141    Some((path, e.span().offset as u32))
142}
143
144/// Descend into `base_type = Component(_)` wrappers until the element
145/// has its own native item. For a component use like `Button { }`, the
146/// runtime items belong to the wrapped component's root element, not to
147/// the use-site element itself.
148fn walk_to_native_root(element: &ElementRc) -> ElementRc {
149    let mut current = element.clone();
150    loop {
151        let next = {
152            let b = current.borrow();
153            if let i_slint_compiler::langtype::ElementType::Component(c) = &b.base_type {
154                Some(c.root_element.clone())
155            } else {
156                None
157            }
158        };
159        match next {
160            Some(n) => current = n,
161            None => return current,
162        }
163    }
164}
165
166/// Return the geometry of every runtime item whose source location covers
167/// the given `(path, offset)` pair.
168pub(crate) fn component_positions(
169    instance: &VRc<ItemTreeVTable, Instance>,
170    path: &Path,
171    offset: u32,
172) -> Vec<HighlightedRect> {
173    element_node_at_source_code_position(instance, path, offset)
174        .into_iter()
175        .flat_map(|(element, _)| {
176            element_positions(instance, &element, ElementPositionFilter::IncludeClipped)
177        })
178        .collect()
179}
180
181/// Look up the `(ElementRc, index)` tuples whose `debug` entries cover
182/// the given source offset. Uses the `TypeLoader` stored on the instance
183/// (if available) to walk the original object-tree `Document`.
184pub(crate) fn element_node_at_source_code_position(
185    instance: &VRc<ItemTreeVTable, Instance>,
186    path: &Path,
187    offset: u32,
188) -> Vec<(ElementRc, usize)> {
189    let Some(type_loader) = instance.type_loaders.type_loader.as_ref() else {
190        return Vec::new();
191    };
192    let Some(doc) = type_loader.get_document(path) else {
193        return Vec::new();
194    };
195    let mut result = Vec::new();
196    // `inner_components` lists every component defined in the file,
197    // exported or not.
198    for component in &doc.inner_components {
199        visit_element_for_position(&component.root_element, path, offset, &mut result);
200    }
201    result
202}
203
204fn visit_element_for_position(
205    element: &ElementRc,
206    path: &Path,
207    offset: u32,
208    result: &mut Vec<(ElementRc, usize)>,
209) {
210    if element.borrow().repeated.is_some() {
211        // The children of a repeated element live in the component the
212        // repeater pass wrapped around it, which is not part of
213        // `inner_components` — descend explicitly. The wrapper's root
214        // element carries the same source node as the repeated element.
215        let base = match &element.borrow().base_type {
216            i_slint_compiler::langtype::ElementType::Component(c) => Some(c.root_element.clone()),
217            _ => None,
218        };
219        if let Some(root) = base {
220            visit_element_for_position(&root, path, offset, result);
221        }
222        return;
223    }
224    for (index, node_path, node_range) in element.borrow().debug.iter().enumerate().map(|(i, n)| {
225        let text_range = n
226            .node
227            .QualifiedName()
228            .map(|n| n.text_range())
229            .or_else(|| {
230                n.node
231                    .child_token(i_slint_compiler::parser::SyntaxKind::LBrace)
232                    .map(|n| n.text_range())
233            })
234            .expect("An Element must contain a LBrace somewhere");
235        (i, n.node.source_file.path(), text_range)
236    }) {
237        if node_path == path && node_range.contains(offset.into()) {
238            result.push((element.clone(), index));
239        }
240    }
241    let children = element.borrow().children.clone();
242    for child in &children {
243        visit_element_for_position(child, path, offset, result);
244    }
245}
246
247/// Scan the instance's flat `item_table` and return every flat index
248/// whose entry points at `(sub_component_path → target_sc_idx, target_local)`.
249/// With `use_site` set, only paths descending through a sub-component
250/// instance whose use-site element sits at that `(path, offset)` match.
251fn find_flat_indices_for_item(
252    instance: &VRc<ItemTreeVTable, Instance>,
253    target_sc_idx: SubComponentIdx,
254    target_local: ItemInstanceIdx,
255    use_site: Option<(&Path, u32)>,
256) -> Vec<usize> {
257    let cu = &instance.root_sub_component.compilation_unit;
258    let root_ty = instance.root_sub_component.sub_component_idx;
259    let mut out = Vec::new();
260    for (flat, entry) in instance.item_table.iter().enumerate() {
261        let Some((path, local_idx)) = entry.as_ref() else { continue };
262        if *local_idx != target_local {
263            continue;
264        }
265        if sub_component_idx_at_path(cu, root_ty, path) != target_sc_idx {
266            continue;
267        }
268        if let Some((us_path, us_offset)) = use_site
269            && !path_passes_use_site(cu, root_ty, path, us_path, us_offset)
270        {
271            continue;
272        }
273        out.push(flat);
274    }
275    out
276}
277
278/// Whether any step of `path` descends through a sub-component instance
279/// whose use-site element is recorded at `(us_path, us_offset)`.
280fn path_passes_use_site(
281    cu: &i_slint_compiler::llr::CompilationUnit,
282    mut current: SubComponentIdx,
283    path: &[SubComponentInstanceIdx],
284    us_path: &Path,
285    us_offset: u32,
286) -> bool {
287    for &instance_idx in path {
288        if let Some(debug) = cu.sub_components[current].debug_info.as_ref()
289            && let Some(loc) = debug.sub_component_use_sites.get(instance_idx)
290            && loc.source_file.as_ref().is_some_and(|f| f.path() == us_path)
291            && loc.span.offset as u32 == us_offset
292        {
293            return true;
294        }
295        current = cu.sub_components[current].sub_components[instance_idx].ty;
296    }
297    false
298}
299
300/// `root` plus every instantiated repeated / conditional row instance
301/// below it, recursively.
302fn all_instances(root: &VRc<ItemTreeVTable, Instance>) -> Vec<VRc<ItemTreeVTable, Instance>> {
303    let mut out = Vec::new();
304    collect_instances(root, &mut out);
305    out
306}
307
308fn collect_instances(
309    inst: &VRc<ItemTreeVTable, Instance>,
310    out: &mut Vec<VRc<ItemTreeVTable, Instance>>,
311) {
312    out.push(inst.clone());
313    collect_row_instances(&inst.root_sub_component, out);
314}
315
316fn collect_row_instances(
317    sub: &Pin<Rc<SubComponentInstance>>,
318    out: &mut Vec<VRc<ItemTreeVTable, Instance>>,
319) {
320    for repeater in sub.repeaters.iter() {
321        repeater.track_instance_changes();
322        for row in repeater.instances_vec() {
323            collect_instances(&row, out);
324        }
325    }
326    for nested in sub.sub_components.iter() {
327        collect_row_instances(nested, out);
328    }
329}
330
331/// Walk the LLR sub_components tree to resolve `path` into its concrete
332/// [`SubComponentIdx`].
333fn sub_component_idx_at_path(
334    cu: &i_slint_compiler::llr::CompilationUnit,
335    root_idx: SubComponentIdx,
336    path: &[SubComponentInstanceIdx],
337) -> SubComponentIdx {
338    let mut current = root_idx;
339    for &instance_idx in path {
340        let nested = &cu.sub_components[current].sub_components[instance_idx];
341        current = nested.ty;
342    }
343    current
344}
345
346/// Whether the item's LLR debug info marks it as an injected geometry
347/// wrapper (`Element::is_injected_wrapper_element`).
348fn is_injected_wrapper_element(instance: &VRc<ItemTreeVTable, Instance>, flat_idx: usize) -> bool {
349    let cu = &instance.root_sub_component.compilation_unit;
350    let root_ty = instance.root_sub_component.sub_component_idx;
351    let Some(Some((path, local_idx))) = instance.item_table.get(flat_idx) else {
352        return false;
353    };
354    let sc_idx = sub_component_idx_at_path(cu, root_ty, path);
355    cu.sub_components[sc_idx]
356        .debug_info
357        .as_ref()
358        .and_then(|debug| debug.items.get(*local_idx))
359        .is_some_and(|item_debug| item_debug.is_injected_wrapper_element)
360}
361
362fn item_flat_index_to_rect(
363    instance: &VRc<ItemTreeVTable, Instance>,
364    root: &VRc<ItemTreeVTable, Instance>,
365    flat_idx: usize,
366) -> Option<HighlightedRect> {
367    let vrc = VRc::into_dyn(instance.clone());
368    let root_vrc = VRc::into_dyn(root.clone());
369    let item_rc = ItemRc::new(vrc.clone(), flat_idx as u32);
370    let geometry = item_rc.geometry();
371    if geometry.size.is_empty() {
372        return None;
373    }
374    // Injected geometry wrappers (opacity/transform/clip/... created by
375    // `lower_property_to_element`) take over the element's geometry and lay the element
376    // out at (0,0) inside themselves, so measuring the parent frame from the element
377    // directly would collapse `rect.origin - parent_origin` to ~0.
378    let mut transform_rotation = 0.;
379    let mut anchor = item_rc.clone();
380    while let Some(parent) =
381        anchor.parent_item(i_slint_core::item_tree::ParentItemTraversalMode::StopAtPopups)
382    {
383        if !VRc::ptr_eq(parent.item_tree(), &vrc) {
384            break; // crossed into another component instance's item tree
385        }
386        if !is_injected_wrapper_element(instance, parent.index() as usize) {
387            break;
388        }
389        if let Some(transform) = i_slint_core::items::ItemRef::downcast_pin::<
390            i_slint_core::items::Transform,
391        >(parent.borrow())
392        {
393            transform_rotation += transform.transform_rotation();
394        }
395        anchor = parent;
396    }
397
398    // Neither transform adds its item's own x/y, so `parent_transform` maps the element's
399    // source-parent coordinate system, and `to_root` the coordinate system the injected
400    // wrappers place the element in — the element's own transform included.
401    let to_root = item_rc.transform_to_item_tree(&root_vrc);
402    let parent_transform = anchor.transform_to_item_tree(&root_vrc);
403    let map_axis = |axis: euclid::Vector2D<f32, _>| to_root.transform_vector(axis).cast();
404
405    let origin: LogicalPoint = to_root.transform_point(geometry.origin.cast()).cast();
406    // Both edges are measured, so a scale along one axis is not assumed to match the other.
407    let size = geometry.size.cast::<f32>();
408    let x_axis = map_axis(euclid::vec2(size.width, 0.));
409    let y_axis = map_axis(euclid::vec2(0., size.height));
410    let width = x_axis.length();
411    let height = y_axis.length();
412    let center = origin + (x_axis + y_axis) / 2.0;
413    Some(HighlightedRect {
414        rect: LogicalRect {
415            origin: center - euclid::vec2(width / 2.0, height / 2.0),
416            size: euclid::size2(width, height),
417        },
418        angle: x_axis.y.atan2(x_axis.x).to_degrees(),
419        renders_as_rectangle: are_perpendicular(x_axis, y_axis),
420        local_rect: if anchor == item_rc { geometry } else { anchor.geometry() },
421        parent_transform,
422        transform_rotation,
423        corner_radii: item_corner_radii(item_rc.borrow()),
424    })
425}
426
427/// Whether two axes still meet at a right angle.
428/// The cosine between them is compared, so the tolerance does not depend on how long they are.
429fn are_perpendicular(x: LogicalVector, y: LogicalVector) -> bool {
430    let squared_lengths = x.square_length() * y.square_length();
431    let dot = x.dot(y);
432    squared_lengths == 0. || dot * dot < 1.0e-6 * squared_lengths
433}
434
435fn positions_by_source(
436    root: &VRc<ItemTreeVTable, Instance>,
437    target_path: &Path,
438    target_offset: u32,
439    use_site: Option<(&Path, u32)>,
440    filter: ElementPositionFilter,
441) -> Vec<HighlightedRect> {
442    items_by_source(root, target_path, target_offset, use_site)
443        .into_iter()
444        .filter_map(|(instance, flat_idx)| {
445            if filter == ElementPositionFilter::ExcludeClipped {
446                let item = ItemRc::new(VRc::into_dyn(instance.clone()), flat_idx as u32);
447                if !item.is_visible() {
448                    return None;
449                }
450            }
451            item_flat_index_to_rect(&instance, root, flat_idx)
452        })
453        .collect()
454}
455
456fn item_corner_radii(item: Pin<i_slint_core::items::ItemRef<'_>>) -> CornerRadii {
457    use i_slint_core::items::{BasicBorderRectangle, BorderRectangle, ItemRef};
458    if let Some(rect) = ItemRef::downcast_pin::<BorderRectangle>(item) {
459        CornerRadii {
460            top_left: rect.border_top_left_radius().get(),
461            top_right: rect.border_top_right_radius().get(),
462            bottom_left: rect.border_bottom_left_radius().get(),
463            bottom_right: rect.border_bottom_right_radius().get(),
464        }
465    } else if let Some(rect) = ItemRef::downcast_pin::<BasicBorderRectangle>(item) {
466        let radius = rect.border_radius().get();
467        CornerRadii {
468            top_left: radius,
469            top_right: radius,
470            bottom_left: radius,
471            bottom_right: radius,
472        }
473    } else {
474        CornerRadii::default()
475    }
476}
477
478fn items_by_source(
479    root: &VRc<ItemTreeVTable, Instance>,
480    target_path: &Path,
481    target_offset: u32,
482    use_site: Option<(&Path, u32)>,
483) -> Vec<(VRc<ItemTreeVTable, Instance>, usize)> {
484    let cu = root.root_sub_component.compilation_unit.clone();
485    let mut results = Vec::new();
486    // Repeated / conditional rows are separate instances with their own
487    // item tables, so search all of them, mapping geometry back into the
488    // root instance's coordinates.
489    for instance in all_instances(root) {
490        for sc_idx in 0..cu.sub_components.len() {
491            let sc_idx: SubComponentIdx = sc_idx.into();
492            let sc = &cu.sub_components[sc_idx];
493            let Some(debug) = sc.debug_info.as_ref() else { continue };
494            for (local_idx, item_dbg) in debug.items.iter_enumerated() {
495                let Some(source_file) = item_dbg.source_location.source_file.as_ref() else {
496                    continue;
497                };
498                if source_file.path() != target_path {
499                    continue;
500                }
501                if item_dbg.source_location.span.offset as u32 != target_offset {
502                    continue;
503                }
504                for flat_idx in find_flat_indices_for_item(&instance, sc_idx, local_idx, use_site) {
505                    results.push((instance.clone(), flat_idx));
506                }
507            }
508        }
509    }
510    results
511}
512
513#[cfg(test)]
514mod tests {
515    use crate::{
516        ComponentInstance,
517        debug_hook::tests::{compile_with_debug_hooks, test_path},
518    };
519
520    fn geometry_of(
521        instance: &ComponentInstance,
522        code: &str,
523        id: &str,
524    ) -> crate::highlight::HighlightedRect {
525        let id_position = code.find(id).unwrap_or_else(|| panic!("{id} not found"));
526        let offset = id_position + code[id_position..].find("Rectangle").unwrap();
527        let (element, _) = instance
528            .element_node_at_source_code_position(&test_path(), offset as u32)
529            .first()
530            .cloned()
531            .unwrap_or_else(|| panic!("element {id} not resolved"));
532        *instance.element_positions(&element).first().expect("geometry")
533    }
534
535    // With debug_hooks enabled every element is wrapped in injected geometry wrappers
536    // (`Transform`, plus `Opacity` etc. when those props are set), which take over the element's
537    // geometry. `element_positions` must still report a `parent_origin` from which the element's
538    // own `x`/`y` can be recovered (`rect.origin - parent_origin == x/y`), otherwise the editor
539    // commits wrong coordinates when repositioning. This must hold through stacked wrappers and
540    // for elements nested below a non-root parent.
541    #[test]
542    fn debug_hooks_parent_origin() {
543        let code = r#"
544export component Win inherits Window {
545    width: 300px;
546    height: 200px;
547    plain := Rectangle {
548        x: 30px;
549        y: 40px;
550        width: 50px;
551        height: 60px;
552    }
553    faded := Rectangle {
554        // extra Opacity and visibility-Clip wrappers stacked around the Transform wrapper
555        opacity: 0.5;
556        visible: true;
557        x: 70px;
558        y: 80px;
559        width: 40px;
560        height: 30px;
561    }
562    outer := Rectangle {
563        x: 10px;
564        y: 20px;
565        width: 120px;
566        height: 100px;
567        nested := Rectangle {
568            x: 5px;
569            y: 7px;
570            width: 20px;
571            height: 20px;
572        }
573    }
574}"#;
575        let instance = compile_with_debug_hooks(code);
576
577        let check = |id: &str, expected: (f32, f32)| {
578            let geometry = geometry_of(&instance, code, id);
579            let x = geometry.rect.origin.x - geometry.parent_origin().x;
580            let y = geometry.rect.origin.y - geometry.parent_origin().y;
581            assert!(
582                (x - expected.0).abs() < 0.5 && (y - expected.1).abs() < 0.5,
583                "{id}: source-relative position ({x}, {y}) should be {expected:?}"
584            );
585        };
586
587        check("plain", (30.0, 40.0));
588        check("faded", (70.0, 80.0));
589        check("nested", (5.0, 7.0));
590    }
591
592    #[test]
593    fn debug_hooks_local_rect() {
594        let code = r#"
595export component Win inherits Window {
596    width: 300px;
597    height: 300px;
598    outer := Rectangle {
599        x: 50px;
600        y: 60px;
601        width: 160px;
602        height: 140px;
603        transform-rotation: 30deg;
604        inner := Rectangle {
605            x: 20px;
606            y: 25px;
607            width: 40px;
608            height: 30px;
609            transform-rotation: 15deg;
610            transform-origin: { x: 0px, y: 0px };
611        }
612    }
613}"#;
614        let instance = compile_with_debug_hooks(code);
615
616        let check = |id: &str, expected: (f32, f32, f32, f32)| {
617            let rect = geometry_of(&instance, code, id).local_rect;
618            let actual = (rect.origin.x, rect.origin.y, rect.width(), rect.height());
619            assert!(
620                (actual.0 - expected.0).abs() < 0.5
621                    && (actual.1 - expected.1).abs() < 0.5
622                    && (actual.2 - expected.2).abs() < 0.5
623                    && (actual.3 - expected.3).abs() < 0.5,
624                "{id}: parent-relative rectangle {actual:?} should be {expected:?}"
625            );
626        };
627
628        check("outer", (50.0, 60.0, 160.0, 140.0));
629        check("inner", (20.0, 25.0, 40.0, 30.0));
630    }
631
632    #[test]
633    fn scaled_geometry_is_measured_per_axis() {
634        let code = r#"
635export component Win inherits Window {
636    width: 400px;
637    height: 400px;
638    stretched := Rectangle {
639        x: 20px;
640        y: 20px;
641        width: 80px;
642        height: 40px;
643        transform-scale-x: 3;
644    }
645    wide := Rectangle {
646        x: 20px;
647        y: 200px;
648        width: 200px;
649        height: 100px;
650        transform-scale-x: 3;
651        sheared := Rectangle {
652            width: 80px;
653            height: 40px;
654            transform-rotation: 30deg;
655        }
656    }
657}"#;
658        let instance = compile_with_debug_hooks(code);
659
660        // Both axes are measured, so a scale along one of them doesn't stretch the other.
661        let stretched = geometry_of(&instance, code, "stretched");
662        assert!(
663            (stretched.rect.width() - 240.).abs() < 0.5
664                && (stretched.rect.height() - 40.).abs() < 0.5,
665            "stretched: {:?}",
666            stretched.rect
667        );
668        assert!(stretched.renders_as_rectangle);
669
670        // A rotation below a non-uniform scale is a parallelogram, which `rect` can't express.
671        let sheared = geometry_of(&instance, code, "sheared");
672        assert!(!sheared.renders_as_rectangle);
673        assert!((sheared.angle - 30.).abs() > 1., "sheared angle: {}", sheared.angle);
674    }
675
676    #[test]
677    fn debug_hooks_parent_rotation() {
678        let code = r#"
679export component Win inherits Window {
680    width: 300px;
681    height: 300px;
682    outer := Rectangle {
683        x: 50px;
684        y: 50px;
685        width: 160px;
686        height: 160px;
687        transform-rotation: 30deg;
688        inner := Rectangle {
689            x: 20px;
690            y: 20px;
691            width: 40px;
692            height: 40px;
693            transform-rotation: 15deg;
694        }
695    }
696}"#;
697        let instance = compile_with_debug_hooks(code);
698
699        let check = |id: &str, expected: f32| {
700            let geometry = geometry_of(&instance, code, id);
701            let rotation = geometry.angle - geometry.parent_rotation();
702            assert!(
703                (rotation - expected).abs() < 0.5,
704                "{id}: source-relative rotation {rotation} should be {expected}"
705            );
706        };
707
708        check("outer", 30.0);
709        check("inner", 15.0);
710    }
711    #[test]
712    fn evaluated_rotation_and_radii_share_geometry_instances() {
713        let code = r#"
714export component Win inherits Window {
715    width: 300px;
716    height: 300px;
717    outer := Rectangle {
718        width: 200px;
719        height: 200px;
720        transform-rotation: 30deg;
721        for angle in [382.25deg, -397.5deg]: Rectangle {
722            width: 40px;
723            height: 40px;
724            transform-rotation: angle;
725            border-top-left-radius: 1.5px;
726            border-top-right-radius: 2.5px;
727            border-bottom-left-radius: 3.5px;
728            border-bottom-right-radius: 4.5px;
729        }
730    }
731}"#;
732        let instance = compile_with_debug_hooks(code);
733        let offset = code
734            .find(
735                "Rectangle {
736            width",
737            )
738            .unwrap();
739        let (element, _) = instance
740            .element_node_at_source_code_position(&test_path(), offset as u32)
741            .first()
742            .cloned()
743            .unwrap();
744        let geometries = instance.element_positions(&element);
745        assert_eq!(geometries.len(), 2);
746        for (geometry, expected) in geometries.iter().zip([382.25, -397.5]) {
747            assert_eq!(geometry.transform_rotation, expected);
748            assert!((geometry.parent_rotation() - 30.).abs() < 0.001);
749            let radii = geometry.corner_radii;
750            assert_eq!(
751                [radii.top_left, radii.top_right, radii.bottom_left, radii.bottom_right],
752                [1.5, 2.5, 3.5, 4.5]
753            );
754        }
755    }
756}