From b1893181247a8a48503f0639af25e2642b3207c9 Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Fri, 4 Sep 2026 10:00:34 +1000 Subject: [PATCH] protocols: support xdg-dialog modal toplevels Advertise xdg_wm_dialog_v1 and mirror each dialog object onto its window. A modal dialog demands attention: on map (or when set modal while mapped) its tab is shown and it takes window focus, in both --tab-groups modes; placement itself is unchanged and session lock still wins. wltest gains --dialog-modal/--extra-modal/--extra-delay; smoke tests cover the global, the background-modal steal with tab-groups on, and the own-tab steal with tab-groups off. --- README.md | 12 +++++ kick.c | 124 +++++++++++++++++++++++++++++++++++++++++++- meson.build | 10 ++++ tests/test_smoke.py | 58 +++++++++++++++++++++ tests/wltest.c | 62 +++++++++++++++++++++- 5 files changed, 264 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 171cc9c..507ed9c 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,18 @@ on headless/nested outputs such tools learn the output is unsupported rather than hanging. Like all hardware LUTs, this only takes visible effect on DRM in standalone mode. +## Modal dialogs (`xdg-dialog`) + +Kick advertises `xdg_wm_dialog_v1`, so toolkits can mark a toplevel modal +instead of relying on process ancestry alone. A modal dialog demands +attention: when it maps (or is set modal while mapped) its tab is shown +and the dialog takes window focus -- the deliberate exception to the +no-steal rule, alongside `Alt+Shift+T`. Placement is unchanged +(`--tab-groups on` still stacks it with its tab; `off` still gives it its +own reachable tab), and modality works identically nested and standalone +(it never depends on the host compositor). Session lock still wins over a +modal. + ## Logging (`KICK_LOG`) Quiet by default: a plain launch prints nothing to the launching terminal diff --git a/kick.c b/kick.c index 6095ba6..0a85dd9 100644 --- a/kick.c +++ b/kick.c @@ -118,6 +118,7 @@ #include #include #include +#include #include #include #include @@ -229,6 +230,17 @@ struct kick_window { struct wl_listener request_minimize; struct wl_listener request_move; struct wl_listener request_resize; + /* xdg-dialog wiring: set when the toplevel carries an xdg_dialog_v1 + object (modal flag mirrored). dialog_wired guards removal: the + dialog object lives and dies on its own schedule, and a toplevel + may gain a second one after the first is destroyed. */ + struct wl_listener dialog_set_modal; + struct wl_listener dialog_destroy; + bool dialog_wired; + bool modal; + /* The wired xdg_dialog_v1 object (wlroots emits set_modal with NULL + data, so the object is recovered here, not from the event). */ + struct wlr_xdg_dialog_v1 *dialog; #if HAVE_XWAYLAND /* Extra X11-only listeners: surface association and the ICCCM requests with no xdg counterpart. map/commit/destroy/request_maximize/ @@ -400,6 +412,11 @@ struct kick_server { request_activate we switch to the tab owning that surface. */ struct wlr_xdg_activation_v1 *xdg_activation; struct wl_listener xdg_activation_request; + /* xdg-dialog (xdg-dialog-v1): modal toplevels demand attention -- on + map (or when set modal while mapped) their tab is shown and the + dialog takes window focus, in both --tab-groups modes. */ + struct wlr_xdg_wm_dialog_v1 *dialog_mgr; + struct wl_listener dialog_new; /* Pointer constraints + relative pointer (zwp_pointer_constraints_v1 / zwp_relative_pointer_v1). Only the focused surface's constraint is active; its region lives in the surface's local space, so the surface's @@ -822,6 +839,10 @@ static void kick_request_redraw(void) { static struct kick_window *window_for_toplevel(struct wlr_xdg_toplevel *toplevel); static void host_teardown_window(void); static int tab_index(struct kick_tab *tab); +/* xdg-dialog bodies live with the seat handlers; called from claim/map. */ +static void modal_activate(struct kick_window *w); +static void dialog_wire(struct kick_window *w, + struct wlr_xdg_dialog_v1 *dialog); /* Foreign-toplevel (taskbar) handle lifecycle; bodies live with the idle handlers. Called from window claim/teardown and state sync points. */ static void foreign_handle_create(struct kick_window *w); @@ -2641,6 +2662,11 @@ static void window_detach_xdg_listeners(struct kick_window *w) { wl_list_remove(&w->request_minimize.link); wl_list_remove(&w->request_move.link); wl_list_remove(&w->request_resize.link); + if (w->dialog_wired) { + wl_list_remove(&w->dialog_set_modal.link); + wl_list_remove(&w->dialog_destroy.link); + w->dialog_wired = false; + } } #if HAVE_XWAYLAND @@ -3453,12 +3479,19 @@ static void handle_window_map(struct wl_listener *listener, void *data) { in the tab is enabled, not whether active_toplevel() is set. */ bool activate = window_map_should_activate(w); + /* Modal dialogs demand attention: a late dialog object (created between + claim and map) is mirrored here, then the modal takes its tab. */ + if (!w->dialog_wired && w->toplevel) + dialog_wire(w, wlr_xdg_dialog_v1_try_from_wlr_xdg_toplevel(w->toplevel)); + /* A window claimed while its tab was still black may have been drawn with a placeholder icon; once a real client maps it, re-resolve the icon so the overlay picks up its app_id. */ w->icon_resolved = false; - if (activate) { + if (w->modal) { + modal_activate(w); + } else if (activate) { if (w->origin == ORIGIN_GROUP && w->tab == w->parent_tab) { /* A grouped window of a shown parent tab takes the keyboard within it; whether it wins the seat depends on the parent tab @@ -3673,6 +3706,9 @@ static void handle_new_toplevel(struct wl_listener *listener, void *data) { } claim_xdg_window(w->tab, w, toplevel, cpid, pgid); + /* A dialog object created in the same commit as the toplevel already + exists: mirror it now (later ones arrive via handle_dialog_new). */ + dialog_wire(w, wlr_xdg_dialog_v1_try_from_wlr_xdg_toplevel(toplevel)); } #if HAVE_XWAYLAND @@ -8463,6 +8499,86 @@ static bool im_grab_forward_modifiers(struct wlr_keyboard *kb) { return true; } +/* ===================== xdg-dialog (modal toplevels) ================= + A modal dialog demands attention: when it maps (or is set modal while + mapped) its tab is shown and it takes window focus, even under the + no-steal rule and even with --tab-groups off (where it still owns its + own tab, so it stays individually reachable). Placement itself is + untouched: ancestry grouping stacks it with --tab-groups on exactly as + before. Session lock still wins (set_active_tab/window_enter refuse + while locked). */ + +/* Show the modal's tab (unless already shown) and focus the dialog. */ +static void modal_activate(struct kick_window *w) { + if (!w || !w->mapped || !w->modal) + return; + if (w->tab != server.active_tab) + set_active_tab(w->tab); + window_enter(w); + kick_log(KICK_LOG_INFO, "[dialog] modal app=%d\n", tab_index(w->tab)); +} + +static void dialog_unwire(struct kick_window *w) { + if (!w || !w->dialog_wired) + return; + wl_list_remove(&w->dialog_set_modal.link); + wl_list_remove(&w->dialog_destroy.link); + w->dialog_wired = false; + w->dialog = NULL; + w->modal = false; +} + +static void handle_dialog_set_modal(struct wl_listener *listener, + void *data) { + struct kick_window *w = wl_container_of(listener, w, dialog_set_modal); + (void)data; /* wlroots emits NULL; the object lives on the window. */ + if (!w->dialog) + return; + w->modal = w->dialog->modal; + kick_log(KICK_LOG_DEBUG, "[dialog] modal=%d app=%d\n", + w->modal, tab_index(w->tab)); + if (w->modal && w->mapped) + modal_activate(w); +} + +static void handle_dialog_destroy(struct wl_listener *listener, void *data) { + struct kick_window *w = wl_container_of(listener, w, dialog_destroy); + (void)data; + dialog_unwire(w); +} + +/* Mirror an xdg_dialog_v1 object onto its window. The object may appear + before claim (same commit as the toplevel), between claim and map, or + long after mapping -- so claim, manager-new-dialog and map all funnel + here. Re-wiring replaces a dead object when the client makes a second + one for the same toplevel. */ +static void dialog_wire(struct kick_window *w, + struct wlr_xdg_dialog_v1 *dialog) { + if (!w || !dialog) + return; + dialog_unwire(w); + w->dialog = dialog; + w->modal = dialog->modal; + w->dialog_set_modal.notify = handle_dialog_set_modal; + wl_signal_add(&dialog->events.set_modal, &w->dialog_set_modal); + w->dialog_destroy.notify = handle_dialog_destroy; + wl_signal_add(&dialog->events.destroy, &w->dialog_destroy); + w->dialog_wired = true; + kick_log(KICK_LOG_DEBUG, "[dialog] wired modal=%d app=%d\n", + w->modal, tab_index(w->tab)); +} + +static void handle_dialog_new(struct wl_listener *listener, void *data) { + (void)listener; + struct wlr_xdg_dialog_v1 *dialog = data; + struct kick_window *w = window_for_toplevel(dialog->xdg_toplevel); + if (!w) + return; + dialog_wire(w, dialog); + if (w->modal && w->mapped) + modal_activate(w); +} + /* Clamp an absolute pointer position to whatever the active constraint allows, in place. LOCKED pins to the lock position; CONFINED keeps the pointer inside the constraint region (empty region = whole surface). */ @@ -10700,6 +10816,12 @@ int main(int argc, char *argv[]) { wl_signal_add(&server.xdg_activation->events.request_activate, &server.xdg_activation_request); + /* xdg-dialog: modal toplevels demand attention (see modal_activate). + Version 1 is the only one wlroots speaks. */ + server.dialog_mgr = wlr_xdg_wm_dialog_v1_create(server.wl_display, 1); + server.dialog_new.notify = handle_dialog_new; + wl_signal_add(&server.dialog_mgr->events.new_dialog, &server.dialog_new); + /* The output layout exists in both modes so xdg-output can advertise logical output geometry to clients; nested mode simply has one output (the host window) in the layout. */ diff --git a/meson.build b/meson.build index 113728a..885778c 100644 --- a/meson.build +++ b/meson.build @@ -231,6 +231,14 @@ protocols = { 'wlr-protocols/wlr-gamma-control-unstable-v1.xml', 'wlr-gamma-control-unstable-v1', ], + # xdg-dialog (modal toplevels): staging protocol from the system + # wayland-protocols. Only the client stub is generated for wltest; + # the server side is wlroots' wlr_xdg_wm_dialog_v1 (no quoted + # server-header include, so no extra custom_target needed). + 'xdg-dialog': [ + wl_protocols_dir / 'staging/xdg-dialog/xdg-dialog-v1.xml', + 'xdg-dialog-v1', + ], # Content hints (photo/video/game) + single-pixel buffers (1x1 solid # color without a shm pool). Both are staging protocols from the # system wayland-protocols, like wlroots itself uses. @@ -364,6 +372,7 @@ executable('wltest', code_by_name['single-pixel'], code_by_name['viewporter'], code_by_name['gamma-control'], + code_by_name['xdg-dialog'], client_headers['xdg-shell'], client_headers['cursor-shape'], client_headers['wlr-layer-shell-unstable-v1'], @@ -383,6 +392,7 @@ executable('wltest', client_headers['single-pixel'], client_headers['viewporter'], client_headers['gamma-control'], + client_headers['xdg-dialog'], dependencies: [dependency('wayland-client'), dependency('xkbcommon')]) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index dbbf0c6..35f7789 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1353,6 +1353,64 @@ def test_standalone_headless_gamma_headless_fails_clean(tmp_path): s.close() +def test_standalone_headless_dialog_globals(tmp_path): + """The standalone compositor advertises xdg_wm_dialog_v1 so clients + can mark toplevels modal.""" + out = tmp_path / "globals.txt" + s = _spawn_standalone({}, "%s --globals-out '%s' '#112233'" % (WL, out)) + try: + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + if out.exists(): + break + time.sleep(0.1) + assert out.exists(), "wltest --globals-out never wrote its file" + names = {line.split()[0] for line in out.read_text().splitlines()} + assert "xdg_wm_dialog_v1" in names, \ + f"missing dialog global; advertised: {sorted(names)}" + finally: + s.close() + + +def test_standalone_headless_modal_steals_tab(tmp_path): + """A modal dialog mapping in a background tab takes its tab: the + dialog client opens its modal 1.5s after boot (past settle), and the + settled foreground tab must yield to the dialog's tab.""" + s = _spawn_standalone( + {}, + "%s --extra '#00ff00' --extra-modal --extra-delay 1500 " + "--app-id dlg '#0000ff'" % WL, + "%s '#ff0000'" % WL) + try: + assert s.wait_any_active() == 1 + s.wait_active_frame() + assert s.proc.poll() is None + assert s.wait_active_change(1, timeout=10.0) == 0 + s.wait_for(r"\[dialog\] modal app=0\b") + assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" + finally: + s.close() + + +def test_standalone_headless_modal_tab_groups_off(tmp_path): + """With --tab-groups off a background modal still takes its (own) tab: + it is glued after its parent and activated on map.""" + s = _spawn_standalone( + {}, "--tab-groups", "off", + "%s --extra '#00ff00' --extra-modal --extra-delay 1500 " + "--app-id dlg '#0000ff'" % WL, + "%s '#ff0000'" % WL) + try: + assert s.wait_any_active() == 1 + s.wait_active_frame() + assert s.proc.poll() is None + # The modal owns the tab glued right after its parent tab. + s.wait_for(r"\[dialog\] modal app=1\b") + assert s.proc.poll() is None, f"log tail:\n{s.read_log()[-400:]}" + finally: + s.close() + + def test_nested_primary_selection_bridged(tmp_path): """Nested mode: a tab's primary selection is fanned out to a second tab exactly as standalone, and the host-side bridge publishes it over diff --git a/tests/wltest.c b/tests/wltest.c index 823f6fe..792d704 100644 --- a/tests/wltest.c +++ b/tests/wltest.c @@ -37,6 +37,7 @@ #include "single-pixel-buffer-v1-client.h" #include "viewporter-client.h" #include "wlr-gamma-control-unstable-v1-client.h" +#include "xdg-dialog-v1-client.h" /* Minimal deterministic xdg toplevel test client: shows a solid-color shm buffer and runs until killed. Used by test.sh to give the smoke test two @@ -143,6 +144,7 @@ static struct zwlr_output_power_manager_v1 *power_mgr; static struct zwp_virtual_keyboard_manager_v1 *virtual_kb_mgr; static struct zwlr_virtual_pointer_manager_v1 *virtual_ptr_mgr; static struct xdg_activation_v1 *activation_mgr; +static struct xdg_wm_dialog_v1 *dialog_mgr; static struct zwp_text_input_manager_v3 *text_input_mgr; static struct zwp_input_method_manager_v2 *input_method_mgr; static struct wp_content_type_manager_v1 *content_type_mgr; @@ -369,6 +371,12 @@ static uint8_t PIXEL[4] = { 0xff, 0x00, 0x00, 0xff }; /* RGBA */ toplevel of an already-structured tab gets its own slot glued right after its parent). */ static bool want_extra; +/* --dialog-modal marks the main toplevel modal; --extra-modal marks the + extra one. --extra-delay sleeps before opening the extra window + so boot settles first (lets a modal steal a settled tab). */ +static bool dialog_modal; +static bool extra_modal; +static int extra_delay_ms; static bool extra_done; static uint8_t EXTRA_PIXEL[4] = { 0x00, 0xff, 0x00, 0xff }; static int EX_W = 640, EX_H = 400; @@ -627,6 +635,13 @@ static void xdg_surface_configure(void *data, struct xdg_surface *xs, uint32_t s wl_surface_commit(surface); if (want_extra && !extra_done) { extra_done = true; + if (extra_delay_ms > 0) { + /* Let boot settle (main window mapped, tabs crowned) before + the second window arrives; flush first so the main commit + is not held back by the sleep. */ + wl_display_flush(display); + usleep((useconds_t)extra_delay_ms * 1000); + } extra_open(); } } @@ -801,6 +816,9 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, activation_mgr = wl_registry_bind(reg, name, &xdg_activation_v1_interface, version < 1 ? version : 1); + } else if (strcmp(interface, xdg_wm_dialog_v1_interface.name) == 0) { + dialog_mgr = wl_registry_bind(reg, name, &xdg_wm_dialog_v1_interface, + version < 1 ? version : 1); } else if (strcmp(interface, zwp_text_input_manager_v3_interface.name) == 0) { text_input_mgr = wl_registry_bind(reg, name, @@ -979,6 +997,15 @@ static void extra_open(void) { xdg_toplevel_set_title(ex_toplevel, "extra"); xdg_surface_add_listener(ex_xdg, &extra_xdg_listener, NULL); xdg_toplevel_add_listener(ex_toplevel, &extra_toplevel_listener, NULL); + if (extra_modal) { + if (!dialog_mgr) { + fprintf(stderr, "wltest: extra-modal: xdg_wm_dialog_v1 missing\n"); + exit(1); + } + struct xdg_dialog_v1 *d = + xdg_wm_dialog_v1_get_xdg_dialog(dialog_mgr, ex_toplevel); + xdg_dialog_v1_set_modal(d); + } wl_surface_commit(ex_surface); } @@ -2219,6 +2246,7 @@ static void print_usage(const char *argv0) { "[--input-method [--im-commit ] [--im-out ]] " "[--content-type ] [--single-pixel] " "[--power ] [--gamma [--gamma-out ]] " + "[--dialog-modal] [--extra-modal] [--extra-delay ] " "#RRGGBB [WIDTHxHEIGHT]\n" " %s shows a solid-color xdg_toplevel, e.g. #ff0000\n" " --extra also opens a second toplevel from the same client\n" @@ -2252,7 +2280,10 @@ static void print_usage(const char *argv0) { " --single-pixel backs the toplevel with a 1x1\n" " single-pixel buffer instead of shm\n" " --gamma asks for gamma control, writing gamma_size or\n" - " failed to --gamma-out and exiting", + " failed to --gamma-out and exiting\n" + " --dialog-modal marks the toplevel modal via xdg-dialog;\n" + " --extra-modal marks the --extra window modal instead;\n" + " --extra-delay sleeps before opening it", argv0, argv0); } @@ -2283,6 +2314,24 @@ int main(int argc, char **argv) { } want_extra = true; i += 2; + } else if (strcmp(argv[i], "--dialog-modal") == 0) { + dialog_modal = true; + i += 1; + } else if (strcmp(argv[i], "--extra-modal") == 0) { + extra_modal = true; + want_extra = true; + i += 1; + } else if (strcmp(argv[i], "--extra-delay") == 0) { + if (i + 1 >= argc) { + fprintf(stderr, "wltest: --extra-delay needs ms\n"); + return 1; + } + extra_delay_ms = atoi(argv[i + 1]); + if (extra_delay_ms < 0) { + fprintf(stderr, "wltest: bad --extra-delay\n"); + return 1; + } + i += 2; } else if (strcmp(argv[i], "--cursor-shape") == 0) { if (i + 1 >= argc) { fprintf(stderr, "wltest: --cursor-shape needs a value " @@ -2859,6 +2908,17 @@ int main(int argc, char **argv) { viewport = wp_viewporter_get_viewport(viewporter, surface); } + /* Modal main window: mark the toplevel a dialog before it maps. */ + if (dialog_modal) { + if (!dialog_mgr) { + fprintf(stderr, "wltest: dialog-modal: xdg_wm_dialog_v1 missing\n"); + return 1; + } + struct xdg_dialog_v1 *d = + xdg_wm_dialog_v1_get_xdg_dialog(dialog_mgr, toplevel); + xdg_dialog_v1_set_modal(d); + } + if (constrain_mode != NO_CONSTRAINT) request_constraint();