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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! Pan/zoom state accessors and GC for [`viewport()`](crate::tree::viewport)
//! containers — the counterpart of [`scroll`](super::scroll) for the 2D
//! pan/zoom transform.
use crate::tree::El;
use crate::viewport::{ViewportConfig, ViewportRequest, ViewportView};
use super::UiState;
use super::types::{ViewportPanDrag, ViewportState};
/// Wheel notch zoom multiplier — one notch zooms in/out by this factor,
/// matching the geometric feel of the 3D camera dolly.
const VIEWPORT_WHEEL_STEP: f32 = 1.1;
/// Maximum number of viewport identities the persistent `views` map
/// retains, mirroring [`SCROLL_LRU_CAP`](super::scroll::SCROLL_LRU_CAP).
/// Live viewports are always kept; the cap only evicts identities absent
/// the longest, so pan/zoom restoration across unmount/remount survives
/// until thousands of dead viewports accumulate.
pub(crate) const VIEWPORT_LRU_CAP: usize = 4096;
impl UiState {
/// Read the current pan/zoom of the viewport keyed `id`
/// (`computed_id`). Returns the reset framing
/// ([`ViewportView::default`]) when the viewport has no stored view
/// yet. Apps use this to display a zoom percentage or drive their own
/// overlays in content coordinates.
pub fn viewport_view(&self, id: &str) -> ViewportView {
self.viewport.views.get(id).copied().unwrap_or_default()
}
/// Read a viewport's pan/zoom by its `.key(...)` rather than its
/// `computed_id` — the ergonomic path for app `build` / `on_event`
/// code (e.g. a zoom-percentage readout). Returns the reset view for
/// a known but not-yet-positioned viewport, and `None` when no laid-out
/// node carries `key`.
pub fn viewport_view_by_key(&self, key: &str) -> Option<ViewportView> {
let id = self.layout.key_index.get(key)?;
Some(
self.viewport
.views
.get(id.as_ref())
.copied()
.unwrap_or_default(),
)
}
/// The bounding box of a viewport's laid-out content in **content
/// space** (pre-transform: pan `(0,0)`, zoom `1.0`), from the last
/// layout. Combine with [`Self::viewport_view`] to project content
/// into screen space — e.g. to draw a minimap or implement custom
/// framing. `None` until the viewport has been laid out with
/// measurable content.
pub fn viewport_content_bounds(&self, id: &str) -> Option<crate::tree::Rect> {
self.viewport.metrics.get(id).and_then(|m| m.content)
}
/// [`Self::viewport_content_bounds`] by the viewport's `.key(...)`
/// rather than its `computed_id` — the ergonomic path for app
/// `build` / `on_event` code (also forwarded on
/// [`BuildCx`](crate::event::BuildCx) / [`EventCx`](crate::event::EventCx)).
/// `None` when no laid-out node carries `key` or the viewport has no
/// measurable content.
pub fn viewport_content_bounds_by_key(&self, key: &str) -> Option<crate::tree::Rect> {
let id = self.layout.key_index.get(key)?;
self.viewport
.metrics
.get(id.as_ref())
.and_then(|m| m.content)
}
/// Whether the viewport keyed `id` (`computed_id`) is still at its
/// *home framing* — the policy fit under
/// [`FitPolicy::Contain`](crate::viewport::FitPolicy::Contain) /
/// [`Lock`](crate::viewport::FitPolicy::Lock), or the last
/// programmatic fit / reset under
/// [`Manual`](crate::viewport::FitPolicy::Manual). Turns `false` the
/// moment the view is steered away — by a user pan / zoom gesture, a
/// [`CenterOn`](crate::viewport::ViewportRequest::CenterOn) request,
/// or [`Self::set_viewport_view`] — and `true` again after a
/// [`ResetView`](crate::viewport::ViewportRequest::ResetView) /
/// [`FitContent`](crate::viewport::ViewportRequest::FitContent)
/// request resolves. Chrome uses this to show "Fit" vs a concrete
/// zoom percentage, or to disable a Reset button at home.
pub fn viewport_at_home(&self, id: &str) -> bool {
!self.viewport.taken_over.contains(id)
}
/// [`Self::viewport_at_home`] by the viewport's `.key(...)`. `None`
/// when no laid-out node carries `key`.
pub fn viewport_at_home_by_key(&self, key: &str) -> Option<bool> {
let id = self.layout.key_index.get(key)?;
Some(!self.viewport.taken_over.contains(id.as_ref()))
}
/// Seed or overwrite the pan/zoom for the viewport keyed `id`. Call
/// after [`crate::layout::assign_ids`] (so `computed_id`s exist) to
/// pre-position a viewport before the first layout. The next layout
/// pass clamps the value against the live viewport rect and content
/// extents.
///
/// A seeded view counts as a deliberate framing: it marks the
/// viewport *taken over* so a
/// [`FitPolicy::Contain`](crate::viewport::FitPolicy::Contain)
/// doesn't immediately re-fit over it (e.g. a framing restored from
/// disk). Push a
/// [`ResetView`](crate::viewport::ViewportRequest::ResetView) /
/// [`FitContent`](crate::viewport::ViewportRequest::FitContent)
/// request to re-arm the policy.
pub fn set_viewport_view(&mut self, id: impl Into<String>, view: ViewportView) {
let id = id.into();
self.viewport.taken_over.insert(id.clone());
// A direct write is a deliberate framing: it grounds any smooth
// navigation still in flight.
self.viewport.flights.remove(&id);
self.viewport.views.insert(id, view);
}
/// Whether the viewport keyed `id` (`computed_id`) is mid-flight on a
/// smooth programmatic navigation
/// ([`ViewportBehavior::Smooth`](crate::viewport::ViewportBehavior::Smooth)).
/// `false` once the flight arrives, and immediately after a user
/// gesture or a new instant request grounds it. Apps use this to
/// gate input or chrome during a fly-to.
pub fn viewport_in_flight(&self, id: &str) -> bool {
self.viewport.flights.contains_key(id)
}
/// [`Self::viewport_in_flight`] by the viewport's `.key(...)`. `None`
/// when no laid-out node carries `key`.
pub fn viewport_in_flight_by_key(&self, key: &str) -> Option<bool> {
let id = self.layout.key_index.get(key)?;
Some(self.viewport.flights.contains_key(id.as_ref()))
}
/// Queue programmatic [`ViewportRequest`]s (fit-to-content, reset,
/// center). Each is consumed during layout of the viewport whose
/// `.key(...)` it names, where the live inner rect and content
/// extents are known. Push once per build; unmatched requests are
/// dropped by [`Self::clear_pending_viewport_requests`].
pub fn push_viewport_requests(&mut self, requests: Vec<ViewportRequest>) {
self.viewport.pending_requests.extend(requests);
}
/// Drop any viewport requests still queued after layout — requests
/// targeting a viewport that wasn't in the tree this frame don't fire
/// against a later re-mount with the same key.
pub fn clear_pending_viewport_requests(&mut self) {
self.viewport.pending_requests.clear();
}
/// Deepest viewport whose inner rect contains `(x, y)`, with its
/// config — the input pass's entry point for routing pan / zoom
/// gestures. "Deepest" is resolved by `computed_id` path length so
/// nested viewports pick the inner one. Reads the per-frame metrics
/// written by layout, so it reflects the last laid-out tree.
///
/// [`FitPolicy::Lock`](crate::viewport::FitPolicy::Lock)ed viewports
/// are not gesture targets: they're skipped entirely, so the gesture
/// falls through to an enclosing viewport (or, for the wheel, to
/// scroll routing).
pub(crate) fn viewport_at(&self, x: f32, y: f32) -> Option<(String, ViewportConfig)> {
let mut best: Option<(&String, ViewportConfig)> = None;
for (id, m) in &self.viewport.metrics {
if matches!(m.cfg.fit, crate::viewport::FitPolicy::Lock { .. }) {
continue;
}
if m.inner.contains(x, y) && best.as_ref().is_none_or(|(b, _)| id.len() > b.len()) {
best = Some((id, m.cfg));
}
}
best.map(|(id, cfg)| (id.clone(), cfg))
}
/// Begin a pan drag on the viewport keyed `id`, anchoring on the
/// current pan so the content tracks the cursor 1:1. Captured at
/// `pointer_down` (or at latent-pan conversion); pre-empts hit-test
/// like the camera drag. `button` is the press that began the drag —
/// only its release ends the capture.
pub(crate) fn begin_viewport_pan(
&mut self,
id: String,
button: crate::event::PointerButton,
x: f32,
y: f32,
) {
let start_pan = self.viewport_view(&id).pan;
self.viewport.pan_drag = Some(ViewportPanDrag {
viewport_id: id,
button,
start_pointer: (x, y),
start_pan,
});
}
/// True while a viewport pan drag is in flight.
pub(crate) fn viewport_pan_active(&self) -> bool {
self.viewport.pan_drag.is_some()
}
/// Update the active pan drag to the current cursor. The stored pan
/// is the start pan plus the cursor delta; the next layout clamps it
/// against the content bounds before paint. Returns whether the pan
/// moved.
pub(crate) fn drag_viewport_to(&mut self, x: f32, y: f32) -> bool {
let Some(drag) = self.viewport.pan_drag.clone() else {
return false;
};
let mut view = self.viewport_view(&drag.viewport_id);
let next = (
drag.start_pan.0 + (x - drag.start_pointer.0),
drag.start_pan.1 + (y - drag.start_pointer.1),
);
let moved = (next.0 - view.pan.0).abs() > f32::EPSILON
|| (next.1 - view.pan.1).abs() > f32::EPSILON;
view.pan = next;
if moved {
// A real pan (not just a press) steers the view away from
// home: release any `FitPolicy::Contain`, flip the at-home
// readback, and ground any smooth navigation in flight —
// the user wins mid-flight.
self.viewport.taken_over.insert(drag.viewport_id.clone());
self.viewport.flights.remove(&drag.viewport_id);
}
self.viewport.views.insert(drag.viewport_id, view);
moved
}
/// End the active pan drag if `button` is the one that began it
/// (issue #128: another button's release falls through to its own
/// normal handling instead of killing the drag). Returns whether a
/// drag ended.
pub(crate) fn end_viewport_pan(&mut self, button: crate::event::PointerButton) -> bool {
if self
.viewport
.pan_drag
.as_ref()
.is_some_and(|d| d.button == button)
{
self.viewport.pan_drag = None;
return true;
}
false
}
/// Zoom the viewport under `(x, y)` by one wheel notch, anchored so
/// the content point under the cursor stays fixed. `dy > 0` (scroll
/// down, Damascene wheel convention) zooms out. Returns `true` when a
/// viewport consumed the wheel — even at a zoom limit, so the wheel
/// doesn't also scroll an enclosing container.
pub(crate) fn viewport_wheel_zoom(&mut self, root: &El, x: f32, y: f32, dy: f32) -> bool {
if dy.abs() <= f32::EPSILON {
return false;
}
let Some((id, cfg)) = self.viewport_at(x, y) else {
return false;
};
// An overlay (modal/dialog/popover) floated over the canvas takes
// the wheel as scroll, not zoom — yield to scroll routing.
if crate::hit_test::occluded_by_overlay(root, (x, y), &id) {
return false;
}
let Some(metrics) = self.viewport.metrics.get(&id).copied() else {
return false;
};
let view = self.viewport_view(&id);
let factor = if dy > 0.0 {
1.0 / VIEWPORT_WHEEL_STEP
} else {
VIEWPORT_WHEEL_STEP
};
let new_zoom = (view.zoom * factor).clamp(cfg.min_zoom, cfg.max_zoom);
if (new_zoom - view.zoom).abs() > f32::EPSILON {
let origin = (metrics.inner.x, metrics.inner.y);
let next = view.zoom_about(new_zoom, (x, y), origin);
// An effective zoom (not a notch at the limit) takes over
// the view — same release rule as a pan drag, including
// grounding any smooth navigation in flight.
self.viewport.taken_over.insert(id.clone());
self.viewport.flights.remove(&id);
self.viewport.views.insert(id, next);
}
true
}
}
impl ViewportState {
/// LRU pass over the persistent `views` map. Same policy as
/// [`ScrollState::gc`](super::types::ScrollState): live identities
/// are stamped fresh and never evicted; once `views` exceeds
/// [`VIEWPORT_LRU_CAP`], the longest-unseen absent identities are
/// dropped.
pub(crate) fn gc(&mut self, live: &rustc_hash::FxHashSet<&str>) {
self.prune_flights(live);
self.frame += 1;
let frame = self.frame;
let mut stamp: Vec<String> = Vec::new();
for id in self.views.keys() {
if live.contains(id.as_str()) || !self.last_seen.contains_key(id) {
stamp.push(id.clone());
}
}
for id in stamp {
self.last_seen.insert(id, frame);
}
// Drop registry entries whose identity no longer keys `views`.
let views = &self.views;
self.last_seen.retain(|id, _| views.contains_key(id));
if self.last_seen.len() <= VIEWPORT_LRU_CAP {
return;
}
let mut absent: Vec<(u64, String)> = self
.last_seen
.iter()
.filter(|(id, _)| !live.contains(id.as_str()))
.map(|(id, f)| (*f, id.clone()))
.collect();
absent.sort_unstable_by(|a, b| (a.0, a.1.as_str()).cmp(&(b.0, b.1.as_str())));
let overflow = self.last_seen.len() - VIEWPORT_LRU_CAP;
for (_, id) in absent.into_iter().take(overflow) {
self.views.remove(&id);
self.last_seen.remove(&id);
}
// The taken-over flag rides on the view: once a viewport's view
// is evicted, its takeover state is meaningless too.
let views = &self.views;
self.taken_over.retain(|id| views.contains_key(id));
}
/// Drop flights whose viewport is no longer in the tree — nothing
/// samples them, and a lingering entry would pin `needs_redraw`
/// forever. Called from the per-frame side-map GC with the live set.
pub(crate) fn prune_flights(&mut self, live: &rustc_hash::FxHashSet<&str>) {
self.flights.retain(|id, _| live.contains(id.as_str()));
}
/// Whether any flight is still inside its animation window at `now` —
/// the runtime's redraw signal. An **expired** flight that layout
/// never got to sample (its viewport sat in a scroll-pruned subtree,
/// which stays in the tree but isn't laid out) must not pin frames:
/// it lazily snaps to its endpoint on the next layout that actually
/// reaches the viewport.
pub(crate) fn any_flight_animating(&self, now: web_time::Instant) -> bool {
let now = self.clock_override.unwrap_or(now);
self.flights
.values()
.any(|f| now.saturating_duration_since(f.started) < f.duration)
}
}