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;