srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/protocols
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/protocols
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz
srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has diverged with its own separate fixes today. Nothing here is reviewed or curated yet - this exists purely so none of this work can be lost to a git operation, disk issue, or worktree cleanup while that reconciliation happens.
Diffstat (limited to 'crates/wayland/src/protocols')
-rw-r--r--crates/wayland/src/protocols/buffer.rs68
-rw-r--r--crates/wayland/src/protocols/compositor.rs182
-rw-r--r--crates/wayland/src/protocols/idle.rs35
-rw-r--r--crates/wayland/src/protocols/input_method.rs86
-rw-r--r--crates/wayland/src/protocols/layer_shell.rs117
-rw-r--r--crates/wayland/src/protocols/misc.rs30
-rw-r--r--crates/wayland/src/protocols/seat.rs31
-rw-r--r--crates/wayland/src/protocols/selection.rs52
-rw-r--r--crates/wayland/src/protocols/xdg_activation.rs36
-rw-r--r--crates/wayland/src/protocols/xdg_decoration.rs63
-rw-r--r--crates/wayland/src/protocols/xdg_shell.rs258
11 files changed, 958 insertions, 0 deletions
diff --git a/crates/wayland/src/protocols/buffer.rs b/crates/wayland/src/protocols/buffer.rs
new file mode 100644
index 0000000..7730de6
--- /dev/null
+++ b/crates/wayland/src/protocols/buffer.rs
@@ -0,0 +1,68 @@
+//! `wl_shm`/`wl_buffer`/`zwp_linux_dmabuf_v1`: the three buffer-transport
+//! protocols, grouped together since none has more than a handful of lines
+//! on its own.
+
+use smithay::backend::allocator::dmabuf::Dmabuf;
+use smithay::backend::renderer::ImportDma;
+use smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer;
+use smithay::wayland::buffer::BufferHandler;
+use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
+use smithay::wayland::shm::{ShmHandler, ShmState};
+
+use crate::state::CompState;
+
+impl ShmHandler for CompState {
+ fn shm_state(&self) -> &ShmState {
+ &self.shm_state
+ }
+}
+
+impl BufferHandler for CompState {
+ fn buffer_destroyed(&mut self, _buffer: &WlBuffer) {}
+}
+
+impl DmabufHandler for CompState {
+ fn dmabuf_state(&mut self) -> &mut DmabufState {
+ &mut self.dmabuf_state
+ }
+
+ /// Validates a client's dmabuf by actually importing it wherever a
+ /// renderer is reachable from here, so a genuinely bad buffer (wrong
+ /// modifier, format the renderer doesn't support) gets the protocol
+ /// error instead of silently rendering garbage later.
+ ///
+ /// That's only the udev backend: its `PixmanRenderer` lives inside
+ /// `self.udev` (`UdevState`), a field of this same struct.
+ /// `PixmanRenderer` supports dmabuf import despite being a pure
+ /// software renderer - `dmabuf_formats()` only advertises the Linear
+ /// modifier, which it imports by mmap'ing the buffer and reading it
+ /// directly as pixels, no GPU involved. This is what actually answers
+ /// `docs/PANEL_SUPPORT_TODO.md`'s P0.3: GTK4 allocates via its own
+ /// EGL/gbm path against the real DRM render node (untouched by this
+ /// compositor either way) and hands the result here as a Linear-
+ /// modifier dmabuf, which pixman can read straight off.
+ ///
+ /// The winit (nested/dev) backend's `GlesRenderer` lives on
+ /// `WaylandPlatform`, a sibling of `CompState`, not reachable from a
+ /// method on `CompState` itself. Accepted there without eager
+ /// validation - the buffer still gets imported the same way every
+ /// other buffer type already is, lazily, the first time it is actually
+ /// rendered via `render_elements_from_surface_tree`. Real hardware,
+ /// where P0.3 actually bites, always goes through the udev path.
+ fn dmabuf_imported(&mut self, _global: &DmabufGlobal, dmabuf: Dmabuf, notifier: ImportNotifier) {
+ match self.udev.as_mut() {
+ Some(udev) => match udev.renderer.import_dmabuf(&dmabuf, None) {
+ Ok(_) => {
+ let _ = notifier.successful::<CompState>();
+ }
+ Err(e) => {
+ log::warn!("udev: rejecting dmabuf import: {e}");
+ notifier.failed();
+ }
+ },
+ None => {
+ let _ = notifier.successful::<CompState>();
+ }
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/compositor.rs b/crates/wayland/src/protocols/compositor.rs
new file mode 100644
index 0000000..f7e7b74
--- /dev/null
+++ b/crates/wayland/src/protocols/compositor.rs
@@ -0,0 +1,182 @@
+//! `wl_compositor`/`wl_surface`: surface creation and the per-commit
+//! bookkeeping every other protocol handler in this module tree depends on
+//! (window mapping, layer-surface visibility, popup lifecycle).
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::Client;
+use smithay::wayland::compositor::{add_pre_commit_hook, with_states, CompositorClientState, CompositorHandler, CompositorState};
+use smithay::wayland::shell::wlr_layer::LayerSurfaceCachedState;
+
+use crate::state::{ClientState, CompState};
+
+impl CompositorHandler for CompState {
+ fn compositor_state(&mut self) -> &mut CompositorState {
+ &mut self.compositor_state
+ }
+
+ fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState {
+ // Two possible client kinds now: our own `ClientState` for regular
+ // Wayland clients, or smithay's `XWaylandClientData` for the single
+ // XWayland client (see `xwayland.rs`) - both carry a
+ // `CompositorClientState`, just under different wrapper types.
+ if let Some(state) = client.get_data::<ClientState>() {
+ return &state.compositor_state;
+ }
+ &client.get_data::<smithay::xwayland::XWaylandClientData>().expect("client is neither ours nor XWayland's").compositor_state
+ }
+
+ /// Workaround for a real smithay bug (see docs/PANEL_SUPPORT_TODO.md and
+ /// `layer_destroyed` below): destroying a `zwlr_layer_surface_v1` role
+ /// resets the surface's `LayerSurfaceCachedState` to
+ /// `Default::default()` (size 0x0, no anchor) rather than removing it,
+ /// but the pre-commit hook smithay itself registers at
+ /// `get_layer_surface` time keeps validating that state against every
+ /// future commit regardless of whether the role still exists --
+ /// tripping its own `width/height 0 requested without ... anchors`
+ /// check and posting `invalid_size`, which kills the client's whole
+ /// connection over what is protocol-legal (committing a now-roleless
+ /// surface).
+ ///
+ /// Fixed by registering our own pre-commit hook here, in `new_surface`
+ /// - called at `wl_compositor.create_surface`, strictly before any
+ /// later `get_layer_surface` on the same surface could register
+ /// smithay's own hook. Hooks run in registration order (`tree.rs`:
+ /// `pre_commit_hooks` is a plain `Vec`, pushed and iterated in order),
+ /// so ours always runs first and can neutralize the stale reset state
+ /// before smithay's hook ever inspects it. This depends on that
+ /// ordering guarantee holding in future smithay versions - it isn't
+ /// documented as an API contract, just an implementation detail
+ /// confirmed against 0.7.0's source - so re-check this file against
+ /// whatever smithay version replaces it.
+ ///
+ /// Cost: one closure registered per `wl_surface` (not just layer
+ /// surfaces, since we don't know in advance which ones will become
+ /// one), each a no-op unless that exact surface is in
+ /// `dead_layer_surfaces`.
+ fn new_surface(&mut self, surface: &WlSurface) {
+ add_pre_commit_hook::<CompState, _>(surface, |state, _dh, surface| {
+ if !state.dead_layer_surfaces.contains(surface) {
+ return;
+ }
+ with_states(surface, |states| {
+ let mut cached = states.cached_state.get::<LayerSurfaceCachedState>();
+ let pending = cached.pending();
+ if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
+ pending.size.w = 1;
+ }
+ if pending.size.h == 0 && !pending.anchor.anchored_vertically() {
+ pending.size.h = 1;
+ }
+ });
+ });
+ }
+
+ fn commit(&mut self, surface: &WlSurface) {
+ smithay::backend::renderer::utils::on_commit_buffer_handler::<CompState>(surface);
+ // XWayland's association of an X11 window with this wl_surface can
+ // arrive at any point relative to the map request (see
+ // `xwayland.rs`'s module docs); `surface_associated` handles the
+ // common ordering, this retries the surfaces still waiting on a
+ // commit to actually make that association queryable.
+ self.retry_pending_x11_windows();
+ if let Some(&id) = self.surface_to_id.get(surface) {
+ if let Some(w) = self.id_to_window.get(&id) {
+ w.on_commit();
+ }
+ // See `content_epoch`'s doc comment: this is the only per-commit
+ // signal the udev backend's rounded-corner mask cache has to
+ // invalidate itself, since content can change every frame,
+ // independent of the geometry-driven points `redraw_decoration_
+ // buffer` already runs at.
+ *self.content_epoch.entry(id).or_insert(0) += 1;
+ crate::state::sync_toplevel_metadata(self, id, surface);
+ // `redraw_decoration_buffer` reads `dwindow.geometry()` (via
+ // `effective_frame`) to size the border/titlebar/shadow against
+ // what the client's surface *really* committed - but nothing
+ // updates that value except this very commit
+ // (`on_commit()` above). Without a call here, a client whose
+ // first real commit settles at a different size than what was
+ // requested (a terminal snapping to a whole character-cell
+ // grid) wouldn't get corrected decoration until some unrelated
+ // trigger (a resize, a focus change) happened to call this
+ // again - cheap regardless, since the signature check inside
+ // makes every commit that didn't actually change the *visible*
+ // size an early return, not a real rebuild.
+ self.redraw_decoration_buffer(id);
+ // `sync_geometry` is what actually maps this window into
+ // `self.space` at `geom.x - content_offset.x, ...` - the same
+ // `content_offset` (`dwindow.geometry().loc`) the render loop
+ // (`udev/render.rs`'s per-frame `pos` computation) reads fresh
+ // on every single frame, straight off the live surface, not
+ // from any cache. Before this call existed here, `self.space`
+ // only got a fresh position from whichever *other* trigger last
+ // called `sync_geometry` (a resize, `maximize_request`, a
+ // decoration-mode change) - so a client that recommits a
+ // *different* `xdg_surface::set_window_geometry` on its own,
+ // with no accompanying resize (a GTK4/Firefox CSD window
+ // shrinking its declared shadow margin once real content
+ // replaces its first, provisional paint, concretely), left
+ // `self.space`'s cached position silently stale while the
+ // render loop kept self-correcting every frame - confirmed
+ // live via temporary diagnostic logging: a window's real render
+ // position and `self.space`'s own `element_under`-reported
+ // position for it disagreed by exactly one `content_offset`,
+ // 10 physical pixels on both axes for the Firefox window that
+ // exposed it. `refresh_pointer_focus`'s content-click path
+ // (`input.rs`) computes `win_relative` from *that* stale
+ // position, not the render loop's fresh one - every click on
+ // such a window was silently off by the same 10px the whole
+ // time it stayed unmapped-and-remapped-by-nothing-else, which
+ // reads as "clicks land near, but not on, whatever's visibly
+ // there" - worst for a window's own small CSD buttons,
+ // exactly what was reported live. Same idempotent-when-nothing-
+ // moved shape as `redraw_decoration_buffer` above: `map_element`
+ // itself is unconditional and cheap (a hashmap insert), and the
+ // one potentially-expensive part - sending a fresh
+ // `xdg_toplevel::configure` - stays gated on `size_changed`
+ // and the existing throttle, both untouched, so a commit that
+ // didn't change size never sends one just because this call is
+ // now here too.
+ self.sync_geometry(id);
+ } else {
+ // `surface` itself isn't a tracked window's root, but may be a
+ // descendant (subsurface) of one - a real commit still
+ // happened, just not on the surface `surface_to_id` keys off.
+ // `masked_content_buffer`'s own resolver
+ // (`rounded_corners_pixman::resolve_content_surface`) reads a
+ // *child* subsurface's buffer directly for the common GTK4/
+ // WebRender pattern (confirmed live: Firefox), so a repaint
+ // that only ever commits that child - which is the normal
+ // case, that's where the real content lives - must still
+ // bump this window's own `content_epoch`, or the masked-
+ // corner cache never sees a reason to invalidate and freezes
+ // on whatever the first frame happened to show. Bounded to a
+ // handful of hops purely as a safety net against a malformed
+ // subsurface tree looping back on itself - a real one is
+ // never more than one or two levels deep.
+ let mut ancestor = smithay::wayland::compositor::get_parent(surface);
+ for _ in 0..8 {
+ let Some(parent) = ancestor else { break };
+ if let Some(&id) = self.surface_to_id.get(&parent) {
+ *self.content_epoch.entry(id).or_insert(0) += 1;
+ break;
+ }
+ ancestor = smithay::wayland::compositor::get_parent(&parent);
+ }
+ }
+ // Before `ensure_layer_initial_configure`: if this commit just
+ // hid or re-showed a layer surface, `sync_layer_visibility` needs
+ // to unmap/re-map it first, so the lookup that function does via
+ // `layer_for_surface` sees the corrected state rather than acting
+ // on stale membership in `LayerMap`'s own list.
+ self.sync_layer_visibility(surface);
+ self.ensure_layer_initial_configure(surface);
+ // Advances a just-created popup from unmapped to mapped (needed for
+ // `PopupManager::popups_for_surface`, which `popup_render_elements`
+ // reads at render time) and prunes dead ones. Cheap and only does
+ // real work on a popup-role surface, so doing it on every commit
+ // rather than throttling is not worth the extra bookkeeping.
+ self.popups.commit(surface);
+ self.popups.cleanup();
+ }
+}
diff --git a/crates/wayland/src/protocols/idle.rs b/crates/wayland/src/protocols/idle.rs
new file mode 100644
index 0000000..a554453
--- /dev/null
+++ b/crates/wayland/src/protocols/idle.rs
@@ -0,0 +1,35 @@
+//! `ext_idle_notify_v1` + `zwp_idle_inhibit_manager_v1`.
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use crate::state::CompState;
+
+/// `ext_idle_notify_v1`. All the real logic (per-notification timers,
+/// resetting them on activity, honouring inhibition) already lives in
+/// smithay's own `IdleNotifierState` - this is just the getter it needs.
+/// See `input.rs`'s `notify_idle_activity` for the other half: nothing
+/// calls `notify_activity` on its own, that has to happen from every real
+/// input path.
+impl smithay::wayland::idle_notify::IdleNotifierHandler for CompState {
+ fn idle_notifier_state(&mut self) -> &mut smithay::wayland::idle_notify::IdleNotifierState<Self> {
+ &mut self.idle_notifier_state
+ }
+}
+
+/// `zwp_idle_inhibit_manager_v1`. A video player (or anything else that
+/// wants the screen to stay on/unlocked while it runs) creates one of
+/// these tied to its own surface; as long as at least one is alive,
+/// `IdleNotifierState::set_is_inhibited` stops idle timers from firing at
+/// all - see `idle_inhibiting_surfaces`'s doc comment on `CompState` for
+/// the one simplification (not workspace-visibility-aware) this takes.
+impl smithay::wayland::idle_inhibit::IdleInhibitHandler for CompState {
+ fn inhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.push(surface);
+ self.idle_notifier_state.set_is_inhibited(true);
+ }
+
+ fn uninhibit(&mut self, surface: WlSurface) {
+ self.idle_inhibiting_surfaces.retain(|s| s != &surface);
+ self.idle_notifier_state.set_is_inhibited(!self.idle_inhibiting_surfaces.is_empty());
+ }
+}
diff --git a/crates/wayland/src/protocols/input_method.rs b/crates/wayland/src/protocols/input_method.rs
new file mode 100644
index 0000000..525383f
--- /dev/null
+++ b/crates/wayland/src/protocols/input_method.rs
@@ -0,0 +1,86 @@
+//! `zwp_text_input_manager_v3` + `zwp_input_method_manager_v2`: lets a real
+//! input method (fcitx5, ibus, any CJK/dead-key/emoji-picker IME) attach to
+//! whichever surface has keyboard focus and draw its own candidate/
+//! composition popup. Without these two globals a client that only speaks
+//! text-input (most modern toolkits do, GTK4/Qt6 included) has no way to
+//! tell the compositor "I have an editable text field, here is its cursor
+//! rectangle" - every desktop app's search box, address bar, and chat
+//! input silently loses IME support, not just an edge case.
+//!
+//! Focus tracking needs *no* wiring here at all: `CompState::KeyboardFocus`
+//! is a plain `WlSurface`, and smithay's own blanket `impl KeyboardTarget
+//! for WlSurface` already calls `seat.text_input().set_focus/.enter()/
+//! .leave()` and `seat.input_method().activate_input_method()/
+//! deactivate_input_method()` from inside `enter`/`leave` - which
+//! `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call already
+//! triggers on every real focus change. The only things actually missing
+//! were the two manager globals and this handler for the popup surface
+//! lifecycle.
+
+use smithay::desktop::PopupKind;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::wayland::input_method::PopupSurface as ImePopupSurface;
+
+use crate::state::CompState;
+
+impl smithay::wayland::input_method::InputMethodHandler for CompState {
+ /// A candidate/composition window (an emoji picker, a CJK candidate
+ /// list) just opened. Tracked as a regular [`PopupKind::InputMethod`]
+ /// in the same [`PopupManager`](smithay::desktop::PopupManager) that
+ /// already owns every `xdg_popup` - `elements::popup_render_elements`
+ /// renders both kinds identically, so no separate render path is
+ /// needed for this to actually become visible.
+ fn new_popup(&mut self, surface: ImePopupSurface) {
+ if let Err(e) = self.popups.track_popup(PopupKind::from(surface)) {
+ log::warn!("input-method: failed to track popup: {e}");
+ }
+ }
+
+ fn dismiss_popup(&mut self, surface: ImePopupSurface) {
+ if let Some(parent) = surface.get_parent().map(|p| p.surface.clone()) {
+ let _ = smithay::desktop::PopupManager::dismiss_popup(&parent, &PopupKind::from(surface));
+ }
+ }
+
+ /// The IME moved its own popup (e.g. following the text cursor as the
+ /// user types) - `PopupSurface::location()` already reflects the new
+ /// position; nothing else needs updating on this side, matching every
+ /// other smithay-based compositor's own no-op here.
+ fn popup_repositioned(&mut self, _surface: ImePopupSurface) {}
+
+ /// Where the IME should anchor its popup, in the parent surface's own
+ /// output-independent (logical, window-relative-origin) space - same
+ /// geometry `elements::popup_targets` already computes for xdg popups,
+ /// reused here rather than duplicated. A window not yet tracked (the
+ /// activation raced ahead of its own mapping) gets a default/zero rect,
+ /// same "no-op rather than an error" stance as `request_activation`
+ /// above.
+ fn parent_geometry(&self, parent: &WlSurface) -> smithay::utils::Rectangle<i32, smithay::utils::Logical> {
+ let Some(&id) = self.surface_to_id.get(parent) else {
+ return smithay::utils::Rectangle::default();
+ };
+ let (geometry, decorated) = {
+ let wm = self.wm.borrow();
+ let Some(w) = wm.window(id) else {
+ return smithay::utils::Rectangle::default();
+ };
+ (w.geometry, w.decorated)
+ };
+ let band = if decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 };
+ // `content_offset`/`effective_frame`: same corrections every other
+ // real position/size computation in this codebase applies (see
+ // `state/geometry.rs::effective_frame`'s doc comment) - missed
+ // here originally, so an IME popup anchored against a CSD window's
+ // raw, unshifted geometry instead of its real visible content,
+ // same class of drift as the border/screenshot gaps fixed
+ // elsewhere.
+ let content_offset = self.id_to_window.get(&id).map(|w| w.geometry().loc).unwrap_or_default();
+ // `frame.height` includes the titlebar band (see `effective_frame`'s
+ // own doc comment) - subtracted back out here since this rect is
+ // meant to cover the content area only, matching the original
+ // (pre-fix) code's own intent for `w.geometry.height`.
+ let frame = self.effective_frame(id, geometry);
+ let content_height = (frame.height as i32 - band).max(0);
+ smithay::utils::Rectangle::new((frame.x - content_offset.x, frame.y + band - content_offset.y).into(), (frame.width as i32, content_height).into())
+ }
+}
diff --git a/crates/wayland/src/protocols/layer_shell.rs b/crates/wayland/src/protocols/layer_shell.rs
new file mode 100644
index 0000000..931ddb4
--- /dev/null
+++ b/crates/wayland/src/protocols/layer_shell.rs
@@ -0,0 +1,117 @@
+//! `zwlr_layer_shell_v1`: panels, bars, launchers, and other output-anchored
+//! shell surfaces (AGS's own bar and popups, notably).
+
+use smithay::desktop::{layer_map_for_output, LayerSurface as DesktopLayerSurface};
+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
+use smithay::reexports::wayland_server::Resource;
+use smithay::wayland::shell::wlr_layer::{Layer, LayerSurface as WlrLayerSurface, WlrLayerShellHandler, WlrLayerShellState};
+
+use crate::state::CompState;
+
+impl WlrLayerShellHandler for CompState {
+ fn shell_state(&mut self) -> &mut WlrLayerShellState {
+ &mut self.layer_shell_state
+ }
+
+ fn new_layer_surface(&mut self, surface: WlrLayerSurface, wl_output: Option<WlOutput>, _layer: Layer, namespace: String) {
+ // Logged before anything else can early-return or panic: the
+ // question this answers (see docs/PANEL_SUPPORT_TODO.md) is
+ // whether this handler is reached AT ALL for a later
+ // `get_layer_surface` request in a create -> commit -> destroy ->
+ // commit-again -> create sequence, or whether the client's
+ // dispatch is already dead by then and this never runs.
+ log::debug!("layer-shell: new_layer_surface entered, surface={:?} namespace={namespace:?} output_named={}", surface.wl_surface().id(), wl_output.is_some());
+ // A client may name the output it wants (a bar on a specific
+ // monitor); if it doesn't, or names one we don't drive, it lands on
+ // the primary output.
+ let output = wl_output
+ .as_ref()
+ .and_then(|wl| self.output_for_wl(wl))
+ .map(|e| e.output.clone())
+ .or_else(|| self.primary_output().cloned());
+ let Some(output) = output else {
+ log::warn!("wayland: layer surface requested but no output exists yet");
+ return;
+ };
+ // Paired with the debug log in `ensure_layer_initial_configure`'s
+ // early return - see docs/PANEL_SUPPORT_TODO.md's P0. This is the
+ // other half of "did map_layer actually succeed, and on which
+ // output": logged unconditionally (not just on the error paths
+ // that already existed) so a real reproduction shows both sides of
+ // the handoff instead of just the failure.
+ let surface_id = surface.wl_surface().id();
+ let layer_surface = DesktopLayerSurface::new(surface, namespace);
+ let result = layer_map_for_output(&output).map_layer(&layer_surface);
+ match &result {
+ Ok(()) => log::debug!("layer-shell: mapped surface {surface_id:?} onto output {}", output.name()),
+ Err(e) => log::warn!("wayland: failed to map layer surface {surface_id:?}: {e}"),
+ }
+ }
+
+ fn layer_destroyed(&mut self, surface: WlrLayerSurface) {
+ // See the matching top-of-function log in `new_layer_surface`.
+ log::debug!("layer-shell: layer_destroyed entered, surface={:?}", surface.wl_surface().id());
+ // Marks this surface for the pre-commit-hook workaround in
+ // `new_surface` - see that function's doc comment for the bug
+ // this exists to route around.
+ self.dead_layer_surfaces.insert(surface.wl_surface().clone());
+ // GTK (confirmed live via an AGS peer session's WAYLAND_DEBUG trace)
+ // reuses the same `wl_surface` for the next `get_layer_surface` role
+ // rather than creating a fresh one - so without this, a "shown at
+ // least once" flag from *this* role would leak onto the next one
+ // and make `sync_layer_visibility` treat that new role's own
+ // ack-configure commit as eligible to hide again, the same bug
+ // `layer_surfaces_shown_once` exists to prevent, just reintroduced
+ // for exactly the reused-surface case that matters here.
+ self.layer_surfaces_shown_once.remove(surface.wl_surface());
+ // The surface belongs to exactly one output's map, but which one is
+ // the client's choice, so unmap from whichever holds it.
+ for output in self.outputs().cloned().collect::<Vec<_>>() {
+ let mut map = layer_map_for_output(&output);
+ let found = map.layers().find(|l| l.layer_surface() == &surface).cloned();
+ if let Some(layer) = found {
+ // Same zone-change recompute `ensure_layer_initial_configure`
+ // already does on every commit that changes a layer's
+ // exclusive zone (state/layers.rs) - but this is the *only* place
+ // that ever runs for a surface that goes away without one
+ // last commit. `unmap_layer` alone doesn't trigger it:
+ // reported live (by the AGS peer session) as a bar unmapping
+ // for fullscreen yet `srd monitors` still reporting the
+ // bar's old reserved_top for as long as fullscreen lasted --
+ // harmless there only because fullscreen targets
+ // `full_geometry`, which ignores the reservation anyway, but
+ // wrong for anything that reads `usable`/`geometry` while a
+ // bar is unmapped without exiting cleanly (a crash, not just
+ // AGS's cooperative fullscreen hide).
+ let zone_before = map.non_exclusive_zone();
+ map.unmap_layer(&layer);
+ let zone_after = map.non_exclusive_zone();
+ if zone_after != zone_before {
+ self.pending.borrow_mut().push(srdwm_core::Event::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0))));
+ }
+ break;
+ }
+ }
+ // A lock/launcher surface holding exclusive keyboard focus just
+ // vanished (crash, or a normal close) - don't leave focus dangling
+ // on a dead surface.
+ //
+ // `sync_keyboard_focus`, not a bare `set_keyboard_focus(None)`: an
+ // `OnDemand` layer surface (a launcher/quicksettings/datemenu
+ // popup, per `wlr-layer-shell`) claiming focus on click
+ // (`input.rs`'s `on_demand` branch) goes straight through
+ // `set_keyboard_focus` without ever touching `WindowManager::
+ // focused` - core has no concept of a layer surface to focus, so
+ // it still correctly points at whatever real toplevel was focused
+ // before the popup opened. Hardcoding `None` here threw that away
+ // regardless, leaving nothing focused until the user happened to
+ // click a window again - reported live (an AGS peer session's
+ // user) as "focus never returns after using the bar". `sync_
+ // keyboard_focus` reads that still-correct core state and restores
+ // real Wayland focus to it, falling through to `None` only if core
+ // genuinely has nothing focused either.
+ if self.seat.get_keyboard().and_then(|k| k.current_focus()).as_ref() == Some(surface.wl_surface()) {
+ crate::input::sync_keyboard_focus(self);
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/misc.rs b/crates/wayland/src/protocols/misc.rs
new file mode 100644
index 0000000..7999ec3
--- /dev/null
+++ b/crates/wayland/src/protocols/misc.rs
@@ -0,0 +1,30 @@
+//! Small protocol handlers whose entire implementation is smithay's own
+//! no-op default - the global still has to exist for clients that treat it
+//! as mandatory, but there's nothing for this compositor to do in response.
+
+use smithay::wayland::tablet_manager::TabletSeatHandler;
+
+use crate::state::CompState;
+
+impl smithay::wayland::output::OutputHandler for CompState {}
+
+/// `wp_cursor_shape_v1`: lets a client ask for a *named* cursor (text,
+/// grab, resize edges, ...) instead of rendering and attaching its own
+/// surface. Its requests route straight into `SeatHandler::cursor_image`
+/// (see `seat.rs`), same as a client-drawn cursor surface does - no extra
+/// state on our side. Without this global at all, a client that only speaks
+/// this (increasingly the norm - recent GTK4/Firefox use it for most
+/// cursor changes) has no way to tell us the pointer should look like
+/// anything but whatever it last was, which reads as the cursor going
+/// stale, wrong, or simply disappearing depending on what was showing when
+/// the client gave up trying.
+///
+/// `TabletSeatHandler` is a supertrait bound of this protocol's `Dispatch`
+/// impl (cursor-shape covers tablet tools too); srdwm has no tablet
+/// support to speak of, so every method is left at its no-op default.
+impl TabletSeatHandler for CompState {}
+
+/// Fractional scaling. srdwm runs every output at scale 1, so there is
+/// nothing to compute - but the global has to exist, because clients that
+/// use it (notably wallpaper daemons) treat it as mandatory.
+impl smithay::wayland::fractional_scale::FractionalScaleHandler for CompState {}
diff --git a/crates/wayland/src/protocols/seat.rs b/crates/wayland/src/protocols/seat.rs
new file mode 100644
index 0000000..bd7ae4c
--- /dev/null
+++ b/crates/wayland/src/protocols/seat.rs
@@ -0,0 +1,31 @@
+//! `wl_seat`: keyboard/pointer/touch focus types and the client-set cursor
+//! image.
+
+use smithay::input::pointer::CursorImageStatus;
+use smithay::input::{Seat, SeatHandler, SeatState};
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+
+use crate::state::CompState;
+
+impl SeatHandler for CompState {
+ type KeyboardFocus = WlSurface;
+ type PointerFocus = WlSurface;
+ type TouchFocus = WlSurface;
+
+ fn seat_state(&mut self) -> &mut SeatState<Self> {
+ &mut self.seat_state
+ }
+
+ fn focus_changed(&mut self, _seat: &Seat<Self>, _focused: Option<&WlSurface>) {}
+ /// Clients set their own cursor (an I-beam over text, a hand over a
+ /// link). Recorded here and drawn by the render paths - on a bare TTY
+ /// nothing else would draw it. See `cursor.rs`.
+ fn cursor_image(&mut self, _seat: &Seat<Self>, image: CursorImageStatus) {
+ self.cursor_status = image;
+ // The client has now explicitly claimed the cursor - see
+ // `decoration_cursor_active`'s own doc comment and `input.rs::
+ // update_cursor_shape` for why this has to be tracked separately
+ // from just overwriting `cursor_status`.
+ self.decoration_cursor_active = false;
+ }
+}
diff --git a/crates/wayland/src/protocols/selection.rs b/crates/wayland/src/protocols/selection.rs
new file mode 100644
index 0000000..fadb0ca
--- /dev/null
+++ b/crates/wayland/src/protocols/selection.rs
@@ -0,0 +1,52 @@
+//! Clipboard/primary-selection/drag-and-drop.
+//!
+//! All three selection protocols below (`wl_data_device_manager`,
+//! `zwp_primary_selection_v1`, `zwlr_data_control_manager_v1`) share
+//! smithay's single `SelectionHandler`. Every transfer here is
+//! *client-to-client*: one client owns the selection and writes the bytes
+//! itself, and smithay wires the two ends together without the data passing
+//! through us. `send_selection` is only ever called for a
+//! **compositor-provided** selection (one this WM set itself via
+//! `set_data_device_selection`), which srdwm never does - so it is
+//! deliberately left unimplemented rather than faked.
+
+use smithay::wayland::selection::data_device::{ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler};
+use smithay::wayland::selection::primary_selection::{PrimarySelectionHandler, PrimarySelectionState};
+use smithay::wayland::selection::wlr_data_control::{DataControlHandler, DataControlState};
+use smithay::wayland::selection::SelectionHandler;
+
+use crate::state::CompState;
+
+impl SelectionHandler for CompState {
+ type SelectionUserData = ();
+}
+
+impl DataDeviceHandler for CompState {
+ fn data_device_state(&self) -> &DataDeviceState {
+ &self.data_device_state
+ }
+}
+
+// Drag-and-drop: the default trait methods already do the right thing for a
+// compositor that doesn't draw its own drag icon or offer server-side drag
+// sources - smithay runs the pointer grab and the offer/accept negotiation
+// internally. Both are implemented empty (rather than skipped) because
+// `DataDeviceHandler` requires them as supertraits.
+impl ClientDndGrabHandler for CompState {}
+impl ServerDndGrabHandler for CompState {}
+
+impl PrimarySelectionHandler for CompState {
+ fn primary_selection_state(&self) -> &PrimarySelectionState {
+ &self.primary_selection_state
+ }
+}
+
+/// `zwlr_data_control_manager_v1`: lets a client read/watch the selection
+/// without ever holding keyboard focus. This is what `wl-paste --watch`
+/// (and thus `cliphist store`, which the user's session autostarts) needs
+/// - a focus-following clipboard manager is impossible without it.
+impl DataControlHandler for CompState {
+ fn data_control_state(&self) -> &DataControlState {
+ &self.data_control_state
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_activation.rs b/crates/wayland/src/protocols/xdg_activation.rs
new file mode 100644
index 0000000..846a213
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_activation.rs
@@ -0,0 +1,36 @@
+//! `xdg_activation_v1`: a launcher hands a spawned app a token, and the
+//! app's own first window presents it back to ask to be raised and focused.
+
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::wayland::xdg_activation::{XdgActivationHandler, XdgActivationState, XdgActivationToken, XdgActivationTokenData};
+
+use crate::state::CompState;
+
+impl XdgActivationHandler for CompState {
+ fn activation_state(&mut self) -> &mut XdgActivationState {
+ &mut self.xdg_activation_state
+ }
+
+ /// A launcher spawns an app after first getting a token
+ /// (`get_activation_token`) and handing it to the new process (usually
+ /// via `XDG_ACTIVATION_TOKEN`); the app's own first window then
+ /// presents that same token back here via `activate`, asking to be
+ /// raised. Without this, that request was silently ignored - the new
+ /// window opened and just sat there unfocused behind everything,
+ /// exactly the gap `docs/PANEL_SUPPORT_TODO.md`'s P1 flagged.
+ ///
+ /// No token bookkeeping of our own: `token_created`'s default already
+ /// accepts every token (fine for a single-user session with no
+ /// cross-client trust boundary to enforce), so all that's left is
+ /// mapping the activating `surface` to a `WindowId` and reusing the
+ /// exact same `focus_window` path a dock's "activate" request already
+ /// goes through (`foreign_toplevel.rs`). If the surface isn't tracked
+ /// yet - the activation raced ahead of this window's own mapping --
+ /// there is nothing to focus yet, so this is a no-op rather than an
+ /// error; the protocol doesn't require honoring every activation.
+ fn request_activation(&mut self, _token: XdgActivationToken, _token_data: XdgActivationTokenData, surface: WlSurface) {
+ if let Some(&id) = self.surface_to_id.get(&surface) {
+ crate::input::focus_window(self, id);
+ }
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_decoration.rs b/crates/wayland/src/protocols/xdg_decoration.rs
new file mode 100644
index 0000000..25973c0
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_decoration.rs
@@ -0,0 +1,63 @@
+//! `zxdg_decoration_manager_v1`: negotiates whether a toplevel draws its own
+//! (client-side) chrome or lets us draw it (server-side).
+
+use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode;
+use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
+use smithay::wayland::shell::xdg::ToplevelSurface;
+
+use crate::state::CompState;
+
+impl XdgDecorationHandler for CompState {
+ /// Offers whichever mode `theme.decorations.default_mode`/`srd set
+ /// decoration_mode` currently prefers - a client with a real opinion
+ /// of its own still overrides this via `request_mode` below regardless
+ /// of what's offered here; this only decides what a client with *no*
+ /// preference ends up with. See `srdwm_core::ThemeConfig::
+ /// default_decorated`'s own doc comment for why this is configurable
+ /// rather than hardcoded to one mode.
+ fn new_decoration(&mut self, toplevel: ToplevelSurface) {
+ let offer = if self.wm.borrow().theme.default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(offer);
+ });
+ }
+
+ /// Honors whichever mode the client actually asked for, rather than
+ /// always forcing server-side - and mirrors the result into our own
+ /// `Window.decorated`, so a client drawing its own titlebar doesn't
+ /// *also* get one drawn on top of it by us.
+ ///
+ /// Always forcing `ServerSide` (what this used to do) is why some
+ /// clients ended up with two sets of window buttons: Firefox requests
+ /// client-side decoration when its own "use system titlebar" setting
+ /// is off, and draws its own close/minimize/maximize row regardless of
+ /// what the compositor grants - so forcing server-side just added
+ /// srdwm's row on top of the one Firefox was drawing anyway, instead
+ /// of preventing it. Respecting the request means srdwm steps out of
+ /// the way for exactly those clients, while everything that accepts
+ /// (or has no preference and gets offered) server-side still gets our
+ /// titlebar as before.
+ fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) {
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(mode);
+ });
+ toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), mode == DecorationMode::ServerSide);
+ }
+
+ /// The client dropped its decoration-mode preference. `new_decoration`
+ /// already offers the configured default as the mode the next
+ /// configure will carry, so mirror that same default here rather than
+ /// leaving whatever mode was negotiated before this - otherwise a
+ /// client that requests one mode, then later unsets it expecting the
+ /// default back, would stay stuck in that mode forever.
+ fn unset_mode(&mut self, toplevel: ToplevelSurface) {
+ let default_decorated = self.wm.borrow().theme.default_decorated;
+ let mode = if default_decorated { DecorationMode::ServerSide } else { DecorationMode::ClientSide };
+ toplevel.with_pending_state(|state| {
+ state.decoration_mode = Some(mode);
+ });
+ toplevel.send_configure();
+ self.set_decorated_from_mode(toplevel.wl_surface(), default_decorated);
+ }
+}
diff --git a/crates/wayland/src/protocols/xdg_shell.rs b/crates/wayland/src/protocols/xdg_shell.rs
new file mode 100644
index 0000000..4fe0f6f
--- /dev/null
+++ b/crates/wayland/src/protocols/xdg_shell.rs
@@ -0,0 +1,258 @@
+//! `xdg_shell`: toplevel and popup lifecycle, and the client-initiated
+//! move/resize/maximize/fullscreen/minimize requests a CSD client sends
+//! instead of (or alongside) the pointer-driven titlebar handlers in
+//! `input.rs`.
+
+use smithay::desktop::{find_popup_root_surface, PopupKeyboardGrab, PopupKind, PopupPointerGrab};
+use smithay::input::pointer::Focus;
+use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
+use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
+use smithay::reexports::wayland_server::protocol::wl_seat;
+use smithay::reexports::wayland_server::Resource;
+use smithay::utils::Serial;
+use smithay::wayland::shell::xdg::{PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState};
+
+use crate::state::CompState;
+
+impl XdgShellHandler for CompState {
+ fn xdg_shell_state(&mut self) -> &mut XdgShellState {
+ &mut self.xdg_shell_state
+ }
+
+ fn new_toplevel(&mut self, surface: ToplevelSurface) {
+ self.new_managed_window(surface);
+ }
+
+ /// `move_request`/`resize_request` were also still smithay's default
+ /// no-op implementations - a much larger gap than the five below:
+ /// this is *how a client-side-decorated window gets dragged or resized
+ /// by its own titlebar/edges at all*. A window we draw our own
+ /// decoration for never needed this (`TitlebarHit::Drag`/`Resize` in
+ /// `input.rs` detect the click directly, since we own those pixels),
+ /// but a window that negotiated client-side decoration and draws its
+ /// own titlebar - Firefox, and most GTK4 apps by default - handles
+ /// the click itself and then asks the compositor to actually perform
+ /// the move/resize via exactly these two requests. Left unimplemented,
+ /// dragging or resizing any such window by its own chrome did
+ /// nothing at all - the only way to reposition it was the
+ /// modifier+drag-anywhere gesture (`bindm`), which most users have no
+ /// reason to know exists and doesn't work for resize-from-a-specific-
+ /// edge at all. Reuses the exact same `WindowManager::start_drag`/
+ /// `start_resize` the pointer-driven titlebar handlers call --
+ /// `handle_pointer_position`/`handle_pointer_button` already drive any
+ /// in-progress drag/resize to completion on subsequent motion/release
+ /// regardless of what started it, so no smithay pointer grab is
+ /// needed here at all, just the same start call from a different
+ /// trigger.
+ fn move_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial) {
+ // Temporary: added to trace a live report that dragging a CSD
+ // window (Firefox) by its own tab strip/header bar does nothing --
+ // this is the only way to tell "the client never sent xdg_toplevel
+ // ::move at all" apart from "it sent it and something downstream
+ // of here didn't follow through." Remove once that's settled.
+ match self.surface_to_id.get(surface.wl_surface()) {
+ Some(&id) => {
+ let pos = crate::input::last_pointer_pos(self);
+ log::info!("move_request: window {id:?} at pointer {pos:?}");
+ self.wm.borrow_mut().start_drag(id, pos.x as i32, pos.y as i32);
+ }
+ None => log::warn!("move_request: surface has no tracked window id"),
+ }
+ }
+
+ fn resize_request(&mut self, surface: ToplevelSurface, _seat: wl_seat::WlSeat, _serial: Serial, edges: xdg_toplevel::ResizeEdge) {
+ let Some(edge) = (match edges {
+ xdg_toplevel::ResizeEdge::Top => Some(srdwm_core::ResizeEdge::Top),
+ xdg_toplevel::ResizeEdge::Bottom => Some(srdwm_core::ResizeEdge::Bottom),
+ xdg_toplevel::ResizeEdge::Left => Some(srdwm_core::ResizeEdge::Left),
+ xdg_toplevel::ResizeEdge::Right => Some(srdwm_core::ResizeEdge::Right),
+ xdg_toplevel::ResizeEdge::TopLeft => Some(srdwm_core::ResizeEdge::TopLeft),
+ xdg_toplevel::ResizeEdge::TopRight => Some(srdwm_core::ResizeEdge::TopRight),
+ xdg_toplevel::ResizeEdge::BottomLeft => Some(srdwm_core::ResizeEdge::BottomLeft),
+ xdg_toplevel::ResizeEdge::BottomRight => Some(srdwm_core::ResizeEdge::BottomRight),
+ // `None` is a valid protocol value (the client leaves the edge
+ // unspecified) but `WindowManager::start_resize` needs one --
+ // there's nothing sensible to default it to that wouldn't be a
+ // guess, so this is a no-op rather than picking one.
+ _ => None,
+ }) else {
+ return;
+ };
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ let pos = crate::input::last_pointer_pos(self);
+ self.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32);
+ }
+ }
+
+ /// `maximize_request`/`unmaximize_request`/`fullscreen_request`/
+ /// `unfullscreen_request`/`minimize_request` were all still smithay's
+ /// default no-op (or configure-only) implementations - found
+ /// investigating the `toggle_fullscreen` decoration bug above, by
+ /// checking what else routes through the same `WindowManager` calls
+ /// the titlebar-button click handlers in `input.rs` already use.
+ /// These five are the *client-initiated* equivalent of those clicks: a
+ /// client's own window-menu "Maximize", pressing F11, an HTML5 video
+ /// going fullscreen, or (for a client that negotiated client-side
+ /// decoration and draws its own titlebar, like Firefox) that titlebar's
+ /// own maximize button - all ask the compositor to actually perform
+ /// the state change via these requests rather than the compositor
+ /// noticing on its own. Left unimplemented, every one of them was a
+ /// silent no-op: the client's button did nothing, with no error and
+ /// nothing to suggest why, from any app that relies on this instead of
+ /// (or in addition to) a compositor-side keybinding.
+ fn maximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unmaximize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().window(id).is_some_and(|w| w.maximized) {
+ self.wm.borrow_mut().toggle_maximize(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ /// `_output` (the client's requested target output) is ignored --
+ /// single-seat, and every other fullscreen entry point (the titlebar
+ /// button, `srd.window.fullscreen()`) already fullscreens on whatever
+ /// monitor the window is already on, so this matches that instead of
+ /// introducing an output-aware fullscreen path only this one request
+ /// would use.
+ fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if !self.wm.borrow().is_fullscreen(id) {
+ // `redraw_decoration_buffer` first, same reason
+ // `set_decorated_from_mode` calls it before `sync_geometry`:
+ // fullscreen also flips `Window.decorated`, and dropping
+ // the decoration needs the buffer actually removed, not
+ // just left stale for `sync_geometry`'s own resize-only
+ // redraw check to skip.
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ if self.wm.borrow().is_fullscreen(id) {
+ self.wm.borrow_mut().toggle_fullscreen(id);
+ self.redraw_decoration_buffer(id);
+ self.sync_geometry(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+ surface.send_configure();
+ }
+
+ /// No `send_configure` here, matching the pointer-driven
+ /// `TitlebarHit::Minimize` handler in `input.rs`: minimizing doesn't
+ /// change the window's own size, only whether it's currently shown, so
+ /// there's nothing new to tell the client about its own geometry.
+ fn minimize_request(&mut self, surface: ToplevelSurface) {
+ if let Some(&id) = self.surface_to_id.get(surface.wl_surface()) {
+ self.wm.borrow_mut().minimize_window(id);
+ crate::foreign_toplevel::send_state(self, id);
+ }
+ }
+
+ /// Was a bare no-op - no `send_configure` at all. Per xdg-shell,
+ /// `xdg_surface.configure` is required before a popup's first commit;
+ /// real toolkits (confirmed live: GTK4's Wayland backend) block that
+ /// commit in a synchronous roundtrip waiting for it, so every popup
+ /// hung its client forever. GTK4 implements tooltips *and*
+ /// `Gtk.Popover` as `xdg_popup`, so this fired on hovering almost any
+ /// widget with a tooltip - confirmed by a peer session's gdb backtrace
+ /// (blocked in `wl_display_dispatch_queue` under `gtk_widget_show`)
+ /// after AGS wedged.
+ ///
+ /// Geometry is `positioner.get_geometry()` un-constrained - no
+ /// on-screen clamping yet (`PositionerState::get_unconstrained_geometry`
+ /// needs a target rect in the parent's surface-local space, which is a
+ /// real follow-up, not this fix); an occasional popup placed near a
+ /// screen edge may render partly off it, which is cosmetic, not a hang.
+ fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) {
+ // Temporary: live report is that Nemo's right-click context menu
+ // never appears at all (not mispositioned - entirely invisible),
+ // while the exact same xdg_popup mechanism works for Firefox. Logs
+ // the unconstrained geometry this popup gets so a live repro tells
+ // us whether it's landing off-screen/degenerate (the known gap this
+ // function's own doc comment already flags) or something else
+ // entirely. Remove once resolved.
+ let geom = positioner.get_geometry();
+ let parent = surface.get_parent_surface();
+ log::warn!("POPUP-GEOM-DIAG geometry={geom:?} parent={:?}", parent.as_ref().map(|s| s.id()));
+ surface.with_pending_state(|state| {
+ state.geometry = geom;
+ state.positioner = positioner;
+ });
+ if surface.send_configure().is_err() {
+ log::warn!("POPUP-GEOM-DIAG send_configure failed");
+ return;
+ }
+ let _ = self.popups.track_popup(smithay::desktop::PopupKind::Xdg(surface));
+ }
+
+ /// Implicit grab + dismiss-on-outside-click. Previously believed
+ /// blocked on `CompState`'s `SeatHandler` associated types not
+ /// satisfying `PopupManager::grab_popup`'s `WaylandFocus +
+ /// From<PopupKind>` bound - rechecked while implementing
+ /// `move_request`/`resize_request` (same trait, adjacent methods) and
+ /// it turns out they already do: `KeyboardFocus`/`PointerFocus` are
+ /// both plain `WlSurface`, smithay provides `impl From<PopupKind> for
+ /// WlSurface` itself, and `WlSurface: From<WlSurface>` trivially. No
+ /// blocker ever existed by the time of this pass; the bound just
+ /// hadn't been rechecked since being noted as unmet.
+ ///
+ /// `self.seat.clone()` rather than resolving `_seat` (the client's
+ /// `wl_seat` resource) via `Seat::from_resource` - this compositor
+ /// only ever has the one seat, matching how `move_request`/
+ /// `resize_request` already ignore the same parameter.
+ fn grab(&mut self, surface: PopupSurface, _seat: wl_seat::WlSeat, serial: Serial) {
+ let popup = PopupKind::Xdg(surface);
+ let Ok(root) = find_popup_root_surface(&popup) else {
+ log::warn!("POPUP-GRAB-DIAG find_popup_root_surface failed");
+ return;
+ };
+ let seat = self.seat.clone();
+ let grab = match self.popups.grab_popup(root, popup, &seat, serial) {
+ Ok(g) => g,
+ Err(e) => {
+ log::warn!("POPUP-GRAB-DIAG grab_popup failed: {e:?}");
+ return;
+ }
+ };
+ log::warn!("POPUP-GRAB-DIAG grab established, has_pointer={} has_keyboard={}", seat.get_pointer().is_some(), seat.get_keyboard().is_some());
+ if let Some(keyboard) = seat.get_keyboard() {
+ keyboard.set_grab(self, PopupKeyboardGrab::new(&grab), serial);
+ }
+ if let Some(pointer) = seat.get_pointer() {
+ pointer.set_grab(self, PopupPointerGrab::new(&grab), serial, Focus::Keep);
+ }
+ }
+
+ fn reposition_request(&mut self, surface: PopupSurface, positioner: PositionerState, token: u32) {
+ surface.with_pending_state(|state| {
+ state.geometry = positioner.get_geometry();
+ state.positioner = positioner;
+ });
+ surface.send_repositioned(token);
+ }
+
+ fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
+ self.remove_window(surface.wl_surface());
+ }
+}