diff --git a/crates/ferese-config/locales/zh-CN.json b/crates/ferese-config/locales/zh-CN.json index a4c63fc..27f0217 100644 --- a/crates/ferese-config/locales/zh-CN.json +++ b/crates/ferese-config/locales/zh-CN.json @@ -1038,5 +1038,9 @@ "Save your work before continuing.": "请先保存工作。", "{action} anyway": "仍然{action}", "Power off": "关机", - "Default output": "默认输出" + "Default output": "默认输出", + "Loading configuration options…": "正在载入配置选项…", + "Retry": "重试", + "Dark only": "仅深色", + "Light only": "仅浅色" } diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs index cd9504b..f6ec291 100644 --- a/crates/ferese-theme/src/gallery.rs +++ b/crates/ferese-theme/src/gallery.rs @@ -6,6 +6,7 @@ use cosmic::{Element, Renderer, Theme}; use ferese_theme_model::Appearance; use ferese_theme_model::families::{Family, Palette}; use iced_accessibility::{A11yTree, accesskit}; +mod preview; pub struct TileOptions { pub variant: Option, @@ -56,9 +57,9 @@ pub fn tile( caption = caption.push( crate::text( if family.light.is_none() { - "Dark only" + ferese_config::tr!("Dark only") } else { - "Light only" + ferese_config::tr!("Light only") }, font, ) @@ -66,23 +67,14 @@ pub fn tile( .class(cosmic::theme::Text::Color(label_color)), ); } - let family_preview = family.clone(); let caption: Element<'static, M> = container(caption) .padding(8) .width(Length::Fill) .height(Length::Fill) .align_y(cosmic::iced::alignment::Vertical::Bottom) .into(); - let image = cosmic::widget::responsive(move |size| { - let height = size.width * 126. / 200.; - // Match the button's logical radius when scaling the cached SVG artwork. - let radius = 10. * 200. / size.width.max(1.); - preview_with_radius(&family_preview, variant, active, selected, radius) - .height(Length::Fixed(height)) - .into() - }) - .height(Length::Shrink); - let content = cosmic::iced::widget::stack([image.into(), caption]); + let image = preview::view(family, variant, active, selected); + let content = cosmic::iced::widget::stack([image, caption]); let tile = button::custom(content) .id(id.clone()) .name(format!("{}, {}", family.name, family.availability())) diff --git a/crates/ferese-theme/src/gallery/preview.rs b/crates/ferese-theme/src/gallery/preview.rs new file mode 100644 index 0000000..16dedea --- /dev/null +++ b/crates/ferese-theme/src/gallery/preview.rs @@ -0,0 +1,334 @@ +//! Draw the small desktop directly. Resizing and selection never create a new +//! SVG, parse a document, or rasterize a family-specific texture on the UI thread. +use cosmic::iced::advanced::{Layout, Widget, layout, mouse, renderer, svg, widget::Tree}; +use cosmic::iced::{Border, Color, Length, Rectangle, Size}; +use cosmic::{Element, Renderer, Theme}; +use ferese_theme_model::{ + Appearance, + families::{Family, Palette}, +}; + +const CHECK_SVG: &str = concat!( + r#""#, + r#""#, +); + +#[derive(Clone, Copy)] +struct Colors { + background: Color, + bar: Color, + muted: Color, + surface: Color, + text: Color, + accent: Color, +} +impl From<&Palette> for Colors { + fn from(p: &Palette) -> Self { + let color = |s: &str| crate::parse_color(s).expect("validated theme preview color"); + Self { + background: color(&p.background), + bar: color(&p.bar), + muted: color(&p.muted), + surface: color(&p.surface), + text: color(&p.text), + accent: color(&p.accent), + } + } +} + +pub(super) fn view( + family: &Family, + variant: Option, + active: Option, + selected: bool, +) -> Element<'static, M> { + Element::new(Preview { + light: family.light.as_ref().map(Colors::from), + dark: family.dark.as_ref().map(Colors::from), + variant, + active, + selected, + }) +} + +struct Preview { + light: Option, + dark: Option, + variant: Option, + active: Option, + selected: bool, +} + +fn rect(renderer: &mut Renderer, bounds: Rectangle, color: Color, radius: f32) { + use renderer::Renderer as _; + renderer.fill_quad( + renderer::Quad { + bounds, + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + }, + color, + ); +} + +impl Preview { + fn desktop( + &self, + renderer: &mut Renderer, + bounds: Rectangle, + colors: Option, + appearance: Appearance, + ) { + let scale = bounds.width / 200.; + let at = |x, y, width, height| Rectangle { + x: bounds.x + x * scale, + y: bounds.y + y * scale, + width: width * scale, + height: height * scale, + }; + let background = colors.map_or(Color::from_rgb8(217, 221, 226), |p| p.background); + use renderer::Renderer as _; + renderer.fill_quad( + renderer::Quad { + bounds, + border: Border { + radius: 10.into(), + shape: cosmic::iced::border::Shape::Continuous, + ..Default::default() + }, + ..Default::default() + }, + background, + ); + let Some(p) = colors else { + // The surrounding caption identifies the unavailable appearance. + // Inset the pattern so the outer corners remain transparent. + for y in (8..120).step_by(8) { + for x in (8..192).step_by(8) { + if (x + y) % 16 == 0 { + rect( + renderer, + at(x as f32, y as f32, 8., 8.), + Color::from_rgb8(196, 201, 209), + 0., + ); + } + } + } + return; + }; + renderer.fill_quad( + renderer::Quad { + bounds: at(8., 8., 184., 14.), + border: Border { + radius: (4. * scale).into(), + width: scale, + color: p.muted.scale_alpha(0.18), + ..Default::default() + }, + ..Default::default() + }, + p.bar, + ); + for (x, y, width) in [(15., 15., 10.), (170., 15., 15.)] { + rect(renderer, at(x, y - 1., width, 2.), p.muted, scale); + } + rect(renderer, at(24., 35., 152., 77.), p.surface, 7. * scale); + rect( + renderer, + at(24., 50.5, 152., 1.), + p.muted.scale_alpha(0.22), + 0., + ); + for (x, y, width) in [ + (35., 44., 34.), + (36., 63., 105.), + (36., 73., 90.), + (36., 83., 115.), + ] { + rect(renderer, at(x, y - 1., width, 2.), p.text, scale); + } + rect(renderer, at(76., 92., 48., 10.), p.accent, 3. * scale); + if self.active == Some(appearance) { + let (x, width) = if self.variant.is_some() { + (8., 184.) + } else if appearance == Appearance::Light { + (8., 84.) + } else { + (108., 84.) + }; + rect(renderer, at(x, 120., width, 3.), p.accent, 1.5 * scale); + } + } +} + +impl Widget for Preview { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Shrink) + } + fn layout(&mut self, _: &mut Tree, _: &Renderer, limits: &layout::Limits) -> layout::Node { + let width = limits.max().width; + let width = if width.is_finite() { width } else { 200. }; + layout::Node::new(limits.resolve( + Length::Fill, + Length::Shrink, + Size::new(width, width * 126. / 200.), + )) + } + fn draw( + &self, + _: &Tree, + renderer: &mut Renderer, + _: &Theme, + _: &renderer::Style, + layout: Layout<'_>, + _: mouse::Cursor, + viewport: &Rectangle, + ) { + use renderer::Renderer as _; + let bounds = layout.bounds(); + let Some(clip) = bounds.intersection(viewport) else { + return; + }; + for (appearance, colors) in [ + (Appearance::Light, self.light), + (Appearance::Dark, self.dark), + ] { + if self.variant.is_some_and(|variant| variant != appearance) { + continue; + } + let half = if self.variant.is_some() { + bounds + } else { + Rectangle { + x: bounds.x + + if appearance == Appearance::Dark { + bounds.width / 2. + } else { + 0. + }, + width: bounds.width / 2., + ..bounds + } + }; + if let Some(clip) = half.intersection(&clip) { + renderer.with_layer(clip, |renderer| { + self.desktop(renderer, bounds, colors, appearance) + }); + } + } + if self.selected { + // A new layer preserves painter order after the two clipped halves; + // root-layer quads would otherwise be batched behind those layers. + renderer.with_layer(clip, |renderer| { + let p = match self.active.unwrap_or(Appearance::Dark) { + Appearance::Light => self.light, + Appearance::Dark => self.dark, + } + .or(self.dark) + .or(self.light) + .expect("theme family has an appearance"); + let scale = bounds.width / 200.; + let badge = Rectangle { + x: bounds.x + 173. * scale, + y: bounds.y + 6. * scale, + width: 20. * scale, + height: 20. * scale, + }; + rect(renderer, badge, p.accent, 10. * scale); + static CHECK: std::sync::OnceLock = std::sync::OnceLock::new(); + let check = CHECK.get_or_init(|| svg::Handle::from_memory(CHECK_SVG.as_bytes())); + let fg = crate::foreground(p.accent, Color::WHITE); + use svg::Renderer as _; + renderer.draw_svg( + svg::Svg::new(check.clone()).color(fg), + Rectangle { + x: badge.x + 4. * scale, + y: badge.y + 4. * scale, + width: 12. * scale, + height: 12. * scale, + }, + clip, + ); + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; + use cosmic::iced::{Font, Pixels}; + + #[test] + fn paired_native_preview_preserves_palettes_and_transparent_corners() { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let mut renderer = runtime + .block_on(Renderer::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let family = ferese_config::families::builtins().remove(1); + for variant in [None, Some(Appearance::Light), Some(Appearance::Dark)] { + let mut view: Element<'_, ()> = view(&family, variant, Some(Appearance::Dark), true); + let mut tree = Tree::new(&view); + let node = view.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(400., 252.)), + ); + let bounds = node.bounds(); + renderer.reset(bounds); + view.as_widget().draw( + &tree, + &mut renderer, + &Theme::default(), + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: 1., + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &bounds, + ); + let pixels = + Headless::screenshot(&mut renderer, Size::new(400, 252), 1., Color::TRANSPARENT); + assert_eq!(pixels[3], 0, "outer corner must remain transparent"); + let accent = crate::parse_color(&family.dark.as_ref().unwrap().accent).unwrap(); + let badge = &pixels[(32 * 400 + 350) * 4..(32 * 400 + 350) * 4 + 4]; + for (actual, expected) in badge.iter().zip([accent.r, accent.g, accent.b, accent.a]) { + assert!( + (f32::from(*actual) - expected * 255.).abs() < 2., + "selected badge must remain above both preview halves: {variant:?} {badge:?}" + ); + } + for (x, appearance) in [ + (20, variant.unwrap_or(Appearance::Light)), + (380, variant.unwrap_or(Appearance::Dark)), + ] { + let expected = + crate::parse_color(&family.palette(appearance).unwrap().background).unwrap(); + let actual = &pixels[(60 * 400 + x) * 4..(60 * 400 + x) * 4 + 4]; + for (actual, expected) in actual + .iter() + .zip([expected.r, expected.g, expected.b, expected.a]) + { + assert!( + (f32::from(*actual) - expected * 255.).abs() < 2., + "{variant:?}: {actual:?} {expected:?}" + ); + } + } + } + } +} diff --git a/docs/development/visual-direction.md b/docs/development/visual-direction.md index 833b0ad..5a756b0 100644 --- a/docs/development/visual-direction.md +++ b/docs/development/visual-direction.md @@ -1,8 +1,8 @@ # Ferese Graphite 视觉方案 -- 文档版本:1.3 +- 文档版本:1.4 - 整理日期:2026-10-28 -- 视觉基线:`1e34ec4`、`9908dde`;本版补充导航滚动、本地化、时间控制面板、活动窗口与可编辑祝日函数。 +- 视觉基线:`1e34ec4`、`9908dde`、`3264659`;本版补充主题预览原生绘制与配置选项的后台载入。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 ## 1. 视觉目标 @@ -240,6 +240,13 @@ Appearance 作为全部设置的入口,内容按实际功能分组: 来源:[内阁府祝日资料](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html)、[国立天文台春秋分日说明](https://www.nao.ac.jp/faq/a0301.html)、[Rhai 运算限制](https://rhai.rs/book/safety/max-operations.html)。官方快照作为回归校验资料,日历运行时使用可编辑函数。 +### 7.7 内容载入与绘制 + +- 主题预览由原生矩形、圆角和线条绘制,保留浅深配对、当前外观指示和选中标记;布局阶段不再生成与窗口尺寸相关的 SVG。主题与快捷键列表继续按可见行挂载。 +- “全部配置”在首次进入或搜索时异步查询 schema,同一请求在完成前只执行一次。成功结果以不可变快照缓存;失败可重试;查询返回时使用最新配置并保留搜索内容。 +- 显示器、连接、托盘应用与壁纸继续使用各自的后台读取或缩略图任务。新增页面应先显示内容结构和载入状态,将耗时采集放到后台,避免在视图构建、布局或绘制回调里执行子进程和文件解析。 +- 设置控件保留稳定 ID,使异步数据刷新、可见行挂载和页面切换保持键盘焦点与未提交输入。 + ## 8. 动效原则 保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。 @@ -292,10 +299,10 @@ status { | 验证项 | 结果 | | --- | --- | -| 设置测试 | 120 项通过,9 项可选检查默认跳过 | +| 设置测试 | 122 项通过,9 项可选检查默认跳过 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 配置测试 | 51 项单元测试及 11 项集成测试通过 | -| 共享主题 / 面板 / Shell | 33 / 69 / 253 项通过 | +| 共享主题 / 面板 / Shell | 34 / 69 / 253 项通过 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 导航居中 | 全部 16 项;包含独立滚动条通道检查 | @@ -306,9 +313,25 @@ status { | 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 | | 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 | | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | +| 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 | +| 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 | 滚动渲染专项、边缘双击与窄窗口抽屉的记录沿用前版 `9908dde`。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 +### 性能采样 + +2026-10-28 在本机同一隔离嵌套会话对比 `3264659` 与本次修订,均为调试构建。原生设置窗口为 1272 × 760 逻辑像素、2 倍缩放;每个程序首次进入主题页一次,再切换三次。原生指标为页面切换至首个重绘回调的时间,不等同于显示器扫描呈现延迟。 + +| 指标 | 原实现 | 优化后 | +| --- | ---: | ---: | +| 原生主题页首次重绘 | 243.3 ms | 15.1 ms | +| 原生主题页后续切换中位数 | 14.0 ms | 10.8 ms | +| WGPU 主题滚动绘制与读回 P95 | 163.9 ms | 3.75 ms | + +滚动采样使用相同的 1000 × 780 逻辑视口、1.25 倍缩放及 48 帧往返滚动,跳过最初 8 帧。软件回退的主题绘制 P95 仍约 304 ms,本轮未进一步调整其共有绘制成本。把 schema 查询人为延迟 2 秒时,约 765 ms 已显示载入界面,随后补齐选项;请求仅执行一次。 + +性能、原生界面与后台载入记录位于 `.artifacts/settings-performance-20261028/`,与此前验收资料分开保存。另保留主题键盘选取、配置写入与恢复检查,以及配对颜色、透明外角和选中标记的像素回归。 + ### 实现位置 | 内容 | 文件 | @@ -321,6 +344,8 @@ status { | 行内说明与标题字重 | `shell/ferese-settings/src/form_controls.rs` | | 日历设置与代码编辑器 | `shell/ferese-settings/src/calendar_controls.rs` | | 日历日期和星期显示 | `crates/ferese-theme/src/calendar.rs` | +| 原生主题预览绘制 | `crates/ferese-theme/src/gallery/preview.rs` | +| 按需 schema 载入与缓存 | `shell/ferese-settings/src/all_options.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 f5f8c8e..0d1ce59 100644 --- a/shell/ferese-settings/src/all_options.rs +++ b/shell/ferese-settings/src/all_options.rs @@ -7,6 +7,7 @@ use super::{ use ferese_config::locale::{text as t, translate as tr}; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; +use std::sync::Arc; pub const GROUPS: &[(&str, &[&str])] = &[ ("General", &["ui", "status"]), @@ -64,7 +65,9 @@ pub enum Input { pub struct State { pub filter: String, pub applying: bool, - schema: Value, + schema: Arc, + ready: bool, + loading: bool, baseline: Value, value: Value, base_source: String, @@ -74,31 +77,44 @@ pub struct State { error: Option, } impl State { - pub fn load(snapshot: &Snapshot) -> Self { - static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); - let result = CATALOG.get_or_init(load_schema).clone(); - let (schema, error) = match result { - Ok(v) => (v, None), - Err(e) => ( - Value::Null, - Some(format!("{}: {e}", t("Cannot load configuration schema"))), - ), - }; + pub fn pending(snapshot: &Snapshot) -> Self { let mut state = Self { filter: String::new(), applying: false, - schema, + schema: Arc::new(Value::Null), + ready: false, + loading: false, baseline: Value::Null, value: Value::Null, base_source: String::new(), expanded: HashSet::new(), text: HashMap::new(), keys: HashMap::new(), - error, + error: None, }; state.reset(snapshot); state } + + fn loaded(&mut self, result: Result, String>, snapshot: &Snapshot) { + self.loading = false; + match result { + Ok(schema) => { + self.schema = schema; + self.ready = true; + self.error = None; + // Use the current document, which may have changed while the + // schema worker was running. Keep the user's search filter. + self.reset(snapshot); + } + Err(error) => { + self.error = Some(format!( + "{}: {error}", + t("Cannot load configuration schema") + )) + } + } + } pub fn reset(&mut self, snapshot: &Snapshot) { self.applying = false; self.baseline = self.schema["defaults"].clone(); @@ -337,6 +353,17 @@ fn load_schema() -> Result { std::thread::sleep(Duration::from_millis(10)); } } + +fn catalog() -> Result, String> { + static CATALOG: std::sync::OnceLock> = std::sync::OnceLock::new(); + if let Some(catalog) = CATALOG.get() { + return Ok(catalog.clone()); + } + let schema = Arc::new(load_schema()?); + // Cache successful results only, so a missing backend can be retried. + let _ = CATALOG.set(schema.clone()); + Ok(schema) +} fn overlay(base: &mut Value, addition: &Value) { if let (Some(a), Some(b)) = (base.as_object_mut(), addition.as_object()) { for (k, v) in b { @@ -410,6 +437,25 @@ fn label(k: &str) -> String { } impl App { + pub(super) fn load_options(&mut self) -> Task { + if self.options.ready || self.options.loading { + return Task::none(); + } + self.options.loading = true; + self.options.error = None; + cosmic::task::future(async { + Message::OptionsLoaded( + tokio::task::spawn_blocking(catalog) + .await + .unwrap_or_else(|error| Err(error.to_string())), + ) + }) + } + + pub(super) fn options_loaded(&mut self, result: Result, String>) { + self.options.loaded(result, &self.current); + } + pub fn options_input(&mut self, input: Input) -> Task { let state = &mut self.options; match input { @@ -581,6 +627,25 @@ impl App { .into() } pub fn all_options_view(&self) -> Element<'_, Message> { + if !self.options.ready { + let mut content = column([]).spacing(10).push( + self.note( + self.options + .error + .as_deref() + .unwrap_or(t("Loading configuration options…")), + ), + ); + if !self.options.loading { + content = content.push(self.settings_button( + t("Retry"), + "M4 10a8 8 0 1 1 0 4 M4 4v6h6", + Some(Message::LoadOptions), + false, + )); + } + return content.into(); + } let mut out = column([]).spacing(10).push(self.note(t( "Advanced changes are saved together. Press Enter to confirm text fields, then Apply.", ))); @@ -1033,11 +1098,34 @@ fn variant_label(s: &Value) -> String { #[cfg(test)] mod tests { use super::*; + #[test] + fn delayed_schema_uses_latest_configuration_and_preserves_search() { + let original = Snapshot::parse("input { repeat-rate 25; }".into()).unwrap(); + let latest = Snapshot::parse("input { repeat-rate 80; }".into()).unwrap(); + let mut pending = State::pending(&original); + assert!(!pending.ready); + pending.filter = "keyboard".into(); + pending.loading = true; + pending.loaded( + Ok(Arc::new(json!({"defaults":{"input":{"repeat_rate":30}}}))), + &latest, + ); + assert_eq!(pending.value["input"]["repeat_rate"], 80); + assert_eq!(pending.filter, "keyboard"); + assert!(pending.ready); + assert!(!pending.loading); + assert!(!pending.dirty()); + } + fn state(snapshot: &Snapshot) -> State { let mut state = State { filter: String::new(), applying: false, - schema: json!({"defaults":{"ui":{"language":"auto"},"input":{"repeat_rate":25,"xkb_layout":"us"},"commands":{}}}), + schema: Arc::new( + json!({"defaults":{"ui":{"language":"auto"},"input":{"repeat_rate":25,"xkb_layout":"us"},"commands":{}}}), + ), + ready: true, + loading: false, baseline: Value::Null, value: Value::Null, base_source: String::new(), diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index b016ff0..f5851f2 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -114,6 +114,8 @@ enum InitialPage { #[derive(Clone, Debug)] enum Message { Options(all_options::Input), + LoadOptions, + OptionsLoaded(Result, String>), TrayApps(Vec), RefreshTrayApps, ReadRenderScale(cosmic::iced::window::Id), @@ -289,7 +291,7 @@ impl cosmic::Application for App { let native_palette = visuals::Palette::from_resolved(&resolved.presented); let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t); ferese_config::locale::set(ferese_config::locale::from_document(¤t.doc)); - let options = all_options::State::load(¤t); + let options = all_options::State::pending(¤t); let mut app = Self { tray_apps: Vec::new(), options, @@ -380,7 +382,15 @@ impl cosmic::Application for App { } else { Task::none() }; - (app, Task::batch([task, connections, thumbnails, displays])) + let options = if app.page == Page::Configuration { + app.load_options() + } else { + Task::none() + }; + ( + app, + Task::batch([task, connections, thumbnails, displays, options]), + ) } fn update(&mut self, message: Message) -> Task { @@ -388,6 +398,8 @@ impl cosmic::Application for App { Message::TrayApps(apps) => self.tray_apps = apps, Message::RefreshTrayApps => return self.load_tray_apps(), Message::Options(input) => return self.options_input(input), + Message::LoadOptions => return self.load_options(), + Message::OptionsLoaded(result) => self.options_loaded(result), Message::DragWindow => return self.core.drag(None), Message::ToggleMaximize => return self.core.toggle_maximize(None), Message::ReadRenderScale(id) => { @@ -580,6 +592,7 @@ impl cosmic::Application for App { Page::Displays | Page::Bar => self.load_displays(), Page::Wallpaper => self.load_thumbnail(), Page::Tray => self.load_tray_apps(), + Page::Configuration => self.load_options(), _ => Task::none(), }; return Task::batch([reset, cleanup, load]); @@ -596,6 +609,9 @@ impl cosmic::Application for App { if page == Page::Wallpaper { return Task::batch([reset, self.load_thumbnail()]); } + if page == Page::Configuration { + return Task::batch([reset, self.load_options()]); + } return reset; } @@ -643,7 +659,12 @@ impl cosmic::Application for App { } } } - Message::Search(query) => self.search = query, + Message::Search(query) => { + self.search = query; + if !self.search.is_empty() { + return self.load_options(); + } + } Message::Draft(path, value) => { self.inputs.insert(path, value); } @@ -1326,6 +1347,21 @@ mod tests { .0 } + #[test] + fn schema_loading_is_lazy_deduplicated_and_retries_after_failure() { + let mut app = app(); + assert_eq!(app.page, Page::Appearance); + assert_eq!(app.load_options().units(), 1); + assert_eq!(app.load_options().units(), 0); + app.options_loaded(Err("temporary backend failure".into())); + assert_eq!(app.load_options().units(), 1); + app.options_loaded(Ok(std::sync::Arc::new(serde_json::json!({ + "schema": {"properties": {}}, "defaults": {} + })))); + assert_eq!(app.load_options().units(), 0); + assert!(!app.options.dirty()); + } + #[test] fn header_drag_targets_the_main_window_and_handles_a_missing_window() { let mut app = app();