Skip to content

fix(ui): pad container children like leaves, and blit without copying - #103

Merged
nicolas-maman merged 1 commit into
mainfrom
fix/ui-insets-and-image-blit
Sep 4, 2026
Merged

fix(ui): pad container children like leaves, and blit without copying#103
nicolas-maman merged 1 commit into
mainfrom
fix/ui-insets-and-image-blit

Conversation

@nicolas-maman

Copy link
Copy Markdown
Contributor

Two reported problems: one about how the toolkit looks, one about what it costs to draw.

Closes #96
Closes #102

#96 — a container child ignored the parent's padding

A leaf child goes through NSStackView's own arranged-subview layout, which applies edgeInsets. A container child (hstack, vstack, divider) is pinned to the stack's leading/trailing anchors so nested rows fill the width — and those pins used the bare anchors. So a heading sat 12px in and the row beneath it did not.

That matters more than it sounds: a label-and-value row is the basic unit of an inspector panel, and a row is an hstack. Every such panel was misaligned by exactly its own padding.

The pins now carry the inset as their constant, and they are tagged so set_edge_insets can update them afterwards — styles are normally applied after the tree is built, so the constraints already exist by the time an inset arrives. Without that, an inset set through a stylesheet would move the leaf children and leave every nested row behind.

Measured on the reported shape, a 400px panel with 12px sides:

without the fix: row_w = 400     <- hangs outside the padding
with the fix:    row_w = 376     <- the same content box the leaf gets

#102 — the per-frame blit copied the whole framebuffer

canvas_draw_image_ptr allocates and copies the entire image on every call. For a one-off image that is correct and cheap. For a surface redrawn every frame it is a malloc plus a full-framebuffer memcpy sixty times a second — on the reported 918x659 viewport, 61% of the frame, more than reading the frame back off the GPU and roughly six times the cost of rendering it, with the same pixels copied twice before reaching the screen.

canvas_draw_image_borrowed_ptr (and the scaled twin) keep the caller's buffer instead of copying it, on all three backends.

The contract is deliberately sharp, and documented at the verb rather than buried: the pixels stay yours and must remain valid until the next canvas_clear, which is the lifetime the retained command list already has — and exactly what a per-frame surface already guarantees, since it owns one buffer and overwrites it in place. A caller that cannot promise that keeps using the copying variant.

I did not take the alternative in the issue (an image handle with create/update/draw). It is the better long-term shape, because it would also let a backend keep a CGImage or texture alive across frames, but it is a larger surface and this removes the measured cost now. Worth its own issue if the handle form is wanted.

Verification

Speed is worthless if the picture is wrong, so the test asserts pixels, not timing: one image blitted both ways and read back.

owned  00=-65536 11=-16711936      # 0xFFFF0000 red, 0xFF00FF00 green
borrow 00=-65536 11=-16711936
MATCH

Coverage

Two new ci.sh phases (5e23, 5e24). Neither needs the driver — both apps read their own state back and quit — so they work on every leg regardless of the sandbox's socket rules.

🤖 Generated with Claude Code

Two reported problems, one about how the toolkit LOOKS and one about what it
costs to draw.

#96 a container child ignored the parent's st_insets while a leaf child
honoured them. A leaf goes through NSStackView's own arranged-subview layout,
which applies edgeInsets; a container (hstack, vstack, divider) is pinned to
the stack's leading/trailing anchors so nested rows fill the width, and those
pins used the BARE anchors. So a heading sat 12px in and the row beneath it
did not — a label-and-value row is the basic unit of an inspector panel, so
every such panel was misaligned by exactly its own padding.

The pins now carry the inset as their constant, and they are tagged so
set_edge_insets can update them later: styles are normally applied AFTER the
tree is built, so the constraints already exist when an inset arrives.
Measured on the reported shape, a 400px panel with 12px sides: the row was
400 wide and is now 376, the same content box the leaf gets.

#102 canvas_draw_image_ptr allocates and copies the whole image on every
call. For a one-off image that is right and cheap. For a surface redrawn
every frame it is a malloc plus a full-framebuffer memcpy sixty times a
second: on a 918x659 viewport that was 61% of the frame, more than reading
the frame back off the GPU and about six times the cost of rendering it, with
the same pixels copied twice before reaching the screen.

canvas_draw_image_borrowed_ptr keeps the caller's buffer instead. The
contract is deliberately sharp and documented at the verb: the pixels stay
yours and must remain valid until the next canvas_clear, which is exactly
what a per-frame surface already guarantees. A caller that cannot promise
that keeps using the copying variant.

Speed is worthless if the picture is wrong, so the test asserts PIXELS: one
image blitted both ways, read back, and required to be identical. It is, on
all three backends.

ci.sh gains a phase each. Neither needs the driver: both apps read their own
state back and quit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nicolas-maman
nicolas-maman merged commit 28c981f into main Sep 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant