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

+8 -3
View File
@@ -1,8 +1,8 @@
# Ferese Graphite 视觉方案 # Ferese Graphite 视觉方案
- 文档版本:1.5 - 文档版本:1.6
- 整理日期:2026-10-28 - 整理日期:2026-10-28
- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`;本版补充全部设置分类与窗口缩放的性能检查。 - 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`、`81b9970`;本版修正活动窗口的应用图标来源。
- 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。
## 1. 视觉目标 ## 1. 视觉目标
@@ -223,6 +223,7 @@ Appearance 作为全部设置的入口,内容按实际功能分组:
- 活动窗口区位于面板右侧、系统托盘区左边,与工作区显示空间独立。排列所有已打开窗口,每个窗口一个应用图标,关闭窗口即移除;不保留已关闭应用的固定图标。 - 活动窗口区位于面板右侧、系统托盘区左边,与工作区显示空间独立。排列所有已打开窗口,每个窗口一个应用图标,关闭窗口即移除;不保留已关闭应用的固定图标。
- 点击图标切换到对应窗口,支持同一应用的多个窗口和跨工作区切换。以启动顺序保持稳定位置,突出当前焦点窗口。 - 点击图标切换到对应窗口,支持同一应用的多个窗口和跨工作区切换。以启动顺序保持稳定位置,突出当前焦点窗口。
- 使用应用桌面入口声明的图标,保留 SVG / PNG 的原始配色;兼容 StartupWMClass 匹配、绝对图标路径及应用 ID 图标查找。没有可用图标时使用通用窗口图形。图标查询在后台执行,按运行中的应用缓存。
- 图标 18 px,按钮宽 30 px,间隔 2 px;悬停显示窗口标题,标题为空时使用应用标识。窗口过多时在分配宽度内横向滚动,默认最大宽 360 px。 - 图标 18 px,按钮宽 30 px,间隔 2 px;悬停显示窗口标题,标题为空时使用应用标识。窗口过多时在分配宽度内横向滚动,默认最大宽 360 px。
- 面板编辑器可添加、隐藏、移动“活动窗口”项目;旧预设在合并时间入口时补齐该项目,新配置中主动移除后不会自动恢复。 - 面板编辑器可添加、隐藏、移动“活动窗口”项目;旧预设在合并时间入口时补齐该项目,新配置中主动移除后不会自动恢复。
@@ -305,7 +306,7 @@ status {
| 设置测试 | 128 项通过,10 项可选检查默认跳过 | | 设置测试 | 128 项通过,10 项可选检查默认跳过 |
| 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 |
| 配置测试 | 51 项单元测试及 11 项集成测试通过 | | 配置测试 | 51 项单元测试及 11 项集成测试通过 |
| 共享主题 / 面板 / Shell | 34 / 69 / 253 项通过 | | 共享主题 / 面板 / Shell | 34 / 69 / 256 项通过 |
| 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 |
| 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 |
| 导航居中 | 全部 16 项;包含独立滚动条通道检查 | | 导航居中 | 全部 16 项;包含独立滚动条通道检查 |
@@ -314,6 +315,7 @@ status {
| 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 | | 导航抽屉 | 已移除关闭按钮,外侧点击和 Esc 关闭均检查通过 |
| 时间控制面板 | 原生预览显示真实音频输出、音量与静音控件、电源模式;关机确认后取消,会话保持运行 | | 时间控制面板 | 原生预览显示真实音频输出、音量与静音控件、电源模式;关机确认后取消,会话保持运行 |
| 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 | | 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 |
| 应用图标 | 本机解析 SOUP、Strata、QQ、Teams 的 SVG / PNG;原生预览中 SOUP 显示现有品牌图标。仅重载 Shell,SOUP 和设置的进程、窗口及焦点保留 |
| 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 | | 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 |
| 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 |
| 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | | 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 |
@@ -356,6 +358,8 @@ status {
本轮记录位于 `.artifacts/settings-audit-20261028/`,包含逐帧数据、前后图像、原生分类与窗口尺寸检查;这些资料只保留在本机。 本轮记录位于 `.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` | | 祝日函数执行与校验 | `crates/ferese-config/src/calendar_rules.rs` |
| 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` | | 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` |
| 活动窗口与焦点切换 | `shell/ferese-shell/src/window_tray.rs` | | 活动窗口与焦点切换 | `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/all_options.rs` |
| 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` | | 分区表面与图标 | `shell/ferese-settings/src/visuals.rs` |
| 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` | | 布局及对齐回归检查 | `shell/ferese-settings/src/scroll_tests.rs` |
+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 bar::{bar_content, output_bar_hidden};
use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface}; use surfaces::{EffectsBinding, EffectsContext, native_wayland_surface};
mod application_icons;
mod capture_status; mod capture_status;
mod clock; mod clock;
mod compositor_ipc; 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 let icons = app_ids
.into_iter() .into_iter()
.map(|app_id| { .map(|app_id| {
let entry = fde::find_app_by_id(&entries, fde::unicase::Ascii::new(app_id.as_str())); let handle = crate::application_icons::resolve(&entries, &app_id);
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));
(app_id, handle) (app_id, handle)
}) })
.collect(); .collect();
@@ -533,7 +520,7 @@ impl WorkspaceUi {
self.icons self.icons
.get(id) .get(id)
.cloned() .cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION)) .unwrap_or_else(crate::application_icons::fallback)
}) })
.collect() .collect()
} }
@@ -542,7 +529,7 @@ impl WorkspaceUi {
self.icons self.icons
.get(app_id) .get(app_id)
.cloned() .cloned()
.unwrap_or_else(|| icon::from_svg_bytes(ferese_theme::icons::APPLICATION)) .unwrap_or_else(crate::application_icons::fallback)
} }
} }