Files
feresaria/crates/ferese-theme/src/gallery.rs
T

611 lines
20 KiB
Rust

use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree};
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer};
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Event, Length, Rectangle, Size, keyboard};
use cosmic::{Element, Renderer, Theme};
use ferese_theme_model::Appearance;
use ferese_theme_model::families::{Family, Palette};
use iced_accessibility::{A11yTree, accesskit};
pub struct TileOptions {
pub variant: Option<Appearance>,
pub active: Option<Appearance>,
pub selected: bool,
pub tab_stop: bool,
pub palette: crate::Palette,
pub font: cosmic::font::Font,
pub id: Id,
}
/// Shows a theme family using its validated appearance palettes.
///
/// # Panics
/// The family must contain a usable palette with valid hexadecimal colors.
pub fn tile<M: Clone + 'static>(
family: &Family,
options: TileOptions,
on_select: M,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'static,
) -> Element<'static, M> {
let TileOptions {
variant,
active,
selected,
tab_stop,
palette,
font,
id,
} = options;
use cosmic::widget::{button, container, row};
let label_color = family
.palette(variant.unwrap_or(Appearance::Light))
.or(family.dark.as_ref())
.map(|palette| {
crate::parse_color(&palette.text).expect("theme family text is a validated color")
})
.unwrap_or(palette.text);
let mut caption = row([])
.spacing(6)
.align_y(cosmic::iced::Alignment::Center)
.push(
crate::text(family.name.clone(), font)
.size(12)
.class(cosmic::theme::Text::Color(label_color)),
);
if family.light.is_none() || family.dark.is_none() {
caption = caption.push(
crate::text(
if family.light.is_none() {
"Dark only"
} else {
"Light only"
},
font,
)
.size(10)
.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 tile = button::custom(content)
.id(id.clone())
.name(format!("{}, {}", family.name, family.availability()))
.width(Length::Fill)
.padding(0)
.class(tile_style(palette, selected))
.on_press(on_select);
radio_with_tab_stop(tile, id, selected, tab_stop, navigate)
}
fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button {
let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style {
shape: Some(BorderShape::Continuous),
outline: None,
background: None,
border_width: if selected || focused {
2.
} else if hovered {
1.
} else {
0.
},
border_color: palette.accent,
border_radius: 10.into(),
outline_width: 0.,
outline_color: palette.text,
text_color: Some(palette.text),
icon_color: Some(palette.text),
..Default::default()
};
cosmic::theme::Button::Custom {
active: Box::new(move |focused, _| style(focused, false)),
hovered: Box::new(move |focused, _| style(focused, true)),
pressed: Box::new(move |focused, _| style(focused, true)),
disabled: Box::new(move |_| style(false, false)),
}
}
pub fn preview(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> cosmic::widget::icon::Icon {
preview_with_radius(family, variant, active, selected, 10.)
}
fn preview_with_radius(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
radius: f32,
) -> cosmic::widget::icon::Icon {
use cosmic::widget::icon;
let key = format!(
"{}/{variant:?}/{active:?}/{selected}/{}",
family.id,
radius.to_bits()
);
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, (Family, icon::Handle)>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let mut cache = cache
.lock()
.expect("theme preview cache must not be used after a mutator panics");
if cache.get(&key).is_none_or(|(cached, _)| cached != family) {
let handle = icon::from_svg_bytes(
svg_with_radius(family, variant, active, selected, radius).into_bytes(),
)
.symbolic(false);
cache.insert(key.clone(), (family.clone(), handle));
}
let handle = cache[&key].1.clone();
// Bound cached previews when users repeatedly edit imported palettes.
if cache.len() > 128 {
cache.clear();
}
handle
.icon()
.width(Length::Fill)
.height(Length::Fixed(112.))
.content_fit(cosmic::iced::ContentFit::Fill)
}
fn desktop(p: &Palette) -> String {
format!(
r#"<rect width="200" height="126" fill="{}"/>
<rect x="8" y="8" width="184" height="14" rx="4" fill="{}" stroke="{}" stroke-opacity=".18"/>
<path d="M15 15h10m145 0h15" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="24" y="35" width="152" height="77" rx="7" fill="{}"/>
<path d="M24 51h152" stroke="{}" stroke-opacity=".22"/>
<path d="M35 44h34M36 63h105m-105 10h90m-90 10h115" stroke="{}" stroke-width="2" stroke-linecap="round"/>
<rect x="76" y="92" width="48" height="10" rx="3" fill="{}"/>"#,
p.background, p.bar, p.muted, p.muted, p.surface, p.muted, p.text, p.accent
)
}
#[must_use]
pub fn svg(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
) -> String {
svg_with_radius(family, variant, active, selected, 10.)
}
fn svg_with_radius(
family: &Family,
variant: Option<Appearance>,
active: Option<Appearance>,
selected: bool,
radius: f32,
) -> String {
let mut result = String::from(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="126" viewBox="0 0 200 126"><defs>
<clipPath id="light"><rect width="100" height="126"/></clipPath><clipPath id="dark"><rect x="100" width="100" height="126"/></clipPath>
<pattern id="missing" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="#D9DDE2"/><path d="M-2 2l4-4M0 8l8-8M6 10l4-4" stroke="#C4C9D1"/></pattern>"##,
);
let outline = cosmic::iced::border::Outline::new(
[0., 0., 200., 126.],
[f64::from(radius); 4],
BorderShape::Continuous,
)
.expect("finite preview dimensions");
// Rasterized once per cached preview; no shaped rendering layer per tile.
let points = outline.polygon(0.02).expect("positive contour tolerance");
result.push_str("<clipPath id=\"preview\"><polygon points=\"");
for [x, y] in points {
use std::fmt::Write as _;
write!(result, "{x:.4},{y:.4} ").expect("formatting into an owned String cannot fail");
}
result.push_str("\"/></clipPath></defs><g clip-path=\"url(#preview)\">");
for appearance in [Appearance::Light, Appearance::Dark] {
if variant.is_some_and(|variant| variant != appearance) {
continue;
}
let clip = match (variant, appearance) {
(None, Appearance::Light) => "light",
(None, Appearance::Dark) => "dark",
_ => "",
};
result.push_str(&format!(
"<g{}>",
if clip.is_empty() {
String::new()
} else {
format!(" clip-path=\"url(#{clip})\"")
}
));
if let Some(palette) = family.palette(appearance) {
result.push_str(&desktop(palette));
if active == Some(appearance) {
let (x, width) = if variant.is_some() {
(8, 184)
} else if appearance == Appearance::Light {
(8, 84)
} else {
(108, 84)
};
result.push_str(&format!(
"<rect x=\"{x}\" y=\"120\" width=\"{width}\" height=\"3\" rx=\"1.5\" fill=\"{}\"/>",
palette.accent
));
}
} else {
let x = if variant.is_some() || appearance == Appearance::Light {
0
} else {
100
};
let width = if variant.is_some() { 200 } else { 100 };
let label = if appearance == Appearance::Light {
"No light variant"
} else {
"No dark variant"
};
result.push_str(&format!(r##"<rect x="{x}" width="{width}" height="126" fill="url(#missing)"/><text x="{}" y="68" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#353B45">{label}</text>"##, x + width / 2));
}
result.push_str("</g>");
}
if selected {
let p = family
.palette(active.unwrap_or(Appearance::Dark))
.or(family.dark.as_ref())
.or(family.light.as_ref())
.expect("a theme family provides at least one appearance palette");
let foreground = ferese_theme_model::hex(ferese_theme_model::readable(
[1.; 4],
ferese_theme_model::rgba(&p.accent)
.expect("resolved theme colors must be valid hexadecimal RGBA"),
4.5,
));
result.push_str(&format!(r#"<circle cx="183" cy="16" r="10" fill="{}"/><path d="M178 16l3 3 6-6" fill="none" stroke="{foreground}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>"#, p.accent));
}
result.push_str("</g></svg>");
result
}
#[must_use]
pub fn neighbor(index: usize, count: usize, key: &keyboard::key::Named) -> Option<usize> {
use keyboard::key::Named::*;
if count == 0 {
return None;
}
Some(match key {
ArrowRight => (index + 1) % count,
ArrowLeft => (index + count - 1) % count,
ArrowDown => {
if index + 3 < count {
index + 3
} else {
index % 3
}
}
ArrowUp => {
if index >= 3 {
index - 3
} else {
((count - 1 - index) / 3) * 3 + index
}
}
Enter => index,
Home => 0,
End => count - 1,
_ => return None,
})
}
#[derive(Default)]
struct Focus(bool);
impl cosmic::iced::advanced::widget::operation::Focusable for Focus {
fn is_focused(&self) -> bool {
self.0
}
fn focus(&mut self) {
self.0 = true;
}
fn unfocus(&mut self) {
self.0 = false;
}
}
type Navigation<'a, M> = Box<dyn Fn(&keyboard::key::Named) -> Option<M> + 'a>;
pub struct Radio<'a, M> {
content: Element<'a, M>,
id: Id,
selected: bool,
tab_stop: bool,
group: bool,
label: String,
navigate: Option<Navigation<'a, M>>,
}
pub fn radio<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
selected: bool,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'a,
) -> Element<'a, M> {
radio_with_tab_stop(content, id, selected, selected, navigate)
}
fn radio_with_tab_stop<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
selected: bool,
tab_stop: bool,
navigate: impl Fn(&keyboard::key::Named) -> Option<M> + 'a,
) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected,
tab_stop,
group: false,
label: String::new(),
navigate: Some(Box::new(navigate)),
})
}
pub fn group<'a, M: Clone + 'a>(
content: impl Into<Element<'a, M>>,
id: Id,
label: &str,
) -> Element<'a, M> {
Element::new(Radio {
content: content.into(),
id,
selected: false,
tab_stop: false,
group: true,
label: label.into(),
navigate: None,
})
}
impl<M: Clone> Widget<M, Theme, Renderer> for Radio<'_, M> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Focus>()
}
fn state(&self) -> tree::State {
tree::State::new(Focus::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation<()>,
) {
if self.group {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
} else if self.tab_stop {
operation.focusable(
Some(&self.id),
layout.bounds(),
tree.state.downcast_mut::<Focus>(),
);
} else {
tree.state.downcast_mut::<Focus>().0 = false;
}
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
if !self.group {
if let Event::A11y(id, request) = event {
if !iced_accessibility::IdEq::eq(&id.0, &self.id.0) {
return;
}
if matches!(
request.action,
accesskit::Action::Focus | accesskit::Action::Click
) && let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
tree.state.downcast_mut::<Focus>().0 =
self.tab_stop && cursor.is_over(layout.bounds());
}
}
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(key),
..
}) = event
&& key.as_str() == " "
&& let Some(message) = self
.navigate
.as_ref()
.and_then(|navigate| navigate(&keyboard::key::Named::Enter))
{
shell.publish(message);
shell.capture_event();
return;
}
if let Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
..
}) = event
&& let Some(message) = self.navigate.as_ref().and_then(|navigate| navigate(key))
{
shell.publish(message);
shell.capture_event();
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if !self.group && self.tab_stop && tree.state.downcast_ref::<Focus>().0 {
use renderer::Renderer as _;
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: cosmic::iced::Border {
shape: BorderShape::Continuous,
color: style.text_color,
width: 2.,
radius: 10.into(),
..Default::default()
},
..Default::default()
},
cosmic::iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn a11y_nodes(&self, layout: Layout<'_>, tree: &Tree, cursor: mouse::Cursor) -> A11yTree {
let children = self
.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor);
if self.group {
let mut node = accesskit::Node::new(accesskit::Role::RadioGroup);
node.set_label(self.label.clone());
A11yTree::node_with_child_tree(
iced_accessibility::A11yNode::new(node, self.id.clone()),
children,
)
} else {
let mut children = children;
for node in children.root_mut() {
node.node_mut().set_role(accesskit::Role::RadioButton);
node.node_mut().set_toggled(if self.selected {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
});
}
children
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arrow_navigation_wraps_rows_and_columns() {
use keyboard::key::Named::*;
assert_eq!(neighbor(0, 6, &ArrowLeft), Some(5));
assert_eq!(neighbor(1, 6, &ArrowUp), Some(4));
assert_eq!(neighbor(4, 6, &ArrowDown), Some(1));
assert_eq!(neighbor(5, 6, &Home), Some(0));
assert_eq!(neighbor(0, 7, &End), Some(6));
}
#[test]
fn paired_previews_share_one_window_across_the_seam() {
let family = ferese_config::families::builtins().remove(1);
let svg = svg(&family, None, Some(Appearance::Dark), true);
assert_eq!(svg.matches("x=\"24\" y=\"35\"").count(), 2);
assert!(svg.contains("clip-path=\"url(#light)\""));
assert!(svg.contains("clip-path=\"url(#dark)\""));
}
}