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