Use application artwork in the running window tray
This commit is contained in:
1 parent
81b99709bb
commit
78ce1ed590
4 files changed
+145
-19
No files matched your search
@@ -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` |
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user