Skip to main content

ravenrs_widgets/lists/
filmstrip.rs

1//! A strip of thumbnails, as long as it needs to be.
2
3use std::cell::Cell;
4use std::collections::BTreeSet;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, CursorIcon, Insets, Key, Rect, Role};
9use ravenrs_layout::{Align, Axis, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::Semantic;
12
13use super::selection;
14use super::virtual_list::{ListProbe, ScrollCommand, VirtualList};
15use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
16use crate::grid::SelectionMode;
17use crate::stack::EdgeArrow;
18use crate::view::{AnyView, View};
19
20/// How long an arrow at the end of the strip is.
21const ARROW_LENGTH: f32 = 32.0;
22/// How much of the view an arrow moves.
23const ARROW_STEP: f32 = 0.8;
24
25/// What a cell of a [`Filmstrip`] is told when it is drawn.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27#[non_exhaustive]
28#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
29pub struct FilmstripCell {
30    /// Its position in the strip.
31    pub index: usize,
32    /// It is in the selection.
33    pub selected: bool,
34    /// The keyboard is on it.
35    pub active: bool,
36}
37
38/// A horizontal strip of thumbnails that builds only what shows, so thousands cost the same as
39/// a few. Each cell comes from whoever uses it, with its own image, stars or badges; the strip
40/// draws the frame around it. A click selects alone, Ctrl or Cmd adds, and Shift takes a range;
41/// the arrows, Home and End move and keep the selected cell in view, Space adds, Enter
42/// activates. Arrows appear on the ends that have more to show.
43///
44/// ```
45/// use std::collections::BTreeSet;
46///
47/// use ravenrs_reactive::State;
48/// use ravenrs_widgets::{Filmstrip, FilmstripCell, SelectionMode, Text, Ui};
49///
50/// let selected = State::new(BTreeSet::from([0_usize]));
51/// let strip = Filmstrip::new(5_000, |cell: FilmstripCell| Text::new(format!("{}", cell.index + 1)))
52///     .selection(selected.binding())
53///     .mode(SelectionMode::Multiple);
54/// let mut ui = Ui::new(strip, ravenrs_style::Theme::raven_dark());
55/// ui.update();
56/// ```
57#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
58pub struct Filmstrip {
59    count: usize,
60    cell: Rc<dyn Fn(FilmstripCell) -> AnyView>,
61    key: Option<Rc<dyn Fn(usize) -> u64>>,
62    selection: Option<Binding<BTreeSet<usize>>>,
63    mode: SelectionMode,
64    on_activate: Option<Rc<dyn Fn(usize)>>,
65    cell_name: Option<Rc<dyn Fn(usize) -> String>>,
66    cell_width: f32,
67    height: f32,
68    spacing: f32,
69    label: Option<String>,
70    fade: Semantic,
71}
72
73impl fmt::Debug for Filmstrip {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.debug_struct("Filmstrip").field("count", &self.count).finish_non_exhaustive()
76    }
77}
78
79#[cfg_attr(feature = "live", ravenrs_live::expose)]
80impl Filmstrip {
81    /// A strip of `count` cells, with `cell` making the view of each one when it shows.
82    pub fn new<V: View + 'static>(count: usize, cell: impl Fn(FilmstripCell) -> V + 'static) -> Self {
83        Self {
84            count,
85            cell: Rc::new(move |info| Box::new(cell(info))),
86            key: None,
87            selection: None,
88            mode: SelectionMode::Single,
89            on_activate: None,
90            cell_name: None,
91            cell_width: 96.0,
92            height: 96.0,
93            spacing: 6.0,
94            label: None,
95            fade: Semantic::Canvas,
96        }
97    }
98
99    /// What identifies a cell, which is its position by default.
100    pub fn key(mut self, key: impl Fn(usize) -> u64 + 'static) -> Self {
101        self.key = Some(Rc::new(key));
102        self
103    }
104
105    /// The selected cells, to read and to change from outside. A single one that changes from
106    /// outside is brought into view.
107    pub fn selection(mut self, selection: Binding<BTreeSet<usize>>) -> Self {
108        self.selection = Some(selection);
109        self
110    }
111
112    /// How cells can be selected ([`SelectionMode::Single`] by default).
113    pub fn mode(mut self, mode: SelectionMode) -> Self {
114        self.mode = mode;
115        self
116    }
117
118    /// Called with a cell on a double click or on Enter.
119    pub fn on_activate(mut self, handler: impl Fn(usize) + 'static) -> Self {
120        self.on_activate = Some(Rc::new(handler));
121        self
122    }
123
124    /// What screen readers call each cell.
125    pub fn cell_name(mut self, name: impl Fn(usize) -> String + 'static) -> Self {
126        self.cell_name = Some(Rc::new(name));
127        self
128    }
129
130    /// The width of each cell (96 by default).
131    pub fn cell_width(mut self, width: f32) -> Self {
132        self.cell_width = width.max(1.0);
133        self
134    }
135
136    /// The height of the strip (96 by default).
137    pub fn height(mut self, height: f32) -> Self {
138        self.height = height.max(1.0);
139        self
140    }
141
142    /// Gap between cells (6 by default).
143    pub fn spacing(mut self, spacing: f32) -> Self {
144        self.spacing = spacing.max(0.0);
145        self
146    }
147
148    /// What screen readers call the strip, instead of the name in [`Labels`](crate::Labels).
149    pub fn label(mut self, label: impl Into<String>) -> Self {
150        self.label = Some(label.into());
151        self
152    }
153
154    /// The color the arrows fade from, which should be what is behind the strip.
155    pub fn fade(mut self, fade: Semantic) -> Self {
156        self.fade = fade;
157        self
158    }
159}
160
161/// The frame around a cell, and what makes it an option.
162struct FilmCell {
163    index: usize,
164    selected: bool,
165    active: bool,
166    keyboard: bool,
167    body: Rc<dyn Fn(FilmstripCell) -> AnyView>,
168    name: Option<Rc<dyn Fn(usize) -> String>>,
169    pick: Rc<dyn Fn(usize)>,
170    open: Option<Rc<dyn Fn(usize)>>,
171}
172
173impl View for FilmCell {
174    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
175        let theme = *cx.theme();
176        let hovered = cx.is_hovered();
177        let body =
178            (self.body)(FilmstripCell { index: self.index, selected: self.selected, active: self.active });
179        let child = cx.child(&*body);
180        // By its thickness as well as its color: the state is not left to color alone.
181        let border = if self.selected {
182            Border { width: 2.0, color: theme.color(Semantic::Accent) }
183        } else if self.active && self.keyboard {
184            Border { width: 2.0, color: theme.color(Semantic::Ink) }
185        } else if hovered {
186            Border { width: 1.0, color: theme.color(Semantic::Ink).with_alpha(0.4) }
187        } else {
188            Border { width: 1.0, color: theme.color(Semantic::Border) }
189        };
190        let node = cx.container(
191            LayoutStyle {
192                grow: 1.0,
193                overflow_hidden: true,
194                align: Align::Stretch,
195                padding: Insets::uniform(2.0),
196                ..LayoutStyle::stack(Axis::Vertical)
197            },
198            &[child],
199            Paint::Quad(QuadPaint {
200                corner_radius: theme.radii.sm,
201                border: Some(border),
202                ..QuadPaint::new(theme.color(Semantic::Surface))
203            }),
204        );
205        let (pick, index) = (self.pick.clone(), self.index);
206        cx.on_click(node, Rc::new(move || pick(index)));
207        if let Some(open) = self.open.clone() {
208            cx.on_double_click(node, Rc::new(move || open(index)));
209        }
210        cx.cursor(node, CursorIcon::Pointer);
211        let mut semantics = Semantics::new(Role::ListBoxOption).selected(self.selected).active(self.active);
212        if let Some(name) = &self.name {
213            semantics = semantics.name(name(index));
214        }
215        cx.semantics(node, semantics);
216        node
217    }
218}
219
220impl View for Filmstrip {
221    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
222        let labels = cx.labels();
223        let local: State<BTreeSet<usize>> = cx.use_state(BTreeSet::new);
224        let selection = self.selection.clone().unwrap_or_else(|| local.binding());
225        let commands: State<Option<ScrollCommand>> = cx.use_state(|| None);
226        let cursor: State<usize> = cx.use_state(|| 0);
227        let anchor: State<usize> = cx.use_state(|| 0);
228        let probe = cx.use_state(|| Rc::new(Cell::new(ListProbe::default()))).peek().clone();
229        // What the strip itself last made of the selection: anything else came from outside.
230        let known: Rc<Cell<Option<usize>>> = cx.use_state(|| Rc::new(Cell::new(None))).peek().clone();
231        let arrow_used: State<bool> = cx.use_state(|| false);
232
233        let count = self.count;
234        let selected = Rc::new(selection.get());
235        let single = (selected.len() == 1).then(|| selected.iter().next().copied()).flatten();
236        if single != known.get() {
237            known.set(single);
238            if let Some(index) = single {
239                if *cursor.peek() != index {
240                    cursor.set(index);
241                    anchor.set(index);
242                }
243                commands.set(Some(ScrollCommand::Reveal(index)));
244            }
245        }
246        let active = cursor.get().min(count.saturating_sub(1));
247        let keyboard = cx.is_focus_visible();
248        let mode = self.mode;
249        let modifiers = cx.modifiers();
250
251        let pick: Rc<dyn Fn(usize)> = {
252            let (selection, cursor, anchor, known) =
253                (selection.clone(), cursor.clone(), anchor.clone(), known.clone());
254            Rc::new(move |index| {
255                let mut next = anchor.get();
256                selection.update(|set| {
257                    next = selection::pick(set, mode, next, index, modifiers.get(), |i| i);
258                    known.set((set.len() == 1).then(|| set.iter().next().copied()).flatten());
259                });
260                anchor.set(next);
261                cursor.set(index);
262            })
263        };
264
265        let (body, name, open, picker, shown) = (
266            self.cell.clone(),
267            self.cell_name.clone(),
268            self.on_activate.clone(),
269            pick.clone(),
270            selected.clone(),
271        );
272        let mut list = VirtualList::new(count, move |index| FilmCell {
273            index,
274            selected: shown.contains(&index),
275            active: index == active,
276            keyboard,
277            body: body.clone(),
278            name: name.clone(),
279            pick: picker.clone(),
280            open: open.clone(),
281        })
282        .horizontal()
283        .row_height(self.cell_width)
284        .spacing(self.spacing)
285        .padding(self.spacing)
286        .height(self.height)
287        .bare()
288        .commands(commands.clone())
289        .probe(probe.clone());
290        list.key = self.key.clone();
291        let strip = cx.child(&list);
292
293        let seen = probe.get();
294        let last = (seen.total - seen.view).max(0.0);
295        let step = (seen.view * ARROW_STEP).max(ARROW_LENGTH);
296        let arrow = |forward: bool| {
297            let at = if forward { seen.view - ARROW_LENGTH } else { 0.0 };
298            let (commands, arrow_used) = (commands.clone(), arrow_used.clone());
299            let by = if forward { step } else { -step };
300            EdgeArrow {
301                forward,
302                vertical: false,
303                frame: Rect::new(at, 0.0, ARROW_LENGTH, self.height),
304                fade: self.fade,
305                label: if forward { labels.scroll_forward.clone() } else { labels.scroll_back.clone() },
306                on_click: Rc::new(move || {
307                    commands.set(Some(ScrollCommand::By(by)));
308                    arrow_used.set(true);
309                }),
310            }
311        };
312        let mut nodes = vec![strip];
313        let back = (seen.offset > 0.5).then(|| cx.keyed_child(1, &arrow(false)));
314        let forward = (seen.offset < last - 0.5).then(|| cx.keyed_child(2, &arrow(true)));
315        nodes.extend(back);
316        nodes.extend(forward);
317        // Reaching the end takes away the arrow that had the keyboard.
318        if arrow_used.get() {
319            arrow_used.set(false);
320            match (back, forward) {
321                (Some(other), None) | (None, Some(other)) => cx.request_focus_node(other),
322                _ => {}
323            }
324        }
325        let root = cx.container(
326            LayoutStyle {
327                height: Some(self.height),
328                min_width: Some(0.0),
329                align: Align::Stretch,
330                align_self: Some(Align::Stretch),
331                ..LayoutStyle::stack(Axis::Vertical)
332            },
333            &nodes,
334            Paint::None,
335        );
336        let name = self.label.clone().unwrap_or_else(|| labels.filmstrip_name.clone());
337        cx.semantics(root, Semantics::new(Role::ListBox).name(name).description(count.to_string()));
338        cx.focusable(root, cx.theme().radii.sm);
339        let open = self.on_activate.clone();
340        let select_all = selection.clone();
341        cx.on_key(
342            root,
343            Rc::new(move |key, modifiers| {
344                if count == 0 {
345                    return false;
346                }
347                let to = match key {
348                    Key::ArrowLeft => active.saturating_sub(1),
349                    Key::ArrowRight => (active + 1).min(count - 1),
350                    Key::Home => 0,
351                    Key::End => count - 1,
352                    Key::Space => {
353                        if mode == SelectionMode::Multiple {
354                            let adding = ravenrs_core::Modifiers { ctrl: true, ..Default::default() };
355                            let mut next = anchor.get();
356                            selection.update(|set| {
357                                next = selection::pick(set, mode, next, active, adding, |i| i);
358                            });
359                            anchor.set(next);
360                        } else {
361                            selection.set(BTreeSet::from([active]));
362                        }
363                        return true;
364                    }
365                    Key::Enter => {
366                        if let Some(open) = &open {
367                            open(active);
368                        }
369                        return true;
370                    }
371                    Key::Character(c) if selection::adds(modifiers) && c.eq_ignore_ascii_case("a") => {
372                        if mode != SelectionMode::Multiple {
373                            return false;
374                        }
375                        select_all.set((0..count).collect());
376                        return true;
377                    }
378                    _ => return false,
379                };
380                cursor.set(to);
381                // Ctrl or Cmd moves without changing the selection.
382                if !selection::adds(modifiers) && mode != SelectionMode::None {
383                    let mut next = anchor.get();
384                    selection.update(|set| {
385                        next = selection::pick(set, mode, next, to, modifiers, |i| i);
386                        known.set((set.len() == 1).then(|| set.iter().next().copied()).flatten());
387                    });
388                    anchor.set(next);
389                }
390                commands.set(Some(ScrollCommand::Reveal(to)));
391                true
392            }),
393        );
394        root
395    }
396}