The tick ran the OnUpdate walk without resolving the layout around it, so a resize made in an event handler fired `OnSizeChanged` after the walk, and some walk slots resolved on their own to compensate. Separately, `OnSizeChanged` gated on 2⁻²² in benilla's units, and an emptied FontString kept its old measured box in the incremental layout. In the reference, the pump drains the layout queue before the walk and again after it, each drain firing `OnSizeChanged`, then re-picks the hover. ApplyRect compares sizes against 2⁻²² in its own units, where the screen diagonal is 1. The tick now drains before and after the walk and drops the stand-in resolves. Size gates scale 2⁻²² by the layout unit, and a FontString's text going empty or back names it on the layout when its node changes. Tests pin each. Fixes #912 Fixes #741 Fixes #914 Fixes #911 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1762 lines
73 KiB
Rust
1762 lines
73 KiB
Rust
use std::collections::HashMap;
|
||
|
||
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
|
||
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
|
||
|
||
use super::types::RegionData;
|
||
use super::{MeasureRequest, Model, UiScript, SCREEN};
|
||
|
||
/// A frame's `effective_alpha` and `effective_scale`, worn by the quads it emits itself.
|
||
#[derive(Clone, Copy)]
|
||
pub(super) struct FramePaint {
|
||
pub(super) alpha: f32,
|
||
pub(super) scale: f32,
|
||
}
|
||
|
||
/// A 128-bit fingerprint of what [`UiScript::resolve_layout`] reads: tier 2 of the change gate,
|
||
/// behind the mutation epoch ([`Model::touch_layout`]). Two lanes, as a collision keeps a stale
|
||
/// rect; fed in `HashMap` order, stable for an unmutated map, so a reorder only costs a resolve.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) struct InputFingerprint(u64, u64);
|
||
|
||
impl Default for InputFingerprint {
|
||
fn default() -> Self {
|
||
// Non-zero bases (FNV-1a's and a mix constant), so leading zeros still move both lanes.
|
||
InputFingerprint(0xcbf2_9ce4_8422_2325, 0x9e37_79b9_7f4a_7c15)
|
||
}
|
||
}
|
||
|
||
impl InputFingerprint {
|
||
#[inline]
|
||
fn feed(&mut self, v: u64) {
|
||
self.0 = (self.0 ^ v).wrapping_mul(0x0000_0100_0000_01b3);
|
||
self.1 = (self.1 ^ v.rotate_left(29)).wrapping_mul(0xff51_afd7_ed55_8ccd);
|
||
}
|
||
|
||
#[inline]
|
||
fn f32(&mut self, v: f32) {
|
||
// Bit pattern, not value: the solve is bit-exact, so -0.0 and NaN payloads are distinct.
|
||
self.feed(u64::from(v.to_bits()));
|
||
}
|
||
|
||
#[inline]
|
||
fn anchors(&mut self, anchors: &[Anchor]) {
|
||
self.feed(anchors.len() as u64);
|
||
for a in anchors {
|
||
self.feed(u64::from(a.point.id()));
|
||
self.feed(u64::from(a.relative_to));
|
||
self.feed(u64::from(a.relative_point.id()));
|
||
self.f32(a.x_off);
|
||
self.f32(a.y_off);
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn input(&mut self, i: &LayoutInput) {
|
||
self.anchors(&i.anchors);
|
||
self.f32(i.width);
|
||
self.f32(i.height);
|
||
self.f32(i.scale);
|
||
self.feed(u64::from(i.clamp));
|
||
self.f32(i.extent_x);
|
||
self.f32(i.extent_y);
|
||
}
|
||
|
||
/// The per-node `u64`; never [`NO_NODE`], or a present node would read as absent.
|
||
#[inline]
|
||
fn finish(self) -> u64 {
|
||
let h = self.0 ^ self.1.rotate_left(32);
|
||
if h == NO_NODE {
|
||
1
|
||
} else {
|
||
h
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn rect(&mut self, r: Rect) {
|
||
self.f32(r.bottom);
|
||
self.f32(r.left);
|
||
self.f32(r.top);
|
||
self.f32(r.right);
|
||
}
|
||
}
|
||
|
||
/// The sentinel in [`LayoutScope::last`] and `now` for an id not in that pass's graph.
|
||
const NO_NODE: u64 = 0;
|
||
|
||
/// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit.
|
||
#[inline]
|
||
fn frame_node_hash(input: &LayoutInput, over: Option<Anchor>) -> u64 {
|
||
let mut node = InputFingerprint::default();
|
||
node.input(input);
|
||
match over {
|
||
Some(a) => node.anchors(std::slice::from_ref(&a)),
|
||
None => node.feed(u64::MAX),
|
||
}
|
||
node.finish()
|
||
}
|
||
|
||
/// The measured extent the region sweep sizes a FontString by: the stored measure while the text
|
||
/// is non-empty, used without a key check, so changed text keeps its last box while the
|
||
/// re-measure is in flight; empty text is never measured, so it has none, never a stale box.
|
||
#[inline]
|
||
pub(super) fn effective_measure(data: &RegionData) -> Option<super::MeasuredText> {
|
||
data.measured
|
||
.filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty()))
|
||
}
|
||
|
||
/// A region's per-node input hash: its anchors, explicit size, owner, whose effective scale the
|
||
/// sweep reads (`None` for an owner with no layout id), and, where an axis authored 0 takes its
|
||
/// span from them, its effective measure and its art: exactly what the sweep reads.
|
||
#[inline]
|
||
pub(super) fn region_node_hash(data: &RegionData, owner: Option<u32>) -> u64 {
|
||
let mut node = InputFingerprint::default();
|
||
node.anchors(&data.anchors);
|
||
node.feed(owner.map_or(u64::MAX, u64::from));
|
||
match data.size {
|
||
Some((w, h)) => {
|
||
node.f32(w);
|
||
node.f32(h);
|
||
}
|
||
None => node.feed(u64::MAX),
|
||
}
|
||
let unsized_axis = data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0);
|
||
match effective_measure(data).filter(|_| unsized_axis) {
|
||
Some(m) => {
|
||
node.f32(m.w);
|
||
node.f32(m.h);
|
||
}
|
||
None => node.feed(u64::MAX),
|
||
}
|
||
// The art (`content_span`): a new texture moves such a region, while a sized region's art
|
||
// cannot move its rect.
|
||
if !data.anchors.is_empty() && unsized_axis {
|
||
node.feed(data.fill.is_some() as u64);
|
||
match &data.texture {
|
||
Some(path) => {
|
||
node.feed(path.len() as u64);
|
||
for b in path.as_bytes() {
|
||
node.feed(u64::from(*b));
|
||
}
|
||
}
|
||
None => node.feed(u64::MAX),
|
||
}
|
||
}
|
||
node.finish()
|
||
}
|
||
|
||
/// A texture's content-derived span in FrameXML units. `CSimpleTexture::GetWidth` (`0x770720`,
|
||
/// height `0x770790`) returns the authored value unless it is exactly 0.0, else the texel extent
|
||
/// through the `<AbsDimension>` converter, so one texel is one unit; a `<Color>` texture spans 8.
|
||
/// `None` when there is nothing to measure: the axis stays 0, as in the reference with no texture.
|
||
pub(super) fn content_span(
|
||
data: &RegionData,
|
||
probe: Option<&crate::script::TextureSizeProbe>,
|
||
) -> Option<(f32, f32)> {
|
||
if data.fill.is_some() {
|
||
return Some((SOLID_TEXTURE_TEXELS, SOLID_TEXTURE_TEXELS));
|
||
}
|
||
texel_span(probe, data.texture.as_deref()?)
|
||
}
|
||
|
||
/// [`content_span`] for a path: SimpleHTML reserves an image's height the same way (`0x770790`).
|
||
pub(super) fn texel_span(
|
||
probe: Option<&crate::script::TextureSizeProbe>,
|
||
path: &str,
|
||
) -> Option<(f32, f32)> {
|
||
let (w, h) = probe?(path)?;
|
||
Some((w as f32, h as f32))
|
||
}
|
||
|
||
/// The edge of the 8×8 surface the colour `SetTexture` makes (`0x770360`, sized by `0x44a900`).
|
||
const SOLID_TEXTURE_TEXELS: f32 = 8.0;
|
||
|
||
/// The floor under a FontString's derived span: `CSimpleFontString::GetWidth` (`0x772930`, height
|
||
/// `0x772a60`) returns at least one FrameXML unit, not one line, so empty text reads back 1, not 0.
|
||
pub(super) const FONTSTRING_MIN_SPAN: f32 = 1.0;
|
||
|
||
/// Marks a [`LayoutScope::node_of`] index as one into `regions` rather than `plan`.
|
||
const REGION_TAG: u32 = 0x8000_0000;
|
||
|
||
/// Per-node layout scope, so a resolve touches only what moved. Indexed densely by layout id, as
|
||
/// frames and regions share one counter (`Model::next_id`); nothing allocates on a steady frame.
|
||
///
|
||
/// The graph is derived in full once, then kept: a write names the ids it touched
|
||
/// ([`Model::touch_layout_frame`]), and the next resolve re-derives just those nodes against the
|
||
/// model ([`UiScript::rederive_named`]), joining, leaving or re-pointing them in place, the way a
|
||
/// retained layout tree marks a node dirty rather than rebuilding itself.
|
||
#[derive(Default)]
|
||
pub(crate) struct LayoutScope {
|
||
/// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty.
|
||
last: Vec<u64>,
|
||
/// The screen `last` was hashed under, every chain's root, so a move is full scope.
|
||
last_screen: Option<Rect>,
|
||
/// This pass's hash per id (parallel to `last`); swapped into `last` on convergence.
|
||
now: Vec<u64>,
|
||
/// Per id: index into this pass's node roster, or [`u32::MAX`].
|
||
node_of: Vec<u32>,
|
||
/// Per id: the head of its dependents' list in `dep_to`/`dep_next`, or [`u32::MAX`].
|
||
dep_head: Vec<u32>,
|
||
dep_to: Vec<u32>,
|
||
dep_next: Vec<u32>,
|
||
/// Edge slots [`Self::unlink_edge`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`.
|
||
dep_free: Vec<u32>,
|
||
/// Per id: the targets of the edges it recorded, in order, so a re-derive unlinks exactly those.
|
||
out: Vec<Vec<u32>>,
|
||
/// Per id: in the dirty closure.
|
||
dirty: Vec<bool>,
|
||
/// The closure's worklist.
|
||
stack: Vec<u32>,
|
||
/// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves.
|
||
plan: Vec<(FrameHandle, u32, Option<Anchor>)>,
|
||
/// The cached roster of live, anchored regions.
|
||
regions: Vec<RegionRow>,
|
||
/// Per ScrollFrame id, the live child it held when last derived: the one frame whose anchors
|
||
/// its override replaces.
|
||
scroll_child: HashMap<u32, FrameHandle>,
|
||
/// Per child, the ScrollFrames holding it, `(handle, id)`: [`Self::scroll_child`] inverted, so
|
||
/// a re-derived frame finds its override without a scan.
|
||
claims: HashMap<FrameHandle, Vec<(FrameHandle, u32)>>,
|
||
/// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence.
|
||
staged: Vec<(u32, u64)>,
|
||
/// A re-derive's target list, reused.
|
||
targets: Vec<u32>,
|
||
}
|
||
|
||
/// One live, anchored region as the round loop consumes it, a row of the cached roster.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
struct RegionRow {
|
||
rh: RegionHandle,
|
||
/// The layout id: the solver's array index and the scope's node key.
|
||
id: u32,
|
||
/// The owning frame, which supplies the region's scale and so is a layout dependency.
|
||
owner: FrameHandle,
|
||
is_fontstring: bool,
|
||
}
|
||
|
||
impl LayoutScope {
|
||
/// Size the per-id arrays for `n` ids and clear all but `last`, ahead of a full derivation.
|
||
fn begin_full(&mut self, n: usize) {
|
||
// `last` only grows: a dead node past the new count keeps its hash, so the scope still
|
||
// seeds what anchored to it.
|
||
if self.last.len() < n {
|
||
self.last.resize(n, NO_NODE);
|
||
}
|
||
self.now.clear();
|
||
self.now.resize(n, NO_NODE);
|
||
self.node_of.clear();
|
||
self.node_of.resize(n, u32::MAX);
|
||
self.dep_head.clear();
|
||
self.dep_head.resize(n, u32::MAX);
|
||
// Cleared in place, so a re-derivation reuses each list's allocation.
|
||
self.out.iter_mut().for_each(Vec::clear);
|
||
self.out.resize_with(n, Vec::new);
|
||
self.dirty.clear();
|
||
self.dirty.resize(n, false);
|
||
self.dep_to.clear();
|
||
self.dep_next.clear();
|
||
self.dep_free.clear();
|
||
self.stack.clear();
|
||
self.plan.clear();
|
||
self.regions.clear();
|
||
self.scroll_child.clear();
|
||
self.claims.clear();
|
||
self.staged.clear();
|
||
}
|
||
|
||
/// The incremental entry: the cached graph survives, grown to `n` ids, and only the dirty
|
||
/// marks reset.
|
||
fn begin_incremental(&mut self, n: usize) {
|
||
if self.node_of.len() < n {
|
||
self.node_of.resize(n, u32::MAX);
|
||
self.dep_head.resize(n, u32::MAX);
|
||
self.out.resize_with(n, Vec::new);
|
||
}
|
||
if self.last.len() < self.node_of.len() {
|
||
self.last.resize(self.node_of.len(), NO_NODE);
|
||
}
|
||
self.dirty.clear();
|
||
self.dirty.resize(self.node_of.len(), false);
|
||
self.stack.clear();
|
||
self.staged.clear();
|
||
}
|
||
|
||
/// Record that node `from` reads `to`'s rect, so a change at `to` reaches `from`.
|
||
#[inline]
|
||
fn edge(&mut self, from: u32, to: u32) {
|
||
let Some(head) = self.dep_head.get_mut(to as usize) else {
|
||
return; // an id with no slot can never turn dirty, so it constrains nothing
|
||
};
|
||
let e = match self.dep_free.pop() {
|
||
Some(e) => {
|
||
self.dep_to[e as usize] = from;
|
||
self.dep_next[e as usize] = *head;
|
||
e
|
||
}
|
||
None => {
|
||
let e = self.dep_to.len() as u32;
|
||
self.dep_to.push(from);
|
||
self.dep_next.push(*head);
|
||
e
|
||
}
|
||
};
|
||
*head = e;
|
||
}
|
||
|
||
/// [`Self::edge`], remembered as one of `from`'s own, for a later [`Self::relink`].
|
||
#[inline]
|
||
fn link(&mut self, from: u32, to: u32) {
|
||
self.edge(from, to);
|
||
if let Some(out) = self.out.get_mut(from as usize) {
|
||
out.push(to);
|
||
}
|
||
}
|
||
|
||
/// Point node `id`'s edges at `targets`, unlinking the ones it recorded; `true` when they
|
||
/// differed, which the caller seeds, as the node now reads other rects.
|
||
fn relink(&mut self, id: u32, targets: &[u32]) -> bool {
|
||
let Some(out) = self.out.get_mut(id as usize) else {
|
||
return false;
|
||
};
|
||
if out.as_slice() == targets {
|
||
return false;
|
||
}
|
||
let old = std::mem::take(out);
|
||
for &t in &old {
|
||
self.unlink_edge(t, id);
|
||
}
|
||
let mut old = old;
|
||
old.clear();
|
||
old.extend_from_slice(targets);
|
||
self.out[id as usize] = old;
|
||
for &t in targets {
|
||
self.edge(id, t);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Unlink one `to → from` edge, if the list holds one.
|
||
fn unlink_edge(&mut self, to: u32, from: u32) {
|
||
let Some(&head) = self.dep_head.get(to as usize) else {
|
||
return;
|
||
};
|
||
let mut prev = u32::MAX;
|
||
let mut e = head;
|
||
while e != u32::MAX {
|
||
let next = self.dep_next[e as usize];
|
||
if self.dep_to[e as usize] == from {
|
||
if prev == u32::MAX {
|
||
self.dep_head[to as usize] = next;
|
||
} else {
|
||
self.dep_next[prev as usize] = next;
|
||
}
|
||
self.dep_free.push(e);
|
||
return;
|
||
}
|
||
prev = e;
|
||
e = next;
|
||
}
|
||
}
|
||
|
||
/// Mark `id` dirty and queue it, if it is not already.
|
||
#[inline]
|
||
fn seed(&mut self, id: u32) {
|
||
if let Some(d) = self.dirty.get_mut(id as usize) {
|
||
if !*d {
|
||
*d = true;
|
||
self.stack.push(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A re-derived node's verdict: stage and seed it when its hash or its edges moved. Every edge
|
||
/// source (anchor targets, the owner, the override) is in the hash, so `relinked` only guards
|
||
/// against a hash that stops covering one.
|
||
#[inline]
|
||
fn note(&mut self, id: u32, hash: u64, relinked: bool) {
|
||
if relinked || hash != self.last.get(id as usize).copied().unwrap_or(NO_NODE) {
|
||
self.staged.push((id, hash));
|
||
self.seed(id);
|
||
}
|
||
}
|
||
|
||
/// Record that ScrollFrame `sid` (`sh`) now holds `child`; the child it held before, if it
|
||
/// changed, so the caller re-derives both.
|
||
fn hold(
|
||
&mut self,
|
||
sh: FrameHandle,
|
||
sid: u32,
|
||
child: Option<FrameHandle>,
|
||
) -> Option<FrameHandle> {
|
||
let before = match child {
|
||
Some(c) => self.scroll_child.insert(sid, c),
|
||
None => self.scroll_child.remove(&sid),
|
||
};
|
||
if before == child {
|
||
return None;
|
||
}
|
||
if let Some(old) = before {
|
||
if let Some(v) = self.claims.get_mut(&old) {
|
||
v.retain(|&(_, s)| s != sid);
|
||
if v.is_empty() {
|
||
self.claims.remove(&old);
|
||
}
|
||
}
|
||
}
|
||
if let Some(c) = child {
|
||
self.claims.entry(c).or_default().push((sh, sid));
|
||
}
|
||
before
|
||
}
|
||
|
||
/// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved
|
||
/// nothing the resolve reads.
|
||
pub(crate) fn holds(&self, id: u32, hash: u64) -> bool {
|
||
self.node_of
|
||
.get(id as usize)
|
||
.is_some_and(|&n| n != u32::MAX)
|
||
&& self.last.get(id as usize) == Some(&hash)
|
||
}
|
||
|
||
/// Drop region node `id` from the roster with its edges, seeding it so what read its rect
|
||
/// re-resolves without it. Returns the row it held.
|
||
fn remove_region(&mut self, id: u32, n: u32) -> RegionRow {
|
||
let idx = (n & !REGION_TAG) as usize;
|
||
let row = self.regions.swap_remove(idx);
|
||
if let Some(moved) = self.regions.get(idx) {
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
{
|
||
self.node_of[moved.id as usize] = idx as u32 | REGION_TAG;
|
||
}
|
||
}
|
||
self.node_of[id as usize] = u32::MAX;
|
||
self.relink(id, &[]);
|
||
self.staged.push((id, NO_NODE));
|
||
self.seed(id);
|
||
row
|
||
}
|
||
|
||
/// Close the dirty set under "depends on": whatever reads a dirty rect, transitively.
|
||
fn close(&mut self) {
|
||
while let Some(id) = self.stack.pop() {
|
||
let mut e = self.dep_head[id as usize];
|
||
while e != u32::MAX {
|
||
let to = self.dep_to[e as usize];
|
||
e = self.dep_next[e as usize];
|
||
self.seed(to);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mark every node dirty, for what a per-node diff cannot see (no memory, a screen move).
|
||
fn dirty_all(&mut self) {
|
||
for (id, &n) in self.node_of.iter().enumerate() {
|
||
if n != u32::MAX {
|
||
self.dirty[id] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Adopt this pass's hashes as the converged memory.
|
||
fn commit(&mut self, screen: Rect) {
|
||
std::mem::swap(&mut self.last, &mut self.now);
|
||
self.last_screen = Some(screen);
|
||
}
|
||
|
||
/// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays,
|
||
/// since a screen move always takes the derivation.
|
||
fn commit_incremental(&mut self) {
|
||
for &(id, h) in &self.staged {
|
||
self.last[id as usize] = h;
|
||
}
|
||
self.staged.clear();
|
||
}
|
||
|
||
/// Forget the memory, so the next resolve derives the graph in full scope.
|
||
pub(crate) fn invalidate(&mut self) {
|
||
self.last_screen = None;
|
||
self.last.clear();
|
||
self.plan.clear();
|
||
self.regions.clear();
|
||
self.node_of.clear();
|
||
self.staged.clear();
|
||
}
|
||
|
||
/// `WOW_LAYOUT_VERIFY`'s graph check: where this cached graph differs from `fresh`, derived
|
||
/// from scratch on the same model, in roster, per-node hash, edges or ScrollFrames.
|
||
fn diverges_from(&self, fresh: &LayoutScope) -> Option<(u32, String)> {
|
||
let at = |v: &[u32], id: usize| v.get(id).copied().unwrap_or(u32::MAX);
|
||
let hash = |s: &LayoutScope, id: usize| s.last.get(id).copied().unwrap_or(NO_NODE);
|
||
let sorted = |v: Option<&Vec<u32>>| {
|
||
let mut v = v.cloned().unwrap_or_default();
|
||
v.sort_unstable();
|
||
v
|
||
};
|
||
let incoming = |s: &LayoutScope, id: usize| {
|
||
let mut v = Vec::new();
|
||
let mut e = at(&s.dep_head, id);
|
||
while e != u32::MAX {
|
||
v.push(s.dep_to[e as usize]);
|
||
e = s.dep_next[e as usize];
|
||
}
|
||
v.sort_unstable();
|
||
v
|
||
};
|
||
let over_eq = |a: Option<Anchor>, b: Option<Anchor>| match (a, b) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => super::object::anchor_bits_eq(&a, &b),
|
||
_ => false,
|
||
};
|
||
let n = self
|
||
.node_of
|
||
.len()
|
||
.max(fresh.node_of.len())
|
||
.max(self.last.len())
|
||
.max(fresh.last.len());
|
||
for id in 0..n {
|
||
let (a, b) = (at(&self.node_of, id), at(&fresh.node_of, id));
|
||
let row = |s: &LayoutScope, n: u32| -> String {
|
||
match n {
|
||
u32::MAX => "absent".into(),
|
||
n if n & REGION_TAG != 0 => {
|
||
let r = s.regions[(n & !REGION_TAG) as usize];
|
||
format!("region {:?} owner {:?}", r.rh, r.owner)
|
||
}
|
||
n => format!(
|
||
"frame {:?} override {:?}",
|
||
s.plan[n as usize].0, s.plan[n as usize].2
|
||
),
|
||
}
|
||
};
|
||
let same_node = match (a, b) {
|
||
(u32::MAX, u32::MAX) => true,
|
||
(u32::MAX, _) | (_, u32::MAX) => false,
|
||
(a, b) if (a ^ b) & REGION_TAG != 0 => false,
|
||
(a, b) if a & REGION_TAG != 0 => {
|
||
self.regions[(a & !REGION_TAG) as usize]
|
||
== fresh.regions[(b & !REGION_TAG) as usize]
|
||
}
|
||
(a, b) => {
|
||
let (x, y) = (self.plan[a as usize], fresh.plan[b as usize]);
|
||
x.0 == y.0 && x.1 == y.1 && over_eq(x.2, y.2)
|
||
}
|
||
};
|
||
if !same_node {
|
||
return Some((
|
||
id as u32,
|
||
format!(
|
||
"id {id}: the cached graph holds {}, a fresh derivation {}",
|
||
row(self, a),
|
||
row(fresh, b)
|
||
),
|
||
));
|
||
}
|
||
if hash(self, id) != hash(fresh, id) {
|
||
return Some((
|
||
id as u32,
|
||
format!(
|
||
"id {id} ({}): the cached hash is stale against a fresh derivation",
|
||
row(self, a)
|
||
),
|
||
));
|
||
}
|
||
let (oa, ob) = (sorted(self.out.get(id)), sorted(fresh.out.get(id)));
|
||
if a != u32::MAX && oa != ob {
|
||
return Some((
|
||
id as u32,
|
||
format!(
|
||
"id {id} ({}): reads {oa:?} in the cached graph, {ob:?} fresh",
|
||
row(self, a)
|
||
),
|
||
));
|
||
}
|
||
let (ia, ib) = (incoming(self, id), incoming(fresh, id));
|
||
if ia != ib {
|
||
return Some((
|
||
id as u32,
|
||
format!("id {id}: read by {ia:?} in the cached graph, {ib:?} fresh"),
|
||
));
|
||
}
|
||
}
|
||
let held = |s: &LayoutScope| {
|
||
let mut v: Vec<(u32, FrameHandle)> =
|
||
s.scroll_child.iter().map(|(&id, &c)| (id, c)).collect();
|
||
v.sort_unstable_by_key(|e| e.0);
|
||
v
|
||
};
|
||
let (sa, sb) = (held(self), held(fresh));
|
||
if sa != sb {
|
||
return Some((0, format!("ScrollFrames hold {sa:?} cached, {sb:?} fresh")));
|
||
}
|
||
// The inverse must agree with its source, entry for entry.
|
||
let claimed: usize = self.claims.values().map(Vec::len).sum();
|
||
let inverse_ok = claimed == sa.len()
|
||
&& sa.iter().all(|&(id, c)| {
|
||
self.claims
|
||
.get(&c)
|
||
.is_some_and(|v| v.iter().any(|&(_, s)| s == id))
|
||
});
|
||
(!inverse_ok).then(|| {
|
||
(
|
||
0,
|
||
format!("ScrollFrame claims {:?} disagree with {sa:?}", self.claims),
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The layout id of the first frame or region whose rect differs between the model and `frames`
|
||
/// and `regions`, in id order.
|
||
fn first_rect_diff(
|
||
model: &Model,
|
||
frames: &HashMap<FrameHandle, Rect>,
|
||
regions: &HashMap<RegionHandle, Rect>,
|
||
) -> Option<u32> {
|
||
let mut ids: Vec<u32> = Vec::new();
|
||
for (h, r) in &model.resolved {
|
||
if frames.get(h) != Some(r) {
|
||
ids.extend(model.frame_to_id.get(h));
|
||
}
|
||
}
|
||
for h in frames.keys().filter(|h| !model.resolved.contains_key(h)) {
|
||
ids.extend(model.frame_to_id.get(h));
|
||
}
|
||
for (h, r) in &model.region_resolved {
|
||
if regions.get(h) != Some(r) {
|
||
ids.extend(model.region_to_id.get(h));
|
||
}
|
||
}
|
||
for h in regions
|
||
.keys()
|
||
.filter(|h| !model.region_resolved.contains_key(h))
|
||
{
|
||
ids.extend(model.region_to_id.get(h));
|
||
}
|
||
let differs = frames != &model.resolved || regions != &model.region_resolved;
|
||
ids.into_iter().min().or(differs.then_some(u32::MAX))
|
||
}
|
||
|
||
/// A node's layout inputs as the model holds them, for `WOW_LAYOUT_VERIFY`'s graph report.
|
||
fn node_inputs(model: &Model, id: u32) -> String {
|
||
if let Some(h) = model.id_to_frame.get(&id) {
|
||
let f = model.arena.frame(*h);
|
||
return format!(
|
||
"frame {:?} {:?}, effective scale {:?}, clamped {:?}",
|
||
f.and_then(|f| f.name.clone()),
|
||
model.layout_inputs.get(h),
|
||
f.map(|f| f.effective_scale),
|
||
f.map(|f| f.clamped_to_screen),
|
||
);
|
||
}
|
||
let Some(rh) = model.id_to_region.get(&id) else {
|
||
return "no live frame or region".into();
|
||
};
|
||
let owner = model.arena.region(*rh).map(|r| r.owner);
|
||
let owner_id = owner.and_then(|o| model.frame_to_id.get(&o));
|
||
match model.region_data.get(rh) {
|
||
Some(d) => format!(
|
||
"region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \
|
||
(text {} bytes), texture {:?}, fill {}",
|
||
d.anchors,
|
||
d.size,
|
||
d.measured.map(|m| (m.w, m.h)),
|
||
d.text.as_deref().map_or(0, str::len),
|
||
d.texture,
|
||
d.fill.is_some()
|
||
),
|
||
None => format!("region owner {owner:?} with no data"),
|
||
}
|
||
}
|
||
|
||
/// `WOW_LAYOUT_PROF=1`: the per-solve `[layout-prof]` and `[layout-pre]` lines on stderr.
|
||
fn layout_prof_enabled() -> bool {
|
||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*ON.get_or_init(|| std::env::var("WOW_LAYOUT_PROF").as_deref() == Ok("1"))
|
||
}
|
||
|
||
/// `WOW_LAYOUT_VERIFY=1`, on in this crate's tests: every shortcut of the resolve (a gate skip, a
|
||
/// tier-1-quiet frame, an incremental pass, the measure ledger) is checked against the full answer.
|
||
fn layout_verify_enabled() -> bool {
|
||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
|
||
}
|
||
|
||
/// `WOW_LAYOUT_PROF=1`'s `[layout-pre]` line: µs per preamble phase, per resolve past tier 1.
|
||
#[derive(Default)]
|
||
struct PreambleProf {
|
||
on: bool,
|
||
last: Option<std::time::Instant>,
|
||
/// The GameTooltip auto-size pre-pass (`layout_tooltips`).
|
||
tooltip: u128,
|
||
/// `OnSizeChanged`'s "before" snapshot.
|
||
watched: u128,
|
||
/// The ids rebuild and the scale and clamp sync into `layout_inputs`.
|
||
ids: u128,
|
||
/// The ScrollFrame override map.
|
||
scroll: u128,
|
||
/// `region_resolved`'s liveness retain.
|
||
retain: u128,
|
||
/// `LayoutScope::begin_full`.
|
||
begin: u128,
|
||
/// The fingerprint and scope walk over every live frame.
|
||
fp_frames: u128,
|
||
/// The same over every anchored region.
|
||
fp_regions: u128,
|
||
/// An incremental pass's whole preamble: re-hash the named nodes, seed the moved ones, close.
|
||
seed: u128,
|
||
/// A flag of its own, since `seed` rounds to 0 µs on most incremental passes.
|
||
incremental: bool,
|
||
}
|
||
|
||
impl PreambleProf {
|
||
fn new(on: bool) -> Self {
|
||
Self {
|
||
on,
|
||
last: on.then(std::time::Instant::now),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Microseconds since the previous lap (0 when off).
|
||
fn lap(&mut self) -> u128 {
|
||
if !self.on {
|
||
return 0;
|
||
}
|
||
let now = std::time::Instant::now();
|
||
self.last
|
||
.replace(now)
|
||
.map_or(0, |t| now.duration_since(t).as_micros())
|
||
}
|
||
|
||
/// Print the line once the gate's verdict is known; `skips=1` returned without solving.
|
||
fn report(&self, skips: bool, frames: usize, regions: usize) {
|
||
if !self.on {
|
||
return;
|
||
}
|
||
let total = self.tooltip
|
||
+ self.watched
|
||
+ self.ids
|
||
+ self.scroll
|
||
+ self.retain
|
||
+ self.begin
|
||
+ self.fp_frames
|
||
+ self.fp_regions
|
||
+ self.seed;
|
||
eprintln!(
|
||
"[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \
|
||
tooltip={} watched={} ids={} scroll={} retain={} begin={} \
|
||
fp_frames={} fp_regions={} seed={}",
|
||
u8::from(skips),
|
||
u8::from(self.incremental),
|
||
self.tooltip,
|
||
self.watched,
|
||
self.ids,
|
||
self.scroll,
|
||
self.retain,
|
||
self.begin,
|
||
self.fp_frames,
|
||
self.fp_regions,
|
||
self.seed
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `OnSizeChanged`'s "after": queue `(id, width, height)` for each watched frame whose size moved
|
||
/// by the `ApplyRect 0x76b580` test ([`crate::layout::size_changed`] at [`Model::size_eps`]), so a
|
||
/// pure move never fires. A frame with no rect at entry compares against the zero rect
|
||
/// `CSimpleFrame`'s ctor leaves; one that loses its rect had no `ApplyRect` and fires nothing.
|
||
fn queue_size_changes(
|
||
watched: &[(FrameHandle, Option<Rect>)],
|
||
resolved: &HashMap<FrameHandle, Rect>,
|
||
frame_to_id: &HashMap<FrameHandle, u32>,
|
||
eps: f64,
|
||
out: &mut Vec<(u32, f32, f32)>,
|
||
) {
|
||
for &(h, before) in watched {
|
||
let Some(&now) = resolved.get(&h) else {
|
||
continue;
|
||
};
|
||
let before = before.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
|
||
if !layout::size_changed(before, now, eps) {
|
||
continue;
|
||
}
|
||
let Some(&id) = frame_to_id.get(&h) else {
|
||
continue;
|
||
};
|
||
out.push((id, now.right - now.left, now.top - now.bottom));
|
||
}
|
||
}
|
||
|
||
impl UiScript {
|
||
/// [`UiScript::resolve`]'s body on a bare `Model`, so a Lua binding can force a fresh resolve,
|
||
/// as a scroll frame's range recompute must reflect a same-tick `SetHeight`.
|
||
pub(super) fn resolve_layout(model: &mut Model) {
|
||
let solves = model.layout_solves;
|
||
Self::resolve_layout_inner(model);
|
||
// A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`).
|
||
if model.layout_solves != solves {
|
||
super::backdrop::after_resolve(model);
|
||
}
|
||
if !model.layout_verify_recheck {
|
||
return;
|
||
}
|
||
// ── `WOW_LAYOUT_VERIFY`: the incremental pass's falsifier ─────────────
|
||
model.layout_verify_recheck = false;
|
||
if let Some(why) = Self::scratch_divergence(model) {
|
||
panic!("WOW_LAYOUT_VERIFY: {why}");
|
||
}
|
||
}
|
||
|
||
/// Re-solve the model from scratch on a fresh graph and compare it with the converged cached
|
||
/// one: the rects, then the graph itself (roster, per-node hashes, edges). This catches a write
|
||
/// that moved a node without naming it, and a re-derive that joined, dropped or re-pointed one
|
||
/// wrong. The cached graph is put back after, so a run of incremental passes is checked step
|
||
/// by step, not reset at each; the meters keep the incremental pass's values.
|
||
pub(super) fn scratch_divergence(model: &mut Model) -> Option<String> {
|
||
let cached = std::mem::take(&mut model.layout_scope);
|
||
let before = (model.resolved.clone(), model.region_resolved.clone());
|
||
let meters = (
|
||
model.layout_solves,
|
||
model.layout_rounds,
|
||
model.layout_last_scope,
|
||
model.layout_gate_walks,
|
||
model.layout_derives,
|
||
);
|
||
let (queued, warned) = (model.pending_size_changed.len(), model.warnings.len());
|
||
model.layout_touched = None; // no ledger: derive the graph
|
||
model.layout_epoch_resolved = None; // past tier 1
|
||
model.layout_fingerprint = None; // past tier 2
|
||
Self::resolve_layout_inner(model); // a fresh scope: full scope
|
||
let graph = cached.diverges_from(&model.layout_scope);
|
||
model.layout_scope = cached;
|
||
model.pending_size_changed.truncate(queued);
|
||
model.warnings.truncate(warned);
|
||
(
|
||
model.layout_solves,
|
||
model.layout_rounds,
|
||
model.layout_last_scope,
|
||
model.layout_gate_walks,
|
||
model.layout_derives,
|
||
) = meters;
|
||
// The first node whose rect differs, with its inputs; the graph's divergence, if any,
|
||
// usually names the cause.
|
||
let report = |id: u32| format!("id {id}, inputs now: {}", node_inputs(model, id));
|
||
let graph = graph.map(|(id, why)| format!("{why} ({})", report(id)));
|
||
if let Some(id) = first_rect_diff(model, &before.0, &before.1) {
|
||
let rect = |frames: &HashMap<FrameHandle, Rect>,
|
||
regions: &HashMap<RegionHandle, Rect>| {
|
||
match (model.id_to_frame.get(&id), model.id_to_region.get(&id)) {
|
||
(Some(h), _) => frames.get(h).copied(),
|
||
(_, Some(rh)) => regions.get(rh).copied(),
|
||
_ => None,
|
||
}
|
||
};
|
||
return Some(format!(
|
||
"an INCREMENTAL layout pass disagreed with a full derivation of the same frame — \
|
||
a write site named the wrong node or moved layout inputs without naming any. \
|
||
First rect to differ: {}, incremental {:?}, fresh {:?}. Graph: {}",
|
||
report(id),
|
||
rect(&before.0, &before.1),
|
||
rect(&model.resolved, &model.region_resolved),
|
||
graph.as_deref().unwrap_or("same as fresh"),
|
||
));
|
||
}
|
||
graph.map(|why| {
|
||
format!(
|
||
"the cached layout graph diverged from a fresh derivation after an INCREMENTAL \
|
||
pass — a write changed a node's roster, edges or inputs without naming it, or \
|
||
the re-derive mis-applied one. {why}"
|
||
)
|
||
})
|
||
}
|
||
|
||
fn resolve_layout_inner(model: &mut Model) {
|
||
// Text that emptied or filled since the last resolve: named now where its node's hash
|
||
// moved, ahead of the epoch's read, as if named at the write.
|
||
if !model.text_flips.is_empty() {
|
||
for rh in std::mem::take(&mut model.text_flips) {
|
||
model.touch_layout_content(rh);
|
||
}
|
||
}
|
||
// ── Tier 1: the mutation epoch ────────────────────────────────────────────────────────
|
||
// No layout touch since the last converged resolve: nothing the fingerprint reads moved,
|
||
// so skip everything. Under verify, fall through so tier 2 checks the claim.
|
||
let verify = layout_verify_enabled();
|
||
let tier1_clean = model.layout_epoch_resolved == Some(model.layout_epoch);
|
||
if tier1_clean && !verify {
|
||
return;
|
||
}
|
||
let epoch_at_entry = model.layout_epoch;
|
||
// Every call past tier 1 counts, verify's re-walks included: the walk is the cost.
|
||
model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1);
|
||
let mut pre = PreambleProf::new(layout_prof_enabled());
|
||
// The GameTooltip pre-pass writes tooltip sizes and offsets from cached measures.
|
||
super::tooltip::layout_tooltips(model);
|
||
pre.tooltip = pre.lap();
|
||
// ── `OnSizeChanged`'s "before" ───────────────────────────────────────────────────────
|
||
// The reference fires it per rect application (`ApplyRect 0x76b580`); our batch fixpoint
|
||
// fires on the entry-vs-convergence diff, never on a half-solved round. Only frames with
|
||
// a handler are snapshotted, from `SetScript`'s list.
|
||
let watched: Vec<(FrameHandle, Option<Rect>)> = model
|
||
.on_size_changed_frames
|
||
.iter()
|
||
.map(|&h| (h, model.resolved.get(&h).copied()))
|
||
.collect();
|
||
let size_eps = model.size_eps();
|
||
pre.watched = pre.lap();
|
||
// ── The graph: the ledger's cache, or a fresh derivation ─────────────────────────────
|
||
// A `Some` ledger claims every write since the graph was built named its node and kept
|
||
// the graph's shape, so the cached roster, edges and hashes stand; `None` derives.
|
||
// The named nodes re-derive in place; a change the cached graph cannot take derives.
|
||
let incremental = match model.layout_touched.take() {
|
||
Some(named) => Self::rederive_named(model, named, &mut pre),
|
||
None => false,
|
||
};
|
||
let fp = (!incremental).then(|| Self::derive_layout_graph(model, &mut pre));
|
||
|
||
let Model {
|
||
arena,
|
||
layout_inputs,
|
||
resolved,
|
||
region_data,
|
||
region_resolved,
|
||
frame_to_id,
|
||
screen,
|
||
warnings,
|
||
diagnostics,
|
||
solver,
|
||
layout_scope: scope,
|
||
layout_fingerprint,
|
||
layout_epoch_resolved,
|
||
layout_touched,
|
||
layout_verify_recheck,
|
||
layout_solves,
|
||
layout_last_scope,
|
||
layout_rounds,
|
||
pending_size_changed,
|
||
texture_size_probe,
|
||
..
|
||
} = model;
|
||
let mut full_scope = false;
|
||
// An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set.
|
||
let gate_skips = match fp {
|
||
Some(_) => *layout_fingerprint == fp,
|
||
None => scope.staged.is_empty(),
|
||
};
|
||
pre.report(gate_skips, scope.plan.len(), scope.regions.len());
|
||
// Under verify, a frame tier 1 judged quiet must pass tier 2 too; a divergence is a write
|
||
// path missing its `touch_layout()`.
|
||
if tier1_clean && fp.is_some() {
|
||
assert!(
|
||
gate_skips,
|
||
"WOW_LAYOUT_VERIFY: the layout epoch judged this frame quiet but the input \
|
||
fingerprint moved — a layout write path is missing its touch_layout()"
|
||
);
|
||
}
|
||
// Nothing read moved since the last converged resolve, whose rects stand: close tier 1.
|
||
if gate_skips {
|
||
*layout_epoch_resolved = Some(epoch_at_entry);
|
||
// Re-arm the ledger: the graph is as it was, so the cache is still trustworthy.
|
||
*layout_touched = Some(Vec::new());
|
||
if !verify {
|
||
return;
|
||
}
|
||
}
|
||
// Under verify a skippable resolve runs anyway, against the rects it must reproduce.
|
||
let verify_against = gate_skips.then(|| (resolved.clone(), region_resolved.clone()));
|
||
// Cleared until the fixpoint converges: a cycle-bailed pass leaves its rects mid-flight
|
||
// for the next frame to carry further, so it must re-enter both tiers dirty.
|
||
*layout_fingerprint = None;
|
||
*layout_epoch_resolved = None;
|
||
// Counted on the gate's decision, so verify's forced runs leave it alone.
|
||
if !gate_skips {
|
||
*layout_solves += 1;
|
||
}
|
||
// ── The scope: which nodes this solve may touch ────
|
||
// A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so
|
||
// the solve runs over the dirty closure and feeds other rects in as externals. Full scope
|
||
// when there is no memory yet, the screen moved, or under verify. An incremental pass
|
||
// seeded its closure in `rederive_named`.
|
||
if !incremental {
|
||
// A node that died past the new id count has no edges recorded (the derivation sized
|
||
// them to the count), so only a full scope reaches what anchored to it.
|
||
let died_past_end = scope
|
||
.last
|
||
.iter()
|
||
.skip(scope.node_of.len())
|
||
.any(|&h| h != NO_NODE);
|
||
full_scope = scope.last.is_empty()
|
||
|| scope.last_screen != Some(*screen)
|
||
|| died_past_end
|
||
|| gate_skips; // the verify path's re-run: it must reproduce the whole graph
|
||
if full_scope {
|
||
scope.dirty_all();
|
||
} else {
|
||
for (id, &n) in scope.node_of.iter().enumerate() {
|
||
if n != u32::MAX && scope.now[id] != scope.last[id] {
|
||
scope.stack.push(id as u32);
|
||
scope.dirty[id] = true;
|
||
}
|
||
}
|
||
// A node that died has no hash left to differ, so its id is seeded instead: the
|
||
// derivation recorded the edges of whatever still anchors to it, and the closure
|
||
// re-resolves those against its missing rect, as a full scope would.
|
||
for id in 0..scope.node_of.len() {
|
||
if scope.last[id] != NO_NODE
|
||
&& scope.node_of[id] == u32::MAX
|
||
&& !scope.dirty[id]
|
||
{
|
||
scope.stack.push(id as u32);
|
||
scope.dirty[id] = true;
|
||
}
|
||
}
|
||
scope.close();
|
||
}
|
||
}
|
||
// A dirty frame is solved, a dirty region swept, and whatever they read is seeded at its
|
||
// cached rect. A dirty region is seeded too, since the frame pass runs before the sweep.
|
||
let mut solve_frames: Vec<u32> = Vec::new();
|
||
let mut sweep_regions: Vec<u32> = Vec::new();
|
||
let mut seed_frames: Vec<u32> = Vec::new();
|
||
let mut seed_regions: Vec<u32> = Vec::new();
|
||
// Whether a node in the closure reads `id`; a rect nothing dirty reads is not seeded.
|
||
let read_by_dirty = |scope: &LayoutScope, id: u32| -> bool {
|
||
let mut e = scope.dep_head[id as usize];
|
||
while e != u32::MAX {
|
||
if scope.dirty[scope.dep_to[e as usize] as usize] {
|
||
return true;
|
||
}
|
||
e = scope.dep_next[e as usize];
|
||
}
|
||
false
|
||
};
|
||
for id in 0..scope.node_of.len() {
|
||
let n = scope.node_of[id];
|
||
if n == u32::MAX {
|
||
continue;
|
||
}
|
||
let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]);
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
let id32 = id as u32;
|
||
if is_region {
|
||
if dirty {
|
||
sweep_regions.push(idx);
|
||
seed_regions.push(idx);
|
||
} else if read_by_dirty(scope, id32) {
|
||
seed_regions.push(idx);
|
||
}
|
||
} else if dirty {
|
||
solve_frames.push(idx);
|
||
} else if read_by_dirty(scope, id32) {
|
||
seed_frames.push(idx);
|
||
}
|
||
}
|
||
// This solve's width, beside `layout_solves` (how often) and `layout_rounds` (how deep).
|
||
*layout_last_scope = (solve_frames.len(), sweep_regions.len());
|
||
let round_cap = scope.plan.len() + region_data.len() + 2;
|
||
// `WOW_LAYOUT_PROF=1`: rounds, graph size, and the frame-solve and region-sweep split.
|
||
let prof = layout_prof_enabled();
|
||
let mut t_frames = std::time::Duration::ZERO;
|
||
let mut t_regions = std::time::Duration::ZERO;
|
||
let mut n_regions_swept = 0u64;
|
||
for round in 0..round_cap {
|
||
*layout_rounds += 1;
|
||
let t_round = prof.then(std::time::Instant::now);
|
||
let mut changed = false;
|
||
|
||
// Seed the screen root and every read rect this pass does not recompute.
|
||
solver.begin();
|
||
solver.set_external(SCREEN, *screen);
|
||
for &i in &seed_regions {
|
||
let n = scope.regions[i as usize];
|
||
if let Some(r) = region_resolved.get(&n.rh) {
|
||
solver.set_external(n.id, *r);
|
||
}
|
||
}
|
||
for &i in &seed_frames {
|
||
let (h, id, _) = scope.plan[i as usize];
|
||
if let Some(r) = resolved.get(&h) {
|
||
solver.set_external(id, *r);
|
||
}
|
||
}
|
||
for &i in &solve_frames {
|
||
let (h, id, over) = scope.plan[i as usize];
|
||
let Some(input) = layout_inputs.get(&h) else {
|
||
continue;
|
||
};
|
||
match over {
|
||
Some(a) => solver.set_frame_anchored(id, input, a),
|
||
None => solver.set_frame(id, input),
|
||
}
|
||
}
|
||
solver.solve();
|
||
for &i in &solve_frames {
|
||
let (h, id, _) = scope.plan[i as usize];
|
||
match solver.rect(id) {
|
||
Some(r) => {
|
||
if resolved.get(&h) != Some(&r) {
|
||
resolved.insert(h, r);
|
||
changed = true;
|
||
}
|
||
}
|
||
None => {
|
||
if resolved.remove(&h).is_some() {
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: anchored regions, through the same `crate::layout` math as frames. An
|
||
// anchor may target a frame or a sibling region; one whose target has no rect yet pins
|
||
// nothing this round, and the fixpoint re-sweeps until every link settles.
|
||
if let Some(t) = t_round {
|
||
t_frames += t.elapsed();
|
||
}
|
||
let t_reg = prof.then(std::time::Instant::now);
|
||
let mut scratch = LayoutInput::default();
|
||
for &ri in &sweep_regions {
|
||
let RegionRow {
|
||
rh,
|
||
id: region_id,
|
||
owner,
|
||
is_fontstring,
|
||
} = scope.regions[ri as usize];
|
||
// A row whose data has gone is skipped until the next derivation drops it.
|
||
let Some(data) = region_data.get(&rh) else {
|
||
continue;
|
||
};
|
||
if prof {
|
||
n_regions_swept += 1;
|
||
}
|
||
// The owner supplies the region's scale and nothing else: no fallback edges.
|
||
let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0);
|
||
// An axis authored 0, width or height, takes the host-measured extent: the
|
||
// reference sizes a zero width to the unwrapped line.
|
||
let measured = effective_measure(data);
|
||
let mut height = data.size.map_or(0.0, |s| s.1);
|
||
let mut width = data.size.map_or(0.0, |s| s.0);
|
||
if let Some(m) = &measured {
|
||
if height == 0.0 {
|
||
height = m.h;
|
||
}
|
||
if width == 0.0 {
|
||
width = m.w;
|
||
}
|
||
}
|
||
// Then the floor: `CSimpleFontString::GetWidth 0x772930` (height `0x772a60`)
|
||
// returns at least one unit on both legs, past the `jp 0x772957` that skips the
|
||
// measure, so a FontString's span is never 0 and a single anchor resolves it.
|
||
if is_fontstring {
|
||
width = width.max(FONTSTRING_MIN_SPAN);
|
||
height = height.max(FONTSTRING_MIN_SPAN);
|
||
}
|
||
// A texture's unset axis takes its texel extent (`content_span`): the reference's
|
||
// size getter is virtual for both region classes. It must apply before the
|
||
// resolve, as `combine_edge` reads a zero span as no size and leaves an edge unset.
|
||
if !is_fontstring && (width == 0.0 || height == 0.0) {
|
||
if let Some((cw, ch)) = content_span(data, texture_size_probe.as_ref()) {
|
||
if width == 0.0 {
|
||
width = cw;
|
||
}
|
||
if height == 0.0 {
|
||
height = ch;
|
||
}
|
||
}
|
||
}
|
||
scratch.anchors.clear();
|
||
scratch.anchors.extend_from_slice(&data.anchors);
|
||
scratch.width = width;
|
||
scratch.height = height;
|
||
scratch.scale = scale;
|
||
let edges = layout::resolve_rect_edges(&scratch, |id| {
|
||
// The screen root is a frame-pass external only: a region anchor to id 0 finds
|
||
// no rect here, though the solver holds one.
|
||
(id != SCREEN).then(|| solver.rect(id)).flatten()
|
||
});
|
||
// An axis resolves, or the region does not. The reference resolver core
|
||
// `[0x7671a0, 0x76761f)` reads no parent or owner pointer, only the region's own
|
||
// anchors and `combineEdge`/`combineCenter` over its own size; `assemble 0x767a20`
|
||
// fails on any unset edge, and `0x768d20` latches the region unresolvable. An
|
||
// anchor-less region is anchored at creation (`region::implicit_creation_anchor`),
|
||
// and a texture with no art has a zero span and resolves to nothing (`0x767440`).
|
||
let axis = |lo: Option<f32>, hi: Option<f32>| -> Option<(f32, f32)> {
|
||
match (lo, hi) {
|
||
(Some(l), Some(h)) => Some((l, h)),
|
||
_ => None,
|
||
}
|
||
};
|
||
let vertical = axis(edges[0], edges[2]);
|
||
let horizontal = axis(edges[1], edges[3]);
|
||
let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else {
|
||
// Unresolvable: drop its rect, so it stops drawing instead of going stale.
|
||
if region_resolved.remove(&rh).is_some() {
|
||
changed = true;
|
||
}
|
||
continue;
|
||
};
|
||
let rect = Rect::new(bottom, left, top, right);
|
||
// Into the solver too: a later region in this sweep that anchors here needs it.
|
||
solver.set_external(region_id, rect);
|
||
if region_resolved.get(&rh) != Some(&rect) {
|
||
region_resolved.insert(rh, rect);
|
||
changed = true;
|
||
}
|
||
}
|
||
|
||
if let Some(t) = t_reg {
|
||
t_regions += t.elapsed();
|
||
}
|
||
if !changed {
|
||
if prof {
|
||
// `anchored` is the sweep's roster, `solved` and `swept` this pass's scope.
|
||
eprintln!(
|
||
"[layout-prof] rounds={} frames={} regions_total={} anchored={} \
|
||
solved={} swept={} scope={} regions_swept={} frame_us={} region_us={}",
|
||
round + 1,
|
||
scope.plan.len(),
|
||
region_data.len(),
|
||
scope.regions.len(),
|
||
solve_frames.len(),
|
||
sweep_regions.len(),
|
||
if full_scope { "full" } else { "dirty" },
|
||
n_regions_swept,
|
||
t_frames.as_micros(),
|
||
t_regions.as_micros()
|
||
);
|
||
}
|
||
if let Some((frames_before, regions_before)) = &verify_against {
|
||
assert!(
|
||
frames_before == resolved && regions_before == region_resolved,
|
||
"WOW_LAYOUT_VERIFY: the change gate skipped a resolve that would have \
|
||
MOVED something — the fingerprint's read set is incomplete. \
|
||
frames {} -> {} changed, regions {} -> {} changed",
|
||
frames_before.len(),
|
||
resolved.len(),
|
||
regions_before.len(),
|
||
region_resolved.len(),
|
||
);
|
||
}
|
||
*layout_fingerprint = fp;
|
||
// Adopted only on convergence; an incremental pass adopts only what it re-hashed.
|
||
if incremental {
|
||
scope.commit_incremental();
|
||
} else {
|
||
scope.commit(*screen);
|
||
}
|
||
// ── Re-arm the ledger ─────────────────────────────────────────────────────────
|
||
// The graph now describes a converged model, so writes may name nodes against it.
|
||
// Only here: the cycle bail below leaves it `None`.
|
||
*layout_touched = Some(Vec::new());
|
||
// Under verify, `resolve_layout` re-derives this frame from scratch and compares.
|
||
*layout_verify_recheck = incremental && verify;
|
||
// A converged solve closes tier 1, at `epoch_at_entry` rather than the live epoch,
|
||
// so a touch during this pass (the tooltip pre-pass, a minted id) re-opens it. The
|
||
// fingerprint hashes inputs only, so the next unmutated resolve would match it.
|
||
*layout_epoch_resolved = Some(epoch_at_entry);
|
||
queue_size_changes(
|
||
&watched,
|
||
resolved,
|
||
frame_to_id,
|
||
size_eps,
|
||
pending_size_changed,
|
||
);
|
||
return;
|
||
}
|
||
if round + 1 == round_cap {
|
||
// `Model::record_warning` inlined, as the model is destructured. Verify's re-run
|
||
// truncates `warnings` but not the log, so a verify build may count a row twice.
|
||
let msg = format!(
|
||
"layout: anchor graph did not converge in {round_cap} rounds — \
|
||
an anchor cycle? (rects left at their last pass)"
|
||
);
|
||
diagnostics.record(super::diagnostics::DiagnosticKind::Warning, &msg);
|
||
warnings.push(msg);
|
||
}
|
||
}
|
||
// The cycle bail: its rects are this frame's, so their size changes still fire, and the
|
||
// scope forgets a graph that never settled.
|
||
scope.invalidate();
|
||
queue_size_changes(
|
||
&watched,
|
||
resolved,
|
||
frame_to_id,
|
||
size_eps,
|
||
pending_size_changed,
|
||
);
|
||
}
|
||
|
||
/// Derive the layout graph from scratch: the roster, the reverse edges, the per-node hashes and
|
||
/// the tier-2 fingerprint. Runs only when the ledger cannot vouch for the cache.
|
||
fn derive_layout_graph(model: &mut Model, pre: &mut PreambleProf) -> InputFingerprint {
|
||
model.layout_derives = model.layout_derives.wrapping_add(1);
|
||
let Model {
|
||
arena,
|
||
layout_inputs,
|
||
region_data,
|
||
region_resolved,
|
||
frame_to_id,
|
||
id_to_region,
|
||
region_to_id,
|
||
next_id,
|
||
screen,
|
||
layout_scope: scope,
|
||
..
|
||
} = model;
|
||
|
||
// One id per live frame, its layout input's scale and clamp synced from the arena.
|
||
let mut ids: Vec<(FrameHandle, u32)> = Vec::with_capacity(frame_to_id.len());
|
||
for (&h, &id) in frame_to_id.iter() {
|
||
if arena.frame(h).is_some() {
|
||
ids.push((h, id));
|
||
}
|
||
}
|
||
for &(h, _) in &ids {
|
||
let (scale, clamp) = arena
|
||
.frame(h)
|
||
.map(|f| (f.effective_scale, f.clamped_to_screen))
|
||
.unwrap_or((1.0, false));
|
||
let input = layout_inputs.entry(h).or_default();
|
||
input.scale = scale;
|
||
// Clamp-to-screen is a frame property (geometry flags bit 4), so every placement path
|
||
// clamps alike, against the live window size.
|
||
input.clamp = clamp;
|
||
if clamp {
|
||
input.extent_x = screen.right;
|
||
input.extent_y = screen.top;
|
||
}
|
||
}
|
||
pre.ids = pre.lap();
|
||
|
||
// A live ScrollFrame's live child is anchored, for this solve only, TOPLEFT to its TOPLEFT
|
||
// at the raw, unnegated `(horizontal, vertical)` offsets, as the reference's re-anchor
|
||
// `0x787100` passes them to `SetPoint`: y up, so a positive vertical lifts the child. Its
|
||
// authored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore.
|
||
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
|
||
let mut scroll_holds: Vec<(FrameHandle, u32, FrameHandle)> = Vec::new();
|
||
for &(h, id) in &ids {
|
||
let Some(frame) = arena.frame(h) else {
|
||
continue;
|
||
};
|
||
if frame.kind != FrameKind::ScrollFrame {
|
||
continue;
|
||
}
|
||
let KindState::Scroll(state) = &frame.kind_state else {
|
||
continue;
|
||
};
|
||
let Some(child) = state.child else { continue };
|
||
if arena.frame(child).is_none() {
|
||
continue; // a stale/destroyed child contributes no override
|
||
}
|
||
scroll_holds.push((h, id, child));
|
||
scroll_child_anchor.insert(
|
||
child,
|
||
Anchor::new(
|
||
Point::TopLeft,
|
||
id,
|
||
Point::TopLeft,
|
||
state.horizontal,
|
||
state.vertical,
|
||
),
|
||
);
|
||
}
|
||
pre.scroll = pre.lap();
|
||
|
||
// The reference resolves one graph in which frames and regions anchor each other; ours
|
||
// alternates a frame solve and a region sweep until a round changes no rect, compared
|
||
// exactly. The round cap is the node count, so only a cycle exhausts it, warned like the
|
||
// reference's resolving-flag bail. Every rect is recomputed from inputs and externals, so
|
||
// last frame's rects are a legal seed: only dead or unanchored regions drop.
|
||
region_resolved.retain(|rh, _| {
|
||
arena.region(*rh).is_some()
|
||
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
|
||
});
|
||
pre.retain = pre.lap();
|
||
|
||
// ── The change gate ───────────────────────────────────────────────────────────────────
|
||
// Fingerprint what the rounds read, to skip them when nothing moved since the last
|
||
// converged resolve: the screen; each live frame's id, synced `LayoutInput` and
|
||
// ScrollFrame override; each anchored region's node hash (anchors, explicit size, measured
|
||
// extent, the art of an unsized axis, owner) and liveness. Seeded rects are outputs, a pure
|
||
// function of these, so they are not hashed.
|
||
// The same walk hashes each node alone into `LayoutScope::now` and records its edges.
|
||
// Sized past `next_id` for the ids the region walk below may mint.
|
||
scope.begin_full(*next_id as usize + region_data.len() + 1);
|
||
for (h, id, child) in scroll_holds {
|
||
scope.hold(h, id, Some(child));
|
||
}
|
||
pre.begin = pre.lap();
|
||
let mut fp = InputFingerprint::default();
|
||
fp.rect(*screen);
|
||
for &(h, id) in &ids {
|
||
let Some(input) = layout_inputs.get(&h) else {
|
||
continue;
|
||
};
|
||
let over = scroll_child_anchor.get(&h).copied();
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
let i = scope.plan.len() as u32;
|
||
fp.feed(u64::from(id));
|
||
fp.input(input);
|
||
match over {
|
||
Some(a) => fp.anchors(std::slice::from_ref(&a)),
|
||
None => fp.feed(u64::MAX),
|
||
}
|
||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||
*slot = frame_node_hash(input, over);
|
||
scope.node_of[id as usize] = i;
|
||
}
|
||
scope.plan.push((h, id, over));
|
||
// A frame reads its anchor targets' rects, or only its ScrollFrame override's target,
|
||
// as the round loop's `set_frame_anchored` does.
|
||
match over {
|
||
Some(a) => scope.link(id, a.relative_to),
|
||
None => {
|
||
for a in &input.anchors {
|
||
scope.link(id, a.relative_to);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
{
|
||
fp.feed(scope.plan.len() as u64);
|
||
}
|
||
pre.fp_frames = pre.lap();
|
||
let mut fed_regions = 0u64;
|
||
// The region roster, built in the same walk: live, anchored regions only.
|
||
scope.regions.reserve(region_data.len());
|
||
for (&rh, data) in region_data.iter() {
|
||
// Anchor-less entries are not layout inputs: hashing them would make a paint-only
|
||
// setter that creates one (`SetTexture`'s `or_default()`) read as a layout change.
|
||
if data.anchors.is_empty() {
|
||
continue;
|
||
}
|
||
fed_regions += 1;
|
||
let live = arena.region(rh);
|
||
let owner_id = live.and_then(|r| frame_to_id.get(&r.owner).copied());
|
||
// The node hash covers every input the sweep reads (anchors, size, measure, the art
|
||
// of an unsized axis, the owner), so the two tiers read one set.
|
||
let node = region_node_hash(data, owner_id);
|
||
fp.feed(rh.fingerprint_bits());
|
||
fp.feed(node);
|
||
if let Some(r) = live {
|
||
// Mint an id for a region that never needed one, without `Model::region_id`'s
|
||
// epoch bump, which would re-dirty the running resolve.
|
||
let id = match region_to_id.get(&rh) {
|
||
Some(&id) => id,
|
||
None => {
|
||
let id = *next_id;
|
||
*next_id += 1;
|
||
region_to_id.insert(rh, id);
|
||
id_to_region.insert(id, rh);
|
||
id
|
||
}
|
||
};
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
let idx = scope.regions.len() as u32 | REGION_TAG;
|
||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||
*slot = node;
|
||
scope.node_of[id as usize] = idx;
|
||
}
|
||
// A region reads its anchor targets' rects and its owner's scale.
|
||
for a in &data.anchors {
|
||
scope.link(id, a.relative_to);
|
||
}
|
||
if let Some(owner_id) = owner_id {
|
||
scope.link(id, owner_id);
|
||
}
|
||
scope.regions.push(RegionRow {
|
||
rh,
|
||
id,
|
||
owner: r.owner,
|
||
is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString),
|
||
});
|
||
}
|
||
// Liveness only (`owner` and `kind` never change): `WidgetArena::destroy` leaves
|
||
// `region_data` behind, so membership alone would miss a destroyed region.
|
||
fp.feed(u64::from(live.is_some()));
|
||
}
|
||
// The count of anchored entries fed, not the map's len.
|
||
fp.feed(fed_regions);
|
||
pre.fp_regions = pre.lap();
|
||
fp
|
||
}
|
||
|
||
/// Re-derive each named node against the model, in the cached graph: a node joins the roster
|
||
/// with its edges (a frame or region born, a region anchored), leaves it with its edges
|
||
/// unlinked (a region freed or cleared), or keeps its slot with its edges re-pointed and its
|
||
/// inputs re-synced, exactly as [`Self::derive_layout_graph`] would build it. Each node whose
|
||
/// hash or edges moved is seeded, and the closure taken. `false` gives up for the derivation:
|
||
/// a frame leaving the roster, or a frame two ScrollFrames claim, whose override the
|
||
/// derivation picks by map order.
|
||
fn rederive_named(model: &mut Model, mut named: Vec<u32>, pre: &mut PreambleProf) -> bool {
|
||
let Model {
|
||
arena,
|
||
layout_inputs,
|
||
region_data,
|
||
region_resolved,
|
||
frame_to_id,
|
||
id_to_frame,
|
||
id_to_region,
|
||
next_id,
|
||
screen,
|
||
layout_scope: scope,
|
||
..
|
||
} = model;
|
||
scope.begin_incremental(*next_id as usize);
|
||
// ScrollFrames first, so a child re-derived after them reads the claims they recorded.
|
||
let is_scroller = |id: u32| {
|
||
id_to_frame
|
||
.get(&id)
|
||
.and_then(|&h| arena.frame(h))
|
||
.is_some_and(|f| f.kind == FrameKind::ScrollFrame)
|
||
};
|
||
named.sort_unstable_by_key(|&id| (!is_scroller(id), id));
|
||
named.dedup();
|
||
let mut targets = std::mem::take(&mut scope.targets);
|
||
let mut at = 0;
|
||
while at < named.len() {
|
||
let id = named[at];
|
||
at += 1;
|
||
let Some(&n) = scope.node_of.get(id as usize) else {
|
||
continue; // past every minted id: never a node
|
||
};
|
||
let is_region_node = n != u32::MAX && n & REGION_TAG != 0;
|
||
// A frame: every live frame with an id is a node.
|
||
if let Some(&h) = id_to_frame.get(&id) {
|
||
let Some(frame) = arena.frame(h).filter(|_| frame_to_id.get(&h) == Some(&id))
|
||
else {
|
||
if n != u32::MAX {
|
||
scope.targets = targets;
|
||
return false;
|
||
}
|
||
continue;
|
||
};
|
||
if is_region_node {
|
||
scope.targets = targets;
|
||
return false;
|
||
}
|
||
// A ScrollFrame records the live child it holds, and that child re-derives after
|
||
// it, as the override is the child's; so does a child it let go.
|
||
if frame.kind == FrameKind::ScrollFrame {
|
||
let child = match &frame.kind_state {
|
||
KindState::Scroll(state) => {
|
||
state.child.filter(|&c| arena.frame(c).is_some())
|
||
}
|
||
_ => None,
|
||
};
|
||
let let_go = scope.hold(h, id, child);
|
||
for c in child.into_iter().chain(let_go) {
|
||
named.extend(frame_to_id.get(&c));
|
||
}
|
||
}
|
||
// The override of the one ScrollFrame holding this frame; a child two hold gives
|
||
// up, as the derivation picks between them by map order.
|
||
let over = match scope.claims.get(&h).map(Vec::as_slice) {
|
||
None | Some([]) => None,
|
||
Some(&[(sh, sid)]) => match arena.frame(sh).map(|f| &f.kind_state) {
|
||
Some(KindState::Scroll(state)) => Some(Anchor::new(
|
||
Point::TopLeft,
|
||
sid,
|
||
Point::TopLeft,
|
||
state.horizontal,
|
||
state.vertical,
|
||
)),
|
||
_ => None,
|
||
},
|
||
Some(_) => {
|
||
scope.targets = targets;
|
||
return false;
|
||
}
|
||
};
|
||
// The derivation's sync of the arena's scale and clamp into the layout input.
|
||
let (scale, clamp) = (frame.effective_scale, frame.clamped_to_screen);
|
||
let input = layout_inputs.entry(h).or_default();
|
||
input.scale = scale;
|
||
input.clamp = clamp;
|
||
if clamp {
|
||
input.extent_x = screen.right;
|
||
input.extent_y = screen.top;
|
||
}
|
||
targets.clear();
|
||
match over {
|
||
Some(a) => targets.push(a.relative_to),
|
||
None => targets.extend(input.anchors.iter().map(|a| a.relative_to)),
|
||
}
|
||
let hash = frame_node_hash(input, over);
|
||
let relinked = if n == u32::MAX {
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
{
|
||
scope.node_of[id as usize] = scope.plan.len() as u32;
|
||
}
|
||
scope.plan.push((h, id, over));
|
||
scope.relink(id, &targets);
|
||
true
|
||
} else {
|
||
scope.plan[n as usize].2 = over;
|
||
scope.relink(id, &targets)
|
||
};
|
||
scope.note(id, hash, relinked);
|
||
continue;
|
||
}
|
||
// A region: a node while it lives and has an anchor.
|
||
let live = id_to_region.get(&id).and_then(|&rh| {
|
||
let r = arena.region(rh)?;
|
||
let data = region_data.get(&rh).filter(|d| !d.anchors.is_empty())?;
|
||
Some((rh, r, data))
|
||
});
|
||
let Some((rh, r, data)) = live else {
|
||
if is_region_node {
|
||
let row = scope.remove_region(id, n);
|
||
region_resolved.remove(&row.rh);
|
||
} else if n != u32::MAX {
|
||
scope.targets = targets;
|
||
return false;
|
||
}
|
||
continue;
|
||
};
|
||
if n != u32::MAX && !is_region_node {
|
||
scope.targets = targets;
|
||
return false;
|
||
}
|
||
let owner_id = frame_to_id.get(&r.owner).copied();
|
||
targets.clear();
|
||
targets.extend(data.anchors.iter().map(|a| a.relative_to));
|
||
targets.extend(owner_id);
|
||
let hash = region_node_hash(data, owner_id);
|
||
let row = RegionRow {
|
||
rh,
|
||
id,
|
||
owner: r.owner,
|
||
is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString),
|
||
};
|
||
let relinked = if n == u32::MAX {
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
{
|
||
scope.node_of[id as usize] = scope.regions.len() as u32 | REGION_TAG;
|
||
}
|
||
scope.regions.push(row);
|
||
scope.relink(id, &targets);
|
||
true
|
||
} else {
|
||
scope.regions[(n & !REGION_TAG) as usize] = row;
|
||
scope.relink(id, &targets)
|
||
};
|
||
scope.note(id, hash, relinked);
|
||
}
|
||
scope.targets = targets;
|
||
scope.close();
|
||
pre.seed = pre.lap();
|
||
pre.incremental = true;
|
||
true
|
||
}
|
||
|
||
/// FontStrings with text whose stored measure is stale, for the host to measure: call after
|
||
/// [`UiScript::resolve`], answer with [`UiScript::set_measured_text`], and resolve again.
|
||
pub fn fontstrings_needing_measure(&mut self) -> Vec<MeasureRequest> {
|
||
let mut model = self.model_mut();
|
||
// Every FontString with text, sized or not: `GetStringWidth` answers the natural width.
|
||
// The ledger (`Model::touch_measure`) re-arms empty, so a second ask this frame sees only
|
||
// the writes since the first; `None` walks every region.
|
||
let ledger = model.measure_dirty.take();
|
||
model.measure_dirty = Some(Vec::new());
|
||
let needy: Vec<RegionHandle> = match ledger {
|
||
Some(mut dirty) => {
|
||
dirty.sort_unstable_by_key(|rh| rh.fingerprint_bits());
|
||
dirty.dedup();
|
||
dirty.retain(|&rh| stale_measure_key(&model, rh).is_some());
|
||
// Under verify, the full walk runs beside the drain and panics on any stale region
|
||
// the ledger missed.
|
||
if layout_verify_enabled() {
|
||
let roster: Vec<RegionHandle> = model
|
||
.region_data
|
||
.iter()
|
||
.filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
|
||
.map(|(&rh, _)| rh)
|
||
.filter(|&rh| stale_measure_key(&model, rh).is_some())
|
||
.collect();
|
||
for rh in &roster {
|
||
assert!(
|
||
dirty.contains(rh),
|
||
"measure ledger missed a write: region {rh:?} (id {:?}) needs a \
|
||
measure the drained ledger never named — a measure-key write site \
|
||
is not enrolled in Model::touch_measure",
|
||
model.region_to_id.get(rh),
|
||
);
|
||
}
|
||
}
|
||
dirty
|
||
}
|
||
None => model
|
||
.region_data
|
||
.iter()
|
||
.filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty()))
|
||
.map(|(&rh, _)| rh)
|
||
.filter(|&rh| stale_measure_key(&model, rh).is_some())
|
||
.collect(),
|
||
};
|
||
// Re-enroll every stale region: a request is not an answer, and nothing else asks again.
|
||
if let Some(list) = &mut model.measure_dirty {
|
||
list.extend(needy.iter().copied());
|
||
}
|
||
let mut out = Vec::with_capacity(needy.len());
|
||
for rh in needy {
|
||
if let Some(req) = measure_request_for(&mut model, rh) {
|
||
out.push(req);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// The [`MeasureRequest`] region `rh` needs now, if any; shared with the synchronous measure
|
||
/// ([`super::measure::ensure_measured`]), as both must build the same cache key.
|
||
pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option<MeasureRequest> {
|
||
let (key, scale) = stale_measure_key(model, rh)?;
|
||
let d = model.region_data.get(&rh)?;
|
||
let text = d.text.clone().filter(|t| !t.is_empty())?;
|
||
let wrap_width = d.size.map(|s| s.0).filter(|w| *w > 0.0);
|
||
let font = d.font_path.clone();
|
||
let height = d.font_height;
|
||
let text_height = d.text_height;
|
||
let outline = d.outline;
|
||
let id = model.region_id(rh);
|
||
Some(MeasureRequest {
|
||
id,
|
||
font,
|
||
height,
|
||
text_height,
|
||
wrap_width,
|
||
outline,
|
||
scale,
|
||
text,
|
||
key,
|
||
})
|
||
}
|
||
|
||
/// `Some((key, scale))` iff `rh` is a FontString with text whose stored measure no longer matches
|
||
/// its key: the one staleness test, allocation-free as the sweep asks it of every FontString.
|
||
fn stale_measure_key(model: &Model, rh: RegionHandle) -> Option<(u64, f32)> {
|
||
let region = model.arena.region(rh)?;
|
||
if !matches!(region.kind, crate::widget::RegionKind::FontString) {
|
||
return None;
|
||
}
|
||
// The host measures at the drawn size, so the key carries the owner's scale.
|
||
let scale = model
|
||
.arena
|
||
.frame(region.owner)
|
||
.map(|f| f.effective_scale)
|
||
.unwrap_or(1.0);
|
||
let d = model.region_data.get(&rh)?;
|
||
if d.text.as_deref().is_none_or(|t| t.is_empty()) {
|
||
return None;
|
||
}
|
||
// The metric reads in `region.rs` check the stored measure against this same key.
|
||
let key = d.measure_key(scale);
|
||
if d.measured.map(|m| m.key) == Some(key) {
|
||
return None;
|
||
}
|
||
Some((key, scale))
|
||
}
|