1use frus_core::{Affine, Point, Rect, Scene};
7
8pub(crate) const PAN_FRICTION: f32 = 6.0;
10pub(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#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct InteractiveView {
28 pub scale: f32,
30 pub tx: f32,
32 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 pub fn matrix(&self) -> Affine {
49 Affine::scale(self.scale, self.scale).then(Affine::translation(self.tx, self.ty))
50 }
51
52 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 pub fn clamped(self, viewport: Rect) -> Self {
68 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 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 let f = new_scale / self.scale;
96 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
105pub 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 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 pub fn width(mut self, width: f32) -> Self {
137 self.width = Dimension::Length(width);
138 self
139 }
140
141 pub fn height(mut self, height: f32) -> Self {
143 self.height = Dimension::Length(height);
144 self
145 }
146
147 pub fn flex(mut self, grow: f32) -> Self {
149 self.flex_grow = grow;
150 self
151 }
152
153 pub fn min_scale(mut self, min: f32) -> Self {
155 self.min_scale = min;
156 self
157 }
158
159 pub fn max_scale(mut self, max: f32) -> Self {
161 self.max_scale = max;
162 self
163 }
164
165 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 }
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 #[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 #[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 #[test]
235 fn zoom_keeps_the_cursor_point_fixed() {
236 let cursor = Point::new(100.0, 60.0);
237 let before = InteractiveView::default();
238 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 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 #[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 #[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 #[test]
291 fn clamp_keeps_zoomed_content_covering() {
292 let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
293 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 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 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 #[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 assert!(
342 (c.tx - 50.0).abs() < 1e-4 && (c.ty - 50.0).abs() < 1e-4,
343 "centred: {c:?}"
344 );
345 }
346
347 #[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 #[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 #[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 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 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}