210 lines
8.9 KiB
Rust
210 lines
8.9 KiB
Rust
//! 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<Item = &'a Rect>, size: (f64, f64)) -> Option<Self> {
|
|
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<String, Placed>,
|
|
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<Rect> = 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);
|
|
}
|
|
}
|