Skip to main content

denise_ui/widgets/
collapse.rs

1//! A section that folds to its header, and the controller that makes several
2//! of them an accordion.
3
4use 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
20/// How long [`set_open`] takes to fold or unfold, unless the caller says.
21pub const FOLD_MS: u64 = 200;
22
23/// A section that folds to its header.
24///
25/// The widget is the **header** — title, chevron, the toggle — and the node it
26/// sits on hosts the body as ordinary children below the header strip, the way
27/// [`Panel`](super::Panel) hosts children. Collapsing is the node's *height*
28/// animating between the header alone and the full section; the body needs no
29/// hiding, because the node's own clip crops it, mid-animation included.
30///
31/// ```
32/// # use denise::{Rect, Size, theme};
33/// # use denise_ui::{Ui, widgets::{self, Collapse, FOLD_MS}};
34/// # #[derive(Clone, Debug)] enum Msg { Network(bool) }
35/// # fn demo(message: Msg) -> Option<()> {
36/// # let mut ui: Ui<Msg> = Ui::new(Size::new(1920, 1080), theme::DARK);
37/// # let stack = ui.root();
38/// # let rect = Rect::new(0, 0, 320, 48);
39/// let section = ui.add(stack, Collapse::new("Nettverk", Msg::Network), rect)?;
40/// // body children of `section`, placed below Collapse::header_height
41/// // ...
42/// match message {
43///     Msg::Network(open) => widgets::set_open(&mut ui, section, open, FOLD_MS),
44/// }
45/// # Some(()) }
46/// ```
47///
48/// The widget cannot animate its own node — `EventCtx` deliberately has no
49/// tree access — so it emits `fn(bool) -> M` and the application answers with
50/// [`set_open`], which flips the chevron and drives
51/// [`Ui::animate_layout`](crate::Ui::animate_layout). Inside a
52/// [`Ui::set_stack`](crate::Ui::set_stack) the siblings follow every frame,
53/// which is the whole accordion mechanism; [`Accordion`] packages the
54/// exclusivity.
55///
56/// # The expanded height is remembered, not configured
57///
58/// [`set_open`] notes the node's height at the moment of collapse, so opening
59/// returns the section to wherever it really was — a section that grew a row
60/// while open comes back at its grown height. The one case with nothing to
61/// remember is a section *built* collapsed:
62/// [`with_expanded_height`](Collapse::with_expanded_height) covers it.
63#[derive(Clone, Debug)]
64pub struct Collapse<M> {
65    title: String,
66    open: bool,
67    /// Where opening returns to. Written by [`set_open`] on the way down, or
68    /// by the builder for a section born collapsed.
69    expanded: Option<i32>,
70    message: Option<fn(bool) -> M>,
71    role: Role,
72    style: TextStyle,
73}
74
75impl<M> Collapse<M> {
76    /// An open section titled `title`, reporting toggles through `message`.
77    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    /// A section that folds and reports nothing, for one the application never
89    /// reads the state of.
90    ///
91    /// **It drives its own height**, which the one built with a message does
92    /// not: a message *is* the application saying it will answer with
93    /// [`set_open`], and one without has nobody else to. So a decorative
94    /// section on a panel folds when pressed and nothing has to be wired to it —
95    /// which is what a form file wants, since a form file has no application to
96    /// name.
97    ///
98    /// [`Accordion`] is still the way to make a run of them exclusive; it drives
99    /// them through `set_open` and works with these too.
100    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    /// Starts the section collapsed. Pair with
112    /// [`with_expanded_height`](Collapse::with_expanded_height), since a
113    /// section that has never been open has no height to remember.
114    pub fn closed(mut self) -> Self {
115        self.open = false;
116        self
117    }
118
119    /// Sets where the first opening goes, for a section built collapsed.
120    pub fn with_expanded_height(mut self, height: i32) -> Self {
121        self.expanded = Some(height.max(0));
122        self
123    }
124
125    /// Sets the header's colour role.
126    pub fn with_role(mut self, role: Role) -> Self {
127        self.role = role;
128        self
129    }
130
131    /// Sets the title's font and size.
132    pub fn with_style(mut self, style: TextStyle) -> Self {
133        self.style = style;
134        self
135    }
136
137    /// Whether the section is open (or opening).
138    #[inline]
139    pub const fn is_open(&self) -> bool {
140        self.open
141    }
142
143    /// The header strip's height: the theme's field height.
144    ///
145    /// The strip the widget draws and the closed height [`set_open`] folds
146    /// to — one definition, so they cannot drift.
147    pub fn header_height(&self, theme: &Theme) -> i32 {
148        theme.metrics.size_field.max(1)
149    }
150
151    /// Flips the open state **without emitting or animating** — [`set_open`]
152    /// calls this; so can an application restoring saved state before first
153    /// paint.
154    pub fn set_open_silent(&mut self, open: bool) {
155        self.open = open;
156    }
157
158    /// The remembered expanded height, if any.
159    #[inline]
160    pub const fn expanded_height(&self) -> Option<i32> {
161        self.expanded
162    }
163
164    /// Remembers where opening should return to.
165    pub fn set_expanded_height(&mut self, height: i32) {
166        self.expanded = Some(height.max(0));
167    }
168}
169
170/// Opens or folds the section at `id`, animated over `duration_ms`.
171///
172/// The application's whole answer to a [`Collapse`] message: flips the
173/// widget's chevron, remembers the expanded height on the way down, and
174/// drives [`Ui::animate_layout`] on the node's height. Does nothing if `id`
175/// does not hold a [`Collapse`].
176///
177/// Opening a section that was built collapsed and never given an expanded
178/// height unfolds to the header alone — visibly wrong rather than silently
179/// absent, so the missing `with_expanded_height` is found in review.
180pub 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        // The height at the moment of folding is where opening returns to.
193        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/// Exclusivity over a run of [`Collapse`] sections: opening one closes the
205/// open one.
206///
207/// A controller the application owns, not a widget — a widget cannot own
208/// other nodes, and which sections belong together is application policy.
209/// Like [`set_open`], it emits nothing: it *is* the answer to the messages.
210///
211/// ```
212/// # use denise::{Size, theme};
213/// # use denise_ui::{Ui, widgets::Accordion};
214/// # #[derive(Clone, Debug)] enum Msg { Section(usize, bool) }
215/// # fn demo(message: Msg, network: denise_ui::NodeId, screen: denise_ui::NodeId,
216/// #         about: denise_ui::NodeId) {
217/// # let mut ui: Ui<Msg> = Ui::new(Size::new(1920, 1080), theme::DARK);
218/// let mut accordion = Accordion::new([network, screen, about]);
219/// // ...
220/// match message {
221///     Msg::Section(index, _) => accordion.toggle(&mut ui, index),
222/// }
223/// # }
224/// ```
225#[derive(Clone, Debug)]
226pub struct Accordion {
227    sections: Vec<NodeId>,
228    open: Option<usize>,
229    duration_ms: u64,
230}
231
232impl Accordion {
233    /// An accordion over `sections`, all assumed open; the first `toggle`
234    /// closes the rest. Call [`collapse_all`](Accordion::collapse_all) after
235    /// building to start folded.
236    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    /// Sets how long each fold takes.
245    pub fn with_duration(mut self, duration_ms: u64) -> Self {
246        self.duration_ms = duration_ms;
247        self
248    }
249
250    /// The open section's index, if one is.
251    #[inline]
252    pub const fn open(&self) -> Option<usize> {
253        self.open
254    }
255
256    /// Folds every section, leaving nothing open.
257    pub fn collapse_all<M: 'static>(&mut self, ui: &mut Ui<M>) {
258        for &section in &self.sections {
259            set_open(ui, section, false, self.duration_ms);
260        }
261        self.open = None;
262    }
263
264    /// Opens section `index`, folding whichever was open — or folds `index`
265    /// itself if it was the open one, leaving the accordion closed.
266    ///
267    /// Out of range does nothing.
268    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
285/// The chevron: a small `>` when closed, rotated to `v` when open, drawn as
286/// two strokes since the built-in font has no glyph for it.
287fn chevron(canvas: &mut Pen<'_>, centre: Point, arm: i32, open: bool, color: denise::Color) {
288    let a = arm.max(2);
289    if open {
290        // Pointing down: two arms meeting at the bottom.
291        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        // Pointing right: two arms meeting at the right.
303        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        // Its header, plus what it is holding open. Shut, it is the header —
326        // which is what makes a column of these arrange correctly as they fold.
327        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                // Space held down must not fold and unfold per repeat, the
410                // same guard Checkbox keeps.
411                !repeat
412            }
413            _ => return Handled::No,
414        };
415        if !toggle {
416            return Handled::No;
417        }
418        // The widget reports; the application animates. `open` flips here so
419        // the chevron answers the press immediately, and `set_open` flips it
420        // again to the same value — idempotent, not doubled.
421        self.open = !self.open;
422        match self.message {
423            Some(message) => ctx.emit(message(self.open)),
424            // Nobody else is going to. `set_open` does exactly this arithmetic
425            // with `&mut Ui` in hand; here the current height comes from
426            // `ctx.bounds`, which is the same number a moment earlier.
427            None => {
428                let header = self.header_height(ctx.theme);
429                let target = if self.open {
430                    self.expanded.unwrap_or(header)
431                } else {
432                    // The height at the moment of folding is where opening
433                    // returns to — see `set_open`.
434                    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        // Every other widget here is focusable whether or not it carries a
449        // message, because an inert one still does something when pressed. This
450        // used to be the exception, and an inert section that folds itself is
451        // no longer one.
452        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            // A section that has never been folded has no remembered height, so
502            // there is nothing to report and nothing for a file to write.
503            "expanded-height" => Value::Int(self.expanded?),
504            "role" => Value::role(self.role),
505            "size" => Value::Int(i32::from(self.style.size_px)),
506            // The message is the application's own type; see the `describe`
507            // module documentation.
508            _ => 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            // Silently: `set_open` animates a node's height, and a form being
516            // loaded or a designer flipping a checkbox is stating what the
517            // section *is*, not folding it in front of anyone. The node's own
518            // height comes from the file, which is why the widget only has to
519            // agree about the chevron.
520            "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    /// An inert section folds itself, because nothing else is going to.
539    ///
540    /// A `Collapse` with a message is telling the application it will answer
541    /// with [`set_open`]; one without has nobody to tell. Before #118 the widget
542    /// only flipped its chevron and the height stayed where it was, which is why
543    /// there was no inert constructor to offer.
544    #[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        // And back to exactly where it folded from: the height at the moment of
603        // folding is where opening returns to, as `set_open` puts it.
604        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    /// The one with a message still leaves the height to the application.
619    ///
620    /// The other half of the rule, and the half that must not have changed: an
621    /// accordion refuses folds, animates them at its own duration and closes the
622    /// section beside the one that opened. A widget that folded itself anyway
623    /// would fight it.
624    #[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    /// A section is a tab stop whether or not it carries a message.
685    ///
686    /// It was not, until #118: a `Collapse` with no message did nothing when
687    /// pressed but flip its chevron, so there was no reason for the keyboard to
688    /// stop on it. An inert one folds itself now, which makes it as interactive
689    /// as every other inert widget here — `Checkbox`, `Toggle` and `Slider` are
690    /// all focusable without a message for the same reason.
691    #[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}