diff --git a/arc/platforms.md b/arc/platforms.md index 52d167b6a3235559e31cdba73976fa27e4a09960..a47bd0d46ecd439a3b812d92c3b8c90864a3ef86 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -63,16 +63,20 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. - X11 and Wayland. Title bars come from the window manager, or on Wayland from the compositor where it offers server-side decorations, and the toolbar's row lies beneath them, as in Dolphin and Kate. Elsewhere on Wayland, as on - GNOME, the row is the title bar, as a GTK 4 header bar holding tools: winit's - Adwaita frame keeps its shadow, corners and resize edges but has no header, - and the row ends in the window's buttons in the order GNOME's `button-layout` - lists them. Under that frame on GNOME the window erases its bottom - corners to transparent pixels and the frame draws libadwaita's window edge - round it: radius, shadows and outline. KWin rounds Breeze's corners itself. + GNOME, the row is the title bar, as a GTK 4 header bar holding tools: + winit's Adwaita frame keeps its shadow, corners and resize edges but has no + header, and the window's buttons sit at the row's ends as GNOME's + `button-layout` places them, kept current through the settings portal. On + GNOME they are libadwaita's, sized and coloured as the release that came + with the running GNOME Shell draws them; elsewhere they are the toolbar's + own buttons, not an imitation of a theme Snowbound can't read. Under that + frame on GNOME the window erases its bottom corners to transparent pixels + and the frame draws libadwaita's window edge round it: radius, shadows and + outline. KWin rounds Breeze's corners itself. - The toolbar and the rest of the chrome continue the title bar's fill, focused and not: on KDE the colour scheme's header colours from - `kdeglobals`, as KWin paints its title bars, and on GNOME winit's Adwaita - frame's. A settings portal signal re-reads them when the scheme changes. + `kdeglobals`, as KWin paints its title bars, and on GNOME libadwaita's + header bar's. A settings portal signal re-reads them when the scheme changes. - Menus and other popups take the desktop's look and motion: libadwaita's popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. diff --git a/crates/snowbound/assets/adwaita/window-close.svg b/crates/snowbound/assets/adwaita/window-close.svg new file mode 100644 index 0000000000000000000000000000000000000000..9e4cde45123b8068a16c2e24640c162967ca6998 --- /dev/null +++ b/crates/snowbound/assets/adwaita/window-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/adwaita/window-maximize.svg b/crates/snowbound/assets/adwaita/window-maximize.svg new file mode 100644 index 0000000000000000000000000000000000000000..56dedbc9d89f646578e98ba6764028a115f6ab6b --- /dev/null +++ b/crates/snowbound/assets/adwaita/window-maximize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/adwaita/window-minimize.svg b/crates/snowbound/assets/adwaita/window-minimize.svg new file mode 100644 index 0000000000000000000000000000000000000000..702a692dfbdd060e96a21276518bb5307121997a --- /dev/null +++ b/crates/snowbound/assets/adwaita/window-minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/adwaita/window-restore.svg b/crates/snowbound/assets/adwaita/window-restore.svg new file mode 100644 index 0000000000000000000000000000000000000000..57a2dc1a021bbf3f9e22c24c0eeea9936b705487 --- /dev/null +++ b/crates/snowbound/assets/adwaita/window-restore.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 84cce0188c05e08e6e35fdfcea9ec8663ff9e69a..0d411b87f3bc381f123c740e75e87346cb17d8ac 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -14,7 +14,7 @@ use std::{ path::PathBuf, process::Command, sync::{ - OnceLock, + Mutex, OnceLock, atomic::{AtomicBool, AtomicU32, Ordering}, }, time::Duration, @@ -30,10 +30,9 @@ use winit::{ }; use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; -/// The title bar's leading margin; the window controls sit at its trailing end. -pub const LEADING: f32 = 8.0; -/// The margin past the window controls. -pub const TRAILING: f32 = 8.0; +/// The title bar's margins at its leading and trailing ends, libadwaita's header bar's. +pub const LEADING: f32 = 7.0; +pub const TRAILING: f32 = 7.0; /// How far inside the window's edges a press resizes it, and how far along them a corner /// reaches, in logical pixels. const EDGE: f32 = 5.0; @@ -41,6 +40,14 @@ const CORNER: f32 = 16.0; const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]; const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; +/// Adwaita's `window-*-symbolic` glyphs, which GTK's window controls show. +const ADWAITA_MINIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-minimize.svg")]; +const ADWAITA_MAXIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-maximize.svg")]; +const ADWAITA_RESTORE: &[&str] = &[include_str!("../assets/adwaita/window-restore.svg")]; +const ADWAITA_CLOSE: &[&str] = &[include_str!("../assets/adwaita/window-close.svg")]; +/// GNOME's `button-layout`, as GTK reads it: the window's buttons at the title bar's start, +/// a colon, then those at its end. Read once, then kept by the settings portal's signal. +static BUTTON_LAYOUT: Mutex> = Mutex::new(None); /// The event loop's, for signals and Snowbound's own dialogs. pub(crate) static PROXY: OnceLock> = OnceLock::new(); @@ -577,76 +584,152 @@ fn kde_color(value: &str) -> Option<[u8; 3]> { Some([channels.next()??, channels.next()??, channels.next()??]) } -/// The header bar of winit's Adwaita frame, focused and not. +/// libadwaita's header bar, focused and not: neutral greys before 1.7, bluish ones since. fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { - let theme = match appearance { - Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(), - Theme::Light => sctk_adwaita::theme::ColorTheme::light(), - }; - [theme.active, theme.inactive].map(|colors| { - let color = colors.headerbar.to_color_u8(); - [color.red(), color.green(), color.blue()] + match (appearance, adwaita_minor() >= 7) { + (Theme::Light, true) => [[0xff; 3], [0xfa, 0xfa, 0xfb]], + (Theme::Light, false) => [[0xff; 3], [0xfa; 3]], + (Theme::Dark, true) => [[0x2e, 0x2e, 0x32], [0x22, 0x22, 0x26]], + (Theme::Dark, false) => [[0x30; 3], [0x24; 3]], + } +} + +/// The minor version of the libadwaita GNOME apps use, as the session's GNOME Shell implies: +/// 1.N came with GNOME 41 + N. The newest where the shell doesn't answer. +fn adwaita_minor() -> u32 { + static MINOR: OnceLock = OnceLock::new(); + *MINOR.get_or_init(|| { + let shell = || -> Option { + let connection = zbus::blocking::connection::Builder::session() + .ok()? + .method_timeout(Duration::from_millis(500)) + .build() + .ok()?; + zbus::blocking::Proxy::new( + &connection, + "org.gnome.Shell", + "/org/gnome/Shell", + "org.gnome.Shell", + ) + .ok()? + .get_property("ShellVersion") + .ok() + }; + shell() + .and_then(|version| version.split('.').next()?.parse::().ok()) + .map_or(u32::MAX, |major| major.saturating_sub(41)) }) } -/// The window's buttons at the title bar's trailing end, in the order GNOME's -/// `button-layout` lists them, which is close alone by default; elsewhere minimize, -/// maximize and close. -pub fn window_controls(ui: &mut Ui, window: &Window) { - static CONTROLS: OnceLock> = OnceLock::new(); - let controls = CONTROLS.get_or_init(|| { - let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout") - .and_then(|value| String::try_from(value).ok()); - match layout { - Some(layout) => layout - .split([':', ',']) - .filter_map(|name| { - ["minimize", "maximize", "close"] - .into_iter() - .find(|known| *known == name) +/// The window's buttons at the title bar's start, or its `end`, as the desktop's button +/// layout places them. On GNOME they are libadwaita's: discs in the header bar's ink, dimmed +/// with it while the window is in the background. Elsewhere, where the compositor offered no +/// decorations of its own, they are the toolbar's own buttons. +pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) { + let layout = BUTTON_LAYOUT + .lock() + .unwrap() + .get_or_insert_with(|| { + portal_setting("org.gnome.desktop.wm.preferences", "button-layout") + .and_then(|value| String::try_from(value).ok()) + .unwrap_or_else(|| { + if desktop() == Desktop::Gnome { + "appmenu:close" + } else { + ":minimize,maximize,close" + } + .into() }) - .collect(), - None if desktop() == Desktop::Gnome => vec!["close"], - None => vec!["minimize", "maximize", "close"], - } + }) + .clone(); + let (start, finish) = layout.split_once(':').unwrap_or((&layout, "")); + let controls: Vec<&str> = if end { finish } else { start } + .split(',') + .map(str::trim) + .filter(|name| matches!(*name, "minimize" | "maximize" | "close")) + .collect(); + if controls.is_empty() { + return; + } + let adwaita = desktop() == Desktop::Gnome; + // libadwaita's are 34 px buttons 3 px apart. + let (button, gap) = if adwaita { + (34.0, 3.0) + } else { + (ui::shell::TOOL, 6.0) + }; + ui.open( + ("controls", end), + Spec { + size: [ui::children(), px(button)], + offset: [0.0, (ui::shell::TOOL - button) / 2.0], + gap, + ..Spec::default() + }, + ); + // `--headerbar-fg-color` at 10 %, 15 % and 30 % for the disc at rest, hovered and + // pressed, and whole for the glyph, over the header bar; at half opacity in the + // background. + let colors = adwaita.then(|| { + // Light text is the dark appearance's. + let appearance = if ui.theme.text[0] > 0.5 { + Theme::Dark + } else { + Theme::Light + }; + let header = adwaita_titlebar(appearance)[usize::from(!ui.window_focused)]; + let (ink, strength) = match appearance { + Theme::Dark => ([0xff; 3], 1.0), + Theme::Light if adwaita_minor() >= 7 => ([0, 0, 6], 0.8), + Theme::Light => ([0; 3], 0.8), + }; + let opacity = if ui.window_focused { 1.0 } else { 0.5 }; + let disc = |alpha: f32| over(ink, strength * alpha, header); + let rest = disc(0.1); + [rest, disc(0.15), disc(0.3), over(ink, strength, rest)] + .map(|color| linear(over(color, opacity, header))) }); - let text = ui.theme.text; - let disc = |alpha| [text[0], text[1], text[2], alpha]; for control in controls { - let icon = match *control { - "minimize" => MINIMIZE, - "maximize" if window.is_maximized() => RESTORE, - "maximize" => MAXIMIZE, - _ => crate::art::CLOSE, + let (name, icons) = match control { + "minimize" => ("Minimize", [MINIMIZE, ADWAITA_MINIMIZE]), + "maximize" if window.is_maximized() => ("Restore", [RESTORE, ADWAITA_RESTORE]), + "maximize" => ("Maximize", [MAXIMIZE, ADWAITA_MAXIMIZE]), + _ => ("Close", [crate::art::CLOSE, ADWAITA_CLOSE]), }; - // Round, on a faint disc, as libadwaita's are. - let clicked = ui - .leaf( + let clicked = if let Some([rest, hover, pressed, glyph]) = colors { + // The 16 px glyph's disc pads it by 2 px, or by 4 px from 1.8. + let disc = if adwaita_minor() >= 8 { 24.0 } else { 20.0 }; + let id = ui.id(control); + let signal = ui.signal(id); + let press = ui.animate( + id.child("pressed"), + f32::from(u8::from(signal.dragging && signal.hovered)), + ); + ui.leaf( control, Spec { flags: Flags::CLICKABLE, - size: [px(ui::shell::TOOL); 2], - icon: Some(icon), - color: Some(text), - fill: Some(disc(0.1)), - hover_fill: Some(disc(0.18)), - radius: ui::shell::TOOL / 2.0, + size: [px(button); 2], + inset: [(button - disc) / 2.0; 4], + icon: Some(icons[1]), + color: Some(glyph), + fill: Some(ui::mix(rest, pressed, press)), + hover_fill: Some(ui::mix(hover, pressed, press)), + radius: disc / 2.0, center: true, role: Some(accesskit::Role::Button), ..Spec::default() }, - ) - .clicked; - let name = match *control { - "minimize" => "Minimize", - "maximize" if window.is_maximized() => "Restore", - "maximize" => "Maximize", - _ => "Close", + ); + signal.clicked + } else { + let text = ui.theme.text; + ui::shell::tool_button(ui, control, icons[0], text, None).clicked }; if let Some(node) = ui.access(ui.id(control)) { node.set_label(name); } - match *control { + match control { _ if !clicked => {} "minimize" => window.set_minimized(true), "maximize" => zoom(window), @@ -657,6 +740,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) { } } } + ui.close(); } /// 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 { } /// Asks for the desktop's colours again whenever the settings portal reports a change to the -/// colour scheme, or to KDE's colours. +/// colour scheme, or to KDE's colours, and keeps GNOME's button layout. fn watch_settings(proxy: EventLoopProxy) { std::thread::spawn(move || { let Ok(connection) = zbus::blocking::Connection::session() else { @@ -1073,16 +1157,25 @@ fn watch_settings(proxy: EventLoopProxy) { return; }; for change in changes { - let Ok((namespace, _, _)) = change - .body() - .deserialize::<(String, String, zbus::zvariant::OwnedValue)>() + let Ok((namespace, key, value)) = + change + .body() + .deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue; }; - if (namespace == "org.freedesktop.appearance" - || namespace.starts_with("org.kde.kdeglobals")) - && proxy.send_event(crate::UserEvent::Appearance).is_err() + let event = if namespace == "org.freedesktop.appearance" + || namespace.starts_with("org.kde.kdeglobals") { + crate::UserEvent::Appearance + } else if namespace == "org.gnome.desktop.wm.preferences" && key == "button-layout" { + *BUTTON_LAYOUT.lock().unwrap() = + innermost(value).and_then(|value| String::try_from(value).ok()); + crate::UserEvent::Redraw + } else { + continue; + }; + if proxy.send_event(event).is_err() { return; } } @@ -1115,11 +1208,17 @@ fn portal_setting(namespace: &str, key: &str) -> Option { .ok()?; let key = (namespace, key); // Portals before ReadOne answer Read, with the value in a second variant. - let reply: OwnedValue = proxy - .call("ReadOne", &key) - .or_else(|_| proxy.call("Read", &key)) - .ok()?; - let mut value = Value::from(reply); + innermost( + proxy + .call("ReadOne", &key) + .or_else(|_| proxy.call("Read", &key)) + .ok()?, + ) +} + +/// `value` out of the variants a setting comes wrapped in. +fn innermost(value: OwnedValue) -> Option { + let mut value = Value::from(value); while let Value::Value(inner) = value { value = *inner; } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 07d0ceaf31eea0c40207ddb7d82e5588e9d8eec5..e4132cb98efad68d3087947c0a74bbbc05915230 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -368,7 +368,7 @@ unsafe fn ns_data_bytes(data: &AnyObject) -> Vec { } /// AppKit draws the traffic lights. -pub fn window_controls(_: &mut ui::Ui, _: &Window) {} +pub fn window_controls(_: &mut ui::Ui, _: &Window, _: bool) {} /// Whether AppKit draws the title bar: before 10.10 content can't extend under it, and /// otherwise the app draws it around the traffic lights. diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 8b1d9581ba7be494df27d5a7f7c89f11cd8c5e74..e0a3b8fa5a15cc63b45a5da36c2199088df38e95 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -2017,6 +2017,9 @@ impl State { ..Spec::default() }, ); + if title { + platform::window_controls(&mut self.ui, &self.window, false); + } if tools { // The groups fold in the room the window's controls leave, and whatever still // overflows is cut there, so the controls always show. @@ -2050,7 +2053,7 @@ impl State { } self.ui.close(); if title { - platform::window_controls(&mut self.ui, &self.window); + platform::window_controls(&mut self.ui, &self.window, true); } self.ui.leaf( "trail", diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 6eb54c24473dffcce4ca44e4c81761a7d3d0692b..a42a840e85884a7b4f065c37f8baafde98914678 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -532,7 +532,7 @@ pub fn cuts_corners() -> bool { false } -pub fn window_controls(_: &mut Ui, _: &Window) {} +pub fn window_controls(_: &mut Ui, _: &Window, _: bool) {} pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { None diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index 6de093463ef64fa84b3ac567f37e5c261ab55133..7155c4659bcdd3936245bac1dc2af5dd689eab6b 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -784,7 +784,10 @@ const CLOSE: &[&str] = &[include_str!("../assets/windows/caption-close.svg")]; /// 7 draws them over the glass and the row leaves them room, and 11 doesn't draw them over /// the DirectComposition surface, so the row draws them as 11 does, lit as the system /// reports the pointer. On 8 and 10 the row draws and runs them as 10 does. -pub fn window_controls(ui: &mut Ui, window: &Window) { +pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) { + if !end { + return; + } let scale = window.scale_factor() as f32; SCALE.store(scale.to_bits(), Ordering::Relaxed); let system = caption() == Caption::Glass;