use alloc::string::String;
use alloc::vec::Vec;
use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
use denise_text::TextStyle;
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
use crate::{NodeId, Ui};
pub const FOLD_MS: u64 = 200;
#[derive(Clone, Debug)]
pub struct Collapse<M> {
title: String,
open: bool,
expanded: Option<i32>,
message: Option<fn(bool) -> M>,
role: Role,
style: TextStyle,
}
impl<M> Collapse<M> {
pub fn new(title: impl Into<String>, message: fn(bool) -> M) -> Self {
Self {
title: title.into(),
open: true,
expanded: None,
message: Some(message),
role: Role::Base200,
style: TextStyle::built_in(16),
}
}
pub fn inert(title: impl Into<String>) -> Self {
Self {
title: title.into(),
open: true,
expanded: None,
message: None,
role: Role::Base200,
style: TextStyle::built_in(16),
}
}
pub fn closed(mut self) -> Self {
self.open = false;
self
}
pub fn with_expanded_height(mut self, height: i32) -> Self {
self.expanded = Some(height.max(0));
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub const fn is_open(&self) -> bool {
self.open
}
pub fn header_height(&self, theme: &Theme) -> i32 {
theme.metrics.size_field.max(1)
}
pub fn set_open_silent(&mut self, open: bool) {
self.open = open;
}
#[inline]
pub const fn expanded_height(&self) -> Option<i32> {
self.expanded
}
pub fn set_expanded_height(&mut self, height: i32) {
self.expanded = Some(height.max(0));
}
}
pub fn set_open<M: 'static>(ui: &mut Ui<M>, id: NodeId, open: bool, duration_ms: u64) {
let Some(layout) = ui.layout(id) else {
return;
};
let theme = *ui.theme();
let Some(collapse) = ui.widget_mut::<Collapse<M>>(id) else {
return;
};
let header = collapse.header_height(&theme);
let target = if open {
collapse.expanded.unwrap_or(header)
} else {
collapse.set_expanded_height(layout.height);
header
};
collapse.set_open_silent(open);
ui.animate_layout(
id,
Rect::new(layout.x, layout.y, layout.width, target),
duration_ms,
);
}
#[derive(Clone, Debug)]
pub struct Accordion {
sections: Vec<NodeId>,
open: Option<usize>,
duration_ms: u64,
}
impl Accordion {
pub fn new(sections: impl IntoIterator<Item = NodeId>) -> Self {
Self {
sections: sections.into_iter().collect(),
open: None,
duration_ms: FOLD_MS,
}
}
pub fn with_duration(mut self, duration_ms: u64) -> Self {
self.duration_ms = duration_ms;
self
}
#[inline]
pub const fn open(&self) -> Option<usize> {
self.open
}
pub fn collapse_all<M: 'static>(&mut self, ui: &mut Ui<M>) {
for §ion in &self.sections {
set_open(ui, section, false, self.duration_ms);
}
self.open = None;
}
pub fn toggle<M: 'static>(&mut self, ui: &mut Ui<M>, index: usize) {
if index >= self.sections.len() {
return;
}
if self.open == Some(index) {
set_open(ui, self.sections[index], false, self.duration_ms);
self.open = None;
return;
}
if let Some(current) = self.open {
set_open(ui, self.sections[current], false, self.duration_ms);
}
set_open(ui, self.sections[index], true, self.duration_ms);
self.open = Some(index);
}
}
fn chevron(canvas: &mut Pen<'_>, centre: Point, arm: i32, open: bool, color: denise::Color) {
let a = arm.max(2);
if open {
canvas.draw_line(
Point::new(centre.x - a, centre.y - a / 2),
Point::new(centre.x, centre.y + a / 2),
color,
);
canvas.draw_line(
Point::new(centre.x + a, centre.y - a / 2),
Point::new(centre.x, centre.y + a / 2),
color,
);
} else {
canvas.draw_line(
Point::new(centre.x - a / 2, centre.y - a),
Point::new(centre.x + a / 2, centre.y),
color,
);
canvas.draw_line(
Point::new(centre.x - a / 2, centre.y + a),
Point::new(centre.x + a / 2, centre.y),
color,
);
}
}
impl<M: 'static> Widget<M> for Collapse<M> {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
let header = self.header_height(ctx.theme);
let body = if self.is_open() {
self.expanded_height().unwrap_or(0)
} else {
0
};
Measured::tall(header.saturating_add(body))
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let header = Rect::new(
bounds.x,
bounds.y,
bounds.width,
self.header_height(ctx.theme).min(bounds.height),
);
let radius = ctx.theme.radius(Radius::Field);
let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
canvas.fill_rounded_rect(header, radius, fill);
let pad = (self.style.size_px as i32 / 2).max(4);
let arm = (header.height / 6).max(3);
chevron(
canvas,
Point::new(header.x + pad + arm, header.y + header.height / 2),
arm,
self.open,
content,
);
let title_box = Rect::new(
header.x + pad * 2 + arm * 2,
header.y,
(header.width - pad * 3 - arm * 2).max(0),
header.height,
);
if !title_box.is_empty() && !self.title.is_empty() {
let mut clipped = canvas.with_clip(title_box);
draw_aligned(
&mut clipped,
ctx.text,
self.style,
title_box,
(Align::Start, Align::Center),
&self.title,
content,
);
}
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(ctx.theme, header, radius, canvas);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let header = Rect::new(
ctx.bounds.x,
ctx.bounds.y,
ctx.bounds.width,
self.header_height(ctx.theme).min(ctx.bounds.height),
);
let toggle = match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
})
| Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => header.contains(*position),
Event::Input(InputEvent::Key {
code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space,
state: ElementState::Down,
repeat,
..
}) if ctx.state.contains(VisualState::FOCUSED) => {
!repeat
}
_ => return Handled::No,
};
if !toggle {
return Handled::No;
}
self.open = !self.open;
match self.message {
Some(message) => ctx.emit(message(self.open)),
None => {
let header = self.header_height(ctx.theme);
let target = if self.open {
self.expanded.unwrap_or(header)
} else {
self.expanded = Some(ctx.bounds.height);
header
};
ctx.resize_height(target, FOLD_MS);
}
}
Handled::Yes
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
true
}
}
impl<M> Describe for Collapse<M> {
const KIND: &'static str = "collapse";
const DOC: &'static str = "A section that folds away to its header and opens again.";
const GROUP: Group = Group::Container;
const ICON: &'static denise::icon::Icon = &super::icons::COLLAPSE;
const PROPERTIES: &'static [Property] = &[
Property::new(
"text",
PropertyKind::Text,
"The header's title. Named as `button` and `label` name theirs, because a form writes it the same way: as the node's first argument.",
),
Property::new(
"open",
PropertyKind::Bool,
"Whether the section is unfolded.",
),
Property::new(
"expanded-height",
PropertyKind::Int { min: 0, max: 4096 },
"The content's height when open; measured from the children without it.",
)
.in_pixels(),
Property::new(
"on-toggle",
PropertyKind::Message(Payload::Bool),
"Emitted with the new state when the header is pressed. The application answers with `set_open`, which is what actually folds the node.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role the header strip is filled with.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Title size in logical pixels.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"text" => Value::text(self.title.as_str()),
"open" => Value::Bool(self.open),
"expanded-height" => Value::Int(self.expanded?),
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"text" => self.title = value.as_text()?,
"open" => self.set_open_silent(value.as_bool()?),
"expanded-height" => self.set_expanded_height(value.as_int()?),
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
"on-toggle" => return Err(Mismatch::Supplied),
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::{ElementState, InputEvent, Modifiers, PointerButton, Size, theme};
use crate::Ui;
#[test]
fn an_inert_section_folds_and_opens_and_reports_nothing() {
#[derive(Clone, Copy, Debug, PartialEq)]
struct Never;
let mut ui: Ui<Never> = Ui::new(Size::new(400, 300), theme::DARK);
let root = ui.root();
let id = ui
.add(root, Collapse::inert("Avansert"), Rect::new(0, 0, 200, 120))
.expect("a root takes children");
let press = |ui: &mut Ui<Never>| {
let at = Point::new(20, 8);
ui.handle(&[
InputEvent::PointerMoved { position: at },
InputEvent::PointerButton {
button: PointerButton::Left,
state: ElementState::Down,
position: at,
modifiers: Modifiers::NONE,
},
InputEvent::PointerButton {
button: PointerButton::Left,
state: ElementState::Up,
position: at,
modifiers: Modifiers::NONE,
},
]);
};
let settle = |ui: &mut Ui<Never>, from: u64| {
for step in 0..=4 {
ui.tick(from + step * FOLD_MS / 2);
}
};
let open_height = ui.layout(id).expect("laid out").height;
press(&mut ui);
assert!(
ui.drain_messages().next().is_none(),
"an inert section emitted something"
);
settle(&mut ui, 0);
let header = ui
.widget::<Collapse<Never>>(id)
.expect("a collapse")
.header_height(&theme::DARK);
assert_eq!(
ui.layout(id).expect("laid out").height,
header,
"it did not fold to its header"
);
assert!(
!ui.widget::<Collapse<Never>>(id)
.expect("a collapse")
.is_open()
);
press(&mut ui);
settle(&mut ui, 10 * FOLD_MS);
assert_eq!(
ui.layout(id).expect("laid out").height,
open_height,
"opening it again did not return to the height it folded from"
);
assert!(
ui.widget::<Collapse<Never>>(id)
.expect("a collapse")
.is_open()
);
}
#[test]
fn a_section_with_a_message_still_waits_to_be_told() {
let mut ui: Ui<bool> = Ui::new(Size::new(400, 300), theme::DARK);
let root = ui.root();
let id = ui
.add(
root,
Collapse::new("Nettverk", |open| open),
Rect::new(0, 0, 200, 120),
)
.expect("a root takes children");
let at = Point::new(20, 8);
ui.handle(&[
InputEvent::PointerMoved { position: at },
InputEvent::PointerButton {
button: PointerButton::Left,
state: ElementState::Down,
position: at,
modifiers: Modifiers::NONE,
},
InputEvent::PointerButton {
button: PointerButton::Left,
state: ElementState::Up,
position: at,
modifiers: Modifiers::NONE,
},
]);
assert_eq!(ui.drain_messages().collect::<Vec<_>>(), vec![false]);
for step in 0..=4 {
ui.tick(step * FOLD_MS / 2);
}
assert_eq!(
ui.layout(id).expect("laid out").height,
120,
"it folded itself instead of waiting for `set_open`"
);
}
#[test]
fn the_header_height_is_the_folded_height() {
let c: Collapse<usize> = Collapse::new("Nettverk", |open| open as usize);
assert_eq!(
c.header_height(&theme::DARK),
theme::DARK.metrics.size_field
);
assert!(c.is_open());
assert!(
!Collapse::<usize>::new("x", |o| o as usize)
.closed()
.is_open()
);
}
#[test]
fn the_expanded_height_floor_is_zero() {
let c: Collapse<usize> = Collapse::new("x", |o| o as usize).with_expanded_height(-40);
assert_eq!(c.expanded_height(), Some(0));
}
#[test]
fn a_section_is_a_tab_stop_with_or_without_a_listener() {
let mut c: Collapse<usize> = Collapse::new("x", |o| o as usize);
assert!(Widget::<usize>::focusable(&c));
c.message = None;
assert!(Widget::<usize>::focusable(&c));
assert!(Widget::<usize>::focusable(&Collapse::<usize>::inert("x")));
}
}