1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_layout::Constraints;
4use rosace_render::PictureRecorder;
5use rosace_scroll::controller::{MAX_VELOCITY, COAST_STOP_THRESHOLD};
6use rosace_state::Atom;
7use super::{Widget, LayoutCtx, PaintCtx, TransformLayerEntry, avail_w, avail_h};
8use super::container::draw_rounded_rect_pub;
9
10pub struct InteractiveViewer<W: Widget + Send + Sync + 'static> {
27 pub child: W,
28 zoom: Atom<f32>,
29 min_scale: f32,
30 max_scale: f32,
31 constrained: bool,
35 zoom_controls: bool,
36}
37
38impl<W: Widget + Send + Sync + 'static> InteractiveViewer<W> {
39 pub fn new(child: W) -> Self {
40 Self {
41 child,
42 zoom: rosace_state::use_atom(1.0_f32),
43 min_scale: 0.5,
44 max_scale: 4.0,
45 constrained: true,
46 zoom_controls: true,
47 }
48 }
49
50 pub fn min_scale(mut self, v: f32) -> Self { self.min_scale = v; self }
51 pub fn max_scale(mut self, v: f32) -> Self { self.max_scale = v; self }
52 pub fn unconstrained(mut self) -> Self { self.constrained = false; self }
54 pub fn no_zoom_controls(mut self) -> Self { self.zoom_controls = false; self }
55}
56
57impl<W: Widget + Send + Sync + 'static> Widget for InteractiveViewer<W> {
58 fn layout(&self, ctx: &LayoutCtx) -> Size {
59 Size { width: avail_w(ctx.constraints), height: avail_h(ctx.constraints) }
62 }
63
64 fn paint(&self, ctx: &mut PaintCtx) {
65 let zoom = self.zoom.get().clamp(self.min_scale, self.max_scale);
66 let vp_rect = ctx.rect;
67
68 let child_lctx = ctx.layout_ctx(Constraints::loose(f32::INFINITY, f32::INFINITY));
71 let child_size = self.child.layout(&child_lctx);
72
73 let mut sub_rec = PictureRecorder::new();
74 let child_rect = Rect { origin: Point { x: 0.0, y: 0.0 }, size: child_size };
75 let sub_node = ctx.tree.borrow_mut().slot(ctx.node, true);
76 let mut sub_ctx = PaintCtx {
77 recorder: &mut sub_rec,
78 rect: child_rect,
79 font: ctx.font,
80 theme: ctx.theme.clone(),
81 tree: ctx.tree.clone(),
82 node: sub_node,
83 owner: ctx.owner,
84 clip_rect: None,
85 };
86 self.child.paint(&mut sub_ctx);
87 let picture = sub_rec.finish();
88
89 ctx.attach_transform(TransformLayerEntry {
90 picture,
91 child_size,
92 viewport_rect: vp_rect,
93 zoom,
94 scroll_x: 0.0,
95 scroll_y: 0.0,
96 });
97
98 let visible_w = vp_rect.size.width / zoom;
101 let visible_h = vp_rect.size.height / zoom;
102 let (max_x, max_y) = if self.constrained {
103 ((child_size.width - visible_w).max(0.0), (child_size.height - visible_h).max(0.0))
104 } else {
105 (f32::INFINITY, f32::INFINITY)
106 };
107
108 let node_id = ctx.node as u64;
109 {
111 let (max_x, max_y) = (max_x, max_y);
112 ctx.register_scroll_target(
113 vp_rect,
114 super::render_tree::ScrollAxes::BOTH,
115 Arc::new(move |dx, dy| {
116 rosace_state::scroll_offset_by(node_id, -dx, -dy, max_x, max_y);
117 }),
118 );
119 }
120
121 {
132 let (max_x, max_y) = (max_x, max_y);
133 ctx.on_press_at(move |px, py| {
134 if let Some((lx, ly, t0)) = rosace_state::drag_last(node_id) {
135 let dt = t0.elapsed().as_secs_f32().max(1.0 / 240.0);
136 let (dx, dy) = ((px - lx) / zoom, (py - ly) / zoom);
137 rosace_state::scroll_offset_by(node_id, -dx, -dy, max_x, max_y);
138 rosace_state::set_pan_velocity(node_id, (
139 (dx / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY),
140 (dy / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY),
141 ));
142 }
143 rosace_state::set_drag_last(node_id, Some((px, py)));
144 });
145 }
146
147 if !ctx.pressed() {
158 rosace_state::set_drag_last(node_id, None);
159 let (vx, vy) = rosace_state::pan_velocity(node_id);
160 if vx.abs() > COAST_STOP_THRESHOLD || vy.abs() > COAST_STOP_THRESHOLD {
161 let dt = rosace_animate::frame_dt().max(0.0001);
162 rosace_state::scroll_offset_by(node_id, -vx * dt, -vy * dt, max_x, max_y);
163 let friction = 0.88_f32.powf(dt / (1.0 / 60.0));
164 let (nvx, nvy) = (vx * friction, vy * friction);
165 let settled = nvx.abs() < COAST_STOP_THRESHOLD && nvy.abs() < COAST_STOP_THRESHOLD;
166 rosace_state::set_pan_velocity(node_id, if settled { (0.0, 0.0) } else { (nvx, nvy) });
167 if !settled {
168 ctx.request_animation();
169 }
170 }
171 }
172
173 {
179 let z = self.zoom.clone();
180 let (min_scale, max_scale) = (self.min_scale, self.max_scale);
181 ctx.register_zoom_target(vp_rect, Arc::new(move |delta| {
182 z.set((z.get() * (1.0 + delta)).clamp(min_scale, max_scale));
183 }));
184 }
185
186 ctx.semantics(super::Semantics::new(rosace_core::Role::Unknown)
187 .label("Interactive viewer".to_string()));
188
189 if self.zoom_controls {
190 self.paint_zoom_controls(ctx, vp_rect);
191 }
192 }
193}
194
195impl<W: Widget + Send + Sync + 'static> InteractiveViewer<W> {
196 fn paint_zoom_controls(&self, ctx: &mut PaintCtx, vp_rect: Rect) {
219 let (bg, fg) = {
220 let t = &ctx.theme.colors;
221 (ctx.tc(t.surface), ctx.tc(t.on_surface))
222 };
223 const BTN: f32 = 32.0;
224 const GAP: f32 = 8.0;
225 const MARGIN: f32 = 12.0;
226 let base_y = vp_rect.origin.y + vp_rect.size.height - MARGIN - BTN;
227 let minus_x = vp_rect.origin.x + vp_rect.size.width - MARGIN - BTN;
228 let plus_x = minus_x - GAP - BTN;
229
230 let (min_scale, max_scale) = (self.min_scale, self.max_scale);
231 let plus_rect = Rect { origin: Point { x: plus_x, y: base_y }, size: Size { width: BTN, height: BTN } };
232 let minus_rect = Rect { origin: Point { x: minus_x, y: base_y }, size: Size { width: BTN, height: BTN } };
233
234 draw_rounded_rect_pub(ctx, plus_rect, bg, 6.0);
235 let pw = ctx.font.measure_text("+", 16.0);
236 ctx.draw_text_at("+", Point {
237 x: plus_rect.origin.x + (BTN - pw) / 2.0,
238 y: super::vcenter_text_y(plus_rect.origin.y, BTN, ctx.font, 16.0),
239 }, fg, 16.0);
240
241 draw_rounded_rect_pub(ctx, minus_rect, bg, 6.0);
242 let mw = ctx.font.measure_text("-", 16.0);
243 ctx.draw_text_at("-", Point {
244 x: minus_rect.origin.x + (BTN - mw) / 2.0,
245 y: super::vcenter_text_y(minus_rect.origin.y, BTN, ctx.font, 16.0),
246 }, fg, 16.0);
247
248 let saved_rect = ctx.rect;
249 {
250 let z = self.zoom.clone();
251 ctx.rect = plus_rect;
252 ctx.on_press_at(move |_, _| z.set((z.get() * 1.25).clamp(min_scale, max_scale)));
253 }
254 {
255 let z = self.zoom.clone();
256 ctx.rect = minus_rect;
257 ctx.on_press_at(move |_, _| z.set((z.get() / 1.25).clamp(min_scale, max_scale)));
258 }
259 ctx.rect = saved_rect;
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::tree::text::Text;
267
268 #[test]
269 fn layout_fills_available_constraints() {
270 let font = rosace_render::FontCache::embedded();
271 let theme = rosace_theme::built_in::dark_theme();
272 let ctx = LayoutCtx::new(Constraints::loose(400.0, 300.0), &font, &theme);
273 let size = InteractiveViewer::new(Text::new("content")).layout(&ctx);
274 assert_eq!((size.width, size.height), (400.0, 300.0));
275 }
276
277 #[test]
278 fn zoom_atom_clamps_to_min_max() {
279 let iv = InteractiveViewer::new(Text::new("x")).min_scale(0.5).max_scale(2.0);
280 iv.zoom.set(10.0);
281 assert_eq!(iv.zoom.get().clamp(iv.min_scale, iv.max_scale), 2.0);
282 iv.zoom.set(0.01);
283 assert_eq!(iv.zoom.get().clamp(iv.min_scale, iv.max_scale), 0.5);
284 }
285
286 #[test]
287 fn constrained_pan_bound_shrinks_as_zoom_increases() {
288 let vp_w = 100.0_f32;
292 let child_w = 500.0_f32;
293 let max_at_1x = (child_w - vp_w / 1.0).max(0.0);
294 let max_at_2x = (child_w - vp_w / 2.0).max(0.0);
295 assert!(max_at_2x > max_at_1x, "zooming in must reveal MORE pannable content");
296 }
297}