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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user