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.": "请先保存工作。",
|
"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": "仅浅色"
|
||||||
}
|
}
|
||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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 视觉方案
|
# 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` |
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, ¤t);
|
let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t);
|
||||||
ferese_config::locale::set(ferese_config::locale::from_document(¤t.doc));
|
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 {
|
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();
|
||||||
|
|||||||
Reference in new issue
Block a user