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, 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    pub fn item(
254        mut self,
255        id: Id,
256        title: impl text::IntoFragment<'a>,
257        content: impl Into<Element<'a, Message>>,
258    ) -> Self {
259        self.items.push((id, title.into_fragment(), content.into()));
260        self
261    }
262
263    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
264        self.on_event = Some(Box::new(on_event));
265        self
266    }
267}
268
269impl<'a, Id, Message> From<Accordion<'a, Id, Message>> for Element<'a, Message>
270where
271    Id: Copy + PartialEq + 'a,
272    Message: Clone + 'a,
273{
274    fn from(accordion: Accordion<'a, Id, Message>) -> Self {
275        let Accordion {
276            state,
277            items,
278            on_event,
279        } = accordion;
280
281        column(items.into_iter().map(|(id, title, content)| {
282            let open = state.is_open(id);
283            let message = on_event
284                .as_ref()
285                .map(|on_event| on_event(Event::Toggle(id)));
286            section(title, content, open, message)
287        }))
288        .width(Length::Fill)
289        .into()
290    }
291}
292
293fn section<'a, Message: Clone + 'a>(
294    title: text::Fragment<'a>,
295    content: Element<'a, Message>,
296    open: bool,
297    message: Option<Message>,
298) -> Element<'a, Message> {
299    let enabled = message.is_some();
300    let chevron = if open {
301        crate::lucide!(ChevronUp)
302    } else {
303        crate::lucide!(ChevronDown)
304    };
305
306    let header = widget::button(
307        row![
308            text(title).size(text_size::SM).width(Length::Fill),
309            themed(chevron, 16.0, opacity(enabled), |theme| {
310                Tokens::of(theme).muted_foreground
311            }),
312        ]
313        .spacing(space::MD)
314        .align_y(Alignment::Center),
315    )
316    .width(Length::Fill)
317    .padding([space::MD, 0.0])
318    .on_press_maybe(message)
319    .style(|theme, status| style(&Tokens::of(theme), status));
320
321    let mut body = column![header].width(Length::Fill);
322    if open {
323        body = body.push(
324            container(content)
325                .padding(iced::Padding::ZERO.bottom(space::LG))
326                .width(Length::Fill),
327        );
328    }
329    body.push(rule::horizontal(1).style(|theme: &Theme| rule::Style {
330        color: Tokens::of(theme).border,
331        radius: 0.0.into(),
332        fill_mode: rule::FillMode::Full,
333        snap: true,
334    }))
335    .into()
336}
337
338/// The style of a section header.
339pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
340    let text_color = match status {
341        Status::Active => tokens.foreground,
342        Status::Hovered | Status::Pressed => tokens.muted_foreground,
343        Status::Disabled => fade(tokens.foreground, 0.5),
344    };
345    widget::button::Style {
346        background: None,
347        text_color,
348        ..widget::button::Style::default()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::theme::{dark, light};
356
357    #[test]
358    fn starts_closed_in_single_mode_by_default() {
359        let state: State<u8> = State::default();
360        assert_eq!(state.mode(), Mode::Single);
361        assert!(state.open().is_empty());
362    }
363
364    fn apply(state: &mut State<u8>, action: Action, sections: &[u8]) {
365        for event in action.events(state, sections) {
366            let _ = state.update(event);
367        }
368    }
369
370    #[test]
371    fn next_and_previous_move_the_open_section_and_wrap() {
372        let sections = [1, 2, 3];
373        let mut state = State::new(Mode::Single);
374        apply(&mut state, Action::Next, &sections);
375        assert_eq!(state.open(), [1]);
376        apply(&mut state, Action::Next, &sections);
377        assert_eq!(state.open(), [2]);
378        apply(&mut state, Action::Previous, &sections);
379        apply(&mut state, Action::Previous, &sections);
380        assert_eq!(state.open(), [3]);
381
382        let mut closed = State::new(Mode::Single);
383        apply(&mut closed, Action::Previous, &sections);
384        assert_eq!(closed.open(), [3]);
385    }
386
387    #[test]
388    fn next_in_multiple_mode_moves_only_the_latest_section() {
389        let mut state = State::new(Mode::Multiple).with_open(1).with_open(2);
390        apply(&mut state, Action::Next, &[1, 2, 3]);
391        assert_eq!(state.open(), [1, 3]);
392    }
393
394    #[test]
395    fn next_with_one_or_no_sections() {
396        let state = State::new(Mode::Single).with_open(1);
397        assert!(Action::Next.events(&state, &[1]).is_empty());
398        assert!(Action::Next.events(&state, &[]).is_empty());
399    }
400
401    #[test]
402    fn expand_and_collapse_all() {
403        let sections = [1, 2, 3];
404        let mut state = State::new(Mode::Multiple).with_open(2);
405        assert_eq!(
406            Action::ExpandAll.events(&state, &sections),
407            [Event::Open(1), Event::Open(3)]
408        );
409        apply(&mut state, Action::ExpandAll, &sections);
410        assert_eq!(state.open(), [2, 1, 3]);
411        apply(&mut state, Action::CollapseAll, &sections);
412        assert!(state.open().is_empty());
413
414        let single = State::new(Mode::Single).with_open(1);
415        assert!(Action::ExpandAll.events(&single, &sections).is_empty());
416        assert_eq!(
417            Action::CollapseAll.events(&single, &sections),
418            [Event::Close(1)]
419        );
420    }
421
422    #[test]
423    fn default_keymap_and_overrides() {
424        let keymap = default_keymap();
425        let resolve = |keymap: &Keymap<Action>, chord: &str| {
426            let chord: Chord = chord.parse().unwrap();
427            keymap.resolve(chord.key(), chord.modifiers())
428        };
429        assert_eq!(resolve(&keymap, "ArrowDown"), Some(Action::Next));
430        assert_eq!(resolve(&keymap, "ArrowUp"), Some(Action::Previous));
431        assert_eq!(
432            resolve(&keymap, "Ctrl+Shift+ArrowDown"),
433            Some(Action::ExpandAll)
434        );
435        assert_eq!(
436            resolve(&keymap, "Ctrl+Shift+ArrowUp"),
437            Some(Action::CollapseAll)
438        );
439        let custom = keymap
440            .unbind_action(&Action::Next)
441            .bind("N".parse().unwrap(), Action::Next);
442        assert_eq!(resolve(&custom, "ArrowDown"), None);
443        assert_eq!(resolve(&custom, "n"), Some(Action::Next));
444    }
445
446    #[test]
447    fn single_mode_keeps_one_section_open() {
448        let mut state = State::new(Mode::Single);
449        assert_eq!(state.update(Event::Toggle(1)), Some(Output::Opened(1)));
450        assert_eq!(state.update(Event::Toggle(2)), Some(Output::Opened(2)));
451        assert_eq!(state.open(), [2]);
452        assert_eq!(state.update(Event::Toggle(2)), Some(Output::Closed(2)));
453        assert!(state.open().is_empty());
454    }
455
456    #[test]
457    fn multiple_mode_opens_sections_independently() {
458        let mut state = State::new(Mode::Multiple);
459        let _ = state.update(Event::Toggle(1));
460        let _ = state.update(Event::Toggle(2));
461        assert!(state.is_open(1) && state.is_open(2));
462        let _ = state.update(Event::Toggle(1));
463        assert_eq!(state.open(), [2]);
464    }
465
466    #[test]
467    fn open_and_close_are_idempotent() {
468        let mut state = State::new(Mode::Multiple).with_open(1);
469        assert_eq!(state.update(Event::Open(1)), None);
470        assert_eq!(state.update(Event::Close(3)), None);
471        assert_eq!(state.update(Event::Close(1)), Some(Output::Closed(1)));
472        assert_eq!(state.update(Event::Close(1)), None);
473    }
474
475    #[test]
476    fn switching_to_single_keeps_the_first_open_section() {
477        let mut state = State::new(Mode::Multiple).with_open(3).with_open(1);
478        state.set_mode(Mode::Single);
479        assert_eq!(state.open(), [3]);
480        let _ = state.update(Event::Open(1));
481        assert_eq!(state.open(), [1]);
482    }
483
484    #[test]
485    fn header_style_in_every_status_and_theme() {
486        for theme in [light(), dark()] {
487            let tokens = Tokens::of(&theme);
488            assert_eq!(style(&tokens, Status::Active).text_color, tokens.foreground);
489            assert_eq!(
490                style(&tokens, Status::Hovered).text_color,
491                tokens.muted_foreground
492            );
493            assert_eq!(
494                style(&tokens, Status::Pressed).text_color,
495                tokens.muted_foreground
496            );
497            assert!(style(&tokens, Status::Disabled).text_color.a < 1.0);
498            assert!(style(&tokens, Status::Active).background.is_none());
499        }
500    }
501}