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 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 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 pub fn width(mut self, width: f32) -> Self {
141 self.width = Dimension::Length(width);
142 self.sized.0 = true;
143 self
144 }
145
146 pub fn height(mut self, height: f32) -> Self {
148 self.height = Dimension::Length(height);
149 self.sized.1 = true;
150 self
151 }
152
153 pub fn flex(mut self, grow: f32) -> Self {
155 self.flex_grow = grow;
156 self
157 }
158
159 pub fn min_scale(mut self, min: f32) -> Self {
161 self.min_scale = min;
162 self
163 }
164
165 pub fn max_scale(mut self, max: f32) -> Self {
167 self.max_scale = max;
168 self
169 }
170
171 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 }
202
203 fn on_click(&self) -> Option<Msg> {
204 None
205 }
206
207 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 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 #[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 #[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 #[test]
262 fn zoom_keeps_the_cursor_point_fixed() {
263 let cursor = Point::new(100.0, 60.0);
264 let before = InteractiveView::default();
265 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 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 #[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 #[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 #[test]
318 fn clamp_keeps_zoomed_content_covering() {
319 let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
320 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 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 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 #[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 assert!(
369 (c.tx - 50.0).abs() < 1e-4 && (c.ty - 50.0).abs() < 1e-4,
370 "centred: {c:?}"
371 );
372 }
373
374 #[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 #[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 #[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 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 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}