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