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    /// Whether the width and the height were given; an axis that was not takes the room on
117    /// offer, as the reference's does (milestone 590).
118    sized: (bool, bool),
119    flex_grow: f32,
120    min_scale: f32,
121    max_scale: f32,
122    children: Vec<Box<dyn Widget<Msg>>>,
123}
124
125impl<Msg> InteractiveViewer<Msg> {
126    /// An empty interactive viewport (the scale bounded to `0.5×`–`4×` by default).
127    pub fn new() -> Self {
128        Self {
129            width: Dimension::Auto,
130            height: Dimension::Length(300.0),
131            sized: (false, false),
132            flex_grow: 0.0,
133            min_scale: 0.5,
134            max_scale: 4.0,
135            children: Vec::new(),
136        }
137    }
138
139    /// The viewport's fixed width, in logical pixels.
140    pub fn width(mut self, width: f32) -> Self {
141        self.width = Dimension::Length(width);
142        self.sized.0 = true;
143        self
144    }
145
146    /// The viewport's fixed height, in logical pixels.
147    pub fn height(mut self, height: f32) -> Self {
148        self.height = Dimension::Length(height);
149        self.sized.1 = true;
150        self
151    }
152
153    /// Flex growth factor along the parent's main axis.
154    pub fn flex(mut self, grow: f32) -> Self {
155        self.flex_grow = grow;
156        self
157    }
158
159    /// The **minimum** scale allowed (zooming out).
160    pub fn min_scale(mut self, min: f32) -> Self {
161        self.min_scale = min;
162        self
163    }
164
165    /// The **maximum** scale allowed (zooming in).
166    pub fn max_scale(mut self, max: f32) -> Self {
167        self.max_scale = max;
168        self
169    }
170
171    /// Sets the pannable and zoomable child.
172    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
173        self.children.clear();
174        self.children.push(Box::new(child));
175        self
176    }
177}
178
179impl<Msg> Default for InteractiveViewer<Msg> {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185impl<Msg: Clone> Widget<Msg> for InteractiveViewer<Msg> {
186    fn style(&self) -> Style {
187        Style {
188            width: self.width,
189            height: self.height,
190            flex_grow: self.flex_grow,
191            ..Default::default()
192        }
193    }
194
195    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
196        &self.children
197    }
198
199    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
200        // The viewport is transparent: only the transformed content is drawn.
201    }
202
203    fn on_click(&self) -> Option<Msg> {
204        None
205    }
206
207    /// **All the room on offer** on an axis nobody sized, as the reference's takes
208    /// (milestone 590) — unless it was told to grow, which says the same thing already.
209    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
210        if self.flex_grow > 0.0 {
211            return crate::widget::FillAxes::NONE;
212        }
213        crate::widget::FillAxes {
214            horizontal: !self.sized.0,
215            vertical: !self.sized.1,
216        }
217    }
218
219    /// The default size is only a default: the room replaces it wherever the layout can
220    /// give the room, and it stands where the reference would have nothing to give.
221    fn soft_extent(&self) -> crate::widget::FillAxes {
222        crate::widget::FillAxes {
223            horizontal: false,
224            vertical: !self.sized.1,
225        }
226    }
227
228    fn interactive(&self) -> Option<(f32, f32)> {
229        Some((self.min_scale, self.max_scale))
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// The identity places the content as is: `matrix` is the identity.
238    #[test]
239    fn default_is_identity() {
240        let m = InteractiveView::default().matrix();
241        let p = m.apply(Point::new(30.0, 40.0));
242        assert!(
243            (p.x - 30.0).abs() < 1e-4 && (p.y - 40.0).abs() < 1e-4,
244            "identity: {p:?}"
245        );
246    }
247
248    /// Panning offsets the content by the exact delta.
249    #[test]
250    fn pan_shifts_the_content() {
251        let v = InteractiveView::default().pan(12.0, -5.0);
252        let p = v.matrix().apply(Point::new(0.0, 0.0));
253        assert!(
254            (p.x - 12.0).abs() < 1e-4 && (p.y + 5.0).abs() < 1e-4,
255            "offset: {p:?}"
256        );
257    }
258
259    /// Zooming keeps **the point under the cursor fixed**: that screen point receives
260    /// the same point of the content before and after the zoom.
261    #[test]
262    fn zoom_keeps_the_cursor_point_fixed() {
263        let cursor = Point::new(100.0, 60.0);
264        let before = InteractiveView::default();
265        // The point of the content currently under the cursor (identity → = cursor).
266        let content_under = {
267            let inv = before.matrix().inverse();
268            inv.apply(cursor)
269        };
270        let after = before.zoom_at(2.0, cursor, 0.5, 4.0);
271        assert!((after.scale - 2.0).abs() < 1e-4, "×2: {}", after.scale);
272        // That same point of the content must reproject **onto the cursor**.
273        let reprojected = after.matrix().apply(content_under);
274        assert!(
275            (reprojected.x - cursor.x).abs() < 1e-3 && (reprojected.y - cursor.y).abs() < 1e-3,
276            "the point under the cursor stays fixed: {reprojected:?}"
277        );
278    }
279
280    /// Zooming is **clamped**: beyond `max`, the scale saturates and the point under
281    /// the cursor stays fixed (a nil effective factor).
282    #[test]
283    fn zoom_clamps_to_max() {
284        let cursor = Point::new(50.0, 50.0);
285        let v = InteractiveView {
286            scale: 4.0,
287            tx: 10.0,
288            ty: 20.0,
289        };
290        let z = v.zoom_at(2.0, cursor, 0.5, 4.0);
291        assert!(
292            (z.scale - 4.0).abs() < 1e-4,
293            "saturated at max: {}",
294            z.scale
295        );
296        assert!(
297            (z.tx - 10.0).abs() < 1e-4 && (z.ty - 20.0).abs() < 1e-4,
298            "unchanged at the edge"
299        );
300    }
301
302    /// At scale 1, panning is **cancelled** by the clamping (the content fills the
303    /// viewport exactly).
304    #[test]
305    fn clamp_pins_pan_at_scale_one() {
306        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
307        let c = InteractiveView::default().pan(50.0, -30.0).clamped(vp);
308        assert!(
309            c.tx.abs() < 1e-4 && c.ty.abs() < 1e-4,
310            "pan cancelled at scale 1: {c:?}"
311        );
312    }
313
314    /// Zoomed ×2, panning is clamped so that the content **covers** the viewport: an
315    /// excessive pan is brought back to the edge (the ×2 content spans `[t, t+400]`
316    /// and must cover `[0, 200]` → `t ∈ [-200, 0]`).
317    #[test]
318    fn clamp_keeps_zoomed_content_covering() {
319        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
320        // A pan too far right (positive t) → brought back to 0 (content's left edge = 0).
321        let a = InteractiveView {
322            scale: 2.0,
323            tx: 500.0,
324            ty: 0.0,
325        }
326        .clamped(vp);
327        assert!(
328            (a.tx - 0.0).abs() < 1e-4,
329            "clamped at the left edge: {}",
330            a.tx
331        );
332        // A pan too far left → brought back to -200 (content's right edge = 200).
333        let b = InteractiveView {
334            scale: 2.0,
335            tx: -900.0,
336            ty: 0.0,
337        }
338        .clamped(vp);
339        assert!(
340            (b.tx + 200.0).abs() < 1e-4,
341            "clamped at the right edge: {}",
342            b.tx
343        );
344        // A moderate pan passes through unchanged.
345        let c = InteractiveView {
346            scale: 2.0,
347            tx: -100.0,
348            ty: -50.0,
349        }
350        .clamped(vp);
351        assert!(
352            (c.tx + 100.0).abs() < 1e-4 && (c.ty + 50.0).abs() < 1e-4,
353            "a valid pan is preserved"
354        );
355    }
356
357    /// Zoomed out (< 1), content smaller than the viewport is **centred**.
358    #[test]
359    fn clamp_centers_shrunken_content() {
360        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
361        let c = InteractiveView {
362            scale: 0.5,
363            tx: 999.0,
364            ty: -999.0,
365        }
366        .clamped(vp);
367        // Centred: t = (1 − 0.5)·(o + len/2) = 0.5·100 = 50 on each axis.
368        assert!(
369            (c.tx - 50.0).abs() < 1e-4 && (c.ty - 50.0).abs() < 1e-4,
370            "centred: {c:?}"
371        );
372    }
373
374    /// The walk wraps the child in **a layer transformed and clipped to the
375    /// viewport**: a `Primitive::Layer` carrying a matrix and a clip = the viewport.
376    #[test]
377    fn walk_emits_a_transformed_clipped_layer() {
378        use crate::Container;
379        use frus_core::{Color, Primitive, Size};
380        let root = InteractiveViewer::<()>::new()
381            .width(200.0)
382            .height(200.0)
383            .child(Container::new().color(Color::rgb(1.0, 0.0, 0.0)));
384        let rt = crate::runtime::Runtime::default();
385        let theme = crate::Theme::dark();
386        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
387        let (has_xform, clip) = ui
388            .scene()
389            .primitives()
390            .iter()
391            .find_map(|p| match p {
392                Primitive::Layer {
393                    transform, clip, ..
394                } => Some((transform.is_some(), *clip)),
395                _ => None,
396            })
397            .expect("un calque interactif");
398        assert!(has_xform, "the layer carries the transform matrix");
399        assert!(
400            clip.width <= 200.5 && clip.height <= 200.5,
401            "clipped to the viewport: {clip:?}"
402        );
403    }
404
405    /// A regression: a sibling placed **after** an interactive viewport keeps its
406    /// layout place. The viewport is a layout **leaf** (its subtree is laid out
407    /// separately); without that, the rectangle index desynchronises and everything
408    /// after it overlaps. Here the viewport is 150 tall → the sibling follows at y=150.
409    #[test]
410    fn sibling_after_viewer_keeps_its_layout_position() {
411        use crate::{Container, Flex};
412        use frus_core::{Color, Primitive, Size};
413        let green = Color::rgb(0.0, 1.0, 0.0);
414        let root = Flex::<()>::column()
415            .width(300.0)
416            .child(
417                InteractiveViewer::new()
418                    .width(300.0)
419                    .height(150.0)
420                    .child(Container::new().color(Color::rgb(0.2, 0.2, 0.2))),
421            )
422            .child(Container::new().width(300.0).height(20.0).color(green));
423        let rt = crate::runtime::Runtime::default();
424        let theme = crate::Theme::dark();
425        let ui = crate::ui::build_ui(&root, Size::new(300.0, 600.0), &rt, &theme);
426        let marker_y = ui
427            .scene()
428            .primitives()
429            .iter()
430            .find_map(|p| match p {
431                Primitive::Rect { rect, color, .. } if color.g > 0.5 && color.r < 0.5 => {
432                    Some(rect.y)
433                }
434                _ => None,
435            })
436            .expect("the green marker");
437        assert!(
438            (marker_y - 150.0).abs() < 0.5,
439            "the sibling follows the viewport (150 tall), not overlapping: y = {marker_y}"
440        );
441    }
442
443    /// Hit-testing **goes through the transformation**: after a pan of +50 in x, a
444    /// click at the moved position reaches the child, and its former position misses.
445    #[test]
446    fn walk_pan_shifts_the_hit_test() {
447        use crate::interaction::WidgetId;
448        use crate::Container;
449        use frus_core::{Color, Size};
450        let root = InteractiveViewer::<i32>::new()
451            .width(200.0)
452            .height(200.0)
453            .child(
454                Container::new()
455                    .width(200.0)
456                    .height(200.0)
457                    .color(Color::rgb(1.0, 0.0, 0.0))
458                    .on_click(9),
459            );
460        let theme = crate::Theme::dark();
461
462        // Identity: the left edge (x = 10) reaches the child.
463        let rt = crate::runtime::Runtime::default();
464        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
465        assert!(
466            ui.hit(Point::new(10.0, 100.0)).is_some(),
467            "identity: the left edge is reached"
468        );
469
470        // After a pan of +50 in x: the content is pushed right; x = 10 falls outside
471        // the content (M⁻¹ = -40), but x = 60 lands back in it (M⁻¹ = 10).
472        let mut rt = crate::runtime::Runtime::default();
473        rt.interactive
474            .insert(WidgetId::ROOT, InteractiveView::default().pan(50.0, 0.0));
475        let ui = crate::ui::build_ui(&root, Size::new(200.0, 200.0), &rt, &theme);
476        assert!(
477            ui.hit(Point::new(10.0, 100.0)).is_none(),
478            "pan: the former position misses"
479        );
480        assert!(
481            ui.hit(Point::new(60.0, 100.0)).is_some(),
482            "pan: the moved position is reached"
483        );
484    }
485}