| ... | @@ -14,7 +14,7 @@ use std::{ | ... | @@ -14,7 +14,7 @@ use std::{ |
| 14 | path::PathBuf, | 14 | path::PathBuf, |
| 15 | process::Command, | 15 | process::Command, |
| 16 | sync::{ | 16 | sync::{ |
| 17 | OnceLock, | 17 | Mutex, OnceLock, |
| 18 | atomic::{AtomicBool, AtomicU32, Ordering}, | 18 | atomic::{AtomicBool, AtomicU32, Ordering}, |
| 19 | }, | 19 | }, |
| 20 | time::Duration, | 20 | time::Duration, |
| ... | @@ -30,10 +30,9 @@ use winit::{ | ... | @@ -30,10 +30,9 @@ use winit::{ |
| 30 | }; | 30 | }; |
| 31 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; | 31 | use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; |
| 32 | | 32 | |
| 33 | /// The title bar's leading margin; the window controls sit at its trailing end. | 33 | /// The title bar's margins at its leading and trailing ends, libadwaita's header bar's. |
| 34 | pub const LEADING: f32 = 8.0; | 34 | pub const LEADING: f32 = 7.0; |
| 35 | /// The margin past the window controls. | 35 | pub const TRAILING: f32 = 7.0; |
| 36 | pub const TRAILING: f32 = 8.0; | | |
| 37 | /// How far inside the window's edges a press resizes it, and how far along them a corner | 36 | /// How far inside the window's edges a press resizes it, and how far along them a corner |
| 38 | /// reaches, in logical pixels. | 37 | /// reaches, in logical pixels. |
| 39 | const EDGE: f32 = 5.0; | 38 | const EDGE: f32 = 5.0; |
| ... | @@ -41,6 +40,14 @@ const CORNER: f32 = 16.0; | ... | @@ -41,6 +40,14 @@ const CORNER: f32 = 16.0; |
| 41 | const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]; | 40 | const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]; |
| 42 | const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; | 41 | const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; |
| 43 | const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; | 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 | /// The event loop's, for signals and Snowbound's own dialogs. | 52 | /// The event loop's, for signals and Snowbound's own dialogs. |
| 46 | pub(crate) static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | 53 | pub(crate) static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); |
| ... | @@ -577,76 +584,152 @@ fn kde_color(value: &str) -> Option<[u8; 3]> { | ... | @@ -577,76 +584,152 @@ fn kde_color(value: &str) -> Option<[u8; 3]> { |
| 577 | Some([channels.next()??, channels.next()??, channels.next()??]) | 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 | fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { | 588 | fn adwaita_titlebar(appearance: Theme) -> [[u8; 3]; 2] { |
| 582 | let theme = match appearance { | 589 | match (appearance, adwaita_minor() >= 7) { |
| 583 | Theme::Dark => sctk_adwaita::theme::ColorTheme::dark(), | 590 | (Theme::Light, true) => [[0xff; 3], [0xfa, 0xfa, 0xfb]], |
| 584 | Theme::Light => sctk_adwaita::theme::ColorTheme::light(), | 591 | (Theme::Light, false) => [[0xff; 3], [0xfa; 3]], |
| 585 | }; | 592 | (Theme::Dark, true) => [[0x2e, 0x2e, 0x32], [0x22, 0x22, 0x26]], |
| 586 | [theme.active, theme.inactive].map(|colors| { | 593 | (Theme::Dark, false) => [[0x30; 3], [0x24; 3]], |
| 587 | let color = colors.headerbar.to_color_u8(); | 594 | } |
| 588 | [color.red(), color.green(), color.blue()] | 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 | 624 | /// The window's buttons at the title bar's start, or its `end`, as the desktop's button |
| 593 | /// `button-layout` lists them, which is close alone by default; elsewhere minimize, | 625 | /// layout places them. On GNOME they are libadwaita's: discs in the header bar's ink, dimmed |
| 594 | /// maximize and close. | 626 | /// with it while the window is in the background. Elsewhere, where the compositor offered no |
| 595 | pub fn window_controls(ui: &mut Ui, window: &Window) { | 627 | /// decorations of its own, they are the toolbar's own buttons. |
| 596 | static CONTROLS: OnceLock<Vec<&'static str>> = OnceLock::new(); | 628 | pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) { |
| 597 | let controls = CONTROLS.get_or_init(|| { | 629 | let layout = BUTTON_LAYOUT |
| 598 | let layout = portal_setting("org.gnome.desktop.wm.preferences", "button-layout") | 630 | .lock() |
| 599 | .and_then(|value| String::try_from(value).ok()); | 631 | .unwrap() |
| 600 | match layout { | 632 | .get_or_insert_with(|| { |
| 601 | Some(layout) => layout | 633 | portal_setting("org.gnome.desktop.wm.preferences", "button-layout") |
| 602 | .split([':', ',']) | 634 | .and_then(|value| String::try_from(value).ok()) |
| 603 | .filter_map(|name| { | 635 | .unwrap_or_else(|| { |
| 604 | ["minimize", "maximize", "close"] | 636 | if desktop() == Desktop::Gnome { |
| 605 | .into_iter() | 637 | "appmenu:close" |
| 606 | .find(|known| *known == name) | 638 | } else { |
| | 639 | ":minimize,maximize,close" |
| | 640 | } |
| | 641 | .into() |
| 607 | }) | 642 | }) |
| 608 | .collect(), | 643 | }) |
| 609 | None if desktop() == Desktop::Gnome => vec!["close"], | 644 | .clone(); |
| 610 | None => vec!["minimize", "maximize", "close"], | 645 | let (start, finish) = layout.split_once(':').unwrap_or((&layout, "")); |
| 611 | } | 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 | for control in controls { | 692 | for control in controls { |
| 616 | let icon = match *control { | 693 | let (name, icons) = match control { |
| 617 | "minimize" => MINIMIZE, | 694 | "minimize" => ("Minimize", [MINIMIZE, ADWAITA_MINIMIZE]), |
| 618 | "maximize" if window.is_maximized() => RESTORE, | 695 | "maximize" if window.is_maximized() => ("Restore", [RESTORE, ADWAITA_RESTORE]), |
| 619 | "maximize" => MAXIMIZE, | 696 | "maximize" => ("Maximize", [MAXIMIZE, ADWAITA_MAXIMIZE]), |
| 620 | _ => crate::art::CLOSE, | 697 | _ => ("Close", [crate::art::CLOSE, ADWAITA_CLOSE]), |
| 621 | }; | 698 | }; |
| 622 | // Round, on a faint disc, as libadwaita's are. | 699 | let clicked = if let Some([rest, hover, pressed, glyph]) = colors { |
| 623 | let clicked = ui | 700 | // The 16 px glyph's disc pads it by 2 px, or by 4 px from 1.8. |
| 624 | .leaf( | 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 | control, | 709 | control, |
| 626 | Spec { | 710 | Spec { |
| 627 | flags: Flags::CLICKABLE, | 711 | flags: Flags::CLICKABLE, |
| 628 | size: [px(ui::shell::TOOL); 2], | 712 | size: [px(button); 2], |
| 629 | icon: Some(icon), | 713 | inset: [(button - disc) / 2.0; 4], |
| 630 | color: Some(text), | 714 | icon: Some(icons[1]), |
| 631 | fill: Some(disc(0.1)), | 715 | color: Some(glyph), |
| 632 | hover_fill: Some(disc(0.18)), | 716 | fill: Some(ui::mix(rest, pressed, press)), |
| 633 | radius: ui::shell::TOOL / 2.0, | 717 | hover_fill: Some(ui::mix(hover, pressed, press)), |
| | 718 | radius: disc / 2.0, |
| 634 | center: true, | 719 | center: true, |
| 635 | role: Some(accesskit::Role::Button), | 720 | role: Some(accesskit::Role::Button), |
| 636 | ..Spec::default() | 721 | ..Spec::default() |
| 637 | }, | 722 | }, |
| 638 | ) | 723 | ); |
| 639 | .clicked; | 724 | signal.clicked |
| 640 | let name = match *control { | 725 | } else { |
| 641 | "minimize" => "Minimize", | 726 | let text = ui.theme.text; |
| 642 | "maximize" if window.is_maximized() => "Restore", | 727 | ui::shell::tool_button(ui, control, icons[0], text, None).clicked |
| 643 | "maximize" => "Maximize", | | |
| 644 | _ => "Close", | | |
| 645 | }; | 728 | }; |
| 646 | if let Some(node) = ui.access(ui.id(control)) { | 729 | if let Some(node) = ui.access(ui.id(control)) { |
| 647 | node.set_label(name); | 730 | node.set_label(name); |
| 648 | } | 731 | } |
| 649 | match *control { | 732 | match control { |
| 650 | _ if !clicked => {} | 733 | _ if !clicked => {} |
| 651 | "minimize" => window.set_minimized(true), | 734 | "minimize" => window.set_minimized(true), |
| 652 | "maximize" => zoom(window), | 735 | "maximize" => zoom(window), |
| ... | @@ -657,6 +740,7 @@ pub fn window_controls(ui: &mut Ui, window: &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 | /// The edge or corner a press at `pointer`, in logical pixels, resizes the window from, when | 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,7 +1139,7 @@ pub fn appearance(_: &Window) -> Theme { |
| 1055 | } | 1139 | } |
| 1056 | | 1140 | |
| 1057 | /// Asks for the desktop's colours again whenever the settings portal reports a change to the | 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 | fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { | 1143 | fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { |
| 1060 | std::thread::spawn(move || { | 1144 | std::thread::spawn(move || { |
| 1061 | let Ok(connection) = zbus::blocking::Connection::session() else { | 1145 | let Ok(connection) = zbus::blocking::Connection::session() else { |
| ... | @@ -1073,16 +1157,25 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { | ... | @@ -1073,16 +1157,25 @@ fn watch_settings(proxy: EventLoopProxy<crate::UserEvent>) { |
| 1073 | return; | 1157 | return; |
| 1074 | }; | 1158 | }; |
| 1075 | for change in changes { | 1159 | for change in changes { |
| 1076 | let Ok((namespace, _, _)) = change | 1160 | let Ok((namespace, key, value)) = |
| 1077 | .body() | 1161 | change |
| 1078 | .deserialize::<(String, String, zbus::zvariant::OwnedValue)>() | 1162 | .body() |
| | 1163 | .deserialize::<(String, String, zbus::zvariant::OwnedValue)>() |
| 1079 | else { | 1164 | else { |
| 1080 | continue; | 1165 | continue; |
| 1081 | }; | 1166 | }; |
| 1082 | if (namespace == "org.freedesktop.appearance" | 1167 | let event = if namespace == "org.freedesktop.appearance" |
| 1083 | || namespace.starts_with("org.kde.kdeglobals")) | 1168 | || namespace.starts_with("org.kde.kdeglobals") |
| 1084 | && proxy.send_event(crate::UserEvent::Appearance).is_err() | | |
| 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 | return; | 1179 | return; |
| 1087 | } | 1180 | } |
| 1088 | } | 1181 | } |
| ... | @@ -1115,11 +1208,17 @@ fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> { | ... | @@ -1115,11 +1208,17 @@ fn portal_setting(namespace: &str, key: &str) -> Option<OwnedValue> { |
| 1115 | .ok()?; | 1208 | .ok()?; |
| 1116 | let key = (namespace, key); | 1209 | let key = (namespace, key); |
| 1117 | // Portals before ReadOne answer Read, with the value in a second variant. | 1210 | // Portals before ReadOne answer Read, with the value in a second variant. |
| 1118 | let reply: OwnedValue = proxy | 1211 | innermost( |
| 1119 | .call("ReadOne", &key) | 1212 | proxy |
| 1120 | .or_else(|_| proxy.call("Read", &key)) | 1213 | .call("ReadOne", &key) |
| 1121 | .ok()?; | 1214 | .or_else(|_| proxy.call("Read", &key)) |
| 1122 | let mut value = Value::from(reply); | 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 | while let Value::Value(inner) = value { | 1222 | while let Value::Value(inner) = value { |
| 1124 | value = *inner; | 1223 | value = *inner; |
| 1125 | } | 1224 | } |