Skip to main content

vizia_core/views/
accordion.rs

1use std::ops::Deref;
2
3use crate::prelude::*;
4
5pub enum AccordionEvent {
6    ToggleOpen(usize, bool),
7    ClearHeaders,
8    RegisterHeader(usize, Entity),
9    FocusNextHeader,
10    FocusPrevHeader,
11    FocusFirstHeader,
12    FocusLastHeader,
13}
14
15/// A view which organizes content into expandable sections.
16///
17/// The accordion is implemented using internal [Collapsible] views and is fully
18/// controlled by external open state passed to [`Handle::open`].
19pub struct Accordion {
20    open_indices: Signal<Vec<usize>>,
21    on_toggle: Option<Box<dyn Fn(&mut EventContext, usize, bool) + 'static>>,
22    header_entities: Vec<Entity>,
23}
24
25impl Accordion {
26    /// Creates a new [Accordion] view.
27    pub fn new<S, V, T, F>(cx: &mut Context, list: S, content: F) -> Handle<Self>
28    where
29        S: Res<V> + 'static,
30        V: Deref<Target = [T]> + Clone + 'static,
31        T: Clone + 'static,
32        F: 'static + Clone + Fn(&mut Context, usize, T) -> AccordionPair,
33    {
34        let list = list.to_signal(cx);
35        let open_indices = Signal::new(Vec::new());
36
37        Self { open_indices, on_toggle: None, header_entities: Vec::new() }.build(cx, move |cx| {
38            Keymap::from(vec![
39                (
40                    KeyChord::new(Modifiers::empty(), Code::ArrowDown),
41                    KeymapEntry::new("Accordion Focus Next", |cx| {
42                        cx.emit(AccordionEvent::FocusNextHeader)
43                    }),
44                ),
45                (
46                    KeyChord::new(Modifiers::empty(), Code::ArrowUp),
47                    KeymapEntry::new("Accordion Focus Previous", |cx| {
48                        cx.emit(AccordionEvent::FocusPrevHeader)
49                    }),
50                ),
51                (
52                    KeyChord::new(Modifiers::empty(), Code::Home),
53                    KeymapEntry::new("Accordion Focus First", |cx| {
54                        cx.emit(AccordionEvent::FocusFirstHeader)
55                    }),
56                ),
57                (
58                    KeyChord::new(Modifiers::empty(), Code::End),
59                    KeymapEntry::new("Accordion Focus Last", |cx| {
60                        cx.emit(AccordionEvent::FocusLastHeader)
61                    }),
62                ),
63            ])
64            .build(cx);
65
66            Binding::new(cx, list, move |cx| {
67                let list_values = list.get();
68                let content = content.clone();
69                let list_length = list.with(|list| list.len());
70
71                cx.emit(AccordionEvent::ClearHeaders);
72
73                for (index, item) in list_values.iter().cloned().enumerate() {
74                    let pair = (content)(cx, index, item);
75
76                    Collapsible::new(cx, pair.header, pair.content)
77                        .on_build(move |cx| {
78                            if let Some(header) = cx.nth_child(0) {
79                                cx.emit(AccordionEvent::RegisterHeader(index, header));
80                            }
81                        })
82                        .on_toggle(move |cx, next_open| {
83                            cx.emit(AccordionEvent::ToggleOpen(index, next_open));
84                        })
85                        .open(open_indices.map(move |indices| indices.contains(&index)));
86
87                    if index < list_length - 1 {
88                        Divider::horizontal(cx);
89                    }
90                }
91            });
92        })
93    }
94}
95
96impl View for Accordion {
97    fn element(&self) -> Option<&'static str> {
98        Some("accordion")
99    }
100
101    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
102        event.map(|accordion_event, _| match accordion_event {
103            AccordionEvent::ToggleOpen(index, next_open) => {
104                if let Some(callback) = &self.on_toggle {
105                    (callback)(cx, *index, *next_open);
106                }
107            }
108
109            AccordionEvent::ClearHeaders => {
110                self.header_entities.clear();
111            }
112
113            AccordionEvent::RegisterHeader(index, entity) => {
114                if self.header_entities.len() <= *index {
115                    self.header_entities.resize(*index + 1, Entity::null());
116                }
117
118                self.header_entities[*index] = *entity;
119            }
120
121            AccordionEvent::FocusNextHeader => {
122                if let Some(index) = self.focused_header_index(cx) {
123                    let next_index = (index + 1) % self.header_entities.len();
124                    let next_header = self.header_entities[next_index];
125                    cx.with_current(next_header, |cx| cx.focus());
126                }
127            }
128
129            AccordionEvent::FocusPrevHeader => {
130                if let Some(index) = self.focused_header_index(cx) {
131                    let prev_index =
132                        if index == 0 { self.header_entities.len() - 1 } else { index - 1 };
133                    let prev_header = self.header_entities[prev_index];
134                    cx.with_current(prev_header, |cx| cx.focus());
135                }
136            }
137
138            AccordionEvent::FocusFirstHeader => {
139                if let Some(first_header) = self.header_entities.first().copied() {
140                    cx.with_current(first_header, |cx| cx.focus());
141                }
142            }
143
144            AccordionEvent::FocusLastHeader => {
145                if let Some(last_header) = self.header_entities.last().copied() {
146                    cx.with_current(last_header, |cx| cx.focus());
147                }
148            }
149        });
150
151        event.map(|window_event, meta| match window_event {
152            WindowEvent::KeyDown(code, _) => match code {
153                Code::ArrowDown | Code::ArrowUp | Code::Home | Code::End => {
154                    if self.focused_header_index(cx).is_some() {
155                        meta.consume();
156                    }
157                }
158                _ => {}
159            },
160            _ => {}
161        });
162    }
163}
164
165impl Accordion {
166    fn focused_header_index(&self, cx: &EventContext) -> Option<usize> {
167        let focused = cx.focused();
168        self.header_entities.iter().position(|header| {
169            !header.is_null() && (focused == *header || focused.is_descendant_of(cx.tree, *header))
170        })
171    }
172}
173
174impl Handle<'_, Accordion> {
175    /// Sets which sections are open by index.
176    pub fn open(mut self, indices: impl Res<Vec<usize>> + 'static) -> Self {
177        let indices = indices.to_signal(self.context());
178        self.bind(indices, move |handle| {
179            handle.modify(|accordion| {
180                accordion.open_indices.set(indices.get());
181            });
182        })
183    }
184
185    /// Set a callback that fires when a section is toggled open or closed.
186    ///
187    /// The callback receives the section index and the desired next open state.
188    /// Use this to update the external signal passed to `open()`.
189    pub fn on_toggle<F>(self, callback: F) -> Self
190    where
191        F: 'static + Fn(&mut EventContext, usize, bool),
192    {
193        self.modify(|accordion| {
194            accordion.on_toggle = Some(Box::new(callback));
195        })
196    }
197}
198
199pub struct AccordionPair {
200    pub header: Box<dyn Fn(&mut Context)>,
201    pub content: Box<dyn Fn(&mut Context)>,
202}
203
204impl AccordionPair {
205    pub fn new<H, C>(header: H, content: C) -> Self
206    where
207        H: 'static + Fn(&mut Context),
208        C: 'static + Fn(&mut Context),
209    {
210        Self { header: Box::new(header), content: Box::new(content) }
211    }
212}