expander.rs raw

//! 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<EventData>,
    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<String>) -> 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<String>) {
        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<String>,
}

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<Option<f32>>,
        _available: taffy::Size<AvailableSpace>,
    ) -> taffy::Size<f32> {
        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<EventData> {
        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;