Skip to main content

iced_cube/layout/
accordion.rs

1//! Sections that expand and collapse under their headers.
2//!
3//! [`State`] tracks which sections are open. In [`Mode::Single`] opening a
4//! section closes the others; in [`Mode::Multiple`] each one is independent.
5
6use iced::keyboard::key::Named;
7use iced::widget::{self, button::Status, column, container, row, rule, text};
8use iced::{Alignment, Element, Length, Theme};
9
10use crate::icon::{opacity, themed};
11use crate::keys::{self, Chord, Keymap};
12use crate::theme::{Tokens, fade, mix, space, text_size};
13
14/// How many sections may be open at once.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub enum Mode {
17    /// At most one section is open.
18    #[default]
19    Single,
20    /// Any number of sections are open.
21    Multiple,
22}
23
24impl Mode {
25    pub const ALL: [Mode; 2] = [Mode::Single, Mode::Multiple];
26}
27
28/// Opens or closes a section.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Event<Id> {
31    Toggle(Id),
32    Open(Id),
33    Close(Id),
34}
35
36/// What changed after an [`Event`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Output<Id> {
39    Opened(Id),
40    Closed(Id),
41}
42
43/// Which sections are open.
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct State<Id> {
46    mode: Mode,
47    open: Vec<Id>,
48}
49
50impl<Id: Copy + PartialEq> State<Id> {
51    /// Starts with every section closed.
52    pub fn new(mode: Mode) -> Self {
53        Self {
54            mode,
55            open: Vec::new(),
56        }
57    }
58
59    /// Starts with `id` open.
60    pub fn with_open(mut self, id: Id) -> Self {
61        let _ = self.update(Event::Open(id));
62        self
63    }
64
65    pub fn mode(&self) -> Mode {
66        self.mode
67    }
68
69    /// Switches mode. Going to single mode keeps only the first open section.
70    pub fn set_mode(&mut self, mode: Mode) {
71        self.mode = mode;
72        if mode == Mode::Single {
73            self.open.truncate(1);
74        }
75    }
76
77    pub fn is_open(&self, id: Id) -> bool {
78        self.open.contains(&id)
79    }
80
81    /// The open sections, in the order they were opened.
82    pub fn open(&self) -> &[Id] {
83        &self.open
84    }
85
86    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
87        match event {
88            Event::Toggle(id) if self.is_open(id) => self.update(Event::Close(id)),
89            Event::Toggle(id) | Event::Open(id) => {
90                if self.is_open(id) {
91                    return None;
92                }
93                if self.mode == Mode::Single {
94                    self.open.clear();
95                }
96                self.open.push(id);
97                Some(Output::Opened(id))
98            }
99            Event::Close(id) => {
100                let index = self.open.iter().position(|open| *open == id)?;
101                let _ = self.open.remove(index);
102                Some(Output::Closed(id))
103            }
104        }
105    }
106}
107
108/// What an accordion keyboard shortcut does.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum Action {
111    /// Moves the most recently opened section to the next one, wrapping.
112    Next,
113    /// Moves the most recently opened section to the previous one, wrapping.
114    Previous,
115    ExpandAll,
116    CollapseAll,
117}
118
119impl Action {
120    /// The events this action sends to `state`, given every section id in
121    /// display order. `ExpandAll` does nothing in [`Mode::Single`].
122    pub fn events<Id: Copy + PartialEq>(
123        self,
124        state: &State<Id>,
125        sections: &[Id],
126    ) -> Vec<Event<Id>> {
127        match self {
128            Action::Next => step(state, sections, true),
129            Action::Previous => step(state, sections, false),
130            Action::ExpandAll if state.mode == Mode::Single => Vec::new(),
131            Action::ExpandAll => sections
132                .iter()
133                .filter(|id| !state.is_open(**id))
134                .map(|id| Event::Open(*id))
135                .collect(),
136            Action::CollapseAll => state.open.iter().map(|id| Event::Close(*id)).collect(),
137        }
138    }
139}
140
141fn step<Id: Copy + PartialEq>(state: &State<Id>, sections: &[Id], forward: bool) -> Vec<Event<Id>> {
142    let len = sections.len();
143    if len == 0 {
144        return Vec::new();
145    }
146    let current = state.open.last().copied();
147    let index = current.and_then(|id| sections.iter().position(|section| *section == id));
148    let target = match (index, forward) {
149        (Some(index), true) => (index + 1) % len,
150        (Some(index), false) => (index + len - 1) % len,
151        (None, true) => 0,
152        (None, false) => len - 1,
153    };
154    let target = sections[target];
155    if current == Some(target) {
156        return Vec::new();
157    }
158    current
159        .map(Event::Close)
160        .into_iter()
161        .chain([Event::Open(target)])
162        .collect()
163}
164
165impl keys::Action for Action {
166    const ALL: &'static [Self] = &[
167        Action::Next,
168        Action::Previous,
169        Action::ExpandAll,
170        Action::CollapseAll,
171    ];
172
173    fn defaults() -> Keymap<Self> {
174        default_keymap()
175    }
176
177    fn name(self) -> &'static str {
178        match self {
179            Action::Next => "Next",
180            Action::Previous => "Previous",
181            Action::ExpandAll => "ExpandAll",
182            Action::CollapseAll => "CollapseAll",
183        }
184    }
185
186    fn description(self) -> &'static str {
187        match self {
188            Action::Next => {
189                "Opens the next section in place of the current one, wrapping at the end."
190            }
191            Action::Previous => {
192                "Opens the previous section in place of the current one, wrapping at the start."
193            }
194            Action::ExpandAll => "Opens every section. Only applies when several may be open.",
195            Action::CollapseAll => "Closes every open section.",
196        }
197    }
198}
199
200/// The default accordion shortcuts:
201///
202/// | Keys | Action |
203/// | --- | --- |
204/// | `ArrowDown` | [`Action::Next`] |
205/// | `ArrowUp` | [`Action::Previous`] |
206/// | `Ctrl+Shift+ArrowDown` | [`Action::ExpandAll`] |
207/// | `Ctrl+Shift+ArrowUp` | [`Action::CollapseAll`] |
208///
209/// Shortcuts are app-wide, so an app with scrolling content or other
210/// arrow-driven components may want to unbind the plain arrows.
211pub fn default_keymap() -> Keymap<Action> {
212    Keymap::new()
213        .bind(Chord::named(Named::ArrowDown), Action::Next)
214        .bind(Chord::named(Named::ArrowUp), Action::Previous)
215        .bind(
216            Chord::named(Named::ArrowDown).ctrl().shift(),
217            Action::ExpandAll,
218        )
219        .bind(
220            Chord::named(Named::ArrowUp).ctrl().shift(),
221            Action::CollapseAll,
222        )
223}
224
225/// An accordion builder. Convert it into an [`Element`] to render.
226pub struct Accordion<'a, Id, Message> {
227    state: &'a State<Id>,
228    items: Vec<(Id, text::Fragment<'a>, Element<'a, Message>)>,
229    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
230}
231
232/// Renders sections whose open state comes from `state`. Without
233/// [`on_event`](Accordion::on_event) the headers render disabled.
234pub fn accordion<Id, Message>(state: &State<Id>) -> Accordion<'_, Id, Message> {
235    Accordion {
236        state,
237        items: Vec::new(),
238        on_event: None,
239    }
240}
241
242impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Accordion<'_, Id, Message> {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("Accordion")
245            .field("state", self.state)
246            .field("items", &self.items.len())
247            .finish_non_exhaustive()
248    }
249}
250
251impl<'a, Id, Message> Accordion<'a, Id, Message> {
252    /// Adds a section with a header and the content shown while open.
253    /// Content inherits muted text unless it supplies its own colour.
254    pub fn item(
255        mut self,
256        id: Id,
257        title: impl text::IntoFragment<'a>,
258        content: impl Into<Element<'a, Message>>,
259    ) -> Self {
260        self.items.push((id, title.into_fragment(), content.into()));
261        self
262    }
263
264    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
265        self.on_event = Some(Box::new(on_event));
266        self
267    }
268}
269
270impl<'a, Id, Message> From<Accordion<'a, Id, Message>> for Element<'a, Message>
271where
272    Id: Copy + PartialEq + 'a,
273    Message: Clone + 'a,
274{
275    fn from(accordion: Accordion<'a, Id, Message>) -> Self {
276        let Accordion {
277            state,
278            items,
279            on_event,
280        } = accordion;
281
282        column(items.into_iter().map(|(id, title, content)| {
283            let open = state.is_open(id);
284            let message = on_event
285                .as_ref()
286                .map(|on_event| on_event(Event::Toggle(id)));
287            section(title, content, open, message)
288        }))
289        .width(Length::Fill)
290        .into()
291    }
292}
293
294fn section<'a, Message: Clone + 'a>(
295    title: text::Fragment<'a>,
296    content: Element<'a, Message>,
297    open: bool,
298    message: Option<Message>,
299) -> Element<'a, Message> {
300    let enabled = message.is_some();
301    let chevron = if open {
302        crate::lucide!(ChevronUp)
303    } else {
304        crate::lucide!(ChevronDown)
305    };
306
307    let header = widget::button(
308        row![
309            text(title)
310                .font(crate::theme::semibold())
311                .size(text_size::SM)
312                .width(Length::Fill),
313            themed(chevron, 16.0, opacity(enabled), |theme| {
314                Tokens::of(theme).muted_foreground
315            }),
316        ]
317        .spacing(space::MD)
318        .align_y(Alignment::Center),
319    )
320    .width(Length::Fill)
321    .padding([space::MD, 0.0])
322    .on_press_maybe(message)
323    .style(|theme, status| style(&Tokens::of(theme), status));
324
325    let mut body = column![header].width(Length::Fill);
326    if open {
327        body = body.push(
328            container(content)
329                .padding(iced::Padding::ZERO.bottom(space::LG).right(space::XL))
330                .width(Length::Fill)
331                .style(|theme: &Theme| container::Style {
332                    text_color: Some(Tokens::of(theme).muted_foreground),
333                    ..container::Style::default()
334                }),
335        );
336    }
337    body.push(rule::horizontal(1).style(|theme: &Theme| {
338        let tokens = Tokens::of(theme);
339        rule::Style {
340            color: mix(tokens.background, tokens.border, 0.7),
341            radius: 0.0.into(),
342            fill_mode: rule::FillMode::Full,
343            snap: true,
344        }
345    }))
346    .into()
347}
348
349/// The style of a section header.
350pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
351    let text_color = match status {
352        Status::Active => tokens.foreground,
353        Status::Hovered | Status::Pressed => tokens.muted_foreground,
354        Status::Disabled => fade(tokens.foreground, 0.5),
355    };
356    widget::button::Style {
357        background: None,
358        text_color,
359        ..widget::button::Style::default()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::theme::{dark, light};
367
368    #[test]
369    fn starts_closed_in_single_mode_by_default() {
370        let state: State<u8> = State::default();
371        assert_eq!(state.mode(), Mode::Single);
372        assert!(state.open().is_empty());
373    }
374
375    fn apply(state: &mut State<u8>, action: Action, sections: &[u8]) {
376        for event in action.events(state, sections) {
377            let _ = state.update(event);
378        }
379    }
380
381    #[test]
382    fn next_and_previous_move_the_open_section_and_wrap() {
383        let sections = [1, 2, 3];
384        let mut state = State::new(Mode::Single);
385        apply(&mut state, Action::Next, &sections);
386        assert_eq!(state.open(), [1]);
387        apply(&mut state, Action::Next, &sections);
388        assert_eq!(state.open(), [2]);
389        apply(&mut state, Action::Previous, &sections);
390        apply(&mut state, Action::Previous, &sections);
391        assert_eq!(state.open(), [3]);
392
393        let mut closed = State::new(Mode::Single);
394        apply(&mut closed, Action::Previous, &sections);
395        assert_eq!(closed.open(), [3]);
396    }
397
398    #[test]
399    fn next_in_multiple_mode_moves_only_the_latest_section() {
400        let mut state = State::new(Mode::Multiple).with_open(1).with_open(2);
401        apply(&mut state, Action::Next, &[1, 2, 3]);
402        assert_eq!(state.open(), [1, 3]);
403    }
404
405    #[test]
406    fn next_with_one_or_no_sections() {
407        let state = State::new(Mode::Single).with_open(1);
408        assert!(Action::Next.events(&state, &[1]).is_empty());
409        assert!(Action::Next.events(&state, &[]).is_empty());
410    }
411
412    #[test]
413    fn expand_and_collapse_all() {
414        let sections = [1, 2, 3];
415        let mut state = State::new(Mode::Multiple).with_open(2);
416        assert_eq!(
417            Action::ExpandAll.events(&state, &sections),
418            [Event::Open(1), Event::Open(3)]
419        );
420        apply(&mut state, Action::ExpandAll, &sections);
421        assert_eq!(state.open(), [2, 1, 3]);
422        apply(&mut state, Action::CollapseAll, &sections);
423        assert!(state.open().is_empty());
424
425        let single = State::new(Mode::Single).with_open(1);
426        assert!(Action::ExpandAll.events(&single, &sections).is_empty());
427        assert_eq!(
428            Action::CollapseAll.events(&single, &sections),
429            [Event::Close(1)]
430        );
431    }
432
433    #[test]
434    fn default_keymap_and_overrides() {
435        let keymap = default_keymap();
436        let resolve = |keymap: &Keymap<Action>, chord: &str| {
437            let chord: Chord = chord.parse().unwrap();
438            keymap.resolve(chord.key(), chord.modifiers())
439        };
440        assert_eq!(resolve(&keymap, "ArrowDown"), Some(Action::Next));
441        assert_eq!(resolve(&keymap, "ArrowUp"), Some(Action::Previous));
442        assert_eq!(
443            resolve(&keymap, "Ctrl+Shift+ArrowDown"),
444            Some(Action::ExpandAll)
445        );
446        assert_eq!(
447            resolve(&keymap, "Ctrl+Shift+ArrowUp"),
448            Some(Action::CollapseAll)
449        );
450        let custom = keymap
451            .unbind_action(&Action::Next)
452            .bind("N".parse().unwrap(), Action::Next);
453        assert_eq!(resolve(&custom, "ArrowDown"), None);
454        assert_eq!(resolve(&custom, "n"), Some(Action::Next));
455    }
456
457    #[test]
458    fn single_mode_keeps_one_section_open() {
459        let mut state = State::new(Mode::Single);
460        assert_eq!(state.update(Event::Toggle(1)), Some(Output::Opened(1)));
461        assert_eq!(state.update(Event::Toggle(2)), Some(Output::Opened(2)));
462        assert_eq!(state.open(), [2]);
463        assert_eq!(state.update(Event::Toggle(2)), Some(Output::Closed(2)));
464        assert!(state.open().is_empty());
465    }
466
467    #[test]
468    fn multiple_mode_opens_sections_independently() {
469        let mut state = State::new(Mode::Multiple);
470        let _ = state.update(Event::Toggle(1));
471        let _ = state.update(Event::Toggle(2));
472        assert!(state.is_open(1) && state.is_open(2));
473        let _ = state.update(Event::Toggle(1));
474        assert_eq!(state.open(), [2]);
475    }
476
477    #[test]
478    fn open_and_close_are_idempotent() {
479        let mut state = State::new(Mode::Multiple).with_open(1);
480        assert_eq!(state.update(Event::Open(1)), None);
481        assert_eq!(state.update(Event::Close(3)), None);
482        assert_eq!(state.update(Event::Close(1)), Some(Output::Closed(1)));
483        assert_eq!(state.update(Event::Close(1)), None);
484    }
485
486    #[test]
487    fn switching_to_single_keeps_the_first_open_section() {
488        let mut state = State::new(Mode::Multiple).with_open(3).with_open(1);
489        state.set_mode(Mode::Single);
490        assert_eq!(state.open(), [3]);
491        let _ = state.update(Event::Open(1));
492        assert_eq!(state.open(), [1]);
493    }
494
495    #[test]
496    fn header_style_in_every_status_and_theme() {
497        for theme in [light(), dark()] {
498            let tokens = Tokens::of(&theme);
499            assert_eq!(style(&tokens, Status::Active).text_color, tokens.foreground);
500            assert_eq!(
501                style(&tokens, Status::Hovered).text_color,
502                tokens.muted_foreground
503            );
504            assert_eq!(
505                style(&tokens, Status::Pressed).text_color,
506                tokens.muted_foreground
507            );
508            assert!(style(&tokens, Status::Disabled).text_color.a < 1.0);
509            assert!(style(&tokens, Status::Active).background.is_none());
510        }
511    }
512}