Skip to main content

i_slint_backend_testing/
testing_backend.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 i_slint_core::api::PhysicalSize;
5use i_slint_core::graphics::euclid::{Point2D, Size2D};
6use i_slint_core::item_rendering::HasFont;
7use i_slint_core::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize};
8use i_slint_core::platform::PlatformError;
9use i_slint_core::renderer::{Renderer, RendererSealed};
10use i_slint_core::textlayout::sharedparley;
11use i_slint_core::window::{
12    InputMethodRequest, WindowAdapter, WindowAdapterInternal, WindowInner, WindowKind,
13};
14
15use i_slint_core::SharedString;
16use i_slint_core::items::TextWrap;
17use std::cell::{Cell, RefCell};
18use std::collections::HashMap;
19use std::pin::Pin;
20use std::rc::{Rc, Weak};
21use std::sync::Mutex;
22
23std::thread_local! {
24    /// Live windows targeted by [`ensure_all_tracked_trees_instantiated`].
25    static ALL_TESTING_WINDOWS: RefCell<Vec<Weak<TestingWindow>>> =
26        const { RefCell::new(Vec::new()) }
27}
28
29/// Run the `ensure_instantiated` repeater instantiation pass on every live
30/// testing window.
31fn ensure_all_tracked_trees_instantiated() {
32    let live: Vec<Rc<TestingWindow>> = ALL_TESTING_WINDOWS.with(|list| {
33        let mut list = list.borrow_mut();
34        list.retain(|w| w.upgrade().is_some());
35        list.iter().filter_map(|w| w.upgrade()).collect()
36    });
37    for tw in live {
38        WindowInner::from_pub(&tw.window).ensure_tree_instantiated();
39    }
40}
41
42/// Advance the mocked time by the given number of milliseconds, updating
43/// animations, firing timers, running change handlers, and instantiating
44/// pending repeaters and conditionals on every live testing window.
45pub fn mock_elapsed_time(time_in_ms: u64) {
46    let tick = i_slint_core::animations::CURRENT_ANIMATION_DRIVER.with(|driver| {
47        let mut tick = driver.current_tick();
48        tick += core::time::Duration::from_millis(time_in_ms);
49        driver.update_animations(tick);
50        tick
51    });
52    i_slint_core::timers::TimerList::maybe_activate_timers(tick);
53    i_slint_core::properties::ChangeTracker::run_change_handlers();
54    ensure_all_tracked_trees_instantiated();
55}
56
57/// Return the current mocked time in milliseconds.
58pub fn get_mocked_time() -> u64 {
59    i_slint_core::animations::CURRENT_ANIMATION_DRIVER
60        .with(|driver| driver.current_tick())
61        .as_millis()
62}
63
64#[cfg(any(feature = "internal", feature = "ffi"))]
65/// Simulate a click at (`x`, `y`) and release after 50 ms of mock time.
66pub fn send_mouse_click(x: f32, y: f32, window_adapter: &i_slint_core::window::WindowAdapterRc) {
67    use i_slint_core::api::LogicalPosition;
68    use i_slint_core::items::PointerEventButton;
69    use i_slint_core::platform::WindowEvent;
70
71    let position = LogicalPosition::new(x, y);
72    let button = PointerEventButton::Left;
73
74    window_adapter.window().dispatch_event(WindowEvent::PointerMoved { position });
75    window_adapter.window().dispatch_event(WindowEvent::PointerPressed { position, button });
76    mock_elapsed_time(50);
77    window_adapter.window().dispatch_event(WindowEvent::PointerReleased { position, button });
78}
79
80#[cfg(any(feature = "internal", feature = "ffi"))]
81/// Dispatch a single key press or release event.
82pub fn send_keyboard_key_text(
83    text: &i_slint_core::SharedString,
84    pressed: bool,
85    window_adapter: &i_slint_core::window::WindowAdapterRc,
86) {
87    use i_slint_core::platform::WindowEvent;
88    window_adapter.window().dispatch_event(if pressed {
89        WindowEvent::KeyPressed { text: text.clone() }
90    } else {
91        WindowEvent::KeyReleased { text: text.clone() }
92    })
93}
94
95#[cfg(feature = "ffi")]
96/// Dispatch each character in the string as a separate key event.
97pub fn send_keyboard_char(
98    string: &i_slint_core::SharedString,
99    pressed: bool,
100    window_adapter: &i_slint_core::window::WindowAdapterRc,
101) {
102    for ch in string.chars() {
103        send_keyboard_key_text(&ch.into(), pressed, window_adapter);
104    }
105}
106
107#[cfg(any(feature = "internal", feature = "ffi"))]
108/// Simulate typing a string, with automatic Shift handling for uppercase letters.
109pub fn send_keyboard_string_sequence(
110    sequence: &i_slint_core::SharedString,
111    window_adapter: &i_slint_core::window::WindowAdapterRc,
112) {
113    use i_slint_core::input::key_codes::Key;
114    use i_slint_core::platform::WindowEvent;
115
116    for ch in sequence.chars() {
117        if ch.is_ascii_uppercase() {
118            window_adapter
119                .window()
120                .dispatch_event(WindowEvent::KeyPressed { text: Key::Shift.into() });
121        }
122
123        let text: i_slint_core::SharedString = ch.into();
124        window_adapter.window().dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
125        window_adapter.window().dispatch_event(WindowEvent::KeyReleased { text });
126
127        if ch.is_ascii_uppercase() {
128            window_adapter
129                .window()
130                .dispatch_event(WindowEvent::KeyReleased { text: Key::Shift.into() });
131        }
132    }
133}
134
135const FIXED_TEST_FONT: &str = "FixedTestFont";
136
137fn is_fixed_test_font(family: &Option<SharedString>) -> bool {
138    family.as_ref().is_some_and(|f| f == FIXED_TEST_FONT)
139}
140
141#[derive(Default)]
142pub struct TestingBackendOptions {
143    pub mock_time: bool,
144    pub threading: bool,
145    /// When set, windows embed a real rasterizer so headless rendering
146    /// (e.g. `Window::take_snapshot`) works. Recognized names: `software`,
147    /// `skia`; an empty string or `default` picks the best available.
148    /// When `None`, the backend keeps its mock renderer with fixed font
149    /// metrics.
150    #[cfg(supports_headless)]
151    pub renderer_name: Option<SharedString>,
152}
153
154pub struct TestingBackend {
155    clipboard: Mutex<Option<String>>,
156    queue: Option<Queue>,
157    mock_time: bool,
158    pub open_url: Rc<RefCell<Option<SharedString>>>,
159    pub debug_logs: Rc<RefCell<Vec<String>>>,
160    #[cfg(supports_headless)]
161    renderer_name: Option<SharedString>,
162}
163
164impl TestingBackend {
165    pub fn new(options: TestingBackendOptions) -> Self {
166        Self {
167            clipboard: Mutex::default(),
168            queue: options.threading.then(|| Queue(Default::default(), std::thread::current())),
169            mock_time: options.mock_time,
170            open_url: Default::default(),
171            debug_logs: Default::default(),
172            #[cfg(supports_headless)]
173            renderer_name: options.renderer_name,
174        }
175    }
176}
177
178impl i_slint_core::platform::Platform for TestingBackend {
179    fn create_window_adapter(
180        &self,
181    ) -> Result<Rc<dyn WindowAdapter>, i_slint_core::platform::PlatformError> {
182        #[cfg(supports_headless)]
183        let renderer =
184            self.renderer_name.as_ref().map(|name| create_headless_renderer(name)).transpose()?;
185        let window = Rc::new_cyclic(|self_weak| TestingWindow {
186            window: i_slint_core::api::Window::new(self_weak.clone() as _),
187            size: Default::default(),
188            ime_requests: Default::default(),
189            mouse_cursor: Default::default(),
190            all_item_trees: Default::default(),
191            open_url: self.open_url.clone(),
192            debug_logs: self.debug_logs.clone(),
193            native_popup: Cell::new(false),
194            #[cfg(supports_headless)]
195            renderer_name: self.renderer_name.clone(),
196            #[cfg(supports_headless)]
197            renderer,
198        });
199        ALL_TESTING_WINDOWS.with(|list| list.borrow_mut().push(Rc::downgrade(&window)));
200        Ok(window)
201    }
202
203    fn duration_since_start(&self) -> core::time::Duration {
204        if self.mock_time {
205            // The slint::testing::mock_elapsed_time updates the animation tick directly
206            core::time::Duration::from_millis(i_slint_core::animations::current_tick().0)
207        } else {
208            static INITIAL_INSTANT: std::sync::OnceLock<std::time::Instant> =
209                std::sync::OnceLock::new();
210            let the_beginning = *INITIAL_INSTANT.get_or_init(std::time::Instant::now);
211            std::time::Instant::now() - the_beginning
212        }
213    }
214
215    fn set_clipboard_text(&self, text: &str, clipboard: i_slint_core::platform::Clipboard) {
216        if clipboard == i_slint_core::platform::Clipboard::DefaultClipboard {
217            *self.clipboard.lock().unwrap() = Some(text.into());
218        }
219    }
220
221    fn clipboard_text(&self, clipboard: i_slint_core::platform::Clipboard) -> Option<String> {
222        if clipboard == i_slint_core::platform::Clipboard::DefaultClipboard {
223            self.clipboard.lock().unwrap().clone()
224        } else {
225            None
226        }
227    }
228
229    fn run_event_loop(&self) -> Result<(), PlatformError> {
230        let queue = match self.queue.as_ref() {
231            Some(queue) => queue.clone(),
232            None => return Err(PlatformError::NoEventLoopProvider),
233        };
234
235        loop {
236            let e = queue.0.lock().unwrap().pop_front();
237            if !self.mock_time {
238                i_slint_core::platform::update_timers_and_animations();
239            }
240            match e {
241                Some(Event::Quit) => break Ok(()),
242                Some(Event::Event(e)) => e(),
243                None => match i_slint_core::platform::duration_until_next_timer_update() {
244                    Some(duration) if !self.mock_time => std::thread::park_timeout(duration),
245                    _ => std::thread::park(),
246                },
247            }
248        }
249    }
250
251    fn new_event_loop_proxy(&self) -> Option<Box<dyn i_slint_core::platform::EventLoopProxy>> {
252        self.queue
253            .as_ref()
254            .map(|q| Box::new(q.clone()) as Box<dyn i_slint_core::platform::EventLoopProxy>)
255    }
256
257    fn open_url(&self, url: &str) -> Result<(), PlatformError> {
258        *self.open_url.borrow_mut() = Some(url.into());
259        Ok(())
260    }
261
262    fn debug_log(&self, arguments: core::fmt::Arguments) {
263        self.debug_logs.borrow_mut().push(arguments.to_string());
264        i_slint_core::debug_log::default_log_message(arguments);
265    }
266}
267
268#[derive(Default)]
269struct CheckAllItemTreesUnregistered(RefCell<HashMap<*const u8, SharedString>>);
270
271impl Drop for CheckAllItemTreesUnregistered {
272    fn drop(&mut self) {
273        if !std::thread::panicking() {
274            assert!(
275                self.0.borrow().is_empty(),
276                "Some item trees were not unregistered: {:?}",
277                self.0.borrow().values()
278            );
279        }
280    }
281}
282
283pub struct TestingWindow {
284    window: i_slint_core::api::Window,
285    size: Cell<PhysicalSize>,
286    pub ime_requests: RefCell<Vec<InputMethodRequest>>,
287    mouse_cursor: Cell<i_slint_core::items::MouseCursor>,
288    all_item_trees: CheckAllItemTreesUnregistered,
289    pub open_url: Rc<RefCell<Option<SharedString>>>,
290    pub debug_logs: Rc<RefCell<Vec<String>>>,
291    native_popup: Cell<bool>,
292    /// Remembered for child popups, so they pick the same rasterizer.
293    #[cfg(supports_headless)]
294    renderer_name: Option<SharedString>,
295    /// Rasterizer returned by `WindowAdapter::renderer` when headless
296    /// rendering was requested, so every `RendererSealed` call routes through
297    /// it. `None` keeps the mock renderer with its fixed test font metrics.
298    #[cfg(supports_headless)]
299    renderer: Option<Box<dyn Renderer>>,
300}
301
302impl TestingWindow {
303    pub fn use_native_popup(&self, native: bool) {
304        self.native_popup.set(native);
305    }
306
307    #[allow(dead_code)] // Used by various tests
308    pub fn mouse_cursor(&self) -> i_slint_core::items::MouseCursor {
309        self.mouse_cursor.get()
310    }
311
312    #[allow(dead_code)]
313    pub fn open_url(&self) -> Option<SharedString> {
314        self.open_url.borrow().clone()
315    }
316
317    /// Drain and return all debug_log messages captured since the last call.
318    pub fn take_debug_log(&self) -> Vec<String> {
319        self.debug_logs.borrow_mut().drain(..).collect()
320    }
321}
322
323impl WindowAdapterInternal for TestingWindow {
324    fn input_method_request(&self, request: i_slint_core::window::InputMethodRequest) {
325        self.ime_requests.borrow_mut().push(request)
326    }
327
328    fn set_mouse_cursor(&self, cursor: i_slint_core::items::MouseCursor) {
329        self.mouse_cursor.set(cursor);
330    }
331
332    fn register_item_tree(&self, item_tree: i_slint_core::item_tree::ItemTreeRefPin) {
333        let mut debug = SharedString::new();
334        item_tree.as_ref().item_element_infos(0, &mut debug);
335        assert_eq!(
336            self.all_item_trees.0.borrow_mut().insert(item_tree.as_ptr(), debug.clone()),
337            None,
338            "Item tree already registered {debug:?}"
339        );
340    }
341
342    fn unregister_item_tree(
343        &self,
344        item_tree: i_slint_core::item_tree::ItemTreeRef,
345        _items: &mut dyn Iterator<Item = Pin<i_slint_core::items::ItemRef<'_>>>,
346    ) {
347        self.all_item_trees.0.borrow_mut().remove(&item_tree.as_ptr());
348    }
349
350    fn create_child_window_adapter(&self, _kind: WindowKind) -> Option<Rc<dyn WindowAdapter>> {
351        if self.native_popup.get() {
352            #[cfg(supports_headless)]
353            let renderer = self
354                .renderer_name
355                .as_ref()
356                .map(|name| create_headless_renderer(name))
357                .transpose()
358                .ok()?;
359            let window = Rc::new_cyclic(|self_weak| TestingWindow {
360                window: i_slint_core::api::Window::new(self_weak.clone() as _),
361                size: Default::default(),
362                ime_requests: Default::default(),
363                mouse_cursor: Default::default(),
364                all_item_trees: Default::default(),
365                open_url: self.open_url.clone(),
366                debug_logs: self.debug_logs.clone(),
367                native_popup: self.native_popup.clone(),
368                #[cfg(supports_headless)]
369                renderer_name: self.renderer_name.clone(),
370                #[cfg(supports_headless)]
371                renderer,
372            });
373            Some(window)
374        } else {
375            None
376        }
377    }
378}
379
380impl WindowAdapter for TestingWindow {
381    fn window(&self) -> &i_slint_core::api::Window {
382        &self.window
383    }
384
385    fn size(&self) -> PhysicalSize {
386        if self.size.get().width == 0 { PhysicalSize::new(800, 600) } else { self.size.get() }
387    }
388
389    fn set_size(&self, size: i_slint_core::api::WindowSize) {
390        self.window.dispatch_event(i_slint_core::platform::WindowEvent::Resized {
391            size: size.to_logical(1.),
392        });
393        self.size.set(size.to_physical(1.))
394    }
395
396    fn renderer(&self) -> &dyn Renderer {
397        #[cfg(supports_headless)]
398        if let Some(renderer) = &self.renderer {
399            return &**renderer;
400        }
401        self
402    }
403
404    fn update_window_properties(&self, properties: i_slint_core::window::WindowProperties<'_>) {
405        if self.size.get().width == 0 {
406            let c = properties.layout_constraints();
407            self.size.set(c.preferred.to_physical(self.window.scale_factor()));
408        }
409    }
410
411    fn internal(&self, _: i_slint_core::InternalToken) -> Option<&dyn WindowAdapterInternal> {
412        Some(self)
413    }
414}
415
416impl RendererSealed for TestingWindow {
417    fn text_size(
418        &self,
419        text_item: Pin<&dyn i_slint_core::item_rendering::RenderString>,
420        item_rc: &i_slint_core::item_tree::ItemRc,
421        max_width: Option<LogicalLength>,
422        text_wrap: TextWrap,
423    ) -> LogicalSize {
424        let font_request = text_item.font_request(item_rc);
425        if is_fixed_test_font(&font_request.family) {
426            let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
427            let text: String = match text_item.text() {
428                i_slint_core::item_rendering::PlainOrStyledText::Plain(s) => s.to_string(),
429                i_slint_core::item_rendering::PlainOrStyledText::Styled(s) => {
430                    i_slint_core::styled_text::get_raw_text(&s).into_owned()
431                }
432            };
433            let max_line_len = text.lines().map(|l: &str| l.len()).max().unwrap_or(0);
434            let num_lines = text.lines().count().max(1);
435            let width = max_line_len as f32 * pixel_size;
436            let height = num_lines as f32 * pixel_size;
437            LogicalSize::new(width, height)
438        } else {
439            sharedparley::text_size(self, text_item, item_rc, max_width, text_wrap, None)
440                .unwrap_or_default()
441        }
442    }
443
444    fn char_size(
445        &self,
446        text_item: Pin<&dyn i_slint_core::item_rendering::HasFont>,
447        item_rc: &i_slint_core::item_tree::ItemRc,
448        ch: char,
449    ) -> LogicalSize {
450        let font_request = text_item.font_request(item_rc);
451        if is_fixed_test_font(&font_request.family) {
452            let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
453            LogicalSize::new(pixel_size, pixel_size)
454        } else {
455            let Some(ctx) = self.slint_context() else {
456                return LogicalSize::default();
457            };
458            let mut font_ctx = ctx.font_context().borrow_mut();
459            sharedparley::char_size(&mut font_ctx, text_item, item_rc, ch).unwrap_or_default()
460        }
461    }
462
463    fn font_metrics(
464        &self,
465        font_request: i_slint_core::graphics::FontRequest,
466    ) -> i_slint_core::items::FontMetrics {
467        if is_fixed_test_font(&font_request.family) {
468            let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
469            i_slint_core::items::FontMetrics {
470                ascent: pixel_size * 0.7,
471                descent: -pixel_size * 0.3,
472                x_height: 3.,
473                cap_height: 7.,
474            }
475        } else {
476            let Some(ctx) = self.slint_context() else {
477                return Default::default();
478            };
479            let mut font_ctx = ctx.font_context().borrow_mut();
480            sharedparley::font_metrics(&mut font_ctx, font_request)
481        }
482    }
483
484    fn text_input_byte_offset_for_position(
485        &self,
486        text_input: Pin<&i_slint_core::items::TextInput>,
487        item_rc: &i_slint_core::item_tree::ItemRc,
488        pos: LogicalPoint,
489    ) -> usize {
490        let font_request = text_input.font_request(item_rc);
491        if is_fixed_test_font(&font_request.family) {
492            let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
493            let text = text_input.text();
494            if pos.y < 0. {
495                return 0;
496            }
497            let line = (pos.y / pixel_size) as usize;
498            let offset = if line >= 1 {
499                text.split('\n').take(line - 1).map(|l| l.len() + 1).sum()
500            } else {
501                0
502            };
503            let Some(line) = text.split('\n').nth(line) else {
504                return text.len();
505            };
506            let column = ((pos.x / pixel_size).max(0.) as usize).min(line.len());
507            offset + column
508        } else {
509            sharedparley::text_input_byte_offset_for_position(self, text_input, item_rc, pos)
510        }
511    }
512
513    fn text_input_cursor_rect_for_byte_offset(
514        &self,
515        text_input: Pin<&i_slint_core::items::TextInput>,
516        item_rc: &i_slint_core::item_tree::ItemRc,
517        byte_offset: usize,
518    ) -> LogicalRect {
519        let font_request = text_input.font_request(item_rc);
520        if is_fixed_test_font(&font_request.family) {
521            let pixel_size = font_request.pixel_size.map_or(10., |s| s.get());
522            let text = text_input.text();
523            let line = text[..byte_offset].chars().filter(|c| *c == '\n').count();
524            let column = text[..byte_offset].split('\n').nth(line).unwrap_or("").len();
525            LogicalRect::new(
526                Point2D::new(column as f32 * pixel_size, line as f32 * pixel_size),
527                Size2D::new(1., pixel_size),
528            )
529        } else {
530            sharedparley::text_input_cursor_rect_for_byte_offset(
531                self,
532                text_input,
533                item_rc,
534                byte_offset,
535            )
536        }
537    }
538
539    fn register_font_from_memory(
540        &self,
541        data: &'static [u8],
542    ) -> Result<(), Box<dyn std::error::Error>> {
543        let ctx = self.slint_context().ok_or("slint platform not initialized")?;
544        ctx.font_context().borrow_mut().register_static_font(data);
545        Ok(())
546    }
547
548    fn register_font_from_path(
549        &self,
550        path: &std::path::Path,
551    ) -> Result<(), Box<dyn std::error::Error>> {
552        let requested_path = path.canonicalize().unwrap_or_else(|_| path.into());
553        let contents = std::fs::read(requested_path)?;
554        let ctx = self.slint_context().ok_or("slint platform not initialized")?;
555        ctx.font_context().borrow_mut().collection.register_fonts(contents.into(), None);
556        Ok(())
557    }
558
559    fn set_window_adapter(&self, _window_adapter: &Rc<dyn WindowAdapter>) {
560        // No-op since TestingWindow is also the WindowAdapter
561    }
562
563    fn window_adapter(&self) -> Option<Rc<dyn WindowAdapter>> {
564        Some(WindowInner::from_pub(&self.window).window_adapter())
565    }
566
567    fn supports_transformations(&self) -> bool {
568        true
569    }
570}
571
572/// Pick the rasterizer for the headless backend.
573/// `""` / `"default"` picks Skia software when compiled in, else the
574/// built-in software renderer.
575#[cfg(supports_headless)]
576fn create_headless_renderer(name: &str) -> Result<Box<dyn Renderer>, PlatformError> {
577    match name {
578        #[cfg(skia_headless)]
579        "" | "default" | "skia" | "skia-software" => {
580            std::thread_local! {
581                /// Shared across all windows so they reuse Skia resources.
582                static SHARED_CONTEXT: i_slint_renderer_skia::SkiaSharedContext =
583                    Default::default();
584            }
585            SHARED_CONTEXT.with(|context| {
586                Ok(Box::new(i_slint_renderer_skia::SkiaRenderer::default_software(context)) as _)
587            })
588        }
589        #[cfg(all(feature = "renderer-software", not(skia_headless)))]
590        "" | "default" => Ok(Box::new(i_slint_renderer_software::SoftwareRenderer::new())),
591        #[cfg(feature = "renderer-software")]
592        "sw" | "software" => Ok(Box::new(i_slint_renderer_software::SoftwareRenderer::new())),
593        other => {
594            let available: &[&str] = &[
595                #[cfg(feature = "renderer-software")]
596                "software",
597                #[cfg(skia_headless)]
598                "skia",
599            ];
600            Err(PlatformError::Other(format!(
601                "Unknown headless renderer {other:?} (available: {})",
602                available.join(", ")
603            )))
604        }
605    }
606}
607
608enum Event {
609    Quit,
610    Event(Box<dyn FnOnce() + Send>),
611}
612#[derive(Clone)]
613struct Queue(
614    std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<Event>>>,
615    std::thread::Thread,
616);
617
618impl i_slint_core::platform::EventLoopProxy for Queue {
619    fn quit_event_loop(&self) -> Result<(), i_slint_core::api::EventLoopError> {
620        self.0.lock().unwrap().push_back(Event::Quit);
621        self.1.unpark();
622        Ok(())
623    }
624
625    fn invoke_from_event_loop(
626        &self,
627        event: Box<dyn FnOnce() + Send>,
628    ) -> Result<(), i_slint_core::api::EventLoopError> {
629        self.0.lock().unwrap().push_back(Event::Event(event));
630        self.1.unpark();
631        Ok(())
632    }
633}