Use application artwork in the running window tray

This commit is contained in:
aria committed 2026-10-28 07:33:19 +09:00
1 parent 81b99709bb
commit 78ce1ed590
4 files changed
+145 -19

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
//! Application artwork is resolved off the UI thread, then cached by app ID.
use std::path::{Path, PathBuf};
use cosmic::widget::icon;
use freedesktop_desktop_entry as fde;
pub(super) fn fallback() -> icon::Handle {
icon::from_svg_bytes(ferese_theme::icons::WINDOW).symbolic(true)
}
pub(super) fn resolve(entries: &[fde::DesktopEntry], app_id: &str) -> icon::Handle {
path(entries, app_id, |name| {
icon::from_name(name.to_owned())
.prefer_svg(true)
.size(32)
// A shortened name could identify a different application.
.fallback(None)
.path()
})
.map(icon::from_path)
.unwrap_or_else(fallback)
}
fn path(
entries: &[fde::DesktopEntry],
app_id: &str,
mut lookup: impl FnMut(&str) -> Option<PathBuf>,
) -> Option<PathBuf> {
let entry = fde::find_app_by_id(entries, fde::unicase::Ascii::new(app_id));
let source = entry
.and_then(fde::DesktopEntry::icon)
.filter(|s| !s.is_empty());
let declared = source.and_then(|source| {
if Path::new(source).is_absolute() {
Some(PathBuf::from(source))
} else {
lookup(source)
}
});
declared.filter(|path| path.is_file()).or_else(|| {
// Some applications install an app-ID icon without an Icon= entry.
// Also try the canonical desktop ID for StartupWMClass aliases.
[Some(app_id), entry.map(|entry| entry.appid.as_str())]
.into_iter()
.flatten()
.filter(|id| !id.is_empty() && !id.contains('/'))
.find_map(|id| lookup(id).filter(|path| path.is_file()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(directory: &Path, id: &str, fields: &str) -> fde::DesktopEntry {
let path = directory.join(format!("{id}.desktop"));
std::fs::write(
&path,
format!("[Desktop Entry]\nType=Application\nName=Test\n{fields}\n"),
)
.unwrap();
fde::DesktopEntry::from_path(path, Some(&[] as &[&str])).unwrap()
}
#[test]
fn declared_artwork_wins_and_absolute_raster_icons_work() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("own.png");
std::fs::write(&png, b"fixture").unwrap();
let entries = [entry(
dir.path(),
"test.app",
&format!("Icon={}", png.display()),
)];
assert_eq!(
path(&entries, "test.app", |_| panic!(
"absolute icon needs no lookup"
)),
Some(png)
);
}
#[test]
fn missing_or_broken_declarations_use_the_app_id_and_wm_class_alias() {
let dir = tempfile::tempdir().unwrap();
let svg = dir.path().join("own.svg");
std::fs::write(&svg, b"fixture").unwrap();
for fields in ["", "Icon=missing-icon", "Icon=/missing/icon.png"] {
let entries = [entry(
dir.path(),
"test.app",
&format!("StartupWMClass=Alias\n{fields}"),
)];
let mut names = Vec::new();
assert_eq!(
path(&entries, "Alias", |name| {
names.push(name.to_owned());
(name == "test.app").then(|| svg.clone())
}),
Some(svg.clone())
);
assert!(names.iter().any(|name| name == "Alias"));
}
}
#[test]
fn unknown_applications_never_use_the_ferese_brand() {
assert!(path(&[], "unknown", |_| None).is_none());
let icon = fallback();
assert!(icon.symbolic);
match icon.data {
icon::Data::Svg(handle) => {
assert_ne!(
handle,
cosmic::widget::svg::Handle::from_memory(ferese_theme::icons::APPLICATION)
);
}
_ => panic!("fallback must be the bundled window glyph"),
}
}
#[test]
#[ignore = "inspect desktop/icon lookup using the caller's XDG environment"]
fn inspect_installed_application_icons() {
let entries: Vec<_> = fde::Iter::new(fde::default_paths())
.entries::<&str>(Some(&[]))
.collect();
let ids = std::env::var("FERESE_TEST_APP_IDS").unwrap();
for id in ids.split(',') {
eprintln!("APP_ICON {id}: {:?}", resolve(&entries, id));
}
}
}
+1
View File
@@ -7,6 +7,7 @@ mod tray;
use bar::{bar_content, output_bar_hidden};
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
mod application_icons;
mod capture_status;
mod clock;
mod compositor_ipc;
+3 -16
View File
@@ -89,20 +89,7 @@ fn discover_icons(entries: Option<Arc<Vec<fde::DesktopEntry>>>, app_ids: Vec<Str
let icons = app_ids
.into_iter()
.map(|app_id| {
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str()));
let path = entry.and_then(fde::DesktopEntry::icon).and_then(|source| {
if source.starts_with('/') {
Some(std::path::PathBuf::from(source))
} else {
icon::from_name(source).prefer_svg(true).size(32).path()
}
});
// Named::handle returns an empty SVG when neither an application
// icon nor a theme fallback exists. Keep every live window visible.
let handle = path
.filter(|path| path.is_file())
.map(icon::from_path)
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION));
let handle = crate::application_icons::resolve(&entries, &app_id);
(app_id, handle)
})
.collect();
@@ -533,7 +520,7 @@ impl WorkspaceUi {
self.icons
.get(id)
.cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
.unwrap_or_else(crate::application_icons::fallback)
})
.collect()
}
@@ -542,7 +529,7 @@ impl WorkspaceUi {
self.icons
.get(app_id)
.cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION))
.unwrap_or_else(crate::application_icons::fallback)
}
}