From 3b81bda6cdf487e0efb08591bfedfcdd0c5406a8 Mon Sep 17 00:00:00 2001 From: nicolasmd87 Date: Fri, 4 Sep 2026 17:44:57 -0300 Subject: [PATCH] feat(ui): drive a multi-select listbox to a known state listbox_select on a multi listbox toggles, which can only ever say "flip this row". Calling it twice silently undoes itself, so an application that selects a row after every add ends up with the wrong row selected, and there was no way to keep an app's own selection model and the widget's in agreement. The only correct multi-select was the one nobody drove. listbox_set_selected(lb, i, on) writes one row's state and listbox_clear_selection(lb) clears them all. Neither fires on_select: a state write is not a user action, and an app syncing its model would otherwise re-enter its own click handler on every sync and have to guard against the echo. listbox_selected_count(lb) saves every caller writing the same loop over listbox_is_selected. Both writes go through one helper that sets the flag and the row's class together, so a selection can never be half applied, which is the one failure an app cannot debug from outside. listbox_selection_mode(lb, 1) gives the widget the behaviour every editor, file manager and mail client has: plain click replaces, cmd/ctrl-click toggles one row, shift-click extends from the anchor, with the anchor staying put so re-extending does not walk the selection away a row at a time. This cannot be built on top of on_select, which reports a row index and no modifier state. Mode 0, today's toggle-every-click, stays the default because that is what a checklist wants and what listbox_multi has always done. The modifier state comes from a new modifiers(), which reports the keys held right now as the bitmask window_on_key already uses. Reading the live state inside a click callback is what lets a widget tell a plain click from a cmd-click without every click callback growing an argument, which would break every existing caller. Real on all three backends: +modifierFlags on AppKit, the seat's keyboard state on GTK4, GetKeyState on win32. The ListBox struct is hand-sized at each allocation, so both malloc sites grew with the two new fields. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 ++++ backend/aether_ui_backend.h | 7 + backend/aether_ui_gtk4.c | 18 +++ backend/aether_ui_macos.m | 13 ++ backend/aether_ui_win32.c | 11 ++ ci.sh | 5 +- examples/selmode_demo/.build.ae | 18 +++ examples/selmode_demo/selmode_demo.ae | 66 +++++++++ tests/selmode_demo/spec_selmode_demo.ae | 64 +++++++++ ui/module.ae | 177 ++++++++++++++++++++++-- 10 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 examples/selmode_demo/.build.ae create mode 100644 examples/selmode_demo/selmode_demo.ae create mode 100644 tests/selmode_demo/spec_selmode_demo.ae diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5f0de9..c44f0317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A multi-select listbox can be driven to a known state.** `listbox_select` + on a multi listbox toggles, which can only ever say "flip this row": calling + it twice silently undoes itself, so an app that selects a row after every add + ends up with the wrong row selected and there was no way to keep an + application's own selection model and the widget's in agreement. + `listbox_set_selected(lb, i, on)` writes one row's state and + `listbox_clear_selection(lb)` clears them all, neither firing `on_select`, + because a state write is not a user action and an app syncing its model would + otherwise re-enter its own click handler on every sync. + `listbox_selected_count(lb)` saves every caller writing the same loop. + +- **`listbox_selection_mode(lb, 1)` gives a multi listbox the selection + behaviour every editor, file manager and mail client has**: a plain click + replaces the selection, cmd/ctrl-click toggles one row, shift-click extends + from the anchor. This could not be built on top of `on_select`, which reports + a row index and no modifier state, so the widget does it: it already owns + `sel_flags` and the hit testing. Mode 0, today's toggle-every-click, stays + the default, because that is what a checklist wants and what `listbox_multi` + has always done. + +- **`modifiers()`** reports the modifier keys held right now as the same + bitmask `window_on_key` uses (1 shift, 2 ctrl, 4 alt, 8 super/command). Real + on all three backends. Reading the live state inside a click callback is what + lets a widget tell a plain click from a cmd-click without every click + callback growing an argument, which would break every existing caller. + + - A `table` announces itself as a table, and its headers as column headers. Before this it announced nothing structural: assistive tech saw an unlabelled stack of buttons above a list. The ROWS were already right, because `table` diff --git a/backend/aether_ui_backend.h b/backend/aether_ui_backend.h index 2d85c5fe..adcb23cd 100644 --- a/backend/aether_ui_backend.h +++ b/backend/aether_ui_backend.h @@ -651,6 +651,13 @@ void aether_ui_shortcut_impl(const char* combo, void* boxed_closure); // would break typing into whatever has focus. mods is a bitmask: // 1 shift, 2 ctrl, 4 alt, 8 super/command. void aether_ui_window_on_key_impl(void* boxed_closure); + +/* The modifier keys held RIGHT NOW, as the public bitmask every other + * modifier-carrying callback uses: 1 shift, 2 ctrl, 4 alt, 8 super/command. + * Meant to be read from inside a click callback, where the platform event is + * still the current one, so a widget can tell a plain click from a + * cmd-click without every click callback growing an argument. */ +int aether_ui_modifiers_impl(void); // Files dropped onto the window, the most common drag-and-drop case by far // and the one an editor or file manager cannot do without. The closure gets // the paths NEWLINE-SEPARATED in one string: the DSL splits that into a list, diff --git a/backend/aether_ui_gtk4.c b/backend/aether_ui_gtk4.c index b8739a2f..6aa9cf79 100644 --- a/backend/aether_ui_gtk4.c +++ b/backend/aether_ui_gtk4.c @@ -3592,6 +3592,24 @@ void aether_ui_window_on_file_drop_impl(void* boxed_closure) { drop_attached = 1; } +int aether_ui_modifiers_impl(void) { + /* The seat's keyboard state is the live one, which is what a click + * callback needs; a GdkEvent is not available at that point. */ + GdkDisplay* dpy = gdk_display_get_default(); + if (!dpy) return 0; + GdkSeat* seat = gdk_display_get_default_seat(dpy); + if (!seat) return 0; + GdkDevice* kb = gdk_seat_get_keyboard(seat); + if (!kb) return 0; + GdkModifierType state = gdk_device_get_modifier_state(kb); + int mods = 0; + if (state & GDK_SHIFT_MASK) mods |= 1; + if (state & GDK_CONTROL_MASK) mods |= 2; + if (state & GDK_ALT_MASK) mods |= 4; + if (state & GDK_SUPER_MASK) mods |= 8; + return mods; +} + void aether_ui_window_on_key_impl(void* boxed_closure) { aeui_window_key_closure_add((AeClosure*)boxed_closure); if (!primary_window) return; // attaches when the window appears diff --git a/backend/aether_ui_macos.m b/backend/aether_ui_macos.m index b8bb6315..b7ea3a90 100644 --- a/backend/aether_ui_macos.m +++ b/backend/aether_ui_macos.m @@ -1305,6 +1305,19 @@ void aether_ui_window_on_key_impl(void* boxed_closure) { window_key_closure_add((AeClosure*)boxed_closure); } +int aether_ui_modifiers_impl(void) { + /* +modifierFlags is the CURRENT keyboard state, not a snapshot from some + * event object, so this answers correctly inside a click callback without + * the event having to be threaded through. */ + NSEventModifierFlags f = [NSEvent modifierFlags]; + int mods = 0; + if (f & NSEventModifierFlagShift) mods |= 1; + if (f & NSEventModifierFlagControl) mods |= 2; + if (f & NSEventModifierFlagOption) mods |= 4; + if (f & NSEventModifierFlagCommand) mods |= 8; + return mods; +} + int aether_ui_window_key_deliver(const char* key_name, int mods) { return window_key_closure_fire(key_name, mods); } diff --git a/backend/aether_ui_win32.c b/backend/aether_ui_win32.c index 535f15e5..85b682a3 100644 --- a/backend/aether_ui_win32.c +++ b/backend/aether_ui_win32.c @@ -2575,6 +2575,17 @@ int aether_ui_window_file_drop_deliver(const char* paths) { return 1; } +int aether_ui_modifiers_impl(void) { + /* GetKeyState reports the state as of the message being processed, which + * inside a click handler is the click itself. */ + int mods = 0; + if (GetKeyState(VK_SHIFT) & 0x8000) mods |= 1; + if (GetKeyState(VK_CONTROL) & 0x8000) mods |= 2; + if (GetKeyState(VK_MENU) & 0x8000) mods |= 4; + if ((GetKeyState(VK_LWIN) & 0x8000) || (GetKeyState(VK_RWIN) & 0x8000)) mods |= 8; + return mods; +} + void aether_ui_window_on_key_impl(void* boxed_closure) { w32_window_key_closure_add((AeClosure*)boxed_closure); } diff --git a/ci.sh b/ci.sh index b47a0f2a..72406231 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 selmode_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. @@ -1153,6 +1153,9 @@ if [ "$SPEC_OK" -eq 1 ]; then UI_SPEC=multiselect_demo/spec_multiselect_demo \ run_server_test "$(EX_BIN multiselect_demo)" \ "$SCRIPT_DIR/tests/run_spec.sh" multiselect_demo || FAIL=$((FAIL + 1)) + UI_SPEC=selmode_demo/spec_selmode_demo \ + run_server_test "$(EX_BIN selmode_demo)" \ + "$SCRIPT_DIR/tests/run_spec.sh" selmode_demo || FAIL=$((FAIL + 1)) UI_SPEC=dblclick_demo/spec_dblclick_demo \ run_server_test "$(EX_BIN dblclick_demo)" \ "$SCRIPT_DIR/tests/run_spec.sh" dblclick_demo || FAIL=$((FAIL + 1)) diff --git a/examples/selmode_demo/.build.ae b/examples/selmode_demo/.build.ae new file mode 100644 index 00000000..e273c12b --- /dev/null +++ b/examples/selmode_demo/.build.ae @@ -0,0 +1,18 @@ +// selmode_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("selmode_demo.ae") + output("selmode_demo") + no_closure_regen() + ui_backend(root) + } + return 0 + } +} diff --git a/examples/selmode_demo/selmode_demo.ae b/examples/selmode_demo/selmode_demo.ae new file mode 100644 index 00000000..f3fd14bb --- /dev/null +++ b/examples/selmode_demo/selmode_demo.ae @@ -0,0 +1,66 @@ +// Aether UI multi-select, driven from the application (#99). +// +// listbox_select on a multi listbox TOGGLES, so it cannot put the widget in a +// known state: calling it twice silently undoes itself, and an app that +// selects a row after every add ends up with the wrong row selected. +// +// listbox_set_selected writes one row's state and listbox_clear_selection +// clears them all, neither firing on_select, so an app can keep its own +// selection model in step with the widget without re-entering its own click +// handler on every sync. +import ui +import ui (window, vstack, hstack, text, btn, divider, + listbox_multi, listbox_update, listbox_set_selected, listbox_clear_selection, + listbox_selection_mode, + enable_test_server) +import std.os (os_getenv) +import std.string +import std.list + +extern malloc(size: int) -> ptr + +struct Item { name: string } + +mk_item(n: int) -> ptr { + it = malloc(16) as *Item + it.name = string.concat("item ", string.from_int(n)) + return it as ptr +} + +main() { + items = list.new() + k = 1 + while k <= 5 { + _ = list.add(items, mk_item(k)) + k = k + 1 + } + + window("selection api demo", 420, 400) { + vstack(8) { + rows = listbox_multi(2) callback |item: ptr, i: int, row: int| { + it = item as *Item + _t = text(row, it.name) + } + listbox_update(rows, items) + listbox_selection_mode(rows, 1) // standard: replace / toggle / extend + divider() + hstack(10) { + // Drive the widget to a KNOWN state, which is what the old API + // could not do. Pressing "set 1 and 3" twice must leave the + // same two rows selected, not undo itself. + _s = btn("set 1 and 3") callback { + listbox_set_selected(rows, 1, 1) + listbox_set_selected(rows, 3, 1) + } + _c = btn("clear") callback { listbox_clear_selection(rows) } + _o = btn("only 2") callback { + listbox_clear_selection(rows) + listbox_set_selected(rows, 2, 1) + } + } + } + if os_getenv("AETHER_UI_TEST_PORT") != null { + enable_test_server(9222) + } + } +} diff --git a/tests/selmode_demo/spec_selmode_demo.ae b/tests/selmode_demo/spec_selmode_demo.ae new file mode 100644 index 00000000..d816a08a --- /dev/null +++ b/tests/selmode_demo/spec_selmode_demo.ae @@ -0,0 +1,64 @@ +// spec_selmode_demo.ae, #99: a multi listbox can be driven to a KNOWN state. +// +// listbox_select toggles, so it could only ever say "flip this row". An app +// that selects a row after every add ends up with the wrong row selected, and +// pressing the same button twice silently undoes itself. These assertions are +// about idempotence and about clearing, which is what "set" and "clear" buy +// over "toggle". +import std.spec +import uidriver +import std.string + +// How many rows currently carry the selection class. +selected_count() -> int { + b = uidriver.get_body("/widgets") + n = 0 + rest = b + while string.contains(rest, "aui-row-selected") == 1 { + n = n + 1 + idx = string.string_index_of(rest, "aui-row-selected") + rest = string.string_substring(rest, idx + 16, string.length(rest)) + } + return n +} + +press(label: string) { + uidriver.post_ok("/widget/${uidriver.widget_id_by_text(label)}/click", "press ${label}") +} + +main() { + fw = spec.init() + uidriver.wait_server() + + spec.describe(fw, "multi-select can be driven to a state (#99)") { + spec.it("nothing is selected to begin with") callback { + spec.assert_eq(selected_count(), 0, "no rows selected at start") + } + + spec.it("set_selected establishes a known selection") callback { + press("set 1 and 3") + ok = uidriver.wait_body_contains("/widgets", "aui-row-selected", 20) + spec.assert_true(ok, "a row is selected") + spec.assert_eq(selected_count(), 2, "rows 1 and 3 are selected") + } + + spec.it("setting the same rows again does not undo itself") callback { + // The whole point: with toggle-only this left ZERO selected. + press("set 1 and 3") + spec.assert_eq(selected_count(), 2, "still exactly the same two rows") + } + + spec.it("clear_selection deselects every row") callback { + press("clear") + spec.assert_eq(selected_count(), 0, "nothing selected after clear") + } + + spec.it("clear then set is how an app says 'only this one'") callback { + press("only 2") + spec.assert_eq(selected_count(), 1, "exactly one row selected") + press("only 2") + spec.assert_eq(selected_count(), 1, "and it is stable under repetition") + } + } + return spec.run_summary(fw) +} diff --git a/ui/module.ae b/ui/module.ae index 3a6e23cb..f97befc1 100644 --- a/ui/module.ae +++ b/ui/module.ae @@ -82,6 +82,8 @@ exports ( listbox, listbox_multi, listbox_reorderable, listbox_update, listbox_move, listbox_items, listbox_select, listbox_selected, listbox_is_selected, listbox_count, + listbox_set_selected, listbox_clear_selection, listbox_selection_mode, + listbox_selected_count, modifiers, on_row_double_click, on_reorder, on_select, add_css_class, remove_css_class, table_cols, table_col, table_col_delegate, table, table_update, @@ -205,6 +207,7 @@ extern aether_ui_context_menu_item_accel_impl(handle: int, label: string, accel: string, closure: ptr) extern aether_ui_shortcut_impl(combo: string, closure: ptr) extern aether_ui_window_on_key_impl(closure: ptr) +extern aether_ui_modifiers_impl() -> int extern aether_ui_window_on_file_drop_impl(closure: ptr) extern aether_ui_widget_draggable_file_impl(handle: int, path: string) extern aether_ui_shortcut_when_impl(combo: string, closure: ptr, enabled: ptr) @@ -1394,6 +1397,17 @@ shortcut_chord(first: string, second: string, cb: fn) { aether_ui_shortcut_chord_impl(first, second, box_closure(cb)) } +// modifiers(), the modifier keys held RIGHT NOW: 1 shift, 2 ctrl, 4 alt, +// 8 super/command, the same bitmask window_on_key reports. +// +// Meant to be read from inside a click callback, where the platform event is +// still the current one, so a widget or an app can tell a plain click from a +// cmd-click without every click callback growing an argument it would break +// every existing caller to add. Real on all three backends. +modifiers() -> int { + return aether_ui_modifiers_impl() +} + // window_on_key(cb): cb(key_name, mods) for ANY key, not a bound combo. // Every shortcut verb answers "was THIS combo pressed"; type-ahead asks // "what was pressed", which no number of registered shortcuts can express. @@ -3324,7 +3338,9 @@ struct ListBox { sel_flags: ptr, // multi: intarr of 0/1 per row, or null on_dbl: ptr, // boxed |i: int| row double-click callback, or null items: ptr, // reorderable: the OWNED std.list, so move() can reorder - on_reorder: ptr // boxed |from, to| move callback, or null + on_reorder: ptr, // boxed |from, to| move callback, or null + sel_mode: int, // multi: 0 = toggle every click, 1 = standard modifiers + anchor: int // multi, standard mode: where a shift-extend measures from } _listbox_invoke_render(cb: fn, item: ptr, i: int, row: int) { @@ -3335,21 +3351,92 @@ _listbox_invoke_sel(cb: fn, i: int) { cb(i) } +// Write one row's flag and make its class follow. The single place that +// touches both, so a selection can never be half-applied: the model saying +// selected and the row not looking it is exactly the bug an app cannot debug +// from the outside. +_listbox_write_flag(lb: *ListBox, i: int, on: int) { + if lb.sel_flags == null { return } + if lb.rows == null { return } + if i < 0 { return } + intarr_set_raw(lb.sel_flags, i, on) + rowh = intarr_get_raw(lb.rows, i) + if rowh > 0 { + if on == 1 { aether_ui_widget_add_css_class_impl(rowh, "aui-row-selected") } + else { aether_ui_widget_remove_css_class_impl(rowh, "aui-row-selected") } + } +} + +_listbox_clear_all(lb: *ListBox) { + if lb.sel_flags == null { return } + n = listbox_count(lb as ptr) + j = 0 + while j < n { + _listbox_write_flag(lb, j, 0) + j = j + 1 + } +} + +// Standard mode: the behaviour every editor, file manager and mail client +// has. Plain click REPLACES the selection, cmd/ctrl-click toggles one row, +// shift-click extends from the anchor. The widget already owns sel_flags and +// the hit testing, so it can do this itself and an app does not have to +// reimplement it (or be able to: a click callback carrying only a row index +// cannot tell the three apart). +_listbox_apply_standard(lb: *ListBox, i: int) { + m = modifiers() + if (m & 1) == 1 { + // Shift-extend from the anchor. The anchor STAYS put, so dragging the + // shift-click up and down re-extends from the same origin instead of + // walking the selection away one row at a time. + a = lb.anchor + if a < 0 { a = i } + lo = a + hi = i + if lo > hi { + lo = i + hi = a + } + _listbox_clear_all(lb) + j = lo + while j <= hi { + _listbox_write_flag(lb, j, 1) + j = j + 1 + } + lb.selected = i + return + } + if (m & 2) == 2 || (m & 8) == 8 { + // ctrl or cmd: toggle just this row, leave the rest alone. + cur = intarr_get_raw(lb.sel_flags, i) + newv = 1 + if cur == 1 { newv = 0 } + _listbox_write_flag(lb, i, newv) + lb.selected = i + lb.anchor = i + return + } + _listbox_clear_all(lb) + _listbox_write_flag(lb, i, 1) + lb.selected = i + lb.anchor = i +} + _listbox_apply_selection(lb: *ListBox, i: int) { if lb.rows == null { return } if lb.multi == 1 { - // Toggle row i's membership; class follows the flag. if lb.sel_flags == null { return } + if lb.sel_mode == 1 { + _listbox_apply_standard(lb, i) + return + } + // Toggle row i's membership; class follows the flag. cur = intarr_get_raw(lb.sel_flags, i) newv = 1 if cur == 1 { newv = 0 } - intarr_set_raw(lb.sel_flags, i, newv) - rowh = intarr_get_raw(lb.rows, i) - if rowh > 0 { - if newv == 1 { aether_ui_widget_add_css_class_impl(rowh, "aui-row-selected") } - else { aether_ui_widget_remove_css_class_impl(rowh, "aui-row-selected") } - } + _listbox_write_flag(lb, i, newv) lb.selected = i // last-touched, for single-select callers + lb.anchor = i return } if lb.selected >= 0 { @@ -3439,7 +3526,7 @@ listbox_multi(_ctx: ptr, spacing: int, render: fn) -> ptr { } _listbox_make(_ctx: ptr, spacing: int, render: fn, multi: int) -> ptr { - lb = malloc(80) as *ListBox + lb = malloc(96) as *ListBox lb.rows = null lb.selected = 0 - 1 lb.on_sel = null @@ -3449,6 +3536,8 @@ _listbox_make(_ctx: ptr, spacing: int, render: fn, multi: int) -> ptr { lb.on_dbl = null lb.items = null lb.on_reorder = null + lb.sel_mode = 0 + lb.anchor = 0 - 1 lbp = lb as ptr lb.group = each(_ctx, "v", spacing) callback |item: ptr, i: int, parent: int| { row = hstack(parent, 8) @@ -3541,7 +3630,7 @@ _listbox_invoke_reorder(cb: fn, from: int, to: int) { // a drop calls listbox_move(source_index, target_index). The drag gesture is // backend C (GTK4 GtkDragSource/GtkDropTarget); the model reorder is shared. _listbox_make_reorder(_ctx: ptr, spacing: int, render: fn) -> ptr { - lb = malloc(80) as *ListBox + lb = malloc(96) as *ListBox lb.rows = null lb.selected = 0 - 1 lb.on_sel = null @@ -3551,6 +3640,8 @@ _listbox_make_reorder(_ctx: ptr, spacing: int, render: fn) -> ptr { lb.on_dbl = null lb.items = null lb.on_reorder = null + lb.sel_mode = 0 + lb.anchor = 0 - 1 lbp = lb as ptr lb.group = each(_ctx, "v", spacing) callback |item: ptr, i: int, parent: int| { row = hstack(parent, 8) @@ -3615,10 +3706,76 @@ listbox_is_selected(lbp: ptr, i: int) -> int { } // Programmatic selection (same path as a row click, incl. the callback). +// +// On a MULTI listbox in toggle mode this toggles, so it cannot establish a +// known selection: calling it twice silently undoes itself. Use +// listbox_set_selected / listbox_clear_selection to drive a multi listbox to +// a state, and this only where a click is what you mean to simulate. listbox_select(lbp: ptr, i: int) { _listbox_row_clicked(lbp, i) } +// listbox_set_selected(lb, i, on) writes ONE row's selection state, without +// firing on_select. A state write, not a user action: an app keeping its own +// selection model in step with the widget would otherwise re-enter its own +// click handler on every sync and have to guard against the echo. +// +// Multi-select only; a single-select listbox has listbox_select. +listbox_set_selected(lbp: ptr, i: int, on: int) { + lb = lbp as *ListBox + if lb.multi != 1 { return } + v = 0 + if on != 0 { v = 1 } + _listbox_write_flag(lb, i, v) + if v == 1 { + lb.selected = i + lb.anchor = i + } +} + +// listbox_clear_selection(lb) deselects every row. Also forgets the +// shift-extend anchor, so the next shift-click measures from where the user +// clicks rather than from a row that is no longer selected. +listbox_clear_selection(lbp: ptr) { + lb = lbp as *ListBox + if lb.multi != 1 { return } + _listbox_clear_all(lb) + lb.selected = 0 - 1 + lb.anchor = 0 - 1 +} + +// listbox_selection_mode(lb, mode) says how a click changes a MULTI listbox's +// selection. +// +// 0 (default) TOGGLE every click flips that row, nothing else moves. +// 1 STANDARD plain click replaces the selection, cmd/ctrl-click +// toggles one row, shift-click extends from the anchor. +// +// Toggle stays the default because it is what listbox_multi has always done +// and a checklist wants exactly that. Standard is what a scene tree, a file +// list or a mailbox wants, and it cannot be built on top of on_select, which +// reports a row index and no modifier state. +listbox_selection_mode(lbp: ptr, mode: int) { + lb = lbp as *ListBox + lb.sel_mode = mode +} + +// listbox_selected_count(lb) reports how many rows are selected. Saves every caller +// writing the same loop over listbox_is_selected. +listbox_selected_count(lbp: ptr) -> int { + lb = lbp as *ListBox + if lb.multi != 1 { return 0 } + if lb.sel_flags == null { return 0 } + n = listbox_count(lbp) + c = 0 + j = 0 + while j < n { + if intarr_get_raw(lb.sel_flags, j) == 1 { c = c + 1 } + j = j + 1 + } + return c +} + listbox_selected(lbp: ptr) -> int { lb = lbp as *ListBox return lb.selected