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 b8bb6315..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 @@ -3266,6 +3299,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 } @@ -6814,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..8e7b734b --- /dev/null +++ b/tests/flexround_demo/spec_flexround_demo.ae @@ -0,0 +1,83 @@ +// 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("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 { + 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) }