Skip to main content

slint_interpreter/
debug_hook.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//! Runtime side of the `debug_hooks` compiler feature: evaluating an
5//! `Expression::DebugHook` calls the callback installed with
6//! `ComponentInstance::set_debug_hook_callback`, which may override the value.
7
8use crate::Value;
9use crate::eval::EvalContext;
10
11use smol_str::SmolStr;
12
13pub type DebugHookCallback = Box<dyn Fn(&str) -> Option<Value>>;
14
15#[cfg(feature = "internal")]
16pub(crate) fn set_debug_hook_callback(
17    instance: &vtable::VRc<i_slint_core::item_tree::ItemTreeVTable, crate::instance::Instance>,
18    func: Option<DebugHookCallback>,
19) {
20    *instance.globals.debug_hook_callback.borrow_mut() = func;
21}
22
23/// `Some` when the installed callback overrides the value, `None` to evaluate the binding.
24pub(crate) fn trigger_debug_hook(ctx: &EvalContext, id: &SmolStr) -> Option<Value> {
25    let globals = ctx.globals.upgrade()?;
26    let callback = globals.debug_hook_callback.borrow();
27    callback.as_ref().and_then(|callback| callback(id))
28}
29
30#[cfg(all(test, feature = "internal", feature = "internal-highlight"))]
31pub(crate) mod tests {
32    use super::*;
33    use crate::{Compiler, ComponentInstance};
34    use i_slint_compiler::source_path::SourcePath;
35    use i_slint_core::{Property, graphics::ApproxEq};
36    use std::{cell::RefCell, collections::HashMap, pin::Pin, rc::Rc};
37
38    pub fn compile_with_debug_hooks(code: &str) -> ComponentInstance {
39        i_slint_backend_testing::init_no_event_loop();
40
41        let mut compiler = Compiler::default();
42        compiler.compiler_configuration(i_slint_core::InternalToken).debug_hooks =
43            Some(std::hash::RandomState::new());
44        let compile_result = spin_on::spin_on(compiler.build_from_source_path(
45            code.to_string(),
46            test_path(),
47            i_slint_core::InternalToken,
48        ));
49        assert!(!compile_result.has_errors(), "{:?}", compile_result.diagnostics);
50        compile_result.components().next().unwrap().create().unwrap()
51    }
52
53    fn install_debug_hook_store(instance: &ComponentInstance) -> Store {
54        let store: Store = Default::default();
55        {
56            let store = Rc::clone(&store);
57            instance.set_debug_hook_callback(Some(Box::new(move |id: &str| -> Option<Value> {
58                let mut m = (*store).borrow_mut();
59                let p = m.entry(SmolStr::from(id)).or_insert_with(|| Box::pin(Property::new(None)));
60                p.as_ref().get()
61            })));
62        }
63        store
64    }
65
66    fn set_override(store: &Store, element_hash: u64, name: &str, value: Option<Value>) {
67        let id = i_slint_compiler::passes::property_id(element_hash, &SmolStr::from(name));
68        let mut store = (*store).borrow_mut();
69        let override_property = store.entry(id).or_insert_with(|| Box::pin(Property::new(None)));
70        (&**override_property).set(value);
71    }
72
73    // Make sure to not actually write this file, it's just a synthetic path
74    pub fn test_path() -> SourcePath {
75        SourcePath::new("/tmp/test.slint")
76    }
77
78    fn find_element_hash(instance: &ComponentInstance, code: &str, search_term: &str) -> u64 {
79        let offset = code.find(search_term).unwrap() as u32;
80        let (element, debug_index) = instance
81            .element_node_at_source_code_position(&test_path(), offset)
82            .first()
83            .cloned()
84            .expect("element resolved");
85        let element_hash = element.borrow().debug[debug_index].element_hash;
86        assert_ne!(element_hash, 0, "debug_hooks should populate element_hash");
87        element_hash
88    }
89
90    fn geometry_at(
91        instance: &ComponentInstance,
92        code: &str,
93        search_term: &str,
94    ) -> i_slint_core::lengths::LogicalRect {
95        let offset = code.find(search_term).unwrap() as u32;
96        instance.component_positions(&test_path(), offset).first().expect("geometry").rect
97    }
98
99    // Editor-style override store + callback (must be installed before the first evaluation so
100    // the hooked bindings register a dependency on the override properties while still `None`).
101    type Store = Rc<RefCell<HashMap<SmolStr, Pin<Box<Property<Option<Value>>>>>>>;
102
103    // Validates the "live drag" mechanism the visual editor relies on: with `debug_hooks`
104    // enabled, a `set_debug_hook_callback` that reads a per-id `Property<Option<Value>>` lets the
105    // editor reactively override a property's value (and revert it) without touching the source.
106    // Setting the override property re-evaluates the hooked binding via Slint's dependency tracker.
107    #[test]
108    fn debug_hook_live_override() {
109        let code = r#"
110export component Win inherits Window {
111    width: 300px;
112    height: 300px;
113    rect := Rectangle {
114        x: 10px;
115        y: 20px;
116        width: 30px;
117        height: 40px;
118    }
119}"#;
120
121        let instance = compile_with_debug_hooks(code);
122
123        let element_hash = find_element_hash(&instance, code, "Rectangle");
124
125        let store = install_debug_hook_store(&instance);
126
127        let base = geometry_at(&instance, code, "Rectangle");
128
129        set_override(&store, element_hash, "x", Some(Value::Number(100.0)));
130        set_override(&store, element_hash, "width", Some(Value::Number(70.0)));
131        let after = geometry_at(&instance, code, "Rectangle");
132        assert!(
133            after.origin.x.approx_eq(&(base.origin.x + 90.0)),
134            "x override should shift the element by 90px (base {}, after {})",
135            base.origin.x,
136            after.origin.x
137        );
138        assert!(
139            after.size.width.approx_eq(&(base.size.width + 40.0)),
140            "width override should grow the element by 40px (base {}, after {})",
141            base.size.width,
142            after.size.width
143        );
144
145        set_override(&store, element_hash, "x", None);
146        set_override(&store, element_hash, "width", None);
147        let reverted = geometry_at(&instance, code, "Rectangle");
148        assert!(reverted.origin.x.approx_eq(&base.origin.x), "x should revert");
149        assert!(reverted.size.width.approx_eq(&base.size.width), "width should revert");
150    }
151
152    #[test]
153    fn debug_hook_component_instance_override() {
154        let code = r#"
155component Sub inherits Rectangle {
156    in property <color> tint: blue;
157    background: tint;
158}
159export component Win inherits Window {
160    width: 300px;
161    height: 300px;
162    sub := Sub { x: 10px; y: 20px; width: 50px; height: 50px; }
163    for _idx in 2: Sub { width: 10px; height: 10px; }
164    out property <brush> sub-background: sub.background;
165}"#;
166        let instance = compile_with_debug_hooks(code);
167
168        let store = install_debug_hook_store(&instance);
169
170        let blue = Value::Brush(i_slint_core::Brush::SolidColor(i_slint_core::Color::from_rgb_u8(
171            0, 0, 255,
172        )));
173        assert_eq!(instance.get_property("sub-background").unwrap(), blue);
174
175        let element_hash = find_element_hash(&instance, code, "Sub {");
176
177        let red = Value::Brush(i_slint_core::Brush::SolidColor(i_slint_core::Color::from_rgb_u8(
178            255, 0, 0,
179        )));
180        set_override(&store, element_hash, "background", Some(red.clone()));
181        assert_eq!(instance.get_property("sub-background").unwrap(), red);
182        set_override(&store, element_hash, "background", None);
183        assert_eq!(instance.get_property("sub-background").unwrap(), blue);
184
185        let base = geometry_at(&instance, code, "Sub {");
186        set_override(&store, element_hash, "x", Some(Value::Number(110.0)));
187        let after = geometry_at(&instance, code, "Sub {");
188        assert!(
189            (after.origin.x - base.origin.x - 100.0).abs() < 0.5,
190            "x override should shift the instance by 100px (base {}, after {})",
191            base.origin.x,
192            after.origin.x
193        );
194        set_override(&store, element_hash, "x", None);
195
196        // Overriding the injected transform-rotation hook must be possible (the Transform
197        // wrapper element is reified around the instance) and must not affect the geometry.
198        set_override(&store, element_hash, "transform-rotation", Some(Value::Number(45.0)));
199        let rotated = geometry_at(&instance, code, "Sub {");
200        assert!(
201            (rotated.origin.x - base.origin.x).abs() < 0.5,
202            "rotation must not move the origin"
203        );
204        set_override(&store, element_hash, "transform-rotation", None);
205    }
206
207    #[test]
208    fn debug_hook_forwards_live_private_state_per_instance() {
209        let code = r#"
210component Sub inherits Rectangle {
211    in property <length> seed;
212    in property <bool> active;
213    in-out property <length> inherited-value: private-child.x;
214    private-child := Rectangle { x: root.seed; }
215    states [
216        active when root.active: {
217            private-child.x: 50px;
218        }
219    ]
220}
221export component Win inherits Window {
222    in-out property <length> first-seed: 10px;
223    in-out property <bool> first-active;
224    first := Sub { seed: root.first-seed; active: root.first-active; }
225    second := Sub { seed: 20px; }
226    out property <length> first-value: first.inherited-value;
227    out property <length> second-value: second.inherited-value;
228}"#;
229        let instance = compile_with_debug_hooks(code);
230        let store = install_debug_hook_store(&instance);
231        let first_hash = find_element_hash(&instance, code, "Sub { seed: root.first-seed");
232
233        assert_eq!(instance.get_property("first-value").unwrap(), Value::Number(10.0));
234        assert_eq!(instance.get_property("second-value").unwrap(), Value::Number(20.0));
235
236        instance.set_property("first-seed", Value::Number(15.0)).unwrap();
237        assert_eq!(instance.get_property("first-value").unwrap(), Value::Number(15.0));
238        assert_eq!(instance.get_property("second-value").unwrap(), Value::Number(20.0));
239
240        instance.set_property("first-active", Value::Bool(true)).unwrap();
241        assert_eq!(instance.get_property("first-value").unwrap(), Value::Number(50.0));
242
243        set_override(&store, first_hash, "inherited-value", Some(Value::Number(90.0)));
244        instance.set_property("first-active", Value::Bool(false)).unwrap();
245        instance.set_property("first-seed", Value::Number(25.0)).unwrap();
246        assert_eq!(instance.get_property("first-value").unwrap(), Value::Number(90.0));
247        assert_eq!(instance.get_property("second-value").unwrap(), Value::Number(20.0));
248
249        set_override(&store, first_hash, "inherited-value", None);
250        assert_eq!(instance.get_property("first-value").unwrap(), Value::Number(25.0));
251        assert_eq!(instance.get_property("second-value").unwrap(), Value::Number(20.0));
252    }
253
254    // Regression test: debug hooks inject bindings for properties the element may not have
255    // natively (geometry, transform-rotation). Every injected binding must end up on a property
256    // that actually exists at runtime, for every kind of element — otherwise instantiation
257    // aborts with "unknown property ... in ...". Exercise the special cases: the root element,
258    // plain items, elements that become component roots later (PopupWindow — plain or through
259    // component inheritance), non-item types (Timer), repeated and conditional elements,
260    // layouts, menus, style widgets, and tooltips with custom content.
261    #[test]
262    fn debug_hooks_instantiate_special_elements() {
263        let code = r#"
264import { Button } from "std-widgets.slint";
265
266component MyPopup inherits PopupWindow {
267    Rectangle { background: yellow; }
268}
269
270export component Win inherits Window {
271    width: 300px;
272    height: 300px;
273
274    MenuBar {
275        Menu {
276            title: "File";
277            MenuItem { title: "Quit"; }
278        }
279    }
280
281    rect := Rectangle {
282        rotated := Rectangle { transform-rotation: 45deg; }
283        scaled := Rectangle { transform-scale: 150%; }
284        plain := Rectangle { }
285    }
286
287    covered := Rectangle {
288        Tooltip {
289            Rectangle { background: #222; }
290        }
291    }
292
293    Button { text: "a widget"; }
294
295    popup := PopupWindow {
296        Text { text: "popup content"; }
297    }
298    my-popup := MyPopup { }
299    callback show-the-popups();
300    show-the-popups() => { popup.show(); my-popup.show(); }
301
302    Timer { interval: 1s; running: false; }
303
304    for _ in 3: Rectangle { width: 10px; }
305    if true: Rectangle { height: 5px; }
306
307    VerticalLayout {
308        Rectangle { }
309    }
310}"#;
311
312        let instance = compile_with_debug_hooks(code);
313
314        // Showing the popups instantiates the popup components (their bindings are only set up then).
315        instance.invoke("show-the-popups", &[]).unwrap();
316    }
317
318    // Enabling debug_hooks now also materializes hooked default bindings for unbound properties.
319    // This must NOT change the rendered result when no override is set: wrapping default-geometry
320    // bindings must preserve fill/implicit sizing, and injecting the type-default for unbound props
321    // must equal their unbound value (e.g. the font sentinel that drives Window inheritance).
322    #[test]
323    fn debug_hooks_preserve_geometry() {
324        i_slint_backend_testing::init_no_event_loop();
325
326        let code = r#"
327export component Win inherits Window {
328    width: 300px;
329    height: 200px;
330    rect := Rectangle { }            // no explicit geometry -> fills the parent
331    txt := Text { text: "Hello"; }   // implicit (font-dependent) size, inherited font
332}"#;
333        let geometries = |debug_hooks: bool| -> Vec<(f32, f32, f32, f32)> {
334            let mut compiler = Compiler::default();
335            if debug_hooks {
336                compiler.compiler_configuration(i_slint_core::InternalToken).debug_hooks =
337                    Some(std::hash::RandomState::new());
338            }
339            let r = spin_on::spin_on(compiler.build_from_source_path(
340                code.to_string(),
341                test_path(),
342                i_slint_core::InternalToken,
343            ));
344            assert!(!r.has_errors(), "{:?}", r.diagnostics);
345            let instance = r.components().next().unwrap().create().unwrap();
346            [code.find("Rectangle").unwrap(), code.find("Text").unwrap()]
347                .into_iter()
348                .map(|offset| {
349                    let geometry = instance
350                        .component_positions(&test_path(), offset as u32)
351                        .first()
352                        .expect("geometry")
353                        .rect;
354                    (
355                        geometry.origin.x,
356                        geometry.origin.y,
357                        geometry.size.width,
358                        geometry.size.height,
359                    )
360                })
361                .collect()
362        };
363
364        let without = geometries(false);
365        let with = geometries(true);
366        for (a, b) in without.iter().zip(with.iter()) {
367            assert!(
368                (a.0 - b.0).abs() < 0.5
369                    && (a.1 - b.1).abs() < 0.5
370                    && (a.2 - b.2).abs() < 0.5
371                    && (a.3 - b.3).abs() < 0.5,
372                "geometry differs with vs without debug_hooks: {a:?} vs {b:?}"
373            );
374        }
375        // Sanity: the Rectangle actually filled the 300x200 window (so we know we compared real sizes).
376        assert!((with[0].2 - 300.0).abs() < 0.5);
377        assert!((with[0].3 - 200.0).abs() < 0.5);
378    }
379}