Text measured `ceil(sum of steps) + 1`: no kerning, float advances, the last glyph priced by its step, and no outline or shadow lead. In 1.12.1 the kernel (`0x5c6940`) seeds a line with the outline lead plus the shadow (`0x5c69c0`, `0x5c69a4`), steps each glyph by `ceil(floor(adv) + 1 + min(kern, 0))` on FreeType's 26.6 advances (`0x5ca2d0`, `0x5d1298`), prices the line's last glyph by its ink (`0x5c6b70`, `0x5cb080`) and carries the previous glyph across a break (`0x5c6aa0`). The draw rows use the same law, shadow-seeded, wrapping at `ceil(width)` (`0x5c7470`, `0x5cddb1`). The measure, the wrap, the justify and the EditBox now follow it, reading the face's own metrics, kerning and composite build (`0x7d40e6`). Tests pin widths computed outside the code base from FRIZQT and ARIALN at 12–34 px, with and without outline and shadow. Fixes #427 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1954 lines
82 KiB
Rust
1954 lines
82 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()));
|
||
}
|
||
|
||
/// The anchors in point order, then their count.
|
||
#[inline]
|
||
fn anchors<'a>(&mut self, anchors: impl IntoIterator<Item = &'a Anchor>) {
|
||
let mut n = 0u64;
|
||
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);
|
||
n += 1;
|
||
}
|
||
self.feed(n);
|
||
}
|
||
|
||
#[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 the closure revisits
|
||
/// what read its rect; nothing invalidates those, so they keep their state. 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)) => a.bits_eq(&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 kept_before = model.region_kept.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"),
|
||
));
|
||
}
|
||
if kept_before != model.region_kept {
|
||
return Some(format!(
|
||
"an INCREMENTAL layout pass kept failed textures' quads {kept_before:?}, a full \
|
||
derivation {:?}. Graph: {}",
|
||
model.region_kept,
|
||
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();
|
||
// The invalidations since the last resolve, the pre-pass's included, each answered by this
|
||
// one, as the reference drains its whole queue (`0x768ed0`).
|
||
let pending = std::mem::take(&mut model.layout_pending);
|
||
// ── `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();
|
||
let resolve_eps = model.resolve_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,
|
||
region_kept,
|
||
frame_to_id,
|
||
id_to_frame,
|
||
id_to_region,
|
||
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,
|
||
layout_force,
|
||
layout_screen,
|
||
..
|
||
} = model;
|
||
let mut full_scope = false;
|
||
// The nodes this resolve stores without the keep test; a write that cleared the resolved
|
||
// bit of no node (an unanchored region's measure) leaves nothing to store.
|
||
let mut forced = std::mem::take(layout_force);
|
||
// An invalidated region with no anchors is no node, and its resolve can only fail
|
||
// (`0x768d55`): its valid bit clears, a Texture keeps its last quad. So does one whose
|
||
// owner's scale moved, which the cascade invalidates (`0x76ad06`).
|
||
let fail_loose = |rh: RegionHandle,
|
||
region_resolved: &mut HashMap<RegionHandle, Rect>,
|
||
region_kept: &mut HashMap<RegionHandle, Rect>| {
|
||
let Some(r) = arena.region(rh) else { return };
|
||
if region_data.get(&rh).is_some_and(|d| !d.anchors.is_empty()) {
|
||
return;
|
||
}
|
||
if let Some(rect) = region_resolved.remove(&rh) {
|
||
if !matches!(r.kind, crate::widget::RegionKind::FontString) {
|
||
region_kept.insert(rh, rect);
|
||
}
|
||
}
|
||
};
|
||
for &id in &pending {
|
||
if let Some(&rh) = id_to_region.get(&id) {
|
||
fail_loose(rh, region_resolved, region_kept);
|
||
}
|
||
}
|
||
for &id in &forced {
|
||
if let Some(&rh) = id_to_region.get(&id) {
|
||
fail_loose(rh, region_resolved, region_kept);
|
||
} else if let Some(f) = id_to_frame.get(&id).and_then(|&h| arena.frame(h)) {
|
||
for &rh in &f.regions {
|
||
fail_loose(rh, region_resolved, region_kept);
|
||
}
|
||
}
|
||
}
|
||
forced.retain(|&id| {
|
||
scope
|
||
.node_of
|
||
.get(id as usize)
|
||
.is_some_and(|&n| n != u32::MAX)
|
||
});
|
||
let mut pending = pending;
|
||
pending.retain(|&id| {
|
||
scope
|
||
.node_of
|
||
.get(id as usize)
|
||
.is_some_and(|&n| n != u32::MAX)
|
||
});
|
||
// An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set.
|
||
// A forced or invalidated node is solved even with its inputs unmoved.
|
||
let gate_skips = forced.is_empty()
|
||
&& pending.is_empty()
|
||
&& 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 (the
|
||
// keep test holds a recompute against the rect it kept, so a re-run is a no-op), 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 forced or invalidated node, and whatever reads it, is solved: the store it owes moves
|
||
// its rect.
|
||
for &id in forced.iter().chain(&pending) {
|
||
scope.seed(id);
|
||
}
|
||
scope.close();
|
||
let mut force_mask: Vec<bool> = Vec::new();
|
||
let mut invalid_mask: Vec<bool> = Vec::new();
|
||
if !forced.is_empty() || !pending.is_empty() {
|
||
force_mask.resize(scope.node_of.len(), false);
|
||
invalid_mask.resize(scope.node_of.len(), false);
|
||
for &id in &forced {
|
||
force_mask[id as usize] = true;
|
||
invalid_mask[id as usize] = true;
|
||
}
|
||
for &id in &pending {
|
||
invalid_mask[id as usize] = true;
|
||
}
|
||
}
|
||
let is_forced = |id: u32| force_mask.get(id as usize).copied().unwrap_or(false);
|
||
// Invalidated since the last resolve (`0x7680e0`): solved from its inputs, never kept.
|
||
let is_invalid = |id: u32| invalid_mask.get(id as usize).copied().unwrap_or(false);
|
||
// 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
|
||
};
|
||
// A cleared region is no node but keeps its rect, which what anchors to it still reads.
|
||
let mut seed_loose: Vec<(u32, Rect)> = Vec::new();
|
||
for id in 0..scope.node_of.len() {
|
||
let n = scope.node_of[id];
|
||
if n == u32::MAX {
|
||
#[allow(clippy::cast_possible_truncation)]
|
||
let id32 = id as u32;
|
||
if scope.dep_head[id] != u32::MAX && read_by_dirty(scope, id32) {
|
||
if let Some(r) = id_to_region
|
||
.get(&id32)
|
||
.and_then(|rh| region_resolved.get(rh))
|
||
{
|
||
seed_loose.push((id32, *r));
|
||
}
|
||
}
|
||
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());
|
||
// ── The keep test's cached rects ──────────────────────────────────────────────────────
|
||
// `0x768d20` holds each new rect against the one its last store left (`[+0x40..+0x4c]`)
|
||
// and keeps that one when every edge moved less than 1e-5 layout units, so a slow drift
|
||
// builds until it crosses. The cache is the rect at this resolve's entry: a mid-pass round
|
||
// is our fixpoint's, not a store. A forced node, a frame with no rect, a region whose
|
||
// owner's scale moved (the cascade reaches its regions, `0x76ad06`), has no cache.
|
||
let entry_frames: Vec<Option<Rect>> = solve_frames
|
||
.iter()
|
||
.map(|&i| {
|
||
let (h, id, _) = scope.plan[i as usize];
|
||
(!is_forced(id))
|
||
.then(|| resolved.get(&h).copied())
|
||
.flatten()
|
||
})
|
||
.collect();
|
||
let entry_regions: Vec<(Option<Rect>, bool)> = sweep_regions
|
||
.iter()
|
||
.map(|&i| {
|
||
let row = scope.regions[i as usize];
|
||
let owner_forced = frame_to_id.get(&row.owner).is_some_and(|&o| is_forced(o));
|
||
let cached = (!is_forced(row.id) && !owner_forced)
|
||
.then(|| region_resolved.get(&row.rh).copied())
|
||
.flatten();
|
||
(cached, is_invalid(row.id) || owner_forced)
|
||
})
|
||
.collect();
|
||
solver.set_keep_eps(resolve_eps);
|
||
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);
|
||
// The screen the last converged resolve read: a move reaches what anchors to it.
|
||
solver.set_entry(SCREEN, *layout_screen, false);
|
||
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 &(id, r) in &seed_loose {
|
||
solver.set_external(id, r);
|
||
}
|
||
for (&i, &(cached, invalid)) in sweep_regions.iter().zip(&entry_regions) {
|
||
solver.set_entry(scope.regions[i as usize].id, cached, invalid);
|
||
}
|
||
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, &cached) in solve_frames.iter().zip(&entry_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.set_entry(id, cached, is_invalid(id));
|
||
}
|
||
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, &(cached, _)) in sweep_regions.iter().zip(&entry_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 = 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 resolved_edges = vertical.zip(horizontal);
|
||
// Not reached since its entry: it keeps its state, rect or none (see
|
||
// `LayoutSolver::set_entry`).
|
||
let reads = data.anchors.iter().map(|a| a.relative_to);
|
||
if !solver.takes_resolve(region_id, resolved_edges.is_some(), reads) {
|
||
match solver.entry(region_id) {
|
||
Some(kept) => {
|
||
solver.set_external(region_id, kept);
|
||
if region_resolved.get(&rh) != Some(&kept) {
|
||
region_resolved.insert(rh, kept);
|
||
changed = true;
|
||
}
|
||
}
|
||
None => {
|
||
if region_resolved.remove(&rh).is_some() {
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
let Some(((bottom, top), (left, right))) = resolved_edges else {
|
||
// The failed resolve (`0x768d55`) clears the valid bit, so the getters and a
|
||
// FontString's draw lose the rect; a Texture keeps the quad its last good
|
||
// resolve wrote (`0x770670`), as its emit checks only its texture (`0x7706e0`).
|
||
if let Some(last) = region_resolved.remove(&rh) {
|
||
changed = true;
|
||
if !is_fontstring {
|
||
region_kept.insert(rh, last);
|
||
}
|
||
}
|
||
continue;
|
||
};
|
||
let rect = Rect::new(bottom, left, top, right);
|
||
// The keep test, as for a frame: the same `0x768d20` resolves regions.
|
||
let rect = cached
|
||
.filter(|&c| layout::keeps_rect(c, rect, resolve_eps))
|
||
.unwrap_or(rect);
|
||
// 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;
|
||
}
|
||
region_kept.remove(&rh);
|
||
}
|
||
|
||
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;
|
||
*layout_screen = Some(*screen);
|
||
// 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,
|
||
region_kept,
|
||
frame_to_id,
|
||
id_to_region,
|
||
region_to_id,
|
||
next_id,
|
||
screen,
|
||
layout_scope: scope,
|
||
layout_force,
|
||
..
|
||
} = 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, id) 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();
|
||
// A moved effective scale clears the resolved bit (`0x768360` from `0x76acd5`).
|
||
if input.scale.to_bits() != scale.to_bits() {
|
||
layout_force.push(id);
|
||
}
|
||
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 and
|
||
// held against its own cached rect, so last frame's rects are a legal seed: only dead
|
||
// regions drop. A cleared one keeps its rect until its own next invalidate.
|
||
region_resolved.retain(|rh, _| arena.region(*rh).is_some() && region_data.contains_key(rh));
|
||
region_kept.retain(|rh, _| arena.region(*rh).is_some() && region_data.contains_key(rh));
|
||
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
|
||
// function of these and of the rects the keep test held them against, which a re-run with
|
||
// the same inputs keeps again, 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,
|
||
region_kept,
|
||
frame_to_id,
|
||
id_to_frame,
|
||
id_to_region,
|
||
next_id,
|
||
screen,
|
||
layout_scope: scope,
|
||
layout_force,
|
||
..
|
||
} = 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();
|
||
if input.scale.to_bits() != scale.to_bits() {
|
||
layout_force.push(id);
|
||
}
|
||
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 {
|
||
// A cleared region leaves the graph but keeps its rect; a dead one drops it.
|
||
let row = scope.remove_region(id, n);
|
||
if arena.region(row.rh).is_none() || !region_data.contains_key(&row.rh) {
|
||
region_resolved.remove(&row.rh);
|
||
region_kept.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 spacing = d.line_gap();
|
||
let shadow_x = d.shadow_x();
|
||
let id = model.region_id(rh);
|
||
Some(MeasureRequest {
|
||
id,
|
||
font,
|
||
height,
|
||
text_height,
|
||
wrap_width,
|
||
outline,
|
||
spacing,
|
||
shadow_x,
|
||
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))
|
||
}
|