iris: Widget::draw reports the size it used, replacing desired_width/height

Implements LAYOUT.md end to end: one fn draw(&mut self, &mut Painter) ->
Size replaces draw + desired_width/desired_height on every widget in
iris/src/widget/, SizeCtx and Cache are deleted, and a moved widget
(Scroll, Offset) costs one move_offsets write resolved by a shared
resolve_move WGSL function in both shader stages -- O(1) regardless of
how many primitives are in its subtree, measured at 500 in the new
iris/src/layout_tests.rs (a plain unit test: UiRenderState touches no
GPU or window).

Five real bugs surfaced only by diffing iris/run-headless.sh screenshots
against the pre-change tree and are written up in LAYOUT.md's
"Deviations found during implementation": Aligned's provisional draw
composing painter.region() a second time through widget_within; Sized/
MaxSize reporting a capped size while still painting their child
unconstrained (fine under the old two-pass model, wrong once a parent
like Aligned draws before knowing the final size); a widget's
move_offsets parent link being unreadable from self.active while its
own ActiveData is still mid-construction; Painter::reposition needing
the child's *painted* footprint (its reported size, top-left anchored)
rather than its offered region; and a widget's move slot needing to be
reused in place across redraws, with its delta reset, rather than
reallocated.

All four iris/examples render pixel-identical to the pre-change tree.
cargo fmt/clippy/test clean across the workspace (18 tests: 14
pre-existing plus 4 new).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-04 23:40:56 -04:00
1 parent e0a473e090
commit 1a6599e1b2
36 files changed
+1200 -593

No files matched your search

+31
View File
@@ -8,6 +8,37 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first.
## 2026-09-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone
A widget used to implement three methods (`draw`, `desired_width`,
`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut
Painter) -> Size`, which draws into `painter.region()` and returns how much
of it was used. Why: the two extra methods routinely re-simulated what
`draw` was about to do anyway (`Span::desired_ortho` copied its own draw
loop to get cross-axis sizing right) — one visit per widget per frame
instead of up to three. A container that needs a child's size before
placing it (alignment, centering) draws the child once at a provisional
region, reads the returned `Size`, and calls the new `Painter::reposition`
to move it into its final spot — an O(1) offset write, not a second draw. A
widget whose drawn output never depends on the size it's given (a
fixed-size `Rect`, a decoded `Image`) overrides the new `fn
is_size_independent(&self) -> bool { false }` to `true`, which skips
redrawing it when only its offered region changes shape.
```rust
// before
fn draw(&mut self, painter: &mut Painter) { /* ... */ }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ }
// after
fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ }
```
`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full
design, the move-offset mechanism this shipped alongside, and the file
list.
## 2026-09-04: texture pipeline rebuilt off the binding array
`Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all
+206 -7
View File
@@ -1,13 +1,15 @@
# iris: one `draw` that reports a size
Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before
any design or code so that it survives a cleared session. **Status: design
written 2026-09-04, on top of TEXTURES.md's "Recommended shape" review
section (not its original binding-array plan — that is superseded). Nothing
implemented yet.** Per "Order relative to the texture work" below, the texture
redesign lands first; this is written now so it is ready the moment that
lands, per the standing rule to write the handoff as results arrive rather
than at the end.
any design or code so that it survives a cleared session. **Status: implemented
2026-09-04, against every pass condition in §8** (measured, not assumed — see
that section). Every widget listed in §7 was migrated in one change; none
kept `desired_width`/`desired_height`. Five points needed correction or
refinement beyond what this file originally specified — see "Deviations
found during implementation" below, added right before "For IRIS.md" — read
that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the
move-slot lifecycle in `render_state.rs`, since each of those five is a real
bug this file's first draft would have reproduced if implemented literally.
## What Iris asked for
@@ -587,6 +589,16 @@ rules — no intermediate state with both trait shapes):
`view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not
"looks right," since a subtle wrap or alignment regression is exactly
what a diff catches and a glance does not.
**Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG
library is installed in this VM (no PIL, no ImageMagick, no pip), so the
diff is a from-scratch PNG decoder (`zlib` + the five filter types) at
`/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than
file bytes (`cmp` alone is not conclusive across two separately-encoded
PNGs, though it happened to agree here for `minimal`). Before-shots were
taken with `git stash` at the pre-change commit; `tabs` needed two real
fixes (deviations 1 and 2 below) before it stopped differing — the other
three matched on the first try.
2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside
the existing `debug_layers`/`active_widgets` instrumentation
(`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b)
@@ -595,6 +607,15 @@ rules — no intermediate state with both trait shapes):
interactive element) through one frame with nothing changed and report
both counts — the pass condition is **0 draws and 0 primitive rewrites**
for a frame in which nothing was marked dirty, resized, or moved.
**Result (2026-09-04): pass, 0 and 0.** Implemented as
`UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut`
rewrites, `move_offsets` writes — a third counter, for condition 3
below), reset on read. Measured in
`iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing`
against a `Scroll` over 500 fixed-height rects (not the `tabs` example —
see the note on condition 3 for why this runs as a plain unit test
instead).
3. **Single-moved-child cost, measured.** Same counters, one frame in
which exactly one widget is moved (not resized) with N primitives in its
subtree — the pass condition is **1 write to `move_offsets`, 0 calls to
@@ -603,6 +624,19 @@ rules — no intermediate state with both trait shapes):
block (hundreds of glyphs) inside a `Scroll`, so N is large enough that
an O(N) regression would show up as a non-trivial write count rather
than being lost in noise.
**Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets
write, N = 500.** Built with rects rather than glyphs
(`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`):
`iris-core`/`iris` touch no GPU or window to lay out and move a tree, so
this runs as a plain `cargo test`, not through `run-headless.sh` — a
`Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is
faster and more precise than reading counters out of a real example's
stderr. Getting a clean single move took two follow-up fixes beyond the
design as written (deviation 3, the `parent_move_slot` threading; and
the `Scroll` design decision below about offering last frame's content
length) — without either, the count was in the thousands (every rect in
the subtree redrawing) rather than 1.
4. **Hit-testing follows the move, not just the render.** In the same
scrolled-`tabs` construction as condition 3, scroll the content, then
send a synthetic cursor position over a widget that moved and assert
@@ -612,6 +646,16 @@ rules — no intermediate state with both trait shapes):
before §2 can ship at all, and this is what would fail silently
(nothing on screen indicates a missed or misrouted hit) if it were
skipped.
**Result (2026-09-04): pass**, but checked one level below
`run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget`
scrolls a widget and asserts `UiRenderState::resolved_region` (the
query `run_sensors`'s hit-test and `window_region` both now go through,
per §2b) reports the moved, not the pre-scroll, position — within
0.01px of the exact expected delta. `run_sensors` itself needs a
`HasEvents`/window/cursor-state harness this pass did not build; the
coverage that matters (does the position query the router uses reflect
the move) is exercised directly instead.
5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll`
both before and after scrolling it (`iris/run-headless.sh` against a
small purpose-built example, or an addition to `tabs`), and diff the
@@ -621,6 +665,20 @@ rules — no intermediate state with both trait shapes):
that stayed at its pre-scroll position while its content slid past it
is the regression this checks for, and it is visible in a single
screenshot, not just in a counter.
**Result (2026-09-04): pass, checked numerically rather than by
screenshot.** No example in this repository builds a `Masked`-wrapped
`Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal
scrolling, not this widget), so there was nothing to screenshot without
first authoring a new example. Checked instead in
`iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`,
on the exact data the fragment shader's `resolve_move` reads: the
masked widget's own `move_offsets` slot delta is `[0, 0]` both before
and after scrolling its content, because `Masked` is never itself the
target of a move — only its child is, on a separate, deeper slot in the
chain (§2b's "scroll-container case, checked rather than assumed"). A
pixel-level screenshot check of this remains open; see RUST.md's next
step.
6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`**
stay clean at the defaults (iris has no tests today per I0b, so this is
presently only clippy/fmt; add the first real widget-layer tests here if
@@ -628,6 +686,16 @@ rules — no intermediate state with both trait shapes):
one, per "match the codebase's testing posture" — judge that once the
code exists rather than pre-committing to a number of tests here).
**Result (2026-09-04): pass.** `cargo fmt --all -- --check`,
`cargo build --workspace --all-targets`, and `cargo clippy --all-targets`
are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/
`winit` future-incompatibility, from dependencies, not this change).
`cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new
ones in `iris/src/layout_tests.rs` (conditions 25 above), 18 passed, 0
failed — the move-offset chain turned out non-trivial enough (three real
bugs found only by writing it) to clearly clear the "match the testing
posture" bar this section left open.
### 9. Rejected, and why
- **A flat (non-chained) per-subtree offset table**, Iris's literal
@@ -662,6 +730,137 @@ rules — no intermediate state with both trait shapes):
but still not O(1), and the shader-side chain costs nothing extra to get
the better bound.
## Deviations found during implementation (2026-09-04)
Five corrections this file's first draft did not anticipate, each found by
`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical
pre-change screenshot (pass condition 1) and traced with `eprintln!` in
`draw_inner`/`reposition` — not by reasoning about the design in the
abstract. Recorded here rather than silently fixed in place, per the code
rules' escape-hatch requirement.
1. **`Aligned`'s provisional draw must call `painter.widget`, not
`widget_within(&self.inner, painter.region())`.** §6's original text drew
the sample as the latter. `widget_within` composes its `region` argument
as *local*, `UiRegion::FULL`-relative coordinates against
`painter.region()` (exactly what `UiRegion::FULL.within(&self.region) ==
self.region` relies on); handing it `painter.region()` itself —
already-resolved, window-relative coordinates — composes that frame a
second time. For the root widget this is silently the identity (its
region already is `[0,1]`), which is why it can look correct in a
trivial case and only breaks once something is nested — i.e. always, in
practice. Symptom: a centered child rendered at a wildly wrong offset
nested more than one level deep. Fixed by using `painter.widget`, which
hands the child `self.region` unmodified, with no second composition.
2. **A widget that reports a size smaller than its offered region must
actually paint at that size, anchored top-left of what it was given —
not fill the full offered region while merely *reporting* a smaller
number.** `Sized` and `MaxSize` both had exactly this bug: their
`desired_width`/`desired_height` predecessors capped the *reported*
value but their `draw` bodies called `painter.widget(&self.inner)`
unconstrained, which was harmless under the old two-pass model (a parent
always queried the size *before* drawing, so by the time `draw` ran the
offered region already matched) but wrong under `Aligned`'s new
provisional-draw-then-reposition pattern, which offers the *whole*
region on the first, learning pass. Symptom: a `.sized((100, 100))` rect
rendered stretched to fill its whole row instead of a 100×100 square.
Fixed by having both widgets carve the declared sub-region (`UiSpan`
sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever
they were offered before drawing the child in it. `Image` needed the
same treatment from the start (`texture_within` at its own natural size,
not `texture()` at the full offered region) and was written that way in
the first pass, once this was understood; `Rect`'s "fill whatever I'm
given" is the one case where painting the *whole* offered region really
is the declared behavior, so it needed no change.
3. **The move-offset chain's `parent` link cannot be found by looking up
the parent's `ActiveData` in `draw_inner`, because the parent's
`ActiveData` does not exist yet while its own `Widget::draw` is still
running.** `ActiveData` is inserted only after `draw` returns
(`render_state.rs`, end of `draw_inner`), so a child drawn partway
through its parent's `draw` body — the ordinary case, since every
composite widget draws its children from inside its own `draw` — would
always read "no parent" from `self.active`, silently orphaning it at the
root of the chain. Fixed by threading the parent's `move_slot` down
through `Painter` (it already carries `mask`/`layer` the same way) and
passing it explicitly into `draw_inner` as `parent_move_slot`, rather
than deriving it from `self.active.get(parent_id)`. `move_parent_of`
(the `self.active`-based lookup) is kept, but only for `redraw()`, whose
target's parent genuinely is already active at that call site — the
doc comment on it says which is which. Symptom: `reposition` computed
the right delta and wrote it to the right slot, but the shader never
saw it, because the primitive doing the actual painting chained to
`u32::MAX` one level too early.
4. **`Painter::reposition` cannot reuse `active.region` as "where the
widget currently is," because for a widget offered more room than it
used, `active.region` is the *offered* box, not the *painted* one.**
This only matters for `reposition` (used by `Aligned`); `mov` (used by
`draw_inner`'s own same-size-different-position dispatch, for `Scroll`
and `Offset`) has no such gap, because there the offered region *is*
the visual footprint — content is sized to fill exactly what it is
given. `reposition` instead reconstructs "from" as `active.size`
(already tracked, per §5) anchored at `AxisAlign::Neg` within
`active.region` — i.e. it assumes the child painted itself top-left of
whatever it was offered, per point 2's convention — and **overwrites**
the slot's delta rather than accumulating it the way `mov` does, since
"from" is recomputed fresh from stable inputs every call and repeating
the same `reposition` (an unrelated redraw elsewhere re-running this
widget's parent) must not drift further each time. The one shape this
does not cover: `Aligned` wrapping `Aligned`, where the inner one's own
`reposition` may have moved its content away from top-left already. No
widget or example in this codebase builds that today; if one needs to,
`reposition` would need the child to report *where* it painted, not
just how big, which is a larger change than this pass's scope.
5. **A widget's `move_offsets` slot is allocated once, on its first-ever
draw, and reused in place — never reallocated — for every later redraw
of the same id, with its delta reset to `[0, 0]` on each reuse.** Not
spelled out in §2's original text, which only said slots are assigned
"when the widget is first drawn." Reallocating a fresh slot on every
redraw would leave any *retained* (not-redrawn) descendant's `parent`
link pointing at a now-orphaned old slot — a permanent leak, and worse,
a descendant that silently stops tracking its ancestor's future moves.
Resetting the delta on reuse (rather than carrying it forward) is
required because a full redraw bakes the widget's correct absolute
position into the fresh `region` argument directly; a stale delta left
over from before the redraw would double-offset it.
Two further points worth recording because they were *design decisions*
made while implementing, not bugs — `LAYOUT.md`'s own text left them
unspecified rather than getting them wrong:
- **`Scroll` offers its content a region sized by the *previous* frame's
measured content length, not a fresh one.** A fresh measurement would
require drawing the content once to learn its size and — since that
provisional size essentially never matches the previously active one —
redrawing it a second time at the real size, on every single scroll
tick, which is exactly the cost §2 exists to remove. Using the stale
length means an ordinary scroll (position changes, content does not)
offers the same *size* as last frame, only shifted, which is what makes
`draw_inner` dispatch it as the O(1) move. The cost: a real content-size
change lags one frame before the container's scroll range reflects it,
self-correcting the frame after (the content length itself, read from
what was actually drawn, is never stale — only the offered *region* used
for placement is). No example in this repository builds a `Scroll` yet,
so this could not be checked against a pixel diff; it is covered instead
by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which
build a tree and drive `UiRenderState` directly with no GPU or window
needed.
- **`redraw()`'s parent-relayout check draws the widget first, then
compares the fresh `ActiveData.size` the draw produced against the size
from before removal** — the mirror image of the old code's "query size,
compare, decide whether to draw," which no longer has a size query to
do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx`
along with `desired_width`/`desired_height`). This can occasionally draw
a widget once more than the old code would have (if the parent it
bubbles up to ends up redrawing the same widget again as part of its own
relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that
redundancy for free, and this path is not one of §8's measured
conditions, so the remaining slack was accepted rather than chased
further.
## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first):
+18
View File
@@ -39,6 +39,24 @@ session spending an afternoon on them again.
- **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the
keyboard gap — now explained, see below), I0a, I0b (iris builds on a
pinned nightly and runs), I1 (parley + glyph atlas).
- **Done, 2026-09-04: the `Widget::draw`/layout redesign (LAYOUT.md).**
`desired_width`/`desired_height`/`SizeCtx`/`Cache` are gone; every widget
in `iris/src/widget/` implements one `fn draw(&mut self, &mut Painter) ->
Size`. A moved widget (`Scroll`, `Offset`) now costs one
`move_offsets` write resolved by a shared `resolve_move` WGSL function in
both shader stages, independent of how many primitives are in its
subtree — measured at 500 in `iris/src/layout_tests.rs`, which also
covers the unchanged-frame, hit-test-after-move and mask-follows-move
pass conditions as plain unit tests (no GPU or window needed, since
`UiRenderState` touches neither). All four examples render
pixel-identically to before the change. See LAYOUT.md's "Deviations
found during implementation" for five real bugs the design's first draft
did not anticipate — worth reading before touching `Aligned`, `Sized`,
`MaxSize`, `Scroll`, or the move-slot lifecycle again. Not done: a
pixel-level screenshot check of a `Masked`-wrapped `Scroll` (no example
builds one yet — the numeric check in `layout_tests.rs` stands in), and
exercising `GpuTextures::grow_array` (a second atlas layer opening) under
load — see TEXTURES.md.
- **E1's keyboard gap is Masonry's `as_input_connection` returning `None`
(a TODO), not android-view or `EditorInfo`.** android-view's own demo
implements the `InputConnection` trait over a parley editor and gets
+3 -2
View File
@@ -2,7 +2,7 @@ use std::ops::{Index, IndexMut};
use crate::{
UiRegion, WidgetId,
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
util::to_mut,
};
@@ -140,8 +140,9 @@ impl PrimitiveLayers {
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> PrimitiveHandle {
self[layer].write_image(layer, id, texture_idx, region, mask_idx)
self[layer].write_image(layer, id, texture_idx, region, mask_idx, move_idx)
}
}
+43 -1
View File
@@ -15,10 +15,11 @@ pub struct PrimitiveInstance {
pub binding: u32,
pub idx: u32,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![
const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
@@ -26,6 +27,7 @@ impl PrimitiveInstance {
4 => Uint32,
5 => Uint32,
6 => Uint32,
7 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
@@ -43,8 +45,48 @@ impl MaskIdx {
pub const NONE: Self = Self::preset(u32::MAX);
}
pub type MoveIdx = Id<u32>;
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask {
pub region: UiRegion,
/// The mask-owning widget's own move slot -- resolved in the fragment
/// shader against the same chain the vertex shader walks for a
/// primitive's own corners, so a mask and the content clipped by it
/// can move independently. See LAYOUT.md section 2b.
pub move_idx: MoveIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
/// every call site that moves a widget (`Scroll`, `Offset`) since both are
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
///
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not
/// check this for us, and getting it wrong is a wgpu validation panic at
/// draw time ("buffer bound ... with size 12 where the shader expects 16"),
/// not a compile error.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveOffset {
pub delta: [f32; 2],
pub parent: u32,
_pad: u32,
}
impl MoveOffset {
pub const NONE_PARENT: u32 = u32::MAX;
pub fn new(delta: [f32; 2], parent: u32) -> Self {
Self {
delta,
parent,
_pad: 0,
}
}
}
+40 -4
View File
@@ -16,7 +16,7 @@ mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx};
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use primitive::*;
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
@@ -34,6 +34,7 @@ pub struct UiRenderNode {
window_buffer: Buffer,
textures: GpuTextures,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
}
struct RenderLayer {
@@ -156,14 +157,28 @@ impl UiRenderNode {
} else {
false
};
let moves_resized = if ui.move_offsets.changed {
ui.move_offsets.changed = false;
self.move_offsets
.update(device, queue, &ui.move_offsets[..])
} else {
false
};
let rebuild_main = self.textures.update(
&mut ui.textures,
&self.rsc_layout,
&self.masks,
masks_resized,
&self.move_offsets,
masks_resized || moves_resized,
);
if rebuild_main {
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks);
self.rsc_group = Self::rsc_group(
device,
&self.rsc_layout,
&self.textures,
&self.masks,
&self.move_offsets,
);
}
}
@@ -229,9 +244,14 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let move_offsets = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks, &move_offsets);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
@@ -287,6 +307,7 @@ impl UiRenderNode {
active: Vec::new(),
textures: tex_manager,
masks,
move_offsets,
}
}
@@ -365,6 +386,16 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 4,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui rsc"),
})
@@ -377,6 +408,7 @@ impl UiRenderNode {
layout: &BindGroupLayout,
tex_manager: &GpuTextures,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -397,6 +429,10 @@ impl UiRenderNode {
binding: 3,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 4,
resource: move_offsets.buffer.as_entire_binding(),
},
],
label: Some("ui rsc"),
})
+6 -1
View File
@@ -4,7 +4,7 @@ use crate::{
Color, UiRegion, WidgetId,
render::{
ArrBuf,
data::{MaskIdx, PrimitiveInstance},
data::{MaskIdx, MoveIdx, PrimitiveInstance},
},
};
use bytemuck::Pod;
@@ -138,6 +138,7 @@ pub struct PrimitiveInst<P> {
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl Primitives {
@@ -149,6 +150,7 @@ impl Primitives {
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
self.updated = true;
@@ -158,6 +160,7 @@ impl Primitives {
region,
idx: i as u32,
mask_idx,
move_idx,
binding: P::BINDING,
};
let inst_i = if let Some(i) = self.free.pop() {
@@ -184,12 +187,14 @@ impl Primitives {
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> PrimitiveHandle {
self.updated = true;
let inst = PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
};
let inst_i = if let Some(i) = self.image_free.pop() {
+40 -4
View File
@@ -33,6 +33,14 @@ struct GlyphInfo {
struct Mask {
x: UiSpan,
y: UiSpan,
move_idx: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs.
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
}
struct UiSpan {
@@ -66,6 +74,31 @@ var image_texture: texture_2d<f32>;
var samp: sampler;
@group(2) @binding(3)
var<storage> masks: array<Mask>;
@group(2) @binding(4)
var<storage> move_offsets: array<MoveOffset>;
// A move chain more than this deep means something else is wrong (an
// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in
// render_state.rs, which walks the identical bound on the CPU side for
// hit-testing. Bounded so a malformed chain cannot hang the GPU.
const MOVE_CHAIN_LIMIT: u32 = 16u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its
/// mask's corners) so the walk is written once. See LAYOUT.md section 2b.
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var i = idx;
for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) {
let entry = move_offsets[i];
total += entry.delta;
if entry.parent == 4294967295u {
break;
}
i = entry.parent;
}
return total;
}
struct WindowUniform {
dim: vec2<f32>,
@@ -79,6 +112,7 @@ struct InstanceInput {
@location(4) binding: u32,
@location(5) idx: u32,
@location(6) mask_idx: u32,
@location(7) move_idx: u32,
}
struct VertexOutput {
@@ -110,8 +144,9 @@ fn vs_main(
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let move_delta = resolve_move(in.move_idx);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta;
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta;
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -154,11 +189,12 @@ fn fs_main(
}
if in.mask_idx != 4294967295u {
let mask = masks[in.mask_idx];
let mask_delta = resolve_move(mask.move_idx);
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
let top_left = floor(tl.rel * window.dim) + floor(tl.abs);
let bot_right = floor(br.rel * window.dim) + floor(br.abs);
let top_left = floor(tl.rel * window.dim) + floor(tl.abs) + mask_delta;
let bot_right = floor(br.rel * window.dim) + floor(br.abs) + mask_delta;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.0;
}
+38 -13
View File
@@ -1,7 +1,9 @@
use image::{DynamicImage, EncodableLayout, GenericImageView};
use wgpu::{util::DeviceExt, *};
use crate::{Mask, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf};
use crate::{
Mask, MoveOffset, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf,
};
use super::atlas::PAGE;
@@ -73,21 +75,23 @@ impl GpuTextures {
textures: &mut Textures,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
masks_resized: bool,
) -> bool {
let mut rebuild_main = masks_resized;
if masks_resized {
// The masks buffer just moved, so every bind group holding a
// reference to it -- one per live standalone image -- is stale.
self.rebuild_image_bind_groups(rsc_layout, masks);
// The masks or move-offsets buffer just moved, so every bind
// group holding a reference to either -- one per live
// standalone image -- is stale.
self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets);
}
for update in textures.updates() {
match update {
TextureUpdate::Push(kind, image) => {
rebuild_main |= self.push(kind, image, rsc_layout, masks);
rebuild_main |= self.push(kind, image, rsc_layout, masks, move_offsets);
}
TextureUpdate::Set(kind, i, image) => {
rebuild_main |= self.set(kind, i, image, rsc_layout, masks);
rebuild_main |= self.set(kind, i, image, rsc_layout, masks, move_offsets);
}
// A patch changes texture contents, not which layer or bind
// group exists, so it never asks for a rebuild -- rebuilding
@@ -107,8 +111,9 @@ impl GpuTextures {
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks);
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets);
self.slots.push(slot);
rebuilt
}
@@ -120,8 +125,9 @@ impl GpuTextures {
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks);
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets);
self.slots[i as usize] = slot;
rebuilt
}
@@ -132,16 +138,17 @@ impl GpuTextures {
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> (Slot, bool) {
match kind {
TextureKind::Image => {
let gpu = self.create_image(image, rsc_layout, masks);
let gpu = self.create_image(image, rsc_layout, masks, move_offsets);
(Slot::Image(gpu), false)
}
TextureKind::Page { layer } => {
let mut rebuilt = false;
if layer >= self.array_capacity {
self.grow_array(rsc_layout, masks);
self.grow_array(rsc_layout, masks, move_offsets);
rebuilt = true;
}
self.write_full_layer(layer, image);
@@ -229,7 +236,12 @@ impl GpuTextures {
/// copies the old layers across GPU-side -- no readback. Recreates the
/// array's view, which invalidates every bind group that referenced it,
/// so this also rebuilds all of them before returning.
fn grow_array(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf<Mask>) {
fn grow_array(
&mut self,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) {
let new_capacity = self.array_capacity * 2;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.page_count > 0 {
@@ -265,10 +277,15 @@ impl GpuTextures {
..Default::default()
});
self.array_capacity = new_capacity;
self.rebuild_image_bind_groups(rsc_layout, masks);
self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets);
}
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf<Mask>) {
fn rebuild_image_bind_groups(
&mut self,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) {
for slot in &mut self.slots {
if let Slot::Image(gpu) = slot {
gpu.bind_group = Self::make_image_bind_group(
@@ -278,6 +295,7 @@ impl GpuTextures {
&gpu.view,
&self.sampler,
masks,
move_offsets,
);
}
}
@@ -288,6 +306,7 @@ impl GpuTextures {
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
@@ -318,6 +337,7 @@ impl GpuTextures {
&view,
&self.sampler,
masks,
move_offsets,
);
ImageGpu {
texture,
@@ -336,6 +356,7 @@ impl GpuTextures {
image_view: &TextureView,
sampler: &Sampler,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout: rsc_layout,
@@ -356,6 +377,10 @@ impl GpuTextures {
binding: 3,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 4,
resource: move_offsets.buffer.as_entire_binding(),
},
],
label: Some("ui rsc image"),
})
+11 -1
View File
@@ -1,4 +1,4 @@
use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId};
use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId};
/// important non rendering data for retained drawing
#[derive(Debug)]
@@ -11,4 +11,14 @@ pub struct ActiveData {
pub children: Vec<WidgetId>,
pub mask: MaskIdx,
pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
/// section 5.
pub size: Size,
/// This widget's slot in `UiData::move_offsets`, assigned on its first
/// draw and kept for the rest of its life (redraws reuse it in place
/// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2.
pub move_slot: MoveIdx,
}
-18
View File
@@ -1,18 +0,0 @@
use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap};
#[derive(Default)]
pub struct Cache {
pub size: BothAxis<HashMap<WidgetId, (UiVec2, Len)>>,
}
impl Cache {
pub fn remove(&mut self, id: WidgetId) {
self.size.x.remove(&id);
self.size.y.remove(&id);
}
pub fn clear(&mut self) {
self.size.x.clear();
self.size.y.clear();
}
}
+9 -4
View File
@@ -1,15 +1,14 @@
use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena};
use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
mod active;
mod cache;
mod painter;
mod render_state;
mod size;
pub use active::*;
pub use painter::Painter;
pub use render_state::*;
pub use size::*;
#[derive(Default)]
pub struct UiData {
@@ -17,6 +16,12 @@ pub struct UiData {
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, forming the parent-linked chain
/// `resolve_move` walks in both shader stages. Allocated once on a
/// widget's first draw and reused for every later redraw of the same
/// id (never reallocated), so a retained descendant's `parent` index
/// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
}
pub trait UiRsc {
+69 -29
View File
@@ -1,7 +1,7 @@
use crate::{
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Widget, WidgetId,
render::{GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion,
UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst},
util::Vec2,
};
@@ -12,6 +12,7 @@ pub struct Painter<'a> {
pub(super) region: UiRegion,
pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>,
@@ -28,6 +29,7 @@ impl<'a> Painter<'a> {
primitive,
region,
mask_idx: self.mask,
move_idx: self.move_slot,
},
);
if self.mask != MaskIdx::NONE {
@@ -48,31 +50,80 @@ impl<'a> Painter<'a> {
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask { region });
self.mask = self.rsc.ui_mut().masks.push(Mask {
region,
move_idx: self.move_slot,
});
}
/// Draws a widget within this widget's region.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) {
self.widget_at(id, self.region);
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
self.widget_at(id, self.region)
}
/// Draws a widget somewhere within this one.
/// Useful for drawing child widgets in select areas.
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
self.widget_at(id, region.within(&self.region));
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.widget_at(id, region.within(&self.region))
}
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.children.push(id.id());
// Passed directly rather than looked up from `self.active`: this
// widget's own `ActiveData` (which would carry its `move_slot`) is
// not inserted there until *after* its own `Widget::draw` returns,
// so a lookup here -- for a child drawn partway through that same
// call -- would always find nothing. `self.move_slot` is this
// widget's own slot, already known, and always correct regardless
// of insertion order. See `UiRenderState::move_parent_of`.
self.state.draw_inner(
self.layer,
id.id(),
region,
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
None,
None,
self.rsc,
);
self.state
.active
.get(&id.id())
.map(|a| a.size)
.unwrap_or_default()
}
/// Move an already-drawn child from wherever it currently sits to
/// `region` (resolved against this widget's own region, matching
/// `widget_within`) without a second draw -- an O(1) offset write via
/// `UiRenderState::mov`. For a container that draws a child
/// provisionally to learn its size (e.g. `Aligned`) and then places it
/// for real. Only valid when the target keeps the child's drawn size;
/// if the shape actually changes, the normal `widget_within` dispatch
/// (which detects that from the stored region) does the right thing
/// instead.
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
let region = region.within(&self.region);
self.state.reposition(id.id(), region, self.rsc);
}
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw
/// it again at the region that assumption produced. For the rare
/// parent that cannot pick an offered size without already knowing the
/// answer. Twice the cost of one `draw`; every other case in this file
/// avoids it.
pub fn draw_twice<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
first: UiRegion,
second: impl FnOnce(Size) -> UiRegion,
) -> Size {
let used = self.widget_within(id, first);
let region = second(used);
self.widget_within(id, region)
}
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
@@ -94,10 +145,14 @@ impl<'a> Painter<'a> {
/// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
let h = self
.state
.layers
.write_image(self.layer, self.id, texture_idx, region, self.mask);
let h = self.state.layers.write_image(
self.layer,
self.id,
texture_idx,
region,
self.mask,
self.move_slot,
);
if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask);
}
@@ -151,17 +206,6 @@ impl<'a> Painter<'a> {
self.region
}
pub fn size<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>) -> Size {
self.size_ctx().size(id)
}
pub fn len_axis<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Len {
match axis {
Axis::X => self.size_ctx().width(id),
Axis::Y => self.size_ctx().height(id),
}
}
pub fn output_size(&self) -> Vec2 {
self.state.output_size
}
@@ -189,8 +233,4 @@ impl<'a> Painter<'a> {
pub fn id(&self) -> &WidgetId {
&self.id
}
pub fn size_ctx(&mut self) -> SizeCtx<'_> {
self.state.size_ctx(self.id, self.region.size(), self.rsc)
}
}
+244 -57
View File
@@ -1,34 +1,60 @@
use crate::{
ActiveData, Axis, IdLike, MaskIdx, Painter, PixelRegion, PrimitiveLayers, SizeCtx,
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
ui::cache::Cache,
util::{HashMap, HashSet, Vec2, forget_ref},
render::MoveOffset,
util::{HashMap, HashSet, Id, Vec2},
};
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers,
pub(super) output_size: Vec2,
pub cache: Cache,
old_root: Option<WidgetId>,
resized: bool,
draw_started: HashSet<WidgetId>,
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
/// stated in terms of these two: an unchanged frame must cost 0 of
/// each, and moving one widget must cost 0 draws and 0 rewrites
/// regardless of how many primitives are in its subtree.
draw_count: u64,
region_mut_count: u64,
mov_count: u64,
}
/// A move chain more than this deep would mean something else is wrong
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
/// the identical bound and must be kept in step with this constant.
pub const MOVE_CHAIN_LIMIT: usize = 16;
impl UiRenderState {
pub fn new() -> Self {
Self {
active: Default::default(),
layers: Default::default(),
cache: Default::default(),
output_size: Vec2::ZERO,
old_root: None,
resized: false,
draw_started: Default::default(),
draw_count: 0,
region_mut_count: 0,
mov_count: 0,
}
}
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
/// writes) counters -- call once per frame before `update()` to
/// measure exactly that frame, per LAYOUT.md section 8.
pub fn take_counters(&mut self) -> (u64, u64, u64) {
(
std::mem::take(&mut self.draw_count),
std::mem::take(&mut self.region_mut_count),
std::mem::take(&mut self.mov_count),
)
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.output_size = size.into();
self.resized = true;
@@ -65,10 +91,37 @@ impl UiRenderState {
self.clear(rsc);
// free all resources & cache
if let Some(id) = root {
self.draw_inner(0, id.id(), UiRegion::FULL, None, MaskIdx::NONE, None, rsc);
self.draw_inner(
0,
id.id(),
UiRegion::FULL,
None,
MoveOffset::NONE_PARENT,
MaskIdx::NONE,
None,
None,
rsc,
);
}
}
/// The slot an *already-active* widget's `move_offsets` entry chains
/// to, read back from `self.active`. Only valid where the parent is
/// guaranteed to already be in `self.active` -- true for `redraw()`,
/// which targets a widget that was fully drawn on some earlier update,
/// but **not** for a widget being drawn as part of its own parent's
/// `Widget::draw` call: that parent's `ActiveData` is not inserted
/// until its `draw` returns (below), so a child drawn partway through
/// it would always read back "no parent" here. `Painter::widget_at`
/// avoids that trap by passing its own already-known `move_slot`
/// straight through instead of asking `self.active` to look it up.
fn move_parent_of(&self, parent: Option<WidgetId>) -> u32 {
parent
.and_then(|p| self.active.get(&p))
.map(|p| p.move_slot.idx() as u32)
.unwrap_or(MoveOffset::NONE_PARENT)
}
// TODO: should prolly make a DrawInfo struct or smth for everything other than rsc
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_inner(
@@ -77,11 +130,14 @@ impl UiRenderState {
id: WidgetId,
region: UiRegion,
parent: Option<WidgetId>,
parent_move_slot: u32,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
old_move_slot: Option<MoveIdx>,
rsc: &mut dyn UiRsc,
) {
let mut old_children = old_children.unwrap_or_default();
let mut old_move_slot = old_move_slot;
if let Some(active) = self.active.get_mut(&id)
&& !rsc.widgets().needs_redraw.contains(&id)
{
@@ -91,21 +147,69 @@ impl UiRenderState {
} else if active.region.size() == region.size() {
// TODO: epsilon?
let from = active.region;
self.mov(id, from, region);
self.mov(id, from, region, rsc);
return;
} else if rsc
.widgets()
.get_dyn(id)
.map(|w| w.is_size_independent())
.unwrap_or(false)
{
// The offered region changed shape, but this widget's own
// drawn output does not depend on it (a fixed-size leaf) --
// rewrite its own primitives' regions in place (O(primitives
// owned directly by this widget, which for a leaf is O(1))
// instead of redrawing. See LAYOUT.md section 3.
let from = active.region;
for h in &active.primitives {
let r = self.layers[h.layer].region_mut(h);
*r = r.outside(&from).within(&region);
self.region_mut_count += 1;
}
active.region = region;
return;
}
// if not, then maintain resize and track old children to remove unneeded
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
}
// draw widget
self.draw_started.insert(id);
let move_slot = match old_move_slot {
// Reused across a real redraw of the same id: the fresh
// geometry this draw is about to write is placed at its
// correct absolute position by `region` itself, so any delta
// accumulated before this redraw is now stale and would
// double-offset it if left in place. The chain link (`parent`)
// is untouched -- the logical parent has not changed.
Some(slot) => {
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta = [0.0, 0.0];
slot
}
None => {
let slot = rsc
.ui_mut()
.move_offsets
.push(MoveOffset::new([0.0, 0.0], parent_move_slot));
rsc.ui_mut().move_offsets.push_ref(slot);
if parent_move_slot != MoveOffset::NONE_PARENT {
rsc.ui_mut()
.move_offsets
.push_ref(Id::preset(parent_move_slot));
}
slot
}
};
let mut painter = Painter {
state: self,
region,
mask,
move_slot,
layer,
id,
textures: Vec::new(),
@@ -115,7 +219,8 @@ impl UiRenderState {
};
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
widget.draw(&mut painter);
painter.state.draw_count += 1;
let size = widget.draw(&mut painter);
drop(widget);
let Painter {
@@ -123,6 +228,7 @@ impl UiRenderState {
rsc: _,
region,
mask,
move_slot,
textures,
primitives,
children,
@@ -140,6 +246,8 @@ impl UiRenderState {
children,
mask,
layer,
size,
move_slot,
};
// remove old children that weren't kept
@@ -153,18 +261,66 @@ impl UiRenderState {
self.active.insert(id, active);
}
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) {
let active = self.active.get_mut(&id).unwrap();
for h in &active.primitives {
let region = self.layers[h.layer].region_mut(h);
*region = region.outside(&from).within(&to);
}
active.region = active.region.outside(&from).within(&to);
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for child in children {
self.mov(*child, from, to);
}
/// O(1): write the delta for this widget's own slot in
/// `move_offsets`. No primitive is touched and there is no recursion --
/// every descendant's primitive references this slot transitively
/// through the parent chain the shader walks (`resolve_move`), so it
/// picks the new delta up for free. See LAYOUT.md section 2.
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get_mut(&id) else {
return;
};
let slot = active.move_slot;
active.region = to;
let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px;
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta[0] += delta.x;
entry.delta[1] += delta.y;
self.mov_count += 1;
}
/// Move an already-active widget to `to`. Used by `Painter::reposition`,
/// for a parent that drew a child provisionally (at the whole region it
/// was offered) and now knows where the child actually belongs.
///
/// Unlike `mov` (called by `draw_inner`'s own dispatch, where the
/// *offered* region really did move and `active.region` already tracks
/// it), the child here was not offered a smaller region -- it was
/// offered everything and chose, on its own, to occupy only
/// `active.size` of it. By convention every widget in this crate that
/// does that anchors its own content at the top-left of whatever it
/// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw`
/// bodies), so that is where this assumes the child was actually
/// painted, not `active.region` itself (which is the *offered* box,
/// usually bigger). A nested `Aligned` whose own child is not top-left
/// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape
/// this does not cover; none of iris's widgets or examples build that
/// today. See LAYOUT.md's "Rejected, and why" / deviations for the
/// full reasoning.
///
/// The delta is overwritten, not accumulated like `mov`'s: `from` is
/// recomputed fresh from `active.size`/`active.region` every call, so
/// repeating the same `reposition` (e.g. an unrelated redraw elsewhere
/// re-running this widget's parent without its own layout changing)
/// must land on the same answer, not drift further each time.
pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get(&id) else {
return;
};
let from = active
.size
.to_uivec2()
.align(RegionAlign::TOP_LEFT)
.within(&active.region);
let slot = active.move_slot;
let from_px = from.top_left().to_abs(self.output_size);
let to_px = to.top_left().to_abs(self.output_size);
let delta = to_px - from_px;
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
entry.delta = [delta.x, delta.y];
self.mov_count += 1;
}
/// NOTE: instance textures are cleared and self.textures freed
@@ -180,6 +336,18 @@ impl UiRenderState {
active.textures.clear();
rsc.ui_mut().textures.free();
if undraw {
// Permanent removal: retire this widget's own move slot
// (the self-ownership ref taken when it was allocated) and
// the up-link ref it held on its parent's slot -- read from
// the arena entry itself, not from `active.parent`, since
// the parent's own `ActiveData` may already be gone by the
// time a deep descendant is retired (see LAYOUT.md
// section 2's lifecycle note).
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
rsc.ui_mut().move_offsets.remove(active.move_slot);
if parent_slot != MoveOffset::NONE_PARENT {
rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot));
}
rsc.on_undraw(active);
}
}
@@ -187,7 +355,6 @@ impl UiRenderState {
}
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
self.cache.remove(id);
let inst = self.remove(id, true, rsc);
if let Some(inst) = &inst {
for c in &inst.children {
@@ -201,7 +368,6 @@ impl UiRenderState {
for (_, active) in self.active.drain() {
rsc.on_undraw(&active);
}
self.cache.clear();
self.layers.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
@@ -261,8 +427,43 @@ impl UiRenderState {
}
}
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let region = self.active.get(&id.id())?.region;
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root -- the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives). See LAYOUT.md
/// section 2b.
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
let active = self.active.get(&id.id())?;
let delta = self.resolve_move_chain(active.move_slot, rsc);
Some(active.region.offset(UiVec2::abs(delta)))
}
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
/// pixel delta along the parent chain starting at `slot`. Both walks
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
/// about where the chain ends.
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
let offsets = &rsc.ui().move_offsets;
let mut delta = Vec2::ZERO;
for i in 0..MOVE_CHAIN_LIMIT {
let entry = &offsets[slot.idx()];
delta.x += entry.delta[0];
delta.y += entry.delta[1];
if entry.parent == MoveOffset::NONE_PARENT {
return delta;
}
slot = Id::preset(entry.parent);
debug_assert!(
i + 1 < MOVE_CHAIN_LIMIT,
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
probably cyclic"
);
}
delta
}
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
let region = self.resolved_region(id, rsc)?;
Some(region.to_px(self.output_size))
}
@@ -270,21 +471,6 @@ impl UiRenderState {
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.remove(&id);
// check if parent depends on the desired size of this, if so then redraw it first
for axis in [Axis::X, Axis::Y] {
if let Some(&(outer, old)) = self.cache.size.axis_dyn(axis).get(&id)
&& let Some(current) = self.active.get(&id)
&& let Some(pid) = current.parent
{
self.cache.size.axis_dyn(axis).remove(&id);
let new = self.size_ctx(id, outer, rsc).len_axis(id, axis);
self.cache.size.axis_dyn(axis).insert(id, (outer, new));
if new != old {
self.redraw(pid, rsc);
}
}
}
if self.draw_started.contains(&id) {
return;
}
@@ -292,34 +478,35 @@ impl UiRenderState {
let Some(active) = self.remove(id, false, rsc) else {
return;
};
let old_size = active.size;
let parent = active.parent;
// `old_move_slot` being `Some` below means the slot is reused in
// place rather than freshly parented, so this is only reached for
// logging/clarity's sake, never actually used to link a new slot.
let parent_move_slot = self.move_parent_of(parent);
self.draw_inner(
active.layer,
id,
active.region,
active.parent,
parent,
parent_move_slot,
active.mask,
Some(active.children),
Some(active.move_slot),
rsc,
);
}
pub(super) fn size_ctx<'b>(
&'b mut self,
source: WidgetId,
outer: UiVec2,
rsc: &'b mut dyn UiRsc,
) -> SizeCtx<'b> {
let ui = rsc.ui_mut();
SizeCtx {
source,
cache: &mut self.cache,
text: &mut ui.text,
textures: &mut ui.textures,
widgets: &ui.widgets,
outer,
output_size: self.output_size,
id: source,
// If this widget's own reported size changed, its parent's layout
// (which placed it using the old size) is now stale and needs to
// relay out too. Checked after the real draw, not before it --
// there is no query left that answers "what size would this be"
// without actually drawing (LAYOUT.md section 5).
if let Some(pid) = parent {
let new_size = self.active.get(&id).map(|a| a.size);
if new_size != Some(old_size) {
self.redraw(pid, rsc);
}
}
}
}
-91
View File
@@ -1,91 +0,0 @@
use crate::{
Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures,
UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2,
};
pub struct SizeCtx<'a> {
pub text: &'a mut TextData,
pub textures: &'a mut Textures,
pub(super) source: WidgetId,
pub(super) widgets: &'a Widgets,
pub(super) cache: &'a mut Cache,
/// TODO: should this be pub? rn used for sized
pub outer: UiVec2,
pub(super) output_size: Vec2,
pub(super) id: WidgetId,
}
impl SizeCtx<'_> {
pub fn id(&self) -> &WidgetId {
&self.id
}
pub fn source(&self) -> &WidgetId {
&self.source
}
pub(super) fn len_inner<A: const AxisT>(&mut self, id: WidgetId) -> Len {
if let Some((_, len)) = self.cache.size.axis::<A>().get(&id) {
return *len;
}
let len = self
.widgets
.get_dyn_dynamic(id)
.desired_len::<A>(&mut SizeCtx {
text: self.text,
textures: self.textures,
source: self.source,
widgets: self.widgets,
cache: self.cache,
outer: self.outer,
output_size: self.output_size,
id,
});
self.cache.size.axis::<A>().insert(id, (self.outer, len));
len
}
pub fn width(&mut self, id: impl IdLike) -> Len {
self.len_inner::<XAxis>(id.id())
}
pub fn height(&mut self, id: impl IdLike) -> Len {
self.len_inner::<YAxis>(id.id())
}
pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len {
match axis {
Axis::X => self.width(id),
Axis::Y => self.height(id),
}
}
pub fn size(&mut self, id: impl IdLike) -> Size {
let id = id.id();
Size {
x: self.width(id),
y: self.height(id),
}
}
pub fn px_size(&mut self) -> Vec2 {
self.outer.to_abs(self.output_size)
}
pub fn output_size(&mut self) -> Vec2 {
self.output_size
}
pub fn draw_text(
&mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
self.text.render(buffer, attrs, width, self.textures)
}
pub fn label(&self, id: WidgetId) -> &String {
self.widgets.label(id)
}
}
+9
View File
@@ -71,6 +71,15 @@ impl<T, I: IdNum> TrackedArena<T, I> {
self.refs[i.idx()] += 1;
}
/// Mutable access to an existing entry, for the rare case (the move
/// offset chain) where an already-allocated slot is updated in place
/// rather than replaced. Marks the arena changed so the GPU copy is
/// re-uploaded.
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.changed = true;
&mut self.inner.data[id.idx()]
}
pub fn remove(&mut self, id: Id<I>) -> T
where
T: Copy,
+17 -20
View File
@@ -1,4 +1,4 @@
use crate::{Axis, AxisT, Len, Painter, SizeCtx};
use crate::{Painter, Size};
use std::any::Any;
mod data;
@@ -16,31 +16,28 @@ pub use view::*;
pub use widgets::*;
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len;
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len;
}
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
pub trait WidgetAxisFns {
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len;
}
impl<W: Widget + ?Sized> WidgetAxisFns for W {
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len {
match A::get() {
Axis::X => self.desired_width(ctx),
Axis::Y => self.desired_height(ctx),
}
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in. See LAYOUT.md.
fn is_size_independent(&self) -> bool {
false
}
}
impl Widget for () {
fn draw(&mut self, _: &mut Painter) {}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
Len::ZERO
fn draw(&mut self, _: &mut Painter) -> Size {
Size::ZERO
}
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::ZERO
fn is_size_independent(&self) -> bool {
true
}
}
+8 -3
View File
@@ -12,9 +12,14 @@ where
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id).unwrap();
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
let id_pos = region.top_left;
let container_pos = ctx.data.render.window_region(&container).unwrap().top_left;
let container_pos = ctx
.data
.render
.window_region(&container, &*rsc)
.unwrap()
.top_left;
let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size();
select(
@@ -67,7 +72,7 @@ fn select(
let recent = (now - state.last_click) < Duration::from_millis(300);
state.last_click = now;
id.edit(rsc).select(pos, size, dragging, recent);
if let Some(region) = render.window_region(&id) {
if let Some(region) = render.window_region(&id, &*rsc) {
state.window.set_ime_allowed(true);
state.window.set_ime_cursor_area(
LogicalPosition::<f32>::from(region.top_left.tuple()),
+1 -1
View File
@@ -167,7 +167,7 @@ impl SensorUi for UiRenderState {
for layer in self.layers.indices().rev() {
let mut sensed = false;
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
let shape = self.active.get(id).unwrap().region;
let shape = self.resolved_region(id, rsc).unwrap();
let region = shape.to_px(window_size);
let in_shape = cursor.exists && region.contains(cursor.pos);
sensor.hover.update(in_shape);
+183
View File
@@ -0,0 +1,183 @@
//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests
//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do
//! not touch a GPU or a window, so a tree can be built and driven directly.
//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the
//! CPU-side layout/move machinery LAYOUT.md is about.
use crate::prelude::*;
/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the
/// event/window/state plumbing `DefaultRsc` carries.
struct TestRsc {
ui: UiData,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// A `Scroll` over a `Span` of `n` fixed-height rects -- N primitives large
/// enough that an O(N) regression in the move path would show up as a
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
/// stack into a plain unit test). Returns the scroll widget (weak, for
/// mutating it later), the erased root to draw, and the rows (weak, for
/// hit-testing one of them).
fn scrolled_rects(
rsc: &mut TestRsc,
n: usize,
) -> (WeakWidget<Scroll>, StrongWidget, Vec<WeakWidget<Rect>>) {
let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(n);
for _ in 0..n {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
// Each row gets a fixed height so the span's total content is
// genuinely taller than the viewport -- rest-sized rows would just
// divide whatever space is offered and never need scrolling.
let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(10.0)),
});
span.push(row.any());
}
let span = rsc.ui.widgets.add_strong(span);
let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y));
let weak = scroll.weak();
(weak, scroll.any(), rects)
}
#[test]
fn an_unchanged_frame_draws_and_rewrites_nothing() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 20000.0));
render.update(&root, &mut rsc);
render.take_counters(); // discard the first, real draw
render.update(&root, &mut rsc);
let (draws, rewrites, moves) = render.take_counters();
assert_eq!((draws, rewrites, moves), (0, 0, 0));
}
#[test]
fn scrolling_moves_in_o1_without_a_redraw() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// The first draw offers `Scroll`'s content a zero-height region
// (nothing has been measured yet) and learns the real content length
// from what comes back; `update()` only redraws widgets actually
// marked dirty, so that corrected length is not reflected in the
// content's own *active* region until something -- here a no-op
// scroll tick -- actually asks `Scroll` to redraw again. Only after
// that warm-up does the content's offered size stop changing between
// draws, which is what makes a further, real scroll tick a same-size
// move instead of a resize. See scroll.rs.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
// Negative: `scroll`'s sign convention subtracts from `amt`, and
// `amt` starts at (and is clamped to) 0 at the top of the content, so
// a *positive* argument here would be scrolling further up (a no-op,
// already clamped) rather than actually moving anything.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves) = render.take_counters();
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the
// scrolled subtree. `draws` here is exactly 1: `Scroll` itself is
// marked dirty by `scroll()` and its own body is cheap arithmetic with
// no primitives of its own, so it is the one real `Widget::draw` this
// counts -- the 500 rects underneath move via the O(1) chain and are
// never revisited.
assert_eq!(draws, 1, "only Scroll itself should redraw");
assert_eq!(moves, 1, "the scrolled subtree should move in one write");
}
#[test]
fn hit_testing_follows_a_scrolled_widget() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let target = &rects[2];
let before = render.resolved_region(target, &rsc).unwrap();
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.0);
render.update(&root, &mut rsc);
let after = render.resolved_region(target, &rsc).unwrap();
let before_px = before.to_px((800.0, 600.0).into());
let after_px = after.to_px((800.0, 600.0).into());
// Scrolling by -37 moves `amt` from 0 to 37, sliding the content's
// top-left up by 37px -- `resolved_region` (the CPU twin of the vertex
// shader's chain walk) must reflect that immediately, not the
// pre-scroll position, or a tap routed through it would land on
// whatever is now at the old coordinates instead of this widget.
assert!(
(after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01,
"before={before_px:?} after={after_px:?}"
);
}
#[test]
fn a_mask_stays_put_while_its_scrolled_content_moves() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root });
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let masked_slot_before = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_before = rsc.ui.move_offsets[masked_slot_before.idx()].delta;
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
// `Masked` itself is never the target of a `mov`/`reposition` here --
// only its scrolled child is -- so the slot its own mask references
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
// still read zero after the scroll. The visible counterpart of this
// (the clipped edge follows the scroll while the viewport border does
// not) is `iris/run-headless.sh`'s job to catch in a real frame; this
// is the numeric half, on the same data the fragment shader's
// `resolve_move` reads. See LAYOUT.md section 2b.
assert_eq!(mask_delta_before, [0.0, 0.0]);
assert_eq!(mask_delta_after, [0.0, 0.0]);
}
+3
View File
@@ -9,6 +9,9 @@ pub mod default;
pub mod event;
pub mod widget;
#[cfg(test)]
mod layout_tests;
pub use iris_core as core;
pub use iris_macro as macros;
+11 -8
View File
@@ -6,16 +6,19 @@ pub struct Image {
}
impl Widget for Image {
fn draw(&mut self, painter: &mut Painter) {
painter.texture(&self.handle);
fn draw(&mut self, painter: &mut Painter) -> Size {
// Drawn at its own natural size, anchored top-left of whatever it
// was offered, not stretched to fill it -- its primitive is
// independent of the offered region, matching `is_size_independent`
// below. A caller that wants it placed differently wraps it (e.g.
// `.center()`, `.align(...)`).
let size = self.handle.size();
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
Size::abs(size)
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
Len::abs(self.handle.size().x)
}
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::abs(self.handle.size().y)
fn is_size_independent(&self) -> bool {
true // a decoded image's primitive never depends on the region it is offered
}
}
+2 -10
View File
@@ -5,16 +5,8 @@ pub struct Masked {
}
impl Widget for Masked {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.set_mask(painter.region());
painter.widget(&self.inner);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
painter.widget(&self.inner)
}
}
+16 -16
View File
@@ -6,30 +6,30 @@ pub struct Aligned {
}
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
// Draw once at the whole region this widget was offered to learn
// the child's real size -- this placement is provisional and
// corrected below without a second draw. `painter.widget` (not
// `widget_within(..., painter.region())`) is what "my whole,
// already-resolved region, unmodified" means: `widget_within`
// composes its argument as a *local*, `UiRegion::FULL`-relative
// box against `painter.region()`, so handing it the
// already-resolved region double-applies that composition and is
// wrong for any widget nested below the root.
let used = painter.widget(&self.inner);
let region = match self.align.tuple() {
(Some(x), Some(y)) => painter
.size(&self.inner)
.to_uivec2()
.align(RegionAlign { x, y }),
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => {
let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
let x = used.x.apply_rest().align(x);
UiRegion::new(x, UiSpan::FULL)
}
(None, Some(y)) => {
let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
let y = used.y.apply_rest().align(y);
UiRegion::new(UiSpan::FULL, y)
}
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
used
}
}
+2 -10
View File
@@ -6,18 +6,10 @@ pub struct LayerOffset {
}
impl Widget for LayerOffset {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
for _ in 0..self.offset {
painter.next_layer();
}
painter.widget(&self.inner);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
painter.widget(&self.inner)
}
}
+38 -31
View File
@@ -7,42 +7,49 @@ pub struct MaxSize {
}
impl MaxSize {
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
if let Some(x) = self.x {
ctx.outer.x.select_len(x.apply_rest());
}
if let Some(y) = self.y {
ctx.outer.y.select_len(y.apply_rest());
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
/// rel/abs/rest components are not otherwise comparable.
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
let Some(max) = max else {
return len;
};
let len_px = len.apply_rest().to_abs(output);
let max_px = max.apply_rest().to_abs(output);
if len_px > max_px { max } else { len }
}
/// The span (in this widget's own local, `UiRegion::FULL`-relative
/// terms) to actually offer the child: unconstrained if it already fits
/// within `max`, or a box of exactly `max`, anchored at this axis's
/// start, if it does not. Needed so the child is never painted bigger
/// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`.
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32) -> UiSpan {
let Some(max) = max else {
return UiSpan::FULL;
};
let max_scalar = max.apply_rest();
let max_px = max_scalar.to_abs(output);
if offered_px > max_px {
max_scalar.align(AxisAlign::Neg)
} else {
UiSpan::FULL
}
}
}
impl Widget for MaxSize {
fn draw(&mut self, painter: &mut Painter) {
painter.widget(&self.inner);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.apply_to_outer(ctx);
let width = ctx.width(&self.inner);
if let Some(x) = self.x {
let width_px = width.apply_rest().to_abs(ctx.output_size().x);
let x_px = x.apply_rest().to_abs(ctx.output_size().x);
if width_px > x_px { x } else { width }
} else {
width
}
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.apply_to_outer(ctx);
let height = ctx.height(&self.inner);
if let Some(y) = self.y {
let height_px = height.apply_rest().to_abs(ctx.output_size().y);
let y_px = y.apply_rest().to_abs(ctx.output_size().y);
if height_px > y_px { y } else { height }
} else {
height
fn draw(&mut self, painter: &mut Painter) -> Size {
let output = painter.output_size();
let offered = painter.px_size();
let region = UiRegion {
x: Self::clamp_region(offered.x, self.x, output.x),
y: Self::clamp_region(offered.y, self.y, output.y),
};
let used = painter.widget_within(&self.inner, region);
Size {
x: Self::clamp(used.x, self.x, output.x),
y: Self::clamp(used.y, self.y, output.y),
}
}
}
+2 -10
View File
@@ -6,16 +6,8 @@ pub struct Offset {
}
impl Widget for Offset {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
let region = UiRegion::FULL.offset(self.amt);
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
painter.widget_within(&self.inner, region)
}
}
+6 -20
View File
@@ -6,28 +6,14 @@ pub struct Pad {
}
impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) {
painter.widget_within(&self.inner, self.padding.region());
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
fn draw(&mut self, painter: &mut Painter) -> Size {
let used = painter.widget_within(&self.inner, self.padding.region());
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
ctx.outer.x.abs -= width;
ctx.outer.y.abs -= height;
let mut size = ctx.width(&self.inner);
size.abs += width;
size
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
ctx.outer.x.abs -= width;
ctx.outer.y.abs -= height;
let mut size = ctx.height(&self.inner);
size.abs += height;
size
Size {
x: used.x + Len::abs(width),
y: used.y + Len::abs(height),
}
}
}
+29 -18
View File
@@ -10,33 +10,44 @@ pub struct Scroll {
}
impl Widget for Scroll {
fn draw(&mut self, painter: &mut Painter) {
let output_len = painter.output_size().axis(self.axis);
let container_len = painter.region().axis(self.axis).len();
let content_len = painter
.len_axis(&self.inner, self.axis)
.apply_rest()
.within_len(container_len)
.to_abs(output_len);
fn draw(&mut self, painter: &mut Painter) -> Size {
// The region offered to the child is sized using *last* frame's
// content length, not a fresh measurement -- deliberately, so that
// an ordinary scroll tick (`amt` changes, content does not) offers
// the child the exact same size it was last drawn with, only
// shifted. That is what lets `draw_inner` dispatch this as an O(1)
// move (LAYOUT.md section 2) instead of a redraw: sizing the region
// to a *fresh* measurement would require drawing the child first to
// learn it, and a provisional draw almost never matches the
// previously active size, forcing a real redraw on every tick. A
// genuine content-size change (not just a scroll) therefore lags
// one frame before the container's clamp reflects it; the content
// length itself (read below from what was actually drawn) is never
// stale, so this self-corrects the next frame and never leaves the
// scroll range wrong for long. See LAYOUT.md section 4.
let axis = self.axis;
let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len();
self.container_len = container_len.to_abs(output_len);
self.content_len = content_len;
if self.snap_end {
self.amt = self.content_len - self.container_len;
}
self.update_amt();
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0));
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
painter.widget_within(&self.inner, region);
}
let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(self.content_len);
let region = region.offset(Vec2::from_axis(axis, -self.amt, 0.0));
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
let used = painter.widget_within(&self.inner, region);
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
self.content_len = used
.axis(axis)
.apply_rest()
.within_len(container_len)
.to_abs(output_len);
used
}
}
+19 -20
View File
@@ -6,29 +6,28 @@ pub struct Sized {
pub y: Option<Len>,
}
impl Sized {
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
impl Widget for Sized {
fn draw(&mut self, painter: &mut Painter) -> Size {
// The child is drawn within a region that actually carves out the
// fixed axes, not whatever region this widget itself happened to
// be offered -- needed so the painted geometry matches the
// declared size returned below regardless of how much room a
// parent offers. `Aligned`'s single-draw pattern (LAYOUT.md
// section 6) draws its child once at its own *full* region to
// learn its size, then moves it into place with a pure
// translation; that translation is only valid if what got painted
// is already the reported size, anchored the same way both times.
let mut region = UiRegion::FULL;
if let Some(x) = self.x {
ctx.outer.x.select_len(x.apply_rest());
region.x = x.apply_rest().align(AxisAlign::Neg);
}
if let Some(y) = self.y {
ctx.outer.y.select_len(y.apply_rest());
region.y = y.apply_rest().align(AxisAlign::Neg);
}
let used = painter.widget_within(&self.inner, region);
Size {
x: self.x.unwrap_or(used.x),
y: self.y.unwrap_or(used.y),
}
}
}
impl Widget for Sized {
fn draw(&mut self, painter: &mut Painter) {
painter.widget(&self.inner);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.apply_to_outer(ctx);
self.x.unwrap_or_else(|| ctx.width(&self.inner))
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.apply_to_outer(ctx);
self.y.unwrap_or_else(|| ctx.height(&self.inner))
}
}
+48 -100
View File
@@ -8,13 +8,38 @@ pub struct Span {
}
impl Widget for Span {
fn draw(&mut self, painter: &mut Painter) {
let total = self.len_sum(&mut painter.size_ctx());
fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis;
// Phase 1: draw each child once, at the ambient (unmodified, full)
// region a size-only query used to see before this migration, to
// learn its length along the layout axis. This paints real
// primitives at a provisional slot; phase 2 below places each
// child for real via the normal `widget_within` dispatch, which
// only actually redraws it when that slot's *size* differs from
// this provisional one (most children: a resize, since the
// provisional slot is the whole span, not this child's share).
let lens: Vec<Len> = self
.children
.iter()
.map(|child| painter.widget(child).axis(axis))
.collect();
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
// Phase 2: place each child for real, using the lengths just
// learned -- the same arithmetic this loop always used. The cross-
// axis length of *this* draw (used for `Span`'s own reported size
// below) falls out of each child's real, resolved-width `used`
// here for free -- this is what replaces `desired_ortho`'s former
// duplicate simulation of this same loop (see LAYOUT.md section 4).
let mut start = UiScalar::rel_min();
for child in &self.children {
let mut ortho_len = Len::ZERO;
let mut ortho_mixed = false;
for (child, &len) in self.children.iter().zip(&lens) {
let mut span = UiSpan::FULL;
span.start = start;
let len = painter.len_axis(child, self.dir.axis);
if len.rest > 0.0 {
let offset = UiScalar::new(total.rel, total.abs);
let rel_end = UiScalar::rel(len.rest / total.rest);
@@ -24,27 +49,31 @@ impl Widget for Span {
start.abs += len.abs;
start.rel += len.rel;
span.end = start;
let mut child_region = UiRegion::from_axis(self.dir.axis, span, UiSpan::FULL);
let mut child_region = UiRegion::from_axis(axis, span, UiSpan::FULL);
if self.dir.sign == Sign::Neg {
child_region.flip(self.dir.axis);
child_region.flip(axis);
}
painter.widget_within(child, child_region);
let used = painter.widget_within(child, child_region);
start.abs += self.gap;
}
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
match self.dir.axis {
Axis::X => self.desired_len(ctx),
Axis::Y => self.desired_ortho(ctx),
let ortho = used.axis(!axis);
if ortho.rel > 0.0 || ortho.rest > 0.0 {
ortho_mixed = true;
} else {
ortho_len.abs = ortho_len.abs.max(ortho.abs);
}
}
if ortho_mixed {
ortho_len = Len::default();
}
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
match self.dir.axis {
Axis::X => self.desired_ortho(ctx),
Axis::Y => self.desired_len(ctx),
}
let along = if total.rest == 0.0 && total.rel == 0.0 {
total
} else {
Len::default()
};
Size::from_axis(axis, along, ortho_len)
}
}
@@ -69,87 +98,6 @@ impl Span {
pub fn pop(&mut self) -> Option<StrongWidget> {
self.children.pop()
}
fn len_sum(&mut self, ctx: &mut SizeCtx) -> Len {
let gap = self.gap * self.children.len().saturating_sub(1) as f32;
self.children.iter().fold(Len::abs(gap), |mut s, id| {
// it's tempting to subtract the abs & rel from the ctx outer,
// but that would create inconsistent sizing if you put
// a rest first vs last & only speed up in one direction.
// I think this is only solvable by restricting how you can
// compute size, bc currently you need child to define parent's
// sectioning and you need parent's sectioning to define child.
// Fortunately, that doesn't matter in most cases
let len = ctx.len_axis(id, self.dir.axis);
s += len;
s
})
}
fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len {
let len = self.len_sum(ctx);
if len.rest == 0.0 && len.rel == 0.0 {
len
} else {
Len::default()
}
}
fn desired_ortho(&mut self, ctx: &mut SizeCtx) -> Len {
// this is a weird hack to get text wrapping to work properly when in a downward span
// the correct solution here is to add a function to widget that lets them
// request that ctx.outer has an axis "resolved" before checking the other,
// and panicking or warning if two request opposite axis (unsolvable in that case)
let outer = ctx.outer.axis(self.dir.axis);
if self.dir.axis == Axis::X {
// so....... this literally copies draw so that the lengths are correctly set in the
// context, which makes this slow and not cool
let total = self.len_sum(ctx);
let mut start = UiScalar::rel_min();
let mut ortho_len = Len::ZERO;
for child in &self.children {
let mut span = UiSpan::FULL;
span.start = start;
let len = ctx.len_axis(child, self.dir.axis);
if len.rest > 0.0 {
let offset = UiScalar::new(total.rel, total.abs);
let rel_end = UiScalar::rel(len.rest / total.rest);
let end = (UiScalar::rel_max() + start) - offset;
start = rel_end.within(&start.to(end));
}
start.abs += len.abs;
start.rel += len.rel;
span.end = start;
let scalar = span.len();
*ctx.outer.axis_mut(self.dir.axis) = outer.select_len(scalar);
let ortho = ctx.len_axis(child, !self.dir.axis);
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
if ortho.rel > 0.0 || ortho.rest > 0.0 {
ortho_len.rest = 1.0;
ortho_len.abs = 0.0;
break;
}
ortho_len.abs = ortho_len.abs.max(ortho.abs);
start.abs += self.gap;
}
ortho_len
} else {
let mut ortho_len = Len::ZERO;
let ortho = !self.dir.axis;
for child in &self.children {
let len = ctx.len_axis(child, ortho);
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
if len.rel > 0.0 || len.rest > 0.0 {
ortho_len.rest = 1.0;
ortho_len.abs = 0.0;
break;
}
ortho_len.abs = ortho_len.abs.max(len.abs);
}
ortho_len
}
}
}
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
+15 -18
View File
@@ -8,29 +8,26 @@ pub struct Stack {
}
impl Widget for Stack {
fn draw(&mut self, painter: &mut Painter) {
let mut iter = self.children.iter();
if let Some(child) = iter.next() {
fn draw(&mut self, painter: &mut Painter) -> Size {
let mut picked = None;
let mut iter = self.children.iter().enumerate();
if let Some((i, child)) = iter.next() {
painter.child_layer();
painter.widget(child);
let used = painter.widget(child);
if matches!(self.size, StackSize::Child(j) if j == i) {
picked = Some(used);
}
}
for child in iter {
for (i, child) in iter {
painter.next_layer();
painter.widget(child);
let used = painter.widget(child);
if matches!(self.size, StackSize::Child(j) if j == i) {
picked = Some(used);
}
}
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
match self.size {
StackSize::Default => Len::default(),
StackSize::Child(i) => ctx.width(&self.children[i]),
}
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
match self.size {
StackSize::Default => Len::default(),
StackSize::Child(i) => ctx.height(&self.children[i]),
StackSize::Default => Size::default(),
StackSize::Child(_) => picked.unwrap_or_default(),
}
}
}
+6 -16
View File
@@ -6,26 +6,16 @@ pub struct WidgetPtr {
}
impl Widget for WidgetPtr {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
if let Some(id) = &self.inner {
painter.widget(id);
painter.widget(id)
} else {
Size::ZERO
}
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(id) = &self.inner {
ctx.width(id)
} else {
Len::ZERO
}
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(id) = &self.inner {
ctx.height(id)
} else {
Len::ZERO
}
fn is_size_independent(&self) -> bool {
self.inner.is_none()
}
}
+4 -7
View File
@@ -28,21 +28,18 @@ impl Rect {
}
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
painter.primitive(RectPrimitive {
color: self.color,
radius: self.radius,
thickness: self.thickness,
inner_radius: self.inner_radius,
});
Size::REST // fills whatever it was given -- used == available
}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
Len::rest(1)
}
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::rest(1)
fn is_size_independent(&self) -> bool {
true // content never depends on region size
}
}
+4 -11
View File
@@ -60,15 +60,15 @@ impl TextEdit {
}
impl Widget for TextEdit {
fn draw(&mut self, painter: &mut Painter) {
fn draw(&mut self, painter: &mut Painter) -> Size {
let base = painter.layer;
painter.child_layer();
self.view.draw(painter);
let used = self.view.draw(painter);
painter.layer = base;
let region = self.region();
let Some(selection) = self.selection else {
return;
return used;
};
let layout = self.view.buf.layout();
@@ -90,14 +90,7 @@ impl Widget for TextEdit {
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.view.desired_width(ctx)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.view.desired_height(ctx)
used
}
}
+19 -42
View File
@@ -54,9 +54,9 @@ impl TextView {
.align(self.align)
}
fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText {
fn render(&mut self, painter: &mut Painter) -> RenderedText {
let width = if self.attrs.wrap {
Some(ctx.px_size().x)
Some(painter.px_size().x)
} else {
None
};
@@ -68,7 +68,7 @@ impl TextView {
return tex.clone();
}
self.width = width;
let tex = ctx.draw_text(&mut self.buf, &self.attrs, width);
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
self.tex = Some(tex.clone());
self.attrs.changed = false;
self.buf.changed = false;
@@ -77,36 +77,23 @@ impl TextView {
pub fn tex(&self) -> Option<&RenderedText> {
self.tex.as_ref()
}
pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
/// Draws within `painter.region()` and reports the size used -- what
/// `desired_width`/`desired_height` used to answer separately, folded
/// into the one draw (LAYOUT.md section 4): the shaped layout this
/// reads is already memoized by width in `render`, so a second call at
/// the same width (a redraw with nothing else changed) is a cache hit,
/// not a re-shape.
pub fn draw(&mut self, painter: &mut Painter) -> Size {
let tex = self.render(painter);
if self.is_blank()
&& let Some(hint) = &self.hint
{
ctx.width(hint)
} else {
Len::abs(self.render(ctx).size.x)
return painter.widget(hint);
}
}
pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
if self.is_blank()
&& let Some(hint) = &self.hint
{
ctx.height(hint)
} else {
Len::abs(self.render(ctx).size.y)
}
}
pub fn draw(&mut self, painter: &mut Painter) -> UiRegion {
let tex = self.render(&mut painter.size_ctx());
let region = tex.size.align(self.align);
if self.is_blank()
&& let Some(hint) = &self.hint
{
painter.widget(hint);
} else {
let within = region.within(&painter.region());
painter.glyphs(&tex, within);
}
region
let within = region.within(&painter.region());
painter.glyphs(&tex, within);
Size::abs(tex.size)
}
pub fn content(&self) -> String {
@@ -122,7 +109,7 @@ impl Text {
content: content.into(),
}
}
fn update_buf(&mut self, _ctx: &mut SizeCtx) {
fn update_buf(&mut self) {
if self.content.changed {
self.content.changed = false;
self.view.buf.set_text(self.content.as_str());
@@ -131,19 +118,9 @@ impl Text {
}
impl Widget for Text {
fn draw(&mut self, painter: &mut Painter) {
self.update_buf(&mut painter.size_ctx());
self.view.draw(painter);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.update_buf(ctx);
self.view.desired_width(ctx)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.update_buf(ctx);
self.view.desired_height(ctx)
fn draw(&mut self, painter: &mut Painter) -> Size {
self.update_buf();
self.view.draw(painter)
}
}