mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439)
* feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). * feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+1200
-41
No files matched your search
Generated
+1
@@ -4220,6 +4220,7 @@ dependencies = [
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"resvg",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
|
||||
@@ -68,6 +68,9 @@ photocraft-cms = { workspace = true }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
|
||||
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
|
||||
resvg = { version = "=0.45.1", default-features = false }
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
|
||||
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
|
||||
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
|
||||
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
|
||||
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
|
||||
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
|
||||
//!
|
||||
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
|
||||
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Desktop {
|
||||
Gnome,
|
||||
Kde,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
|
||||
/// the like count as GNOME.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
|
||||
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
|
||||
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
return if v.contains("gnome") {
|
||||
Desktop::Gnome
|
||||
} else if v.contains("kde") || v.contains("plasma") {
|
||||
Desktop::Kde
|
||||
} else {
|
||||
Desktop::Other
|
||||
};
|
||||
}
|
||||
if var("KDE_FULL_SESSION").is_some() {
|
||||
Desktop::Kde
|
||||
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
|
||||
Desktop::Gnome
|
||||
} else {
|
||||
Desktop::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
|
||||
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
|
||||
match desktop {
|
||||
Desktop::Gnome => CaptionFamily::Adwaita,
|
||||
Desktop::Kde => CaptionFamily::Breeze,
|
||||
Desktop::Other => {
|
||||
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
|
||||
CaptionFamily::Adwaita
|
||||
} else if theme.starts_with("breeze") {
|
||||
CaptionFamily::Breeze
|
||||
} else {
|
||||
CaptionFamily::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
|
||||
let mut inside = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name == section;
|
||||
} else if inside
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
|
||||
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
|
||||
/// defaults: the window menu on the left, minimize, maximize and close on the right.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn kwin_layout(kwinrc: &str) -> String {
|
||||
let side = |key: &str, default: &str| {
|
||||
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
|
||||
letters
|
||||
.chars()
|
||||
.filter_map(|c| match c {
|
||||
'I' => Some("minimize"),
|
||||
'A' => Some("maximize"),
|
||||
'X' => Some("close"),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
|
||||
}
|
||||
|
||||
/// What the style is worked out from.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Default)]
|
||||
struct Found {
|
||||
gtk_theme: Option<String>,
|
||||
/// A GTK decoration layout.
|
||||
layout: Option<String>,
|
||||
icon_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
|
||||
/// the desktop's setting, or its default theme.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
match var("PHOTOCRAFT_CAPTION_ICONS") {
|
||||
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
|
||||
Some(name) => Some(name.trim().to_string()),
|
||||
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The style from what was found; `icons` loads a theme's glyphs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
|
||||
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
|
||||
// GNOME's own default shows Close only; GTK's shows all three.
|
||||
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
|
||||
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
|
||||
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
|
||||
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
|
||||
style
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_text(path: &std::path::Path) -> Option<String> {
|
||||
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
|
||||
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
|
||||
let config = var("XDG_CONFIG_HOME")
|
||||
.filter(|d| std::path::Path::new(d).is_absolute())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
|
||||
let Some(config) = config else { return Found::default() };
|
||||
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
|
||||
let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name");
|
||||
if desktop == Desktop::Kde {
|
||||
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
|
||||
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
|
||||
Found { gtk_theme, layout, icon_theme }
|
||||
} else {
|
||||
let layout = ini_value(>k, "Settings", "gtk-decoration-layout");
|
||||
Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
|
||||
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn load_icons(theme: &str) -> Option<CaptionIcons> {
|
||||
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
|
||||
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
|
||||
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
|
||||
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
|
||||
if files.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let render = move |icon: CaptionIcon, px: u32| {
|
||||
let i = match icon {
|
||||
CaptionIcon::Minimize => 0,
|
||||
CaptionIcon::Maximize => 1,
|
||||
CaptionIcon::Restore => 2,
|
||||
CaptionIcon::Close => 3,
|
||||
};
|
||||
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
|
||||
};
|
||||
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
|
||||
}
|
||||
|
||||
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl IconCache {
|
||||
fn get(&self, theme: &str) -> Option<CaptionIcons> {
|
||||
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((name, icons)) = last.as_ref()
|
||||
&& name == theme
|
||||
{
|
||||
return icons.clone();
|
||||
}
|
||||
let icons = load_icons(theme);
|
||||
*last = Some((theme.to_string(), icons.clone()));
|
||||
icons
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
|
||||
#[cfg(target_os = "linux")]
|
||||
const LAYOUT_KEY: &str = "button-layout";
|
||||
#[cfg(target_os = "linux")]
|
||||
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICONS_KEY: &str = "icon-theme";
|
||||
|
||||
/// A string setting from the portal, unwrapping the variants some portals nest it in.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
|
||||
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
|
||||
/// work and wakes the UI only when the style changes; the first answer usually lands before the
|
||||
/// first frame.
|
||||
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let desktop = desktop(&var);
|
||||
let mut found = from_files(desktop, &var);
|
||||
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
|
||||
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake));
|
||||
let cache = IconCache::default();
|
||||
let publish = move |found: &Found| {
|
||||
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
|
||||
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *cur != style {
|
||||
*cur = style;
|
||||
if let Some(ctx) = waker.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
|
||||
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
|
||||
let Some(proxy) = proxy else {
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
return;
|
||||
};
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let signals = proxy.receive_signal("SettingChanged").ok();
|
||||
let read = |namespace: &str, key: &str| {
|
||||
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
|
||||
value.and_then(portal_string)
|
||||
};
|
||||
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
|
||||
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
for message in signals.into_iter().flatten() {
|
||||
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
|
||||
match (namespace.as_str(), key.as_str()) {
|
||||
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
|
||||
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
|
||||
_ => continue,
|
||||
}
|
||||
publish(&found);
|
||||
}
|
||||
});
|
||||
// A fast desktop answers before the first frame; a slow one updates the bar later.
|
||||
if spawned.is_ok() {
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
|
||||
}
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
|
||||
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_comes_from_the_first_variable_set() {
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[])), Desktop::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_follows_the_desktop_then_the_gtk_theme() {
|
||||
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
|
||||
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kwin_button_letters_become_a_gtk_layout() {
|
||||
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
|
||||
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
|
||||
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
|
||||
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
|
||||
assert_eq!(ini_value(rc, "nope", "library"), None);
|
||||
// Garbage is just no buttons on that side, never a panic.
|
||||
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
|
||||
}
|
||||
|
||||
fn no_icons(_: &str) -> Option<CaptionIcons> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
|
||||
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
|
||||
// The style carries what the loader returns for that name.
|
||||
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
|
||||
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
|
||||
});
|
||||
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_and_overrides_resolve_to_a_style() {
|
||||
let none = env(&[]);
|
||||
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
|
||||
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
|
||||
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
|
||||
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
|
||||
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
|
||||
let other = resolve(Desktop::Other, &found, &none, &no_icons);
|
||||
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
|
||||
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
|
||||
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
|
||||
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
|
||||
// An unknown override name falls back to what was found.
|
||||
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_strings_unwrap_nested_variants() {
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_files_are_read_from_the_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
|
||||
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
|
||||
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
|
||||
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
|
||||
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
|
||||
let other = from_files(Desktop::Other, &var);
|
||||
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
|
||||
let kde = from_files(Desktop::Kde, &var);
|
||||
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
|
||||
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
|
||||
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
|
||||
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
|
||||
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
|
||||
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
|
||||
/// theme lists, and the bytes read from one file.
|
||||
const MAX_THEMES: usize = 32;
|
||||
const MAX_DIRS: usize = 4096;
|
||||
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
|
||||
const SCAN_DEPTH: usize = 3;
|
||||
const MAX_FILE: u64 = 1 << 20;
|
||||
/// The size the caption glyphs are drawn at, in the spec's units.
|
||||
const ICON_SIZE: i64 = 16;
|
||||
|
||||
/// A theme or icon name that stays inside its directory.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
|
||||
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
|
||||
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
|
||||
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let absolute = |p: &String| Path::new(p).is_absolute();
|
||||
let home = var("HOME").filter(absolute).map(PathBuf::from);
|
||||
let mut dirs = vec![];
|
||||
if let Some(home) = &home {
|
||||
dirs.push(home.join(".icons"));
|
||||
}
|
||||
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
|
||||
dirs.push(data.join("icons"));
|
||||
}
|
||||
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// One `[section]` of an `index.theme`, as (key, value) pairs.
|
||||
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
|
||||
let mut inside = false;
|
||||
let mut pairs = vec![];
|
||||
for line in index.lines().map(str::trim) {
|
||||
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
if inside {
|
||||
break;
|
||||
}
|
||||
inside = s == name;
|
||||
} else if inside && let Some((k, v)) = line.split_once('=') {
|
||||
pairs.push((k.trim(), v.trim()));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
|
||||
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
|
||||
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
|
||||
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
|
||||
let size = num("Size", 0);
|
||||
let scale = num("Scale", 1).max(1);
|
||||
let d = match get("Type").unwrap_or("Threshold") {
|
||||
"Fixed" => (size * scale - ICON_SIZE).abs(),
|
||||
"Scalable" => {
|
||||
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
|
||||
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
|
||||
}
|
||||
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
|
||||
};
|
||||
(d, scale)
|
||||
}
|
||||
|
||||
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
|
||||
/// path order: scalable and symbolic ones first, as icons there fit any size.
|
||||
fn subdirectories(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..SCAN_DEPTH {
|
||||
let mut next = vec![];
|
||||
for dir in &level {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten().take(MAX_DIRS) {
|
||||
let path = entry.path();
|
||||
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
|
||||
next.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.extend(next.iter().cloned());
|
||||
level = next;
|
||||
}
|
||||
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
|
||||
found.sort_by_key(|p| (vector(p), p.clone()));
|
||||
found
|
||||
}
|
||||
|
||||
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
|
||||
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
|
||||
struct Theme {
|
||||
roots: Vec<PathBuf>,
|
||||
listed: Vec<PathBuf>,
|
||||
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
|
||||
/// often add `symbolic/ui` without listing it.
|
||||
fn unlisted(&self) -> &[PathBuf] {
|
||||
self.unlisted.get_or_init(|| {
|
||||
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
|
||||
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme and the themes it inherits, in search order.
|
||||
pub struct IconTheme {
|
||||
themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
impl IconTheme {
|
||||
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
|
||||
/// `index.theme`.
|
||||
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
|
||||
let mut chain: Vec<String> = vec![name.to_string()];
|
||||
let mut themes = vec![];
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(theme) = chain.get(i).cloned() else {
|
||||
// Every chain ends in hicolor, whether or not the themes before it exist.
|
||||
if chain.iter().any(|t| t == "hicolor") {
|
||||
break;
|
||||
}
|
||||
chain.push("hicolor".into());
|
||||
continue;
|
||||
};
|
||||
i += 1;
|
||||
if !valid_name(&theme) {
|
||||
continue;
|
||||
}
|
||||
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
|
||||
// The first index wins; a theme can still be spread over several base directories.
|
||||
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
|
||||
let index = String::from_utf8_lossy(&index);
|
||||
let head = section(&index, "Icon Theme");
|
||||
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
|
||||
.chain(list(get("ScaledDirectories")))
|
||||
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
|
||||
.take(MAX_DIRS)
|
||||
.map(|d| (distance(§ion(&index, d)), d))
|
||||
.collect();
|
||||
subdirs.sort_by_key(|(key, _)| *key);
|
||||
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
|
||||
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
|
||||
for parent in list(get("Inherits")) {
|
||||
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
|
||||
chain.push(parent.to_string());
|
||||
}
|
||||
}
|
||||
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
|
||||
}
|
||||
(!themes.is_empty()).then_some(Self { themes })
|
||||
}
|
||||
|
||||
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
|
||||
/// ones, before its parent.
|
||||
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
|
||||
if !valid_name(icon) {
|
||||
return None;
|
||||
}
|
||||
let file = format!("{icon}.svg");
|
||||
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
|
||||
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
|
||||
/// unreadable SVG or an absurd size.
|
||||
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
if !(1..=256).contains(&px) {
|
||||
return None;
|
||||
}
|
||||
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
|
||||
// (`/dev/zero`, a huge picture) or nest an SVG.
|
||||
let opts = usvg::Options {
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
|
||||
..usvg::Options::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
let side = size.width().max(size.height());
|
||||
if !side.is_finite() || side <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let s = px as f32 / side;
|
||||
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
|
||||
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throwaway icon tree: `files` are (path under the base, contents).
|
||||
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for (path, text) in files {
|
||||
let p = base.join(path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
|
||||
|
||||
#[test]
|
||||
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
|
||||
let base = tree(
|
||||
"chain",
|
||||
&[
|
||||
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
|
||||
("Child/48/a-symbolic.svg", "big"),
|
||||
// Not in the index: still found, and before the parent's copy.
|
||||
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
|
||||
("Parent/ui/d-symbolic.svg", SQUARE),
|
||||
("Child/16/a-symbolic.svg", SQUARE),
|
||||
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
|
||||
("Parent/ui/b-symbolic.svg", SQUARE),
|
||||
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
|
||||
("hicolor/s/c-symbolic.svg", SQUARE),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
|
||||
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
|
||||
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
|
||||
assert_eq!(theme.find_svg("missing"), None);
|
||||
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
|
||||
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
|
||||
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
|
||||
#[test]
|
||||
fn cyclic_or_hostile_indexes_are_harmless() {
|
||||
let base = tree(
|
||||
"hostile",
|
||||
&[
|
||||
(
|
||||
"A/index.theme",
|
||||
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
|
||||
),
|
||||
("A/ok/x.svg", SQUARE),
|
||||
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
|
||||
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
|
||||
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_follows_the_spec() {
|
||||
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
|
||||
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
|
||||
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
|
||||
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_square_coverage() {
|
||||
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
|
||||
assert_eq!(mask.len(), 32 * 32);
|
||||
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
|
||||
assert_eq!(mask[0], 0, "outside it");
|
||||
assert!(rasterize_mask(b"not svg", 16).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
|
||||
}
|
||||
|
||||
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
|
||||
#[test]
|
||||
fn masks_load_no_images() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
|
||||
<image href="/dev/zero" width="16" height="16"/>
|
||||
<image xlink:href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>" width="16" height="16"/>
|
||||
</svg>"##;
|
||||
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
|
||||
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dirs_follow_xdg() {
|
||||
let var = |k: &str| match k {
|
||||
"HOME" => Some("/home/u".to_string()),
|
||||
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let dirs = base_dirs(&var);
|
||||
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
|
||||
assert_eq!(dirs, want);
|
||||
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,14 @@ mod appearance;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod caps_lock;
|
||||
mod caption_style;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod file_dialog_probe;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod icon_theme;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -492,6 +495,10 @@ fn main() -> eframe::Result {
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
// border offset the content and the pointer on some Windows systems (#2246).
|
||||
app.services.window_frame = window_frame::service(cc, custom_titlebar);
|
||||
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
|
||||
if custom_titlebar {
|
||||
app.services.caption_style = caption_style::service();
|
||||
}
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
|
||||
@@ -147,3 +147,6 @@ sync_github_name = true
|
||||
[people.megaDon101]
|
||||
display_name = "megadon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sssehnsuchttt]
|
||||
sync_github_name = true
|
||||
@@ -195,6 +195,7 @@ pub mod tiff_options_ui;
|
||||
mod tile_overlay;
|
||||
mod timeline_ui;
|
||||
mod titlebar;
|
||||
pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn};
|
||||
pub mod tone;
|
||||
mod tool_cursor;
|
||||
pub mod tool_feedback;
|
||||
@@ -379,6 +380,8 @@ pub type CapsLockFn = Box<dyn FnMut() -> bool>;
|
||||
/// Keeps an undecorated window's OS frame from offsetting its content and pointer (#2246).
|
||||
/// Called once per frame; `None` when the window is decorated or the platform needs no fixing.
|
||||
pub type WindowFrameFn = Box<dyn FnMut()>;
|
||||
/// The caption buttons' current style ([`Services::caption_style`]).
|
||||
pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
|
||||
|
||||
/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of
|
||||
/// I/O dependencies.
|
||||
@@ -455,6 +458,10 @@ pub struct Services {
|
||||
/// (Windows custom title bar, #2246). Called once per frame; `None` when the window is
|
||||
/// decorated or the platform needs no fixing.
|
||||
pub window_frame: Option<WindowFrameFn>,
|
||||
/// How the custom title bar's caption buttons look and where they sit (Linux: the desktop's
|
||||
/// style and button layout). Read every frame, with the context to wake when it changes;
|
||||
/// `None` keeps Windows' buttons at the right end.
|
||||
pub caption_style: Option<CaptionStyleFn>,
|
||||
/// The persistent brush preset store, loading in the background (desktop; see
|
||||
/// `photocraft_engine::preset_store`). Attached to the session once it arrives; without
|
||||
/// one, brush presets are session-only unless the shell attached a store before startup.
|
||||
|
||||
@@ -550,17 +550,25 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) {
|
||||
|
||||
/// The app's top bar: brand mark, menus, the document title and the workspace controls. With
|
||||
/// [`PhotocraftApp::custom_titlebar`] (Windows and Linux) it is also the window's title bar, as in
|
||||
/// Photoshop on Windows: the caption buttons take its right end (`titlebar`). With the system
|
||||
/// Photoshop on Windows: the caption buttons take its ends (`titlebar`). With the system
|
||||
/// title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and title, so the
|
||||
/// in-app bar shows menus and workspace controls only: no brand mark, no centred title.
|
||||
pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let custom = app.custom_titlebar;
|
||||
let system = system_title_bar(app);
|
||||
let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 };
|
||||
let right = if custom { 0 } else { 10 };
|
||||
// The caption buttons' room at each end (`titlebar`); a side with buttons has no margin.
|
||||
let (caption_left, caption_right) = crate::titlebar::reserved(app, ui.ctx());
|
||||
let left = if cfg!(target_os = "macos") && app.integrated_titlebar {
|
||||
78
|
||||
} else if caption_left > 0.0 {
|
||||
0
|
||||
} else {
|
||||
10
|
||||
};
|
||||
let right = if caption_right > 0.0 { 0 } else { 10 };
|
||||
let bar = egui::Panel::top("title_bar")
|
||||
.exact_size(if t.pro { 32.0 } else { 38.0 })
|
||||
.exact_size(crate::titlebar::bar_height(app, ui.ctx(), if t.pro { 32.0 } else { 38.0 }))
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
@@ -586,9 +594,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let rtl = crate::direction::is_rtl();
|
||||
let mut free = (full.left(), full.right());
|
||||
crate::direction::horizontal_centered(ui, |ui| {
|
||||
if rtl && custom {
|
||||
// The caption buttons keep the right end; the menus start beside them.
|
||||
ui.add_space(crate::titlebar::WIDTH + 4.0);
|
||||
// The menus start beside the caption buttons at their end, if it has any.
|
||||
let lead = if rtl { caption_right } else { caption_left };
|
||||
if lead > 0.0 {
|
||||
ui.add_space(lead + 4.0);
|
||||
}
|
||||
if !system {
|
||||
let side = if t.pro { 18.0 } else { 20.0 };
|
||||
@@ -606,9 +615,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// The menu bar takes the whole row, so the far-end group gets its own rect: from
|
||||
// the menus to the bar's end, or to the caption buttons.
|
||||
let group = if rtl {
|
||||
egui::Rect::from_min_max(egui::pos2(full.left(), full.top()), egui::pos2((menus_end - TITLE_GAP).max(full.left()), full.bottom()))
|
||||
let left_edge = if caption_left > 0.0 { full.left() + caption_left + 4.0 } else { full.left() };
|
||||
egui::Rect::from_min_max(egui::pos2(left_edge, full.top()), egui::pos2((menus_end - TITLE_GAP).max(left_edge), full.bottom()))
|
||||
} else {
|
||||
let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() };
|
||||
let right_edge = if caption_right > 0.0 { full.right() - caption_right - 4.0 } else { full.right() };
|
||||
egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom()))
|
||||
};
|
||||
let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing()));
|
||||
|
||||
+431
-31
@@ -1,35 +1,217 @@
|
||||
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
|
||||
//! ([`PhotocraftApp::custom_titlebar`]): the app's top bar (`panels::title_bar`) is the title bar,
|
||||
//! as in Photoshop on Windows. The free gap between its menus and controls drags the window and a
|
||||
//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons at
|
||||
//! the far right minimize, maximize/restore and close, and thin invisible zones along the window's
|
||||
//! edges resize it.
|
||||
//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons
|
||||
//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize
|
||||
//! it.
|
||||
//!
|
||||
//! The caption buttons follow the desktop ([`CaptionStyle`], from `Services::caption_style`):
|
||||
//! Windows' 46 pt buttons at the right end by default; on Linux libadwaita's round buttons (GNOME),
|
||||
//! Breeze's (KDE) or a plain round set elsewhere, with the set of buttons and their side taken
|
||||
//! from the desktop's button layout. The shapes are drawn here, not read from the icon theme.
|
||||
|
||||
use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// One caption button: as wide as the native Windows ones.
|
||||
/// One Windows caption button: as wide as the native ones.
|
||||
pub const BUTTON_WIDTH: f32 = 46.0;
|
||||
/// The three caption buttons together.
|
||||
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
|
||||
/// Resize zone thickness along an edge, and the side of a corner zone. 5 pt was hard to find
|
||||
/// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt.
|
||||
const EDGE: f32 = 8.0;
|
||||
const CORNER: f32 = 12.0;
|
||||
/// Breeze's Close button on hover and press (KDE's Breeze decoration colours).
|
||||
const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2);
|
||||
const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Caption {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Caption {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Left to right.
|
||||
/// Windows' order, left to right.
|
||||
const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
|
||||
|
||||
/// Which desktop's caption buttons to look like.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum CaptionFamily {
|
||||
/// Windows 10/11: full-height buttons 46 pt wide; Close turns red on hover.
|
||||
#[default]
|
||||
Windows,
|
||||
/// GNOME (libadwaita): 24 pt round buttons on a faint disc that darkens on hover and press.
|
||||
Adwaita,
|
||||
/// KDE (Breeze decoration): smaller round buttons, bare at rest, inverted on hover.
|
||||
Breeze,
|
||||
/// Other Linux desktops: libadwaita's buttons without the disc at rest.
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl CaptionFamily {
|
||||
/// `windows`, `adwaita`, `breeze` or `generic`, in any case.
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"windows" => Some(Self::Windows),
|
||||
"adwaita" => Some(Self::Adwaita),
|
||||
"breeze" => Some(Self::Breeze),
|
||||
"generic" => Some(Self::Generic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The button side, gap on each side of it, and gap between the outermost button and the
|
||||
/// window's edge. libadwaita: 24 pt buttons 13 pt apart, 10 pt from the edge (the gap is split
|
||||
/// between neighbours, so the hit areas touch). Breeze sizes buttons by the font (about 1.3 em,
|
||||
/// 18 pt at KDE's default 10 pt font), half a button apart, 8 pt from the edge.
|
||||
fn metrics(self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
Self::Windows => (BUTTON_WIDTH, 0.0, 0.0),
|
||||
Self::Adwaita | Self::Generic => (24.0, 6.5, 10.0),
|
||||
Self::Breeze => (18.0, 4.5, 8.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption buttons' look and placement.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct CaptionStyle {
|
||||
pub family: CaptionFamily,
|
||||
/// The buttons at the bar's left end, left to right.
|
||||
pub left: Vec<Caption>,
|
||||
/// The buttons at the bar's right end, left to right.
|
||||
pub right: Vec<Caption>,
|
||||
/// The round buttons' glyphs from the desktop's icon theme; `None`, or an icon it lacks, draws
|
||||
/// the family's own.
|
||||
pub icons: Option<CaptionIcons>,
|
||||
}
|
||||
|
||||
/// A caption button glyph, as the icon themes name them (`window-restore-symbolic`, …).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum CaptionIcon {
|
||||
Minimize,
|
||||
Maximize,
|
||||
Restore,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Renders an icon as a square coverage mask (`side` × `side` bytes, row by row), or `None`.
|
||||
pub type RenderIconFn = dyn Fn(CaptionIcon, u32) -> Option<Vec<u8>> + Send + Sync;
|
||||
|
||||
/// Glyphs from an icon theme (Linux: `window-*-symbolic`), drawn in the button's ink colour as
|
||||
/// symbolic icons are.
|
||||
#[derive(Clone)]
|
||||
pub struct CaptionIcons {
|
||||
/// The theme's name; the cached textures follow it.
|
||||
pub theme: String,
|
||||
pub render: std::sync::Arc<RenderIconFn>,
|
||||
}
|
||||
|
||||
impl PartialEq for CaptionIcons {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.theme == other.theme && std::sync::Arc::ptr_eq(&self.render, &other.render)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for CaptionIcons {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CaptionIcons").field("theme", &self.theme).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CaptionStyle {
|
||||
fn default() -> Self {
|
||||
Self { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptionStyle {
|
||||
/// `family` with the buttons a GTK decoration layout names (`gtk-decoration-layout`, GNOME's
|
||||
/// `button-layout`, e.g. `appmenu:minimize,maximize,close`): those before the colon go left,
|
||||
/// those after it right; without a colon all go left, as in GTK. Names other than `minimize`,
|
||||
/// `maximize` and `close` (`appmenu`, `icon`, `spacer`, …) are skipped, and a button named
|
||||
/// twice is placed once.
|
||||
pub fn from_gtk_layout(family: CaptionFamily, layout: &str) -> Self {
|
||||
let (left, right) = layout.split_once(':').unwrap_or((layout, ""));
|
||||
let mut seen = vec![];
|
||||
let mut side = |names: &str| {
|
||||
let mut v = vec![];
|
||||
for name in names.split(',') {
|
||||
let c = match name.trim() {
|
||||
"minimize" => Caption::Minimize,
|
||||
"maximize" => Caption::Maximize,
|
||||
"close" => Caption::Close,
|
||||
_ => continue,
|
||||
};
|
||||
if !seen.contains(&c) {
|
||||
seen.push(c);
|
||||
v.push(c);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let left = side(left);
|
||||
let right = side(right);
|
||||
Self { family, left, right, icons: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption style the platform reports, or Windows' when it reports none.
|
||||
fn style(app: &PhotocraftApp, ctx: &egui::Context) -> CaptionStyle {
|
||||
app.services.caption_style.as_ref().map(|read| read(ctx)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The title bar's height: `base`, or with GNOME's buttons at least libadwaita's header bar
|
||||
/// (46 pt, plus the bar's 1 pt separator), so the bar is as tall as its neighbours' title bars.
|
||||
pub fn bar_height(app: &PhotocraftApp, ctx: &egui::Context, base: f32) -> f32 {
|
||||
if app.custom_titlebar && style(app, ctx).family == CaptionFamily::Adwaita { base.max(47.0) } else { base }
|
||||
}
|
||||
|
||||
/// The width the caption buttons take at the bar's (left, right) end: zero for a side without
|
||||
/// buttons, and for both without the custom title bar.
|
||||
pub fn reserved(app: &PhotocraftApp, ctx: &egui::Context) -> (f32, f32) {
|
||||
if !app.custom_titlebar {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let s = style(app, ctx);
|
||||
let width = |side: &[Caption]| slots(s.family, side, 0.0, true).last().map_or(0.0, |(_, r, _)| r.1);
|
||||
(width(&s.left), width(&s.right))
|
||||
}
|
||||
|
||||
/// Each button's horizontal extent (left, right) and face centre on a side starting at `from`, in
|
||||
/// order. `outer_left` puts the edge gap on the first button's left (the left end) instead of the
|
||||
/// last button's right.
|
||||
fn slots(family: CaptionFamily, side: &[Caption], from: f32, outer_left: bool) -> Vec<(Caption, (f32, f32), f32)> {
|
||||
let (size, gap, end) = family.metrics();
|
||||
let mut x = from;
|
||||
let n = side.len();
|
||||
side.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &c)| {
|
||||
let before = if outer_left && i == 0 { end } else { gap };
|
||||
let after = if !outer_left && i + 1 == n { end } else { gap };
|
||||
let slot = (x, x + before + size + after);
|
||||
let centre = x + before + size / 2.0;
|
||||
x = slot.1;
|
||||
(c, slot, centre)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every caption button's hit rect (full bar height, flush with the window's edge) and face
|
||||
/// centre, for a bar `bar` between the window's `left` and `right` edges.
|
||||
fn layout(s: &CaptionStyle, bar: Rect, left: f32, right: f32) -> Vec<(Caption, Rect, Pos2)> {
|
||||
let rect = |(c, (l, r), centre): (Caption, (f32, f32), f32)| {
|
||||
(c, Rect::from_min_max(pos2(l, bar.top()), pos2(r, bar.bottom() - 1.0)), pos2(centre, bar.center().y))
|
||||
};
|
||||
let lefts = slots(s.family, &s.left, left, true);
|
||||
let width = slots(s.family, &s.right, 0.0, false).last().map_or(0.0, |(_, r, _)| r.1);
|
||||
let rights = slots(s.family, &s.right, right - width, false);
|
||||
lefts.into_iter().chain(rights).map(rect).collect()
|
||||
}
|
||||
|
||||
/// Caption button ids (stable, so tests and agents can find them).
|
||||
fn caption_id(c: Caption) -> Id {
|
||||
Id::new(("titlebar-caption", c as u8))
|
||||
@@ -43,29 +225,38 @@ fn toggle_maximize(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
|
||||
}
|
||||
|
||||
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title bar's outer rect),
|
||||
/// full bar height, flush with the window's edge (even if the bar's contents run wider) so a
|
||||
/// maximized window's corner hits Close. Close runs File › Exit, which asks about unsaved
|
||||
/// documents first.
|
||||
/// The caption buttons at the ends of `bar` (the title bar's outer rect), full bar height, flush
|
||||
/// with the window's edges (even if the bar's contents run wider) so a maximized window's corner
|
||||
/// hits the outermost one. Close runs File › Exit, which asks about unsaved documents first. On
|
||||
/// Linux the buttons drag the window too, as GTK's and KDE's do.
|
||||
pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let s = style(app, ui.ctx());
|
||||
let max = maximized(ui.ctx());
|
||||
let right = bar.right().min(ui.ctx().content_rect().right());
|
||||
let content = ui.ctx().content_rect();
|
||||
let windows = s.family == CaptionFamily::Windows;
|
||||
let mut clicked = None;
|
||||
for (i, c) in CAPTIONS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(right - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0));
|
||||
let resp = ui.interact(r, caption_id(c), Sense::click());
|
||||
let close = c == Caption::Close;
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) if close => t.caption_close.gamma_multiply(0.8),
|
||||
(_, true) if close => t.caption_close,
|
||||
(true, _) => t.pressed,
|
||||
(_, true) => t.hover,
|
||||
_ => egui::Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };
|
||||
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) {
|
||||
let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() });
|
||||
if resp.drag_started_by(PointerButton::Primary) {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
let pressed = resp.is_pointer_button_down_on() && resp.hovered();
|
||||
if windows {
|
||||
let close = c == Caption::Close;
|
||||
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
|
||||
(true, _) if close => t.caption_close.gamma_multiply(0.8),
|
||||
(_, true) if close => t.caption_close,
|
||||
(true, _) => t.pressed,
|
||||
(_, true) => t.hover,
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };
|
||||
paint_windows_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
|
||||
} else {
|
||||
paint_round(ui, &t, &s, c, max, centre, resp.hovered(), pressed);
|
||||
}
|
||||
let tip = match c {
|
||||
Caption::Minimize => tl!("Minimize"),
|
||||
Caption::Maximize if max => tl!("Restore"),
|
||||
@@ -88,8 +279,8 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 pt line glyphs of the caption buttons.
|
||||
fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
/// The 10 pt line glyphs of the Windows caption buttons.
|
||||
fn paint_windows_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) {
|
||||
let p = ui.painter();
|
||||
let g = Rect::from_center_size(at, vec2(10.0, 10.0));
|
||||
match c {
|
||||
@@ -112,6 +303,119 @@ fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke)
|
||||
}
|
||||
}
|
||||
|
||||
/// A round Linux caption button centred at `centre`: its disc, then its glyph on a 16 pt grid.
|
||||
/// The disc is the icon colour at a fraction of its opacity (libadwaita's `alpha(currentColor)`),
|
||||
/// so it suits light and dark themes alike; Breeze instead fills it with the icon colour on hover
|
||||
/// and draws the glyph in the bar's colour.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_round(ui: &Ui, t: &Tokens, s: &CaptionStyle, c: Caption, maximized: bool, centre: Pos2, hovered: bool, pressed: bool) {
|
||||
let family = s.family;
|
||||
let (size, _, _) = family.metrics();
|
||||
// Whole pixels, so the glyphs' edges stay sharp.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let centre = pos2((centre.x * ppp).round() / ppp, (centre.y * ppp).round() / ppp);
|
||||
let close = c == Caption::Close;
|
||||
let (fill, ink) = if family == CaptionFamily::Breeze {
|
||||
match (hovered, pressed) {
|
||||
(_, true) if close => (BREEZE_CLOSE_PRESSED, t.chrome),
|
||||
(true, _) if close => (BREEZE_CLOSE_HOVER, t.chrome),
|
||||
(_, true) => (t.icon.gamma_multiply(0.5), t.chrome),
|
||||
(true, _) => (t.icon, t.chrome),
|
||||
_ => (Color32::TRANSPARENT, t.icon),
|
||||
}
|
||||
} else {
|
||||
let rest = if family == CaptionFamily::Adwaita { 0.10 } else { 0.0 };
|
||||
let target = if pressed {
|
||||
0.30
|
||||
} else if hovered {
|
||||
0.15
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
// libadwaita eases the disc over 0.2 s.
|
||||
let a = ui.ctx().animate_value_with_time(caption_id(c).with("disc"), target, 0.2);
|
||||
(t.icon.gamma_multiply(a), t.icon)
|
||||
};
|
||||
let p = ui.painter();
|
||||
if fill.a() > 0 {
|
||||
p.circle_filled(centre, size / 2.0, fill);
|
||||
}
|
||||
// The glyph grid: 16 pt, or 2 pt less than a smaller button.
|
||||
let k = (size - 2.0).min(16.0) / 16.0;
|
||||
let icon = match c {
|
||||
Caption::Minimize => CaptionIcon::Minimize,
|
||||
Caption::Maximize if maximized => CaptionIcon::Restore,
|
||||
Caption::Maximize => CaptionIcon::Maximize,
|
||||
Caption::Close => CaptionIcon::Close,
|
||||
};
|
||||
if let Some(icons) = &s.icons
|
||||
&& let Some(tex) = theme_icon(ui.ctx(), icons, icon, (16.0 * k * ppp).round() as u32)
|
||||
{
|
||||
let side = tex.size_vec2() / ppp;
|
||||
let min = centre - side / 2.0;
|
||||
let rect = Rect::from_min_size(pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp), side);
|
||||
p.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), ink);
|
||||
return;
|
||||
}
|
||||
let at = |x: f32, y: f32| centre + vec2(x - 8.0, y - 8.0) * k;
|
||||
if family == CaptionFamily::Breeze {
|
||||
// Breeze: 1 pt chevrons (down to minimize, up to maximize), a diamond to restore, an X.
|
||||
let s = Stroke::new(1.0, ink);
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.line(vec![at(2.5, 5.5), at(8.0, 11.0), at(13.5, 5.5)], s);
|
||||
}
|
||||
Caption::Maximize if maximized => {
|
||||
p.add(egui::Shape::closed_line(vec![at(8.0, 3.0), at(13.0, 8.0), at(8.0, 13.0), at(3.0, 8.0)], s));
|
||||
}
|
||||
Caption::Maximize => {
|
||||
p.line(vec![at(2.5, 10.5), at(8.0, 5.0), at(13.5, 10.5)], s);
|
||||
}
|
||||
Caption::Close => {
|
||||
let s = Stroke::new(1.2, ink);
|
||||
p.line_segment([at(3.6, 3.6), at(12.4, 12.4)], s);
|
||||
p.line_segment([at(12.4, 3.6), at(3.6, 12.4)], s);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// libadwaita: a 2 pt bar low down, a 2 pt square outline (smaller to restore), an X.
|
||||
let frame = |a: f32, b: f32| Rect::from_min_max(at(a, a), at(b, b));
|
||||
match c {
|
||||
Caption::Minimize => {
|
||||
p.rect_filled(Rect::from_min_max(at(4.0, 10.0), at(12.0, 12.0)), 0.0, ink);
|
||||
}
|
||||
Caption::Maximize => {
|
||||
let square = if maximized { frame(5.0, 11.0) } else { frame(4.0, 12.0) };
|
||||
p.rect_stroke(square, 0.0, Stroke::new(2.0 * k, ink), egui::StrokeKind::Inside);
|
||||
}
|
||||
Caption::Close => {
|
||||
let s = Stroke::new(1.5 * k, ink);
|
||||
p.line_segment([at(4.5, 4.5), at(11.5, 11.5)], s);
|
||||
p.line_segment([at(11.5, 4.5), at(4.5, 11.5)], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `icon` from the theme at `px` device pixels, uploaded once per theme and size; `None` (also
|
||||
/// remembered) when the theme lacks it or it doesn't render.
|
||||
fn theme_icon(ctx: &egui::Context, icons: &CaptionIcons, icon: CaptionIcon, px: u32) -> Option<egui::TextureHandle> {
|
||||
let id = Id::new(("caption-icon", &icons.theme, icon, px));
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
||||
return cached;
|
||||
}
|
||||
let side = usize::try_from(px).ok().filter(|&s| (1..=256).contains(&s));
|
||||
let tex = side.and_then(|side| {
|
||||
let mask = (icons.render)(icon, px).filter(|m| m.len() == side * side)?;
|
||||
// White at the mask's coverage, tinted with the ink when drawn.
|
||||
let pixels = mask.into_iter().map(Color32::from_white_alpha).collect();
|
||||
let image = egui::ColorImage { size: [side, side], source_size: vec2(px as f32, px as f32), pixels };
|
||||
Some(ctx.load_texture(format!("caption-{icon:?}-{px}"), image, egui::TextureOptions::LINEAR))
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
|
||||
tex
|
||||
}
|
||||
|
||||
/// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners
|
||||
/// come last so they win where they overlap the edges. Edges use the one-sided cursors
|
||||
/// (`w-resize`, …), as GTK and KDE window borders do: some cursor themes ship no `ew-resize` /
|
||||
@@ -465,4 +769,100 @@ mod tests {
|
||||
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
|
||||
assert!(w.click(pos2(999.5, 0.5)).iter().any(|c| matches!(c, ViewportCommand::Close)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gtk_layouts_pick_the_buttons_and_their_sides() {
|
||||
use Caption::*;
|
||||
let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:minimize,maximize,close");
|
||||
assert_eq!((s.left, s.right), (vec![], vec![Minimize, Maximize, Close]));
|
||||
// Unknown names are skipped, spaces trimmed, a repeat placed once, a second colon is a name.
|
||||
let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "close,close , minimize:icon,maximize:close");
|
||||
assert_eq!((s.left, s.right), (vec![Close, Minimize], vec![]));
|
||||
let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "minimize,close");
|
||||
assert_eq!((s.left, s.right), (vec![Minimize, Close], vec![]), "no colon: GTK puts them all at the start");
|
||||
let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "");
|
||||
assert!(s.left.is_empty() && s.right.is_empty());
|
||||
assert_eq!(CaptionFamily::parse(" Breeze "), Some(CaptionFamily::Breeze));
|
||||
assert_eq!(CaptionFamily::parse("aqua"), None);
|
||||
assert_eq!(CaptionStyle::default(), CaptionStyle { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_buttons_sit_where_the_layout_says_and_drag_the_window() {
|
||||
use Caption::*;
|
||||
for (family, size, gap, end) in [(CaptionFamily::Adwaita, 24.0, 6.5, 10.0), (CaptionFamily::Breeze, 18.0, 4.5, 8.0)] {
|
||||
let width = 1100.0;
|
||||
let mut w = Win::new(width);
|
||||
w.app.services.caption_style = Some(Box::new(move |_| CaptionStyle { family, left: vec![Close], right: vec![Minimize, Maximize], icons: None }));
|
||||
w.frame(vec![]);
|
||||
w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
// Flush with the window's edges, the edge gap outermost, the hit areas touching.
|
||||
assert_eq!((close.left(), close.width()), (0.0, end + size + gap), "{family:?}");
|
||||
assert_eq!((max.right(), max.width()), (width, gap + size + end), "{family:?}");
|
||||
assert_eq!((min.right(), min.width()), (max.left(), 2.0 * gap + size), "{family:?}");
|
||||
// GNOME's header bars are taller than Photoshop's bar; KDE's are not.
|
||||
if family == CaptionFamily::Adwaita {
|
||||
assert_eq!(close.height(), 46.0);
|
||||
} else {
|
||||
assert!((31.0..46.0).contains(&close.height()), "{close:?}");
|
||||
}
|
||||
let mark = crate::brand::mark_rect(&w.ctx).expect("the brand mark is painted");
|
||||
assert!(mark.left() > close.right(), "the bar starts after the left buttons: {mark:?} {close:?}");
|
||||
for a in w.bar_widgets() {
|
||||
assert!(a.left() >= close.right() && a.right() <= min.left(), "{a:?} runs into the caption buttons ({family:?})");
|
||||
}
|
||||
assert!(w.click(max.center()).contains(&ViewportCommand::Maximized(true)));
|
||||
assert!(w.click(close.center()).iter().any(|c| matches!(c, ViewportCommand::Close)));
|
||||
// A drag that starts on a button moves the window instead of pressing it.
|
||||
let at = min.center();
|
||||
let cmds = w.gesture(&[at, at + vec2(12.0, 4.0), at + vec2(30.0, 8.0)]);
|
||||
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
|
||||
assert!(!cmds.contains(&ViewportCommand::Minimized(true)), "{cmds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A side without buttons reserves nothing; without the custom title bar, neither does any.
|
||||
#[test]
|
||||
fn only_sides_with_buttons_take_room() {
|
||||
let mut w = Win::new(1000.0);
|
||||
w.app.services.caption_style = Some(Box::new(|_| CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:close")));
|
||||
assert_eq!(reserved(&w.app, &w.ctx), (0.0, 6.5 + 24.0 + 10.0));
|
||||
w.app.custom_titlebar = false;
|
||||
assert_eq!(reserved(&w.app, &w.ctx), (0.0, 0.0));
|
||||
w.app.custom_titlebar = true;
|
||||
w.app.services.caption_style = None;
|
||||
assert_eq!(reserved(&w.app, &w.ctx), (0.0, 3.0 * BUTTON_WIDTH));
|
||||
}
|
||||
|
||||
/// Theme glyphs upload once per theme and size; an icon the theme lacks, a wrong-sized mask or
|
||||
/// an absurd size gives `None`, so the button draws its own glyph.
|
||||
#[test]
|
||||
fn theme_icons_are_cached_and_fall_back() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let calls = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = std::sync::Arc::clone(&calls);
|
||||
let render = move |icon: CaptionIcon, px: u32| {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
match icon {
|
||||
CaptionIcon::Close => Some(vec![255; (px * px) as usize]),
|
||||
CaptionIcon::Minimize => Some(vec![255; 3]),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let icons = CaptionIcons { theme: "Test".into(), render: std::sync::Arc::new(render) };
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(RawInput::default(), |ui| {
|
||||
let tex = theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).expect("rendered");
|
||||
assert_eq!(tex.size(), [16, 16]);
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).is_some());
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none());
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none());
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Minimize, 16).is_none(), "a mask of the wrong size");
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 0).is_none());
|
||||
assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 100_000).is_none());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(calls.load(Ordering::Relaxed), 3, "each icon and size rendered once; absurd sizes never");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user