Carry a length as a rule beside a widget, not a widget around it

`.width()` built a `SetSize` whose whole job was to answer `size_hint`, so
every declared length cost a widget, an `ActiveData` and a link of chain to
say one number. It is now a `SizeRule` per axis on `WidgetData`, beside
`region_node`, resolved by `Painter` where the widget is drawn. `SetSize`
and `MaxSize` are gone; `MaxSize` had no caller but its own builders.

That settles which of two answers is the size. A rule wins on the axis it
names and the `Size` returned by `draw` answers the rest, applied once in
`draw_inner` rather than by each widget that could carry one -- so the
widget under a rule never learns of it. `Painter::size_hint` reads the rule
first for the same reason: a rule that beats what a widget would draw has
to beat what it says about itself.

`declared_lens` still falls back to a non-leftover `size_hint`, which is
how an image or a gap gets its own pixel size rather than the whole offer.
That is the offer's business rather than a declaration's, and it falls away
when a widget occupies its reported size inside the box it was offered.

`known` and `declared` are separate because a share is a length to whoever
divides one and not to whoever composes a box: `.width(leftover(3))` is
known without drawing but cannot narrow anything.

Checked: fmt, clippy, 85 tests, and 100 generated seeds agreeing warm
against cold in 67.6 s. `minimal`, `text` and `view` render byte-identical
at 1920x1200; `tabs` differs only in the widget count it prints about
itself, which is two wrapper types smaller.
This commit is contained in:
iris-ai committed 2026-09-15 19:44:21 -04:00
1 parent 0283c9d6c7
commit 8220a78d4a
21 files changed
+252 -214

No files matched your search

+29 -17
View File
@@ -2,7 +2,7 @@
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Holds, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Widget, WidgetId,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, Widgets,
render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
PrimitiveKind, TexturePrimitive,
@@ -111,16 +111,12 @@ impl<'a> Painter<'a> {
self.widget_at(id, region)
}
/// What a widget declares its lengths to be, which whoever draws it
/// resolves into its box. `leftover` is not among them: a part of what is
/// left over is only a length to the widget dividing one, so it passes
/// up in the size instead. Reading it depends on nothing -- the box that
/// comes of it is kept on the child, and `redraw` compares it there.
/// What a widget's rules declare its lengths to be, which whoever draws
/// it resolves into its box. Reading them depends on nothing -- the box
/// that comes of them is kept on the child, and `redraw` compares it
/// there.
fn declared_lens<W: ?Sized>(&self, id: &StrongWidget<W>) -> [Option<Len>; 2] {
let Some(widget) = self.rsc.widgets().get_dyn(id.id()) else {
return [None; 2];
};
AXES.map(|axis| declared_len(widget, axis))
declared_lens(self.rsc.widgets(), id.id())
}
/// Takes back a child that was drawn only to find out how long it is.
@@ -201,11 +197,14 @@ impl<'a> Painter<'a> {
/// What a child says its length is without being drawn, if it can say.
/// Asking counts as reading its size.
pub fn size_hint<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> {
let hint = self
.rsc
.widgets()
.get_dyn(id.id())
.and_then(|widget| widget.size_hint(axis));
let widgets = self.rsc.widgets();
// A rule is the answer where there is one: it wins over whatever the
// widget would draw, so it has to win over what the widget says too.
let hint = widgets.size_rules(id.id()).axis(axis).known().or_else(|| {
widgets
.get_dyn(id.id())
.and_then(|widget| widget.size_hint(axis))
});
#[cfg(feature = "layout-diagnostics")]
diag::hint_read(id.id(), self.id, axis, hint);
match hint {
@@ -439,8 +438,21 @@ impl PrimitiveLike for &TextureHandle {
/// What a widget declares a length of its box to be. `leftover` is not one: a
/// share of what is left over is only a length to the widget dividing one,
/// so it passes up in the size instead.
pub(crate) fn declared_len(widget: &dyn Widget, axis: Axis) -> Option<Len> {
widget.size_hint(axis).filter(|len| len.leftover == 0.0)
pub(crate) fn declared_lens(widgets: &Widgets, id: WidgetId) -> [Option<Len>; 2] {
let rules = widgets.size_rules(id);
let widget = widgets.get_dyn(id);
AXES.map(|axis| {
rules.axis(axis).declared().or_else(|| {
// A hint still narrows the box where no rule does, which is how a
// widget with a natural pixel size -- an image, a gap -- gets that
// size rather than the whole offer. That is the offer's business
// rather than a declaration's, and this falls away once a widget
// occupies its reported size inside the box it was offered.
widget
.and_then(|widget| widget.size_hint(axis))
.filter(|len| len.leftover == 0.0)
})
})
}
/// Takes a widget's declared lengths in the box `region` is given in, since a
+10 -6
View File
@@ -1,6 +1,6 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::ui::painter::declared_len;
use crate::ui::painter::declared_lens;
use crate::{
ActiveData, Axis, DrawLayers, Holds, IdLike, Len, MaskIdx, MoveIdx, Moves, Painter,
PixelRegion, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId, Widgets,
@@ -256,6 +256,14 @@ impl UiRenderState {
"'{}' ({id:?}) drew a size its size_hint disagrees with",
rsc.widgets().label(id)
);
// A rule wins on the axis it names, and the draw answers the rest.
// Applied here so it is one place rather than every widget that could
// carry one, and so the widget under a rule never learns of it.
let rules = rsc.widgets().size_rules(id);
let size = Size {
x: rules.x.apply(size.x),
y: rules.y.apply(size.y),
};
let holds = [own[0].and(under[0]), own[1].and(under[1])];
debug_assert!(
holds[0].contains(px.x) && holds[1].contains(px.y),
@@ -739,11 +747,7 @@ impl UiRenderState {
// whether to draw it at all, so a change to either is the parent's
// to draw -- with the mark left on, so the parent draws it rather
// than keeping it.
let declared_changed = rsc.widgets().get_dyn(id).is_some_and(|widget| {
AXES.into_iter()
.zip(active.declared)
.any(|(axis, was)| declared_len(widget, axis) != was)
});
let declared_changed = declared_lens(rsc.widgets(), id) != active.declared;
if let Some(parent) = active.parent
&& (declared_changed || !active.drawn)
{
+3 -1
View File
@@ -1,9 +1,10 @@
use crate::Widget;
use crate::{SizeRules, Widget};
pub struct WidgetData {
pub widget: Box<dyn Widget>,
pub label: String,
pub(super) region_node: bool,
pub(super) size: SizeRules,
/// dynamic borrow checking
pub borrowed: bool,
}
@@ -18,6 +19,7 @@ impl WidgetData {
widget: Box::new(widget),
label,
region_node: false,
size: SizeRules::default(),
borrowed: false,
}
}
+2
View File
@@ -4,6 +4,7 @@ use std::any::Any;
mod data;
mod handle;
mod like;
mod size_rule;
mod tag;
mod view;
mod widgets;
@@ -11,6 +12,7 @@ mod widgets;
pub use data::*;
pub use handle::*;
pub use like::*;
pub use size_rule::*;
pub use tag::*;
pub use view::*;
pub use widgets::*;
+86
View File
@@ -0,0 +1,86 @@
use crate::{Axis, Len};
/// What a widget's length on one axis is, as a rule its parent applies where
/// it draws it rather than an answer the widget gives about itself.
///
/// A rule and a drawn size are not two opinions to reconcile: a rule wins on
/// the axis it names, and the `Size` returned by `draw` answers only the axes
/// with no rule. That is what lets a span divide its space around a length
/// nobody has drawn yet, and it is why a rule lives beside the widget rather
/// than inside it -- the widget under the rule never has to know about it.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SizeRule {
/// Whatever the widget reports from drawing.
#[default]
Free,
/// This length, whatever the widget reports.
Exact(Len),
}
impl SizeRule {
/// The length this rule gives without the widget being drawn, if it can
/// give one. `leftover` is never among them: a share is a length only to
/// whoever divides one, so it passes up in the reported size instead and
/// is resolved there.
pub fn declared(&self) -> Option<Len> {
match self {
Self::Exact(len) if len.leftover == 0.0 => Some(*len),
_ => None,
}
}
/// The length this rule fixes, whether or not it can narrow a box. A
/// share is a length the widget's parent still has to divide, so it is
/// known here and resolved there -- unlike `declared`, which is only the
/// ones that give a box directly.
pub fn known(&self) -> Option<Len> {
match self {
Self::Free => None,
Self::Exact(len) => Some(*len),
}
}
/// The length a widget reporting `reported` ends up with.
pub fn apply(&self, reported: Len) -> Len {
match self {
Self::Free => reported,
Self::Exact(len) => *len,
}
}
}
impl From<Len> for SizeRule {
fn from(len: Len) -> Self {
Self::Exact(len)
}
}
impl From<Option<Len>> for SizeRule {
fn from(len: Option<Len>) -> Self {
len.map_or(Self::Free, Self::Exact)
}
}
/// One rule per axis, which is how a widget carries a length on one axis and
/// leaves the other to whatever it draws.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SizeRules {
pub x: SizeRule,
pub y: SizeRule,
}
impl SizeRules {
pub fn axis(&self, axis: Axis) -> SizeRule {
match axis {
Axis::X => self.x,
Axis::Y => self.y,
}
}
pub fn axis_mut(&mut self, axis: Axis) -> &mut SizeRule {
match axis {
Axis::X => &mut self.x,
Axis::Y => &mut self.y,
}
}
}
+31 -1
View File
@@ -1,7 +1,7 @@
use std::sync::mpsc::{Receiver, Sender, channel};
use crate::{
IdLike, StrongWidget, WeakWidget, Widget, WidgetData, WidgetId,
Axis, IdLike, SizeRule, SizeRules, StrongWidget, WeakWidget, Widget, WidgetData, WidgetId,
util::{DynBorrower, HashSet, SlotVec, forget_mut, to_mut},
};
@@ -118,6 +118,36 @@ impl Widgets {
self.needs_redraw.insert(id);
}
/// The length rules whoever draws this widget applies to its box.
pub fn size_rules(&self, id: impl IdLike) -> SizeRules {
self.data(id).unwrap().size
}
/// Sets one axis's rule. The widget is marked rather than its parent
/// because the parent is not known here; `redraw` escalates a changed
/// declared length to whoever resolves it.
pub fn set_size_rule(&mut self, id: impl IdLike, axis: Axis, rule: SizeRule) {
let id = id.id();
let data = self.data_mut(id).unwrap();
if *data.size.axis_mut(axis) == rule {
return;
}
*data.size.axis_mut(axis) = rule;
self.needs_redraw.insert(id);
}
/// Both axes at once, for a caller holding a pair.
pub fn set_size_rules(
&mut self,
id: impl IdLike,
x: impl Into<SizeRule>,
y: impl Into<SizeRule>,
) {
let id = id.id();
self.set_size_rule(id, Axis::X, x.into());
self.set_size_rule(id, Axis::Y, y.into());
}
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
self.vec.get_mut(id.id())
}