Avoid cold theme preview rasterization and load settings schema asynchronously

This commit is contained in:
aria committed 2026-10-28 06:28:06 +09:00
1 parent 326465952a
commit 6671960d5e
6 files changed
+514 -35

No files matched your search

+5 -1
View File
@@ -1038,5 +1038,9 @@
"Save your work before continuing.": "请先保存工作。", "Save your work before continuing.": "请先保存工作。",
"{action} anyway": "仍然{action}", "{action} anyway": "仍然{action}",
"Power off": "关机", "Power off": "关机",
"Default output": "默认输出" "Default output": "默认输出",
"Loading configuration options…": "正在载入配置选项…",
"Retry": "重试",
"Dark only": "仅深色",
"Light only": "仅浅色"
} }
+5 -13
View File
@@ -6,6 +6,7 @@ use cosmic::{Element, Renderer, Theme};
use ferese_theme_model::Appearance; use ferese_theme_model::Appearance;
use ferese_theme_model::families::{Family, Palette}; use ferese_theme_model::families::{Family, Palette};
use iced_accessibility::{A11yTree, accesskit}; use iced_accessibility::{A11yTree, accesskit};
mod preview;
pub struct TileOptions { pub struct TileOptions {
pub variant: Option<Appearance>, pub variant: Option<Appearance>,
@@ -56,9 +57,9 @@ pub fn tile<M: Clone + 'static>(
caption = caption.push( caption = caption.push(
crate::text( crate::text(
if family.light.is_none() { if family.light.is_none() {
"Dark only" ferese_config::tr!("Dark only")
} else { } else {
"Light only" ferese_config::tr!("Light only")
}, },
font, font,
) )
@@ -66,23 +67,14 @@ pub fn tile<M: Clone + 'static>(
.class(cosmic::theme::Text::Color(label_color)), .class(cosmic::theme::Text::Color(label_color)),
); );
} }
let family_preview = family.clone();
let caption: Element<'static, M> = container(caption) let caption: Element<'static, M> = container(caption)
.padding(8) .padding(8)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.align_y(cosmic::iced::alignment::Vertical::Bottom) .align_y(cosmic::iced::alignment::Vertical::Bottom)
.into(); .into();
let image = cosmic::widget::responsive(move |size| { let image = preview::view(family, variant, active, selected);
let height = size.width * 126. / 200.; let content = cosmic::iced::widget::stack([image, caption]);
// 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 tile = button::custom(content) let tile = button::custom(content)
.id(id.clone()) .id(id.clone())
.name(format!("{}, {}", family.name, family.availability())) .name(format!("{}, {}", family.name, family.availability()))
+334
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">"#,
r#"<path d="M1 6l3 3 6-6" fill="none" stroke="white" stroke-width="2" "#,
r#"stroke-linecap="round" stroke-linejoin="round"/></svg>"#,
);
#[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<M: 'static>(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
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<Colors>,
dark: Option<Colors>,
variant: Option<Appearance>,
active: Option<Appearance>,
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<Colors>,
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<M> Widget<M, Theme, Renderer> for Preview {
fn size(&self) -> Size<Length> {
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<svg::Handle> = 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:?}"
);
}
}
}
}
}
+29 -4
View File
@@ -1,8 +1,8 @@
# Ferese Graphite 视觉方案 # Ferese Graphite 视觉方案
- 文档版本:1.3 - 文档版本:1.4
- 整理日期:2026-10-28 - 整理日期:2026-10-28
- 视觉基线:`1e34ec4`、`9908dde`;本版补充导航滚动、本地化、时间控制面板、活动窗口与可编辑祝日函数。 - 视觉基线:`1e34ec4`、`9908dde`、`3264659`;本版补充主题预览原生绘制与配置选项的后台载入。
- 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。 - 适用范围:Ferese 设置、Shell 共享控件、锁屏与原生 Rime 候选界面的主题协同。
## 1. 视觉目标 ## 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)。官方快照作为回归校验资料,日历运行时使用可编辑函数。 来源:[内阁府祝日资料](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. 动效原则 ## 8. 动效原则
保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。 保留现有动画与“减少动画”配置。动效服务于状态变化与空间关系,悬停和按下反馈保持简洁。
@@ -292,10 +299,10 @@ status {
| 验证项 | 结果 | | 验证项 | 结果 |
| --- | --- | | --- | --- |
| 设置测试 | 120 项通过,9 项可选检查默认跳过 | | 设置测试 | 122 项通过,9 项可选检查默认跳过 |
| 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 | | 滚动渲染专项 | 额外运行 1 项通过,覆盖 WGPU / tiny-skia 与两种主题配置 |
| 配置测试 | 51 项单元测试及 11 项集成测试通过 | | 配置测试 | 51 项单元测试及 11 项集成测试通过 |
| 共享主题 / 面板 / Shell | 33 / 69 / 253 项通过 | | 共享主题 / 面板 / Shell | 34 / 69 / 253 项通过 |
| 布局检查 | 覆盖 480–1800 px 窗口宽度 | | 布局检查 | 覆盖 480–1800 px 窗口宽度 |
| 原生预览 | 已检查深浅外观及 631 px 窄窗口 | | 原生预览 | 已检查深浅外观及 631 px 窄窗口 |
| 导航居中 | 全部 16 项;包含独立滚动条通道检查 | | 导航居中 | 全部 16 项;包含独立滚动条通道检查 |
@@ -306,9 +313,25 @@ status {
| 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 | | 活动窗口区 | 位于右侧托盘左边;三个窗口点击、相邻图标切换、跨工作区激活、关闭移除与重新打开均通过 |
| 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 | | 祝日函数 | 原生编辑并应用函数可改变日历;无限循环被拒绝且保存配置不变,恢复内置函数后配置校验通过 |
| 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 | | 通知标记 | 独立预览中创建并移除测试通知,检查白色与透明状态,点击区域宽度保持不变 |
| 原生主题预览 | 检查配对颜色、透明外角和选中标记图层顺序;保留比例与键盘导航 |
| 后台 schema | 将查询人为延迟 2 秒,窗口提前显示载入状态;结果完成后显示选项,查询只执行一次;覆盖失败重试与最新配置接续 |
滚动渲染专项、边缘双击与窄窗口抽屉的记录沿用前版 `9908dde`。本版原生检查使用独立嵌套预览,保留原预览中的应用;没有重新执行关机、挂起或切换实际桌面会话。主机显示器、默认应用关联和 GNOME 输入源配置与开始时一致。 滚动渲染专项、边缘双击与窄窗口抽屉的记录沿用前版 `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/form_controls.rs` |
| 日历设置与代码编辑器 | `shell/ferese-settings/src/calendar_controls.rs` | | 日历设置与代码编辑器 | `shell/ferese-settings/src/calendar_controls.rs` |
| 日历日期和星期显示 | `crates/ferese-theme/src/calendar.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` | | 祝日函数执行与校验 | `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` |
+102 -14
View File
@@ -7,6 +7,7 @@ use super::{
use ferese_config::locale::{text as t, translate as tr}; use ferese_config::locale::{text as t, translate as tr};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc;
pub const GROUPS: &[(&str, &[&str])] = &[ pub const GROUPS: &[(&str, &[&str])] = &[
("General", &["ui", "status"]), ("General", &["ui", "status"]),
@@ -64,7 +65,9 @@ pub enum Input {
pub struct State { pub struct State {
pub filter: String, pub filter: String,
pub applying: bool, pub applying: bool,
schema: Value, schema: Arc<Value>,
ready: bool,
loading: bool,
baseline: Value, baseline: Value,
value: Value, value: Value,
base_source: String, base_source: String,
@@ -74,31 +77,44 @@ pub struct State {
error: Option<String>, error: Option<String>,
} }
impl State { impl State {
pub fn load(snapshot: &Snapshot) -> Self { pub fn pending(snapshot: &Snapshot) -> Self {
static CATALOG: std::sync::OnceLock<Result<Value, String>> = 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"))),
),
};
let mut state = Self { let mut state = Self {
filter: String::new(), filter: String::new(),
applying: false, applying: false,
schema, schema: Arc::new(Value::Null),
ready: false,
loading: false,
baseline: Value::Null, baseline: Value::Null,
value: Value::Null, value: Value::Null,
base_source: String::new(), base_source: String::new(),
expanded: HashSet::new(), expanded: HashSet::new(),
text: HashMap::new(), text: HashMap::new(),
keys: HashMap::new(), keys: HashMap::new(),
error, error: None,
}; };
state.reset(snapshot); state.reset(snapshot);
state state
} }
fn loaded(&mut self, result: Result<Arc<Value>, 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) { pub fn reset(&mut self, snapshot: &Snapshot) {
self.applying = false; self.applying = false;
self.baseline = self.schema["defaults"].clone(); self.baseline = self.schema["defaults"].clone();
@@ -337,6 +353,17 @@ fn load_schema() -> Result<Value, String> {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
} }
fn catalog() -> Result<Arc<Value>, String> {
static CATALOG: std::sync::OnceLock<Arc<Value>> = 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) { fn overlay(base: &mut Value, addition: &Value) {
if let (Some(a), Some(b)) = (base.as_object_mut(), addition.as_object()) { if let (Some(a), Some(b)) = (base.as_object_mut(), addition.as_object()) {
for (k, v) in b { for (k, v) in b {
@@ -410,6 +437,25 @@ fn label(k: &str) -> String {
} }
impl App { impl App {
pub(super) fn load_options(&mut self) -> Task<Message> {
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<Arc<Value>, String>) {
self.options.loaded(result, &self.current);
}
pub fn options_input(&mut self, input: Input) -> Task<Message> { pub fn options_input(&mut self, input: Input) -> Task<Message> {
let state = &mut self.options; let state = &mut self.options;
match input { match input {
@@ -581,6 +627,25 @@ impl App {
.into() .into()
} }
pub fn all_options_view(&self) -> Element<'_, Message> { 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( let mut out = column([]).spacing(10).push(self.note(t(
"Advanced changes are saved together. Press Enter to confirm text fields, then Apply.", "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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 { fn state(snapshot: &Snapshot) -> State {
let mut state = State { let mut state = State {
filter: String::new(), filter: String::new(),
applying: false, 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, baseline: Value::Null,
value: Value::Null, value: Value::Null,
base_source: String::new(), base_source: String::new(),
+39 -3
View File
@@ -114,6 +114,8 @@ enum InitialPage {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
Options(all_options::Input), Options(all_options::Input),
LoadOptions,
OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>),
TrayApps(Vec<tray_controls::Application>), TrayApps(Vec<tray_controls::Application>),
RefreshTrayApps, RefreshTrayApps,
ReadRenderScale(cosmic::iced::window::Id), ReadRenderScale(cosmic::iced::window::Id),
@@ -289,7 +291,7 @@ impl cosmic::Application for App {
let native_palette = visuals::Palette::from_resolved(&resolved.presented); let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let family_sections = theme_controls::FamilySections::new(&resolved.families, &current); let family_sections = theme_controls::FamilySections::new(&resolved.families, &current);
ferese_config::locale::set(ferese_config::locale::from_document(&current.doc)); ferese_config::locale::set(ferese_config::locale::from_document(&current.doc));
let options = all_options::State::load(&current); let options = all_options::State::pending(&current);
let mut app = Self { let mut app = Self {
tray_apps: Vec::new(), tray_apps: Vec::new(),
options, options,
@@ -380,7 +382,15 @@ impl cosmic::Application for App {
} else { } else {
Task::none() 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<Message> { fn update(&mut self, message: Message) -> Task<Message> {
@@ -388,6 +398,8 @@ impl cosmic::Application for App {
Message::TrayApps(apps) => self.tray_apps = apps, Message::TrayApps(apps) => self.tray_apps = apps,
Message::RefreshTrayApps => return self.load_tray_apps(), Message::RefreshTrayApps => return self.load_tray_apps(),
Message::Options(input) => return self.options_input(input), 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::DragWindow => return self.core.drag(None),
Message::ToggleMaximize => return self.core.toggle_maximize(None), Message::ToggleMaximize => return self.core.toggle_maximize(None),
Message::ReadRenderScale(id) => { Message::ReadRenderScale(id) => {
@@ -580,6 +592,7 @@ impl cosmic::Application for App {
Page::Displays | Page::Bar => self.load_displays(), Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(), Page::Wallpaper => self.load_thumbnail(),
Page::Tray => self.load_tray_apps(), Page::Tray => self.load_tray_apps(),
Page::Configuration => self.load_options(),
_ => Task::none(), _ => Task::none(),
}; };
return Task::batch([reset, cleanup, load]); return Task::batch([reset, cleanup, load]);
@@ -596,6 +609,9 @@ impl cosmic::Application for App {
if page == Page::Wallpaper { if page == Page::Wallpaper {
return Task::batch([reset, self.load_thumbnail()]); return Task::batch([reset, self.load_thumbnail()]);
} }
if page == Page::Configuration {
return Task::batch([reset, self.load_options()]);
}
return reset; 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) => { Message::Draft(path, value) => {
self.inputs.insert(path, value); self.inputs.insert(path, value);
} }
@@ -1326,6 +1347,21 @@ mod tests {
.0 .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] #[test]
fn header_drag_targets_the_main_window_and_handles_a_missing_window() { fn header_drag_targets_the_main_window_and_handles_a_missing_window() {
let mut app = app(); let mut app = app();