1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub enum Mode {
17 #[default]
19 Single,
20 Multiple,
22}
23
24impl Mode {
25 pub const ALL: [Mode; 2] = [Mode::Single, Mode::Multiple];
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Event<Id> {
31 Toggle(Id),
32 Open(Id),
33 Close(Id),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Output<Id> {
39 Opened(Id),
40 Closed(Id),
41}
42
43#[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 pub fn new(mode: Mode) -> Self {
53 Self {
54 mode,
55 open: Vec::new(),
56 }
57 }
58
59 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
110pub enum Action {
111 Next,
113 Previous,
115 ExpandAll,
116 CollapseAll,
117}
118
119impl Action {
120 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
200pub 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
225pub 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
232pub 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 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
338pub 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, §ions);
375 assert_eq!(state.open(), [1]);
376 apply(&mut state, Action::Next, §ions);
377 assert_eq!(state.open(), [2]);
378 apply(&mut state, Action::Previous, §ions);
379 apply(&mut state, Action::Previous, §ions);
380 assert_eq!(state.open(), [3]);
381
382 let mut closed = State::new(Mode::Single);
383 apply(&mut closed, Action::Previous, §ions);
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, §ions),
407 [Event::Open(1), Event::Open(3)]
408 );
409 apply(&mut state, Action::ExpandAll, §ions);
410 assert_eq!(state.open(), [2, 1, 3]);
411 apply(&mut state, Action::CollapseAll, §ions);
412 assert!(state.open().is_empty());
413
414 let single = State::new(Mode::Single).with_open(1);
415 assert!(Action::ExpandAll.events(&single, §ions).is_empty());
416 assert_eq!(
417 Action::CollapseAll.events(&single, §ions),
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}