Skip to main content

qframe/widgets/
accordion.rs

1//! Accordions: a column of titled sections that open and close in place.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::widget::{Container, EventCx, MeasureCx, Node, PaintCx, Widget};
6
7use super::sections::{Flow, Section, Sections};
8
9/// A vertical list of titled sections; each opens with a click or Enter to show its body.
10///
11/// Add one child per section with [`View::add_with`](crate::widget::View::add_with): the first
12/// child is the body of the first section, and so on. The application owns which sections are
13/// open and hears about changes through [`Accordion::on_toggle`]. Sections are separated by
14/// surface tones, never frames: the title row is one tone above the body. An open body takes
15/// its natural height, so an accordion usually lives inside a [`ScrollView`](super::ScrollView)
16/// or a panel; opening unfolds the body row by row over twice `motion.enter`.
17///
18/// A hovered title (or the keyboard's title while focused) rises with the pillar, and its icon and
19/// title slide one cell right. The chevron and the detail never move.
20///
21/// Keys while focused: ↑/↓, Home/End move between titles, Enter or Space opens or closes. A
22/// click anywhere on a title row opens or closes it.
23///
24/// Style keys: `section` (`gap`), `section-title` (`bg`, `fg`, `bold`, `pillar`) with `hover`,
25/// `focus`, `checked` (open), `section-chevron` (`fg`), `section-detail` (`fg`),
26/// `section-body` (`bg`, `padding`). Icons: `section-open`, `section-closed`.
27pub struct Accordion<Msg> {
28    model: Sections<Msg>,
29}
30
31impl<Msg: 'static> Accordion<Msg> {
32    /// An accordion of `sections`, all closed.
33    #[must_use]
34    pub fn new(sections: impl IntoIterator<Item = impl Into<Section>>) -> Self {
35        Self { model: Sections::new(sections.into_iter().map(Into::into).collect()) }
36    }
37
38    /// Which sections are open; `open[i]` for section `i`, missing entries are closed.
39    #[must_use]
40    pub fn open(mut self, open: impl IntoIterator<Item = bool>) -> Self {
41        self.model.open = open.into_iter().collect();
42        self
43    }
44
45    /// Message for opening (`true`) or closing (`false`) section `index`.
46    #[must_use]
47    pub fn on_toggle(mut self, message: impl Fn(usize, bool) -> Msg + 'static) -> Self {
48        self.model.on_toggle = Some(Box::new(message));
49        self
50    }
51
52    /// Keeps at most one section open: opening one also sends close messages for the others.
53    #[must_use]
54    pub fn single(mut self, single: bool) -> Self {
55        self.model.single = single;
56        self
57    }
58}
59
60impl<Msg: 'static> Container<Msg> for Accordion<Msg> {
61    fn set_children(&mut self, children: Vec<Node<Msg>>) {
62        self.model.bodies = children;
63    }
64}
65
66impl<Msg: 'static> Widget<Msg> for Accordion<Msg> {
67    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
68        self.model.measure(cx, available)
69    }
70
71    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
72        self.model.paint(cx, area, Flow::Natural);
73    }
74
75    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
76        self.model.event(cx, event)
77    }
78
79    fn focusable(&self) -> bool {
80        !self.model.sections.is_empty()
81    }
82
83    fn children(&self) -> &[Node<Msg>] {
84        &self.model.bodies
85    }
86
87    fn children_mut(&mut self) -> &mut [Node<Msg>] {
88        &mut self.model.bodies
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::time::Duration;
95
96    use super::*;
97    use crate::runtime::{App, Command, Harness};
98    use crate::widget::View;
99    use crate::widgets::{Button, Text};
100
101    struct Demo {
102        open: Vec<bool>,
103        single: bool,
104        pressed: bool,
105        icons: bool,
106    }
107
108    #[derive(Clone)]
109    enum Msg {
110        Toggle(usize, bool),
111        Press,
112    }
113
114    impl App for Demo {
115        type Msg = Msg;
116        fn update(&mut self, msg: Msg) -> Command<Msg> {
117            match msg {
118                Msg::Toggle(index, open) => self.open[index] = open,
119                Msg::Press => self.pressed = true,
120            }
121            Command::none()
122        }
123        fn view(&self, ui: &mut View<'_, Msg>) {
124            let mut sections = [Section::new("Ports").detail("3"), Section::new("Volumes"), Section::new("Logs")];
125            if self.icons {
126                sections = sections.map(|section| section.icon("folder"));
127            }
128            let accordion = Accordion::new(sections).open(self.open.clone()).single(self.single).on_toggle(Msg::Toggle);
129            ui.add_with(accordion, |ui| {
130                ui.add(Text::new("8080 → 80"));
131                ui.column(|ui| {
132                    ui.add(Text::new("data"));
133                    ui.add(Button::new("Prune").on_press(Msg::Press)).id("prune");
134                });
135                ui.add(Text::new("ready"));
136            })
137            .fill_width()
138            .id("accordion");
139        }
140    }
141
142    fn demo(open: [bool; 3], single: bool) -> Demo {
143        Demo { open: open.to_vec(), single, pressed: false, icons: false }
144    }
145
146    #[test]
147    fn closed_sections_are_title_rows_separated_by_a_gap() {
148        let h = Harness::new(demo([false; 3], false), 24, 6);
149        assert_eq!(h.screen(), "  ▸ Ports            3\n\n  ▸ Volumes\n\n  ▸ Logs\n\n");
150        let theme = h.env().theme();
151        assert_eq!(h.bg(10, 0), theme.color("raised"));
152        assert_eq!(h.bg(10, 1), theme.color("canvas"));
153    }
154
155    #[test]
156    fn click_opens_with_a_row_by_row_reveal() {
157        let mut h = Harness::new(demo([false; 3], false), 24, 12);
158        h.click_text("Volumes");
159        assert_eq!(h.app().open, vec![false, true, false]);
160        // The rows are reserved at once; the body unfolds into them.
161        assert!(!h.screen().contains("Prune"), "{}", h.screen());
162        h.advance(Duration::from_millis(400));
163        assert_eq!(h.screen(), "  ▸ Ports            3\n\n▌ ▾  Volumes\n\n  data\n    Prune\n\n\n  ▸ Logs\n\n\n\n");
164        assert_eq!(
165            h.bg(10, 4),
166            h.env().theme().color("surface").map(|s| s.mix(h.env().theme().color("raised").unwrap_or(s), 0.5))
167        );
168        h.click_text("Prune");
169        assert!(h.app().pressed);
170    }
171
172    #[test]
173    fn keyboard_moves_between_titles_and_toggles() {
174        let mut h = Harness::new(demo([false; 3], false), 24, 12);
175        h.set_reduced_motion(true);
176        h.press("tab").press("down").press("down").press("enter");
177        assert_eq!(h.app().open, vec![false, false, true]);
178        assert!(h.screen().contains("ready"));
179        h.press("home").press("space");
180        assert_eq!(h.app().open, vec![true, false, true]);
181        h.press("space");
182        assert_eq!(h.app().open, vec![false, false, true]);
183    }
184
185    #[test]
186    fn single_closes_the_others() {
187        let mut h = Harness::new(demo([true, false, false], true), 24, 12);
188        h.set_reduced_motion(true);
189        h.click_text("Logs");
190        assert_eq!(h.app().open, vec![false, false, true]);
191    }
192
193    #[test]
194    fn the_chevron_stays_put_while_icon_and_title_slide() {
195        let app = Demo { icons: true, ..demo([false; 3], false) };
196        let mut h = Harness::new(app, 24, 6);
197        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
198        assert_eq!(
199            h.screen(),
200            "  ▸ ■ Ports          3
201
202  ▸ ■ Volumes
203
204  ▸ ■ Logs
205
206"
207        );
208        h.hover(10, 2);
209        assert_eq!(h.screen().lines().nth(2), Some("▌ ▸  ■ Volumes"), "{}", h.screen());
210        assert_eq!(h.screen().lines().next(), Some("  ▸ ■ Ports          3"), "the detail never moves");
211        assert_eq!(h.fg(2, 2), h.env().theme().color("muted"), "the chevron keeps its own colour");
212
213        let mut env = crate::env::Env::builtin();
214        env.set_slide(false);
215        let app = Demo { icons: true, ..demo([false; 3], false) };
216        let mut h = Harness::with_env(app, env, 24, 6);
217        h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
218        h.hover(10, 2);
219        assert_eq!(h.screen().lines().nth(2), Some("▌ ▸ ■ Volumes"), "{}", h.screen());
220    }
221
222    #[test]
223    fn a_click_on_the_chevron_of_a_hovered_title_toggles() {
224        let mut h = Harness::new(demo([false; 3], false), 24, 12);
225        h.set_reduced_motion(true);
226        h.hover(10, 2).click(2, 2);
227        assert_eq!(h.app().open, vec![false, true, false]);
228        assert_eq!(h.screen().lines().nth(2), Some("▌ ▾  Volumes"), "{}", h.screen());
229    }
230
231    #[test]
232    fn narrow_titles_truncate_and_drop_the_detail() {
233        let h = Harness::new(demo([false; 3], false), 12, 6);
234        assert_eq!(h.screen().lines().next(), Some("  ▸ Ports"));
235        let h = Harness::new(demo([false; 3], false), 10, 6);
236        assert_eq!(h.screen().lines().nth(2), Some("  ▸ Vo…"));
237    }
238
239    #[test]
240    fn ascii_mode_has_no_brackets() {
241        let mut h = Harness::new(demo([true, false, false], false), 24, 10);
242        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
243        let screen = h.screen();
244        assert!(screen.starts_with("  v Ports"), "{screen}");
245        assert!(!screen.contains(['[', ']', '(', ')']));
246    }
247}