use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree}; use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer}; use cosmic::iced::{Event, Length, Rectangle, Size, keyboard}; use cosmic::{Element, Renderer, Theme}; use ferese_config::families::{Family, Palette}; use ferese_config::theme::Appearance; use iced_accessibility::{A11yTree, accesskit}; pub fn tile( family: &Family, variant: Option, active: Option, selected: bool, palette: crate::Palette, font: cosmic::font::Font, id: Id, on_select: M, navigate: impl Fn(&keyboard::key::Named) -> Option + 'static, ) -> Element<'static, M> { use cosmic::widget::{button, column, row}; let mut caption = row([]) .spacing(6) .align_y(cosmic::iced::Alignment::Center) .push(crate::text(family.name.clone(), font).size(12)); 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(palette.muted)), ); } let image = preview(family, variant, active, selected); let image = cosmic::widget::responsive(move |size| image.clone().height(Length::Fixed(size.width * 126. / 200.)).into()) .height(Length::Shrink); let content = column([]).spacing(2).push(image).push(caption); let tile = button::custom(content) .id(id.clone()) .name(format!("{}, {}", family.name, family.availability())) .width(Length::Fill) .padding(8) .class(tile_style(palette, selected)) .on_press(on_select); radio(tile, id, selected, navigate) } fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button { let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style { background: Some(if hovered { palette.sidebar } else { palette.card }.into()), border_width: if selected { 2. } else { 0. }, border_color: palette.accent, border_radius: 10.into(), outline_width: if focused { 2. } else { 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, active: Option, selected: bool, ) -> cosmic::widget::icon::Icon { use cosmic::widget::icon; let key = format!("{family:?}/{variant:?}/{active:?}/{selected}"); static CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); let cache = CACHE.get_or_init(Default::default); let mut cache = cache.lock().unwrap(); let handle = cache .entry(key) .or_insert_with(|| icon::from_svg_bytes(svg(family, variant, active, selected).into_bytes()).symbolic(false)) .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#" "#, p.background, p.bar, p.muted, p.muted, p.surface, p.muted, p.text, p.accent ) } pub fn svg(family: &Family, variant: Option, active: Option, selected: bool) -> String { let mut result = String::from( r##" "##, ); 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!( "", 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!( "", 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##"{label}"##, x + width / 2)); } result.push_str(""); } if selected { let p = family .palette(active.unwrap_or(Appearance::Dark)) .or(family.dark.as_ref()) .or(family.light.as_ref()) .unwrap(); let foreground = ferese_config::theme::hex(ferese_config::theme::readable( [1.; 4], ferese_config::theme::rgba(&p.accent).unwrap(), 4.5, )); result.push_str(&format!(r#""#, p.accent)); } result.push_str(""); result } pub fn neighbor(index: usize, count: usize, key: &keyboard::key::Named) -> Option { 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; } } pub struct Radio<'a, M> { content: Element<'a, M>, id: Id, selected: bool, group: bool, label: String, navigate: Option Option + 'a>>, } pub fn radio<'a, M: Clone + 'a>( content: impl Into>, id: Id, selected: bool, navigate: impl Fn(&keyboard::key::Named) -> Option + 'a, ) -> Element<'a, M> { Element::new(Radio { content: content.into(), id, selected, group: false, label: String::new(), navigate: Some(Box::new(navigate)), }) } pub fn group<'a, M: Clone + 'a>(content: impl Into>, id: Id, label: &str) -> Element<'a, M> { Element::new(Radio { content: content.into(), id, selected: false, group: true, label: label.into(), navigate: None, }) } impl Widget for Radio<'_, M> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(Focus::default()) } fn children(&self) -> Vec { 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 { 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.selected { operation.focusable(Some(&self.id), layout.bounds(), tree.state.downcast_mut::()); } else { tree.state.downcast_mut::().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::().0 = self.selected && cursor.is_over(layout.bounds()); } } if !self.group && self.selected && tree.state.downcast_ref::().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.selected && tree.state.downcast_ref::().0 { use renderer::Renderer as _; renderer.fill_quad( renderer::Quad { bounds: layout.bounds(), border: cosmic::iced::Border { color: style.text_color, width: 2., radius: 10.into(), }, ..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)\"")); } }