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:
authored and GitHub committed 2026-10-12 23:01:08 -04:00
1 parent 20539b5dc3
commit f5f0f73f25
10 files changed
+1200 -41

No files matched your search

Generated
+1
View File
@@ -4220,6 +4220,7 @@ dependencies = [
"photocraft-raster",
"photocraft-tablet",
"photocraft-ui-egui",
"resvg",
"rfd",
"ron",
"serde_json",
+3
View File
@@ -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"
+1 -1
View File
@@ -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()
}
+399
View File
@@ -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(&gtk, "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(&gtk, "Settings", "gtk-decoration-layout");
Found { gtk_theme, layout, icon_theme: ini_value(&gtk, "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(&current), 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);
}
}
+329
View File
@@ -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(&section(&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,&lt;svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'&gt;&lt;rect width='16' height='16'/&gt;&lt;/svg&gt;" 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")]);
}
}
+7
View File
@@ -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);
+3
View File
@@ -147,3 +147,6 @@ sync_github_name = true
[people.megaDon101]
display_name = "megadon"
sync_github_name = false
[people.sssehnsuchttt]
sync_github_name = true
+7
View File
@@ -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.
+19 -9
View File
@@ -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
View File
@@ -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");
}
}