git.lucas.co / cce-window-manager
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 }