1use 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
9pub struct Accordion<Msg> {
28 model: Sections<Msg>,
29}
30
31impl<Msg: 'static> Accordion<Msg> {
32 #[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 #[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 #[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 #[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 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}