From ab3b355f864244de17964b1d60ad113785b0b503 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Fri, 4 Sep 2026 15:04:12 -0300 Subject: [PATCH] fix(ui): pad container children like leaves, and blit without copying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/aether_ui_backend.h | 11 +++ backend/aether_ui_gtk4.c | 41 +++++++- backend/aether_ui_macos.m | 100 ++++++++++++++++++-- backend/aether_ui_win32.c | 41 +++++++- ci.sh | 35 ++++++- examples/blitborrow_demo/.build.ae | 18 ++++ examples/blitborrow_demo/blitborrow_demo.ae | 74 +++++++++++++++ examples/insets_demo/.build.ae | 18 ++++ examples/insets_demo/insets_demo.ae | 57 +++++++++++ ui/module.ae | 32 +++++++ 10 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 examples/blitborrow_demo/.build.ae create mode 100644 examples/blitborrow_demo/blitborrow_demo.ae create mode 100644 examples/insets_demo/.build.ae create mode 100644 examples/insets_demo/insets_demo.ae diff --git a/backend/aether_ui_backend.h b/backend/aether_ui_backend.h index 3b05a349..2d85c5fe 100644 --- a/backend/aether_ui_backend.h +++ b/backend/aether_ui_backend.h @@ -275,6 +275,17 @@ void aether_ui_window_close_impl(int win_handle); /* #95: state a widget's width/height, and read back what it actually got. The setter is what holds a panel at a size across a layout pass; the getter answers from the real allocation, not from the request. */ +/* #102: blit WITHOUT copying — the pixels stay the caller's and must stay + valid until the next canvas_clear. For a surface redrawn every frame the + owning variant's malloc+memcpy of the whole framebuffer dominated the + frame; a caller that cannot promise the lifetime uses the owning one. */ +void aether_ui_canvas_draw_image_borrowed_impl(int canvas_id, double x, double y, + int iw, int ih, + const unsigned char* rgba, int byte_len); +void aether_ui_canvas_draw_image_scaled_borrowed_impl(int canvas_id, double x, double y, + double dw, double dh, int iw, int ih, + const unsigned char* rgba, int byte_len); + void aether_ui_set_width_impl(int handle, int px); void aether_ui_set_height_impl(int handle, int px); int aether_ui_get_width_impl(int handle); diff --git a/backend/aether_ui_gtk4.c b/backend/aether_ui_gtk4.c index 5b76f644..b8739a2f 100644 --- a/backend/aether_ui_gtk4.c +++ b/backend/aether_ui_gtk4.c @@ -4359,7 +4359,10 @@ typedef struct { // DRAW_IMAGE pixel dims double a0, a1; // ARC start/end angle (radians) char* text; // FILL_TEXT string (owned, freed on clear/destroy) - unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer (owned), iw*ih*4 bytes + unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer, iw*ih*4 bytes + /* #102: 0 = owned and freed with the command, 1 = borrowed from the + caller until the next canvas_clear. */ + int pixels_borrowed; int iw, ih; // DRAW_IMAGE pixel width/height // Gradient (FILL_LINEAR / FILL_RADIAL): geometry in x,y..(see impls), // plus owned stop arrays. FILL_LINEAR uses (gx1,gy1)→(gx2,gy2); @@ -5621,6 +5624,38 @@ void aether_ui_canvas_draw_image_impl(int canvas_id, double x, double y, // As draw_image, but scale the iw×ih source to a dw×dh destination rect at // (x,y) — for a video/raster frame whose pixel resolution differs from the // region's canvas-px extent. dw/dh <= 0 falls back to a 1:1 native blit. +/* #102: draw WITHOUT copying. The pixels stay the caller's and must remain + * valid until the next canvas_clear on this canvas — the lifetime the + * retained command list already has. That is what a per-frame surface (a 3D + * viewport, a video frame, a game framebuffer) already guarantees: one buffer, + * overwritten in place, canvas cleared and redrawn each frame. The owning + * variant copies the whole framebuffer per call, which measured 61% of the + * frame at 918x659. A caller that cannot promise the lifetime keeps using the + * owning variant. */ +void aether_ui_canvas_draw_image_borrowed_impl(int canvas_id, double x, double y, + int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + canvas_add_cmd(canvas_id, (CanvasCmd){ + .type = CANVAS_DRAW_IMAGE, .x = x, .y = y, + .pixels = (unsigned char*)rgba, .pixels_borrowed = 1, + .iw = iw, .ih = ih + }); +} + +void aether_ui_canvas_draw_image_scaled_borrowed_impl(int canvas_id, double x, double y, + double dw, double dh, int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + canvas_add_cmd(canvas_id, (CanvasCmd){ + .type = CANVAS_DRAW_IMAGE, .x = x, .y = y, .w = dw, .h = dh, + .pixels = (unsigned char*)rgba, .pixels_borrowed = 1, + .iw = iw, .ih = ih + }); +} + void aether_ui_canvas_draw_image_scaled_impl(int canvas_id, double x, double y, double dw, double dh, int iw, int ih, const unsigned char* rgba, int byte_len) { @@ -5692,7 +5727,9 @@ void aether_ui_canvas_clear_impl(int canvas_id) { free(c->text); c->text = NULL; } if (c->type == CANVAS_DRAW_IMAGE && c->pixels) { - free(c->pixels); c->pixels = NULL; + /* #102: a borrowed buffer belongs to the caller. */ + if (!c->pixels_borrowed) free(c->pixels); + c->pixels = NULL; } if (c->type == CANVAS_FILL_LINEAR || c->type == CANVAS_FILL_RADIAL) { free(c->stop_off); c->stop_off = NULL; diff --git a/backend/aether_ui_macos.m b/backend/aether_ui_macos.m index 6ef6be44..b8bb6315 100644 --- a/backend/aether_ui_macos.m +++ b/backend/aether_ui_macos.m @@ -2859,9 +2859,28 @@ int aether_ui_state_style_impl(int handle, int state) { void aether_ui_set_edge_insets(int handle, double top, double right, double bottom, double left) { NSView* v = (__bridge NSView*)aether_ui_get_widget(handle); - if (v && [v isKindOfClass:[NSStackView class]]) { - [(NSStackView*)v setEdgeInsets:NSEdgeInsetsMake(top, left, bottom, right)]; + if (!v || ![v isKindOfClass:[NSStackView class]]) return; + NSStackView* sv = (NSStackView*)v; + [sv setEdgeInsets:NSEdgeInsetsMake(top, left, bottom, right)]; + + /* #96: container children are pinned to this stack's own edges rather + * than laid out as ordinary arranged subviews, so NSStackView's insets do + * not reach them. Those pins carry the inset as their constant, and this + * is where they learn a NEW one: styles are normally applied after the + * tree is built, so the constraints already exist when an inset arrives. + * Without this, an inset set through a stylesheet would move the leaf + * children and leave every nested row behind. */ + for (NSLayoutConstraint* c in [sv constraints]) { + NSString* id_ = [c identifier]; + if (!id_) continue; + if ([id_ isEqualToString:@"aeui-inset-lead"]) { + c.constant = left; + } else if ([id_ isEqualToString:@"aeui-inset-trail"] || + [id_ isEqualToString:@"aeui-inset-trail-max"]) { + c.constant = -right; + } } + [sv setNeedsLayout:YES]; } // Does this view carry its own width-to-constant constraint? @@ -4505,7 +4524,13 @@ void aether_ui_image_set_size(int handle, int width, int height) { double w, h; double a0, a1; // ARC start/end angle char* text; // FILL_TEXT string (owned) - unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer (owned) + unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer + /* #102: 0 = this command owns `pixels` and frees them with the command; + 1 = they belong to the caller and are only borrowed until the next + canvas_clear. A per-frame viewport hands over the same stable buffer + every frame, and copying 2.4 MB sixty times a second cost more than + reading the frame off the GPU did. */ + int pixels_borrowed; int iw, ih; // DRAW_IMAGE pixel dims double gx1, gy1, gx2, gy2, gr, gfx, gfy; // gradient geometry double grad_line_width; // 0 → fill; >0 → stroke at this width @@ -5617,6 +5642,43 @@ void aether_ui_canvas_draw_image_impl(int canvas_id, double x, double y, // backend only). The command carries the dest extent in w/h and the // executor hands CGContextDrawImage a dest rect of that size; CG scales // natively, same as GTK4's cairo path and win32's StretchBlt. +/* #102: draw WITHOUT copying. The pixels stay the caller's, and must remain + * valid and unchanged until the next canvas_clear on this canvas — the same + * lifetime the retained command list already has. + * + * That is exactly the contract a per-frame surface already satisfies: a 3D + * viewport, a video frame or a game framebuffer owns one buffer, overwrites + * it in place, and clears and redraws the canvas each frame. The owning + * variant above allocated and copied the whole framebuffer on every call, + * which for a 918x659 viewport measured 61% of the frame — more than reading + * the frame back off the GPU, and six times more than rendering it. + * + * A caller that cannot promise that lifetime should keep using the owning + * variant; this is a sharper tool on purpose. */ +void aether_ui_canvas_draw_image_borrowed_impl(int canvas_id, double x, double y, + int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + canvas_add_cmd(canvas_id, (CanvasCmd){ + .type = CANVAS_DRAW_IMAGE, .x = x, .y = y, + .pixels = (unsigned char*)rgba, .pixels_borrowed = 1, + .iw = iw, .ih = ih + }); +} + +void aether_ui_canvas_draw_image_scaled_borrowed_impl(int canvas_id, double x, double y, + double dw, double dh, int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + canvas_add_cmd(canvas_id, (CanvasCmd){ + .type = CANVAS_DRAW_IMAGE, .x = x, .y = y, .w = dw, .h = dh, + .pixels = (unsigned char*)rgba, .pixels_borrowed = 1, + .iw = iw, .ih = ih + }); +} + void aether_ui_canvas_draw_image_scaled_impl(int canvas_id, double x, double y, double dw, double dh, int iw, int ih, const unsigned char* rgba, int byte_len) { @@ -5682,7 +5744,9 @@ void aether_ui_canvas_clear_impl(int canvas_id) { free(c->text); c->text = NULL; } if (c->type == CANVAS_DRAW_IMAGE && c->pixels) { - free(c->pixels); c->pixels = NULL; + /* #102: a borrowed buffer belongs to the caller. */ + if (!c->pixels_borrowed) free(c->pixels); + c->pixels = NULL; } if (c->type == CANVAS_FILL_LINEAR || c->type == CANVAS_FILL_RADIAL) { free(c->stop_off); c->stop_off = NULL; @@ -6351,10 +6415,32 @@ void aether_ui_widget_add_child_ctx(void* parent_ctx, int child_handle) { // narrower than the parent; trailing == at high-but-not-required // priority makes it stretch whenever nothing forbids it (which is // what gives the calculator its full-width button rows). - [child.leadingAnchor constraintEqualToAnchor:sv.leadingAnchor].active = YES; - [child.trailingAnchor constraintLessThanOrEqualToAnchor:sv.trailingAnchor].active = YES; + // + // #96: the constants are the parent's edge INSETS. Pinning to + // the bare anchors is what made a container child ignore the + // padding a leaf child gets for free from NSStackView's own + // arranged-subview layout, so a heading sat 12px in and the + // row under it did not — every inspector panel misaligned by + // exactly the padding. Tagged so set_edge_insets can update + // them when styles are applied AFTER the tree is built, which + // is the usual order (`apply_styles` at the end of a block). + NSEdgeInsets pins = [sv edgeInsets]; + NSLayoutConstraint* lead = + [child.leadingAnchor constraintEqualToAnchor:sv.leadingAnchor + constant:pins.left]; + [lead setIdentifier:@"aeui-inset-lead"]; + lead.active = YES; + + NSLayoutConstraint* cap = + [child.trailingAnchor constraintLessThanOrEqualToAnchor:sv.trailingAnchor + constant:-pins.right]; + [cap setIdentifier:@"aeui-inset-trail-max"]; + cap.active = YES; + NSLayoutConstraint* stretch = - [child.trailingAnchor constraintEqualToAnchor:sv.trailingAnchor]; + [child.trailingAnchor constraintEqualToAnchor:sv.trailingAnchor + constant:-pins.right]; + [stretch setIdentifier:@"aeui-inset-trail"]; stretch.priority = NSLayoutPriorityDefaultHigh; stretch.active = YES; } diff --git a/backend/aether_ui_win32.c b/backend/aether_ui_win32.c index a769e379..535f15e5 100644 --- a/backend/aether_ui_win32.c +++ b/backend/aether_ui_win32.c @@ -5854,7 +5854,10 @@ typedef struct { float cr, cg, cb, calpha; int cap, join; // STROKE and gradient STROKE: 0=butt/miter 1=round 2=square/bevel char* text; // FILL_TEXT string (owned) - unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer (owned) + unsigned char* pixels; // DRAW_IMAGE RGBA8888 buffer + /* #102: 0 = owned and freed with the command, 1 = borrowed from the + caller until the next canvas_clear. */ + int pixels_borrowed; int iw, ih; // DRAW_IMAGE pixel dims // Gradient: linear (gx1,gy1)→(gx2,gy2); radial center (gx1,gy1) r gr. float gx1, gy1, gx2, gy2, gr, gfx, gfy; @@ -6303,6 +6306,38 @@ void aether_ui_canvas_draw_image_impl(int canvas_id, double x, double y, // source-pixel size). Dest extent rides p2/p3 and the executor hands // StretchDIBits a dest rect of that size; GDI scales natively, matching // GTK4's cairo path. +/* #102: draw WITHOUT copying. The pixels stay the caller's and must remain + * valid until the next canvas_clear on this canvas — the lifetime the + * retained command list already has. That is what a per-frame surface (a 3D + * viewport, a video frame, a game framebuffer) already guarantees: one buffer, + * overwritten in place, canvas cleared and redrawn each frame. The owning + * variant copies the whole framebuffer per call, which measured 61% of the + * frame at 918x659. A caller that cannot promise the lifetime keeps using the + * owning variant. */ +void aether_ui_canvas_draw_image_borrowed_impl(int canvas_id, double x, double y, + int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + CanvasCmd c = {0}; + c.k = CV_DRAW_IMAGE; c.p0 = x; c.p1 = y; + c.pixels = (unsigned char*)rgba; c.pixels_borrowed = 1; + c.iw = iw; c.ih = ih; + canvas_add_cmd(canvas_id, c); +} + +void aether_ui_canvas_draw_image_scaled_borrowed_impl(int canvas_id, double x, double y, + double dw, double dh, int iw, int ih, + const unsigned char* rgba, int byte_len) { + if (iw <= 0 || ih <= 0 || !rgba) return; + if (byte_len < iw * ih * 4) return; + CanvasCmd c = {0}; + c.k = CV_DRAW_IMAGE; c.p0 = x; c.p1 = y; c.p2 = dw; c.p3 = dh; + c.pixels = (unsigned char*)rgba; c.pixels_borrowed = 1; + c.iw = iw; c.ih = ih; + canvas_add_cmd(canvas_id, c); +} + void aether_ui_canvas_draw_image_scaled_impl(int canvas_id, double x, double y, double dw, double dh, int iw, int ih, const unsigned char* rgba, int byte_len) { @@ -6370,7 +6405,9 @@ static void canvas_free_text(int canvas_id) { } if (c->font_family) { free(c->font_family); c->font_family = NULL; } if (c->k == CV_DRAW_IMAGE && c->pixels) { - free(c->pixels); c->pixels = NULL; + /* #102: a borrowed buffer belongs to the caller. */ + if (!c->pixels_borrowed) free(c->pixels); + c->pixels = NULL; } if (c->k == CV_FILL_LINEAR || c->k == CV_FILL_RADIAL) { free(c->stop_off); c->stop_off = NULL; diff --git a/ci.sh b/ci.sh index d00319d9..b47a0f2a 100755 --- a/ci.sh +++ b/ci.sh @@ -65,7 +65,7 @@ fi # ------------------------------------------------------------------------- # All examples that must compile in Phase 1. -EXAMPLES=(disclosure_demo icons_demo pills_demo textpath_demo counter form picker styled system canvas testable calculator context_menu overlay_demo vg_tooltip each_demo rebuild_demo fileicon_demo scrollbg_demo keyhandler_demo imagefill_demo filedrop_demo barfill_demo listbox_demo table_demo transitions_demo split_demo bindings_demo tabs_demo menu rbind_demo typo_demo multiselect_demo dblclick_demo tree_demo tabledeleg_demo weightclamp_demo shortcut_demo polish_demo vlist_demo wshortcut_demo multiwindow_demo timer_demo canvasscroll_demo canvasclip_demo canvasresetclip_demo resizecb_demo quit_demo panelsize_demo groupalpha_demo hoverpaint_demo gradspread_demo placeholder_demo multikey_demo sheet_demo winmenu_demo reorder_demo overlaytr_demo a11y_demo material_demo themes_demo csssem_demo zen_demo states_demo undo_demo roles_demo command_demo clipboard window_title) +EXAMPLES=(disclosure_demo icons_demo pills_demo textpath_demo counter form picker styled system canvas testable calculator context_menu overlay_demo vg_tooltip each_demo rebuild_demo fileicon_demo scrollbg_demo keyhandler_demo imagefill_demo filedrop_demo barfill_demo listbox_demo table_demo transitions_demo split_demo bindings_demo tabs_demo menu rbind_demo typo_demo multiselect_demo dblclick_demo tree_demo tabledeleg_demo weightclamp_demo shortcut_demo polish_demo vlist_demo wshortcut_demo multiwindow_demo timer_demo canvasscroll_demo canvasclip_demo canvasresetclip_demo resizecb_demo quit_demo panelsize_demo insets_demo blitborrow_demo groupalpha_demo hoverpaint_demo gradspread_demo placeholder_demo multikey_demo sheet_demo winmenu_demo reorder_demo overlaytr_demo a11y_demo material_demo themes_demo csssem_demo zen_demo states_demo undo_demo roles_demo command_demo clipboard window_title) # Examples without a test server — Phase 2 smoke-launches each. # calculator and testable are exercised through their HTTP drivers in # Phases 3-4, so they are not smoke-tested here. @@ -976,6 +976,39 @@ if [ "$SPEC_OK" -eq 1 ]; then FAIL=$((FAIL + 1)) fi + # A container child must get the same content box as a leaf child. Both + # read back through get_width, so no driver is needed. The panel is 400 + # wide with 12px side insets: the row must measure 376. Before the fix it + # measured the full 400, hanging 12px outside the padding every leaf + # child respected. + echo "-- Phase 5e23: a container child sits inside the parent's insets --" + run_self_quitting "$(EX_BIN insets_demo)" insets_demo 30 + ins_rc=$? + ins_out=$(cat /tmp/ci_insets_demo.selfquit.log 2>/dev/null) + if [ "$ins_rc" -eq 0 ] && printf '%s' "$ins_out" | grep -q "row_w=376"; then + echo " OK insets_demo: container child inset like a leaf (376)" + else + echo " FAIL insets_demo: rc=$ins_rc" + printf '%s\n' "$ins_out" | grep -a "row_w" | sed 's/^/ /' \ + || echo " (no width line printed)" + FAIL=$((FAIL + 1)) + fi + + # The borrowed blit skips the per-frame copy; it is only worth having if + # it draws the SAME pixels. The demo blits one image both ways and reads + # back two of them, so this asserts the picture, not the speed. + echo "-- Phase 5e24: a borrowed blit draws what the copying blit draws --" + run_self_quitting "$(EX_BIN blitborrow_demo)" blitborrow_demo 30 + blit_rc=$? + blit_out=$(cat /tmp/ci_blitborrow_demo.selfquit.log 2>/dev/null) + if [ "$blit_rc" -eq 0 ] && printf '%s' "$blit_out" | grep -q "^MATCH$"; then + echo " OK blitborrow_demo: borrowed and copied blits agree" + else + echo " FAIL blitborrow_demo: rc=$blit_rc" + printf '%s\n' "$blit_out" | grep -aE "owned|borrow" | sed 's/^/ /' + FAIL=$((FAIL + 1)) + fi + echo "-- Phase 5e19: canvas_reset_clip widens the clip back --" UI_SPEC=canvasresetclip_demo/spec_canvasresetclip_demo \ run_server_test "$(EX_BIN canvasresetclip_demo)" \ diff --git a/examples/blitborrow_demo/.build.ae b/examples/blitborrow_demo/.build.ae new file mode 100644 index 00000000..5f10dba8 --- /dev/null +++ b/examples/blitborrow_demo/.build.ae @@ -0,0 +1,18 @@ +// blitborrow_demo — aether-ui toolkit example, built as its own cached aeb node. +import bldr +import aether +import aether (source, output, no_closure_regen) +import build_support.aetherui (ui_backend) + +main() { + bldr.build() { + root = _get("root") + aether.program() { + source("blitborrow_demo.ae") + output("blitborrow_demo") + no_closure_regen() + ui_backend(root) + } + return 0 + } +} diff --git a/examples/blitborrow_demo/blitborrow_demo.ae b/examples/blitborrow_demo/blitborrow_demo.ae new file mode 100644 index 00000000..455f7dd9 --- /dev/null +++ b/examples/blitborrow_demo/blitborrow_demo.ae @@ -0,0 +1,74 @@ +// #102 — a borrowed blit must draw exactly what the copying blit draws. +// +// canvas_draw_image_ptr allocates and copies the whole image on every call. +// For a surface redrawn every frame that measured 61% of the frame, more than +// reading the frame off the GPU. The borrowed variant keeps the caller's +// buffer instead of copying it. +// +// Speed is worthless if the pixels are wrong, so this asserts the PIXELS: the +// same buffer drawn both ways must read back identically. + +import ui +import ui (root_vstack, text, canvas_create, canvas_clear, canvas_draw_image_ptr, + canvas_draw_image_borrowed_ptr, canvas_read_pixel, app_quit, timer, + timer_cancel, enable_test_server_root) +import std.bytes +import std.os (os_getenv) + +// A 4x4 RGBA image: red, with one green pixel at (1,1). +build_image() -> ptr { + b = bytes.new(4 * 4 * 4) + bytes.set_length(b, 4 * 4 * 4) + i = 0 + while i < 16 { + bytes.set(b, i * 4 + 0, 255) + bytes.set(b, i * 4 + 1, 0) + bytes.set(b, i * 4 + 2, 0) + bytes.set(b, i * 4 + 3, 255) + i = i + 1 + } + // (1,1) is index 5: make it green. + bytes.set(b, 5 * 4 + 0, 0) + bytes.set(b, 5 * 4 + 1, 255) + bytes.set(b, 5 * 4 + 2, 0) + return b +} + +main() { + cv = 0 + main_root = root_vstack(0) { + _t = text("blit") + cv = canvas_create(4, 4) + } + img_b = build_image() + // The _ptr blits take a RAW buffer; a bytes object carries a header, so + // hand over its data pointer, not the object. + img = bytes.data(img_b) + + app = ui.app_create("blit borrow", 120, 120) + if os_getenv("AETHER_UI_TEST_PORT") != null { + enable_test_server_root(9222, main_root) + } + + tid = 0 + tid = timer(120) callback { + timer_cancel(tid) + // Copying blit first. + canvas_clear(cv) + canvas_draw_image_ptr(cv, 0.0, 0.0, 4, 4, img, 64) + owned_00 = canvas_read_pixel(cv, 0, 0, 4, 4) + owned_11 = canvas_read_pixel(cv, 1, 1, 4, 4) + + // Same buffer, borrowed. + canvas_clear(cv) + canvas_draw_image_borrowed_ptr(cv, 0.0, 0.0, 4, 4, img, 64) + borrow_00 = canvas_read_pixel(cv, 0, 0, 4, 4) + borrow_11 = canvas_read_pixel(cv, 1, 1, 4, 4) + + println("owned 00=${owned_00} 11=${owned_11}") + println("borrow 00=${borrow_00} 11=${borrow_11}") + if owned_00 == borrow_00 { if owned_11 == borrow_11 { println("MATCH") } } + app_quit() + } + ui.app_start(app, main_root) +} diff --git a/examples/insets_demo/.build.ae b/examples/insets_demo/.build.ae new file mode 100644 index 00000000..471db153 --- /dev/null +++ b/examples/insets_demo/.build.ae @@ -0,0 +1,18 @@ +// insets_demo — aether-ui toolkit example, built as its own cached aeb node. +import bldr +import aether +import aether (source, output, no_closure_regen) +import build_support.aetherui (ui_backend) + +main() { + bldr.build() { + root = _get("root") + aether.program() { + source("insets_demo.ae") + output("insets_demo") + no_closure_regen() + ui_backend(root) + } + return 0 + } +} diff --git a/examples/insets_demo/insets_demo.ae b/examples/insets_demo/insets_demo.ae new file mode 100644 index 00000000..43129e62 --- /dev/null +++ b/examples/insets_demo/insets_demo.ae @@ -0,0 +1,57 @@ +// #96 — a container child must sit inside the parent's insets, like a leaf does. +// +// A leaf child (text) was laid out inside st_insets; a container child +// (hstack, divider) was pinned to the stack's raw edges and ignored them. A +// heading sat 12px in and the row under it did not, which is every inspector +// panel in the toolkit misaligned by exactly the padding. +// +// The app reports the x of a leaf and of a container child, so the assertion +// is the two numbers agreeing. + +import ui +import ui (root_vstack, vstack, hstack, text, divider, add_css_class, + create_styles, st_insets, st_bg, apply_styles, get_width, app_quit, + timer, timer_cancel, set_text, enable_test_server_root) +import std.os (os_getenv) + +main() { + panel = 0 + leaf = 0 + row = 0 + report = 0 + + main_root = root_vstack(0) { + report = text("insets: pending") + panel = vstack(0) { + leaf = text("leaf child") + row = hstack(8) { + _t = text("inside a container child") + } + _d = divider() + } + } + + sheet = create_styles() + st_insets(sheet, "panel", 10.0, 12.0, 10.0, 12.0) + st_bg(sheet, "panel", 0x21252B) + add_css_class(panel, "panel") + // Applied AFTER the tree is built, which is the usual order and the case + // that needs the constraint constants updated. + apply_styles(0, sheet) + + app = ui.app_create("insets", 400, 300) + if os_getenv("AETHER_UI_TEST_PORT") != null { + enable_test_server_root(9222, main_root) + } + tid = 0 + tid = timer(150) callback { + timer_cancel(tid) + lw = get_width(leaf) + rw = get_width(row) + // The row must get the same content box as the leaf: parent width + // minus both insets. Report both widths; equal means aligned. + println("leaf_w=${lw} row_w=${rw}") + app_quit() + } + ui.app_start(app, main_root) +} diff --git a/ui/module.ae b/ui/module.ae index 9b78a934..3a6e23cb 100644 --- a/ui/module.ae +++ b/ui/module.ae @@ -103,6 +103,7 @@ exports ( canvas_set_clip_rects, canvas_reset_clip, canvas_clear, canvas_redraw, canvas_write_png, canvas_cmd_count, canvas_render_range_rgba, canvas_draw_image_ptr, + canvas_draw_image_borrowed_ptr, canvas_draw_image_scaled_borrowed_ptr, canvas_draw_image_scaled_ptr, canvas_arc, canvas_close_path, canvas_fill, canvas_fill_text, canvas_stroke_text, canvas_group_begin, canvas_group_end, canvas_read_pixel, @@ -485,6 +486,10 @@ extern aether_ui_canvas_draw_image_scaled_impl_ptr(canvas_id: int, x: float, y: dw: float, dh: float, iw: int, ih: int, rgba: ptr, byte_len: int) extern aether_ui_canvas_draw_image_impl_ptr(canvas_id: int, x: float, y: float, iw: int, ih: int, rgba: ptr, byte_len: int) +extern aether_ui_canvas_draw_image_borrowed_impl(canvas_id: int, x: float, y: float, + iw: int, ih: int, rgba: ptr, byte_len: int) +extern aether_ui_canvas_draw_image_scaled_borrowed_impl(canvas_id: int, x: float, y: float, + dw: float, dh: float, iw: int, ih: int, rgba: ptr, byte_len: int) extern aether_ui_canvas_render_range_rgba_impl(canvas_id: int, start: int, end: int, ox: float, oy: float, width: int, height: int, @@ -4749,6 +4754,33 @@ canvas_draw_image_scaled_ptr(cid: int, x: float, y: float, dw: float, dh: float, aether_ui_canvas_draw_image_scaled_impl_ptr(cid, x, y, dw, dh, iw, ih, rgba, byte_len) } +// canvas_draw_image_borrowed_ptr — blit WITHOUT copying (#102). +// +// The owning blits above allocate and copy 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. +// Measured on a 918x659 viewport it was 61% of the frame, more than reading +// the frame back off the GPU and roughly six times the cost of rendering it. +// +// CONTRACT, and it is a sharp one: the pixels stay YOURS. They must remain +// valid and unchanged until the next canvas_clear on this canvas, because the +// canvas retains the command and reads the buffer when it paints. That is +// exactly what a per-frame surface already does — one buffer, overwritten in +// place, canvas cleared and redrawn each frame. If your buffer can be freed +// or resized before the next clear, use canvas_draw_image_ptr and take the +// copy. +canvas_draw_image_borrowed_ptr(cid: int, x: float, y: float, iw: int, ih: int, + rgba: ptr, byte_len: int) { + aether_ui_canvas_draw_image_borrowed_impl(cid, x, y, iw, ih, rgba, byte_len) +} + +// Scaled twin of canvas_draw_image_borrowed_ptr. Same lifetime contract. +canvas_draw_image_scaled_borrowed_ptr(cid: int, x: float, y: float, + dw: float, dh: float, iw: int, ih: int, + rgba: ptr, byte_len: int) { + aether_ui_canvas_draw_image_scaled_borrowed_impl(cid, x, y, dw, dh, iw, ih, rgba, byte_len) +} + canvas_cmd_count(cid: int) -> int { return aether_ui_canvas_cmd_count_impl(cid) }