Make alignment a widget property

This commit is contained in:
iris-ai committed 2026-09-15 23:12:16 -04:00
1 parent 8220a78d4a
commit d3b0ebf90c
21 files changed
+823 -300

No files matched your search

+221 -55
View File
@@ -1,9 +1,10 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::ui::painter::declared_lens;
use crate::ui::painter::{declared_box, declared_lens, placed_box};
use crate::{
ActiveData, Axis, DrawLayers, Holds, IdLike, Len, MaskIdx, MoveIdx, Moves, Painter,
PixelRegion, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId, Widgets,
PixelRegion, RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId,
Widgets,
util::{HashMap, Vec2},
};
@@ -23,6 +24,9 @@ pub(super) struct DrawInfo {
/// that box in pixels.
pub offer: UiRegion,
pub offered_px: Vec2,
/// A container's answer for where the widget sits. `None` uses the
/// widget's own property.
pub align: Option<RegionAlign>,
}
pub struct UiRenderState {
@@ -39,6 +43,13 @@ pub struct UiRenderState {
/// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot.
slots: HashMap<WidgetId, MoveIdx>,
/// Answers invalidated by a declared-length change below them. These are
/// replaced even when retained placement means the redraw is not at the
/// old offer.
answer_invalid: crate::util::HashSet<WidgetId>,
/// Whether this frame contains a declared-length change, so any dirty
/// dependent replaces its answer too.
replace_answers: bool,
pub moves: Moves,
}
@@ -50,6 +61,8 @@ impl UiRenderState {
output_size: Vec2::ZERO,
old_root: None,
slots: Default::default(),
answer_invalid: Default::default(),
replace_answers: false,
moves: Default::default(),
root_move: MoveIdx::NONE,
resized: false,
@@ -91,6 +104,7 @@ impl UiRenderState {
mask: MaskIdx::NONE,
offer: UiRegion::FULL,
offered_px: self.output_size,
align: None,
}
}
@@ -131,12 +145,15 @@ impl UiRenderState {
// anything dirty settles, so that whatever a new output draws
// again is drawn once, in the box it will have.
let info = self.root_info();
self.draw_inner(root.id(), UiRegion::FULL, info, None, rsc);
let region = Self::root_region(root.id(), rsc.widgets());
let answer = self.draw_inner(root.id(), region, info, None, rsc);
self.active.get_mut(&root.id()).unwrap().answer = answer;
}
self.resized = false;
if rsc.widgets().has_updates() {
self.redraw_updates(rsc);
}
self.replace_answers = false;
self.free(rsc);
}
@@ -148,10 +165,19 @@ impl UiRenderState {
self.write_root();
if let Some(id) = root {
let info = self.root_info();
self.draw_inner(id.id(), UiRegion::FULL, info, None, rsc);
let region = Self::root_region(id.id(), rsc.widgets());
self.draw_inner(id.id(), region, info, None, rsc);
}
}
fn root_region(id: WidgetId, widgets: &Widgets) -> UiRegion {
declared_box(
UiRegion::FULL,
declared_lens(widgets, id),
widgets.alignment(id),
)
}
pub(super) fn draw_inner(
&mut self,
id: WidgetId,
@@ -159,7 +185,7 @@ impl UiRenderState {
info: DrawInfo,
mut old: Option<ActiveData>,
rsc: &mut dyn UiRsc,
) -> Size {
) -> (Size, [Holds; 2]) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::DrawRequests);
@@ -171,15 +197,82 @@ impl UiRenderState {
info.region_node,
);
}
if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, info, rsc) {
return size;
let own_align = rsc.widgets().alignment(id);
let align = info.align.unwrap_or(own_align);
let replace_answer = self.answer_invalid.remove(&id)
|| (self.replace_answers
&& (rsc.widgets().needs_redraw.contains(&id)
|| self.dirty_size_under(id, rsc.widgets())));
let retained = match replace_answer {
true => None,
false => self
.retained_answer(id, region, info, rsc.widgets())
.or_else(|| self.try_reuse(id, region, info, rsc)),
};
let answer = retained.unwrap_or_else(|| {
if old.is_none() {
old = self.remove(id, false, rsc);
}
// if not, then maintain resize and track old children to remove unneeded
old = self.remove(id, false, rsc);
self.draw_at(id, region, info, align, old.take(), rsc)
});
let declared = declared_lens(rsc.widgets(), id);
// A near-edge override means the caller already chose this box from
// the child's answer. Applying the answer again would compound the
// placement; it is also how the second, final ask terminates.
let placed = match info.align == Some(RegionAlign::NEAR) {
true => region,
false => placed_box(region, answer.0, align, declared),
};
let placed_info = DrawInfo {
align: Some(RegionAlign::NEAR),
..info
};
// The symbolic box can be unchanged while its parent slot changed
// pixel size. Reuse checks the resolved box even in that case.
if self.try_reuse(id, placed, placed_info, rsc).is_none() {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::PlaceRedraws);
let old = self.remove(id, false, rsc);
self.draw_at(id, placed, placed_info, RegionAlign::NEAR, old, rsc);
}
// draw widget
// The answer is only reusable while both parts of the operation are:
// what the widget reported in the offered box, and what it drew in
// the box its report selected. Express the latter's contract back in
// terms of the offered box before handing it to the parent.
let drawing_holds = self.active[&id].holds;
let mut settled = answer;
for axis in AXES {
let reported = answer.0.axis(axis);
let placed_len = match reported.leftover != 0.0 || declared[axis as usize].is_some() {
true => UiScalar::FULL,
false => UiScalar::new(reported.rel, reported.px),
};
settled.1[axis as usize] =
settled.1[axis as usize].and(drawing_holds[axis as usize].through(placed_len));
}
let active = self.active.get_mut(&id).unwrap();
active.offer = info.offer;
active.answer = settled;
active.align = align;
active.align_override = info.align.is_some();
active.own_align = own_align;
active.depth = info.depth;
settled
}
/// Calls a widget's `draw` and keeps what it drew in `region`.
fn draw_at(
&mut self,
id: WidgetId,
region: UiRegion,
info: DrawInfo,
align: RegionAlign,
old: Option<ActiveData>,
rsc: &mut dyn UiRsc,
) -> (Size, [Holds; 2]) {
let (move_idx, local, retired_move) = match info.region_node {
// Its box becomes its movable region, so it draws in that
// region's coordinates and its box is one entry to rewrite.
@@ -295,6 +388,7 @@ impl UiRenderState {
mask,
offer: UiRegion::FULL,
offered_px: px,
align: None,
},
rsc,
);
@@ -302,7 +396,6 @@ impl UiRenderState {
}
}
// add to active
let active = ActiveData {
id,
region,
@@ -318,8 +411,10 @@ impl UiRenderState {
primitives,
children,
size_deps,
// Written by whoever draws it, which is what resolves them.
declared: [None; 2],
declared: declared_lens(rsc.widgets(), id),
align,
align_override: info.align.is_some(),
own_align: rsc.widgets().alignment(id),
move_idx,
parent_move: info.parent_move,
mask,
@@ -327,7 +422,7 @@ impl UiRenderState {
};
rsc.on_draw(&active);
self.active.insert(id, active);
size
(size, holds)
}
/// Keeps a region node's entry across redraws because descendants retain
@@ -358,9 +453,9 @@ impl UiRenderState {
.to_px(self.output_size)
}
/// A clean, drawn widget's retained size, if its drawing holds for a box
/// of `px`. This observes the answer only; it does not move or otherwise
/// reuse the widget's drawing.
/// A clean widget's retained answer, if that answer holds for a box of
/// `px`. This does not move its drawing, which may already be in the box
/// that answer placed it in.
pub(super) fn retained_size(
&self,
id: WidgetId,
@@ -372,8 +467,38 @@ impl UiRenderState {
return None;
}
let active = self.active.get(&id)?;
let valid = active.drawn && active.parent_move == parent_move && active.holds_at(px);
valid.then_some((active.size, active.holds))
let (size, holds) = active.answer;
let valid = active.drawn
&& active.parent_move == parent_move
&& holds[0].contains(px.x)
&& holds[1].contains(px.y);
valid.then_some((size, holds))
}
/// The answer to an ask can be retained independently of where its
/// drawing ended up. Alignment is exactly that case: the first box is the
/// question and the smaller placed box holds the drawing.
fn retained_answer(
&self,
id: WidgetId,
region: UiRegion,
info: DrawInfo,
widgets: &Widgets,
) -> Option<(Size, [Holds; 2])> {
if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) {
return None;
}
let active = self.active.get(&id)?;
let has_region_node = active.move_idx != active.parent_move;
if !active.drawn
|| has_region_node != info.region_node
|| active.parent_move != info.parent_move
{
return None;
}
let px = self.px_of(info.parent_move, region);
let (size, holds) = active.answer;
(holds[0].contains(px.x) && holds[1].contains(px.y)).then_some((size, holds))
}
/// Whether anything whose size this widget's own size was read from is
@@ -388,29 +513,42 @@ impl UiRenderState {
})
}
/// The pixel size of the box a widget was first asked about in, composed
/// through the boxes its ancestors were asked in.
fn offered_px(&self, id: WidgetId) -> Vec2 {
let Some(active) = self.active.get(&id) else {
return self.output_size;
/// The first box a widget was asked about, re-expressed in the coordinate
/// space its drawing uses. Keeping the relative box and composing it
/// again avoids rebuilding a shifted box from rounded pixel lengths.
fn offered_region(&self, id: WidgetId) -> UiRegion {
let active = &self.active[&id];
let parent_region = match active.parent.and_then(|id| self.active.get(&id)) {
Some(parent) if parent.move_idx == active.parent_move => {
if parent.move_idx == parent.parent_move {
self.offered_region(parent.id)
} else {
UiRegion::FULL
}
}
_ => UiRegion::FULL,
};
let parent = match active.parent {
Some(parent) => self.offered_px(parent),
None => self.output_size,
let mut offered = match active.offer == UiRegion::FULL {
true => parent_region,
false => active.offer.within(&parent_region),
};
let size = active.offer.size();
Vec2::new(size.x.to_px(parent.x), size.y.to_px(parent.y))
for axis in AXES {
if active.declared[axis as usize].is_some() {
*offered.axis_mut(axis) = *active.region.axis(axis);
}
}
offered
}
/// The drawing a widget already has, kept for a new box if it holds for
/// that box.
/// Reuses the actual drawing in a new box if its retained contract holds
/// there. Answers retained from a different ask are handled separately.
fn try_reuse(
&mut self,
id: WidgetId,
region: UiRegion,
info: DrawInfo,
rsc: &mut dyn UiRsc,
) -> Option<Size> {
) -> Option<(Size, [Holds; 2])> {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ReuseAttempts);
if rsc.widgets().needs_redraw.contains(&id) {
@@ -453,8 +591,12 @@ impl UiRenderState {
return None;
}
let moved = active.region != region;
let (size, old_region, slot, mask) =
(active.size, active.region, active.move_idx, info.mask);
let (answer, old_region, slot, mask) = (
(active.size, active.holds),
active.region,
active.move_idx,
info.mask,
);
if moved {
if has_region_node {
self.moves.set(slot, region);
@@ -487,7 +629,7 @@ impl UiRenderState {
},
);
}
Some(size)
Some(answer)
}
/// Re-expresses an ordinary retained subtree in a new parent region.
@@ -610,6 +752,9 @@ impl UiRenderState {
size_deps: Vec::new(),
move_idx: info.parent_move,
declared: [None; 2],
align: RegionAlign::default(),
align_override: false,
own_align: rsc.widgets().alignment(id),
parent_move: info.parent_move,
mask: info.mask,
layer: info.layer,
@@ -624,6 +769,8 @@ impl UiRenderState {
}
}
self.slots.clear();
self.answer_invalid.clear();
self.replace_answers = false;
self.moves.clear();
self.root_move = MoveIdx::NONE;
self.layers.clear();
@@ -638,6 +785,7 @@ impl UiRenderState {
rsc.on_remove(id);
self.remove(id, true, rsc);
self.drop_slot(id);
self.answer_invalid.remove(&id);
}
rsc.ui_mut().textures.free();
}
@@ -748,9 +896,19 @@ impl UiRenderState {
// to draw -- with the mark left on, so the parent draws it rather
// than keeping it.
let declared_changed = declared_lens(rsc.widgets(), id) != active.declared;
let alignment_changed = rsc.widgets().alignment(id) != active.own_align;
if let Some(parent) = active.parent
&& (declared_changed || !active.drawn)
&& (declared_changed || alignment_changed || !active.drawn)
{
if declared_changed {
self.replace_answers = true;
let mut at = Some(id);
while let Some(next) = at {
self.answer_invalid.insert(next);
rsc.widgets_mut().needs_redraw.insert(next);
at = self.active[&next].parent;
}
}
rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc);
// Whatever the parent did not draw again is nothing it holds now.
@@ -762,15 +920,27 @@ impl UiRenderState {
}
let region = active.region;
let region_node = active.parent.is_some() && rsc.widgets().is_region_node(id);
let offered_px = self.offered_px(id);
let asked_in = match active.parent {
Some(_) => self.offered_region(id),
None => Self::root_region(id, rsc.widgets()),
};
let offered_px = self.px_of(active.parent_move, asked_in);
let at_offer = same_px(self.px_of(active.parent_move, region), offered_px);
// Asked again where its parent asked: a box decided from its own
// answer gives that answer back whatever the content now says. Only
// a region node can be redrawn away from its current box without
// first involving the parent that chose that box.
let parent_must_place = active.parent.is_some()
&& (!region_node || active.align_override)
&& !same_pixel_region(
self.moves
.resolve(active.parent_move, region)
.to_px(self.output_size),
self.moves
.resolve(active.parent_move, asked_in)
.to_px(self.output_size),
);
// An independently positioned region node can redraw at its offer
// and move its slot to its own placement. Every other widget needs
// its parent to reproduce a different final position.
if let Some(parent) = active.parent
&& !region_node
&& !at_offer
&& parent_must_place
{
rsc.widgets_mut().needs_redraw.insert(id);
self.redraw(parent, rsc);
@@ -786,23 +956,15 @@ impl UiRenderState {
mask: active.mask,
offer: active.offer,
offered_px,
align: active.align_override.then_some(active.align),
};
let (was_answer, was) = (active.answer, (active.size, active.holds));
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::LocalRedraws);
let mut asked_in = region;
if !at_offer {
for axis in AXES {
let span = asked_in.axis_mut(axis);
span.end = span.start + UiScalar::px(offered_px.axis(axis));
}
}
let old = self.remove(id, false, rsc);
let size = self.draw_inner(id, asked_in, info, old, rsc);
let active = self.active.get_mut(&id).unwrap();
let answer = (size, active.holds);
active.answer = answer;
let answer = self.draw_inner(id, asked_in, info, old, rsc);
self.active.get_mut(&id).unwrap().answer = answer;
let Some(parent) = info.parent else {
return;
};
@@ -834,6 +996,10 @@ fn same_px(a: Vec2, b: Vec2) -> bool {
Holds::at(a.x).contains(b.x) && Holds::at(a.y).contains(b.y)
}
fn same_pixel_region(a: PixelRegion, b: PixelRegion) -> bool {
same_px(a.top_left, b.top_left) && same_px(a.bot_right, b.bot_right)
}
/// A retained region rewritten from one parent box into another. A fixed
/// source extent can be translated but cannot recover fractions for a resize.
#[derive(Clone, Copy)]