Skip to main content

frus_widgets/
interactive.rs

1//! [`InteractiveViewer`]: a viewport that lets its child be **panned** and
2//! **zoomed**. The transformation (scale + translation) is **state retained** in
3//! the runtime, driven by the shell's gestures (drag to pan, wheel or pinch to
4//! zoom).
5
6use frus_core::{Affine, Point, Rect, Scene};
7
8/// The friction of a released pan's *fling* (exponential decay, per second).
9pub(crate) const PAN_FRICTION: f32 = 6.0;
10/// Below this speed (px/s), the fling stops and comes to rest.
11pub(crate) const PAN_MIN_VELOCITY: f32 = 24.0;
12use frus_layout::{Dimension, Style};
13
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18/// The **retained** state of an [`InteractiveViewer`]: the scale factor and the
19/// translation applied to the child. The screen point `q` receives the content
20/// painted flat at `p` according to `q = scale · p + (tx, ty)`. The identity
21/// (`scale = 1`, zero translation) places the child as is in the viewport.
22///
23/// All the **gesture mathematics** lives here (pure, testable): the shell merely
24/// calls [`pan`](InteractiveView::pan) and [`zoom_at`](InteractiveView::zoom_at),
25/// then renders the [`matrix`](InteractiveView::matrix).
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct InteractiveView {
28    /// The current scale factor (`1.0` = natural size).
29    pub scale: f32,
30    /// The paint translation, in logical pixels.
31    pub tx: f32,
32    /// The paint translation along the y axis, in logical pixels; `tx` is the x axis.
33    pub ty: f32,
34}
35
36impl Default for InteractiveView {
37    fn default() -> Self {
38        Self {
39            scale: 1.0,
40            tx: 0.0,
41            ty: 0.0,
42        }
43    }
44}
45
46impl InteractiveView {
47    /// The matrix `q = scale · p + t` (scaling about the origin, then translation).
48    pub fn matrix(&self) -> Affine {
49        Affine::scale(self.scale, self.scale).then(Affine::translation(self.tx, self.ty))
50    }
51
52    /// **Pans** the content by `(dx, dy)` screen pixels: the finger or cursor pushes
53    /// the content by the same delta.
54    pub fn pan(self, dx: f32, dy: f32) -> Self {
55        Self {
56            tx: self.tx + dx,
57            ty: self.ty + dy,
58            ..self
59        }
60    }
61
62    /// **Clamps** the translation so that the content (at the current scale) always
63    /// **covers** the `viewport`: an edge of the content cannot be dragged inside
64    /// the viewport. When the content is **smaller** than the viewport (zoomed out
65    /// below 1), it is **centred**. At scale 1, panning is nil (the content fills
66    /// exactly).
67    pub fn clamped(self, viewport: Rect) -> Self {
68        // Screen content `q = s·p + t`, with `p` covering the viewport at scale 1. The
69        // content's left edge ≤ the viewport's left edge → `t ≤ (1−s)·o`; its right
70        // edge ≥ the viewport's right edge → `t ≥ (1−s)·(o+len)`. For `s < 1` the
71        // interval flips → centre instead.
72        let clamp_axis = |t: f32, s: f32, o: f32, len: f32| -> f32 {
73            let hi = (1.0 - s) * o;
74            let lo = (1.0 - s) * (o + len);
75            if lo <= hi {
76                t.clamp(lo, hi)
77            } else {
78                (lo + hi) * 0.5
79            }
80        };
81        Self {
82            scale: self.scale,
83            tx: clamp_axis(self.tx, self.scale, viewport.x, viewport.width),
84            ty: clamp_axis(self.ty, self.scale, viewport.y, viewport.height),
85        }
86    }
87
88    /// **Zooms** by `factor` while keeping the screen point `cursor` fixed (a
89    /// cursor-anchored zoom), with the final scale clamped to `[min, max]`. The
90    /// point of the content under the cursor does not move — the behaviour expected
91    /// of a wheel or a pinch.
92    pub fn zoom_at(self, factor: f32, cursor: Point, min: f32, max: f32) -> Self {
93        let new_scale = (self.scale * factor).clamp(min, max);
94        // The **effective** factor after clamping (nil if already at the edge).
95        let f = new_scale / self.scale;
96        // Pins the point under the cursor: t' = cursor·(1 - f) + f·t.
97        Self {
98            scale: new_scale,
99            tx: cursor.x * (1.0 - f) + f * self.tx,
100            ty: cursor.y * (1.0 - f) + f * self.ty,
101        }
102    }
103}
104
105/// A **pannable and zoomable** viewport: its child fills the viewport at scale 1,
106/// then the user pans it (by dragging) and zooms it (wheel or pinch) about the
107/// cursor. Any content that overflows is **clipped** to the viewport. Ideal for a
108/// map, a detailed image, a plan, a diagram.
109///
110/// Like [`crate::SingleChildScrollView`], the viewport needs a **bounded size** (otherwise it
111/// collapses): a fixed `width`/`height`, or `flex` within a column or row. The
112/// scale is bounded by `min_scale` / `max_scale`.
113pub struct InteractiveViewer<Msg = crate::callback::Callback> {
114    width: Dimension,
115    height: Dimension,
116    flex_grow: f32,
117    min_scale: f32,
118    max_scale: f32,
119    children: Vec<Box<dyn Widget<Msg>>>,
120}
121
122impl<Msg> InteractiveViewer<Msg> {
123    /// An empty interactive viewport (the scale bounded to `0.5×`–`4×` by default).
124    pub fn new() -> Self {
125        Self {
126            width: Dimension::Auto,
127            height: Dimension::Length(300.0),
128            flex_grow: 0.0,
129            min_scale: 0.5,
130            max_scale: 4.0,
131            children: Vec::new(),
132        }
133    }
134
135    /// The viewport's fixed width, in logical pixels.
136    pub fn width(mut self, width: f32) -> Self {
137        self.width = Dimension::Length(width);
138        self
139    }
140
141    /// The viewport's fixed height, in logical pixels.
142    pub fn height(mut self, height: f32) -> Self {
143        self.height = Dimension::Length(height);
144        self
145    }
146
147    /// Flex growth factor along the parent's main axis.
148    pub fn flex(mut self, grow: f32) -> Self {
149        self.flex_grow = grow;
150        self
151    }
152
153    /// The **minimum** scale allowed (zooming out).
154    pub fn min_scale(mut self, min: f32) -> Self {
155        self.min_scale = min;
156        self
157    }
158
159    /// The **maximum** scale allowed (zooming in).
160    pub fn max_scale(mut self, max: f32) -> Self {
161        self.max_scale = max;
162        self
163    }
164
165    /// Sets the pannable and zoomable child.
166    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
167        self.children.clear();
168        self.children.push(Box::new(child));
169        self
170    }
171}
172
173impl<Msg> Default for InteractiveViewer<Msg> {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl<Msg: Clone> Widget<Msg> for InteractiveViewer<Msg> {
180    fn style(&self) -> Style {
181        Style {
182            width: self.width,
183            height: self.height,
184            flex_grow: self.flex_grow,
185            ..Default::default()
186        }
187    }
188
189    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
190        &self.children
191    }
192
193    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
194        // The viewport is transparent: only the transformed content is drawn.
195    }
196
197    fn on_click(&self) -> Option<Msg> {
198        None
199    }
200
201    fn interactive(&self) -> Option<(f32, f32)> {
202        Some((self.min_scale, self.max_scale))
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// The identity places the content as is: `matrix` is the identity.
211    #[test]
212    fn default_is_identity() {
213        let m = InteractiveView::default().matrix();
214        let p = m.apply(Point::new(30.0, 40.0));
215        assert!(
216            (p.x - 30.0).abs() < 1e-4 && (p.y - 40.0).abs() < 1e-4,
217            "identity: {p:?}"
218        );
219    }
220
221    /// Panning offsets the content by the exact delta.
222    #[test]
223    fn pan_shifts_the_content() {
224        let v = InteractiveView::default().pan(12.0, -5.0);
225        let p = v.matrix().apply(Point::new(0.0, 0.0));
226        assert!(
227            (p.x - 12.0).abs() < 1e-4 && (p.y + 5.0).abs() < 1e-4,
228            "offset: {p:?}"
229        );
230    }
231
232    /// Zooming keeps **the point under the cursor fixed**: that screen point receives
233    /// the same point of the content before and after the zoom.
234    #[test]
235    fn zoom_keeps_the_cursor_point_fixed() {
236        let cursor = Point::new(100.0, 60.0);
237        let before = InteractiveView::default();
238        // The point of the content currently under the cursor (identity → = cursor).
239        let content_under = {
240            let inv = before.matrix().inverse();
241            inv.apply(cursor)
242        };
243        let after = before.zoom_at(2.0, cursor, 0.5, 4.0);
244        assert!((after.scale - 2.0).abs() < 1e-4, "×2: {}", after.scale);
245        // That same point of the content must reproject **onto the cursor**.
246        let reprojected = after.matrix().apply(content_under);
247        assert!(
248            (reprojected.x - cursor.x).abs() < 1e-3 && (reprojected.y - cursor.y).abs() < 1e-3,
249            "the point under the cursor stays fixed: {reprojected:?}"
250        );
251    }
252
253    /// Zooming is **clamped**: beyond `max`, the scale saturates and the point under
254    /// the cursor stays fixed (a nil effective factor).
255    #[test]
256    fn zoom_clamps_to_max() {
257        let cursor = Point::new(50.0, 50.0);
258        let v = InteractiveView {
259            scale: 4.0,
260            tx: 10.0,
261            ty: 20.0,
262        };
263        let z = v.zoom_at(2.0, cursor, 0.5, 4.0);
264        assert!(
265            (z.scale - 4.0).abs() < 1e-4,
266            "saturated at max: {}",
267            z.scale
268        );
269        assert!(
270            (z.tx - 10.0).abs() < 1e-4 && (z.ty - 20.0).abs() < 1e-4,
271            "unchanged at the edge"
272        );
273    }
274
275    /// At scale 1, panning is **cancelled** by the clamping (the content fills the
276    /// viewport exactly).
277    #[test]
278    fn clamp_pins_pan_at_scale_one() {
279        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
280        let c = InteractiveView::default().pan(50.0, -30.0).clamped(vp);
281        assert!(
282            c.tx.abs() < 1e-4 && c.ty.abs() < 1e-4,
283            "pan cancelled at scale 1: {c:?}"
284        );
285    }
286
287    /// Zoomed ×2, panning is clamped so that the content **covers** the viewport: an
288    /// excessive pan is brought back to the edge (the ×2 content spans `[t, t+400]`
289    /// and must cover `[0, 200]` → `t ∈ [-200, 0]`).
290    #[test]
291    fn clamp_keeps_zoomed_content_covering() {
292        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
293        // A pan too far right (positive t) → brought back to 0 (content's left edge = 0).
294        let a = InteractiveView {
295            scale: 2.0,
296            tx: 500.0,
297            ty: 0.0,
298        }
299        .clamped(vp);
300        assert!(
301            (a.tx - 0.0).abs() < 1e-4,
302            "clamped at the left edge: {}",
303            a.tx
304        );
305        // A pan too far left → brought back to -200 (content's right edge = 200).
306        let b = InteractiveView {
307            scale: 2.0,
308            tx: -900.0,
309            ty: 0.0,
310        }
311        .clamped(vp);
312        assert!(
313            (b.tx + 200.0).abs() < 1e-4,
314            "clamped at the right edge: {}",
315            b.tx
316        );
317        // A moderate pan passes through unchanged.
318        let c = InteractiveView {
319            scale: 2.0,
320            tx: -100.0,
321            ty: -50.0,
322        }
323        .clamped(vp);
324        assert!(
325            (c.tx + 100.0).abs() < 1e-4 && (c.ty + 50.0).abs() < 1e-4,
326            "a valid pan is preserved"
327        );
328    }
329
330    /// Zoomed out (< 1), content smaller than the viewport is **centred**.
331    #[test]
332    fn clamp_centers_shrunken_content() {
333        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
334        let c = InteractiveView {
335            scale: 0.5,
336            tx: 999.0,
337            ty: -999.0,
338        }
339        .clamped(vp);
340        // Centred: t = (1 − 0.5)·(o + len/2) = 0.5·100 = 50 on each axis.
341        assert!(
342            (c.tx - 50.0).abs() < 1e-4 && (c.ty - 50.0).abs() < 1e-4,
343            "centred: {c:?}"
344        );
345    }
346
347    /// The walk wraps the child in **a layer transformed and clipped to the
348    /// viewport**: a `Primitive::Layer` carrying a matrix and a clip = the viewport.
349    #[test]
350    fn walk_emits_a_transformed_clipped_layer() {
351        use crate::Container;
352        use frus_core::{Color, Primitive, Size};
353        let root = InteractiveViewer::<()>::new()
354            .width(200.0)
355            .height(200.0)
356            .child(Container::new().color(Color::rgb(1.0, 0.0, 0.0)));
357        let rt = crate::runtime::Runtime::default();
358        let theme = crate::Theme::dark();
359        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
360        let (has_xform, clip) = ui
361            .scene()
362            .primitives()
363            .iter()
364            .find_map(|p| match p {
365                Primitive::Layer {
366                    transform, clip, ..
367                } => Some((transform.is_some(), *clip)),
368                _ => None,
369            })
370            .expect("un calque interactif");
371        assert!(has_xform, "the layer carries the transform matrix");
372        assert!(
373            clip.width <= 200.5 && clip.height <= 200.5,
374            "clipped to the viewport: {clip:?}"
375        );
376    }
377
378    /// A regression: a sibling placed **after** an interactive viewport keeps its
379    /// layout place. The viewport is a layout **leaf** (its subtree is laid out
380    /// separately); without that, the rectangle index desynchronises and everything
381    /// after it overlaps. Here the viewport is 150 tall → the sibling follows at y=150.
382    #[test]
383    fn sibling_after_viewer_keeps_its_layout_position() {
384        use crate::{Container, Flex};
385        use frus_core::{Color, Primitive, Size};
386        let green = Color::rgb(0.0, 1.0, 0.0);
387        let root = Flex::<()>::column()
388            .width(300.0)
389            .child(
390                InteractiveViewer::new()
391                    .width(300.0)
392                    .height(150.0)
393                    .child(Container::new().color(Color::rgb(0.2, 0.2, 0.2))),
394            )
395            .child(Container::new().width(300.0).height(20.0).color(green));
396        let rt = crate::runtime::Runtime::default();
397        let theme = crate::Theme::dark();
398        let ui = crate::ui::build_ui(&root, Size::new(300.0, 600.0), &rt, &theme);
399        let marker_y = ui
400            .scene()
401            .primitives()
402            .iter()
403            .find_map(|p| match p {
404                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
405                    Some(rect.y)
406                }
407                _ => None,
408            })
409            .expect("the green marker");
410        assert!(
411            (marker_y - 150.0).abs() < 0.5,
412            "the sibling follows the viewport (150 tall), not overlapping: y = {marker_y}"
413        );
414    }
415
416    /// Hit-testing **goes through the transformation**: after a pan of +50 in x, a
417    /// click at the moved position reaches the child, and its former position misses.
418    #[test]
419    fn walk_pan_shifts_the_hit_test() {
420        use crate::interaction::WidgetId;
421        use crate::Container;
422        use frus_core::{Color, Size};
423        let root = InteractiveViewer::<i32>::new()
424            .width(200.0)
425            .height(200.0)
426            .child(
427                Container::new()
428                    .width(200.0)
429                    .height(200.0)
430                    .color(Color::rgb(1.0, 0.0, 0.0))
431                    .on_click(9),
432            );
433        let theme = crate::Theme::dark();
434
435        // Identity: the left edge (x = 10) reaches the child.
436        let rt = crate::runtime::Runtime::default();
437        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
438        assert!(
439            ui.hit(Point::new(10.0, 100.0)).is_some(),
440            "identity: the left edge is reached"
441        );
442
443        // After a pan of +50 in x: the content is pushed right; x = 10 falls outside
444        // the content (M⁻¹ = -40), but x = 60 lands back in it (M⁻¹ = 10).
445        let mut rt = crate::runtime::Runtime::default();
446        rt.interactive
447            .insert(WidgetId::ROOT, InteractiveView::default().pan(50.0, 0.0));
448        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
449        assert!(
450            ui.hit(Point::new(10.0, 100.0)).is_none(),
451            "pan: the former position misses"
452        );
453        assert!(
454            ui.hit(Point::new(60.0, 100.0)).is_some(),
455            "pan: the moved position is reached"
456        );
457    }
458}