//! An expander: a titled row that shows or hides what is under it. //! //! Controlled, like `:checked` and a dialog's `:open` — the application owns //! whether it is open, the widget reports being asked to change, and the //! binding is what actually changes it. That is what lets a group of them //! behave as an accordion without an accordion widget: bind each `:open` to a //! comparison, exactly as a radio group binds `:checked`. //! //! Its content is *not* deferred. A menu's rows are built on open because a //! closed menu has no rows anywhere on screen and may never be opened; an //! expander's content is part of the page, is usually opened, and — unlike a //! popup — its height participates in the layout around it. Building it lazily //! would trade a real ambiguity about layout for a saving nobody asked for. use guiduck_scene::Fragment; use guiduck_scene::geom::{Point, Rect, Size}; use guiduck_scene::paint::{Brush, Color}; use taffy::AvailableSpace; use super::{Widget, is_transparent}; use crate::dirty::Dirt; use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent}; use crate::graphic::{Graphic, GraphicPaint}; use crate::style::{ComputedStyle, StyleReader}; use crate::text::{Paragraph, TextContext}; use crate::widget::CursorShape; /// Height of the title row; the content sits below it. const HEADER_H: f64 = 26.0; const MARK_SIZE: f64 = 10.0; const PAD: f64 = 6.0; pub struct Expander { open: bool, title: Paragraph, tokens: Tokens, dirt: Dirt, emitted: Vec, focused: bool, } impl Default for Expander { fn default() -> Self { Self { open: false, title: Paragraph::default(), tokens: Tokens::default(), dirt: Dirt::CLEAN, emitted: Vec::new(), focused: false, } } } impl Expander { pub fn new() -> Self { Self::default() } pub fn title(mut self, title: impl Into) -> Self { self.set_title(title); self } pub fn open(mut self, open: bool) -> Self { self.set_open(open); self } pub fn set_title(&mut self, title: impl Into) { if self .title .set_content(crate::text::RichText::new(title.into())) { self.dirt.mark_layout(); } } /// Silent, like every controlled setter: the binding is the truth and must /// not be able to make the widget report back at it. pub fn set_open(&mut self, open: bool) { if self.open != open { self.open = open; self.dirt.mark_layout(); } } pub fn is_open(&self) -> bool { self.open } /// The user asked to change it. A *request*, exactly as a dialog's Escape /// is: the application answers by flipping `:open`, so the binding stays /// the single truth and an accordion is expressible. fn request_toggle(&mut self) { self.emitted.push(EventData::Toggled(!self.open)); } } struct Tokens { background: Brush, color: Brush, corner_radius: f64, mark: Graphic, mark_color: Brush, focus_ring_color: Brush, focus_ring_width: f64, font_size: f32, family: Option, } impl Tokens { fn blank() -> Self { Self { background: Color::TRANSPARENT.into(), color: Color::TRANSPARENT.into(), corner_radius: 0.0, mark: Graphic::Path(Default::default()), mark_color: Color::TRANSPARENT.into(), focus_ring_color: Color::TRANSPARENT.into(), focus_ring_width: 0.0, font_size: 14.0, family: None, } } fn read(&mut self, style: &ComputedStyle) -> bool { let mut reader = StyleReader::new(style); reader.brush(&mut self.background, "background"); reader.brush(&mut self.color, "color"); reader.number(&mut self.corner_radius, "corner-radius"); reader.graphic(&mut self.mark, "expand-mark"); reader.brush(&mut self.mark_color, "expand-color"); reader.brush(&mut self.focus_ring_color, "focus-ring-color"); reader.number(&mut self.focus_ring_width, "focus-ring-width"); let mut size = self.font_size as f64; reader.number(&mut size, "font-size"); self.font_size = size as f32; let mut family = self.family.clone().unwrap_or_default(); reader.string(&mut family, "font-family"); if !family.is_empty() { self.family = Some(family); } reader.changed() } } impl Default for Tokens { fn default() -> Self { let mut tokens = Self::blank(); tokens.read(&super::fallback_style("expander")); tokens } } impl Widget for Expander { fn adjust_style(&self, style: &mut taffy::Style) { // The title row is the widget's own painting, so the content it // contains has to start below it — and vanish when closed, which taffy // expresses by hiding the children rather than by the widget lying // about its size. style.display = if self.open { taffy::Display::Flex } else { taffy::Display::Block }; style.flex_direction = taffy::FlexDirection::Column; style.padding.top = taffy::LengthPercentage::length(HEADER_H as f32); } fn measure( &mut self, _text: &mut TextContext, known: taffy::Size>, _available: taffy::Size, ) -> taffy::Size { taffy::Size { width: known.width.unwrap_or(0.0), height: known.height.unwrap_or(HEADER_H as f32), } } fn finalize_layout(&mut self, text: &mut TextContext, size: Size, _content: Size) { self.title.set_font_size(self.tokens.font_size); self.title.set_family(self.tokens.family.clone()); self.title.set_brush(self.tokens.color.clone()); self.title .layout_at(text, Some((size.width - PAD * 3.0 - MARK_SIZE) as f32)); } fn paint(&mut self, fragment: &mut Fragment, size: Size) { let header = Rect::new(0.0, 0.0, size.width, HEADER_H.min(size.height)); if self.focused && !is_transparent(&self.tokens.focus_ring_color) { let ring = header.inflate(self.tokens.focus_ring_width, self.tokens.focus_ring_width); fragment.fill( ring.to_rounded_rect(self.tokens.corner_radius + self.tokens.focus_ring_width), self.tokens.focus_ring_color.clone(), ); } fragment.fill( header.to_rounded_rect(self.tokens.corner_radius), self.tokens.background.clone(), ); { let mark = &self.tokens.mark; // The same mark, turned: pointing along the row when closed and // down into the content when open, so one graphic serves both and // a theme overriding it keeps that relationship. let cy = HEADER_H / 2.0; let rect = Rect::new( PAD, cy - MARK_SIZE / 2.0, PAD + MARK_SIZE, cy + MARK_SIZE / 2.0, ); let mut marked = Fragment::default(); mark.paint_into( &mut marked, Rect::new(0.0, 0.0, MARK_SIZE, MARK_SIZE), &GraphicPaint::Fill(self.tokens.mark_color.clone()), ); let turn = if self.open { guiduck_scene::geom::Affine::rotate_about( std::f64::consts::FRAC_PI_2, Point::new(MARK_SIZE / 2.0, MARK_SIZE / 2.0), ) } else { guiduck_scene::geom::Affine::IDENTITY }; fragment .push_transform(guiduck_scene::geom::Affine::translate((rect.x0, rect.y0)) * turn); fragment.items.extend(marked.items); fragment.pop_transform(); } let text_h = self.title.layout().map_or(0.0, |l| l.height() as f64); self.title.paint( fragment, Point::new(PAD * 2.0 + MARK_SIZE, (HEADER_H - text_h) / 2.0), ); } fn role(&self) -> accesskit::Role { // A disclosure is a button that says whether it is expanded — which is // exactly what accesskit's expanded flag on a button means. accesskit::Role::Button } fn accessibility(&self, node: &mut accesskit::Node) { node.set_label(self.title.text().to_owned()); node.set_expanded(self.open); } fn focusable(&self) -> bool { true } fn on_focus_changed(&mut self, focused: bool) { if self.focused != focused { self.focused = focused; self.dirt.mark_paint(); } } fn cursor(&self, local: Point) -> CursorShape { if local.y <= HEADER_H { CursorShape::Pointer } else { CursorShape::Default } } fn on_key( &mut self, key: &KeyInput, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { if matches!(key.key, Key::Enter) || key.key == Key::Character(" ".into()) { self.request_toggle(); return true; } false } fn on_pointer( &mut self, kind: EventKind, event: &PointerEvent, _text: &mut TextContext, _clipboard: &mut dyn crate::clipboard::Clipboard, ) -> bool { // Only the title row toggles: a click in the content belongs to the // content. if kind == EventKind::Click && event.local.y <= HEADER_H { self.request_toggle(); return true; } false } fn take_dirt(&mut self) -> Dirt { std::mem::take(&mut self.dirt) } fn take_emitted(&mut self) -> Vec { std::mem::take(&mut self.emitted) } fn type_name(&self) -> &'static str { "expander" } fn apply_style(&mut self, style: &ComputedStyle) { if self.tokens.read(style) { self.dirt.mark_layout(); } } } #[cfg(test)] mod tests;