Avoid cold theme preview rasterization and load settings schema asynchronously
This commit is contained in:
1 parent
326465952a
commit
6671960d5e
6 files changed
+514
-35
No files matched your search
@@ -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": "仅浅色"
|
||||
}
|
||||
@@ -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<Appearance>,
|
||||
@@ -56,9 +57,9 @@ pub fn tile<M: Clone + 'static>(
|
||||
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<M: Clone + 'static>(
|
||||
.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()))
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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<Value>,
|
||||
ready: bool,
|
||||
loading: bool,
|
||||
baseline: Value,
|
||||
value: Value,
|
||||
base_source: String,
|
||||
@@ -74,31 +77,44 @@ pub struct State {
|
||||
error: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
pub fn load(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"))),
|
||||
),
|
||||
};
|
||||
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<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) {
|
||||
self.applying = false;
|
||||
self.baseline = self.schema["defaults"].clone();
|
||||
@@ -337,6 +353,17 @@ fn load_schema() -> Result<Value, String> {
|
||||
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) {
|
||||
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<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> {
|
||||
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(),
|
||||
|
||||
@@ -114,6 +114,8 @@ enum InitialPage {
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
Options(all_options::Input),
|
||||
LoadOptions,
|
||||
OptionsLoaded(Result<std::sync::Arc<serde_json::Value>, String>),
|
||||
TrayApps(Vec<tray_controls::Application>),
|
||||
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<Message> {
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user