diff --git a/CHANGELOG.md b/CHANGELOG.md index bf08e67e..bdb5de7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **win32: a canvas grows with its window, and `on_resize` fires.** + `canvas_create`'s width and height are the canvas's natural size on + every backend (GTK4 expands the drawing area past its content size, + AppKit holds the size at priority 150); win32 stored them as the pin + `width()`/`height()`/`canvas_size` set, so a canvas created 80x80 stayed + 80x80 in a 700px window and `canvas_on_resize` never fired + (`tests/resizecb_demo` failed on Windows for that reason). The natural + size is the measure's answer now; a pin is still a pin. +- **win32: a picker's text is its selection, and a programmatic selection + fires `on_change`.** The driver's `text` for a picker was always empty + (a combo box has no window text to cache), so `tests/picker` failed on + Windows; the selected item is cached after every change. `CB_SETCURSEL` + sends no `CBN_SELCHANGE`, so `picker_set_selected` invokes the closure + itself when the index changed, as GTK4, AppKit and UIKit do. +- **A selected list row is visible on every backend.** `.aui-row-selected` + was painted by GTK4's stylesheet and, on AppKit, Win32 and UIKit, only + reported to the driver: a listbox's selection could be read by a spec + and not seen by a user. Each backend now paints its system's selection + tint over the row's own ground -- Win32 the accent colour as a tint the + labels stay legible on (`w32_selection_ground`, a quarter over white or + a good third over the dark ground), AppKit + `unemphasizedSelectedContentBackgroundColor`, UIKit + `tertiarySystemFillColor` -- and takes it off when the class leaves. +- **win32 lays the containers above a changed stack out again.** A child + added to a nested stack (a listbox's rows go into the `each` container + inside the app's column) changes that stack's natural size, and the + column has to place what follows it lower; the layout request stopped at + the nested stack, the column kept the container at the height it had + when empty, and 200 rows were drawn over the buttons under it. + `w32_request_layout` now climbs to every container whose size can + change, stopping after a scrollview or a stack pinned in height, the + bounds `set_hidden`'s synchronous climb already used. rebuild_bench is + unchanged (0.88-0.95s on screen, 0.63-0.67s headless). - **win32 dark mode: fields and text areas wear a dark edge, the accent colour while focused.** The dark theme class darkens an EDIT and what it draws inside, but the sunken client edge around it is the system's and diff --git a/backend/aether_ui_macos.m b/backend/aether_ui_macos.m index 239a7c77..62799326 100644 --- a/backend/aether_ui_macos.m +++ b/backend/aether_ui_macos.m @@ -2980,8 +2980,26 @@ static void aeui_apply_state_bg(NSView* v) { style = objc_getAssociatedObject(v, "aeui-active-style"); if (!style && objc_getAssociatedObject(v, "aeui-hovered")) style = objc_getAssociatedObject(v, "aeui-hover-style"); + // A selected list row (.aui-row-selected) paints the system's selection + // ground over whatever ground it has, the one the other backends paint + // too: the unemphasized one, which keeps the labels' own colours legible + // in both appearances (an emphasized selection wants white text, and + // the row's labels are the app's). + if (!style && objc_getAssociatedObject(v, "aeui-row-selected")) { + [v setWantsLayer:YES]; + v.layer.backgroundColor = [[NSColor unemphasizedSelectedContentBackgroundColor] CGColor]; + return; + } if (!style) style = objc_getAssociatedObject(v, "aeui_styled_bg"); - if (!style) return; + if (!style) { + // Nothing to paint: a row that just lost its selection goes back to + // no ground of its own. + if (objc_getAssociatedObject(v, "aeui-row-selection-painted")) { + objc_setAssociatedObject(v, "aeui-row-selection-painted", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (v.layer) v.layer.backgroundColor = NULL; + } + return; + } int packed = [style intValue]; if (!(packed & 0x1000000)) return; [v setWantsLayer:YES]; @@ -4308,12 +4326,28 @@ static int class_list_has(const char* list, const char* cls) { return 0; } +// A class is a name the driver reads back, and on GTK4 a stylesheet's hook. +// One has a look here as well: .aui-row-selected, the listbox's selection, +// which every list on every backend has to show (see aeui_apply_state_bg). +static void aeui_class_visual(int handle, const char* cls, int on) { + if (strcmp(cls, "aui-row-selected") != 0) return; + NSView* v = (__bridge NSView*)aether_ui_get_widget(handle); + if (!v) return; + objc_setAssociatedObject(v, "aeui-row-selected", on ? @(1) : nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (on) objc_setAssociatedObject(v, "aeui-row-selection-painted", @(1), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + aeui_apply_state_bg(v); + [v setNeedsDisplay:YES]; +} + void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { if (handle < 1 || handle > widget_count || !cls || !*cls) return; char* cur = widget_classes[handle - 1]; if (class_list_has(cur, cls)) return; // idempotent, like GTK's if (!cur) { widget_classes[handle - 1] = strdup(cls); + aeui_class_visual(handle, cls, 1); return; } char* joined = (char*)malloc(strlen(cur) + strlen(cls) + 2); @@ -4321,6 +4355,7 @@ void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { sprintf(joined, "%s %s", cur, cls); free(cur); widget_classes[handle - 1] = joined; + aeui_class_visual(handle, cls, 1); } void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { @@ -4341,6 +4376,7 @@ void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { free(work); free(cur); widget_classes[handle - 1] = out; // "" when the last class was removed + aeui_class_visual(handle, cls, 0); } // --------------------------------------------------------------------------- // The drawn tooltip — a vg-drawn shape's tooltip, rendered as an overlay diff --git a/backend/aether_ui_uikit.m b/backend/aether_ui_uikit.m index f17cff70..4faf6f6f 100644 --- a/backend/aether_ui_uikit.m +++ b/backend/aether_ui_uikit.m @@ -2934,10 +2934,33 @@ void aether_ui_widget_weight_impl(int handle, int n) { } // --- CSS classes (per-widget, space-separated; drives selectors + the driver) +// A class is a name the driver reads back, and on GTK4 a stylesheet's hook. +// One has a look here as well: .aui-row-selected, the listbox's selection, +// which every list on every backend has to show. The system fill is the +// tint an iOS list draws under a selected cell; the row's own colour is +// kept and comes back when the selection leaves. +static void aeui_class_visual(int handle, const char* cls, int on) { + if (strcmp(cls, "aui-row-selected") != 0) return; + UIView* v = (__bridge UIView*)aether_ui_get_widget(handle); + if (!v) return; + if (on) { + if (!objc_getAssociatedObject(v, "aeui-row-orig")) + objc_setAssociatedObject(v, "aeui-row-orig", v.backgroundColor ?: [NSNull null], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + v.backgroundColor = [UIColor tertiarySystemFillColor]; + } else { + id orig = objc_getAssociatedObject(v, "aeui-row-orig"); + if (orig) { + v.backgroundColor = (orig == [NSNull null]) ? nil : orig; + objc_setAssociatedObject(v, "aeui-row-orig", nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + } +} + void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { if (handle < 1 || handle > widget_count || !cls || !cls[0]) return; char* cur = widget_classes[handle - 1]; - if (!cur) { widget_classes[handle - 1] = strdup(cls); return; } + if (!cur) { widget_classes[handle - 1] = strdup(cls); aeui_class_visual(handle, cls, 1); return; } // Already present? (whole-token match) size_t clen = strlen(cls); const char* p = cur; @@ -2952,6 +2975,7 @@ void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { snprintf(joined, n, "%s %s", cur, cls); free(cur); widget_classes[handle - 1] = joined; + aeui_class_visual(handle, cls, 1); } void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { if (handle < 1 || handle > widget_count || !cls || !cls[0]) return; @@ -2973,6 +2997,7 @@ void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { } free(cur); widget_classes[handle - 1] = out[0] ? out : (free(out), (char*)NULL); + aeui_class_visual(handle, cls, 0); } const char* aether_ui_widget_classes_impl(int handle) { if (handle < 1 || handle > widget_count) return ""; diff --git a/backend/aether_ui_win32.c b/backend/aether_ui_win32.c index 6b06b6c5..efda03c2 100644 --- a/backend/aether_ui_win32.c +++ b/backend/aether_ui_win32.c @@ -333,6 +333,9 @@ typedef struct { // LIST is the driver's selection-visibility contract (.aui-row-selected) // — tracked here, emitted in widget JSON. Space-separated, owned. char* classes; + // The one class with a look of its own: a selected list row paints the + // selection ground (w32_selection_ground) over whatever ground it has. + int row_selected; } Widget; static Widget** widgets = NULL; @@ -427,6 +430,10 @@ static void widget_hash_insert(HWND h, int handle) { static void mark_subtree_dead(HWND hwnd); static void w32_drain_graveyard(void); +static int w32_canvas_natural(const Widget* w, int* out_w, int* out_h); +static void w32_picker_cache_selection(Widget* w); +static COLORREF w32_accent_color(void); +static COLORREF w32_system_ground(void); static void w32_field_frame(HWND hwnd); static int w32_px(HWND hwnd, int at96); static void w32_refont_tree(HWND top, UINT dpi); @@ -984,6 +991,13 @@ static void measure_widget_intrinsic(Widget* w, int* out_w, int* out_h) { *out_h = w->pref_height > 0 ? w->pref_height : w32_px(dh, 80); return; } + if (w->kind == WK_CANVAS) { + int nw = 0, nh = 0; + w32_canvas_natural(w, &nw, &nh); + *out_w = w->pref_width > 0 ? w->pref_width : nw; + *out_h = w->pref_height > 0 ? w->pref_height : nh; + return; + } RECT r; if (GetWindowRect(w->hwnd, &r)) { int cur_w = r.right - r.left; @@ -1134,21 +1148,48 @@ static int w32_own_visible(HWND hwnd) { return w && w->redraw_held && !w->dead; } -static void w32_request_layout(HWND stack_hwnd) { - int h = handle_for_hwnd(stack_hwnd); - if (h == 0) return; - Widget* sw = widget_at(h); - if (!sw || sw->dead || sw->layout_pending) return; +// One stack into the queue. Returns 0 when it was already there (or is not +// a live stack), which is what stops the climb in w32_request_layout: a +// stack already owed a layout had its ancestors marked when it was. +static int w32_queue_layout(Widget* sw, HWND stack_hwnd) { + if (!sw || sw->dead || sw->layout_pending) return 0; if (w32_layout_queue_count >= w32_layout_queue_cap) { int cap = w32_layout_queue_cap ? w32_layout_queue_cap * 2 : 64; int* grown = (int*)realloc(w32_layout_queue, sizeof(int) * cap); - if (!grown) { stack_do_layout(stack_hwnd); return; } // no memory: lay out now + if (!grown) { stack_do_layout(stack_hwnd); return 0; } // no memory: lay out now w32_layout_queue = grown; w32_layout_queue_cap = cap; } sw->layout_pending = 1; - w32_layout_queue[w32_layout_queue_count++] = h; + w32_layout_queue[w32_layout_queue_count++] = handle_for_hwnd(stack_hwnd); w32_hold_redraw(sw); + return 1; +} + +// A stack whose children changed, and every container above it whose +// size that can change. A child added to a nested stack -- a listbox's +// rows go into the `each` container inside the app's column -- changes +// the nested stack's natural size, and the column has to place what +// follows it lower; GTK and AppKit propagate a size request up on their +// own, and until this the request stopped at the nested stack: the column +// kept the container at the height it had when empty, and 200 rows were +// drawn over the buttons under it. The climb stops after a scrollview +// (its viewport does not grow with its document; its own layout moves the +// document and the bar) and after a stack pinned in height, the same +// bounds set_hidden's synchronous climb uses. The flush lays the outermost +// out first, and a parent's pass resizes its children, whose WM_SIZE lays +// them out in turn. +static void w32_request_layout(HWND stack_hwnd) { + HWND h = stack_hwnd; + while (h) { + Widget* sw = widget_at(handle_for_hwnd(h)); + if (!sw) break; + if (!(sw->kind == WK_VSTACK || sw->kind == WK_HSTACK || sw->kind == WK_ZSTACK + || sw->kind == WK_SCROLLVIEW)) break; + if (!w32_queue_layout(sw, h)) break; + if (sw->kind == WK_SCROLLVIEW || sw->pref_height > 0) break; + h = GetAncestor(h, GA_PARENT); + } // Wake the run loop so the flush is not left waiting on the next event: // a rebuild from a timer with nothing else queued must still land. if (!w32_layout_flush_posted) { @@ -1650,10 +1691,26 @@ static void w32_set_text(Widget* w, const char* text) { // ancestor's; only with no ancestor painted at all does the class brush // stand. // --------------------------------------------------------------------------- +// The ground of a selected list row: the user's accent, as a tint the row's +// text stays legible on -- a quarter of it over white on a light system, a +// good third over the dark ground on a dark one, the weights Windows' own +// list views use. GTK4 paints .aui-row-selected from its theme's selection +// colour; this is that, for a backend with no stylesheet. +static COLORREF w32_selection_ground(void) { + COLORREF a = w32_accent_color(); + int dark = aether_ui_dark_mode_check(); + COLORREF base = dark ? w32_system_ground() : RGB(255, 255, 255); + int k = dark ? 38 : 25; // percent of accent + return RGB((GetRValue(a) * k + GetRValue(base) * (100 - k)) / 100, + (GetGValue(a) * k + GetGValue(base) * (100 - k)) / 100, + (GetBValue(a) * k + GetBValue(base) * (100 - k)) / 100); +} + static int w32_own_ground(const Widget* w, COLORREF* out) { if (!w) return 0; if (w->active_set && w->is_pressed) { *out = w->active_bg; return 1; } if (w->hover_set && w->is_hovered) { *out = w->hover_bg; return 1; } + if (w->row_selected) { *out = w32_selection_ground(); return 1; } if (w->bg.has_value) { *out = w->bg.color; return 1; } return 0; } @@ -1927,6 +1984,7 @@ static LRESULT CALLBACK stack_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp } } } else if (cw->kind == WK_PICKER && code == CBN_SELCHANGE) { + w32_picker_cache_selection(cw); if (!cw->sealed) invoke_closure(cw->on_change); } } @@ -3818,6 +3876,24 @@ int aether_ui_picker_create(void* boxed_closure) { return handle; } +// A picker's text is its selection, on every backend: what the driver's +// `text` reports and a spec asserts ("label tracks the selection"). A +// combo box has no window text of its own to cache, so the selected item +// is read out after every change of selection. +static void w32_picker_cache_selection(Widget* w) { + if (!w) return; + LRESULT idx = SendMessageW(w->hwnd, CB_GETCURSEL, 0, 0); + if (idx == CB_ERR) { w32_cache_text(w, ""); return; } + LRESULT len = SendMessageW(w->hwnd, CB_GETLBTEXTLEN, (WPARAM)idx, 0); + if (len == CB_ERR || len < 0 || len > 4096) { w32_cache_text(w, ""); return; } + wchar_t* wbuf = (wchar_t*)malloc(sizeof(wchar_t) * ((size_t)len + 1)); + if (!wbuf) return; + wbuf[0] = 0; + SendMessageW(w->hwnd, CB_GETLBTEXT, (WPARAM)idx, (LPARAM)wbuf); + w32_cache_text(w, wide_to_utf8(wbuf)); + free(wbuf); +} + void aether_ui_picker_add_item(int handle, const char* item) { Widget* w = widget_at(handle); if (!w) return; @@ -3829,11 +3905,19 @@ void aether_ui_picker_add_item(int handle, const char* item) { if (SendMessageW(w->hwnd, CB_GETCURSEL, 0, 0) == CB_ERR) { SendMessageW(w->hwnd, CB_SETCURSEL, 0, 0); } + w32_picker_cache_selection(w); } +// A programmatic selection fires on_change, as it does on GTK4 (the drop +// down's notify::selected), AppKit and UIKit: CB_SETCURSEL sends no +// CBN_SELCHANGE, so the closure is invoked here when the index changed. void aether_ui_picker_set_selected(int handle, int index) { Widget* w = widget_at(handle); - if (w) SendMessageW(w->hwnd, CB_SETCURSEL, (WPARAM)index, 0); + if (!w) return; + LRESULT was = SendMessageW(w->hwnd, CB_GETCURSEL, 0, 0); + SendMessageW(w->hwnd, CB_SETCURSEL, (WPARAM)index, 0); + w32_picker_cache_selection(w); + if (was != (LRESULT)index && !w->sealed) invoke_closure(w->on_change); } int aether_ui_picker_get_selected(int handle) { @@ -6008,6 +6092,18 @@ int aether_ui_fire_appearance(int dark) { // /drop fire path, which cross-thread SendMessages tolerate. return aether_ui_appearance_invoke(dark ? 1 : 0); } +// A class is a name the driver reads back, and on GTK4 a stylesheet's +// hook. One of them has a look here as well: .aui-row-selected, the listbox's +// selection, which every list on every backend has to show. The row repaints +// with its children, so the labels take the new ground's legible text. +static void w32_class_visual(Widget* w, const char* cls, int on) { + if (strcmp(cls, "aui-row-selected") != 0) return; + if (w->row_selected == on) return; + w->row_selected = on; + if (IsWindow(w->hwnd)) + RedrawWindow(w->hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN); +} + void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { Widget* w = widget_at(handle); if (!w || !cls || !cls[0]) return; @@ -6021,6 +6117,7 @@ void aether_ui_widget_add_css_class_impl(int handle, const char* cls) { } free(w->classes); w->classes = nc; + w32_class_visual(w, cls, 1); } void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { Widget* w = widget_at(handle); @@ -6033,6 +6130,7 @@ void aether_ui_widget_remove_css_class_impl(int handle, const char* cls) { if (*from == ' ') from++; else if (pos > w->classes && pos[-1] == ' ') pos--; memmove(pos, from, strlen(from) + 1); + w32_class_visual(w, cls, 0); } /* Group opacity lives with the other canvas command builders, below the CanvasCmd definition — see aether_ui_canvas_group_begin_impl there. */ @@ -6971,13 +7069,30 @@ int aether_ui_canvas_create_impl(int width, int height) { int widget_handle = register_widget_typed(h, WK_CANVAS); Widget* ww = widget_at(widget_handle); if (ww) { - ww->pref_width = width; - ww->pref_height = height; + // The size is the canvas's NATURAL size, what the measure answers + // when no parent forces one (w32_canvas_natural), and not a pin: + // GTK4 expands a drawing area past its content size and AppKit holds + // the size at priority 150, so on both a canvas fills its stack's + // slack and on_resize fires when the window grows. Here it went + // into pref_width/pref_height, which the layout reads as the app's + // own choice of size (width()/height(), canvas_size), so a canvas + // created 80x80 stayed 80x80 in a 700px window and on_resize never + // fired. canvas_size still pins: it goes through set_width. ww->u.canvas.canvas_id = canvas_count; } return canvas_count; } +// The natural size of a canvas: what canvas_create was given. +static int w32_canvas_natural(const Widget* w, int* out_w, int* out_h) { + if (!w || w->kind != WK_CANVAS) return 0; + int id = w->u.canvas.canvas_id; + if (id < 1 || id > canvas_count) return 0; + *out_w = canvases[id - 1].width; + *out_h = canvases[id - 1].height; + return 1; +} + /* --------------------------------------------------------------------------- * GPU surface (#92) -- not implemented on Win32 yet.