diff --git a/kick.c b/kick.c index 61681bd..51386ac 100644 --- a/kick.c +++ b/kick.c @@ -354,10 +354,11 @@ struct kick_server { struct wlr_layer_shell_v1 *layer_shell; struct wl_listener new_layer_surface; struct wlr_scene_tree *layer_tree; - /* Parentless xdg_popups (menus/tooltips from a client without a live - toplevel parent, e.g. GTK layer-shell bars) hang off this dedicated - scene tree so they map at the output origin instead of inheriting a - tab tree's bar-offset. Kept on top like layer_tree. */ + /* xdg_popups from foreign clients (a layer-shell bar such as wf-panel-pi) + that have no live kick-managed parent hang off this dedicated scene + tree, rooted at the output origin, so their positioner geometry maps to + real pixels instead of inheriting a tab tree's bar-offset. Kept on top + like layer_tree. */ struct wlr_scene_tree *popup_tree; /* Layer surfaces, in reservation order (head = highest layer / lowest overlap priority); see kick_layer. */ @@ -4248,6 +4249,21 @@ static void popup_handle_commit(struct wl_listener *listener, void *data) { popup_usable_box(&box); wlr_xdg_popup_unconstrain_from_box(xdg_surface->popup, &box); } + /* Once the popup maps, report its output-space top-left and size. Tests + key on this line to assert a bar's menu hung flush under the bar; the + coordinates are scene-exact (no wall-clock guesswork). */ + if (xdg_surface->surface->mapped && tp->scene) { + int lx = 0, ly = 0; + wlr_scene_node_coords(&tp->scene->node, &lx, &ly); + pid_t pcpid = -1; + if (xdg_surface->client && xdg_surface->client->client) + wl_client_get_credentials(xdg_surface->client->client, + &pcpid, NULL, NULL); + kick_log(KICK_LOG_DEBUG, + "[popup] out=%d,%d geo=%dx%d pcpid=%d\n", lx, ly, + xdg_surface->geometry.width, xdg_surface->geometry.height, + pcpid); + } } static void popup_handle_reposition(struct wl_listener *listener, void *data) { @@ -4350,19 +4366,34 @@ found: a window that closed, or a non-tab surface such as a layer-shell bar). Rather than drop the popup -- which would leave the client waiting forever for its initial configure ("Timed out waiting for initial - .configure") -- fall back to the currently-displayed window's tree so - the popup still gets rendered and configured. */ + .configure") -- fall back so the popup still gets rendered and + configured, choosing the mount by which client made it: + * a parentless popup from the active tab's own client still hangs + under that window's (bar-offset) tree, keeping it aligned with the + app's content just as before; and + * a popup from any other client (a layer-shell bar such as + wf-panel-pi, or a gone/foreign surface) maps at the output origin + so its positioner geometry lands on real pixels instead of being + shifted down/right past the bar's reserved edge. */ if (!parent_tree) { - kick_log(KICK_LOG_DEBUG, - "[popup] parent %p not live; hanging popup under popup tree " - "at output origin\n", - (void *)parent_ws); - /* No live kick-managed parent. These are popups whose parent was a - layer-shell bar or a gone window. Map them under the root-level - popup_tree so their positioner geometry lands at the real output - coordinates (bars' menus would otherwise be shifted down by the - bar's reserved edge from hanging under an offset window tree). */ - parent_tree = server.popup_tree; + pid_t pcpid = -1; + if (popup->base->client) + wl_client_get_credentials(popup->base->client->client, + &pcpid, NULL, NULL); + struct kick_window *cur = tab_current_window(server.active_tab); + if (cur && cur->client_pid > 0 && cur->client_pid == pcpid) { + kick_log(KICK_LOG_DEBUG, + "[popup] parent %p not live; reusing active window tree " + "for same-client popup\n", + (void *)parent_ws); + parent_tree = popup_tree_for_window(cur); + } else { + kick_log(KICK_LOG_DEBUG, + "[popup] parent %p not live; hanging popup under popup " + "tree at output origin\n", + (void *)parent_ws); + parent_tree = server.popup_tree; + } } if (!parent_tree) return; create_popup(popup, parent_tree); @@ -4522,6 +4553,16 @@ static void layer_handle_commit(struct wl_listener *listener, void *data) { (void)data; struct kick_layer *sl = wl_container_of(listener, sl, commit); bool mapped = sl->surface->surface->mapped; + /* Deterministic "the bar is on screen" signal for tests: fires on the + commit that maps a layer surface, including a first-commit map. */ + if (mapped && !sl->mapped) { + pid_t pid = -1; + if (sl->surface->resource) + wl_client_get_credentials( + wl_resource_get_client(sl->surface->resource), + &pid, NULL, NULL); + kick_log(KICK_LOG_DEBUG, "[layer] map pid=%d\n", pid); + } if (!sl->seen_init) { /* First commit. wlroots' layer_surface_role_commit runs first and sets surface->initialized, so this is the earliest point we may call diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b48794f..cb40682 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -3081,3 +3081,62 @@ def test_layer_zero_exclusive_does_not_resize(): layer_proc.wait(timeout=5) finally: s.close() + + +# -- wf-panel-pi menu placement --------------------------------------- + +def test_panel_menu_flush_under_bar(): + """wf-panel-pi opens its menus as (transiently) parentless xdg_popups. + They must hang off the output origin so the menu renders flush under the + top bar -- not shifted down by the bar's reserved edge (the menu-offset + regression). Deterministic: waits on kick's own log signals ([layer] map, + then the panel client's [popup] out= line), no pixel diffing or sleeps.""" + if not os.environ.get("WAYLAND_DISPLAY"): + pytest.skip("needs a live host Wayland session (WAYLAND_DISPLAY)") + panel = shutil.which("wf-panel-pi") + if not panel: + pytest.skip("wf-panel-pi not installed") + s = _nested_session(f"{WL} '{RED}'", extra_args=("--panel", panel)) + try: + s.wait_for(r"\[diag\] map app=0", timeout=20.0) + + # The panel's layer surface must map before it can take clicks. + s.wait_for(r"\[layer\] map pid=\d+", timeout=30.0) + pm = re.findall(r"\[layer\] map pid=(\d+)", s.read_log()) + assert pm, "no [layer] map pid= line" + panel_pid = int(pm[-1]) + + # Open the wf-panel-pi left menu (smenu button near the top-left). + # The menu popup is created by the panel's own client, so its [popup] + # out= line is tagged with the panel pid. + out_rx = re.compile( + r"\[popup\] out=-?\d+,(-?\d+) geo=\d+x\d+ pcpid=%d\b" % panel_pid) + + s.warp(25, 26) + last_y = None + since = time.monotonic() + while time.monotonic() - since < 30.0: + s.click() + # Poll long enough for the gtk menu to map, and re-click only if a + # whole poll window passes with no popup (an already-open menu + # re-click only toggles it closed; the mapped out= line is still + # in the log, so we still detect it). + poll_since = time.monotonic() + while time.monotonic() - poll_since < 2.0: + for m in out_rx.findall(s.read_log()): + last_y = int(m) + # The bar is 52px tall; the menu's output-space top must + # sit flush under it. y ~109+ is the regression (menu + # shifted down by the bar's reserved edge because it hung + # under the active window's tree instead of the origin); + # y <= 80 stays comfortably away from that. + if 48 <= int(m) <= 80: + return + time.sleep(0.1) + tail = s.read_log()[-1200:] + pytest.fail( + f"panel menu popup never mapped flush under the bar " + f"(pcpid={panel_pid}, outermost y seen={last_y}, bar is 52px);\n" + f"{tail}") + finally: + s.close()