1use alloc::string::String;
5use alloc::vec::Vec;
6
7use denise::Pen;
8use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
9use denise_text::TextStyle;
10
11use crate::widget::{
12 Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
13};
14use crate::widgets::describe::{
15 Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
16};
17use crate::widgets::style::{Align, draw_aligned, focus_ring, interactive_pair};
18use crate::{NodeId, Ui};
19
20pub const FOLD_MS: u64 = 200;
22
23#[derive(Clone, Debug)]
64pub struct Collapse<M> {
65 title: String,
66 open: bool,
67 expanded: Option<i32>,
70 message: Option<fn(bool) -> M>,
71 role: Role,
72 style: TextStyle,
73}
74
75impl<M> Collapse<M> {
76 pub fn new(title: impl Into<String>, message: fn(bool) -> M) -> Self {
78 Self {
79 title: title.into(),
80 open: true,
81 expanded: None,
82 message: Some(message),
83 role: Role::Base200,
84 style: TextStyle::built_in(16),
85 }
86 }
87
88 pub fn inert(title: impl Into<String>) -> Self {
101 Self {
102 title: title.into(),
103 open: true,
104 expanded: None,
105 message: None,
106 role: Role::Base200,
107 style: TextStyle::built_in(16),
108 }
109 }
110
111 pub fn closed(mut self) -> Self {
115 self.open = false;
116 self
117 }
118
119 pub fn with_expanded_height(mut self, height: i32) -> Self {
121 self.expanded = Some(height.max(0));
122 self
123 }
124
125 pub fn with_role(mut self, role: Role) -> Self {
127 self.role = role;
128 self
129 }
130
131 pub fn with_style(mut self, style: TextStyle) -> Self {
133 self.style = style;
134 self
135 }
136
137 #[inline]
139 pub const fn is_open(&self) -> bool {
140 self.open
141 }
142
143 pub fn header_height(&self, theme: &Theme) -> i32 {
148 theme.metrics.size_field.max(1)
149 }
150
151 pub fn set_open_silent(&mut self, open: bool) {
155 self.open = open;
156 }
157
158 #[inline]
160 pub const fn expanded_height(&self) -> Option<i32> {
161 self.expanded
162 }
163
164 pub fn set_expanded_height(&mut self, height: i32) {
166 self.expanded = Some(height.max(0));
167 }
168}
169
170pub fn set_open<M: 'static>(ui: &mut Ui<M>, id: NodeId, open: bool, duration_ms: u64) {
181 let Some(layout) = ui.layout(id) else {
182 return;
183 };
184 let theme = *ui.theme();
185 let Some(collapse) = ui.widget_mut::<Collapse<M>>(id) else {
186 return;
187 };
188 let header = collapse.header_height(&theme);
189 let target = if open {
190 collapse.expanded.unwrap_or(header)
191 } else {
192 collapse.set_expanded_height(layout.height);
194 header
195 };
196 collapse.set_open_silent(open);
197 ui.animate_layout(
198 id,
199 Rect::new(layout.x, layout.y, layout.width, target),
200 duration_ms,
201 );
202}
203
204#[derive(Clone, Debug)]
226pub struct Accordion {
227 sections: Vec<NodeId>,
228 open: Option<usize>,
229 duration_ms: u64,
230}
231
232impl Accordion {
233 pub fn new(sections: impl IntoIterator<Item = NodeId>) -> Self {
237 Self {
238 sections: sections.into_iter().collect(),
239 open: None,
240 duration_ms: FOLD_MS,
241 }
242 }
243
244 pub fn with_duration(mut self, duration_ms: u64) -> Self {
246 self.duration_ms = duration_ms;
247 self
248 }
249
250 #[inline]
252 pub const fn open(&self) -> Option<usize> {
253 self.open
254 }
255
256 pub fn collapse_all<M: 'static>(&mut self, ui: &mut Ui<M>) {
258 for §ion in &self.sections {
259 set_open(ui, section, false, self.duration_ms);
260 }
261 self.open = None;
262 }
263
264 pub fn toggle<M: 'static>(&mut self, ui: &mut Ui<M>, index: usize) {
269 if index >= self.sections.len() {
270 return;
271 }
272 if self.open == Some(index) {
273 set_open(ui, self.sections[index], false, self.duration_ms);
274 self.open = None;
275 return;
276 }
277 if let Some(current) = self.open {
278 set_open(ui, self.sections[current], false, self.duration_ms);
279 }
280 set_open(ui, self.sections[index], true, self.duration_ms);
281 self.open = Some(index);
282 }
283}
284
285fn chevron(canvas: &mut Pen<'_>, centre: Point, arm: i32, open: bool, color: denise::Color) {
288 let a = arm.max(2);
289 if open {
290 canvas.draw_line(
292 Point::new(centre.x - a, centre.y - a / 2),
293 Point::new(centre.x, centre.y + a / 2),
294 color,
295 );
296 canvas.draw_line(
297 Point::new(centre.x + a, centre.y - a / 2),
298 Point::new(centre.x, centre.y + a / 2),
299 color,
300 );
301 } else {
302 canvas.draw_line(
304 Point::new(centre.x - a / 2, centre.y - a),
305 Point::new(centre.x + a / 2, centre.y),
306 color,
307 );
308 canvas.draw_line(
309 Point::new(centre.x - a / 2, centre.y + a),
310 Point::new(centre.x + a / 2, centre.y),
311 color,
312 );
313 }
314}
315
316impl<M: 'static> Widget<M> for Collapse<M> {
317 fn describe(&self) -> Option<&dyn DynDescribe> {
318 Some(self)
319 }
320
321 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
322 Some(self)
323 }
324 fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
325 let header = self.header_height(ctx.theme);
328 let body = if self.is_open() {
329 self.expanded_height().unwrap_or(0)
330 } else {
331 0
332 };
333 Measured::tall(header.saturating_add(body))
334 }
335
336 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
337 let bounds = ctx.bounds;
338 if bounds.is_empty() {
339 return;
340 }
341 let header = Rect::new(
342 bounds.x,
343 bounds.y,
344 bounds.width,
345 self.header_height(ctx.theme).min(bounds.height),
346 );
347 let radius = ctx.theme.radius(Radius::Field);
348 let (fill, content) = interactive_pair(ctx.theme, self.role, ctx.state);
349 canvas.fill_rounded_rect(header, radius, fill);
350
351 let pad = (self.style.size_px as i32 / 2).max(4);
352 let arm = (header.height / 6).max(3);
353 chevron(
354 canvas,
355 Point::new(header.x + pad + arm, header.y + header.height / 2),
356 arm,
357 self.open,
358 content,
359 );
360
361 let title_box = Rect::new(
362 header.x + pad * 2 + arm * 2,
363 header.y,
364 (header.width - pad * 3 - arm * 2).max(0),
365 header.height,
366 );
367 if !title_box.is_empty() && !self.title.is_empty() {
368 let mut clipped = canvas.with_clip(title_box);
369 draw_aligned(
370 &mut clipped,
371 ctx.text,
372 self.style,
373 title_box,
374 (Align::Start, Align::Center),
375 &self.title,
376 content,
377 );
378 }
379
380 if ctx.state.contains(VisualState::FOCUSED) {
381 focus_ring(ctx.theme, header, radius, canvas);
382 }
383 }
384
385 fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
386 let header = Rect::new(
387 ctx.bounds.x,
388 ctx.bounds.y,
389 ctx.bounds.width,
390 self.header_height(ctx.theme).min(ctx.bounds.height),
391 );
392 let toggle = match event {
393 Event::Input(InputEvent::PointerButton {
394 state: ElementState::Up,
395 position,
396 ..
397 })
398 | Event::Input(InputEvent::TouchUp {
399 position,
400 cancelled: false,
401 ..
402 }) => header.contains(*position),
403 Event::Input(InputEvent::Key {
404 code: KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space,
405 state: ElementState::Down,
406 repeat,
407 ..
408 }) if ctx.state.contains(VisualState::FOCUSED) => {
409 !repeat
412 }
413 _ => return Handled::No,
414 };
415 if !toggle {
416 return Handled::No;
417 }
418 self.open = !self.open;
422 match self.message {
423 Some(message) => ctx.emit(message(self.open)),
424 None => {
428 let header = self.header_height(ctx.theme);
429 let target = if self.open {
430 self.expanded.unwrap_or(header)
431 } else {
432 self.expanded = Some(ctx.bounds.height);
435 header
436 };
437 ctx.resize_height(target, FOLD_MS);
438 }
439 }
440 Handled::Yes
441 }
442
443 fn accepts_pointer(&self) -> bool {
444 true
445 }
446
447 fn focusable(&self) -> bool {
448 true
453 }
454}
455
456impl<M> Describe for Collapse<M> {
457 const KIND: &'static str = "collapse";
458 const DOC: &'static str = "A section that folds away to its header and opens again.";
459 const GROUP: Group = Group::Container;
460 const ICON: &'static denise::icon::Icon = &super::icons::COLLAPSE;
461
462 const PROPERTIES: &'static [Property] = &[
463 Property::new(
464 "text",
465 PropertyKind::Text,
466 "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.",
467 ),
468 Property::new(
469 "open",
470 PropertyKind::Bool,
471 "Whether the section is unfolded.",
472 ),
473 Property::new(
474 "expanded-height",
475 PropertyKind::Int { min: 0, max: 4096 },
476 "The content's height when open; measured from the children without it.",
477 )
478 .in_pixels(),
479 Property::new(
480 "on-toggle",
481 PropertyKind::Message(Payload::Bool),
482 "Emitted with the new state when the header is pressed. The application answers with `set_open`, which is what actually folds the node.",
483 ),
484 Property::new(
485 "role",
486 PropertyKind::Enum(ROLES),
487 "Colour role the header strip is filled with.",
488 ),
489 Property::new(
490 "size",
491 PropertyKind::Int { min: 6, max: 96 },
492 "Title size in logical pixels.",
493 )
494 .in_pixels(),
495 ];
496
497 fn get(&self, name: &str) -> Option<Value> {
498 Some(match name {
499 "text" => Value::text(self.title.as_str()),
500 "open" => Value::Bool(self.open),
501 "expanded-height" => Value::Int(self.expanded?),
504 "role" => Value::role(self.role),
505 "size" => Value::Int(i32::from(self.style.size_px)),
506 _ => return None,
509 })
510 }
511
512 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
513 match name {
514 "text" => self.title = value.as_text()?,
515 "open" => self.set_open_silent(value.as_bool()?),
521 "expanded-height" => self.set_expanded_height(value.as_int()?),
522 "role" => self.role = value.as_role()?,
523 "size" => self.style.size_px = value.as_size()?,
524 "on-toggle" => return Err(Mismatch::Supplied),
525 _ => return Err(Mismatch::Unknown),
526 }
527 Ok(())
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use denise::{ElementState, InputEvent, Modifiers, PointerButton, Size, theme};
535
536 use crate::Ui;
537
538 #[test]
545 fn an_inert_section_folds_and_opens_and_reports_nothing() {
546 #[derive(Clone, Copy, Debug, PartialEq)]
547 struct Never;
548
549 let mut ui: Ui<Never> = Ui::new(Size::new(400, 300), theme::DARK);
550 let root = ui.root();
551 let id = ui
552 .add(root, Collapse::inert("Avansert"), Rect::new(0, 0, 200, 120))
553 .expect("a root takes children");
554
555 let press = |ui: &mut Ui<Never>| {
556 let at = Point::new(20, 8);
557 ui.handle(&[
558 InputEvent::PointerMoved { position: at },
559 InputEvent::PointerButton {
560 button: PointerButton::Left,
561 state: ElementState::Down,
562 position: at,
563 modifiers: Modifiers::NONE,
564 },
565 InputEvent::PointerButton {
566 button: PointerButton::Left,
567 state: ElementState::Up,
568 position: at,
569 modifiers: Modifiers::NONE,
570 },
571 ]);
572 };
573 let settle = |ui: &mut Ui<Never>, from: u64| {
574 for step in 0..=4 {
575 ui.tick(from + step * FOLD_MS / 2);
576 }
577 };
578
579 let open_height = ui.layout(id).expect("laid out").height;
580 press(&mut ui);
581 assert!(
582 ui.drain_messages().next().is_none(),
583 "an inert section emitted something"
584 );
585 settle(&mut ui, 0);
586
587 let header = ui
588 .widget::<Collapse<Never>>(id)
589 .expect("a collapse")
590 .header_height(&theme::DARK);
591 assert_eq!(
592 ui.layout(id).expect("laid out").height,
593 header,
594 "it did not fold to its header"
595 );
596 assert!(
597 !ui.widget::<Collapse<Never>>(id)
598 .expect("a collapse")
599 .is_open()
600 );
601
602 press(&mut ui);
605 settle(&mut ui, 10 * FOLD_MS);
606 assert_eq!(
607 ui.layout(id).expect("laid out").height,
608 open_height,
609 "opening it again did not return to the height it folded from"
610 );
611 assert!(
612 ui.widget::<Collapse<Never>>(id)
613 .expect("a collapse")
614 .is_open()
615 );
616 }
617
618 #[test]
625 fn a_section_with_a_message_still_waits_to_be_told() {
626 let mut ui: Ui<bool> = Ui::new(Size::new(400, 300), theme::DARK);
627 let root = ui.root();
628 let id = ui
629 .add(
630 root,
631 Collapse::new("Nettverk", |open| open),
632 Rect::new(0, 0, 200, 120),
633 )
634 .expect("a root takes children");
635
636 let at = Point::new(20, 8);
637 ui.handle(&[
638 InputEvent::PointerMoved { position: at },
639 InputEvent::PointerButton {
640 button: PointerButton::Left,
641 state: ElementState::Down,
642 position: at,
643 modifiers: Modifiers::NONE,
644 },
645 InputEvent::PointerButton {
646 button: PointerButton::Left,
647 state: ElementState::Up,
648 position: at,
649 modifiers: Modifiers::NONE,
650 },
651 ]);
652 assert_eq!(ui.drain_messages().collect::<Vec<_>>(), vec![false]);
653 for step in 0..=4 {
654 ui.tick(step * FOLD_MS / 2);
655 }
656 assert_eq!(
657 ui.layout(id).expect("laid out").height,
658 120,
659 "it folded itself instead of waiting for `set_open`"
660 );
661 }
662
663 #[test]
664 fn the_header_height_is_the_folded_height() {
665 let c: Collapse<usize> = Collapse::new("Nettverk", |open| open as usize);
666 assert_eq!(
667 c.header_height(&theme::DARK),
668 theme::DARK.metrics.size_field
669 );
670 assert!(c.is_open());
671 assert!(
672 !Collapse::<usize>::new("x", |o| o as usize)
673 .closed()
674 .is_open()
675 );
676 }
677
678 #[test]
679 fn the_expanded_height_floor_is_zero() {
680 let c: Collapse<usize> = Collapse::new("x", |o| o as usize).with_expanded_height(-40);
681 assert_eq!(c.expanded_height(), Some(0));
682 }
683
684 #[test]
692 fn a_section_is_a_tab_stop_with_or_without_a_listener() {
693 let mut c: Collapse<usize> = Collapse::new("x", |o| o as usize);
694 assert!(Widget::<usize>::focusable(&c));
695 c.message = None;
696 assert!(Widget::<usize>::focusable(&c));
697 assert!(Widget::<usize>::focusable(&Collapse::<usize>::inert("x")));
698 }
699}