Files
iris/core/src/ui/mod.rs
T
iris-aiandClaude Opus 5 45a717695b Compose a box down its chain once, not once a level
Bryan's call, 2026-09-16, for correctness. `Moves` walked the move chain in
`Len`, so every level's four multiplies landed back on the grid before the
next started and the residue grew with the depth of the tree. `WideLen`
carries a length through the walk on a grid twenty-four bits of a box and
twenty-two of a pixel finer, and rounds once at the end.

What it buys, measured rather than argued: `Holds::through`'s allowance for
the two routes to a length drops from three half steps to two, and the whole
of a box now maps back to a range one step wide rather than one step per
level of nesting. One half step further is arithmetically available -- the
`Holds` assertion is quiet there and the whole-box case becomes an exact
identity -- and it is **not taken**, because shrinker seed 220 then lays out
differently warm than cold. Too narrow is meant to cost a redraw and no more;
there it re-breaks a wrapping text, whose reported width moves a `Branch`
onto its other subtree. That is the unsettled-text family, and closing it is
what would let this go lower. The note is in `through`.

`Moves` now answers three questions instead of one, and they are different
questions: `size_of` for how long a box is, which is what reads a box in
pixels; `compose` for where both of its ends are, which is what compares two
boxes; and `resolve`, unchanged, for the `Len` walk the vertex shader does
again in floats. A length composes on its own in two multiplies a level
rather than four, since where the parent sits falls out of the difference --
which is most of why this is not slower.

Measured on the fixed-shape fixture, seed 1 depth 8, 500 frames of `many`,
medians of 25 runs with all twenty-five work counters identical between the
two: 1,880M instructions and 755M cycles against 1,908M and 760M. So it is
free, and a little better on instructions. Three things were tried on the way
and two kept: composing the length alone rather than both ends (-111M
instructions), taking the pixel term's fraction on the ordinary grid so it
stays in an `i64` (-2M instructions, -8M cycles), and skipping a parent that
spans its own box, which **cost** 18M instructions and is not here -- the
same verdict a short-circuit got in `UiSpan::within`.

Checked: fmt, clippy, 83 suite tests, 17 core unit tests, the release oracle
at 100 seeds and at 1000 seeds of depth 6, all fifteen shrinker cases at 400
seeds of depth 5, and `tabs`, `view`, `minimal`, `text` and `random`
byte-identical at 1920x1200 against `d21a215`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:30:45 -04:00

168 lines
5.4 KiB
Rust

use crate::{
Mask, MoveIdx, MoveOffset, PrimitiveRegistry, TextData, Textures, UiRegion, WeakWidget,
WideRegion, WideSize, WidgetId, Widgets,
util::{Arena, Id, TrackedArena},
};
/// How far the shader will walk a move chain. It bounds a malformed cycle
/// rather than any real tree; `Moves::resolve` uses the same number so the
/// two agree on what a deep tree resolves to.
pub const CHAIN_LIMIT: u32 = 64;
mod active;
mod holds;
mod painter;
mod render_state;
pub use active::*;
pub use holds::*;
pub use painter::{Painter, PrimitiveLike};
pub use render_state::*;
#[derive(Default)]
pub struct UiData {
pub widgets: Widgets,
/// Every primitive this ui can draw.
pub primitives: PrimitiveRegistry,
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
}
/// Where each widget's drawing sits relative to its parent's slot, so moving
/// a subtree writes one entry rather than every descendant's primitives.
#[derive(Default)]
pub struct Moves {
arena: Arena<MoveOffset, u32>,
pub changed: bool,
}
impl Moves {
pub fn push(&mut self, parent: MoveIdx, region: UiRegion) -> MoveIdx {
self.changed = true;
MoveIdx::slot(self.arena.push(MoveOffset::new(parent, region)).idx())
}
/// Re-points a slot at a different parent, for a widget drawn somewhere
/// else in the tree than it was.
pub fn set_parent(&mut self, idx: MoveIdx, parent: MoveIdx) {
let entry = self.arena.get_mut(Id::preset(idx.idx() as u32));
if entry.parent != parent {
entry.parent = parent;
self.changed = true;
}
}
pub fn remove(&mut self, idx: MoveIdx) {
self.changed = true;
self.arena.remove(Id::preset(idx.idx() as u32));
}
/// Sets the box a slot's contents are placed within, itself given in the
/// coordinates of its parent slot.
pub fn set(&mut self, idx: MoveIdx, region: UiRegion) {
let entry = self.arena.get_mut(Id::preset(idx.idx() as u32));
if entry.region != region {
entry.region = region;
self.changed = true;
}
}
/// Composes a region held in `idx`'s coordinates down the chain, on a
/// grid fine enough that the whole walk rounds once. Composing in `Len`
/// rounds four multiplies a level, so the residue grew with the depth of
/// the tree -- and it is the box this produces that layout takes a
/// structural decision on.
pub fn compose(&self, idx: MoveIdx, local: UiRegion) -> WideRegion {
let mut region = WideRegion::of(local);
self.walk(idx, |entry| region = region.within(entry));
region
}
/// How long a region held in `idx`'s coordinates is, composed down the
/// chain on the same fine grid as [`Self::compose`] and for the same
/// reason. Half the multiplies of composing the box, since a length does
/// not need to know where the box sits -- and what reads a box in pixels
/// nearly always wants only this.
pub fn size_of(&self, idx: MoveIdx, local: UiRegion) -> WideSize {
let mut size = WideSize::of(local);
self.walk(idx, |entry| size = size.within(entry));
size
}
/// The same walk the vertex shader does, in the same `Len` the shader is
/// handed. What layout decides on is [`Self::compose`]; this is for
/// asking where the drawing will actually land, which the shader works
/// out again in floats from these same entries.
pub fn resolve(&self, idx: MoveIdx, local: UiRegion) -> UiRegion {
let mut region = local;
self.walk(idx, |entry| region = region.within(entry));
region
}
fn walk(&self, idx: MoveIdx, mut step: impl FnMut(&UiRegion)) {
let mut at = idx;
for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE {
return;
}
let entry = &self.arena[at.idx()];
step(&entry.region);
at = entry.parent;
}
debug_assert!(
at == MoveIdx::NONE,
"a move chain longer than {CHAIN_LIMIT} resolves to the wrong place, \
and the shader stops at the same depth"
);
}
/// How many slots a region in `idx` is composed through, which is what
/// the shader's walk costs per primitive.
pub fn depth(&self, idx: MoveIdx) -> usize {
let mut depth = 0;
let mut at = idx;
while at != MoveIdx::NONE && depth < CHAIN_LIMIT as usize {
at = self.arena[at.idx()].parent;
depth += 1;
}
depth
}
pub fn entries(&self) -> &[MoveOffset] {
&self.arena
}
pub fn clear(&mut self) {
self.changed = true;
self.arena = Arena::default();
}
}
pub trait UiRsc {
fn ui(&self) -> &UiData;
fn ui_mut(&mut self) -> &mut UiData;
#[allow(unused_variables)]
fn on_add(&mut self, id: WeakWidget) {}
#[allow(unused_variables)]
fn on_remove(&mut self, id: WidgetId) {}
#[allow(unused_variables)]
fn on_draw(&mut self, active: &ActiveData) {}
#[allow(unused_variables)]
fn on_undraw(&mut self, active: &ActiveData) {}
fn widgets(&self) -> &Widgets {
&self.ui().widgets
}
fn widgets_mut(&mut self) -> &mut Widgets {
&mut self.ui_mut().widgets
}
fn free(&mut self) {
while let Some(id) = self.widgets_mut().free_next() {
self.on_remove(id);
}
self.ui_mut().textures.free();
}
}