diff options
| author | srdusr <[email protected]> | 2026-07-27 01:33:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-07-27 01:33:00 +0200 |
| commit | 3c47912ae0a2b81d06e4e9101b5b4e2f1e2997d9 (patch) | |
| tree | be0aa3740beb1ec18dfccbd68a4fcf16faa8638b | |
| parent | e20d49b0ee3dbd83499445d61eb2d65904d74311 (diff) | |
| download | srdwm-3c47912ae0a2b81d06e4e9101b5b4e2f1e2997d9.tar.gz srdwm-3c47912ae0a2b81d06e4e9101b5b4e2f1e2997d9.zip | |
Reach GTK4's window buttons too, and speak the decoration protocol GTK knows
Two findings from reading what other projects do, and then measuring this
machine rather than trusting the reading.
GTK has never implemented xdg-decoration, so a compositor that advertises
only that protocol is invisible to every GTK client on the decoration
question. What GTK does implement is KDE's older
org_kde_kwin_server_decoration - confirmed by reading libgtk-4's own symbol
strings, where the manager, the mode enum and the default-mode handler are
all present, and absent from libgtk-3. That is the channel a KDE session
uses. srdwm now advertises it alongside xdg-decoration, with both answering
from the same policy (theme.default_decorated, theme.force_server_side) so a
client is told the same thing whichever it asks through.
Measured what that actually buys, with WAYLAND_DEBUG on a real GTK4 client:
it binds the manager and receives default_mode(2) = Server, and then never
creates a decoration object for its window. So it changes nothing for a GTK
application's own header bar, and it is kept because it is the correct thing
to advertise and because clients that do honour it - Qt and KDE's own --
now get server-side decoration from srdwm instead of nothing.
The second finding is the one that fixes what was reported. GTK3 and GTK4
put their window buttons under different CSS selectors, and the generated
stylesheet named only GTK3's. A diagnostic rule proved it both ways: a flat
colour reached Nemo (GTK3) through `headerbar button.titlebutton` and
gnome-calculator (GTK4) through `windowcontrols button`, and neither
selector reached the other toolkit. Every style is now written for both, so
a GTK4 application is styled rather than silently skipped. `headerbar`
itself works in both, so the titlebar block needed no split.
Verified on screen: gnome-calculator, a GTK4/libadwaita application, now
draws its header bar in srdwm's own titlebar colour with srdwm's text
colour, where before it kept its theme's.
Also worth writing down, because it bounds what any of this can achieve: an
application's header bar is its own widget. No protocol removes it. GTK_CSD=0
does not, the KDE protocol does not, and neither does forcing server-side
decoration - that only adds a second titlebar above the first. What a
compositor can do is make the two look like one, which is what this does.
| -rw-r--r-- | crates/srdwm/src/main.rs | 68 | ||||
| -rw-r--r-- | crates/wayland/src/protocols.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/protocols/kde_decoration.rs | 74 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 6 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 8 | ||||
| -rw-r--r-- | crates/wayland/src/winit/connect.rs | 8 |
6 files changed, 153 insertions, 12 deletions
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs index d53c76e..4a68006 100644 --- a/crates/srdwm/src/main.rs +++ b/crates/srdwm/src/main.rs @@ -164,23 +164,44 @@ const GTK_STYLE_FILE: &str = "srdwm-buttons.css"; /// gives the user's own rules the last word, since later rules win. const GTK_STYLE_IMPORT: &str = "@import url(\"srdwm-buttons.css\");"; -/// The CSS for one button style, without the shared reset in `GTK_BUTTON_BASE`. +/// The selectors a toolkit puts its window buttons under. /// -/// Every style is generated into the stylesheet on every write. Exactly one is -/// live and the rest are commented out, so the file doubles as the menu of -/// what `button_style` can be: you can see what each one would do, and read -/// the rules rather than guess at them. +/// GTK3 and GTK4 disagree, and a stylesheet that names only one silently +/// does nothing for half the applications on the desktop. Measured, not +/// assumed: a diagnostic rule that turned the buttons a flat colour +/// reached Nemo (GTK3) through `headerbar button.titlebutton` and +/// gnome-calculator (GTK4) through `windowcontrols button`, and neither +/// selector reached the other. `headerbar` itself works in both, so the +/// titlebar block above needs no such split. +/// +/// Both are always written. A toolkit ignores a selector whose node it +/// does not have, so naming both costs a few lines and covers both. +const GTK_BUTTON_SELECTORS: [&str; 2] = ["headerbar button.titlebutton", "windowcontrols button"]; + +/// The CSS for one button style, as a template over `{sel}` - one of +/// `GTK_BUTTON_SELECTORS`. +/// +/// Every style is generated into the stylesheet on every write. Exactly one +/// is live and the rest are commented out, so the file doubles as the menu +/// of what `button_style` can be: you can see what each one would do, and +/// read the rules rather than guess at them. const GTK_BUTTON_STYLES: [(&str, &str); 2] = [ ( "traditional", - "headerbar button.titlebutton {\n min-width: 24px;\n min-height: 24px;\n border-radius: 4px;\n}\n\nheaderbar button.titlebutton > image {\n opacity: 1;\n}\n\nheaderbar button.titlebutton.close {\n background-image: -gtk-icontheme(\"window-close-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\nheaderbar button.titlebutton.minimize {\n background-image: -gtk-icontheme(\"window-minimize-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\nheaderbar button.titlebutton.maximize {\n background-image: -gtk-icontheme(\"window-maximize-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\nheaderbar button.titlebutton:hover {\n background-color: alpha(currentColor, 0.14);\n}\n\nheaderbar button.titlebutton:active {\n background-color: alpha(currentColor, 0.22);\n}\n", + "{sel} {\n min-width: 24px;\n min-height: 24px;\n border-radius: 4px;\n}\n\n{sel} > image {\n opacity: 1;\n}\n\n{sel}.close {\n background-image: -gtk-icontheme(\"window-close-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\n{sel}.minimize {\n background-image: -gtk-icontheme(\"window-minimize-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\n{sel}.maximize {\n background-image: -gtk-icontheme(\"window-maximize-symbolic\");\n background-repeat: no-repeat;\n background-position: center;\n background-size: 16px 16px;\n}\n\n{sel}:hover {\n background-color: alpha(currentColor, 0.14);\n}\n\n{sel}:active {\n background-color: alpha(currentColor, 0.22);\n}\n", ), ( "traffic_lights", - "headerbar button.titlebutton {\n min-width: 13px;\n min-height: 13px;\n border-radius: 9999px;\n}\n\nheaderbar button.titlebutton > image {\n opacity: 0;\n}\n\nheaderbar button.titlebutton.close {\n background-image: radial-gradient(circle at 32% 28%, #ffb3ad 0%, #ff5f57 42%, #dd4b43 100%);\n}\n\nheaderbar button.titlebutton.minimize {\n background-image: radial-gradient(circle at 32% 28%, #ffe2a8 0%, #ffbd2e 42%, #dba024 100%);\n}\n\nheaderbar button.titlebutton.maximize {\n background-image: radial-gradient(circle at 32% 28%, #9df0a6 0%, #28c840 42%, #1f9f34 100%);\n}\n\nheaderbar button.titlebutton:hover {\n filter: brightness(1.12);\n}\n\nheaderbar button.titlebutton:active {\n filter: brightness(0.9);\n}\n", + "{sel} {\n min-width: 13px;\n min-height: 13px;\n border-radius: 9999px;\n}\n\n{sel} > image {\n opacity: 0;\n}\n\n{sel}.close {\n background-image: radial-gradient(circle at 32% 28%, #ffb3ad 0%, #ff5f57 42%, #dd4b43 100%);\n}\n\n{sel}.minimize {\n background-image: radial-gradient(circle at 32% 28%, #ffe2a8 0%, #ffbd2e 42%, #dba024 100%);\n}\n\n{sel}.maximize {\n background-image: radial-gradient(circle at 32% 28%, #9df0a6 0%, #28c840 42%, #1f9f34 100%);\n}\n\n{sel}:hover {\n filter: brightness(1.12);\n}\n\n{sel}:active {\n filter: brightness(0.9);\n}\n", ), ]; +/// One style's rules, written out once for every selector a toolkit might +/// use. +fn button_style_rules(template: &str) -> String { + GTK_BUTTON_SELECTORS.iter().map(|sel| template.replace("{sel}", sel)).collect() +} + /// The reset every style starts from. /// /// WhiteSur, and themes like it, paint the control as the button's own @@ -188,7 +209,14 @@ const GTK_BUTTON_STYLES: [(&str, &str); 2] = [ /// `image` widget. Clearing that background is what leaves a button with /// nothing drawn in it at all, so each style sets a background of its own /// rather than relying on un-hiding something underneath. -const GTK_BUTTON_BASE: &str = "headerbar button.titlebutton,\n.solid-csd headerbar button.titlebutton {\n background-image: none;\n background-color: transparent;\n border: none;\n box-shadow: none;\n padding: 0;\n}\n\n"; +fn gtk_button_base() -> String { + let mut selectors: Vec<String> = Vec::new(); + for sel in GTK_BUTTON_SELECTORS { + selectors.push(sel.to_string()); + selectors.push(format!(".solid-csd {sel}")); + } + format!("{} {{\n background-image: none;\n background-color: transparent;\n border: none;\n box-shadow: none;\n padding: 0;\n}}\n\n", selectors.join(",\n")) +} /// The generated stylesheet body for the configured button style. /// @@ -253,8 +281,9 @@ fn gtk_titlebar_css(look: &TitlebarLook) -> String { if !look.title_centered { out.push_str("headerbar .title {\n margin-left: 0;\n}\n\n"); } - out.push_str(GTK_BUTTON_BASE); - for (name, body) in GTK_BUTTON_STYLES { + out.push_str(>k_button_base()); + for (name, template) in GTK_BUTTON_STYLES { + let body = button_style_rules(template); if name == active { out.push_str(&format!("/* button_style = \"{name}\" - in use */\n\n{body}\n")); } else { @@ -1294,6 +1323,7 @@ mod tests { for traffic_lights in [true, false] { let live = uncommented(>k_titlebar_css(&look(traffic_lights))); assert!(live.contains(".solid-csd headerbar button.titlebutton")); + assert!(live.contains(".solid-csd windowcontrols button")); } } @@ -1332,6 +1362,20 @@ mod tests { } } + /// GTK3 and GTK4 put their window buttons under different selectors, + /// and a stylesheet naming only one silently does nothing for half the + /// applications on the desktop. + #[test] + fn every_style_is_written_for_both_toolkits() { + for traffic_lights in [true, false] { + let live = uncommented(>k_titlebar_css(&look(traffic_lights))); + for sel in GTK_BUTTON_SELECTORS { + assert!(live.contains(&format!("{sel}.close")), "no close rule for {sel}"); + assert!(live.contains(&format!("{sel}:hover")), "no hover rule for {sel}"); + } + } + } + /// srdwm-x is the window manager of the X display it runs on, not a /// client of another compositor, so a set `DISPLAY` must not make it /// look nested and stop it publishing the user's own settings. @@ -1346,8 +1390,8 @@ mod tests { /// leave that style's rules live alongside the configured one. #[test] fn no_style_body_can_terminate_the_comment_that_hides_it() { - for (_, body) in GTK_BUTTON_STYLES { - assert!(!body.contains("*/"), "a style body closes a comment"); + for (_, template) in GTK_BUTTON_STYLES { + assert!(!template.contains("*/"), "a style body closes a comment"); } } } diff --git a/crates/wayland/src/protocols.rs b/crates/wayland/src/protocols.rs index 664e889..bd8307a 100644 --- a/crates/wayland/src/protocols.rs +++ b/crates/wayland/src/protocols.rs @@ -23,6 +23,7 @@ mod misc; mod seat; mod selection; mod xdg_activation; +mod kde_decoration; mod xdg_decoration; mod xdg_shell; diff --git a/crates/wayland/src/protocols/kde_decoration.rs b/crates/wayland/src/protocols/kde_decoration.rs new file mode 100644 index 0000000..2448a39 --- /dev/null +++ b/crates/wayland/src/protocols/kde_decoration.rs @@ -0,0 +1,74 @@ +//! `org_kde_kwin_server_decoration`: KDE's older decoration protocol, and +//! the only decoration protocol GTK actually speaks. +//! +//! GTK has never implemented `xdg-decoration`. It does implement this one: +//! `org_kde_kwin_server_decoration_manager` is present in libgtk-4 on this +//! machine (confirmed by reading the library's own symbol strings), and it +//! is how a KDE session gets GTK applications to stop drawing their own +//! frame. A compositor that advertises only `xdg-decoration` is invisible +//! to those clients, which is why "set the decoration once and every +//! application follows" stopped at srdwm's own titlebars. +//! +//! Both protocols are advertised, and both answer with the same policy +//! (`theme.default_decorated`, `theme.force_server_side`), so a client is +//! told the same thing whichever one it asks through. +//! +//! The protocol's own warning applies here: a client may ignore the mode +//! the compositor suggests and ask for its own. That is honoured the same +//! way `xdg_decoration.rs` honours it, and for the same reason - a client +//! that draws its own titlebar regardless (Firefox with its system-titlebar +//! setting off) would otherwise get srdwm's row on top of its own. + +use smithay::reexports::wayland_protocols_misc::server_decoration::server::org_kde_kwin_server_decoration::{ + Mode, OrgKdeKwinServerDecoration, +}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::WEnum; +use smithay::wayland::shell::kde::decoration::{KdeDecorationHandler, KdeDecorationState}; + +use crate::state::CompState; + +impl KdeDecorationHandler for CompState { + fn kde_decoration_state(&self) -> &KdeDecorationState { + &self.kde_decoration_state + } + + /// Tells a client, the moment it asks, which mode this compositor + /// wants - the same answer `XdgDecorationHandler::new_decoration` + /// gives through the other protocol. + /// + /// The manager's own default mode (set once, at startup) is what a + /// client sees before it creates a decoration object at all; this is + /// what it sees afterward, and it has to agree, or a client that reads + /// both ends up with two different answers. + fn new_decoration(&mut self, surface: &WlSurface, decoration: &OrgKdeKwinServerDecoration) { + let server = self.wm.borrow().theme.default_decorated; + decoration.mode(if server { Mode::Server } else { Mode::Client }); + self.set_decorated_from_mode(surface, server); + } + + /// Honours what the client asked for, unless `force_server_side` says + /// otherwise - identical policy to the xdg-decoration path, and the + /// mode is echoed back either way because the protocol requires the + /// compositor to confirm what it decided. + fn request_mode(&mut self, surface: &WlSurface, decoration: &OrgKdeKwinServerDecoration, mode: WEnum<Mode>) { + let WEnum::Value(requested) = mode else { return }; + let forced = self.wm.borrow().theme.force_server_side; + // `Mode::None` means no decoration at all, which for srdwm's + // purposes is a client saying it wants nothing drawn around it -- + // treated as client-side, the same as `Mode::Client`, rather than + // as a request for a titlebar. + let server = forced || requested == Mode::Server; + let granted = if server { Mode::Server } else { requested }; + decoration.mode(granted); + self.set_decorated_from_mode(surface, server); + } + + /// The client is going away, or has dropped its decoration object. + /// Nothing to undo: `remove_window` already clears everything keyed on + /// this surface, and a surface with no decoration object keeps + /// whatever mode it last negotiated, exactly as before. + fn release(&mut self, _decoration: &OrgKdeKwinServerDecoration, _surface: &WlSurface) {} +} + +smithay::delegate_kde_decoration!(CompState); diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs index 64990c3..6382ee3 100644 --- a/crates/wayland/src/state/mod.rs +++ b/crates/wayland/src/state/mod.rs @@ -173,6 +173,12 @@ pub(crate) struct CompState { pub(crate) compositor_state: CompositorState, pub(crate) xdg_shell_state: XdgShellState, pub(crate) _xdg_decoration_state: XdgDecorationState, + /// KDE's older decoration protocol - see `protocols/kde_decoration.rs` + /// for why it is advertised alongside `xdg-decoration` rather than + /// instead of it. Read by `KdeDecorationHandler`, so unlike the + /// xdg-decoration state above this one is not an underscore-prefixed + /// keep-alive. + pub(crate) kde_decoration_state: smithay::wayland::shell::kde::decoration::KdeDecorationState, pub(crate) shm_state: ShmState, /// `zwp_linux_dmabuf_v1` - see `protocols.rs`'s `DmabufHandler` impl. /// Without this global, no client can hand the compositor a GPU buffer diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index d3c091c..45def0a 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -178,6 +178,14 @@ impl UdevPlatform { compositor_state, xdg_shell_state, _xdg_decoration_state: xdg_decoration_state, + // Default mode advertised before a client creates a decoration + // object at all - see `protocols/kde_decoration.rs`. Server, + // so a GTK application that reads only this protocol is told + // srdwm decorates, which is the whole point of advertising it. + kde_decoration_state: smithay::wayland::shell::kde::decoration::KdeDecorationState::new::<CompState>( + &display_handle, + smithay::reexports::wayland_protocols_misc::server_decoration::server::org_kde_kwin_server_decoration_manager::Mode::Server, + ), shm_state, dmabuf_state, xdg_activation_state: XdgActivationState::new::<CompState>(&display_handle), diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs index 1f2f876..e320cb8 100644 --- a/crates/wayland/src/winit/connect.rs +++ b/crates/wayland/src/winit/connect.rs @@ -91,6 +91,14 @@ impl WaylandPlatform { compositor_state, xdg_shell_state, _xdg_decoration_state: xdg_decoration_state, + // Default mode advertised before a client creates a decoration + // object at all - see `protocols/kde_decoration.rs`. Server, + // so a GTK application that reads only this protocol is told + // srdwm decorates, which is the whole point of advertising it. + kde_decoration_state: smithay::wayland::shell::kde::decoration::KdeDecorationState::new::<CompState>( + &dh, + smithay::reexports::wayland_protocols_misc::server_decoration::server::org_kde_kwin_server_decoration_manager::Mode::Server, + ), shm_state, dmabuf_state, xdg_activation_state: XdgActivationState::new::<CompState>(&dh), |