Skip to main content

floem_test/
lib.rs

1//! Testing utilities for Floem UI applications.
2//!
3//! This crate provides a headless harness for testing Floem views without
4//! creating an actual window.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use floem_test::prelude::*;
10//!
11//! #[test]
12//! fn test_button_click() {
13//!     let tracker = ClickTracker::new();
14//!
15//!     let view = tracker.track(
16//!         Empty::new().style(|s| s.size(100.0, 100.0))
17//!     );
18//!
19//!     let mut harness = HeadlessHarness::new_with_size(view, 100.0, 100.0);
20//!     harness.click(50.0, 50.0);
21//!
22//!     assert!(tracker.was_clicked());
23//! }
24//! ```
25//!
26//! # Testing Z-Index / Layered Views
27//!
28//! ```rust,ignore
29//! use floem_test::prelude::*;
30//!
31//! #[test]
32//! fn test_z_index_click_order() {
33//!     let tracker = ClickTracker::new();
34//!
35//!     // Create overlapping views with different z-indices
36//!     let view = layers((
37//!         tracker.track_named("back", Empty::new().z_index(1)),
38//!         tracker.track_named("front", Empty::new().z_index(10)),
39//!     ));
40//!
41//!     let mut harness = HeadlessHarness::new_with_size(view, 100.0, 100.0);
42//!     harness.click(50.0, 50.0);
43//!
44//!     // Front (z-index 10) should receive the click
45//!     assert_eq!(tracker.clicked_names(), vec!["front"]);
46//! }
47//! ```
48
49use std::cell::{Cell, RefCell};
50use std::rc::Rc;
51
52use floem::kurbo::Vec2;
53use floem::prelude::*;
54use floem::views::scroll::ScrollChanged;
55use floem::views::{Decorators, Stack};
56
57// Re-export the headless harness from floem
58pub use floem::headless::*;
59
60// Re-export commonly used floem types for convenience
61pub use floem::ViewId;
62
63/// Prelude module for convenient imports in tests.
64pub mod prelude {
65    pub use super::{ClickTracker, PointerCaptureTracker, ScrollTracker, layer, layers};
66    pub use floem::ViewId;
67    pub use floem::event::PointerId;
68    pub use floem::headless::*;
69    pub use floem::prelude::*;
70    pub use floem::views::{Container, Decorators, Empty, Scroll, Stack};
71}
72
73/// Tracks click events on views for testing.
74///
75/// This helper makes it easy to verify which views received click events
76/// and in what order.
77///
78/// # Example
79///
80/// ```rust,ignore
81/// let tracker = ClickTracker::new();
82///
83/// let view = tracker.track(my_view);
84/// // ... click the view ...
85/// assert!(tracker.was_clicked());
86/// ```
87#[derive(Clone, Default)]
88pub struct ClickTracker {
89    clicks: Rc<RefCell<Vec<Option<String>>>>,
90    count: Rc<Cell<usize>>,
91    double_clicks: Rc<RefCell<Vec<Option<String>>>>,
92    double_click_count: Rc<Cell<usize>>,
93    secondary_clicks: Rc<RefCell<Vec<Option<String>>>>,
94    secondary_click_count: Rc<Cell<usize>>,
95}
96
97impl ClickTracker {
98    /// Create a new click tracker.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Wrap a view to track when it receives clicks.
104    ///
105    /// The view will have `on_click_stop` added to it.
106    pub fn track<V: IntoView>(&self, view: V) -> impl IntoView + use<V> {
107        let clicks = self.clicks.clone();
108        let count = self.count.clone();
109        view.into_view().action(move || {
110            clicks.borrow_mut().push(None);
111            count.set(count.get() + 1);
112        })
113    }
114
115    /// Wrap a view to track when it receives clicks, with a name for identification.
116    ///
117    /// The view will have `on_click_stop` added to it.
118    pub fn track_named<V: IntoView>(&self, name: &str, view: V) -> impl IntoView + use<V> {
119        let clicks = self.clicks.clone();
120        let count = self.count.clone();
121        let name = name.to_string();
122        view.into_view().action(move || {
123            clicks.borrow_mut().push(Some(name.clone()));
124            count.set(count.get() + 1);
125        })
126    }
127
128    /// Wrap a view to track when it receives clicks, with a name for identification.
129    ///
130    /// The view will have `on_click_cont` added to it, allowing the event to bubble.
131    pub fn track_named_cont<V: IntoView>(&self, name: &str, view: V) -> impl IntoView + use<V> {
132        let clicks = self.clicks.clone();
133        let count = self.count.clone();
134        let name = name.to_string();
135        view.into_view()
136            .on_event_cont(listener::Click, move |_, _| {
137                clicks.borrow_mut().push(Some(name.clone()));
138                count.set(count.get() + 1);
139            })
140    }
141
142    /// Returns true if any tracked view was clicked.
143    pub fn was_clicked(&self) -> bool {
144        self.count.get() > 0
145    }
146
147    /// Returns the number of clicks recorded.
148    pub fn click_count(&self) -> usize {
149        self.count.get()
150    }
151
152    /// Returns the names of clicked views in order.
153    ///
154    /// Views tracked without names will be represented as `None`.
155    pub fn clicked(&self) -> Vec<Option<String>> {
156        self.clicks.borrow().clone()
157    }
158
159    /// Returns just the names of clicked views (ignoring unnamed ones).
160    pub fn clicked_names(&self) -> Vec<String> {
161        self.clicks
162            .borrow()
163            .iter()
164            .filter_map(|n| n.clone())
165            .collect()
166    }
167
168    /// Reset the tracker, clearing all recorded clicks.
169    pub fn reset(&self) {
170        self.clicks.borrow_mut().clear();
171        self.count.set(0);
172        self.double_clicks.borrow_mut().clear();
173        self.double_click_count.set(0);
174        self.secondary_clicks.borrow_mut().clear();
175        self.secondary_click_count.set(0);
176    }
177
178    /// Wrap a view to track when it receives double clicks, with a name for identification.
179    ///
180    /// The view will have `on_double_click_stop` added to it.
181    pub fn track_double_click<V: IntoView>(&self, name: &str, view: V) -> impl IntoView + use<V> {
182        let clicks = self.double_clicks.clone();
183        let count = self.double_click_count.clone();
184        let name = name.to_string();
185        view.into_view()
186            .on_event_stop(listener::DoubleClick, move |_, _| {
187                clicks.borrow_mut().push(Some(name.clone()));
188                count.set(count.get() + 1);
189            })
190    }
191
192    /// Returns the number of double clicks recorded.
193    pub fn double_click_count(&self) -> usize {
194        self.double_click_count.get()
195    }
196
197    /// Returns the names of double-clicked views in order.
198    pub fn double_clicked_names(&self) -> Vec<String> {
199        self.double_clicks
200            .borrow()
201            .iter()
202            .filter_map(|n| n.clone())
203            .collect()
204    }
205
206    /// Wrap a view to track when it receives secondary (right) clicks, with a name.
207    ///
208    /// The view will have `on_secondary_click_stop` added to it.
209    pub fn track_secondary_click<V: IntoView>(
210        &self,
211        name: &str,
212        view: V,
213    ) -> impl IntoView + use<V> {
214        let clicks = self.secondary_clicks.clone();
215        let count = self.secondary_click_count.clone();
216        let name = name.to_string();
217        view.into_view()
218            .on_event_stop(listener::SecondaryClick, move |_, _| {
219                clicks.borrow_mut().push(Some(name.clone()));
220                count.set(count.get() + 1);
221            })
222    }
223
224    /// Returns the number of secondary (right) clicks recorded.
225    pub fn secondary_click_count(&self) -> usize {
226        self.secondary_click_count.get()
227    }
228
229    /// Returns the names of secondary-clicked views in order.
230    pub fn secondary_clicked_names(&self) -> Vec<String> {
231        self.secondary_clicks
232            .borrow()
233            .iter()
234            .filter_map(|n| n.clone())
235            .collect()
236    }
237}
238
239/// Create a single layer (absolute positioned view that fills its container).
240///
241/// This is useful for creating overlapping views for z-index testing.
242///
243/// # Example
244///
245/// ```rust,ignore
246/// let view = layer(Empty::new().z_index(5));
247/// ```
248pub fn layer(view: impl IntoView) -> impl IntoView {
249    view.into_view()
250        .style(|s| s.absolute().inset(0.0).size_full())
251}
252
253/// Create a stack of overlapping layers.
254///
255/// Each child view is positioned absolutely to fill the container,
256/// making them overlap. This is useful for testing z-index behavior.
257///
258/// # Example
259///
260/// ```rust,ignore
261/// let view = layers((
262///     Empty::new().z_index(1),  // Back layer
263///     Empty::new().z_index(10), // Front layer
264/// ));
265/// ```
266pub fn layers<VT: ViewTuple + 'static>(children: VT) -> impl IntoView {
267    // Convert each child to a layer with absolute positioning
268    let children_iter = children
269        .into_views()
270        .into_iter()
271        .map(|v| v.style(|s| s.absolute().inset(0.0).size_full()));
272
273    Stack::from_iter(children_iter).style(|s| s.size_full())
274}
275
276/// Tracks scroll events on Scroll views for testing.
277///
278/// This helper records viewport changes from scroll events, making it easy
279/// to verify scroll behavior in tests.
280///
281/// # Example
282///
283/// ```rust,ignore
284/// let scroll_tracker = ScrollTracker::new();
285///
286/// let content = Empty::new().style(|s| s.size(200.0, 400.0));
287/// let scroll_view = scroll_tracker.track(Scroll::new(content));
288///
289/// let mut harness = HeadlessHarness::new_with_size(scroll_view, 100.0, 100.0);
290/// harness.scroll_vertical(50.0, 50.0, 50.0);
291///
292/// let viewport = scroll_tracker.last_viewport().unwrap();
293/// assert!(viewport.y0 > 0.0, "Should have scrolled down");
294/// ```
295/// Kurbo types re-exported for convenience.
296pub use floem::kurbo::{Point, Rect};
297
298#[derive(Clone, Default)]
299pub struct ScrollTracker {
300    offsets: Rc<RefCell<Vec<Vec2>>>,
301}
302
303impl ScrollTracker {
304    /// Create a new scroll tracker.
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Wrap a Scroll view to track its viewport changes.
310    pub fn track(&self, scroll: floem::views::Scroll) -> floem::views::Scroll {
311        let viewports = self.offsets.clone();
312        scroll.on_event_stop(ScrollChanged::listener(), move |_cx, state| {
313            viewports.borrow_mut().push(state.offset);
314        })
315    }
316
317    /// Returns true if any scroll events have been recorded.
318    pub fn has_scrolled(&self) -> bool {
319        !self.offsets.borrow().is_empty()
320    }
321
322    /// Returns the number of scroll events recorded.
323    pub fn scroll_count(&self) -> usize {
324        self.offsets.borrow().len()
325    }
326
327    /// Returns the last recorded viewport, if any.
328    pub fn last_offset(&self) -> Option<Vec2> {
329        self.offsets.borrow().last().copied()
330    }
331
332    /// Returns all recorded viewports in order.
333    pub fn offsets(&self) -> Vec<Vec2> {
334        self.offsets.borrow().clone()
335    }
336
337    /// Returns the current scroll position (top-left of viewport).
338    pub fn scroll_position(&self) -> Option<Point> {
339        self.last_offset().map(|v| v.to_point())
340    }
341
342    /// Reset the tracker, clearing all recorded viewports.
343    pub fn reset(&self) {
344        self.offsets.borrow_mut().clear();
345    }
346}
347
348/// Type alias for pointer event tracking with optional pointer ID.
349type PointerEventLog = Rc<RefCell<Vec<(String, Option<floem::event::PointerId>)>>>;
350
351/// Tracks pointer capture events on views for testing.
352///
353/// This helper makes it easy to verify which views received GainedPointerCapture
354/// and LostPointerCapture events.
355///
356/// # Example
357///
358/// ```rust,ignore
359/// let tracker = PointerCaptureTracker::new();
360///
361/// let view = tracker.track("my_view", my_view);
362/// // ... set pointer capture ...
363/// assert!(tracker.got_capture_count() > 0);
364/// ```
365#[derive(Clone, Default)]
366pub struct PointerCaptureTracker {
367    gained_captures: Rc<RefCell<Vec<(String, floem::event::PointerId)>>>,
368    lost_captures: Rc<RefCell<Vec<(String, floem::event::PointerId)>>>,
369    pointer_downs: PointerEventLog,
370    pointer_moves: PointerEventLog,
371    pointer_ups: PointerEventLog,
372}
373
374impl PointerCaptureTracker {
375    /// Create a new pointer capture tracker.
376    pub fn new() -> Self {
377        Self::default()
378    }
379
380    /// Wrap a view to track pointer capture events with a name.
381    pub fn track<V: IntoView>(&self, name: &str, view: V) -> impl IntoView + use<V> {
382        let got_captures = self.gained_captures.clone();
383        let lost_captures = self.lost_captures.clone();
384        let pointer_downs = self.pointer_downs.clone();
385        let pointer_moves = self.pointer_moves.clone();
386        let pointer_ups = self.pointer_ups.clone();
387        let name = name.to_string();
388
389        let name_got = name.clone();
390        let name_lost = name.clone();
391        let name_down = name.clone();
392        let name_move = name.clone();
393        let name_up = name.clone();
394
395        view.into_view()
396            .on_event(listener::GainedPointerCapture, move |_cx, drag_token| {
397                got_captures
398                    .borrow_mut()
399                    .push((name_got.clone(), drag_token.pointer_id()));
400                floem::event::EventPropagation::Continue
401            })
402            .on_event(listener::LostPointerCapture, move |_cx, pointer_id| {
403                lost_captures
404                    .borrow_mut()
405                    .push((name_lost.clone(), *pointer_id));
406                floem::event::EventPropagation::Continue
407            })
408            .on_event(listener::PointerDown, move |_cx, pe| {
409                pointer_downs
410                    .borrow_mut()
411                    .push((name_down.clone(), pe.pointer.pointer_id));
412                floem::event::EventPropagation::Continue
413            })
414            .on_event(listener::PointerMove, move |_cx, pu| {
415                pointer_moves
416                    .borrow_mut()
417                    .push((name_move.clone(), pu.pointer.pointer_id));
418                floem::event::EventPropagation::Continue
419            })
420            .on_event(listener::PointerUp, move |_cx, pe| {
421                pointer_ups
422                    .borrow_mut()
423                    .push((name_up.clone(), pe.pointer.pointer_id));
424                floem::event::EventPropagation::Continue
425            })
426    }
427
428    /// Returns the number of GainedPointerCapture events recorded.
429    pub fn gained_capture_count(&self) -> usize {
430        self.gained_captures.borrow().len()
431    }
432
433    /// Returns the number of LostPointerCapture events recorded.
434    pub fn lost_capture_count(&self) -> usize {
435        self.lost_captures.borrow().len()
436    }
437
438    /// Returns the names of views that got pointer capture, in order.
439    pub fn got_capture_names(&self) -> Vec<String> {
440        self.gained_captures
441            .borrow()
442            .iter()
443            .map(|(name, _)| name.clone())
444            .collect()
445    }
446
447    /// Returns the names of views that lost pointer capture, in order.
448    pub fn lost_capture_names(&self) -> Vec<String> {
449        self.lost_captures
450            .borrow()
451            .iter()
452            .map(|(name, _)| name.clone())
453            .collect()
454    }
455
456    /// Returns the names of views that received PointerDown events, in order.
457    pub fn pointer_down_names(&self) -> Vec<String> {
458        self.pointer_downs
459            .borrow()
460            .iter()
461            .map(|(name, _)| name.clone())
462            .collect()
463    }
464
465    /// Returns the names of views that received PointerMove events, in order.
466    pub fn pointer_move_names(&self) -> Vec<String> {
467        self.pointer_moves
468            .borrow()
469            .iter()
470            .map(|(name, _)| name.clone())
471            .collect()
472    }
473
474    /// Returns the names of views that received PointerUp events, in order.
475    pub fn pointer_up_names(&self) -> Vec<String> {
476        self.pointer_ups
477            .borrow()
478            .iter()
479            .map(|(name, _)| name.clone())
480            .collect()
481    }
482
483    /// Reset the tracker, clearing all recorded events.
484    pub fn reset(&self) {
485        self.gained_captures.borrow_mut().clear();
486        self.lost_captures.borrow_mut().clear();
487        self.pointer_downs.borrow_mut().clear();
488        self.pointer_moves.borrow_mut().clear();
489        self.pointer_ups.borrow_mut().clear();
490    }
491}