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 -13
View File
@@ -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()))
+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:?}"
);
}
}
}
}
}