waybetter/crates/waybetter-host/src/layout.rs

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);
}
}