| ... | ... | @@ -14,7 +14,7 @@ use std::{ |
| 14 | 14 | path::PathBuf, |
| 15 | 15 | process::Command, |
| 16 | 16 | sync::{ |
| 17 | | OnceLock, |
| 17 | Mutex, OnceLock, |
| 18 | 18 | atomic::{AtomicBool, AtomicU32, Ordering}, |
| 19 | 19 | }, |
| 20 | 20 | time::Duration, |
| ... | ... | @@ -30,10 +30,9 @@ use winit::{ |
| 30 | 30 | }; |
| 31 | 31 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; |
| 32 | 32 | |
| 33 | | /// The title bar's leading margin; the window controls sit at its trailing end. |
| 34 | | pub const LEADING: f32 = 8.0; |
| 35 | | /// The margin past the window controls. |
| 36 | | pub const TRAILING: f32 = 8.0; |
| 33 | /// The title bar's margins at its leading and trailing ends, libadwaita's header bar's. |
| 34 | pub const LEADING: f32 = 7.0; |
| 35 | pub const TRAILING: f32 = 7.0; |
| 37 | 36 | /// How far inside the window's edges a press resizes it, and how far along them a corner |
| 38 | 37 | /// reaches, in logical pixels. |
| 39 | 38 | const EDGE: f32 = 5.0; |
| ... | ... | @@ -41,6 +40,14 @@ const CORNER: f32 = 16.0; |
| 41 | 40 | const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]; |
| 42 | 41 | const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; |
| 43 | 42 | const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; |
| 43 | /// Adwaita's `window-*-symbolic` glyphs, which GTK's window controls show. |
| 44 | const ADWAITA_MINIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-minimize.svg")]; |
| 45 | const ADWAITA_MAXIMIZE: &[&str] = &[include_str!("../assets/adwaita/window-maximize.svg")]; |
| 46 | const ADWAITA_RESTORE: &[&str] = &[include_str!("../assets/adwaita/window-restore.svg")]; |
| 47 | const 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. |
| 50 | static BUTTON_LAYOUT: Mutex<Option<String>> = Mutex::new(None); |
| 44 | 51 | |
| 45 | 52 | /// The event loop's, for signals and Snowbound's own dialogs. |
| 46 | 53 | pub(crate) static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| ... | ... | @@ -577,76 +584,152 @@ fn kde_color(value: &str) -> Option<[u8; 3]> { |
| 577 | 584 | Some([channels.next()??, channels.next()??, channels.next()??]) |
| 578 | 585 | } |
| 579 | 586 | |
| 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. |
| 581 | 588 | fn 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. |
| 599 | fn 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)) |
| 589 | 621 | }) |
| 590 | 622 | } |
| 591 | 623 | |
| 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. |
| 595 | | pub 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. |
| 628 | pub 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() |
| 607 | 642 | }) |
| 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))) |
| 612 | 691 | }); |
| 613 | | let text = ui.theme.text; |
| 614 | | let disc = |alpha| [text[0], text[1], text[2], alpha]; |
| 615 | 692 | 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]), |
| 621 | 698 | }; |
| 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( |
| 625 | 709 | control, |
| 626 | 710 | Spec { |
| 627 | 711 | 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, |
| 634 | 719 | center: true, |
| 635 | 720 | role: Some(accesskit::Role::Button), |
| 636 | 721 | ..Spec::default() |
| 637 | 722 | }, |
| 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 |
| 645 | 728 | }; |
| 646 | 729 | if let Some(node) = ui.access(ui.id(control)) { |
| 647 | 730 | node.set_label(name); |
| 648 | 731 | } |
| 649 | | match *control { |
| 732 | match control { |
| 650 | 733 | _ if !clicked => {} |
| 651 | 734 | "minimize" => window.set_minimized(true), |
| 652 | 735 | "maximize" => zoom(window), |
| ... | ... | @@ -657,6 +740,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) { |
| 657 | 740 | } |
| 658 | 741 | } |
| 659 | 742 | } |
| 743 | ui.close(); |
| 660 | 744 | } |
| 661 | 745 | |
| 662 | 746 | /// 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 { |
| 1055 | 1139 | } |
| 1056 | 1140 | |
| 1057 | 1141 | /// 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. |
| 1059 | 1143 | fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { |
| 1060 | 1144 | std::thread::spawn(move || { |
| 1061 | 1145 | let Ok(connection) = zbus::blocking::Connection::session() else { |
| ... | ... | @@ -1073,16 +1157,25 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { |
| 1073 | 1157 | return; |
| 1074 | 1158 | }; |
| 1075 | 1159 | 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)>() |
| 1079 | 1164 | else { |
| 1080 | 1165 | continue; |
| 1081 | 1166 | }; |
| 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") |
| 1085 | 1169 | { |
| 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() { |
| 1086 | 1179 | return; |
| 1087 | 1180 | } |
| 1088 | 1181 | } |
| ... | ... | @@ -1115,11 +1208,17 @@ fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> { |
| 1115 | 1208 | .ok()?; |
| 1116 | 1209 | let key = (namespace, key); |
| 1117 | 1210 | // 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. |
| 1220 | fn innermost(value: OwnedValue) -> Option<OwnedValue> { |
| 1221 | let mut value = Value::from(value); |
| 1123 | 1222 | while let Value::Value(inner) = value { |
| 1124 | 1223 | value = *inner; |
| 1125 | 1224 | } |