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.
This commit is contained in:
CRTkafa 2026-09-29 03:23:44 +07:00
parent b5bef3463b
commit 30eed908cb
3 changed files with 327 additions and 6 deletions

View file

@ -264,11 +264,13 @@ mod helper_close {
pub fn place_child<R: Runtime>(webview: &Webview<R>, x: f64, y: f64, width: f64, height: f64) {
#[cfg(target_os = "linux")]
linux::place(webview, x, y, width, height);
// One native move+resize: separate set_position/set_size calls were two WebView2 `SetBounds`
// (the page re-laid out at the old size, then at the new one) and two main-thread messages.
#[cfg(not(target_os = "linux"))]
{
let _ = webview.set_position(tauri::LogicalPosition::new(x, y));
let _ = webview.set_size(tauri::LogicalSize::new(width, height));
}
let _ = webview.set_bounds(tauri::Rect {
position: tauri::LogicalPosition::new(x, y).into(),
size: tauri::LogicalSize::new(width, height).into(),
});
}
/// Linux: the shell webview becomes the base layer of a GtkOverlay filling the window, which
@ -281,8 +283,6 @@ pub fn attach_shell<R: Runtime>(shell: &Webview<R>) {
let _ = shell;
}
/// Where [`place_child`] last put a child webview, in logical px (x, y, width, height). Linux
/// only: there `Webview::position` is not the placement (always 0,0 outside X11 child windows).
/// Puts the shell webview above every tab webview (`on_top`), so shell menus, dialogs and drag
/// zones draw over the panes while the panes stay on screen (the shell page must then be
/// transparent where the panes are), or back below them. Returns whether this platform can.
@ -364,6 +364,8 @@ mod macos {
}
}
/// Where [`place_child`] last put a child webview, in logical px (x, y, width, height). Linux
/// only: there `Webview::position` is not the placement (always 0,0 outside X11 child windows).
#[cfg(target_os = "linux")]
pub fn placed_rect<R: Runtime>(webview: &Webview<R>) -> Option<(f64, f64, f64, f64)> {
linux::placed(webview.label())
@ -526,6 +528,11 @@ pub fn set_tab_visible<R: Runtime>(webview: &Webview<R>, visible: bool, parked:
capture_still(webview);
}
let _ = if visible { webview.show() } else { webview.hide() };
set_memory_target(webview, visible, parked);
}
fn set_memory_target<R: Runtime>(webview: &Webview<R>, visible: bool, parked: bool) {
let _ = (webview, visible, parked);
#[cfg(windows)]
{
use std::collections::HashMap;
@ -559,3 +566,113 @@ pub fn set_tab_visible<R: Runtime>(webview: &Webview<R>, visible: bool, parked:
});
}
}
fn placements() -> std::sync::MutexGuard<'static, crate::layout::Placements> {
use std::sync::{Mutex, OnceLock};
static PLACEMENTS: OnceLock<Mutex<crate::layout::Placements>> = OnceLock::new();
PLACEMENTS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Places a tab webview and shows or hides it ([`place_child`], [`set_tab_visible`]), skipping the
/// native calls for whatever did not change since its last placement: a sync that moves one pane
/// touches one webview, and one that changes nothing costs nothing.
pub fn place_tab<R: Runtime>(webview: &Webview<R>, rect: crate::layout::Rect, visible: bool, parked: bool) {
let changed = placements().place(webview.label(), rect, visible);
if changed.bounds {
let (x, y, width, height) = rect;
place_child(webview, x, y, width, height);
}
if changed.visible {
set_tab_visible(webview, visible, parked);
} else {
set_memory_target(webview, visible, parked);
}
}
/// A tab webview was closed: a new one with the same label is placed from scratch.
pub fn forget_tab(label: &str) {
placements().forget(label);
}
/// The window size (logical px) and state a tab layout is clamped against.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WindowMetrics {
pub width: f64,
pub height: f64,
pub maximized: bool,
pub frameless: bool,
}
fn metrics_cache() -> std::sync::MutexGuard<'static, Option<WindowMetrics>> {
use std::sync::{Mutex, OnceLock};
static METRICS: OnceLock<Mutex<Option<WindowMetrics>>> = OnceLock::new();
METRICS.get_or_init(Default::default).lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn read_metrics<R: Runtime>(window: &tauri::Window<R>) -> tauri::Result<WindowMetrics> {
let size = window.inner_size()?.to_logical::<f64>(window.scale_factor()?);
Ok(WindowMetrics {
width: size.width,
height: size.height,
maximized: window.is_maximized().unwrap_or(false),
frameless: !window.is_decorated().unwrap_or(true),
})
}
/// The window's metrics, kept current by [`follow_window_resizes`]: a tab sync (off the main
/// thread) then needs no blocking round trip to the event loop for them.
pub fn window_metrics<R: Runtime>(window: &tauri::Window<R>) -> tauri::Result<WindowMetrics> {
if let Some(metrics) = *metrics_cache() {
return Ok(metrics);
}
let metrics = read_metrics(window)?;
*metrics_cache() = Some(metrics);
Ok(metrics)
}
/// The shell laid its panes out for a window of `viewport` (its own inner size); returns the
/// window size to lay them out in now and whether the layout is stale (the window was resized
/// since). Stale layouts are re-based with [`crate::layout::follow_rect`] by the caller.
pub fn layout_window(metrics: &WindowMetrics, viewport: Option<(f64, f64)>) -> Option<(f64, f64)> {
let (width, height) = viewport.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 1.0 && *h > 1.0)?;
let stale = (width - metrics.width).abs() > 1.5 || (height - metrics.height).abs() > 1.5;
stale.then_some((width, height))
}
/// Records the window size the tab placements now correspond to.
pub fn layout_placed(metrics: &WindowMetrics) {
placements().set_window((metrics.width, metrics.height));
}
/// Keeps tab webviews glued to the window while it is resized, maximized or snapped: on every
/// resize (main thread, in the same event the runtime resizes the shell webview in) the visible
/// tabs are re-placed from the last layout ([`crate::layout::Placements::follow`]), with no JS
/// round trip. The shell's exact layout follows a frame or two later and replaces the estimate.
/// Call once from `setup`, after the window is built.
pub fn follow_window_resizes<R: Runtime>(window: &tauri::Window<R>) {
if let Ok(metrics) = read_metrics(window) {
*metrics_cache() = Some(metrics);
}
let target = window.clone();
window.on_window_event(move |event| {
if !matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) {
return;
}
let Ok(metrics) = read_metrics(&target) else { return };
*metrics_cache() = Some(metrics);
// Minimized: 0x0; nothing to follow, and the next restore re-bases from the last layout.
if metrics.width < 2.0 || metrics.height < 2.0 {
return;
}
let moved = placements().follow((metrics.width, metrics.height));
for (label, (x, y, width, height)) in moved {
if let Some(webview) = target.app_handle().get_webview(&label) {
// Never over the titlebar or the resize border, like every shell layout.
let clamped = policy::clamp_tab_rect(x, y, width, height, metrics.width, metrics.height, metrics.maximized, metrics.frameless);
if let Ok(Some((x, y, width, height))) = clamped {
place_child(&webview, x, y, width, height);
}
}
}
});
}

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

View file

@ -13,10 +13,12 @@
//! - [`hotfix`]: the signed, data-only hotfix channel.
//! - [`update`]: silent-update scheduling and the crash handshake.
//! - [`tabs`]: tab layout/state payloads, link routing and tab-chrome validation.
//! - [`layout`]: native placement bookkeeping (unchanged bounds skipped, window-resize follow).
//!
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
pub mod hotfix;
pub mod layout;
pub mod plugins;
pub mod policy;
pub mod profiles;