1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
use std::sync::Arc;
use rosace_core::types::{Point, Rect, Size};
use rosace_layout::Constraints;
use rosace_render::PictureRecorder;
use rosace_scroll::controller::{MAX_VELOCITY, COAST_STOP_THRESHOLD};
use rosace_state::Atom;
use super::{Widget, LayoutCtx, PaintCtx, TransformLayerEntry, avail_w, avail_h};
use super::container::draw_rounded_rect_pub;
/// A large 2D plane with pan + zoom, driven by wheel/drag and +/- controls
/// (Flutter-`InteractiveViewer`-like, Phase 32/D115).
///
/// Built on the same GPU-composited transform-layer seam `ScrollView` uses
/// (D087/D090): the child is recorded ONCE into an independent `Picture`
/// and replayed by the platform into its own content texture, so panning is
/// a zero-repaint UV shift. Zoom rasterizes that SAME picture at a higher
/// physical resolution (`content_scale = dpi_scale * zoom` in
/// `rosace/src/engine.rs`) — real GPU-crisp magnification, reusing the exact
/// mechanism that already gives HiDPI displays sharp text/shapes; the
/// compositor's existing UV-window math needs no changes.
///
/// Pinch/trackpad-magnify gesture support is a NAMED deferral (mirrors the
/// project's existing "mobile pinch deferred" pattern for Phase 28 Step 6):
/// this MVP drives zoom through explicit `+`/`-` controls, which needs no
/// new platform-level gesture plumbing.
pub struct InteractiveViewer<W: Widget + Send + Sync + 'static> {
pub child: W,
zoom: Atom<f32>,
min_scale: f32,
max_scale: f32,
/// When true (default), panning is clamped so content can't be dragged
/// fully off-screen — a bounded viewer of a large image/canvas. When
/// false, pan is unclamped — an infinite-plane canvas.
constrained: bool,
zoom_controls: bool,
}
impl<W: Widget + Send + Sync + 'static> InteractiveViewer<W> {
pub fn new(child: W) -> Self {
Self {
child,
zoom: rosace_state::use_atom(1.0_f32),
min_scale: 0.5,
max_scale: 4.0,
constrained: true,
zoom_controls: true,
}
}
pub fn min_scale(mut self, v: f32) -> Self { self.min_scale = v; self }
pub fn max_scale(mut self, v: f32) -> Self { self.max_scale = v; self }
/// Unbounded infinite-plane panning instead of the default clamped-to-content behavior.
pub fn unconstrained(mut self) -> Self { self.constrained = false; self }
pub fn no_zoom_controls(mut self) -> Self { self.zoom_controls = false; self }
}
impl<W: Widget + Send + Sync + 'static> Widget for InteractiveViewer<W> {
fn layout(&self, ctx: &LayoutCtx) -> Size {
// Fills whatever space the parent gives it, like Container/Expanded —
// a canvas viewer has no natural size of its own.
Size { width: avail_w(ctx.constraints), height: avail_h(ctx.constraints) }
}
fn paint(&self, ctx: &mut PaintCtx) {
let zoom = self.zoom.get().clamp(self.min_scale, self.max_scale);
let vp_rect = ctx.rect;
// Measure + record the child ONCE at its natural (unconstrained) size —
// same recipe as TransformLayer/ScrollView.
let child_lctx = ctx.layout_ctx(Constraints::loose(f32::INFINITY, f32::INFINITY));
let child_size = self.child.layout(&child_lctx);
let mut sub_rec = PictureRecorder::new();
let child_rect = Rect { origin: Point { x: 0.0, y: 0.0 }, size: child_size };
let sub_node = ctx.tree.borrow_mut().slot(ctx.node, true);
let mut sub_ctx = PaintCtx {
recorder: &mut sub_rec,
rect: child_rect,
font: ctx.font,
theme: ctx.theme.clone(),
tree: ctx.tree.clone(),
node: sub_node,
owner: ctx.owner,
clip_rect: None,
};
self.child.paint(&mut sub_ctx);
let picture = sub_rec.finish();
ctx.attach_transform(TransformLayerEntry {
picture,
child_size,
viewport_rect: vp_rect,
zoom,
scroll_x: 0.0,
scroll_y: 0.0,
});
// Visible content window shrinks (in content-native px) as zoom
// increases — the same relationship `child_coords` inverts.
let visible_w = vp_rect.size.width / zoom;
let visible_h = vp_rect.size.height / zoom;
let (max_x, max_y) = if self.constrained {
((child_size.width - visible_w).max(0.0), (child_size.height - visible_h).max(0.0))
} else {
(f32::INFINITY, f32::INFINITY)
};
let node_id = ctx.node as u64;
// Wheel/trackpad pan.
{
let (max_x, max_y) = (max_x, max_y);
ctx.register_scroll_target(
vp_rect,
super::render_tree::ScrollAxes::BOTH,
Arc::new(move |dx, dy| {
rosace_state::scroll_offset_by(node_id, -dx, -dy, max_x, max_y);
}),
);
}
// Click-drag pan: on_press_at streams absolute window-space points
// for the whole gesture (see rosace/src/engine.rs's active_drag) —
// turn consecutive points into a delta, scaled by zoom (dragging a
// fixed SCREEN distance moves a smaller CONTENT distance at higher
// zoom, matching child_coords' own `/zoom`). Also tracks REAL
// wall-clock velocity (content-space px/sec) for the momentum coast
// below — `rosace_animate::frame_dt()` isn't usable here since
// panning deliberately does NOT repaint per move (D090's whole
// point), so there's no per-frame dt to lean on; a raw `Instant`
// works regardless of whether/how often paint() runs.
{
let (max_x, max_y) = (max_x, max_y);
ctx.on_press_at(move |px, py| {
if let Some((lx, ly, t0)) = rosace_state::drag_last(node_id) {
let dt = t0.elapsed().as_secs_f32().max(1.0 / 240.0);
let (dx, dy) = ((px - lx) / zoom, (py - ly) / zoom);
rosace_state::scroll_offset_by(node_id, -dx, -dy, max_x, max_y);
rosace_state::set_pan_velocity(node_id, (
(dx / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY),
(dy / dt).clamp(-MAX_VELOCITY, MAX_VELOCITY),
));
}
rosace_state::set_drag_last(node_id, Some((px, py)));
});
}
// Momentum coast (the reported "abrupt, no momentum" gap): once
// released, keep applying the last tracked velocity with exponential
// decay until it settles — same friction/threshold constants
// `ScrollView`'s own momentum uses (`rosace_scroll::controller`), so
// panning here feels consistent with scrolling elsewhere in the app.
// `ctx.pressed()` transitioning true->false is what starts this:
// MouseUp already forces one repaint for the pressed node
// (`engine.rs`'s `set_pressed` → `request_frame`) regardless of
// momentum, which is the hook that lets this widget observe the
// release at all despite not repainting during the drag itself.
if !ctx.pressed() {
rosace_state::set_drag_last(node_id, None);
let (vx, vy) = rosace_state::pan_velocity(node_id);
if vx.abs() > COAST_STOP_THRESHOLD || vy.abs() > COAST_STOP_THRESHOLD {
let dt = rosace_animate::frame_dt().max(0.0001);
rosace_state::scroll_offset_by(node_id, -vx * dt, -vy * dt, max_x, max_y);
let friction = 0.88_f32.powf(dt / (1.0 / 60.0));
let (nvx, nvy) = (vx * friction, vy * friction);
let settled = nvx.abs() < COAST_STOP_THRESHOLD && nvy.abs() < COAST_STOP_THRESHOLD;
rosace_state::set_pan_velocity(node_id, if settled { (0.0, 0.0) } else { (nvx, nvy) });
if !settled {
ctx.request_animation();
}
}
}
// Real trackpad pinch-to-zoom (macOS/iOS — winit's `PinchGesture`,
// routed through rosace-platform's `InputEvent::Pinch` +
// `RenderTree::zoom_test`). `delta` is an increment, not a
// multiplier (winit's own convention) — same `zoom *= 1.0 + delta`
// used by the `+`/`-` controls' 1.25 step, just gesture-driven.
{
let z = self.zoom.clone();
let (min_scale, max_scale) = (self.min_scale, self.max_scale);
ctx.register_zoom_target(vp_rect, Arc::new(move |delta| {
z.set((z.get() * (1.0 + delta)).clamp(min_scale, max_scale));
}));
}
ctx.semantics(super::Semantics::new(rosace_core::Role::Unknown)
.label("Interactive viewer".to_string()));
if self.zoom_controls {
self.paint_zoom_controls(ctx, vp_rect);
}
}
}
impl<W: Widget + Send + Sync + 'static> InteractiveViewer<W> {
/// KNOWN LIMITATION (named, not silently dropped): these hits are
/// registered directly on `ctx.node`, but the transform-layer content
/// is a real CHILD of that same node — and `hit_test_node` always
/// checks children before a node's own hits/hits_at (paint-order
/// z-index, D092). So a child that itself registers a hit spanning this
/// corner (e.g. a full-bleed interactive drawing canvas) would shadow
/// these buttons. Fine for the common case (images/diagrams/static
/// content), but a real fix needs a render-tree "always-on-top overlay
/// child" concept — out of scope for this widget; revisit if it bites.
///
/// Side-by-side (not stacked) so both buttons sit on one row — halves
/// the vertical footprint a short viewport needs to fully reveal both,
/// and removes any risk of one being clipped while the other isn't.
///
/// Registered via `on_press_at` (POSITIONAL hits_at), not `register_hit`
/// (plain hits): the pan-drag handler earlier in `paint()` also claims
/// `vp_rect` via `on_press_at`, and `hit_test_node` checks a node's
/// hits_at in reverse-registration order — registering the buttons
/// AFTER the pan handler makes them win for clicks in their own rect,
/// otherwise the pan handler (checked at the SAME tier but registered
/// first) would swallow every click before plain `hits` are ever
/// reached.
fn paint_zoom_controls(&self, ctx: &mut PaintCtx, vp_rect: Rect) {
let (bg, fg) = {
let t = &ctx.theme.colors;
(ctx.tc(t.surface), ctx.tc(t.on_surface))
};
const BTN: f32 = 32.0;
const GAP: f32 = 8.0;
const MARGIN: f32 = 12.0;
let base_y = vp_rect.origin.y + vp_rect.size.height - MARGIN - BTN;
let minus_x = vp_rect.origin.x + vp_rect.size.width - MARGIN - BTN;
let plus_x = minus_x - GAP - BTN;
let (min_scale, max_scale) = (self.min_scale, self.max_scale);
let plus_rect = Rect { origin: Point { x: plus_x, y: base_y }, size: Size { width: BTN, height: BTN } };
let minus_rect = Rect { origin: Point { x: minus_x, y: base_y }, size: Size { width: BTN, height: BTN } };
draw_rounded_rect_pub(ctx, plus_rect, bg, 6.0);
let pw = ctx.font.measure_text("+", 16.0);
ctx.draw_text_at("+", Point {
x: plus_rect.origin.x + (BTN - pw) / 2.0,
y: super::vcenter_text_y(plus_rect.origin.y, BTN, ctx.font, 16.0),
}, fg, 16.0);
draw_rounded_rect_pub(ctx, minus_rect, bg, 6.0);
let mw = ctx.font.measure_text("-", 16.0);
ctx.draw_text_at("-", Point {
x: minus_rect.origin.x + (BTN - mw) / 2.0,
y: super::vcenter_text_y(minus_rect.origin.y, BTN, ctx.font, 16.0),
}, fg, 16.0);
let saved_rect = ctx.rect;
{
let z = self.zoom.clone();
ctx.rect = plus_rect;
ctx.on_press_at(move |_, _| z.set((z.get() * 1.25).clamp(min_scale, max_scale)));
}
{
let z = self.zoom.clone();
ctx.rect = minus_rect;
ctx.on_press_at(move |_, _| z.set((z.get() / 1.25).clamp(min_scale, max_scale)));
}
ctx.rect = saved_rect;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tree::text::Text;
#[test]
fn layout_fills_available_constraints() {
let font = rosace_render::FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let ctx = LayoutCtx::new(Constraints::loose(400.0, 300.0), &font, &theme);
let size = InteractiveViewer::new(Text::new("content")).layout(&ctx);
assert_eq!((size.width, size.height), (400.0, 300.0));
}
#[test]
fn zoom_atom_clamps_to_min_max() {
let iv = InteractiveViewer::new(Text::new("x")).min_scale(0.5).max_scale(2.0);
iv.zoom.set(10.0);
assert_eq!(iv.zoom.get().clamp(iv.min_scale, iv.max_scale), 2.0);
iv.zoom.set(0.01);
assert_eq!(iv.zoom.get().clamp(iv.min_scale, iv.max_scale), 0.5);
}
#[test]
fn constrained_pan_bound_shrinks_as_zoom_increases() {
// Mirrors the InteractiveViewer::paint calculation directly: the
// visible content window (and therefore the pannable max) shrinks
// as zoom grows, since more content is shown magnified per screen px.
let vp_w = 100.0_f32;
let child_w = 500.0_f32;
let max_at_1x = (child_w - vp_w / 1.0).max(0.0);
let max_at_2x = (child_w - vp_w / 2.0).max(0.0);
assert!(max_at_2x > max_at_1x, "zooming in must reveal MORE pannable content");
}
}