1use iced::keyboard::key::Named;
7use iced::widget::{self, button::Status, column, container, row, rule, text};
8use iced::{Alignment, Color, Element, Length, Theme};
9
10use crate::icon::tinted;
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 tinted(chevron, 16.0, None).style(move |theme: &Theme, _| widget::svg::Style {
310 color: Some(chevron_colour(&Tokens::of(theme), enabled)),
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
338fn chevron_colour(tokens: &Tokens, enabled: bool) -> Color {
339 if enabled {
340 tokens.muted_foreground
341 } else {
342 fade(tokens.muted_foreground, 0.5)
343 }
344}
345
346pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
348 let text_color = match status {
349 Status::Active => tokens.foreground,
350 Status::Hovered | Status::Pressed => tokens.muted_foreground,
351 Status::Disabled => fade(tokens.foreground, 0.5),
352 };
353 widget::button::Style {
354 background: None,
355 text_color,
356 ..widget::button::Style::default()
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::theme::{dark, light};
364
365 #[test]
366 fn starts_closed_in_single_mode_by_default() {
367 let state: State<u8> = State::default();
368 assert_eq!(state.mode(), Mode::Single);
369 assert!(state.open().is_empty());
370 }
371
372 fn apply(state: &mut State<u8>, action: Action, sections: &[u8]) {
373 for event in action.events(state, sections) {
374 let _ = state.update(event);
375 }
376 }
377
378 #[test]
379 fn next_and_previous_move_the_open_section_and_wrap() {
380 let sections = [1, 2, 3];
381 let mut state = State::new(Mode::Single);
382 apply(&mut state, Action::Next, §ions);
383 assert_eq!(state.open(), [1]);
384 apply(&mut state, Action::Next, §ions);
385 assert_eq!(state.open(), [2]);
386 apply(&mut state, Action::Previous, §ions);
387 apply(&mut state, Action::Previous, §ions);
388 assert_eq!(state.open(), [3]);
389
390 let mut closed = State::new(Mode::Single);
391 apply(&mut closed, Action::Previous, §ions);
392 assert_eq!(closed.open(), [3]);
393 }
394
395 #[test]
396 fn next_in_multiple_mode_moves_only_the_latest_section() {
397 let mut state = State::new(Mode::Multiple).with_open(1).with_open(2);
398 apply(&mut state, Action::Next, &[1, 2, 3]);
399 assert_eq!(state.open(), [1, 3]);
400 }
401
402 #[test]
403 fn next_with_one_or_no_sections() {
404 let state = State::new(Mode::Single).with_open(1);
405 assert!(Action::Next.events(&state, &[1]).is_empty());
406 assert!(Action::Next.events(&state, &[]).is_empty());
407 }
408
409 #[test]
410 fn expand_and_collapse_all() {
411 let sections = [1, 2, 3];
412 let mut state = State::new(Mode::Multiple).with_open(2);
413 assert_eq!(
414 Action::ExpandAll.events(&state, §ions),
415 [Event::Open(1), Event::Open(3)]
416 );
417 apply(&mut state, Action::ExpandAll, §ions);
418 assert_eq!(state.open(), [2, 1, 3]);
419 apply(&mut state, Action::CollapseAll, §ions);
420 assert!(state.open().is_empty());
421
422 let single = State::new(Mode::Single).with_open(1);
423 assert!(Action::ExpandAll.events(&single, §ions).is_empty());
424 assert_eq!(
425 Action::CollapseAll.events(&single, §ions),
426 [Event::Close(1)]
427 );
428 }
429
430 #[test]
431 fn default_keymap_and_overrides() {
432 let keymap = default_keymap();
433 let resolve = |keymap: &Keymap<Action>, chord: &str| {
434 let chord: Chord = chord.parse().unwrap();
435 keymap.resolve(chord.key(), chord.modifiers())
436 };
437 assert_eq!(resolve(&keymap, "ArrowDown"), Some(Action::Next));
438 assert_eq!(resolve(&keymap, "ArrowUp"), Some(Action::Previous));
439 assert_eq!(
440 resolve(&keymap, "Ctrl+Shift+ArrowDown"),
441 Some(Action::ExpandAll)
442 );
443 assert_eq!(
444 resolve(&keymap, "Ctrl+Shift+ArrowUp"),
445 Some(Action::CollapseAll)
446 );
447 let custom = keymap
448 .unbind_action(&Action::Next)
449 .bind("N".parse().unwrap(), Action::Next);
450 assert_eq!(resolve(&custom, "ArrowDown"), None);
451 assert_eq!(resolve(&custom, "n"), Some(Action::Next));
452 }
453
454 #[test]
455 fn single_mode_keeps_one_section_open() {
456 let mut state = State::new(Mode::Single);
457 assert_eq!(state.update(Event::Toggle(1)), Some(Output::Opened(1)));
458 assert_eq!(state.update(Event::Toggle(2)), Some(Output::Opened(2)));
459 assert_eq!(state.open(), [2]);
460 assert_eq!(state.update(Event::Toggle(2)), Some(Output::Closed(2)));
461 assert!(state.open().is_empty());
462 }
463
464 #[test]
465 fn multiple_mode_opens_sections_independently() {
466 let mut state = State::new(Mode::Multiple);
467 let _ = state.update(Event::Toggle(1));
468 let _ = state.update(Event::Toggle(2));
469 assert!(state.is_open(1) && state.is_open(2));
470 let _ = state.update(Event::Toggle(1));
471 assert_eq!(state.open(), [2]);
472 }
473
474 #[test]
475 fn open_and_close_are_idempotent() {
476 let mut state = State::new(Mode::Multiple).with_open(1);
477 assert_eq!(state.update(Event::Open(1)), None);
478 assert_eq!(state.update(Event::Close(3)), None);
479 assert_eq!(state.update(Event::Close(1)), Some(Output::Closed(1)));
480 assert_eq!(state.update(Event::Close(1)), None);
481 }
482
483 #[test]
484 fn switching_to_single_keeps_the_first_open_section() {
485 let mut state = State::new(Mode::Multiple).with_open(3).with_open(1);
486 state.set_mode(Mode::Single);
487 assert_eq!(state.open(), [3]);
488 let _ = state.update(Event::Open(1));
489 assert_eq!(state.open(), [1]);
490 }
491
492 #[test]
493 fn header_style_in_every_status_and_theme() {
494 for theme in [light(), dark()] {
495 let tokens = Tokens::of(&theme);
496 assert_eq!(style(&tokens, Status::Active).text_color, tokens.foreground);
497 assert_eq!(
498 style(&tokens, Status::Hovered).text_color,
499 tokens.muted_foreground
500 );
501 assert_eq!(
502 style(&tokens, Status::Pressed).text_color,
503 tokens.muted_foreground
504 );
505 assert!(style(&tokens, Status::Disabled).text_color.a < 1.0);
506 assert!(style(&tokens, Status::Active).background.is_none());
507 }
508 }
509
510 #[test]
511 fn chevron_fades_when_disabled() {
512 let tokens = Tokens::of(&light());
513 assert!(chevron_colour(&tokens, false).a < chevron_colour(&tokens, true).a);
514 }
515}