Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions backend/aether_ui_gtk4.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <math.h>

#ifdef AEUI_HAVE_LIBNOTIFY
#include <libnotify/notify.h>
Expand Down Expand Up @@ -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;
}

Expand Down
84 changes: 66 additions & 18 deletions backend/aether_ui_macos.m
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand Down
18 changes: 18 additions & 0 deletions examples/flexround_demo/.build.ae
Original file line number Diff line number Diff line change
@@ -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
}
}
34 changes: 34 additions & 0 deletions examples/flexround_demo/flexround_demo.ae
Original file line number Diff line number Diff line change
@@ -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) }
}
}
83 changes: 83 additions & 0 deletions tests/flexround_demo/spec_flexround_demo.ae
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading