window management library
git clone https://git.lucas.co/cce-window-manager.git
src/camera.rs (26.8K)
1 // Viewport camera policy: the pan/zoom math behind zoom actions, wheel
2 // zoom, viewport jumps, overview fit, and focus-follow panning.
3 //
4 // The desktop camera is (pan_x, pan_y, zoom): a virtual point v appears on
5 // an output at `(v - pan) * zoom` output-local px, so the viewport shows the
6 // virtual rect [pan, pan + extent/zoom). Every function here is a pure map
7 // from one camera to another — the mechanism owns the actual fields (and the
8 // animation easing toward targets) and applies the results.
9
10 /// Camera state, by value. Mechanism copies `desk_pan_x/y`/`desk_zoom` in,
11 /// writes the result back.
12 #[derive(Debug, Clone, Copy, PartialEq)]
13 pub struct Camera {
14 pub pan_x: f64,
15 pub pan_y: f64,
16 pub zoom: f64,
17 }
18
19 pub const ZOOM_MIN: f64 = 0.1;
20 pub const ZOOM_MAX: f64 = 10.0;
21 /// Multiplier per keyed ZoomIn/ZoomOut press.
22 pub const KEYED_ZOOM_STEP: f64 = 1.1;
23 /// Per-unit wheel-delta zoom base: factor = WHEEL_ZOOM_BASE^(-delta).
24 pub const WHEEL_ZOOM_BASE: f64 = 1.005;
25 /// Zoom ≠ 1 within this tolerance still counts as "normal" (not overview).
26 const OVERVIEW_EPSILON: f64 = 0.001;
27
28 /// Overview mode is simply "the camera is zoomed": any zoom meaningfully
29 /// away from 1.
30 pub fn is_overview(zoom: f64) -> bool {
31 (zoom - 1.0).abs() > OVERVIEW_EPSILON
32 }
33
34 /// One keyed zoom press. `dir` > 0 zooms in, < 0 out, 0 resets to 1.
35 pub fn keyed_zoom(zoom: f64, dir: f64) -> f64 {
36 if dir > 0.0 {
37 (zoom * KEYED_ZOOM_STEP).min(ZOOM_MAX)
38 } else if dir < 0.0 {
39 (zoom / KEYED_ZOOM_STEP).max(ZOOM_MIN)
40 } else {
41 1.0
42 }
43 }
44
45 /// Continuous wheel zoom: scroll up (negative delta) zooms in.
46 pub fn wheel_zoom(zoom: f64, delta: f64) -> f64 {
47 (zoom * WHEEL_ZOOM_BASE.powf(-delta)).clamp(ZOOM_MIN, ZOOM_MAX)
48 }
49
50 /// Continuous pinch zoom. libinput reports `scale` as the absolute finger
51 /// spread relative to the gesture's begin (not a per-event delta), so the
52 /// whole gesture maps off the zoom captured at pinch begin — never the
53 /// current zoom, which would compound every update into runaway growth.
54 pub fn pinch_zoom(start_zoom: f64, scale: f64) -> f64 {
55 (start_zoom * scale).clamp(ZOOM_MIN, ZOOM_MAX)
56 }
57
58 /// Change zoom while keeping the virtual point under an output-local anchor
59 /// (`ax`, `ay` px from the output's top-left) fixed on screen — the wheel
60 /// zooms about the cursor, keyed zooms about the viewport center.
61 pub fn zoom_about_anchor(cam: Camera, ax: f64, ay: f64, new_zoom: f64) -> Camera {
62 let new_zoom = new_zoom.clamp(ZOOM_MIN, ZOOM_MAX);
63 Camera {
64 pan_x: cam.pan_x + ax * (1.0 / cam.zoom - 1.0 / new_zoom),
65 pan_y: cam.pan_y + ay * (1.0 / cam.zoom - 1.0 / new_zoom),
66 zoom: new_zoom,
67 }
68 }
69
70 /// The camera that centers virtual point (`cx`, `cy`) in a viewport of
71 /// `vw` x `vh` output px at the given zoom.
72 pub fn center_on(cx: f64, cy: f64, vw: f64, vh: f64, zoom: f64) -> Camera {
73 Camera {
74 pan_x: cx - (vw / 2.0) / zoom,
75 pan_y: cy - (vh / 2.0) / zoom,
76 zoom,
77 }
78 }
79
80 /// Virtual top-left that puts a `win`-long window in the middle of the
81 /// viewport, on one axis. The mirror of [`center_on`]: that moves the camera
82 /// to a window, this moves a window to the camera.
83 ///
84 /// The viewport shows `[pan, pan + extent/zoom)`, so its virtual midpoint is
85 /// `pan + extent/(2*zoom)` and the window starts half its own length before
86 /// it. `extent` is the output's length in px; `win` is virtual (unscaled),
87 /// because a window's stored geometry is virtual and zoom is applied when it
88 /// is drawn.
89 ///
90 /// Session modals place with this so they open where the user is currently
91 /// looking rather than wherever they last sat — on a panning desktop a
92 /// remembered position is usually off-view by the time the window reopens.
93 pub fn centered_window_origin(pan: f64, extent: f64, zoom: f64, win: f64) -> f64 {
94 pan + (extent / zoom - win) / 2.0
95 }
96
97 /// Anchor-stable zoom-pan interpolation between two cameras at progress
98 /// `p` ∈ `[0, 1]`: zoom log-lerps, and pan is DERIVED from the unique world
99 /// point that maps to the same screen position under both cameras — so the
100 /// whole transition reads as a single zoom about a stationary anchor
101 /// instead of a sideways slide-while-zooming (independent pan/zoom lerp
102 /// keeps no point fixed; every pixel bows along a curve). Endpoints are
103 /// exact. Near-equal zooms have no anchor (it runs to infinity), so that
104 /// case degrades to a straight pan at constant zoom.
105 pub fn anchored_interp(start: Camera, end: Camera, p: f64) -> Camera {
106 let z0 = start.zoom.max(1e-9);
107 let z1 = end.zoom.max(1e-9);
108 let zoom = (z0.ln() + (z1.ln() - z0.ln()) * p).exp();
109 if (z1 / z0).ln().abs() < 1e-6 {
110 return Camera {
111 pan_x: start.pan_x + (end.pan_x - start.pan_x) * p,
112 pan_y: start.pan_y + (end.pan_y - start.pan_y) * p,
113 zoom,
114 };
115 }
116 // Per axis: the fixed point q solves (q - pan0)·z0 = (q - pan1)·z1;
117 // its constant screen coordinate is a = (q - pan0)·z0, and the pan at
118 // any zoom follows from holding q at a.
119 let axis = |pan0: f64, pan1: f64| -> f64 {
120 let q = (pan0 * z0 - pan1 * z1) / (z0 - z1);
121 let a = (q - pan0) * z0;
122 q - a / zoom
123 };
124 Camera {
125 pan_x: axis(start.pan_x, end.pan_x),
126 pan_y: axis(start.pan_y, end.pan_y),
127 zoom,
128 }
129 }
130
131 /// Fraction of a virtual-space window rect visible in the viewport, 0.0–1.0.
132 ///
133 /// Its one caller is [`recalled_origin`], which restores a remembered
134 /// floating window where it was only if at least [`RESTORE_VISIBLE_MIN`] of
135 /// it would show. It fed the focus-follow decision too until 2026-09-12,
136 /// when [`pan_into_view`] replaced "below a threshold, centre it" with the
137 /// minimal pan that brings a window fully into view — focus asks how far a
138 /// window is out of view, not how much of it is in.
139 pub fn visible_fraction(
140 x: f64,
141 y: f64,
142 w: f64,
143 h: f64,
144 cam: Camera,
145 vw: f64,
146 vh: f64,
147 ) -> f64 {
148 if w <= 0.0 || h <= 0.0 {
149 return 0.0;
150 }
151 let v_right = cam.pan_x + vw / cam.zoom;
152 let v_bottom = cam.pan_y + vh / cam.zoom;
153 let i_w = (x + w).min(v_right) - x.max(cam.pan_x);
154 let i_h = (y + h).min(v_bottom) - y.max(cam.pan_y);
155 (i_w.max(0.0) * i_h.max(0.0)) / (w * h)
156 }
157
158 /// Breathing room a window lands with when the camera moves for it, output
159 /// px — enough for the hover/border band, so the grab surface comes along
160 /// with the content.
161 const VIEW_MARGIN: f64 = 24.0;
162
163 /// The camera a focus change moves to: the MINIMAL pan that brings a window
164 /// fully into view, or `None` when it already is (a window parked exactly
165 /// flush at an edge is left alone — only a window actually crossing the
166 /// viewport bound moves the camera). Each axis is handled independently;
167 /// the corrected edge lands `VIEW_MARGIN` in from the viewport. A window too
168 /// large to fit prioritizes its top-left edge.
169 ///
170 /// This is the whole focus-follow rule, for a window half off the edge and
171 /// for one a screen away alike. It used to apply only to a window already
172 /// three-quarters visible, and anything less got centered — so focusing the
173 /// window immediately to the right swung the desktop over and parked it in
174 /// the middle, throwing away the spatial relationship the user had just
175 /// navigated by. The camera should move as little as the request demands:
176 /// the window arrives at the edge it was behind, and everything else on
177 /// screen stays where the eye left it.
178 pub fn pan_into_view(
179 x: f64,
180 y: f64,
181 w: f64,
182 h: f64,
183 cam: Camera,
184 vw: f64,
185 vh: f64,
186 ) -> Option<Camera> {
187 // Screen-space rect of the window under the current camera.
188 let l = (x - cam.pan_x) * cam.zoom;
189 let t = (y - cam.pan_y) * cam.zoom;
190 let r = l + w * cam.zoom;
191 let b = t + h * cam.zoom;
192
193 // Per axis: the screen-px shift applied to the WINDOW (camera moves the
194 // opposite way). Nothing happens unless the window actually crosses the
195 // viewport bounds on that axis.
196 let axis_shift = |low: f64, high: f64, extent: f64| -> f64 {
197 let mut d = 0.0;
198 if high > extent {
199 d = (extent - VIEW_MARGIN) - high;
200 }
201 if low + d < 0.0 {
202 // Clipped low (or over-corrected by the high fix / oversized
203 // window): top-left priority.
204 d = VIEW_MARGIN - low;
205 }
206 d
207 };
208 let dx = axis_shift(l, r, vw);
209 let dy = axis_shift(t, b, vh);
210
211 if dx == 0.0 && dy == 0.0 {
212 return None;
213 }
214 Some(Camera {
215 pan_x: cam.pan_x - dx / cam.zoom,
216 pan_y: cam.pan_y - dy / cam.zoom,
217 zoom: cam.zoom,
218 })
219 }
220
221 /// A remembered floating window whose position would show LESS than this
222 /// fraction of it is recalled into view instead of restored where it was.
223 pub const RESTORE_VISIBLE_MIN: f64 = 0.25;
224
225 /// Whether a remembered window rect is ON THE DESK: it overlaps the tiled
226 /// windows' bounding box inflated by one viewport on every side (virtual
227 /// units, so at zoom 1 a viewport is `vw` x `vh`). `None` for the desk means
228 /// there are no tiled windows to be beside, and nothing is on the desk.
229 ///
230 /// A floating window parked beside a tiled column, or one screen past the
231 /// desk's edge, is at most one pan away from content the user navigates
232 /// by — it is placed, not lost. Only a window with no tiled neighbour
233 /// within a screen has nothing on the desk to say where it is.
234 pub fn on_tiled_desk(
235 x: f64,
236 y: f64,
237 w: f64,
238 h: f64,
239 desk: Option<(f64, f64, f64, f64)>,
240 vw: f64,
241 vh: f64,
242 ) -> bool {
243 let Some((min_x, min_y, max_x, max_y)) = desk else { return false };
244 if w <= 0.0 || h <= 0.0 || max_x <= min_x || max_y <= min_y {
245 return false;
246 }
247 x < max_x + vw && x + w > min_x - vw && y < max_y + vh && y + h > min_y - vh
248 }
249
250 /// Where a remembered FLOATING window should reopen: `None` to keep its
251 /// remembered origin, or the origin that centers it in the current view.
252 ///
253 /// On a panning desktop a remembered position is often off-view by the
254 /// time the window reopens — the camera was somewhere else when the
255 /// session was saved, or has moved since. Tiled windows are part of the
256 /// grid and belong wherever the grid puts them, so this is for floating
257 /// windows only: an Inkscape start screen restored a screen above the
258 /// viewport is not "remembered", it is lost, with nothing on screen to say
259 /// it exists. A window that would still be mostly visible keeps its spot —
260 /// a floating window deliberately tucked at an edge stays tucked.
261 ///
262 /// So does a window ON THE DESK (`on_tiled_desk` against `desk`, the tiled
263 /// windows' bounding box): a data editor parked beside the leftmost tiled
264 /// column was recalled into the middle of the view every login because the
265 /// camera had been left two screens to the right at logout. Off-view is
266 /// not lost when the tiled desk is right there to pan along; the recall is
267 /// for a window with no neighbour at all.
268 pub fn recalled_origin(
269 x: f64,
270 y: f64,
271 w: f64,
272 h: f64,
273 cam: Camera,
274 vw: f64,
275 vh: f64,
276 desk: Option<(f64, f64, f64, f64)>,
277 ) -> Option<(f64, f64)> {
278 if w <= 0.0 || h <= 0.0 {
279 return None;
280 }
281 if visible_fraction(x, y, w, h, cam, vw, vh) >= RESTORE_VISIBLE_MIN {
282 return None;
283 }
284 if on_tiled_desk(x, y, w, h, desk, vw, vh) {
285 return None;
286 }
287 let zoom = cam.zoom.max(0.01);
288 Some((
289 centered_window_origin(cam.pan_x, vw, zoom, w),
290 centered_window_origin(cam.pan_y, vh, zoom, h),
291 ))
292 }
293
294 /// Margin kept around the fitted bounds when entering overview, output px.
295 const OVERVIEW_MARGIN: f64 = 100.0;
296 /// The margin never shrinks the usable viewport below this, output px.
297 const OVERVIEW_MIN_AVAIL: f64 = 200.0;
298 /// Overview fit only zooms OUT (cap 1.0), and never further than this.
299 const OVERVIEW_ZOOM_MIN: f64 = 0.05;
300
301 /// Entering overview: fit the virtual bounding box [min_x, max_x] x
302 /// [min_y, max_y] into the viewport with a margin, centered. Zoom is capped
303 /// at 1 — a desktop smaller than the screen is centered, not magnified.
304 pub fn fit_bounds(
305 min_x: f64,
306 min_y: f64,
307 max_x: f64,
308 max_y: f64,
309 vw: f64,
310 vh: f64,
311 ) -> Camera {
312 let box_w = max_x - min_x;
313 let box_h = max_y - min_y;
314 let avail_w = (vw - 2.0 * OVERVIEW_MARGIN).max(OVERVIEW_MIN_AVAIL);
315 let avail_h = (vh - 2.0 * OVERVIEW_MARGIN).max(OVERVIEW_MIN_AVAIL);
316 let zoom = (avail_w / box_w.max(1.0))
317 .min(avail_h / box_h.max(1.0))
318 .min(1.0)
319 .max(OVERVIEW_ZOOM_MIN);
320 center_on(min_x + box_w / 2.0, min_y + box_h / 2.0, vw, vh, zoom)
321 }
322
323 #[cfg(test)]
324 mod tests {
325 use super::*;
326
327 const VW: f64 = 1920.0;
328 const VH: f64 = 1080.0;
329
330 fn cam(pan_x: f64, pan_y: f64, zoom: f64) -> Camera {
331 Camera { pan_x, pan_y, zoom }
332 }
333
334 #[test]
335 fn a_remembered_floating_window_off_view_is_recalled_to_center() {
336 // Camera at (-4708, -3196), zoom 1: the view spans y -3196..-2116.
337 // A 700x666 window remembered at y=-4422 ends at -3756 — a whole
338 // screen above. It comes back centered in the view.
339 let c = cam(-4708.0, -3196.0, 1.0);
340 let got = recalled_origin(-3272.0, -4422.0, 700.0, 666.0, c, VW, VH, None);
341 assert_eq!(got, Some((-4708.0 + (VW - 700.0) / 2.0, -3196.0 + (VH - 666.0) / 2.0)));
342 }
343
344 #[test]
345 fn a_remembered_window_beside_the_tiled_desk_keeps_its_spot() {
346 // The 2026-09-14 login, at output scale 2 (1920x1200 logical):
347 // camera (-5928, -3244), the data editor 952x904 remembered at
348 // (-8461, -3564) — two and a half screens left, 0% visible — and
349 // the leftmost tiled column at x=-7380, 129 px to its right.
350 let (vw, vh) = (1920.0, 1200.0);
351 let c = cam(-5928.0, -3244.0, 1.0);
352 let desk = Some((-7380.0, -4380.0, -2984.0, -2076.0));
353 assert_eq!(recalled_origin(-8461.0, -3564.0, 952.0, 904.0, c, vw, vh, desk), None);
354 // Without a tiled desk the same window is lost, and recalled.
355 assert!(recalled_origin(-8461.0, -3564.0, 952.0, 904.0, c, vw, vh, None).is_some());
356 // More than a viewport past the desk's edge: nothing to be beside.
357 assert!(recalled_origin(-7380.0 - vw - 952.0 - 1.0, -3564.0, 952.0, 904.0, c, vw, vh, desk).is_some());
358 // Exactly one viewport past still counts — the pan that reaches
359 // the desk's edge shows it.
360 assert_eq!(recalled_origin(-7380.0 - vw - 952.0 + 1.0, -3564.0, 952.0, 904.0, c, vw, vh, desk), None);
361 }
362
363 #[test]
364 fn on_tiled_desk_is_the_inflated_bounding_box() {
365 let desk = Some((0.0, 0.0, 1000.0, 1000.0));
366 // Inside, overlapping, and within a viewport of every side.
367 assert!(on_tiled_desk(100.0, 100.0, 200.0, 200.0, desk, VW, VH));
368 assert!(on_tiled_desk(-VW - 100.0, 0.0, 200.0, 200.0, desk, VW, VH));
369 assert!(on_tiled_desk(0.0, 1000.0 + VH - 1.0, 200.0, 200.0, desk, VW, VH));
370 // Past the inflated box on either axis.
371 assert!(!on_tiled_desk(-VW - 200.0, 0.0, 200.0, 200.0, desk, VW, VH));
372 assert!(!on_tiled_desk(0.0, 1000.0 + VH, 200.0, 200.0, desk, VW, VH));
373 // No desk, a sizeless window, or a degenerate desk: never on it.
374 assert!(!on_tiled_desk(100.0, 100.0, 200.0, 200.0, None, VW, VH));
375 assert!(!on_tiled_desk(100.0, 100.0, 0.0, 0.0, desk, VW, VH));
376 assert!(!on_tiled_desk(100.0, 100.0, 200.0, 200.0, Some((5.0, 5.0, 5.0, 5.0)), VW, VH));
377 }
378
379 #[test]
380 fn a_remembered_window_mostly_in_view_keeps_its_spot() {
381 let c = cam(0.0, 0.0, 1.0);
382 // Fully visible.
383 assert_eq!(recalled_origin(100.0, 100.0, 700.0, 666.0, c, VW, VH, None), None);
384 // Half off the right edge: 50% visible, above the quarter floor.
385 assert_eq!(recalled_origin(VW - 350.0, 100.0, 700.0, 666.0, c, VW, VH, None), None);
386 // Only a sliver (10%) on screen: recalled.
387 assert!(recalled_origin(VW - 70.0, 100.0, 700.0, 666.0, c, VW, VH, None).is_some());
388 }
389
390 #[test]
391 fn recall_centers_under_the_current_zoom() {
392 // Zoomed out to 0.5 the view covers twice the virtual extent.
393 let c = cam(1000.0, 1000.0, 0.5);
394 let got = recalled_origin(-9000.0, -9000.0, 400.0, 300.0, c, VW, VH, None);
395 assert_eq!(got, Some((1000.0 + (VW / 0.5 - 400.0) / 2.0, 1000.0 + (VH / 0.5 - 300.0) / 2.0)));
396 // A sizeless window has nothing to place.
397 assert_eq!(recalled_origin(-9000.0, -9000.0, 0.0, 0.0, c, VW, VH, None), None);
398 }
399
400 #[test]
401 fn overview_is_any_meaningful_zoom() {
402 assert!(!is_overview(1.0));
403 assert!(!is_overview(1.0005));
404 assert!(is_overview(1.1));
405 assert!(is_overview(0.5));
406 }
407
408 #[test]
409 fn keyed_zoom_steps_and_clamps() {
410 assert_eq!(keyed_zoom(1.0, 1.0), 1.1);
411 assert_eq!(keyed_zoom(1.1, -1.0), 1.0);
412 assert_eq!(keyed_zoom(9.99, 1.0), ZOOM_MAX);
413 assert_eq!(keyed_zoom(0.10001, -1.0), ZOOM_MIN);
414 assert_eq!(keyed_zoom(3.7, 0.0), 1.0);
415 }
416
417 #[test]
418 fn centered_window_origin_puts_the_window_mid_viewport() {
419 // Zoom 1: a 640-wide window in a 1920 viewport starts 640 in, and
420 // the whole thing shifts with the pan.
421 assert_eq!(centered_window_origin(0.0, VW, 1.0, 640.0), 640.0);
422 assert_eq!(centered_window_origin(5000.0, VW, 1.0, 640.0), 5640.0);
423 // Vertical axis is the same call.
424 assert_eq!(centered_window_origin(0.0, VH, 1.0, 400.0), 340.0);
425
426 // Zoomed out to 0.5 the viewport covers 3840 virtual px, so the same
427 // window centers further from the pan origin — the point of dividing
428 // the extent by zoom rather than scaling the window.
429 assert_eq!(centered_window_origin(0.0, VW, 0.5, 640.0), 1600.0);
430 // Zoomed in 2x it covers only 960, so the window sits nearer.
431 assert_eq!(centered_window_origin(0.0, VW, 2.0, 640.0), 160.0);
432
433 // Round-trip against the projection the module documents:
434 // screen = (virtual - pan) * zoom. The window's screen midpoint must
435 // land on the viewport's screen midpoint at any camera.
436 for &(pan, zoom, win) in &[(0.0, 1.0, 640.0), (1234.5, 0.75, 500.0), (-800.0, 1.6, 900.0)] {
437 let v = centered_window_origin(pan, VW, zoom, win);
438 let screen_mid = (v - pan) * zoom + (win * zoom) / 2.0;
439 assert!((screen_mid - VW / 2.0).abs() < 1e-9, "pan={pan} zoom={zoom}");
440 }
441
442 // A window wider than the viewport overhangs symmetrically (negative
443 // origin) rather than being clamped — centering, not fitting.
444 assert_eq!(centered_window_origin(0.0, VW, 1.0, 2920.0), -500.0);
445 }
446
447 #[test]
448 fn pinch_zoom_maps_off_the_begin_zoom_and_clamps() {
449 // Absolute-scale semantics: spreading to 2x from zoom 1.5 lands on
450 // 3.0 no matter how many intermediate updates arrived.
451 assert_eq!(pinch_zoom(1.5, 2.0), 3.0);
452 assert_eq!(pinch_zoom(1.5, 1.0), 1.5); // begin-scale identity
453 assert_eq!(pinch_zoom(1.0, 0.5), 0.5);
454 // Clamped at both ends.
455 assert_eq!(pinch_zoom(8.0, 4.0), ZOOM_MAX);
456 assert_eq!(pinch_zoom(0.4, 0.1), ZOOM_MIN);
457 }
458
459 #[test]
460 fn zoom_about_anchor_pins_the_anchored_point() {
461 // Virtual point under the anchor before == after. Anchor (960, 540),
462 // camera (100, 50, 1): virtual point = pan + anchor/zoom.
463 let c0 = cam(100.0, 50.0, 1.0);
464 let (ax, ay) = (960.0, 540.0);
465 let before = (c0.pan_x + ax / c0.zoom, c0.pan_y + ay / c0.zoom);
466 let c1 = zoom_about_anchor(c0, ax, ay, 2.0);
467 let after = (c1.pan_x + ax / c1.zoom, c1.pan_y + ay / c1.zoom);
468 assert!((before.0 - after.0).abs() < 1e-9);
469 assert!((before.1 - after.1).abs() < 1e-9);
470 assert_eq!(c1.zoom, 2.0);
471 }
472
473 #[test]
474 fn center_on_round_trips_through_visibility() {
475 // A 400x300 window centered by center_on is fully visible.
476 let c = center_on(200.0, 150.0, VW, VH, 1.0);
477 assert_eq!(visible_fraction(0.0, 0.0, 400.0, 300.0, c, VW, VH), 1.0);
478 }
479
480 #[test]
481 fn visible_fraction_partial_and_none() {
482 // Viewport [0,1920)x[0,1080): a 200-wide window half off the left
483 // edge is half visible; one fully outside is 0.
484 let c = cam(0.0, 0.0, 1.0);
485 assert_eq!(visible_fraction(-100.0, 0.0, 200.0, 100.0, c, VW, VH), 0.5);
486 assert_eq!(visible_fraction(-500.0, 0.0, 200.0, 100.0, c, VW, VH), 0.0);
487 // Zoom 2 halves the visible virtual extent: a window spanning
488 // [0, 1920) virtual is only half on screen.
489 let z = cam(0.0, 0.0, 2.0);
490 assert_eq!(visible_fraction(0.0, 0.0, 1920.0, 100.0, z, VW, VH), 0.5);
491 }
492
493 #[test]
494 fn fit_bounds_fits_and_centers() {
495 // 3440x1880 bounds into 1920x1080: avail 1720x880, zoom limited by
496 // height 880/1880; the bounds' center lands at the viewport center.
497 let c = fit_bounds(0.0, 0.0, 3440.0, 1880.0, VW, VH);
498 assert!((c.zoom - 880.0 / 1880.0).abs() < 1e-9);
499 assert!((c.pan_x + (VW / 2.0) / c.zoom - 1720.0).abs() < 1e-9);
500 // Tiny bounds: zoom caps at 1, no magnification.
501 let c = fit_bounds(0.0, 0.0, 100.0, 100.0, VW, VH);
502 assert_eq!(c.zoom, 1.0);
503 }
504
505 #[test]
506 fn anchored_interp_endpoints_are_exact() {
507 let s = cam(100.0, 50.0, 1.0);
508 let e = cam(-400.0, -90.0, 0.5);
509 let a0 = anchored_interp(s, e, 0.0);
510 let a1 = anchored_interp(s, e, 1.0);
511 assert!((a0.pan_x - s.pan_x).abs() < 1e-9 && (a0.zoom - s.zoom).abs() < 1e-12);
512 assert!((a1.pan_x - e.pan_x).abs() < 1e-6 && (a1.pan_y - e.pan_y).abs() < 1e-6);
513 assert!((a1.zoom - e.zoom).abs() < 1e-9);
514 }
515
516 #[test]
517 fn anchored_interp_keeps_the_fixed_point_stationary() {
518 let s = cam(200.0, -80.0, 1.0);
519 let e = cam(-350.0, 140.0, 0.4);
520 // The per-axis fixed point and its screen coordinate under start.
521 let qx = (s.pan_x * s.zoom - e.pan_x * e.zoom) / (s.zoom - e.zoom);
522 let qy = (s.pan_y * s.zoom - e.pan_y * e.zoom) / (s.zoom - e.zoom);
523 let ax = (qx - s.pan_x) * s.zoom;
524 let ay = (qy - s.pan_y) * s.zoom;
525 for i in 0..=10 {
526 let c = anchored_interp(s, e, i as f64 / 10.0);
527 assert!(((qx - c.pan_x) * c.zoom - ax).abs() < 1e-6, "p={}", i);
528 assert!(((qy - c.pan_y) * c.zoom - ay).abs() < 1e-6, "p={}", i);
529 }
530 }
531
532 #[test]
533 fn anchored_interp_zoom_about_viewport_center_stays_centered() {
534 // start/end share their viewport center: the anchor IS that center,
535 // which must stay put the whole way (1920x1080 viewport).
536 let s = cam(0.0, 0.0, 1.0);
537 let cx = 960.0;
538 let cy = 540.0;
539 let e = center_on(cx, cy, 1920.0, 1080.0, 0.5);
540 for i in 0..=10 {
541 let c = anchored_interp(s, e, i as f64 / 10.0);
542 let sx = (cx - c.pan_x) * c.zoom;
543 let sy = (cy - c.pan_y) * c.zoom;
544 assert!((sx - 960.0).abs() < 1e-6 && (sy - 540.0).abs() < 1e-6, "p={}", i);
545 }
546 }
547
548 #[test]
549 fn anchored_interp_equal_zoom_is_straight_pan() {
550 let s = cam(0.0, 0.0, 1.0);
551 let e = cam(500.0, -300.0, 1.0);
552 let c = anchored_interp(s, e, 0.5);
553 assert!((c.pan_x - 250.0).abs() < 1e-9 && (c.pan_y + 150.0).abs() < 1e-9);
554 assert_eq!(c.zoom, 1.0);
555 }
556
557 #[test]
558 fn pan_leaves_fully_visible_windows_alone() {
559 let c = cam(0.0, 0.0, 1.0);
560 // Comfortably inside, and flush at the origin edge: both untouched.
561 assert!(pan_into_view(500.0, 300.0, 400.0, 300.0, c, VW, VH).is_none());
562 assert!(pan_into_view(0.0, 0.0, 400.0, 300.0, c, VW, VH).is_none());
563 }
564
565 #[test]
566 fn pan_slides_clipped_bottom_edge_on_screen() {
567 // 1080-tall viewport; a 300-tall window at y=900 hangs 120px off the
568 // bottom. The pan shifts the camera down so the bottom lands 24px in:
569 // window bottom 1200 → 1056, a pan_y increase of 144.
570 let c = cam(0.0, 0.0, 1.0);
571 let n = pan_into_view(100.0, 900.0, 400.0, 300.0, c, VW, VH).unwrap();
572 assert_eq!(n.pan_x, 0.0);
573 assert_eq!(n.pan_y, 144.0);
574 }
575
576 #[test]
577 fn pan_left_clip_lands_with_margin() {
578 // Window 80px off the left edge: lands at screen x = 24.
579 let c = cam(0.0, 0.0, 1.0);
580 let n = pan_into_view(-80.0, 100.0, 400.0, 300.0, c, VW, VH).unwrap();
581 assert_eq!(n.pan_x, -104.0);
582 assert_eq!(n.pan_y, 0.0);
583 }
584
585 #[test]
586 fn pan_oversized_window_prefers_top_left() {
587 // Taller than the viewport and clipped both ways: the top edge wins,
588 // landing at margin.
589 let c = cam(0.0, 0.0, 1.0);
590 let n = pan_into_view(100.0, -50.0, 400.0, 2000.0, c, VW, VH).unwrap();
591 assert_eq!(n.pan_y, -74.0);
592 }
593
594 #[test]
595 fn a_window_fully_offscreen_to_the_right_is_brought_to_the_near_edge() {
596 // The reported case: the focused window fills the view and the next
597 // one sits entirely off the right edge. Focusing it must pan just
598 // far enough to show it — NOT center it.
599 let c = cam(0.0, 0.0, 1.0);
600 let n = pan_into_view(2000.0, 100.0, 400.0, 300.0, c, VW, VH).unwrap();
601 // Its right edge (2400) lands VIEW_MARGIN in from the 1920 viewport:
602 // a pan of 2400 - (1920 - 24) = 504.
603 assert_eq!(n.pan_x, 504.0);
604 assert_eq!(n.pan_y, 0.0);
605 // Fully visible afterwards, and hard against the edge it came from:
606 // centering would have put it at pan_x = 2200 - 960 = 1240.
607 let moved = cam(n.pan_x, n.pan_y, 1.0);
608 assert_eq!(visible_fraction(2000.0, 100.0, 400.0, 300.0, moved, VW, VH), 1.0);
609 assert!(n.pan_x < 1240.0, "minimal pan, not a recentre");
610 }
611
612 #[test]
613 fn a_window_fully_offscreen_to_the_left_lands_at_the_left_margin() {
614 // The mirror: its left edge lands VIEW_MARGIN in, so the camera
615 // stops as soon as the window is whole.
616 let c = cam(0.0, 0.0, 1.0);
617 let n = pan_into_view(-900.0, 100.0, 400.0, 300.0, c, VW, VH).unwrap();
618 assert_eq!(n.pan_x, -924.0);
619 let moved = cam(n.pan_x, n.pan_y, 1.0);
620 assert_eq!(visible_fraction(-900.0, 100.0, 400.0, 300.0, moved, VW, VH), 1.0);
621 }
622
623 #[test]
624 fn a_distant_window_moves_the_camera_no_further_than_it_must() {
625 // Two windows the same size, one twice as far away: the camera moves
626 // exactly the extra distance, not to two different centres.
627 let c = cam(0.0, 0.0, 1.0);
628 let near = pan_into_view(2000.0, 0.0, 400.0, 300.0, c, VW, VH).unwrap();
629 let far = pan_into_view(3000.0, 0.0, 400.0, 300.0, c, VW, VH).unwrap();
630 assert_eq!(far.pan_x - near.pan_x, 1000.0);
631 }
632
633 #[test]
634 fn pan_respects_zoom() {
635 // At zoom 0.5, a window at virtual x=3900 (screen 1950) pokes 30px
636 // past the 1920 edge... screen shift -54 → pan shift +108 virtual.
637 let c = cam(0.0, 0.0, 0.5);
638 let n = pan_into_view(3700.0, 100.0, 200.0, 200.0, c, VW, VH).unwrap();
639 // screen right = (3700-0)*0.5 + 200*0.5 = 1950; overhang 30 + 24 margin.
640 assert!((n.pan_x - 108.0).abs() < 1e-9);
641 }
642 }