swayfx-mosaic/docs/design/STEP20C_POPUP_OWNERSHIP_PROBE.md

24 KiB
Raw Permalink Blame History

STEP20C popup viewport ownership probe

Decision: PARTIALLY_FEASIBLE (isolated experiment). Starting Mosaic commit cd550382b311efdbfe3ed892f4e1d281d852e754. This plan was written before changing disposable experimental C code. It does not authorize production STEP20C, a SceneFX fork, or a package change.

Observed failure and actual ownership

The SceneFX clipping spike demonstrated a private inherited clip for the tiled subtree. A GTK4 Gtk.Popover from a partly visible Mosaic tiled window still rendered on neighboring HEADLESS-2. At global (900,190), the popup client logged pointer enter and right button while the ordinary wev window beneath logged no pointer event.

In sway/desktop/xdg_shell.c, handle_new_popup passes the owning sway_view to popup_create but parents the root popup scene tree under root->layers.popup. The popup descriptor stores both that view and the relative content-tree node. popup_handle_new_popup creates a nested popup beneath the parent popup's XDG scene tree, retaining the same owning view. arrange_popups in sway/desktop/transaction.c positions root popup scene nodes from the current global coordinates of their relative view node. Popup destroy removes listeners, destroys the scene tree, then frees the popup. Initial surface commit and reposition call popup_unconstrain, which currently builds an output box using the owner's pending content coordinates. This pending/current mismatch must be investigated, not silently reused for a committed visual clip.

node_at_coords in sway/input/cursor.c asks each root layer for its topmost scene hit and can resolve a popup descriptor back to the owning container. A popup pointer or keyboard grab may independently consume input; visual hit filtering alone does not promise click-through. The unmodified headless control will establish the existing grab behavior before any new input conclusion.

PROPOSED owner-derived boundary

The logical chain is popup -> owning XDG view -> real tiled container -> current workspace -> current Mosaic row viewport. The root popup may remain in the global popup scene layer for normal stacking and grab behavior. A private SceneFX inherited clip would attach to that root popup scene tree; nested descendants inherit it. SceneFX owns only the clip mechanism. Mosaic owns the owner/viewport association and never duplicates popup buffers or container authority.

PROPOSED coordinates: obtain the owner's committed workspace and output; intersect its tiled usable-area rectangle after gaps with the output's current global logical box; translate that global rectangle into the root popup tree's local coordinates using its current scene-node coordinates. Do not use container->pending.content_x/y as the visible clip while a transaction is in flight. Recompute after scene positioning changes, output/workspace changes and camera moves. When the Mosaic owner is not visibly tiled on its active workspace, make the popup non-visible/non-hittable without leaving stale pixels; restore ordinary popup behavior after layout exit or conversion to floating/fullscreen. The exact off-screen popup UX policy remains open.

The clip and popup tree have different owners: the popup owns its scene node; its view pointer is valid only through the ordinary Sway popup/view lifetime. A callback must not retain a workspace/output pointer past destruction or output migration. A future multi-row design resolves the row from the actual owner and applies that row's viewport; rows[0] is permitted only in disposable Row-0 wiring.

Alternatives to test

Option Expected advantage Risk to test
A. Clip the root popup tree One inherited boundary for root and nested popup descendants, retaining normal global stacking. Primary proposed experiment. Local/global conversion, grab behavior, popup damage and lifecycle updates; may need special handling if nested surfaces are elsewhere.
B. XDG unconstrain/reposition plus clip Could keep more of a popup visible near its owner. wlr_xdg_popup_unconstrain_from_box changes XDG geometry; it does not promise clipping or removal of hidden hits. Client reposition protocol and pending/current geometry may diverge.
C. Reparent Mosaic popup scenes below tiling Would inherit the existing tiling clip automatically. Changes global stacking, overlay order and popup scene coordinates; may interact with grabs and nested popups. Requires evidence before use.

Select A initially because the observed failure is a missing inherited boundary on one existing root popup tree. Reject it if ordinary popup behavior regresses or it cannot cover nested rendering/input/damage. Do not choose C solely for clipping convenience.

Input, grabs and acceptance rules

Test both a non-grab popup and a client-requested XDG grab if a reproducible client is available. For a clipped-away region, no popup surface may receive pointer focus or buttons. A legitimate grab may consume an outside click or dismiss the popup; the probe must compare against an unmodified SwayFX headless control and distinguish this from a scene hit on invisible popup pixels. Do not force click-through contrary to normal popup semantics. Test left/right buttons, enter/leave, motion, client-local coordinates, popup close and focus changes.

The primary headless matrix uses two horizontal 800×600 outputs, Mosaic GTK owner on HEADLESS-1, ordinary wev or Kitty on HEADLESS-2, and the previous failure point (900,190). Record screenshots and actual client events. Vary owner visibility (full/partial/hidden), camera scroll, popup open/reposition/close, workspace switch, owner destruction, layout exit, floating/fullscreen conversion, and nested popup if a client can produce one. Extend to vertical, negative-origin, scale-1.25 and rotated outputs; unexecuted cases are NOT TESTED. Instrument clip changes and output-specific damage for exposed/vacated pixels. Decorations, shadow, blur and saved transaction buffers require explicit coverage or a clear untested label.

The experiment passes only if root and nested popup rendering and pointer behavior, relevant grabs, lifecycle, damage, ordinary popup controls and tested output arrangements agree. Partial root-only success is PARTIALLY_FEASIBLE. A persistent visible/hittable leak or an unavoidable ordinary-popup regression is BLOCKED. No popup-specific result alone authorizes production STEP20C.

Isolation and ABI boundary

Reuse only verified /tmp SceneFX/Mosaic source and build artifacts; make a new disposable checkout and build directory for this probe. The previous first patch changed struct wlr_scene_tree layout and crashed; retain the ABI-preserving sidecar approach for any private test. Load a private SceneFX library only in a private headless process, with private Wayland and IPC sockets. Do not install or replace system libraries or compositors. The previous static-link success is a laboratory result; SceneFX/wlroots overlapping symbols, ABI updates, upstream rebases, security maintenance and package ownership remain separate approval gates.

Source and API audit after the experiment

Installed packages were swayfx 0.6-1, scenefx 0.5-1, and wlroots0.20 0.20.2-1. The private checkouts were Mosaic cd550382b311efdbfe3ed892f4e1d281d852e754 and SceneFX 0.5 37ccd723bef49e6891156ffafce8f549f01446cc. The main checkout was clean at the stated Mosaic baseline before this document was created.

  • sway/desktop/xdg_shell.c:80 popup_create stores popup->view and a scene descriptor pointing to the view content tree. handle_new_popup at line 369 supplies root->layers.popup as the root scene parent. popup_handle_new_popup at line 26 uses the parent popup's xdg_surface_tree for nested children while retaining the same view. popup_handle_destroy at line 33 removes listeners, destroys the scene tree, then frees the descriptor owner. The image-capture scene has separate ownership and was not validated here.
  • popup_unconstrain at line 44 uses container->pending.workspace, pending content coordinates and the output geometry. It runs on initial surface commit and reposition. wlroots 0.20 declares void wlr_xdg_popup_unconstrain_from_box(struct wlr_xdg_popup *popup, const struct wlr_box *toplevel_space_box); in wlr/types/wlr_xdg_shell.h; its documented contract is to adjust popup geometry under XDG positioner rules in root toplevel surface coordinates. It does not clip rendered pixels or pointer hits. This pending geometry path was left unchanged in the experiment.
  • sway/desktop/transaction.c:1086 arrange_popups positions root popup nodes using the view's current global content-tree coordinates; arrange_root invokes it after arranging outputs. sway/input/cursor.c:39 node_at_coords visits the global scene layers top-down, calls wlr_scene_node_at, and resolves a hit popup descriptor back to its view container. This is distinct from popup grab state. sway/tree/view.c owns the view/container relationship; it does not make the popup scene a child of the tiled scene.
  • The prior private SceneFX patch modifies types/scene/wlr_scene.c visibility (scene_node_update_iterator, scene_node_bounds), hit testing (scene_node_at_iterator), direct scanout eligibility (scene_entry_try_direct_scanout), and damage through scene_node_update/scene_output_damage. Its void wlr_scene_tree_set_clip(struct wlr_scene_tree *, const struct wlr_box *) takes a copied tree-local logical-coordinate rectangle; NULL clears it. The new private bool wlr_scene_tree_get_clip(struct wlr_scene_tree *, struct wlr_box *) copies the applied rectangle or returns false. Both symbols belong to the private SceneFX shared object, not installed wlroots. These are experimental APIs, absent from installed SceneFX 0.5.

The private Mosaic change in transaction.c reads the already-applied tiled-tree clip for the popup owner's current Mosaic workspace, converts it to global logical coordinates using the tiled scene node's current position, intersects it with the owner's output box, then subtracts the root popup scene node's global position to obtain a root-popup-local clip. This preserves the global popup layer and nests descendants below the clipped root. It clears the clip for standard layout, floating or fullscreen owner; an inactive Mosaic workspace receives an empty clip. The root creation hook in xdg_shell.c arranges immediately before the first map. No second window tree or buffer copy was made. The Row-0 tiled viewport comes from the previous spike; the popup-specific lookup is through the owner, workspace and applied tree clip. A future row would supply that row's viewport through the same tree API without rows[0] in popup ownership.

The prototype has not proved that current state and applied scene state remain synchronized during every transaction, output migration or resize. popup_unconstrain still uses pending state. A production design must give the viewport/scene generation an explicit ownership and lifetime contract.

Alternatives evaluated

Strategy Finding
A. Owner-derived root popup tree clip Selected for the private test. One local clip covers the root and its nested scene descendants while retaining global popup stacking and Sway's existing grab path. It requires the private SceneFX inherited clip and careful committed-state conversion.
B. XDG unconstrain/reposition plus clip Reposition was observed and may improve popup placement, but wlroots only adjusts geometry according to XDG positioner rules. It cannot enforce a viewport boundary alone. Retain as a later UX choice, coupled with A if desired.
C. Reparent beneath the Mosaic tiled tree Would inherit a clip but alters normal global popup stacking and coordinate parenting. No need arose in this test. Overlay, nested and grab consequences were not proven safe, so this is not the preferred path.

Disposable source and reproduction

All experiment material is under /tmp/swayfx-mosaic-popup-yEamPX/; no system installation occurred. The previous evidence directory /tmp/swayfx-mosaic-step20c-scenefx-aiMRk6/ was verified before reuse. The new directory contains mosaic/, scenefx/, mosaic-build/, scenefx-build/, GTK clients, a virtual-pointer injector, run_popup.py, per-run logs and PNGs. mosaic-popup-plus-base.patch and scenefx-popup-plus-base.patch retain the complete private differences from the pinned bases, including the earlier tiled clip. Their SHA-256 values are respectively d416f6413ea097a48a44d231e50ffba363914f46c8687f0ea2e4dcacd5f31fe9 and c74c515b98da8f5f637e260e6a3755c7a75bd78d1c39e54a67850f1c383e30e7. The popup-specific delta is probe_clip_popup/arrange_popups and the root creation call in Mosaic, plus the SceneFX getter. Source files and scripts in /tmp are disposable; the preceding audit and observations preserve their essential logic if that directory is removed.

A representative run is:

python3 /tmp/swayfx-mosaic-popup-yEamPX/run_popup.py exp 900,190 right
python3 /tmp/swayfx-mosaic-popup-yEamPX/run_popup.py control 900,190 left
python3 /tmp/swayfx-mosaic-popup-yEamPX/run_popup.py exp_nested 350,250 left

The script launches either the private mosaic-build/sway/swayfx-mosaic with LD_LIBRARY_PATH=/tmp/swayfx-mosaic-popup-yEamPX/scenefx-build, or the unmodified /usr/bin/sway, with WLR_BACKENDS=headless, two 800×600 outputs, WLR_RENDERER=pixman, and run-local Wayland/IPC sockets. It starts a Kitty cell, a GTK4 popover owner on HEADLESS-1 and a wev client on HEADLESS-2; it injects a virtual-pointer move/button pair, captures both outputs with grim, and writes gtk.log, X.log, compositor.log, tree.json and screenshots to the named run-* directory. The GTK clients log widget events and WAYLAND_DEBUG=1 protocol events. The test process and private library path do not affect the daily session. To rebuild from retained source, apply each patch to the pinned checkout, configure SceneFX 0.5 and Mosaic with Meson in separate /tmp build directories using the private SceneFX pkg-config/library path, then run the commands above. The prior SceneFX spike records the compatible base build flags. The recorded Meson setup is meson setup /tmp/swayfx-mosaic-popup-yEamPX/scenefx-build /tmp/swayfx-mosaic-popup-yEamPX/scenefx -Dexamples=false -Dwerror=false, followed by PKG_CONFIG_PATH=/tmp/swayfx-mosaic-popup-yEamPX/scenefx-build/meson-uninstalled meson setup /tmp/swayfx-mosaic-popup-yEamPX/mosaic-build /tmp/swayfx-mosaic-popup-yEamPX/mosaic -Dmosaic-package=true -Ddefault-wallpaper=false -Dswaybar=false -Dswaynag=false -Dman-pages=disabled -Dsd-bus-provider=libsystemd -Dwerror=false; meson compile -C each build directory in that order. The private SceneFX .pc file resolves the private headers and library. These commands require the retained patch application first.

Rendering and real pointer observations

At global (900,190) (HEADLESS-2 local (100,190)), the unmodified SwayFX control screenshot pixel was (49,50,68), the escaped GTK popup color. Its GTK client recorded POPUP_POINTER_ENTER and right/left POPUP_BUTTON; wev received no corresponding button. With the private owner clip, that pixel was the ordinary second-output client (102,102,102). The popup client recorded no pointer enter or button there. A right click reached wev: enter at client-local (98,165) followed by button 273 press/release. The private log applied root clip local=-162,-25 800x600. This establishes visual and hit-test exclusion for that tested point while leaving HEADLESS-2 content visible.

At (750,190) inside the clipped popup on HEADLESS-1, the pixel stayed (49,50,68) and GTK recorded real enter and both right and left button events. The Wayland trace for the left run recorded wl_pointer.enter(... wl_surface#84, 1244, 42) and wl_pointer.button(... 272, pressed/released); the widget coordinates differ from raw surface coordinates because GTK reports a child-widget origin. The client-local raw protocol coordinate remained stable across the tested event. The output screenshot and actual Wayland/client logs are complementary evidence.

The nested GTK client created a root XDG popup and a second get_popup whose parent was the first popup's xdg_surface. At (900,190), the second-output pixel stayed (102,102,102), no nested popup button was recorded, and wev received the right click. At visible nested content (350,250), the pixel was (83,84,99) and GTK logged NESTED_ENTER plus NESTED_BUTTON 1; the raw protocol trace showed wl_pointer.enter(... wl_surface#105, 844, 42) and button 272 press/release. This demonstrates a real nested child inherits the root clip in the tested geometry. It does not cover every nested positioner or submenu arrangement.

Grab and outside-click distinction

The GTK client emitted xdg_popup.grab(wl_seat, 0) in the protocol trace. Because serial zero is not a valid user-input serial for a general XDG grab, this does not qualify valid-serial grab behavior. In the clipped-away (900,190) region, the private run's left click caused xdg_popup.popup_done and did not deliver a popup button or a wev button; wev did receive pointer enter first. The private right click also dismissed the popup but reached wev as button 273. In the unmodified control, the same (900,190) left/right clicks went to the visible popup. At (900,400), outside the popup surface in both control and private runs, wev got a right click and GTK got popup_done. These observations distinguish popup dismissal/consumption from a hidden scene hit. They do not establish a universal click-through contract or keyboard-grab semantics. The safety invariant held in the tested case: no clipped-away popup surface received pointer enter or button. Valid-serial grabs require a dedicated reproducer and policy review.

Camera, lifecycle, damage and ordinary-layout checks

A layout mosaic scroll -400 changed the tiled clip from local x=480 to x=80 and the popup clip from x=-162 to x=-562; the second-output pixel stayed ordinary gray and wev received a right click. A +400 attempt had been clamped and is not counted as camera evidence. A real GTK xdg_popup.reposition request received repositioned(1) and a new configure while the second-output pixel remained gray and wev remained interactive. Switching to workspace 2 and destroying the owner separately produced no popup pixel at the sampled second-output point and no compositor crash; the owner-destroy run necessarily terminated GTK before another client event could be recorded. The workspace switch sent popup_done. Moving the owner to floating or fullscreen cleared the extra clip in the private log; sampled second-output interaction remained available, but those cases were not comprehensively compared. Switching the Mosaic workspace to standard splith cleared the clip and restored the popup's ordinary cross-output pixel and right-button behavior in the sampled control configuration.

The private SceneFX log recorded per-output SPIKE_DAMAGE extents during open, camera movement, popup change and scene destruction. The screenshots before and after pointer action showed no stale popup pixel at the sampled leak point. Some recorded damage extents were near whole-output size, and the log includes ordinary client changes and frame scheduling; it does not isolate popup-only damage cost or prove minimal invalidation, all exposed/vacated regions, or absence of every stale pixel. No full effect/buffer matrix was run. GTK's client-drawn popover and its nested surface were rendered; compositor SceneFX shadow and blur for a popup, title decorations, subsurfaces beyond GTK's surfaces, and saved transaction buffers were NOT TESTED. No effect was deliberately disabled by the popup-specific change.

An attempt to make the GTK owner fully hidden by adding more cells and scrolling was inconclusive: launching the new clients changed focus and GTK closed its popup before the hidden-owner measurement. Do not count this as a hidden-popup PASS. The prior tiled-cell spike's fully hidden result does not substitute for this popup case.

Test matrix

Configuration or case Result Boundary of evidence
Two horizontal 800×600 outputs, root popup at (900,190) PASS for sampled pixels and pointer events Control leaked; private clip kept popup off HEADLESS-2 and right click reached ordinary client.
Partly visible root popup PASS for tested visible and clipped-away points Left/right visible buttons reached GTK; clipped-away surface received neither.
Nested root/child popup PASS for tested visible and clipped-away points Real second XDG popup, nested visible left click and outside right click.
Camera scroll, popup reposition, workspace switch, owner destroy PARTIAL Sampled screenshots and event/lifecycle logs; transaction races and complete damage not exhausted.
Standard splith layout restoration PASS at sampled point Ordinary popup visibility/input restored after layout exit.
Floating/fullscreen conversion PARTIAL Clip clear observed; broader visual and grab parity unqualified.
Fully hidden popup owner NOT TESTED Focus change dismissed popup in attempted fixture.
Valid-serial XDG pointer/keyboard grab; non-grab popup NOT TESTED GTK request used serial zero; outside left/right behavior observed only for that control.
Vertical outputs, negative origin, scale 1.25, rotation NOT TESTED No reliable popup fixture executed in these coordinate configurations.
Output migration/resize, effect boundaries, saved transaction buffers NOT TESTED Requires more controlled fixture and damage instrumentation.

ABI, packaging and decision

The initial inherited-clip experiment's change to public struct wlr_scene_tree layout crashed; this test reused the ABI-preserving sidecar, which copies its box and removes it on tree destruction. nm -D found the private wlr_scene_tree_set_clip, wlr_scene_tree_get_clip and wlr_scene_node_at symbols in the private libscenefx-0.5.so. ldd on the private compositor resolved SceneFX from /tmp/swayfx-mosaic-popup-yEamPX/scenefx-build and wlroots from /usr/lib/libwlroots-0.20.so. This demonstrates process-local dynamic library selection, not stable ABI across wlroots or SceneFX updates. The private getter adds API surface; symbols that SceneFX and wlroots both export need version-aware review before packaging. The normal /usr/bin/sway control and installed libraries were untouched. A permanent SceneFX fork, system library replacement, production patch or package dependency is not authorized. Upstreaming a generic inherited clip remains the preferred maintenance discussion; privately bundling a versioned compatible implementation would require a separate legal, security, symbol and update plan. A second system SceneFX package would violate the preferred package architecture.

Decision: PARTIALLY_FEASIBLE. Owner-derived clipping on the existing root popup scene hierarchy closes the specific horizontal visual and pointer-hit escape and covers a tested nested popup while preserving standard splith behavior at the tested point. The result is insufficient for POPUP_FEASIBLE: valid-serial grabs, fully hidden owner, output geometry variants, transaction/output migration, complete damage and compositor effect/buffer coverage remain open. The next bounded probe should create a deterministic valid-serial grabbed popup with a controllable fully hidden owner and exercise output move/resize/transform plus damage. Decide the off-screen popup UX separately: hiding, dismissing or repositioning are all possible policies, and this probe approves none. STEP20C production implementation and multi-output support remain unauthorized.