diff options
| -rw-r--r-- | crates/core/src/window.rs | 111 | ||||
| -rw-r--r-- | crates/platform/src/appmenu_registrar.rs | 142 | ||||
| -rw-r--r-- | crates/wayland/src/appmenu.rs | 124 | ||||
| -rw-r--r-- | crates/wayland/src/gtk_shell.rs | 33 | ||||
| -rw-r--r-- | crates/x11/src/platform/connect.rs | 1 | ||||
| -rw-r--r-- | crates/x11/src/platform/global_menu.rs | 90 | ||||
| -rw-r--r-- | crates/x11/src/platform/mod.rs | 30 | ||||
| -rw-r--r-- | crates/x11/src/platform/trait_impl.rs | 15 |
8 files changed, 533 insertions, 13 deletions
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs index 850b936..7df7b5c 100644 --- a/crates/core/src/window.rs +++ b/crates/core/src/window.rs @@ -45,16 +45,78 @@ pub struct GlobalMenu { /// `app.xxx`/`win.xxx`; a consumer must insert two action groups, under /// prefixes `"app"` and `"win"`, from [`GlobalMenu::app_path`]/ /// [`GlobalMenu::window_path`] respectively. -/// - [`MenuSource::Unity`]: the older Ubuntu Unity-era export -/// (`_UNITY_OBJECT_PATH`, still relevant for some Qt platform-theme -/// builds). Items reference actions as `unity.xxx`, all against one -/// group at the menu's own path - a consumer inserts a single group -/// under prefix `"unity"` instead. +/// - [`MenuSource::Unity`]: still a `GMenuModel` underneath - same +/// `org.gtk.Menus`/`Gio.DBusMenuModel`-compatible wire content as +/// [`MenuSource::Gtk`] - but from `appmenu-gtk-module`'s Unity- +/// compatibility shim (a plain `Gtk.Window` with no `GtkApplication`), +/// which serves it under one `unity.xxx`-prefixed action group at the +/// menu's own path instead of separate `app.xxx`/`win.xxx` groups. +/// Confirmed live by an AGS peer session reading the actual bus content +/// for this exact case: `_GTK_MENUBAR_OBJECT_PATH` and +/// `_UNITY_OBJECT_PATH` set to the *same* `org.gtk.Menus` object, real +/// content, `unity.File`/`unity.Edit` actions - not a different wire +/// protocol, just a different action-group prefix. A consumer that +/// already speaks `Gio.DBusMenuModel` for [`Self::Gtk`] needs nothing +/// more than reading this variant to also handle this one. +/// - [`MenuSource::DbusMenu`]: a genuinely different wire protocol, +/// `com.canonical.dbusmenu` - what a client sets `_UNITY_OBJECT_PATH` +/// for *without* any `_GTK_*` atom alongside it (the original pre-GTK3.4 +/// Ubuntu Unity export this atom was created for, and what `appmenu- +/// qt5`'s classic Unity-registrar model still uses), or what the +/// Wayland-native `org_kde_kwin_appmenu` protocol always carries. +/// `Gio.DBusMenuModel` cannot read this at all - pointed at a +/// `com.canonical.dbusmenu` object it silently returns an empty model, +/// the same class of silent failure as every other menu-source bug this +/// session - a consumer needs an actual dbusmenu client for this one, +/// not a differently-prefixed `GMenuModel` read. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum MenuSource { #[default] Gtk, Unity, + DbusMenu, +} + +/// The decision every X11-property-reading global-menu source needs -- +/// pulled out as a pure function, shared by the Wayland backend's +/// `xwayland.rs::read_global_menu` and the native X11 backend, so it's +/// unit-testable without a real X connection and can't drift into two +/// differently-behaving copies. +/// +/// `gtk_menu_path` present (real `GtkApplication` or not) always means +/// `org.gtk.Menus` - that's the only thing `_GTK_MENUBAR_OBJECT_PATH`/ +/// `_GTK_APP_MENU_OBJECT_PATH` ever address, GTK-module shim included (see +/// [`MenuSource::Unity`]'s own doc comment for the live evidence). Only a +/// bare `unity_path`, with no GTK atom at all, gets [`MenuSource::DbusMenu`]: +/// that's `_UNITY_OBJECT_PATH` doing the job it was actually created for -- +/// the original pre-GTK3.4 Unity/`libdbusmenu` export, and what a non-GTK +/// client (`appmenu-qt5`'s classic model) still uses it for - rather than +/// `appmenu-gtk-module` setting it as a compatibility alias alongside a GTK +/// atom that already answers the question on its own. +/// +/// `is_real_gtk_application` overrides "a GTK path exists" rather than the +/// reverse: a real `GMenuModel` export (`app.`/`win.`-prefixed actions) +/// only ever comes from a `GtkApplication`, which always also sets +/// `_GTK_APPLICATION_OBJECT_PATH`/`_GTK_WINDOW_OBJECT_PATH` (or their +/// `gtk_shell1` equivalents) - if both are absent despite a GTK menubar +/// path existing, `appmenu-gtk-module` is exporting a plain window's menu +/// through its Unity-compatibility shim instead: real content, at this +/// same path, but under `unity.`-prefixed actions. Getting this wrong means +/// every menu item renders permanently insensitive against action groups +/// the app never inserted - a silent failure that reads exactly like a +/// broken app, not a wiring bug. Confirmed live by an AGS peer session +/// reading the actual exported menu content off the bus for exactly this +/// case (`_GTK_MENUBAR_OBJECT_PATH` set, `app_path`/`window_path` both +/// empty, every action `unity.*`). +pub fn classify_menu_source(gtk_menu_path: Option<String>, is_real_gtk_application: bool, unity_path: Option<String>) -> (Option<String>, MenuSource) { + match gtk_menu_path { + Some(path) if !is_real_gtk_application => (Some(path), MenuSource::Unity), + Some(path) => (Some(path), MenuSource::Gtk), + None => match unity_path { + Some(path) => (Some(path), MenuSource::DbusMenu), + None => (None, MenuSource::Gtk), + }, + } } /// State of a single managed window. This is platform-independent: backends @@ -90,6 +152,11 @@ pub struct Window { pub always_on_top: bool, pub border_color: (u8, u8, u8), pub border_width: u32, + /// Titlebar/border-strip corner radius, in logical pixels. Copied from + /// `ThemeConfig::default_corner_radius` at creation (see `WindowManager + /// ::add_window`), same as `border_color`/`border_width`; a rule's own + /// `corner_radius` action still wins afterward. + pub corner_radius: u32, /// This window's own content opacity, `0.0`..=`1.0`. Only the content /// (the client's own surface tree) is affected - srdwm's own /// decoration (titlebar/border/shadow) always renders fully opaque @@ -147,6 +214,7 @@ impl Window { always_on_top: false, border_color: (136, 192, 208), // Nord accent, matches legacy theme default border_width: 2, + corner_radius: 6, opacity: 1.0, resize_margin: None, workspace: 0, @@ -396,6 +464,39 @@ mod tests { } #[test] + fn appmenu_gtk_module_shim_is_classified_as_unity_not_gtk() { + // The exact live case that motivated this: a GTK menubar path + // present, but no application/window object path - confirmed by + // an AGS peer session reading the actual exported menu content off + // the bus and finding `unity.`-prefixed actions despite the GTK + // atom being what resolved the path. + let (path, source) = classify_menu_source(Some("/org/appmenu/gtk/window/0".to_string()), false, None); + assert_eq!(path.as_deref(), Some("/org/appmenu/gtk/window/0"), "the path itself is still correct - only the label was wrong"); + assert_eq!(source, MenuSource::Unity); + } + + #[test] + fn real_gtk_application_export_is_still_classified_as_gtk() { + let (path, source) = classify_menu_source(Some("/org/gtk/menus/window/1".to_string()), true, None); + assert_eq!(path.as_deref(), Some("/org/gtk/menus/window/1")); + assert_eq!(source, MenuSource::Gtk); + } + + #[test] + fn plain_unity_object_path_with_no_gtk_atom_is_real_dbusmenu() { + let (path, source) = classify_menu_source(None, false, Some("/com/canonical/menu/1".to_string())); + assert_eq!(path.as_deref(), Some("/com/canonical/menu/1")); + assert_eq!(source, MenuSource::DbusMenu); + } + + #[test] + fn neither_path_present_is_none() { + let (path, source) = classify_menu_source(None, false, None); + assert_eq!(path, None); + assert_eq!(source, MenuSource::Gtk); + } + + #[test] fn close_button_is_top_right_corner_of_titlebar() { let f = frame(); let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5, true, 0, RESIZE_MARGIN); diff --git a/crates/platform/src/appmenu_registrar.rs b/crates/platform/src/appmenu_registrar.rs new file mode 100644 index 0000000..60511e6 --- /dev/null +++ b/crates/platform/src/appmenu_registrar.rs @@ -0,0 +1,142 @@ +//! `com.canonical.AppMenu.Registrar`: the classic Unity/`appmenu-qt5` +//! global-menu registration service. Unlike every other menu source srdwm +//! reads (the Wayland backend's `xwayland.rs`'s `_GTK_*`/ +//! `_UNITY_OBJECT_PATH` X11 properties and `gtk_shell.rs`'s native-Wayland +//! `gtk_surface1.set_dbus_properties`), a classic Qt app using +//! `appmenu-qt5`'s original model never puts its menu's D-Bus address on an +//! X11 property at all - it calls `RegisterWindow(windowId, menuObjectPath)` +//! on this well-known D-Bus name instead, with the *sender* of that call +//! being the only way to learn which bus name the menu actually lives on. +//! Reading properties can't discover this; something has to actually own +//! this name on the session bus and receive the call, which is what this +//! module does. +//! +//! Lives in `srdwm-platform`, not `srdwm-wayland`, even though `windowId` +//! is always a raw X11 XID (this model predates Wayland entirely): both the +//! Wayland backend's XWayland integration *and* the native X11 backend +//! (`srdwm-x11`) need the exact same service against the exact same kind of +//! id, and neither depends on the other - `srdwm-platform` is the one +//! crate both already depend on. Each backend constructs its own +//! `AppmenuRegistrarState` (only ever one should actually be running at a +//! time, since only one backend runs per session) and resolves each +//! `RegistrarEvent`'s `window_id` back to its own `WindowId` however it +//! already maps X11 ids - the Wayland backend's `xwayland.rs::apply_ +//! registrar_events` matches `X11Surface::window_id()` against `id_to_ +//! window`, the X11 backend's `xid_to_core` map directly. +//! +//! This compositor has no async runtime anywhere else (see `srdwm/src/ +//! main.rs`'s plain synchronous loop), and zbus's `blocking` API exists +//! specifically so this doesn't need one: `zbus::blocking::Connection` +//! still runs its socket-reading/dispatch on a background thread +//! internally (via `async-io`'s own global executor, which spawns that +//! thread the first time any work is submitted to it - nothing here has +//! to drive that explicitly), so holding the built `Connection` alive is +//! the only thing `AppmenuRegistrarState` needs to do to keep the service +//! running. The interface impl below only ever sends a plain event over a +//! `std::sync::mpsc` channel - draining it is a non-blocking, synchronous +//! `try_iter()` call from the compositor's own event-loop tick, the same +//! "background thread feeds a channel, the main loop drains it" shape +//! `IpcServer` already uses for the control socket. + +use std::sync::mpsc::{Receiver, Sender}; + +use zbus::message::Header; +use zbus::zvariant::OwnedObjectPath; + +/// One `RegisterWindow`/`UnregisterWindow` call, captured off the D-Bus +/// message and handed to a backend's own event-loop tick to resolve +/// against a real `WindowId` and update `Window.global_menu`. +#[derive(Debug)] +pub enum RegistrarEvent { + Registered { window_id: u32, bus_name: String, menu_path: String }, + Unregistered { window_id: u32 }, +} + +/// The D-Bus interface itself - deliberately does nothing but forward +/// each call onto `tx`. Resolving `window_id` against `WindowManager` has +/// to happen back on the compositor's own thread (everything `WindowManager` +/// touches is `Rc`/`RefCell`, not `Send`), so there is nothing more useful +/// this could do while actually running on zbus's background thread. +struct RegistrarIface { + tx: Sender<RegistrarEvent>, +} + +#[zbus::interface(name = "com.canonical.AppMenu.Registrar")] +impl RegistrarIface { + /// The registrar's own defined method: a window announcing (or + /// re-announcing - a client is free to call this again if its menu + /// path changes) its `com.canonical.dbusmenu` object. `#[zbus(header)]` + /// is what makes the caller's bus name visible at all - it's the + /// message envelope, not a request argument, so there is no other way + /// to learn it. + fn register_window(&mut self, window_id: u32, menu_object_path: OwnedObjectPath, #[zbus(header)] header: Header<'_>) { + let Some(bus_name) = header.sender() else { return }; + let _ = self.tx.send(RegistrarEvent::Registered { window_id, bus_name: bus_name.to_string(), menu_path: menu_object_path.to_string() }); + } + + /// The protocol's own way of saying "no menu after all" - a window + /// closing its menu, or closing entirely, without necessarily also + /// destroying the D-Bus connection that registered it. Matches + /// `xwayland.rs`/`gtk_shell.rs`'s own handling of the equivalent case: + /// clear `Window.global_menu` rather than leaving it stale. + fn unregister_window(&mut self, window_id: u32) { + let _ = self.tx.send(RegistrarEvent::Unregistered { window_id }); + } +} + +/// Owns the registrar's D-Bus connection for as long as an X11-capable +/// backend (XWayland under the Wayland backend, or the native X11 backend) +/// is running. `None` if starting the service failed (another process +/// already owns the name, or no session bus is reachable at all) - a +/// classic Qt app's global menu simply won't be seen in that case, same +/// "degrade, don't crash the compositor over a cosmetic feature" handling +/// as every other optional protocol here. +pub struct AppmenuRegistrarState { + // Never read again after construction - its only job is to outlive + // the compositor so the background-thread service it owns keeps + // running. `Option` because starting it can fail (see above). + _connection: Option<zbus::blocking::Connection>, + rx: Receiver<RegistrarEvent>, +} + +impl AppmenuRegistrarState { + pub fn new() -> Self { + let (tx, rx) = std::sync::mpsc::channel(); + let iface = RegistrarIface { tx }; + // `replace_existing_names`: a previous srdwm process that crashed + // or was killed (rather than exiting cleanly) can leave this name + // owned until the bus itself notices the connection is gone, which + // is not guaranteed to have happened yet by the time a fresh + // process starts - same reasoning as `IpcServer::bind_in` removing + // a stale control-socket file before binding its own fresh one, so + // a restart doesn't leave the *new* process's registrar silently + // unreachable behind a name the old one still holds. + let connection = zbus::blocking::connection::Builder::session() + .and_then(|b| b.serve_at("/com/canonical/AppMenu/Registrar", iface)) + .and_then(|b| b.name("com.canonical.AppMenu.Registrar")) + .map(|b| b.replace_existing_names(true)) + .and_then(|b| b.build()); + let _connection = match connection { + Ok(c) => Some(c), + Err(e) => { + log::warn!("appmenu_registrar: couldn't start com.canonical.AppMenu.Registrar ({e}); classic Qt/appmenu-qt5 global menus won't be seen"); + None + } + }; + Self { _connection, rx } + } + + /// Every registration/unregistration that arrived since the last call, + /// non-blockingly - called once per event-loop tick from `poll_events`, + /// the same drain-a-channel shape `IpcServer::poll` already uses for + /// the control socket. + pub fn drain_events(&self) -> Vec<RegistrarEvent> { + self.rx.try_iter().collect() + } +} + +impl Default for AppmenuRegistrarState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/wayland/src/appmenu.rs b/crates/wayland/src/appmenu.rs new file mode 100644 index 0000000..9abe826 --- /dev/null +++ b/crates/wayland/src/appmenu.rs @@ -0,0 +1,124 @@ +//! `org_kde_kwin_appmenu`: the Wayland-native equivalent of the `_GTK_*`/ +//! `_UNITY_OBJECT_PATH` X11 properties `xwayland.rs`'s `read_global_menu` +//! reads - lets a client link a `wl_surface` straight to a +//! `com.canonical.dbusmenu` D-Bus address, no XWayland/X11 property +//! round-trip involved at all. +//! +//! This is the only real gap X11 property reading structurally can't +//! close: `xwayland.rs::read_global_menu` only ever runs for a window that +//! `x11_surface()` resolves (an XWayland client), so a genuinely +//! Wayland-native GTK4/Qt6 window - no XWayland involved - could never +//! have exported a menu srdwm would see, regardless of how correct the X11 +//! side is. There is no Wayland-native equivalent of `_GTK_MENUBAR_OBJECT_ +//! PATH`/GMenuModel (GTK's own menu export is X11-property-only, by +//! GTK's own design, XWayland or not), but Qt/KDE's side of this - the +//! same `com.canonical.dbusmenu` content `appmenu-qt5`'s Unity-registrar +//! path exports over X11 - does have a real Wayland-native protocol for +//! it, and it's what this file implements. Closes the other open half of +//! the Qt global-menu gap found this session: `read_global_menu`'s +//! `_UNITY_OBJECT_PATH` handling only ever reaches a Qt app that still +//! goes through XWayland, which a `QT_QPA_PLATFORM=wayland` app (the +//! default this session's `env.conf` port now sets) does not. +//! +//! Always `MenuSource::DbusMenu`, never `MenuSource::Unity`: this +//! protocol's own description says exactly what it addresses - "a +//! `com.canonical.dbusmenu` interface" - and despite the name, `Unity` +//! does *not* mean dbusmenu content in this codebase (see that variant's +//! own doc comment, corrected after an AGS peer session caught this exact +//! mislabeling live: `appmenu-gtk-module`'s `_UNITY_OBJECT_PATH` points at +//! an `org.gtk.Menus` object, not a dbusmenu one). `DbusMenu` is the one +//! that means what this protocol carries. No classification heuristic +//! needed the way `xwayland.rs::classify_menu_source` needs one for the +//! X11 side, though: this protocol only ever carries the one kind of +//! content. +//! +//! No smithay helper exists for this protocol (same as `gamma_control.rs`/ +//! `output_power.rs`/`screencopy.rs`), so the `GlobalDispatch`/`Dispatch` +//! plumbing below is hand-written against the raw `wayland-protocols- +//! plasma` server bindings. + +use smithay::reexports::wayland_server::backend::{ClientId, GlobalId}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New}; +use wayland_protocols_plasma::appmenu::server::org_kde_kwin_appmenu::{self, OrgKdeKwinAppmenu}; +use wayland_protocols_plasma::appmenu::server::org_kde_kwin_appmenu_manager::{self, OrgKdeKwinAppmenuManager}; + +use crate::state::CompState; + +/// The manager global. Held by `CompState` purely to keep the global alive +/// for the compositor's lifetime - same reasoning as `GammaControlManagerState`. +pub struct AppmenuManagerState { + _global: GlobalId, +} + +impl AppmenuManagerState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<OrgKdeKwinAppmenuManager, ()> + 'static, + { + Self { _global: dh.create_global::<D, OrgKdeKwinAppmenuManager, _>(2, ()) } + } +} + +/// Which `wl_surface` an `org_kde_kwin_appmenu` object addresses, resolved +/// once at creation - same reasoning as `GammaControlData`'s output. +pub struct AppmenuData { + surface: WlSurface, +} + +impl GlobalDispatch<OrgKdeKwinAppmenuManager, ()> for CompState { + fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<OrgKdeKwinAppmenuManager>, _data: &(), data_init: &mut DataInit<'_, Self>) { + data_init.init(manager, ()); + } +} + +impl Dispatch<OrgKdeKwinAppmenuManager, ()> for CompState { + fn request( + _state: &mut Self, + _client: &Client, + _manager: &OrgKdeKwinAppmenuManager, + request: org_kde_kwin_appmenu_manager::Request, + _data: &(), + _dh: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + let org_kde_kwin_appmenu_manager::Request::Create { id, surface } = request else { return }; + data_init.init(id, AppmenuData { surface }); + } +} + +impl Dispatch<OrgKdeKwinAppmenu, AppmenuData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + _resource: &OrgKdeKwinAppmenu, + request: org_kde_kwin_appmenu::Request, + data: &AppmenuData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let org_kde_kwin_appmenu::Request::SetAddress { service_name, object_path } = request else { return }; + let Some(id) = state.surface_to_id.get(&data.surface).copied() else { return }; + if let Some(w) = state.wm.borrow_mut().window_mut(id) { + w.global_menu = Some(srdwm_core::GlobalMenu { + bus_name: service_name, + menu_path: Some(object_path), + app_path: None, + window_path: None, + source: srdwm_core::MenuSource::DbusMenu, + }); + } + } + + /// The protocol's own doc comment: "If not applicable, clients should + /// remove this object" - releasing (or disconnecting) is a real, + /// expected way for a client to say "no menu after all", not just + /// cleanup, so the address needs actually clearing here rather than + /// left stale for whichever window this surface maps to. + fn destroyed(state: &mut Self, _client: ClientId, _resource: &OrgKdeKwinAppmenu, data: &AppmenuData) { + let Some(id) = state.surface_to_id.get(&data.surface).copied() else { return }; + if let Some(w) = state.wm.borrow_mut().window_mut(id) { + w.global_menu = None; + } + } +} diff --git a/crates/wayland/src/gtk_shell.rs b/crates/wayland/src/gtk_shell.rs index f4ae030..1b0e57b 100644 --- a/crates/wayland/src/gtk_shell.rs +++ b/crates/wayland/src/gtk_shell.rs @@ -86,16 +86,37 @@ impl Dispatch<GtkSurface1, GtkSurfaceData> for CompState { // (or never had one) - matches `xwayland.rs`'s `read_global_menu` // returning `None` for the same case, so a panel sees the same // shape regardless of which backend a window came from. - // Always `MenuSource::Gtk`: this protocol has no Unity-style - // equivalent to carry, unlike XWayland's `_UNITY_OBJECT_PATH` -- - // see `xwayland.rs`'s `read_global_menu` for the case that needs - // the other variant. + // + // `source` used to be hardcoded `MenuSource::Gtk` unconditionally + // here, on the reasoning that this protocol "has no Unity-style + // equivalent to carry" - true of the *wire message*, but not of + // what's actually behind it: `appmenu-gtk-module` is the same + // module regardless of whether it signals its address via X11 + // atoms (`xwayland.rs`) or `gtk_surface1.set_dbus_properties` + // (here), and exports a plain `Gtk.Window`'s menu (no + // `GtkApplication`) through its Unity-compatibility shim -- + // `unity.`-prefixed actions and all - on *either* path. That's + // exactly the misclassification `classify_menu_source` was written + // to fix for the X11 side (see its own doc comment and + // `xwayland.rs::read_global_menu`); this call site just never + // adopted it, so a native-Wayland GTK window hitting the same + // shim case stayed permanently mislabeled `Gtk` while its XWayland + // counterpart got the fix. Confirmed live: Nemo (native Wayland, + // not XWayland - only Spotify was in `_NET_CLIENT_LIST` at the + // time) reported `source: "gtk"` here, but its actual exported + // menu content, read directly off the bus, used `unity.`-prefixed + // actions throughout (File/Edit/View/Go/Bookmarks/Help) - the + // exact "every item renders, none of them are ever clickable" + // failure `classify_menu_source`'s own tests exist to catch. + let is_real_gtk_application = application_object_path.as_deref().is_some_and(|s| !s.is_empty()) || window_object_path.as_deref().is_some_and(|s| !s.is_empty()); + let gtk_menu_path = menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty())); + let (menu_path, source) = srdwm_core::classify_menu_source(gtk_menu_path, is_real_gtk_application, None); let menu = unique_bus_name.filter(|s| !s.is_empty()).map(|bus_name| srdwm_core::GlobalMenu { bus_name, - menu_path: menubar_path.filter(|s| !s.is_empty()).or_else(|| app_menu_path.filter(|s| !s.is_empty())), + menu_path, app_path: application_object_path.filter(|s| !s.is_empty()), window_path: window_object_path.filter(|s| !s.is_empty()), - source: srdwm_core::MenuSource::Gtk, + source, }); if let Some(w) = state.wm.borrow_mut().window_mut(id) { w.global_menu = menu; diff --git a/crates/x11/src/platform/connect.rs b/crates/x11/src/platform/connect.rs index d5c3a59..2d2f726 100644 --- a/crates/x11/src/platform/connect.rs +++ b/crates/x11/src/platform/connect.rs @@ -94,6 +94,7 @@ impl X11Platform { keyboard_mapping, numlock_mask, ipc, + appmenu_registrar: Some(srdwm_platform::AppmenuRegistrarState::new()), }) } diff --git a/crates/x11/src/platform/global_menu.rs b/crates/x11/src/platform/global_menu.rs new file mode 100644 index 0000000..161fe80 --- /dev/null +++ b/crates/x11/src/platform/global_menu.rs @@ -0,0 +1,90 @@ +//! Global-menu support for the native X11 backend - parity with the +//! Wayland backend's XWayland integration (`crates/wayland/src/xwayland.rs` +//! and `srdwm_platform::appmenu_registrar`), reading the identical X11 +//! properties and running the identical `com.canonical.AppMenu.Registrar` +//! D-Bus service, since a client-side toolkit (GTK/Qt) exports its menu the +//! same way regardless of which X server it's actually talking to. + +use super::*; + +impl X11Platform { + /// Reads `xid`'s global-menu D-Bus address straight off its own X11 + /// properties - `_GTK_UNIQUE_BUS_NAME` plus whichever menu-path atom + /// the client actually set. Exact mirror of `crates/wayland/src/ + /// xwayland.rs::EwmhState::read_global_menu`; see that method's doc + /// comment for the full reasoning (menubar wins over app-menu, the + /// `appmenu-gtk-module` Unity-shim case `classify_menu_source` exists + /// for). No bus name means no menu at all, so this returns `None` + /// rather than a `GlobalMenu` with an empty `bus_name`. + pub(super) fn read_global_menu(&self, xid: XWindow) -> Option<srdwm_core::GlobalMenu> { + let read_string = |atom: u32| -> Option<String> { + let reply = self.conn.get_property(false, xid, atom, x11rb::protocol::xproto::AtomEnum::ANY, 0, u32::MAX).ok()?.reply().ok()?; + if reply.value.is_empty() { + return None; + } + String::from_utf8(reply.value).ok().filter(|s| !s.is_empty()) + }; + + // Checked before anything GTK-atom-related - see `xwayland.rs`'s + // identical check in its own `read_global_menu` for why: these two + // are already a complete address on their own, and a Qt app under + // a KDE Plasma session never sets `_GTK_UNIQUE_BUS_NAME` at all. + if let (Some(bus_name), Some(menu_path)) = (read_string(self.atoms._KDE_NET_WM_APPMENU_SERVICE_NAME), read_string(self.atoms._KDE_NET_WM_APPMENU_OBJECT_PATH)) { + return Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu }); + } + + let bus_name = read_string(self.atoms._GTK_UNIQUE_BUS_NAME)?; + let app_path = read_string(self.atoms._GTK_APPLICATION_OBJECT_PATH); + let window_path = read_string(self.atoms._GTK_WINDOW_OBJECT_PATH); + let is_real_gtk_application = app_path.is_some() || window_path.is_some(); + let gtk_menu_path = read_string(self.atoms._GTK_MENUBAR_OBJECT_PATH).or_else(|| read_string(self.atoms._GTK_APP_MENU_OBJECT_PATH)); + let unity_path = read_string(self.atoms._UNITY_OBJECT_PATH); + let (menu_path, source) = srdwm_core::classify_menu_source(gtk_menu_path, is_real_gtk_application, unity_path); + Some(srdwm_core::GlobalMenu { bus_name, menu_path, app_path, window_path, source }) + } + + /// Refreshes the focused window's `global_menu` from its own X11 + /// properties - call on every real focus change (`Platform::focus`, + /// the single chokepoint every focus path already goes through, same + /// role `update_net_active_window` plays for the Wayland backend). + /// Global-menu properties are usually set once, shortly after a client + /// registers on the session bus, which can race a window's own initial + /// map - reading only at map time would miss a client that finished + /// registering a moment later, so this re-reads on every focus instead. + /// `read_global_menu` returning `None` (the common case for anything + /// non-GTK, or a GTK app with no menu to export) correctly clears a + /// stale value left over from whichever window was focused before. + pub(super) fn refresh_focused_global_menu(&mut self, id: WindowId, client: XWindow) { + let menu = self.read_global_menu(client); + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + + /// Drains `AppmenuRegistrarState`'s channel and applies every event to + /// the matching `Window.global_menu` - call once per `poll_events` + /// tick, same as the Wayland backend's `xwayland.rs::apply_registrar_ + /// events`. `xid_to_core` already maps XID straight to `WindowId` here + /// (unlike the Wayland backend, which has to scan for the matching + /// `X11Surface`), so this needs no extra lookup structure of its own. + pub(super) fn apply_registrar_events(&mut self) { + let Some(registrar) = &self.appmenu_registrar else { return }; + let events = registrar.drain_events(); + if events.is_empty() { + return; + } + for event in events { + let (window_id, menu) = match event { + srdwm_platform::RegistrarEvent::Registered { window_id, bus_name, menu_path } => ( + window_id, + Some(srdwm_core::GlobalMenu { bus_name, menu_path: Some(menu_path), app_path: None, window_path: None, source: srdwm_core::MenuSource::DbusMenu }), + ), + srdwm_platform::RegistrarEvent::Unregistered { window_id } => (window_id, None), + }; + let Some(&id) = self.xid_to_core.get(&window_id) else { continue }; + if let Some(w) = self.wm.borrow_mut().window_mut(id) { + w.global_menu = menu; + } + } + } +} diff --git a/crates/x11/src/platform/mod.rs b/crates/x11/src/platform/mod.rs index eea936f..04c918c 100644 --- a/crates/x11/src/platform/mod.rs +++ b/crates/x11/src/platform/mod.rs @@ -55,6 +55,26 @@ x11rb::atom_manager! { _NET_CLIENT_LIST, _NET_ACTIVE_WINDOW, UTF8_STRING, + // Global-menu properties - see `read_global_menu`'s doc comment. + // A native X11 client is exactly the same GTK/Qt app the Wayland + // backend's `xwayland.rs::read_global_menu` already reads these + // from (XWayland is just another X server as far as a toolkit is + // concerned), so this is the identical atom set for the identical + // reason. + _GTK_UNIQUE_BUS_NAME, + _GTK_APPLICATION_OBJECT_PATH, + _GTK_WINDOW_OBJECT_PATH, + _GTK_MENUBAR_OBJECT_PATH, + _GTK_APP_MENU_OBJECT_PATH, + _UNITY_OBJECT_PATH, + // KWin's own global-menu property pair - what `libdbusmenu-qt`'s + // KDE integration sets. Already a complete, unambiguous + // `com.canonical.dbusmenu` address on its own (no classification + // needed the way the GTK/Unity atoms above need), and checked + // first in `read_global_menu` for exactly that reason - see that + // method's doc comment. + _KDE_NET_WM_APPMENU_SERVICE_NAME, + _KDE_NET_WM_APPMENU_OBJECT_PATH, } } @@ -121,6 +141,15 @@ pub struct X11Platform { /// itself still starts either way, matching how the Wayland backends /// already treat this as non-fatal. ipc: Option<srdwm_platform::IpcServer>, + /// `com.canonical.AppMenu.Registrar` - the classic Qt/`appmenu-qt5` + /// global-menu source, see `srdwm_platform::appmenu_registrar`'s module + /// doc comment. Unlike the Wayland backend (where this is `None` until + /// XWayland finishes starting up), a native X11 session always has a + /// real X server the moment this struct exists, so it's started + /// unconditionally in `connect` - still `Option` because starting the + /// D-Bus service itself can independently fail (see that module's own + /// `None` handling). + appmenu_registrar: Option<srdwm_platform::AppmenuRegistrarState>, } @@ -139,6 +168,7 @@ impl ClonedForRender for Option<&CoreWindow> { mod actions; mod connect; mod events; +mod global_menu; mod trait_impl; mod window; diff --git a/crates/x11/src/platform/trait_impl.rs b/crates/x11/src/platform/trait_impl.rs index 0a082be..1ff7845 100644 --- a/crates/x11/src/platform/trait_impl.rs +++ b/crates/x11/src/platform/trait_impl.rs @@ -34,6 +34,7 @@ impl Platform for X11Platform { out.push(e); } } + self.apply_registrar_events(); if let Some(ipc) = self.ipc.as_mut() { if ipc.poll(&self.wm) { out.push(Event::WorkspaceChanged); @@ -106,9 +107,11 @@ impl Platform for X11Platform { fn focus(&mut self, window: WindowId) -> PlatformResult<()> { if let Some(frame) = self.frames.get(&window) { - self.conn.set_input_focus(x11rb::protocol::xproto::InputFocus::POINTER_ROOT, frame.client, x11rb::CURRENT_TIME).map_err(err)?; - self.conn.change_property32(x11rb::protocol::xproto::PropMode::REPLACE, self.root, self.atoms._NET_ACTIVE_WINDOW, x11rb::protocol::xproto::AtomEnum::WINDOW, &[frame.client]).map_err(err)?; + let client = frame.client; + self.conn.set_input_focus(x11rb::protocol::xproto::InputFocus::POINTER_ROOT, client, x11rb::CURRENT_TIME).map_err(err)?; + self.conn.change_property32(x11rb::protocol::xproto::PropMode::REPLACE, self.root, self.atoms._NET_ACTIVE_WINDOW, x11rb::protocol::xproto::AtomEnum::WINDOW, &[client]).map_err(err)?; self.conn.flush().map_err(err)?; + self.refresh_focused_global_menu(window, client); } Ok(()) } @@ -217,4 +220,12 @@ impl Platform for X11Platform { self.conn.ungrab_key(0, self.root, ModMask::ANY).map_err(err)?; Ok(()) } + + fn keyboard_layout(&mut self) -> PlatformResult<String> { + Err(PlatformError::Unsupported("keyboard_layout")) + } + + fn cycle_keyboard_layout(&mut self) -> PlatformResult<String> { + Err(PlatformError::Unsupported("cycle_keyboard_layout")) + } } |