From 78ce1ed590197f9824b506837d5cd0f7cf4a7efb Mon Sep 17 00:00:00 2001 From: aria Date: Wed, 28 Oct 2026 07:33:19 +0900 Subject: [PATCH] Use application artwork in the running window tray --- docs/development/visual-direction.md | 11 +- shell/ferese-shell/src/application_icons.rs | 133 ++++++++++++++++++++ shell/ferese-shell/src/main.rs | 1 + shell/ferese-shell/src/workspace_ui.rs | 19 +-- 4 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 shell/ferese-shell/src/application_icons.rs diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index b5ebb91..fbb5d55 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.5 +- 文档版本:1.6 - 整理日期:2026-10-28 -- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`;本版补充全部设置分类与窗口缩放的性能检查。 +- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版修正活动窗口的应用图标来源。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -223,6 +223,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组: - 活动窗口区位于面板右侧、系统托盘区左边,与工作区显示空间独立。排列所有已打开窗口,每个窗口一个应用图标,关闭窗口即移除;不保留已关闭应用的固定图标。 - 点击图标切换到对应窗口,支持同一应用的多个窗口和跨工作区切换。以启动顺序保持稳定位置,突出当前焦点窗口。 +- 使用应用桌面入口声明的图标,保留 SVG / PNG 的原始配色;兼容 StartupWMClass 匹配、绝对图标路径及应用 ID 图标查找。没有可用图标时使用通用窗口图形。图标查询在后台执行,按运行中的应用缓存。 - 图标 18 px,按钮宽 30 px,间隔 2 px;悬停显示窗口标题,标题为空时使用应用标识。窗口过多时在分配宽度内横向滚动,默认最大宽 360 px。 - 面板编辑器可添加、隐藏、移动“活动窗口”项目;旧预设在合并时间入口时补齐该项目,新配置中主动移除后不会自动恢复。 @@ -305,7 +306,7 @@ status { | 设置测试 | 128 项通过,10 项可选检查默认跳过 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 配置测试 | 51 项单元测试及 11 项集成测试通过 | -| 共享主题 / 面板 / Shell | 34 / 69 / 253 项通过 | +| 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 导航居中 | 全部 16 项;包含独立滚动条通道检查 | @@ -314,6 +315,7 @@ status { | 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | | 时间控制面板 | 原生预览显示真实音频输出、音量与静音控件、电源模式;关机确认后取消,会话保持运行 | | 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 | +| 应用图标 | 本机解析 SOUP、Strata、QQ、Teams 的 SVG / PNG;原生预览中 SOUP 显示现有品牌图标。仅重载 Shell,SOUP 和设置的进程、窗口及焦点保留 | | 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 | | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | | 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | @@ -356,6 +358,8 @@ status { 本轮记录位于 `.artifacts/settings-audit-20261028/`,包含逐帧数据、前后图像、原生分类与窗口尺寸检查;这些资料只保留在本机。 +应用图标修订记录位于 `.artifacts/application-icons-20261028/`。SOUP 桌面入口未声明图标,本机将已有 SOUP 品牌 SVG 注册在其应用 ID 下;图标资产的来源与校验值随记录保存。Ferese 使用通用应用图标解析规则。 + ### 实现位置 | 内容 | 文件 | @@ -377,6 +381,7 @@ status { | 祝日函数执行与校验 | `crates/ferese-config/src/calendar_rules.rs` | | 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` | | 活动窗口与焦点切换 | `shell/ferese-shell/src/window_tray.rs` | +| 应用图标来源与通用兜底 | `shell/ferese-shell/src/application_icons.rs` | | 总览卡片与双列布局 | `shell/ferese-settings/src/all_options.rs` | | 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` | | 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` | diff --git a/shell/ferese-shell/src/application_icons.rs b/shell/ferese-shell/src/application_icons.rs new file mode 100644 index 0000000..d57f680 --- /dev/null +++ b/shell/ferese-shell/src/application_icons.rs @@ -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, +) -> Option { + 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)); + } + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 6a0c2e4..9a4d65f 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -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; diff --git a/shell/ferese-shell/src/workspace_ui.rs b/shell/ferese-shell/src/workspace_ui.rs index b6a8f26..15d5c8d 100644 --- a/shell/ferese-shell/src/workspace_ui.rs +++ b/shell/ferese-shell/src/workspace_ui.rs @@ -89,20 +89,7 @@ fn discover_icons(entries: Option>>, app_ids: Vec