use alloc::string::String;
use alloc::vec::Vec;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
use denise_render::Canvas;
use denise_text::TextStyle;
use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
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 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 Canvas<'_>, 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 paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
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;
if let Some(message) = self.message {
ctx.emit(message(self.open));
}
Handled::Yes
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
self.message.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
#[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_collapse_without_a_listener_is_not_a_tab_stop() {
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));
}
}