host: tab placements skip unchanged bounds, one SetBounds per move, and follow window resizes natively
layout.rs (headless, tested): Placements remembers each tab webview's last rectangle and visibility, so a tabs_sync that changes nothing makes no native call and one that moves one pane touches one webview; Frame/follow_rect estimate where panes go when the window size changes (outer margins fixed, proportional in between, shared borders stay shared). host.rs: place_tab/forget_tab use it; place_child is a single set_bounds (one WebView2 SetBounds instead of a move then a resize, each a page re-layout); follow_window_resizes re-places visible tabs from the window's own Resized events on the main thread, before the shell lays out again, and keeps WindowMetrics current so a sync needs no blocking round trips for the window size; layout_window says when a layout was made for a stale size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ad52a4ce7
commit
9313f249af
3 changed files with 327 additions and 6 deletions
202
crates/waybetter-host/src/layout.rs
Normal file
202
crates/waybetter-host/src/layout.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! 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 {
|
||||
/// 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();
|
||||
p.place("tab-a", (5.0, 92.0, 680.0, 803.0), false);
|
||||
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 });
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue