diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index 5a756b0..b5ebb91 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.4 +- 文档版本:1.5 - 整理日期:2026-10-28 -- 视觉基线:`1e34ec4`、`9908dde`、`3264659`;本版补充主题预览原生绘制与配置选项的后台载入。 +- 视觉基线:`1e34ec4`、`9908dde`、`3264659`、`6671960`;本版补充全部设置分类与窗口缩放的性能检查。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -245,6 +245,9 @@ Appearance 作为全部设置的入口,内容按实际功能分组: - 主题预览由原生矩形、圆角和线条绘制,保留浅深配对、当前外观指示和选中标记;布局阶段不再生成与窗口尺寸相关的 SVG。主题与快捷键列表继续按可见行挂载。 - “全部配置”在首次进入或搜索时异步查询 schema,同一请求在完成前只执行一次。成功结果以不可变快照缓存;失败可重试;查询返回时使用最新配置并保留搜索内容。 - 显示器、连接、托盘应用与壁纸继续使用各自的后台读取或缩略图任务。新增页面应先显示内容结构和载入状态,将耗时采集放到后台,避免在视图构建、布局或绘制回调里执行子进程和文件解析。 +- 显示器查询合并进行中请求,成功或失败返回后 1 秒内的自动访问复用结果,保留原有 2 秒轮询。托盘应用查询使用 2 秒短时缓存和 3 秒总超时;失败保留上次成功列表。手动刷新绕过缓存有效期,但不会叠加进行中请求。 +- 面板预览在配置解析时保留规范化数据,仅在面板字段编辑后更新,避免每帧重复反序列化。启动时的主题快照在初始化中复用;手动重新载入配置使用后台任务,结果不会覆盖请求之后的新编辑。 +- 窗口布局示意图使用缓存矢量几何,保留原有渐变、曲线、颜色、描边和比例;布局参数或主题改变时更新缓存。最大化与还原保持同一示意图尺寸时复用几何。 - 设置控件保留稳定 ID,使异步数据刷新、可见行挂载和页面切换保持键盘焦点与未提交输入。 ## 8. 动效原则 @@ -299,7 +302,7 @@ status { | 验证项 | 结果 | | --- | --- | -| 设置测试 | 122 项通过,9 项可选检查默认跳过 | +| 设置测试 | 128 项通过,10 项可选检查默认跳过 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 配置测试 | 51 项单元测试及 11 项集成测试通过 | | 共享主题 / 面板 / Shell | 34 / 69 / 253 项通过 | @@ -316,7 +319,7 @@ status { | 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | | 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 | -滚动渲染专项、边缘双击与窄窗口抽屉的记录沿用前版 `9908dde`。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 +滚动渲染专项记录沿用前版 `9908dde`;边缘双击与窄窗口抽屉本轮再次检查通过。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 ### 性能采样 @@ -332,6 +335,27 @@ status { 性能、原生界面与后台载入记录位于 `.artifacts/settings-performance-20261028/`,与此前验收资料分开保存。另保留主题键盘选取、配置写入与恢复检查,以及配对颜色、透明外角和选中标记的像素回归。 +#### 全部分类与最大化 / 还原 + +随后以 `6671960` 为基线,检查全部 18 个页面(包含动效、快捷键子页)。同一配置、WGPU、2 倍缩放,每页测量首次构建、稳定帧、10 次往返尺寸变化及 4 次切换,共 324 帧;尺寸为 1272 × 760 与 631 × 568 逻辑像素。连接、托盘和三屏数据使用固定模型,异步硬件查询另由请求合并测试和原生预览检查覆盖。 + +| 指标(中位数,调试构建) | 基线 | 优化后 | +| --- | ---: | ---: | +| 窗口页尺寸变化:绘制与 GPU 读回 | 28.52 ms | 8.35 ms | +| 窗口页尺寸变化:构建、布局、绘制与读回总计 | 49.64 ms | 29.32 ms | +| 面板页稳定帧:构建与布局 | 2.20 ms | 1.66 ms | +| 面板页重复切换:构建与布局 | 2.15 ms | 1.47 ms | + +其余分类重复切换的构建与布局中位数约 0.69–1.63 ms,没有发现同类 SVG 渲染尖峰。完整尺寸变化仍需重新排版文字,各页约 6–21 ms;首次进程的字体和渲染器初始化成本仍存在。这些读回指标用于实现对照,不代表物理屏幕的呈现延迟,也不表示每个页面都获得同等加速。 + +54 张相同尺寸的页面图像对照中,52 张逐像素一致;窗口页的两张宽窗口图仅有矢量替换差异,平均每通道误差约 0.071 / 255。独立示意图对照的平均每通道误差约 0.023 / 255,涵盖描边缩放和 SVG 渐变坐标的保持。请求合并、失败重试、预览缓存失效和后台重新载入期间的新输入保护均有回归检查。 + +独立嵌套会话中,18 页各切换两次,切换到首个重绘回调为 3.0–19.8 ms,两轮中位数分别为 7.9 / 7.4 ms。窗口页原生最大化 / 还原各测 12 次:新尺寸内容提交的中位数为基线 37.1 ms、优化后 35.2 ms,包含 IPC 轮询误差,未显示明显的原生提交延迟改善。视觉动画结束约 413 ms,保持原有动效;它与客户端绘制耗时分别记录,不以缩短动效替代渲染优化。 + +手动重新载入另用隔离配置和仅测试进程使用的读取拦截器人为延迟 2 秒:基线的读取线程就是界面主线程,新版的读取发生在工作线程。约 0.88 秒时,新版已经切换并显示主题页,基线仍等待读取。两版测试实例随后关闭,拦截器不用于保留的开发预览。预览运行程序的 SHA-256 与本次构建一致,检查前后配置的 SHA-256 一致。 + +本轮记录位于 `.artifacts/settings-audit-20261028/`,包含逐帧数据、前后图像、原生分类与窗口尺寸检查;这些资料只保留在本机。 + ### 实现位置 | 内容 | 文件 | @@ -346,6 +370,10 @@ status { | 日历日期和星期显示 | `crates/ferese-theme/src/calendar.rs` | | 原生主题预览绘制 | `crates/ferese-theme/src/gallery/preview.rs` | | 按需 schema 载入与缓存 | `shell/ferese-settings/src/all_options.rs` | +| 数据刷新合并与有效期 | `shell/ferese-settings/src/refresh.rs` | +| 规范化面板预览缓存 | `shell/ferese-settings/src/store.rs` | +| 窗口示意图缓存绘制 | `shell/ferese-settings/src/window_preview.rs` | +| 全部分类与尺寸性能采样 | `shell/ferese-settings/src/performance_tests.rs` | | 祝日函数执行与校验 | `crates/ferese-config/src/calendar_rules.rs` | | 时间弹层与控制中心 | `shell/ferese-shell/src/status_ui/` | | 活动窗口与焦点切换 | `shell/ferese-shell/src/window_tray.rs` | diff --git a/shell/ferese-settings/src/all_options.rs b/shell/ferese-settings/src/all_options.rs index 0d1ce59..1dd7750 100644 --- a/shell/ferese-settings/src/all_options.rs +++ b/shell/ferese-settings/src/all_options.rs @@ -354,7 +354,7 @@ fn load_schema() -> Result { } } -fn catalog() -> Result, String> { +pub(super) fn catalog() -> Result, String> { static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); if let Some(catalog) = CATALOG.get() { return Ok(catalog.clone()); diff --git a/shell/ferese-settings/src/calendar_controls.rs b/shell/ferese-settings/src/calendar_controls.rs index 1205d4b..69e3c25 100644 --- a/shell/ferese-settings/src/calendar_controls.rs +++ b/shell/ferese-settings/src/calendar_controls.rs @@ -27,6 +27,7 @@ mod tests { "/unused/calendar.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; diff --git a/shell/ferese-settings/src/connections/controller.rs b/shell/ferese-settings/src/connections/controller.rs index 9182fbb..f64a9ba 100644 --- a/shell/ferese-settings/src/connections/controller.rs +++ b/shell/ferese-settings/src/connections/controller.rs @@ -271,6 +271,7 @@ mod tests { "/unused/connections-test.kdl".into(), crate::store::Snapshot::parse(String::new()), None, + None, ), ) .0 @@ -347,6 +348,7 @@ mod tests { Some(crate::InitialPage::Connections( super::super::Tab::Bluetooth, )), + None, ), ); assert_eq!(app.page, crate::schema::Page::Connections); diff --git a/shell/ferese-settings/src/connections/mod.rs b/shell/ferese-settings/src/connections/mod.rs index c23da50..845e12e 100644 --- a/shell/ferese-settings/src/connections/mod.rs +++ b/shell/ferese-settings/src/connections/mod.rs @@ -33,6 +33,36 @@ pub(crate) struct Snapshot { pub bluetooth: Result, } +#[cfg(test)] +pub(super) fn performance_snapshot() -> Snapshot { + Snapshot { + wifi: Ok(wifi::Wifi { + enabled: true, + hardware_enabled: true, + radios: vec![wifi::Radio { + path: "/test/wlan0".into(), + name: "Test Wi-Fi".into(), + }], + networks: (0..12) + .map(|i| Network { + ssid: format!("Network {i}").into_bytes(), + name: format!("Network {i}"), + security: Security::Personal, + strength: 90 - i, + device: "/test/wlan0".into(), + ap: format!("/test/ap{i}"), + saved: None, + connected: i == 0, + connecting: false, + hidden: false, + }) + .collect(), + saved: Vec::new(), + }), + bluetooth: Ok(bluetooth::Bluetooth::default()), + } +} + pub(crate) struct State { pub client: Option>, pub snapshot: Option, diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index f5851f2..5466a24 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -9,6 +9,7 @@ mod panel_drag; mod panel_edit; mod panel_inspector; mod panel_preview; +mod refresh; mod theme_controls; mod tray_controls; @@ -22,7 +23,10 @@ mod store; mod visuals; mod wallpaper_controls; mod watch; +mod window_preview; +#[cfg(test)] +mod performance_tests; #[cfg(test)] mod scroll_tests; @@ -91,7 +95,8 @@ fn main() -> cosmic::iced::Result { if let Ok(snapshot) = &initial { ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc)); } - let theme = visuals::native_theme(initial.as_ref().ok()); + let resolved = ferese_theme_client::service::current(); + let theme = visuals::Palette::from_resolved(&resolved.presented).native_theme(); cosmic::app::run::( Settings::default() @@ -99,7 +104,7 @@ fn main() -> cosmic::iced::Result { .is_daemon(false) .antialiasing(true) .default_text_size(14.), - (path, initial, initial_page), + (path, initial, initial_page, Some(resolved)), ) } @@ -116,7 +121,7 @@ enum Message { Options(all_options::Input), LoadOptions, OptionsLoaded(Result, String>), - TrayApps(Vec), + TrayApps(Result, String>), RefreshTrayApps, ReadRenderScale(cosmic::iced::window::Id), RenderScale(f32), @@ -128,6 +133,7 @@ enum Message { SelectDisplay(String), DisplayResolution(String, displays::Resolution), RefreshDisplays, + PollDisplays, DisplaysLoaded(Result, String>), RefreshRate(String, displays::Mode, bool), ThemeChanged(Box), @@ -153,6 +159,7 @@ enum Message { Release(Field), Saved(Result<(Snapshot, bool), String>), Reload, + Reloaded(u64, Result), ExternalConfig(Result), Undo, Family(String, Option), @@ -188,9 +195,11 @@ enum Message { struct App { tray_apps: Vec, + tray_refresh: refresh::Gate, options: all_options::State, connections: connections::State, displays: Vec, + display_refresh: refresh::Gate, display_selection: Option, display_error: Option, core: Core, @@ -200,6 +209,8 @@ struct App { draft: Snapshot, pending: Vec, saving: bool, + reloading: bool, + edit_revision: u64, undo: Option, saving_previous: String, error: Option, @@ -261,7 +272,12 @@ impl Drop for App { impl cosmic::Application for App { type Executor = cosmic::executor::Default; - type Flags = (PathBuf, Result, Option); + type Flags = ( + PathBuf, + Result, + Option, + Option, + ); type Message = Message; const APP_ID: &'static str = "dev.ferese.Settings"; @@ -273,7 +289,10 @@ impl cosmic::Application for App { &mut self.core } - fn init(mut core: Core, (path, initial, initial_page): Self::Flags) -> (Self, Task) { + fn init( + mut core: Core, + (path, initial, initial_page, resolved): Self::Flags, + ) -> (Self, Task) { core.window.show_headerbar = false; let status = if path == store::config_path() { @@ -286,7 +305,7 @@ impl cosmic::Application for App { let current = initial.unwrap_or_else(|_| { Snapshot::parse(String::new()).expect("an empty Settings document uses valid defaults") }); - let resolved = ferese_theme_client::service::current(); + let resolved = resolved.unwrap_or_else(ferese_theme_client::service::current); let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t); @@ -294,6 +313,7 @@ impl cosmic::Application for App { let options = all_options::State::pending(¤t); let mut app = Self { tray_apps: Vec::new(), + tray_refresh: Default::default(), options, connections: connections::State { tab: match initial_page { @@ -303,6 +323,7 @@ impl cosmic::Application for App { ..connections::State::default() }, displays: vec![], + display_refresh: Default::default(), display_selection: None, display_error: None, core, @@ -312,6 +333,8 @@ impl cosmic::Application for App { current, pending: vec![], saving: false, + reloading: false, + edit_revision: 0, undo: None, saving_previous: String::new(), error, @@ -394,9 +417,25 @@ impl cosmic::Application for App { } fn update(&mut self, message: Message) -> Task { + // Draft text and editors can change without committing a document edit. + // A slow reload must not replace input entered after it was requested. + if matches!( + message, + Message::Draft(..) + | Message::Range(..) + | Message::Options(_) + | Message::CalendarFunction(_) + ) { + self.edit_revision = self.edit_revision.wrapping_add(1); + } match message { - Message::TrayApps(apps) => self.tray_apps = apps, - Message::RefreshTrayApps => return self.load_tray_apps(), + Message::TrayApps(apps) => { + self.tray_refresh.finish(); + if let Ok(apps) = apps { + self.tray_apps = apps; + } + } + Message::RefreshTrayApps => return self.refresh_tray_apps(true), Message::Options(input) => return self.options_input(input), Message::LoadOptions => return self.load_options(), Message::OptionsLoaded(result) => self.options_loaded(result), @@ -430,14 +469,18 @@ impl cosmic::Application for App { return self.edit_many(displays::edits(&prefix, mode, automatic)); } } - Message::RefreshDisplays => return self.load_displays(), - Message::DisplaysLoaded(result) => match result { - Ok(displays) => { - self.displays = displays; - self.display_error = None; + Message::RefreshDisplays => return self.refresh_displays(true), + Message::PollDisplays => return self.load_displays(), + Message::DisplaysLoaded(result) => { + self.display_refresh.finish(); + match result { + Ok(displays) => { + self.displays = displays; + self.display_error = None; + } + Err(error) => self.display_error = Some(error), } - Err(error) => self.display_error = Some(error), - }, + } Message::RefreshRate(prefix, mode, automatic) => { self.inputs.remove(&format!("{prefix}.mode")); return self.edit_many(displays::edits(&prefix, mode, automatic)); @@ -493,6 +536,7 @@ impl cosmic::Application for App { )); self.options.reset(&snapshot); self.calendar_editor.refresh(&snapshot); + self.edit_revision = self.edit_revision.wrapping_add(1); self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new( @@ -885,29 +929,52 @@ impl cosmic::Application for App { } } } - Message::Reload if !self.saving => match Snapshot::read(&self.path) { - Ok(snapshot) => { - ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc)); - self.options.reset(&snapshot); - self.calendar_editor.refresh(&snapshot); - self.current = snapshot.clone(); - self.draft = snapshot; - self.family_sections = - theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.font = fonts::interface_font( - &self.resolved.presented.tokens.typography.font_family, - ); - self.pending.clear(); - self.inputs.clear(); - self.ranges.clear(); - self.error = None; - self.undo = None; - self.status = "Reloaded from your config".into(); - - return Task::batch([self.update_theme(), self.load_thumbnail()]); + Message::Reload if !self.saving && !self.reloading => { + self.reloading = true; + let revision = self.edit_revision; + let path = self.path.clone(); + return cosmic::task::future(async move { + Message::Reloaded( + revision, + tokio::task::spawn_blocking(move || Snapshot::read(&path)) + .await + .unwrap_or_else(|e| Err(e.to_string())), + ) + }); + } + Message::Reloaded(revision, result) => { + self.reloading = false; + if self.saving || revision != self.edit_revision { + return Task::none(); } - Err(error) => self.error = Some(error), - }, + match result { + Ok(snapshot) => { + ferese_config::locale::set(ferese_config::locale::from_document( + &snapshot.doc, + )); + self.options.reset(&snapshot); + self.calendar_editor.refresh(&snapshot); + self.current = snapshot.clone(); + self.draft = snapshot; + self.family_sections = theme_controls::FamilySections::new( + &self.resolved.families, + &self.draft, + ); + self.font = fonts::interface_font( + &self.resolved.presented.tokens.typography.font_family, + ); + self.pending.clear(); + self.inputs.clear(); + self.ranges.clear(); + self.error = None; + self.undo = None; + self.status = "Reloaded from your config".into(); + + return Task::batch([self.update_theme(), self.load_thumbnail()]); + } + Err(error) => self.error = Some(error), + } + } Message::Undo if !self.saving => { if let Some(source) = self.undo.take() { return self.start_save(source); @@ -1189,7 +1256,7 @@ impl cosmic::Application for App { .map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), if matches!(self.page, Page::Displays | Page::Bar) { cosmic::iced::time::every(std::time::Duration::from_secs(2)) - .map(|_| Message::RefreshDisplays) + .map(|_| Message::PollDisplays) } else { cosmic::iced::Subscription::none() }, @@ -1225,6 +1292,7 @@ impl App { cosmic::command::set_theme(palette.native_theme()) } fn edit_many(&mut self, edits: Vec) -> Task { + self.edit_revision = self.edit_revision.wrapping_add(1); for edit in edits { if let Err(error) = self.draft.edit(&edit) { self.error = Some(error); @@ -1235,7 +1303,17 @@ impl App { self.flush() } - fn load_displays(&self) -> Task { + fn load_displays(&mut self) -> Task { + self.refresh_displays(false) + } + + fn refresh_displays(&mut self, force: bool) -> Task { + if !self + .display_refresh + .begin(force, std::time::Duration::from_secs(1)) + { + return Task::none(); + } cosmic::task::future(async { Message::DisplaysLoaded( tokio::task::spawn_blocking(displays::load) @@ -1246,6 +1324,7 @@ impl App { } fn change(&mut self, edit: Edit) -> Task { + self.edit_revision = self.edit_revision.wrapping_add(1); if let Edit::Set(path, value) = &edit && matches!( path.as_str(), @@ -1307,6 +1386,7 @@ impl App { } fn start_save(&mut self, desired: String) -> Task { + self.edit_revision = self.edit_revision.wrapping_add(1); self.saving = true; self.saving_previous = self.current.source.clone(); let expected = self.current.source.clone(); @@ -1342,11 +1422,108 @@ mod tests { PathBuf::from("/unused/settings-test.kdl"), Snapshot::parse(String::new()), None, + None, ), ) .0 } + #[test] + fn refreshes_coalesce_keep_last_good_data_and_allow_manual_retry() { + let mut app = app(); + assert_eq!(app.load_displays().units(), 1); + assert_eq!(app.load_displays().units(), 0); + assert_eq!(app.update(Message::RefreshDisplays).units(), 0); + drop( + app.update(Message::DisplaysLoaded(Ok(vec![displays::Display { + connector: "DP-1".into(), + identity: "Test display".into(), + profile: None, + logical_width: Some(1920), + focused: true, + current: None, + modes: vec![], + }]))), + ); + assert_eq!(app.load_displays().units(), 0); + assert_eq!(app.update(Message::RefreshDisplays).units(), 1); + drop(app.update(Message::DisplaysLoaded(Err("disconnected".into())))); + assert_eq!(app.displays.len(), 1); + assert_eq!(app.display_error.as_deref(), Some("disconnected")); + assert_eq!(app.update(Message::RefreshDisplays).units(), 1); + + assert_eq!(app.load_tray_apps().units(), 1); + assert_eq!(app.load_tray_apps().units(), 0); + assert_eq!(app.update(Message::RefreshTrayApps).units(), 0); + drop( + app.update(Message::TrayApps(Ok(vec![tray_controls::Application { + id: "test.app".into(), + title: "Test app".into(), + }]))), + ); + assert_eq!(app.load_tray_apps().units(), 0); + assert_eq!(app.update(Message::RefreshTrayApps).units(), 1); + drop(app.update(Message::TrayApps(Err("timeout".into())))); + assert_eq!(app.tray_apps[0].id, "test.app"); + assert_eq!(app.update(Message::RefreshTrayApps).units(), 1); + drop(app.update(Message::TrayApps(Ok(vec![])))); + assert!(app.tray_apps.is_empty()); + } + + #[test] + fn startup_uses_the_already_loaded_theme_snapshot() { + let mut resolved = ferese_theme_client::service::current(); + resolved.revision += 123; + let expected_revision = resolved.revision; + let expected_palette = visuals::Palette::from_resolved(&resolved.presented); + let (app, _) = App::init( + Core::default(), + ( + "/unused/settings-test.kdl".into(), + Snapshot::parse(String::new()), + None, + Some(resolved), + ), + ); + assert_eq!(app.resolved.revision, expected_revision); + assert_eq!(app.native_palette.accent, expected_palette.accent); + } + + #[test] + fn manual_reload_runs_in_a_worker_and_cannot_replace_newer_edits() { + let mut app = app(); + assert_eq!(app.update(Message::Reload).units(), 1); + let revision = app.edit_revision; + assert!(app.reloading); + assert!(app.error.is_none()); + assert_eq!(app.update(Message::Reload).units(), 0); + drop(app.update(Message::Draft("test.field".into(), "new input".into()))); + drop(app.update(Message::Reloaded( + revision, + Snapshot::parse("animations { speed 0.5; }".into()), + ))); + assert_eq!(app.inputs["test.field"], "new input"); + assert!(!app.reloading); + assert!(!app.current.source.contains("speed")); + + assert_eq!(app.update(Message::Reload).units(), 1); + let revision = app.edit_revision; + drop(app.update(Message::Reloaded( + revision, + Snapshot::parse("animations { speed 0.8; }".into()), + ))); + assert!(app.inputs.is_empty()); + assert_eq!(app.current.number("animations.speed", 1.), 0.8); + assert!(!app.reloading); + assert_eq!(app.update(Message::Reload).units(), 1); + drop(app.update(Message::Reloaded( + app.edit_revision, + Err("read failed".into()), + ))); + assert_eq!(app.error.as_deref(), Some("read failed")); + assert_eq!(app.current.number("animations.speed", 1.), 0.8); + } + #[test] fn schema_loading_is_lazy_deduplicated_and_retries_after_failure() { let mut app = app(); diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index a1f7a7f..2fa0f57 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -1025,6 +1025,7 @@ mod tests { "/unused/range.kdl".into(), store::Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -1117,6 +1118,7 @@ mod tests { "/unused/editor.kdl".into(), store::Snapshot::parse(String::new()), None, + None, ), ) .0; diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index 8844e37..7df33dd 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -4,7 +4,7 @@ use ferese_panel::layout::{Placement, Resolution}; use ferese_panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone}; use ferese_theme::panel::{Sample, Spacing, frame}; -use crate::{App, Element, Message, panel_controls, visuals}; +use crate::{App, Element, Message, visuals}; const SAMPLE_AVAILABILITY: Availability = Availability { tray: true, @@ -104,25 +104,7 @@ impl App { } pub(super) fn preview_panel(&self) -> Result { - let panels = match self.draft.item("panels") { - Some(panels) => panels.clone(), - None => { - let crate::store::Edit::Set(_, panels) = panel_controls::initialize(&self.draft)? - else { - unreachable!() - }; - panels - } - }; - serde_json::from_value::>(panels) - .map_err(|error| error.to_string())? - .into_iter() - .next() - .map(|mut panel| { - panel.consolidate_clock_controls(); - panel - }) - .ok_or_else(|| "No panel configured.".into()) + self.draft.preview_panel() } fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> { @@ -628,6 +610,7 @@ mod tests { "/unused/preview-test.kdl".into(), crate::store::Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -764,6 +747,7 @@ mod tests { "/unused/preview-centering.kdl".into(), crate::store::Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -873,6 +857,7 @@ mod tests { "/unused/alignment.kdl".into(), crate::store::Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -980,6 +965,7 @@ mod tests { "/unused/hover.kdl".into(), crate::store::Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -1083,6 +1069,7 @@ mod tests { "/unused/preview-test.kdl".into(), crate::store::Snapshot::parse("panel main { group-surface unknown; }".into()), None, + None, ), ) .0; diff --git a/shell/ferese-settings/src/performance_tests.rs b/shell/ferese-settings/src/performance_tests.rs new file mode 100644 index 0000000..c8038eb --- /dev/null +++ b/shell/ferese-settings/src/performance_tests.rs @@ -0,0 +1,171 @@ +//! Opt-in, reproducible category/resize measurements. No hardware settings change. +use super::*; +use cosmic::Application; +use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; +use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree}; +use cosmic::iced::{Color, Event, Font, Pixels, Rectangle, Size, window}; + +fn fixture() -> App { + let source = std::env::var_os("FERESE_BENCH_CONFIG") + .map(|p| std::fs::read_to_string(p).unwrap()) + .unwrap_or_else(|| include_str!("../../../packaging/config.kdl").into()); + let (mut app, _) = App::init( + Core::default(), + ( + "/unused/settings-performance.kdl".into(), + Snapshot::parse(source), + None, + None, + ), + ); + app.options_loaded(all_options::catalog()); + app.connections.snapshot = Some(connections::performance_snapshot()); + app.tray_apps = (0..12) + .map(|i| tray_controls::Application { + id: format!("app-{i}"), + title: format!("Application {i}"), + }) + .collect(); + app.displays = (0..3) + .map(|i| displays::Display { + connector: format!("DP-{}", i + 1), + identity: format!("Test display {i}"), + profile: None, + logical_width: Some(1920), + focused: i == 0, + current: Some(displays::Mode { + width: 3840, + height: 2160, + refresh: 60000, + }), + modes: vec![ + displays::Mode { + width: 3840, + height: 2160, + refresh: 60000, + }, + displays::Mode { + width: 2560, + height: 1440, + refresh: 60000, + }, + ], + }) + .collect(); + app +} + +#[test] +#[ignore = "category/resize profile; requires the matching compositor schema binary"] +fn profile_all_categories_and_resizing() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let backend = std::env::var("FERESE_BENCH_BACKEND").unwrap_or_else(|_| "wgpu".into()); + let mut renderer = runtime + .block_on(cosmic::Renderer::new( + Font::default(), + Pixels(14.), + Some(&backend), + )) + .unwrap(); + let scale = 2.; + for page in Page::ALL { + let mut app = fixture(); + drop(app.update(Message::Page(page))); + let theme = app.native_palette.native_theme(); + let mut tree: Option = None; + for frame in 0..18 { + let scenario = if frame == 0 { + "cold" + } else if frame < 4 { + "warm" + } else if frame < 14 { + "resize" + } else { + "switch" + }; + if frame >= 14 { + drop(app.update(Message::Page(Page::General))); + drop(app.update(Message::Page(page))); + } + let size = if (4..14).contains(&frame) && frame % 2 == 0 { + Size::new(631., 568.) + } else { + Size::new(1272., 760.) + }; + let viewport = Rectangle::with_size(size); + let mut messages = Vec::new(); + let started = std::time::Instant::now(); + let mut view = app.page_view(); + let tree = tree.get_or_insert_with(|| Tree::new(&view)); + cosmic::iced::advanced::widget::tree::NAMED + .with(|named| *named.borrow_mut() = tree.take_all_named()); + tree.diff(view.as_widget_mut()); + let node = view.as_widget_mut().layout( + tree, + &renderer, + &layout::Limits::new(Size::ZERO, size), + ); + cosmic::iced::advanced::widget::tree::NAMED.with(|named| named.borrow_mut().clear()); + view.as_widget_mut().update( + tree, + &Event::Window(window::Event::RedrawRequested(std::time::Instant::now())), + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + let layout_us = started.elapsed().as_micros(); + renderer.reset(viewport); + let started = std::time::Instant::now(); + view.as_widget().draw( + tree, + &mut renderer, + &theme, + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: scale as f64, + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &viewport, + ); + let draw_us = started.elapsed().as_micros(); + drop(view); + let started = std::time::Instant::now(); + let pixels = Headless::screenshot( + &mut renderer, + Size::new((size.width * scale) as u32, (size.height * scale) as u32), + scale, + theme.cosmic().bg_color().into(), + ); + let render_us = started.elapsed().as_micros(); + if matches!(frame, 0 | 4 | 5) + && let Some(dir) = std::env::var_os("FERESE_BENCH_IMAGES_DIR") + { + let path = PathBuf::from(dir); + std::fs::create_dir_all(&path).unwrap(); + image::save_buffer( + path.join(format!("{page:?}-{frame}.png")), + &pixels, + (size.width * scale) as u32, + (size.height * scale) as u32, + image::ColorType::Rgba8, + ) + .unwrap(); + } + for message in messages { + drop(app.update(message)); + } + eprintln!( + "AUDIT {}", + serde_json::json!({"backend":backend,"page":format!("{page:?}"),"frame":frame,"scenario":scenario,"width":size.width,"layout_us":layout_us,"draw_us":draw_us,"render_us":render_us}) + ); + } + } +} diff --git a/shell/ferese-settings/src/refresh.rs b/shell/ferese-settings/src/refresh.rs new file mode 100644 index 0000000..417dfec --- /dev/null +++ b/shell/ferese-settings/src/refresh.rs @@ -0,0 +1,47 @@ +//! Coalesce UI refreshes; changing window size never starts a new read. +use std::time::{Duration, Instant}; + +#[derive(Default)] +pub(super) struct Gate { + in_flight: bool, + completed: Option, +} +impl Gate { + pub fn begin(&mut self, force: bool, max_age: Duration) -> bool { + self.begin_at(Instant::now(), force, max_age) + } + fn begin_at(&mut self, now: Instant, force: bool, max_age: Duration) -> bool { + if self.in_flight + || (!force + && self + .completed + .is_some_and(|at| now.saturating_duration_since(at) < max_age)) + { + return false; + } + self.in_flight = true; + true + } + pub fn finish(&mut self) { + self.in_flight = false; + self.completed = Some(Instant::now()); + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn concurrent_requests_coalesce_and_cached_visits_do_not_read_again() { + let mut gate = Gate::default(); + let now = Instant::now(); + let ttl = Duration::from_secs(1); + assert!(gate.begin_at(now, false, ttl)); + assert!(!gate.begin_at(now, true, ttl)); + gate.in_flight = false; + gate.completed = Some(now); + assert!(!gate.begin_at(now + Duration::from_millis(500), false, ttl)); + assert!(gate.begin_at(now + Duration::from_secs(2), false, ttl)); + gate.finish(); + assert!(gate.begin_at(Instant::now(), true, ttl)); + } +} diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs index ba8e68e..6ecbcc7 100644 --- a/shell/ferese-settings/src/scroll_tests.rs +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -79,6 +79,7 @@ fn panel_preview_stays_visible_while_the_items_editor_scrolls() { "/unused/editor.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -185,6 +186,7 @@ fn appearance_scroll_preserves_progress_and_settles_visibility() { PathBuf::from("/unused/scroll-test.kdl"), Snapshot::parse(source), None, + None, ), ) .0; @@ -459,6 +461,7 @@ fn gpu_panel_text_survives_preview_clipping_and_scrolling() { "/unused/panel-text.kdl".into(), Snapshot::parse("theme { mode dark; family \"ferese-blue\"; }".into()), Some(InitialPage::Bar), + None, ), ) .0; @@ -652,6 +655,7 @@ fn render_panel_review_states() { "/unused/review.kdl".into(), Snapshot::parse(String::new()), Some(InitialPage::Bar), + None, ), ) .0; @@ -899,6 +903,7 @@ fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() { "/unused/layout.kdl".into(), Snapshot::parse(String::new()), Some(InitialPage::Bar), + None, ), ) .0; @@ -1002,6 +1007,7 @@ fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() { "/unused/sidebar.kdl".into(), Snapshot::parse(String::new()), Some(InitialPage::Bar), + None, ), ) .0; @@ -1121,6 +1127,7 @@ fn appearance_cards_reflow_without_overlap_and_keep_every_destination() { "/unused/overview.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -1232,6 +1239,7 @@ fn sidebar_rows_center_inside_the_full_button_hit_area() { "/unused/navigation-centers.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -1305,6 +1313,7 @@ fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() { "/unused/edge-gesture.kdl".into(), Snapshot::parse(String::new()), Some(InitialPage::Settings(Page::General)), + None, ), ) .0; @@ -1397,6 +1406,7 @@ fn holiday_function_editor_keeps_state_when_focus_and_text_rebuild_the_page() { "/unused/calendar-editor.kdl".into(), Snapshot::parse(String::new()), Some(InitialPage::Settings(Page::Calendar)), + None, ), ) .0; diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index 1eda9ed..1c6fc8d 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -13,6 +13,7 @@ pub const LIMIT: usize = ferese_config::file::LIMIT; pub struct Snapshot { pub source: String, pub doc: Document, + preview: Result, String>, } impl Snapshot { @@ -21,12 +22,19 @@ impl Snapshot { return Err("Configuration exceeds the 60 KiB limit.".into()); } let doc = Document::parse(&source).map_err(|e| e.to_string())?; - if let Some(value) = doc.get("panels") { + let panel = if let Some(value) = doc.get("panels") { let panels: Vec = serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; ferese_panel::validate(&panels)?; - } - Ok(Self { source, doc }) + panels.into_iter().next().ok_or("No panel configured.")? + } else { + ferese_panel::Panel::default() + }; + Ok(Self { + source, + doc, + preview: Ok(Self::normalized(panel)), + }) } pub fn read(path: &Path) -> Result { @@ -95,8 +103,38 @@ impl Snapshot { } .map_err(|e| e.to_string())?; self.source = self.doc.to_string(); + let path = match edit { + Edit::Unset(p) | Edit::Set(p, _) | Edit::Add(p, _) | Edit::Remove(p, _) => p, + }; + if path == "panels" || path.starts_with("panels.") { + // Multi-field panel edits can be temporarily incomplete. Cache the + // same diagnostic the preview used to produce, without rejecting + // intermediate document edits or changing the authored source. + self.preview = match self.doc.get("panels") { + None => Ok(Self::normalized(ferese_panel::Panel::default())), + Some(value) => serde_json::from_value::>(value.clone()) + .map_err(|e| e.to_string()) + .and_then(|p| { + p.into_iter() + .next() + .ok_or_else(|| "No panel configured.".into()) + }) + .map(Self::normalized), + }; + } Ok(()) } + + fn normalized(mut panel: ferese_panel::Panel) -> std::sync::Arc { + panel.consolidate_clock_controls(); + std::sync::Arc::new(panel) + } + pub fn preview_panel(&self) -> Result { + self.preview + .as_ref() + .map(|p| p.as_ref().clone()) + .map_err(Clone::clone) + } } #[derive(Clone, Debug)] @@ -244,6 +282,43 @@ pub fn reload_running() -> bool { #[cfg(test)] mod tests { use super::*; + #[test] + fn panel_preview_cache_tracks_panel_edits_and_recovers_from_incomplete_edits() { + let mut snapshot = Snapshot::parse(String::new()).unwrap(); + let original = snapshot.preview.as_ref().unwrap().clone(); + snapshot.edit(&set("animations.speed", 0.8)).unwrap(); + assert!(std::sync::Arc::ptr_eq( + &original, + snapshot.preview.as_ref().unwrap() + )); + + let mut panel = ferese_panel::Panel::default(); + panel.geometry.height = Some(42.); + snapshot + .edit(&set("panels", serde_json::to_value(vec![panel]).unwrap())) + .unwrap(); + assert!(!std::sync::Arc::ptr_eq( + &original, + snapshot.preview.as_ref().unwrap() + )); + assert_eq!(snapshot.preview_panel().unwrap().geometry.height, Some(42.)); + snapshot + .edit(&set( + "panels", + serde_json::json!([{"geometry": {"height": 42}}]), + )) + .unwrap(); + assert!(snapshot.preview_panel().is_err()); + snapshot.edit(&Edit::Unset("panels".into())).unwrap(); + let panel = snapshot.preview_panel().unwrap(); + let mut default = ferese_panel::Panel::default(); + default.consolidate_clock_controls(); + assert_eq!( + serde_json::to_value(panel).unwrap(), + serde_json::to_value(default).unwrap() + ); + } + #[test] fn activity_display_settings_roundtrip_without_changing_rules() { for source in [ diff --git a/shell/ferese-settings/src/tray_controls.rs b/shell/ferese-settings/src/tray_controls.rs index b401738..5d5bfc5 100644 --- a/shell/ferese-settings/src/tray_controls.rs +++ b/shell/ferese-settings/src/tray_controls.rs @@ -49,8 +49,23 @@ async fn applications() -> Result, String> { Ok(apps) } impl App { - pub(super) fn load_tray_apps(&self) -> Task { - cosmic::task::future(async { Message::TrayApps(applications().await.unwrap_or_default()) }) + pub(super) fn load_tray_apps(&mut self) -> Task { + self.refresh_tray_apps(false) + } + pub(super) fn refresh_tray_apps(&mut self, force: bool) -> Task { + if !self + .tray_refresh + .begin(force, std::time::Duration::from_secs(2)) + { + return Task::none(); + } + cosmic::task::future(async { + Message::TrayApps( + tokio::time::timeout(std::time::Duration::from_secs(3), applications()) + .await + .unwrap_or_else(|_| Err("Tray application query timed out".into())), + ) + }) } pub(super) fn tray_application_controls(&self) -> Element<'_, Message> { let hidden = self diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index a265dd4..6f3ab74 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -1,5 +1,7 @@ use cosmic::Element; -use cosmic::iced::{Color, Length}; +use cosmic::iced::Color; +#[cfg(test)] +use cosmic::iced::Length; use cosmic::widget::icon as svg_icon; pub use ferese_theme::Palette; pub const PAGE_TITLE_SIZE: f32 = 16.; @@ -109,6 +111,7 @@ pub fn color(value: &str, fallback: Color) -> Color { ferese_theme::parse_color(value).unwrap_or(fallback) } +#[cfg(test)] fn hex(c: Color) -> String { format!( "#{:02x}{:02x}{:02x}", @@ -118,10 +121,6 @@ fn hex(c: Color) -> String { ) } -pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme { - Palette::from_resolved(&ferese_theme_client::service::current().presented).native_theme() -} - pub fn icon(page: Page, tint: Color) -> svg_icon::Icon { ferese_theme::icons::outline(page.icon(), tint, 19) } @@ -311,11 +310,20 @@ pub fn preview_resolved<'a>( snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme, ) -> Element<'a, Message> { + crate::window_preview::view(snapshot, theme) +} + +#[cfg(test)] +pub(super) fn legacy_preview_resolved( + snapshot: &Snapshot, + theme: &ferese_config::theme::ResolvedTheme, +) -> Element<'static, Message> { let mut snapshot = snapshot.clone(); snapshot.doc = snapshot.doc.with_theme(theme); preview(&snapshot, theme) } +#[cfg(test)] fn preview( snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme, diff --git a/shell/ferese-settings/src/wallpaper_controls.rs b/shell/ferese-settings/src/wallpaper_controls.rs index a8a3a4d..e057d69 100644 --- a/shell/ferese-settings/src/wallpaper_controls.rs +++ b/shell/ferese-settings/src/wallpaper_controls.rs @@ -514,6 +514,7 @@ mod tests { "/preview/config.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; @@ -582,6 +583,7 @@ mod tests { "/preview/config.kdl".into(), Snapshot::parse(String::new()), None, + None, ), ) .0; diff --git a/shell/ferese-settings/src/window_preview.rs b/shell/ferese-settings/src/window_preview.rs new file mode 100644 index 0000000..08eaeb3 --- /dev/null +++ b/shell/ferese-settings/src/window_preview.rs @@ -0,0 +1,277 @@ +//! A cached vector scene keeps the existing window/layout illustration without +//! repeatedly cloning the configuration or rasterizing a whole SVG on resize. +use crate::{Element, Message, Snapshot}; +use cosmic::iced::advanced::graphics::geometry::{ + Cache, Frame, LineCap, Path, Renderer as _, Stroke, +}; +use cosmic::iced::advanced::{Layout, Renderer as _, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Color, Length, Point, Rectangle, Size, Vector}; + +#[derive(Clone, Debug, PartialEq)] +struct Scene { + base: Color, + card: Color, + accent: Color, + muted: Color, + gap: f32, + radius: f32, + bar_y: f32, + margin: f32, + height: f32, + bar_radius: f32, + opacity: f32, +} +impl Scene { + fn new(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Self { + let p = crate::visuals::Palette::from_resolved(theme); + // Match the original SVG's 8-bit, opaque hexadecimal colors. + let color = + |c: Color| Color::from_rgb8((c.r * 255.) as u8, (c.g * 255.) as u8, (c.b * 255.) as u8); + Self { + base: color(p.sidebar), + card: color(p.card), + accent: color(p.accent), + muted: color(p.muted), + gap: snapshot.number("layout.inner_gap", 8.).clamp(0., 32.) as f32, + radius: theme.tokens.geometry.window_radius.clamp(0., 28.) as f32, + bar_y: (snapshot.number("panels.0.geometry.edge_margin", 0.) * 0.5 + 12.) as f32, + margin: (snapshot.number("panels.0.geometry.side_margins", 0.) * 0.5 + 14.) as f32, + height: (snapshot.number("panels.0.geometry.height", 28.) * 0.65) as f32, + bar_radius: (theme.tokens.geometry.shell_radius * 0.65) as f32, + opacity: if theme.tokens.material.style == "translucent" { + theme.tokens.material.opacity.clamp(0., 1.) as f32 + } else { + 1. + }, + } + } + fn paint(&self, frame: &mut Frame, scale: f32) { + use cosmic::iced::advanced::graphics::geometry::gradient; + let rounded = |x, y, w, h, r: f32| { + Path::rounded_rectangle(Point::new(x, y), Size::new(w, h), r.into()) + }; + let background = rounded(0., 0., 680., 210., 14.); + frame.fill(&background, self.base); + frame.fill( + &background, + // SVG's objectBoundingBox gradient projects in a unit square before + // its non-uniform scale to 680 x 210. Preserve that projection. + gradient::Linear::new( + Point::ORIGIN, + Point::new( + 2. * 680. * 210_f32.powi(2) / (680_f32.powi(2) + 210_f32.powi(2)), + 2. * 210. * 680_f32.powi(2) / (680_f32.powi(2) + 210_f32.powi(2)), + ), + ) + .add_stop(0., self.accent.scale_alpha(0.20)) + .add_stop(1., self.base), + ); + let wave = Path::new(|p| { + p.move_to(Point::new(0., 180.)); + p.quadratic_curve_to(Point::new(160., 30.), Point::new(350., 145.)); + p.quadratic_curve_to(Point::new(540., 260.), Point::new(680., 100.)); + p.line_to(Point::new(680., 210.)); + p.line_to(Point::new(0., 210.)); + p.close(); + }); + frame.fill(&wave, self.accent.scale_alpha(0.055)); + frame.fill( + &rounded( + self.margin, + self.bar_y, + 680. - 2. * self.margin, + self.height, + self.bar_radius, + ), + self.base.scale_alpha(self.opacity), + ); + frame.fill( + &rounded(30., self.bar_y + self.height / 2. - 2.5, 12., 5., 2.5), + self.accent, + ); + frame.fill( + &Path::circle(Point::new(51., self.bar_y + self.height / 2.), 2.5), + self.muted, + ); + let line = |frame: &mut Frame, x, y, w, color, width| { + frame.stroke( + &Path::line(Point::new(x, y), Point::new(x + w, y)), + Stroke::default() + .with_color(color) + .with_width(width * scale) + .with_line_cap(LineCap::Round), + ) + }; + for x in [595., 615., 635.] { + line(frame, x, self.bar_y + self.height / 2., 10., self.muted, 3.); + } + let left_width = 288. - self.gap / 2.; + let right = 340. + self.gap / 2.; + let left = rounded(44., 60., left_width, 132., self.radius); + frame.fill(&left, self.card); + frame.stroke( + &left, + Stroke::default() + .with_color(self.accent) + .with_width(1.2 * scale), + ); + frame.fill( + &rounded(right, 60., left_width, 132., self.radius), + self.card, + ); + for (y, w) in [(80., 65.), (100., 170.), (115., 120.), (130., 152.)] { + line(frame, 64., y, w, self.muted.scale_alpha(0.45), 4.); + } + frame.fill(&rounded(right + 16., 78., 50., 94., 5.), self.base); + for (y, w) in [(82., 85.), (103., 175.), (121., 150.), (139., 160.)] { + line(frame, 420., y, w, self.muted.scale_alpha(0.32), 4.); + } + } +} + +pub(super) fn view( + snapshot: &Snapshot, + theme: &ferese_config::theme::ResolvedTheme, +) -> Element<'static, Message> { + cosmic::Element::new(Preview(Scene::new(snapshot, theme))) +} +struct Preview(Scene); +struct State { + scene: Scene, + cache: Cache, +} +impl Widget for Preview { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(State { + scene: self.0.clone(), + cache: Cache::default(), + }) + } + fn diff(&mut self, tree: &mut widget::Tree) { + let state = tree.state.downcast_mut::(); + if state.scene != self.0 { + state.scene = self.0.clone(); + state.cache.clear(); + } + } + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fixed(84.)) + } + fn layout( + &mut self, + _: &mut widget::Tree, + _: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::Node::new(limits.resolve(Length::Fill, Length::Fixed(84.), Size::new(272., 84.))) + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + _: &cosmic::Theme, + _: &renderer::Style, + layout: Layout<'_>, + _: mouse::Cursor, + viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + if !bounds.intersects(viewport) { + return; + } + let scale = (bounds.width / 680.).min(bounds.height / 210.); + let size = Size::new(680. * scale, 210. * scale); + let state = tree.state.downcast_ref::(); + let geometry = state.cache.draw(renderer, size, |frame| { + frame.scale(scale); + self.0.paint(frame, scale); + }); + renderer.with_layer(*viewport, |renderer| { + renderer.with_translation( + Vector::new( + bounds.center_x() - size.width / 2., + bounds.center_y() - size.height / 2., + ), + |renderer| renderer.draw_geometry(geometry), + ) + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::renderer::Headless; + #[test] + fn vector_scene_matches_the_existing_svg_illustration() { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut renderer = runtime + .block_on(::new( + cosmic::iced::Font::default(), + cosmic::iced::Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let snapshot = Snapshot::parse("layout { inner-gap 24; }".into()).unwrap(); + let theme = ferese_config::theme::default_theme(); + let mut outputs = Vec::new(); + for mut view in [ + crate::visuals::legacy_preview_resolved(&snapshot, &theme), + view(&snapshot, &theme), + ] { + let mut tree = widget::Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(800., 84.)), + ); + renderer.reset(node.bounds()); + view.as_widget().draw( + &tree, + &mut renderer, + &cosmic::Theme::default(), + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: 2., + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &node.bounds(), + ); + outputs.push(Headless::screenshot( + &mut renderer, + Size::new(1600, 168), + 2., + Color::TRANSPARENT, + )); + } + let error = outputs[0] + .iter() + .zip(&outputs[1]) + .map(|(a, b)| u64::from(a.abs_diff(*b))) + .sum::() as f64 + / outputs[0].len() as f64; + if let Some(path) = std::env::var_os("FERESE_PREVIEW_COMPARISON") { + let path = std::path::PathBuf::from(path); + std::fs::create_dir_all(&path).unwrap(); + for (i, pixels) in outputs.iter().enumerate() { + image::save_buffer( + path.join(format!("window-{i}.png")), + pixels, + 1600, + 168, + image::ColorType::Rgba8, + ) + .unwrap(); + } + } + eprintln!("SVG/native mean channel error {error:.3}"); + assert!(error < 0.3, "SVG/native mean channel error {error:.3}"); + } +}