authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:39:48-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 03:16:54-07:00
log3dade15eef573afab71c86c6ae88e452c7da8176
treefec73d566ff894d0a4e799983463c79bbcbc41e6
parentba16a18feec9b6b0d5b03464b1e8cda9ded4a81e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Linux window controls match libadwaita on GNOME and follow the button layout

On GNOME the title bar row drew a thin stroked X on a 22 px disc of the app text colour over sctk-adwaita's GTK 3 header grey. It now draws libadwaita's window controls: 34 px buttons 3 px apart, a 24 px disc (20 px before 1.8) at 10/15/30 % of the header bar ink at rest, hovered and pressed, Adwaita's window-*-symbolic glyphs, the whole set at half opacity in the background, over libadwaita's header colours. The libadwaita release follows the running GNOME Shell (1.N with GNOME 41+N). The buttons sit at the start or end of the row as button-layout places them, re-read when the settings portal reports a change. Desktops other than GNOME that leave decorations to the client get the toolbar's own buttons rather than an imitation of Adwaita. Assisted-by: claude-opus-5.5

10 files changed, 201 insertions(+), 80 deletions(-)

arc/platforms.md+12-8
......@@ -63,16 +63,20 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
6363- X11 and Wayland. Title bars come from the window manager, or on Wayland from
6464 the compositor where it offers server-side decorations, and the toolbar's
6565 row lies beneath them, as in Dolphin and Kate. Elsewhere on Wayland, as on
66 GNOME, the row is the title bar, as a GTK 4 header bar holding tools: winit's
67 Adwaita frame keeps its shadow, corners and resize edges but has no header,
68 and the row ends in the window's buttons in the order GNOME's `button-layout`
69 lists them. Under that frame on GNOME the window erases its bottom
70 corners to transparent pixels and the frame draws libadwaita's window edge
71 round it: radius, shadows and outline. KWin rounds Breeze's corners itself.
66 GNOME, the row is the title bar, as a GTK 4 header bar holding tools:
67 winit's Adwaita frame keeps its shadow, corners and resize edges but has no
68 header, and the window's buttons sit at the row's ends as GNOME's
69 `button-layout` places them, kept current through the settings portal. On
70 GNOME they are libadwaita's, sized and coloured as the release that came
71 with the running GNOME Shell draws them; elsewhere they are the toolbar's
72 own buttons, not an imitation of a theme Snowbound can't read. Under that
73 frame on GNOME the window erases its bottom corners to transparent pixels
74 and the frame draws libadwaita's window edge round it: radius, shadows and
75 outline. KWin rounds Breeze's corners itself.
7276- The toolbar and the rest of the chrome continue the title bar's fill,
7377 focused and not: on KDE the colour scheme's header colours from
74 `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita
75 frame's. A settings portal signal re-reads them when the scheme changes.
78 `kdeglobals`, as KWin paints its title bars, and on GNOME libadwaita's
79 header bar's. A settings portal signal re-reads them when the scheme changes.
7680- Menus and other popups take the desktop's look and motion: libadwaita's
7781 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's
7882 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.
crates/snowbound/assets/adwaita/window-close.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 4H5L8 6.6 11 4H12V5L9.4 8 12 11V12H11L8 9.4 5 12H4V11L6.6 8 4 5Z" fill="currentColor"/>
3</svg>
crates/snowbound/assets/adwaita/window-maximize.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 4H12V12H4ZM6 6V10H10V6Z" fill="currentColor" fill-rule="evenodd"/>
3</svg>
crates/snowbound/assets/adwaita/window-minimize.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 10H12V12H4Z" fill="currentColor"/>
3</svg>
crates/snowbound/assets/adwaita/window-restore.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M5 5H11V11H5ZM7 7V9H9V7Z" fill="currentColor" fill-rule="evenodd"/>
3</svg>
crates/snowbound/src/linux.rs+167-68
......@@ -14,7 +14,7 @@ use std::{
1414 path::PathBuf,
1515 process::Command,
1616 sync::{
17 OnceLock,
17 Mutex, OnceLock,
1818 atomic::{AtomicBool, AtomicU32, Ordering},
1919 },
2020 time::Duration,
......@@ -30,10 +30,9 @@ use winit::{
3030};
3131use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
3232
33/// The title bar's leading margin; the window controls sit at its trailing end.
34pub const LEADING: f32 = 8.0;
35/// The margin past the window controls.
36pub const TRAILING: f32 = 8.0;
33/// The title bar's margins at its leading and trailing ends, libadwaita's header bar's.
34pub const LEADING: f32 = 7.0;
35pub const TRAILING: f32 = 7.0;
3736/// How far inside the window's edges a press resizes it, and how far along them a corner
3837/// reaches, in logical pixels.
3938const EDGE: f32 = 5.0;
......@@ -41,6 +40,14 @@ const CORNER: f32 = 16.0;
4140const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")];
4241const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")];
4342const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];
43/// Adwaita's `window-*-symbolic` glyphs, which GTK's window controls show.
44const ADWAITA_MINIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-minimize.svg")];
45const ADWAITA_MAXIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-maximize.svg")];
46const ADWAITA_RESTORE: &[&str] = &[include_str!("../assets/adwaita/window-restore.svg")];
47const ADWAITA_CLOSE: &[&str] = &[include_str!("../assets/adwaita/window-close.svg")];
48/// GNOME's `button-layout`, as GTK reads it: the window's buttons at the title bar's start,
49/// a colon, then those at its end. Read once, then kept by the settings portal's signal.
50static BUTTON_LAYOUT: Mutex<Option<String>> = Mutex::new(None);
4451
4552/// The event loop's, for signals and Snowbound's own dialogs.
4653pub(crate) static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
......@@ -577,76 +584,152 @@ fn kde_color(value: &str) -> Option<[u8; 3]> {
577584 Some([channels.next()??, channels.next()??, channels.next()??])
578585}
579586
580/// The header bar of winit's Adwaita frame, focused and not.
587/// libadwaita's header bar, focused and not: neutral greys before 1.7, bluish ones since.
581588fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] {
582 let theme = match appearance {
583 Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(),
584 Theme::Light => sctk_adwaita::theme::ColorTheme::light(),
585 };
586 [theme.active, theme.inactive].map(|colors| {
587 let color = colors.headerbar.to_color_u8();
588 [color.red(), color.green(), color.blue()]
589 match (appearance, adwaita_minor() >= 7) {
590 (Theme::Light, true) => [[0xff; 3], [0xfa, 0xfa, 0xfb]],
591 (Theme::Light, false) => [[0xff; 3], [0xfa; 3]],
592 (Theme::Dark, true) => [[0x2e, 0x2e, 0x32], [0x22, 0x22, 0x26]],
593 (Theme::Dark, false) => [[0x30; 3], [0x24; 3]],
594 }
595}
596
597/// The minor version of the libadwaita GNOME apps use, as the session's GNOME Shell implies:
598/// 1.N came with GNOME 41 + N. The newest where the shell doesn't answer.
599fn adwaita_minor() -> u32 {
600 static MINOR: OnceLock<u32> = OnceLock::new();
601 *MINOR.get_or_init(|| {
602 let shell = || -> Option<String> {
603 let connection = zbus::blocking::connection::Builder::session()
604 .ok()?
605 .method_timeout(Duration::from_millis(500))
606 .build()
607 .ok()?;
608 zbus::blocking::Proxy::new(
609 &connection,
610 "org.gnome.Shell",
611 "/org/gnome/Shell",
612 "org.gnome.Shell",
613 )
614 .ok()?
615 .get_property("ShellVersion")
616 .ok()
617 };
618 shell()
619 .and_then(|version| version.split('.').next()?.parse::<u32>().ok())
620 .map_or(u32::MAX, |major| major.saturating_sub(41))
589621 })
590622}
591623
592/// The window's buttons at the title bar's trailing end, in the order GNOME's
593/// `button-layout` lists them, which is close alone by default; elsewhere minimize,
594/// maximize and close.
595pub fn window_controls(ui: &mut Ui, window: &Window) {
596 static CONTROLS: OnceLock<Vec<&'static str>> = OnceLock::new();
597 let controls = CONTROLS.get_or_init(|| {
598 let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout")
599 .and_then(|value| String::try_from(value).ok());
600 match layout {
601 Some(layout) => layout
602 .split([':', ','])
603 .filter_map(|name| {
604 ["minimize", "maximize", "close"]
605 .into_iter()
606 .find(|known| *known == name)
624/// The window's buttons at the title bar's start, or its `end`, as the desktop's button
625/// layout places them. On GNOME they are libadwaita's: discs in the header bar's ink, dimmed
626/// with it while the window is in the background. Elsewhere, where the compositor offered no
627/// decorations of its own, they are the toolbar's own buttons.
628pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) {
629 let layout = BUTTON_LAYOUT
630 .lock()
631 .unwrap()
632 .get_or_insert_with(|| {
633 portal_setting("org.gnome.desktop.wm.preferences", "button-layout")
634 .and_then(|value| String::try_from(value).ok())
635 .unwrap_or_else(|| {
636 if desktop() == Desktop::Gnome {
637 "appmenu:close"
638 } else {
639 ":minimize,maximize,close"
640 }
641 .into()
607642 })
608 .collect(),
609 None if desktop() == Desktop::Gnome => vec!["close"],
610 None => vec!["minimize", "maximize", "close"],
611 }
643 })
644 .clone();
645 let (start, finish) = layout.split_once(':').unwrap_or((&layout, ""));
646 let controls: Vec<&str> = if end { finish } else { start }
647 .split(',')
648 .map(str::trim)
649 .filter(|name| matches!(*name, "minimize" | "maximize" | "close"))
650 .collect();
651 if controls.is_empty() {
652 return;
653 }
654 let adwaita = desktop() == Desktop::Gnome;
655 // libadwaita's are 34 px buttons 3 px apart.
656 let (button, gap) = if adwaita {
657 (34.0, 3.0)
658 } else {
659 (ui::shell::TOOL, 6.0)
660 };
661 ui.open(
662 ("controls", end),
663 Spec {
664 size: [ui::children(), px(button)],
665 offset: [0.0, (ui::shell::TOOL - button) / 2.0],
666 gap,
667 ..Spec::default()
668 },
669 );
670 // `--headerbar-fg-color` at 10 %, 15 % and 30 % for the disc at rest, hovered and
671 // pressed, and whole for the glyph, over the header bar; at half opacity in the
672 // background.
673 let colors = adwaita.then(|| {
674 // Light text is the dark appearance's.
675 let appearance = if ui.theme.text[0] > 0.5 {
676 Theme::Dark
677 } else {
678 Theme::Light
679 };
680 let header = adwaita_titlebar(appearance)[usize::from(!ui.window_focused)];
681 let (ink, strength) = match appearance {
682 Theme::Dark => ([0xff; 3], 1.0),
683 Theme::Light if adwaita_minor() >= 7 => ([0, 0, 6], 0.8),
684 Theme::Light => ([0; 3], 0.8),
685 };
686 let opacity = if ui.window_focused { 1.0 } else { 0.5 };
687 let disc = |alpha: f32| over(ink, strength * alpha, header);
688 let rest = disc(0.1);
689 [rest, disc(0.15), disc(0.3), over(ink, strength, rest)]
690 .map(|color| linear(over(color, opacity, header)))
612691 });
613 let text = ui.theme.text;
614 let disc = |alpha| [text[0], text[1], text[2], alpha];
615692 for control in controls {
616 let icon = match *control {
617 "minimize" => MINIMIZE,
618 "maximize" if window.is_maximized() => RESTORE,
619 "maximize" => MAXIMIZE,
620 _ => crate::art::CLOSE,
693 let (name, icons) = match control {
694 "minimize" => ("Minimize", [MINIMIZE, ADWAITA_MINIMIZE]),
695 "maximize" if window.is_maximized() => ("Restore", [RESTORE, ADWAITA_RESTORE]),
696 "maximize" => ("Maximize", [MAXIMIZE, ADWAITA_MAXIMIZE]),
697 _ => ("Close", [crate::art::CLOSE, ADWAITA_CLOSE]),
621698 };
622 // Round, on a faint disc, as libadwaita's are.
623 let clicked = ui
624 .leaf(
699 let clicked = if let Some([rest, hover, pressed, glyph]) = colors {
700 // The 16 px glyph's disc pads it by 2 px, or by 4 px from 1.8.
701 let disc = if adwaita_minor() >= 8 { 24.0 } else { 20.0 };
702 let id = ui.id(control);
703 let signal = ui.signal(id);
704 let press = ui.animate(
705 id.child("pressed"),
706 f32::from(u8::from(signal.dragging && signal.hovered)),
707 );
708 ui.leaf(
625709 control,
626710 Spec {
627711 flags: Flags::CLICKABLE,
628 size: [px(ui::shell::TOOL); 2],
629 icon: Some(icon),
630 color: Some(text),
631 fill: Some(disc(0.1)),
632 hover_fill: Some(disc(0.18)),
633 radius: ui::shell::TOOL / 2.0,
712 size: [px(button); 2],
713 inset: [(button - disc) / 2.0; 4],
714 icon: Some(icons[1]),
715 color: Some(glyph),
716 fill: Some(ui::mix(rest, pressed, press)),
717 hover_fill: Some(ui::mix(hover, pressed, press)),
718 radius: disc / 2.0,
634719 center: true,
635720 role: Some(accesskit::Role::Button),
636721 ..Spec::default()
637722 },
638 )
639 .clicked;
640 let name = match *control {
641 "minimize" => "Minimize",
642 "maximize" if window.is_maximized() => "Restore",
643 "maximize" => "Maximize",
644 _ => "Close",
723 );
724 signal.clicked
725 } else {
726 let text = ui.theme.text;
727 ui::shell::tool_button(ui, control, icons[0], text, None).clicked
645728 };
646729 if let Some(node) = ui.access(ui.id(control)) {
647730 node.set_label(name);
648731 }
649 match *control {
732 match control {
650733 _ if !clicked => {}
651734 "minimize" => window.set_minimized(true),
652735 "maximize" => zoom(window),
......@@ -657,6 +740,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) {
657740 }
658741 }
659742 }
743 ui.close();
660744}
661745
662746/// The edge or corner a press at `pointer`, in logical pixels, resizes the window from, when
......@@ -1055,7 +1139,7 @@ pub fn appearance(_: &Window) -> Theme {
10551139}
10561140
10571141/// Asks for the desktop's colours again whenever the settings portal reports a change to the
1058/// colour scheme, or to KDE's colours.
1142/// colour scheme, or to KDE's colours, and keeps GNOME's button layout.
10591143fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
10601144 std::thread::spawn(move || {
10611145 let Ok(connection) = zbus::blocking::Connection::session() else {
......@@ -1073,16 +1157,25 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) {
10731157 return;
10741158 };
10751159 for change in changes {
1076 let Ok((namespace, _, _)) = change
1077 .body()
1078 .deserialize::<(String, String, zbus::zvariant::OwnedValue)>()
1160 let Ok((namespace, key, value)) =
1161 change
1162 .body()
1163 .deserialize::<(String, String, zbus::zvariant::OwnedValue)>()
10791164 else {
10801165 continue;
10811166 };
1082 if (namespace == "org.freedesktop.appearance"
1083 || namespace.starts_with("org.kde.kdeglobals"))
1084 && proxy.send_event(crate::UserEvent::Appearance).is_err()
1167 let event = if namespace == "org.freedesktop.appearance"
1168 || namespace.starts_with("org.kde.kdeglobals")
10851169 {
1170 crate::UserEvent::Appearance
1171 } else if namespace == "org.gnome.desktop.wm.preferences" && key == "button-layout" {
1172 *BUTTON_LAYOUT.lock().unwrap() =
1173 innermost(value).and_then(|value| String::try_from(value).ok());
1174 crate::UserEvent::Redraw
1175 } else {
1176 continue;
1177 };
1178 if proxy.send_event(event).is_err() {
10861179 return;
10871180 }
10881181 }
......@@ -1115,11 +1208,17 @@ fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> {
11151208 .ok()?;
11161209 let key = (namespace, key);
11171210 // Portals before ReadOne answer Read, with the value in a second variant.
1118 let reply: OwnedValue = proxy
1119 .call("ReadOne", &key)
1120 .or_else(|_| proxy.call("Read", &key))
1121 .ok()?;
1122 let mut value = Value::from(reply);
1211 innermost(
1212 proxy
1213 .call("ReadOne", &key)
1214 .or_else(|_| proxy.call("Read", &key))
1215 .ok()?,
1216 )
1217}
1218
1219/// `value` out of the variants a setting comes wrapped in.
1220fn innermost(value: OwnedValue) -> Option<OwnedValue> {
1221 let mut value = Value::from(value);
11231222 while let Value::Value(inner) = value {
11241223 value = *inner;
11251224 }
crates/snowbound/src/macos.rs+1-1
......@@ -368,7 +368,7 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> {
368368}
369369
370370/// AppKit draws the traffic lights.
371pub fn window_controls(_: &mut ui::Ui, _: &Window) {}
371pub fn window_controls(_: &mut ui::Ui, _: &Window, _: bool) {}
372372
373373/// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and
374374/// otherwise the app draws it around the traffic lights.
crates/snowbound/src/main.rs+4-1
......@@ -2017,6 +2017,9 @@ impl State {
20172017 ..Spec::default()
20182018 },
20192019 );
2020 if title {
2021 platform::window_controls(&mut self.ui, &self.window, false);
2022 }
20202023 if tools {
20212024 // The groups fold in the room the window's controls leave, and whatever still
20222025 // overflows is cut there, so the controls always show.
......@@ -2050,7 +2053,7 @@ impl State {
20502053 }
20512054 self.ui.close();
20522055 if title {
2053 platform::window_controls(&mut self.ui, &self.window);
2056 platform::window_controls(&mut self.ui, &self.window, true);
20542057 }
20552058 self.ui.leaf(
20562059 "trail",
crates/snowbound/src/web.rs+1-1
......@@ -532,7 +532,7 @@ pub fn cuts_corners() -> bool {
532532 false
533533}
534534
535pub fn window_controls(_: &mut Ui, _: &Window) {}
535pub fn window_controls(_: &mut Ui, _: &Window, _: bool) {}
536536
537537pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<ResizeDirection> {
538538 None
crates/snowbound/src/windows.rs+4-1
......@@ -784,7 +784,10 @@ const CLOSE: &[&str] = &[include_str!("../assets/windows/caption-close.svg")];
784784/// 7 draws them over the glass and the row leaves them room, and 11 doesn't draw them over
785785/// the DirectComposition surface, so the row draws them as 11 does, lit as the system
786786/// reports the pointer. On 8 and 10 the row draws and runs them as 10 does.
787pub fn window_controls(ui: &mut Ui, window: &Window) {
787pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) {
788 if !end {
789 return;
790 }
788791 let scale = window.scale_factor() as f32;
789792 SCALE.store(scale.to_bits(), Ordering::Relaxed);
790793 let system = caption() == Caption::Glass;