From 3d22553ee94d5e811daa04061c5027a6163e9de8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Fri, 4 Sep 2026 16:13:40 -0300 Subject: [PATCH 1/3] fix(ui): ui.timer keeps firing during modal event tracking Checkpoint before further work. --- backend/aether_ui_macos.m | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/aether_ui_macos.m b/backend/aether_ui_macos.m index b8bb6315..314bbb73 100644 --- a/backend/aether_ui_macos.m +++ b/backend/aether_ui_macos.m @@ -3266,6 +3266,15 @@ int aether_ui_timer_create_impl(int interval_ms, void* boxed_closure) { selector:@selector(tick:) userInfo:nil repeats:YES]; + /* #97: also run during modal event tracking. -scheduledTimer... registers + * in NSDefaultRunLoopMode alone, and AppKit runs slider and scrollbar + * drags, menu tracking and live window resize in NSEventTrackingRunLoopMode, + * where a default-mode timer does not fire. An app that paints a surface + * from ui.timer therefore froze for the whole duration of any drag — the + * viewport stops the moment you grab the control that is meant to move it. + * The toolkit's own internal 60Hz timer already does this; the public one + * did not. */ + [[NSRunLoop mainRunLoop] addTimer:t.timer forMode:NSRunLoopCommonModes]; [active_timers addObject:t]; return (int)[active_timers count]; // 1-based id } From 360618793684409ae8857b3af7f4dbd790e61d48 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Fri, 4 Sep 2026 17:12:07 -0300 Subject: [PATCH 2/3] fix(ui): keep flexible children inside their parent, and let weight win The geometry a caller reads back is now the distance between two rounded edges, never a rounded size. A stack divides its width exactly (96.5 + 6 + 97 + 6 + 96.5 fills a 302px row), but rounding each child's size on its own reported 97/97/97 for a total of 303, so the last child looked like it hung a pixel past the row and "every widget fits inside its parent", the one invariant a layout audit wants to trust, was false by a pixel and compounded with nesting. Edge rounding makes adjacent children tile: one child's trailing edge is the next one's leading edge, so a 302px row reads back as 97 + 97 + 96. This covers get_width and get_height and the /widgets and /widget/N geometry the driver reports, on AppKit and on GTK4, where the same independent rounding truncated instead and under-reported. Win32 already derived its sizes from integer edges. Found while confirming the weighted half of the same report: weight() on buttons was silently overridden by the equal-width chain a row of buttons gets by default. That chain is a required constraint and the proportional shares are not, so weights of 16 / 62 / 22 came out 500/500/500 in a 1500px row. A weight is an explicit instruction to divide space unevenly, so it now retracts the chain for that child exactly as an explicit width() already did, and the same weights now give 240/930/330, summing to the row exactly. ui.timer also keeps firing while a slider or scrollbar is being dragged, a menu is open, or a window is resized live. scheduledTimer registers in the default run-loop mode alone, and AppKit runs all of that tracking in NSEventTrackingRunLoopMode, where a default-mode timer never fires, so an animation or a clock stopped dead for as long as the mouse was held down. flexround_demo and its driver spec assert the invariant rather than per-backend pixel counts, so they mean the same thing on AppKit and GTK4. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 ++++++++ backend/aether_ui_gtk4.c | 19 +++++- backend/aether_ui_macos.m | 75 ++++++++++++++++----- ci.sh | 5 +- examples/flexround_demo/.build.ae | 18 +++++ examples/flexround_demo/flexround_demo.ae | 34 ++++++++++ tests/flexround_demo/spec_flexround_demo.ae | 74 ++++++++++++++++++++ ui/module.ae | 10 +++ 8 files changed, 241 insertions(+), 21 deletions(-) create mode 100644 examples/flexround_demo/.build.ae create mode 100644 examples/flexround_demo/flexround_demo.ae create mode 100644 tests/flexround_demo/spec_flexround_demo.ae diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5f0de9..d6275b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `ui.timer` keeps firing while a slider or a scrollbar is being dragged, a + menu is open, or a window is being resized live. `scheduledTimer` registers + in the default run-loop mode alone, and AppKit runs all of that tracking in + `NSEventTrackingRunLoopMode`, where a default-mode timer does not fire: an + animation or a clock stopped dead for as long as the mouse was held down and + jumped on release. The timer now goes into the common modes. + +- The geometry a caller reads back keeps flexible children inside their parent. + A stack divides its width exactly (96.5 + 6 + 97 + 6 + 96.5 fills a 302px + row), but the readback rounded each child's SIZE on its own, reporting + 97/97/97 for a total of 303, so the last child looked like it hung a pixel + past the row and "every widget fits inside its parent" was false by one + pixel, compounding with nesting. Sizes are now the distance between two + ROUNDED EDGES, so adjacent children tile: one child's trailing edge is the + next one's leading edge, and three children of a 302px row report 97/97/96. + This covers `get_width` and `get_height` and the `/widgets` and `/widget/N` + geometry the driver reports, on AppKit and on GTK4, where the same + independent rounding truncated instead and under-reported. Win32 already + derived its sizes from integer edges. + +- `weight()` on buttons is no longer overridden by the equal-width chain that + a row of buttons gets by default. The chain is a required constraint and the + proportional shares are not, so weights of 16 / 62 / 22 came out 500/500/500 + in a 1500px row with no diagnostic. A weight is an explicit instruction to + divide space unevenly, so it now retracts the chain for that child, exactly + as an explicit `width()` already did. + - Every spec returns its `run_summary` verdict again. A previous change in this series stripped the `return` from 83 of them on the strength of a local `~/.aether` stdlib where `run_summary` was void and called `exit(1)` — a diff --git a/backend/aether_ui_gtk4.c b/backend/aether_ui_gtk4.c index b8739a2f..089169d8 100644 --- a/backend/aether_ui_gtk4.c +++ b/backend/aether_ui_gtk4.c @@ -12,6 +12,7 @@ #include #include #include +#include #ifdef AEUI_HAVE_LIBNOTIFY #include @@ -7549,14 +7550,28 @@ static int hook_widget_children(int handle, int* out_handles, int max) { } // Window-local geometry. 0 = success, per the hook contract. +/* CRITICAL for #101: a reported size is the distance between two ROUNDED + * EDGES, never the rounded size. Under fractional scaling a row of flexible + * children sits on fractional bounds that tile their parent exactly; rounding + * each child's size on its own breaks that, so the reported parts no longer + * add up to the whole and "every widget fits inside its parent" stops being a + * check a layout audit can trust. Rounding the edges keeps one child's + * trailing edge equal to the next one's leading edge. */ +static int aeui_round_edge(float v) { + return (int)floor((double)v + 0.5); +} + static int hook_widget_rect(int handle, int* x, int* y, int* w, int* hgt) { GtkWidget* wd = aether_ui_get_widget(handle); if (!wd) return 1; graphene_rect_t r; GtkWidget* root = GTK_WIDGET(gtk_widget_get_root(wd)); if (!root || !gtk_widget_compute_bounds(wd, root, &r)) return 1; - *x = (int)r.origin.x; *y = (int)r.origin.y; - *w = (int)r.size.width; *hgt = (int)r.size.height; + int x0 = aeui_round_edge(r.origin.x); + int y0 = aeui_round_edge(r.origin.y); + *x = x0; *y = y0; + *w = aeui_round_edge(r.origin.x + r.size.width) - x0; + *hgt = aeui_round_edge(r.origin.y + r.size.height) - y0; return 0; } diff --git a/backend/aether_ui_macos.m b/backend/aether_ui_macos.m index 314bbb73..f56a9dad 100644 --- a/backend/aether_ui_macos.m +++ b/backend/aether_ui_macos.m @@ -1595,6 +1595,25 @@ int aether_ui_split_position_impl(int handle) { * The constraint is created once and updated afterwards, so repeated calls do * not pile up conflicting constraints on the same view. Priority is just * below required, leaving a user's divider drag able to win. */ +// Drop any equal-width chain this view is part of. A table header sets an +// explicit per-column width; leaving the button-row equality in place makes +// the two required constraints unsatisfiable, and Auto Layout resolves that +// by breaking one — silently, and with the wrong column widths surviving. +// +// Every call that gives a child a width of its own has to do this, which is +// why it is a function and not two copies: an equality left standing outranks +// both an explicit width and a weight, since it is required and they are not. +static void aeui_drop_btneq(NSView* v) { + NSView* p = v ? [v superview] : nil; + if (!p) return; + NSMutableArray* drop = [NSMutableArray array]; + for (NSLayoutConstraint* c in [p constraints]) { + if (![[c identifier] isEqualToString:@"aeui-btneq"]) continue; + if (c.firstItem == v || c.secondItem == v) [drop addObject:c]; + } + if ([drop count]) [p removeConstraints:drop]; +} + static void aeui_pin_size(NSView* v, const char* key, int px, int vertical) { if (!v) return; NSLayoutConstraint* c = objc_getAssociatedObject(v, key); @@ -1624,16 +1643,36 @@ void aether_ui_set_height_impl(int handle, int px) { "aeui_height_c", px, 1); } +/* CRITICAL for #101: round the frame's EDGES, never its size. Auto Layout + * places views on half-point boundaries so a row of flexible children tiles + * its parent exactly (96.5 + 6 + 97 + 6 + 96.5 = 302). Rounding each size on + * its own turns that into 97 + 6 + 97 + 6 + 97 = 303, so the reported parts + * come to more than the whole and a caller laying out against these numbers + * pushes the last child past the parent's edge. Rounding the edges keeps + * adjacent children tiling (97, 97, 96) because one child's rounded trailing + * edge is the next one's rounded leading edge. + * + * floor(x + 0.5), not lround: lround rounds half AWAY from zero, so a view at + * a negative offset (a scrolled document view) would round its two edges in + * opposite directions and gain a point. */ +static int aeui_round_extent(CGFloat lo, CGFloat hi) { + long a = (long)floor((double)lo + 0.5); + long b = (long)floor((double)hi + 0.5); + return (int)(b - a); +} + int aether_ui_get_width_impl(int handle) { NSView* v = (__bridge NSView*)aether_ui_get_widget(handle); if (!v) return 0; - return (int)lround([v frame].size.width); + NSRect f = [v frame]; + return aeui_round_extent(NSMinX(f), NSMaxX(f)); } int aether_ui_get_height_impl(int handle) { NSView* v = (__bridge NSView*)aether_ui_get_widget(handle); if (!v) return 0; - return (int)lround([v frame].size.height); + NSRect f = [v frame]; + return aeui_round_extent(NSMinY(f), NSMaxY(f)); } void aether_ui_split_set_position_impl(int handle, int px) { @@ -1744,6 +1783,12 @@ void aether_ui_widget_weight_impl(int handle, int n) { NSView* v = (__bridge NSView*)aether_ui_get_widget(handle); if (!v) return; widget_weights[handle - 1] = n; + // A weight is an explicit instruction to share space unevenly, so it has to + // outrank the equal-width chain buttons get by default. It did not: the + // chain is required and the flex multipliers are not, so weight(16/62/22) + // on three buttons came out 500/500/500 in a 1500px row, with no + // diagnostic. set_width already retracted the chain for the same reason. + aeui_drop_btneq(v); NSView* parent = [v superview]; if ([parent isKindOfClass:[NSStackView class]]) { aeui_apply_flex((NSStackView*)parent); @@ -2897,19 +2942,7 @@ void aether_ui_set_width(int handle, int width) { if (!v) return; [v setTranslatesAutoresizingMaskIntoConstraints:NO]; - // Drop any equal-width chain this view is part of. A table header sets an - // explicit per-column width; leaving the button-row equality in place makes - // the two required constraints unsatisfiable, and Auto Layout resolves that - // by breaking one — silently, and with the wrong column widths surviving. - NSView* p = [v superview]; - if (p) { - NSMutableArray* drop = [NSMutableArray array]; - for (NSLayoutConstraint* c in [p constraints]) { - if (![[c identifier] isEqualToString:@"aeui-btneq"]) continue; - if (c.firstItem == v || c.secondItem == v) [drop addObject:c]; - } - if ([drop count]) [p removeConstraints:drop]; - } + aeui_drop_btneq(v); // On a weighted child, width() is a FLOOR (>=), not a fixed size: the flex // share fills above it, clamping to this min only when space is tight. A @@ -6823,10 +6856,16 @@ static int hook_widget_rect(int handle, int* x, int* y, int* w, int* hgt) { r = [v convertRect:[v bounds] toView:content]; } CGFloat ch = [content bounds].size.height; + /* #101: sizes come from the ROUNDED EDGES, so a row of flexible + * children reported here still tiles its parent. Rounding the size on + * its own reported three 96.5/97 frames in a 302px row as 97/97/97, + * and "every widget fits inside its parent", the one invariant a + * layout audit wants to trust, came out false by a pixel. */ + CGFloat top = ch - (r.origin.y + r.size.height); rx = (int)lround(r.origin.x); - ry = (int)lround(ch - (r.origin.y + r.size.height)); // bottom-left → top-left - rw = (int)lround(r.size.width); - rh = (int)lround(r.size.height); + ry = (int)lround(top); // bottom-left → top-left + rw = aeui_round_extent(r.origin.x, r.origin.x + r.size.width); + rh = aeui_round_extent(top, top + r.size.height); rc = 0; }; if ([NSThread isMainThread]) compute(); diff --git a/ci.sh b/ci.sh index b47a0f2a..be32a76c 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 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=(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 flexround_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. @@ -1173,6 +1173,9 @@ if [ "$SPEC_OK" -eq 1 ]; then UI_SPEC=weightclamp_demo/spec_weightclamp_demo \ run_server_test "$(EX_BIN weightclamp_demo)" \ "$SCRIPT_DIR/tests/run_spec.sh" weightclamp_demo || FAIL=$((FAIL + 1)) + UI_SPEC=flexround_demo/spec_flexround_demo \ + run_server_test "$(EX_BIN flexround_demo)" \ + "$SCRIPT_DIR/tests/run_spec.sh" flexround_demo || FAIL=$((FAIL + 1)) UI_SPEC=shortcut_demo/spec_shortcut_demo \ run_server_test "$(EX_BIN shortcut_demo)" \ "$SCRIPT_DIR/tests/run_spec.sh" shortcut_demo || FAIL=$((FAIL + 1)) diff --git a/examples/flexround_demo/.build.ae b/examples/flexround_demo/.build.ae new file mode 100644 index 00000000..665a78a6 --- /dev/null +++ b/examples/flexround_demo/.build.ae @@ -0,0 +1,18 @@ +// flexround_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("flexround_demo.ae") + output("flexround_demo") + no_closure_regen() + ui_backend(root) + } + return 0 + } +} diff --git a/examples/flexround_demo/flexround_demo.ae b/examples/flexround_demo/flexround_demo.ae new file mode 100644 index 00000000..26d27671 --- /dev/null +++ b/examples/flexround_demo/flexround_demo.ae @@ -0,0 +1,34 @@ +// Aether UI flex rounding: flexible children fit inside their parent. +// +// Row 1: a 302px row with two 6px gaps leaves 290 for three buttons, 96.67 +// each. Auto Layout places them exactly (96.5 + 6 + 97 + 6 + 96.5 = 302); the +// geometry a caller reads back has to agree, and not round each width up on +// its own into a 303px total that overflows the row. +// +// Row 2: weights 16 / 62 / 22 divide the row proportionally, and the shares +// still add up to exactly the row. +import ui +import ui (window, vstack, hstack, btn, weight, width, enable_test_server) +import std.os (os_getenv) + +main() { + window("flex rounding", 1500, 300) { + vstack(12) { + row = hstack(6) { + b1 = btn("One") callback { } + b2 = btn("Two") callback { } + b3 = btn("Three") callback { } + } + width(row, 302) + prow = hstack(0) { + p1 = btn("Left") callback { } + weight(p1, 16) + p2 = btn("Centre") callback { } + weight(p2, 62) + p3 = btn("Right") callback { } + weight(p3, 22) + } + } + if os_getenv("AETHER_UI_TEST_PORT") != null { enable_test_server(9222) } + } +} diff --git a/tests/flexround_demo/spec_flexround_demo.ae b/tests/flexround_demo/spec_flexround_demo.ae new file mode 100644 index 00000000..3b1ed3a5 --- /dev/null +++ b/tests/flexround_demo/spec_flexround_demo.ae @@ -0,0 +1,74 @@ +// spec_flexround_demo.ae, #101: flexible children fit inside their parent. +// +// The invariant a layout audit wants to be able to trust is "every widget +// fits inside its parent". It was false by a pixel: the geometry a caller +// reads back rounded each child's WIDTH on its own instead of rounding its +// edges, so three children of a 302px row reported 97/97/97 and came to 303. +// +// Every assertion here is a relationship between the reported numbers, not a +// per-backend pixel count, so it means the same thing on AppKit and GTK4. +import std.spec +import uidriver + +width_of(label: string) -> int { + return uidriver.widget_int_field_by_id(uidriver.widget_id_by_text(label), "w") +} + +right_of(label: string) -> int { + id = uidriver.widget_id_by_text(label) + x = uidriver.widget_int_field_by_id(id, "x") + w = uidriver.widget_int_field_by_id(id, "w") + return x + w +} + +main() { + fw = spec.init() + uidriver.wait_server() + + spec.describe(fw, "flexible children fit their parent (#101)") { + spec.it("three buttons plus the gaps do not exceed the 302px row") callback { + w1 = width_of("One") + w2 = width_of("Two") + w3 = width_of("Three") + spec.assert_true(w1 > 0, "the buttons were found (One is ${w1} wide)") + total = w1 + w2 + w3 + 12 + spec.assert_true(total <= 302, + "children + gaps fit the row (got ${total} from ${w1}/${w2}/${w3})") + } + + spec.it("the last button's right edge stays inside the row") callback { + id = uidriver.widget_id_by_text("One") + left = uidriver.widget_int_field_by_id(id, "x") + spec.assert_true(right_of("Three") <= left + 302, + "the last child does not hang past the row's right edge") + } + + spec.it("the remainder is spread, not dumped on one child") callback { + w1 = width_of("One") + w3 = width_of("Three") + d = w1 - w3 + if d < 0 { d = 0 - d } + spec.assert_true(d <= 1, + "widest and narrowest differ by at most a pixel (${w1} vs ${w3})") + } + + spec.it("weighted children divide their row proportionally") callback { + q1 = width_of("Left") + q2 = width_of("Centre") + q3 = width_of("Right") + row = q1 + q2 + q3 + spec.assert_true(row > 0, "the weighted row has a width (got ${row})") + // 16 / 62 / 22. Compare shares against the row the panes actually + // add up to, so the check does not depend on the window's size. + spec.assert_true(q2 > q3, "the 62-weight pane is the widest (${q2} vs ${q3})") + spec.assert_true(q3 > q1, "the 22-weight pane beats the 16 (${q3} vs ${q1})") + tol = row / 20 + e2 = (row * 62) / 100 + d2 = q2 - e2 + if d2 < 0 { d2 = 0 - d2 } + spec.assert_true(d2 <= tol, + "the 62-weight pane got ~62% of ${row} (got ${q2}, expected ~${e2})") + } + } + return spec.run_summary(fw) +} diff --git a/ui/module.ae b/ui/module.ae index 3a6e23cb..8fcb15d4 100644 --- a/ui/module.ae +++ b/ui/module.ae @@ -797,6 +797,10 @@ split_set_position(handle: int, px: int) { // feeding it into the stack measure pass. spec_weightclamp_demo asserts the // min-clamp on macOS and Linux in CI. This comment claimed "GTK4 only" long // after the other two landed, the same rot on_layout's comment records. +// +// An explicit weight also retracts the equal-width chain a row of buttons gets +// by default: that chain is a hard constraint and the proportional shares are +// not, so without this weights of 16 / 62 / 22 came out as an even split. weight(handle: int, n: int) { aether_ui_widget_weight_impl(handle, n) } @@ -3011,6 +3015,12 @@ set_height(handle: int, px: int) -> int { // get_width(h) / get_height(h) — what the widget ACTUALLY got, read from its // allocation rather than from whatever was requested. 0 before the first // layout pass. +// +// The size is the distance between the widget's two rounded edges, so a row of +// flexible children still tiles its parent: three children of a 302px row with +// two 6px gaps read back as 97 + 97 + 96, not 97 + 97 + 97. Rounding each size +// on its own would make the parts add up to more than the whole and put the +// last child a pixel outside the parent (#101). get_width(handle: int) -> int { return aether_ui_get_width_impl(handle) } From 7252d72f8e0951a20a7d245c2fa27c70977cd52c Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Fri, 4 Sep 2026 17:26:37 -0300 Subject: [PATCH 3/3] test(ui): assert the tiling invariant, not AppKit's equal-width rows The new spec claimed the three buttons end up within a pixel of each other. That is AppKit behaviour: an equal-width chain is what gives a row of buttons its grid-like look there, while GTK4 sizes each button to its own label, so the row came out 60 / 71 and the assertion failed on Linux while the three real invariants passed on both backends. Replaced with the property the fix actually delivers, which is true on either backend: adjacent children's reported edges are separated by exactly the stack's spacing. Rounding each width on its own pushed the remainder into the gaps, so a 6px gap read back as 5 or 7 depending on where the fractions fell; edge-rounded geometry makes it 6 every time, because both edges are offset by the same whole number before rounding. Co-Authored-By: Claude Opus 5 --- tests/flexround_demo/spec_flexround_demo.ae | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/flexround_demo/spec_flexround_demo.ae b/tests/flexround_demo/spec_flexround_demo.ae index 3b1ed3a5..8e7b734b 100644 --- a/tests/flexround_demo/spec_flexround_demo.ae +++ b/tests/flexround_demo/spec_flexround_demo.ae @@ -43,13 +43,22 @@ main() { "the last child does not hang past the row's right edge") } - spec.it("the remainder is spread, not dumped on one child") callback { - w1 = width_of("One") - w3 = width_of("Three") - d = w1 - w3 - if d < 0 { d = 0 - d } - spec.assert_true(d <= 1, - "widest and narrowest differ by at most a pixel (${w1} vs ${w3})") + spec.it("adjacent children are exactly one gap apart") callback { + // Edge-rounded geometry tiles: a child's reported trailing edge + // and its neighbour's reported leading edge are separated by the + // stack's spacing and nothing else. Rounding each width on its + // own put the remainder into the gaps instead, so a 6px gap read + // back as 5 or 7 depending on where the fractions fell. + // + // Deliberately NOT asserting the three are equal: an equal-width + // row of buttons is AppKit behaviour, GTK4 sizes each button to + // its own label, and the invariant under test holds either way. + x2 = uidriver.widget_int_field_by_id(uidriver.widget_id_by_text("Two"), "x") + x3 = uidriver.widget_int_field_by_id(uidriver.widget_id_by_text("Three"), "x") + g1 = x2 - right_of("One") + g2 = x3 - right_of("Two") + spec.assert_true(g1 == 6, "One and Two are one 6px gap apart (got ${g1})") + spec.assert_true(g2 == 6, "Two and Three are one 6px gap apart (got ${g2})") } spec.it("weighted children divide their row proportionally") callback {