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 @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions backend/aether_ui_backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions backend/aether_ui_gtk4.c
Original file line number Diff line number Diff line change
Expand Up @@ -3593,6 +3593,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
Expand Down
13 changes: 13 additions & 0 deletions backend/aether_ui_macos.m
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
11 changes: 11 additions & 0 deletions backend/aether_ui_win32.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
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 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=(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 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 @@ -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))
Expand Down
18 changes: 18 additions & 0 deletions examples/selmode_demo/.build.ae
Original file line number Diff line number Diff line change
@@ -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
}
}
66 changes: 66 additions & 0 deletions examples/selmode_demo/selmode_demo.ae
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
64 changes: 64 additions & 0 deletions tests/selmode_demo/spec_selmode_demo.ae
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading