//! Native placement bookkeeping for tab webviews, headless-testable. //! //! - [`Placements::place`]: what each child webview last got, so a sync that changes nothing costs //! no native call (a WebView2 `SetBounds` re-lays out the whole page; `show` re-sends //! visibility), and a sync that only moves one pane touches only that pane. //! - [`Placements::follow`]: the window was resized before the shell laid out again. Each visible //! child is re-placed natively from the last layout (no JS round trip), so panes stay glued to //! the window while it is dragged, maximized or snapped; the shell's exact layout replaces the //! estimate a frame or two later. //! - [`follow_rect`]: the estimate. The layout's outer margins are fixed (titlebar, address bars //! and resize border on top/left, the border on right/bottom) and everything between them scales //! proportionally, which is what the shell's split view does with its panes. use std::collections::HashMap; /// A rectangle in logical px: x, y, width, height. pub type Rect = (f64, f64, f64, f64); /// What changed for one child in [`Placements::place`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Changed { pub bounds: bool, pub visible: bool, } #[derive(Clone, Copy, Debug, PartialEq)] struct Placed { rect: Rect, visible: bool, } /// The outer margins of a layout: everything between them scales with the window. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Frame { pub left: f64, pub top: f64, pub right: f64, pub bottom: f64, } impl Frame { /// The margins around `rects` in a window of `size`. `None` without a rectangle. pub fn around<'a>(rects: impl IntoIterator, size: (f64, f64)) -> Option { let mut bounds: Option<(f64, f64, f64, f64)> = None; for &(x, y, w, h) in rects { let (l, t, r, b) = bounds.unwrap_or((x, y, x + w, y + h)); bounds = Some((l.min(x), t.min(y), r.max(x + w), b.max(y + h))); } let (l, t, r, b) = bounds?; Some(Self { left: l, top: t, right: (size.0 - r).max(0.0), bottom: (size.1 - b).max(0.0) }) } } /// Maps one edge from an old span to a new one: fixed margins, proportional in between. fn edge(value: f64, start: f64, old_end: f64, new_end: f64) -> f64 { let old = old_end - start; if old <= 1.0 { return value; } start + (value - start) * ((new_end - start).max(0.0) / old) } /// Where `rect` (laid out in a window of `old` size, inside `frame`) goes in a window of `new` /// size. Edges are mapped, not the size, so neighbouring panes keep sharing their border; the /// result is in whole px with at least 1x1. pub fn follow_rect(rect: Rect, old: (f64, f64), new: (f64, f64), frame: Frame) -> Rect { let (x, y, w, h) = rect; let map_x = |v: f64| edge(v, frame.left, old.0 - frame.right, new.0 - frame.right).round(); let map_y = |v: f64| edge(v, frame.top, old.1 - frame.bottom, new.1 - frame.bottom).round(); let (left, top) = (map_x(x), map_y(y)); let (right, bottom) = (map_x(x + w), map_y(y + h)); (left, top, (right - left).max(1.0), (bottom - top).max(1.0)) } /// Last native placement per child webview label, and the window size the shell laid them out in. #[derive(Debug, Default)] pub struct Placements { tabs: HashMap, window: Option<(f64, f64)>, } impl Placements { pub fn was_visible(&self, label: &str) -> bool { self.tabs.get(label).is_some_and(|placed| placed.visible) } /// Records `label` at `rect`/`visible` and says what differs from its last placement (a child /// never placed before differs in both). pub fn place(&mut self, label: &str, rect: Rect, visible: bool) -> Changed { let next = Placed { rect, visible }; match self.tabs.insert(label.to_owned(), next) { Some(last) => Changed { bounds: last.rect != rect, visible: last.visible != visible }, None => Changed { bounds: true, visible: true }, } } /// A child webview was closed: a new one with the same label starts from scratch. pub fn forget(&mut self, label: &str) { self.tabs.remove(label); } /// The window size (logical px) the shell's layout was computed for. pub fn set_window(&mut self, size: (f64, f64)) { self.window = Some(size); } pub fn window(&self) -> Option<(f64, f64)> { self.window } /// The window is now `size`: new rectangles for the visible children, estimated from the last /// layout ([`follow_rect`]), already recorded as their placement. Empty when nothing moves. pub fn follow(&mut self, size: (f64, f64)) -> Vec<(String, Rect)> { let Some(old) = self.window.replace(size) else { return Vec::new() }; if old == size { return Vec::new(); } let visible: Vec = self.tabs.values().filter(|p| p.visible).map(|p| p.rect).collect(); let Some(frame) = Frame::around(&visible, old) else { return Vec::new() }; let mut moved = Vec::new(); for (label, placed) in self.tabs.iter_mut().filter(|(_, p)| p.visible) { let rect = follow_rect(placed.rect, old, size, frame); if rect != placed.rect { placed.rect = rect; moved.push((label.clone(), rect)); } } moved.sort_by(|a, b| a.0.cmp(&b.0)); moved } } #[cfg(test)] mod tests { use super::*; const WIN: (f64, f64) = (1360.0, 900.0); #[test] fn unchanged_placements_cost_nothing() { let mut p = Placements::default(); assert_eq!(p.place("tab-a", (5.0, 92.0, 680.0, 803.0), true), Changed { bounds: true, visible: true }); assert_eq!(p.place("tab-a", (5.0, 92.0, 680.0, 803.0), true), Changed { bounds: false, visible: false }); assert_eq!(p.place("tab-a", (5.0, 92.0, 600.0, 803.0), true), Changed { bounds: true, visible: false }); assert_eq!(p.place("tab-a", (5.0, 92.0, 600.0, 803.0), false), Changed { bounds: false, visible: true }); } #[test] fn a_recreated_webview_is_placed_again() { let mut p = Placements::default(); assert!(!p.was_visible("tab-a")); p.place("tab-a", (5.0, 92.0, 680.0, 803.0), false); assert!(!p.was_visible("tab-a")); p.forget("tab-a"); // A fresh webview starts visible: hiding it must not be skipped as "unchanged". assert_eq!(p.place("tab-a", (5.0, 92.0, 680.0, 803.0), false), Changed { bounds: true, visible: true }); p.place("tab-a", (5.0, 92.0, 680.0, 803.0), true); assert!(p.was_visible("tab-a")); } #[test] fn frame_is_the_margin_around_the_layout() { let rects = [(5.0, 92.0, 676.0, 803.0), (687.0, 92.0, 668.0, 803.0)]; assert_eq!(Frame::around(&rects, WIN), Some(Frame { left: 5.0, top: 92.0, right: 5.0, bottom: 5.0 })); assert_eq!(Frame::around(&[], WIN), None); } #[test] fn follow_keeps_margins_and_scales_between_them() { let frame = Frame { left: 5.0, top: 92.0, right: 5.0, bottom: 5.0 }; // A single pane fills the frame at any size. assert_eq!(follow_rect((5.0, 92.0, 1350.0, 803.0), WIN, (1000.0, 700.0), frame), (5.0, 92.0, 990.0, 603.0)); // Two side-by-side panes keep sharing their border, which moves proportionally. let left = follow_rect((5.0, 92.0, 676.0, 803.0), WIN, (1000.0, 900.0), frame); let right = follow_rect((687.0, 92.0, 668.0, 803.0), WIN, (1000.0, 900.0), frame); assert_eq!(left.0, 5.0); assert_eq!(right.0 + right.2, 995.0); assert!((right.0 - (left.0 + left.2) - 6.0).abs() <= 2.0, "the gap between panes stays about the same: {left:?} {right:?}"); } #[test] fn follow_moves_only_visible_children_and_records_them() { let mut p = Placements::default(); p.set_window(WIN); p.place("tab-a", (5.0, 92.0, 1350.0, 803.0), true); p.place("tab-b", (5.0, 92.0, 1350.0, 803.0), false); let moved = p.follow((1200.0, 800.0)); assert_eq!(moved, vec![("tab-a".to_owned(), (5.0, 92.0, 1190.0, 703.0))]); assert_eq!(p.window(), Some((1200.0, 800.0))); // The shell's exact layout for the new size is then a no-op for native code. assert_eq!(p.place("tab-a", (5.0, 92.0, 1190.0, 703.0), true), Changed { bounds: false, visible: false }); // The same size again moves nothing. assert!(p.follow((1200.0, 800.0)).is_empty()); } #[test] fn follow_needs_a_known_window() { let mut p = Placements::default(); p.place("tab-a", (5.0, 92.0, 1350.0, 803.0), true); assert!(p.follow((1000.0, 700.0)).is_empty()); assert_eq!(p.window(), Some((1000.0, 700.0))); } #[test] fn follow_never_collapses_below_one_px() { let frame = Frame { left: 5.0, top: 92.0, right: 5.0, bottom: 5.0 }; let (_, _, w, h) = follow_rect((5.0, 92.0, 100.0, 50.0), WIN, (10.0, 10.0), frame); assert!(w >= 1.0 && h >= 1.0); } }