1use 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
20const ARROW_LENGTH: f32 = 32.0;
22const ARROW_STEP: f32 = 0.8;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27#[non_exhaustive]
28#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
29pub struct FilmstripCell {
30 pub index: usize,
32 pub selected: bool,
34 pub active: bool,
36}
37
38#[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 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 pub fn key(mut self, key: impl Fn(usize) -> u64 + 'static) -> Self {
101 self.key = Some(Rc::new(key));
102 self
103 }
104
105 pub fn selection(mut self, selection: Binding<BTreeSet<usize>>) -> Self {
108 self.selection = Some(selection);
109 self
110 }
111
112 pub fn mode(mut self, mode: SelectionMode) -> Self {
114 self.mode = mode;
115 self
116 }
117
118 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 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 pub fn cell_width(mut self, width: f32) -> Self {
132 self.cell_width = width.max(1.0);
133 self
134 }
135
136 pub fn height(mut self, height: f32) -> Self {
138 self.height = height.max(1.0);
139 self
140 }
141
142 pub fn spacing(mut self, spacing: f32) -> Self {
144 self.spacing = spacing.max(0.0);
145 self
146 }
147
148 pub fn label(mut self, label: impl Into<String>) -> Self {
150 self.label = Some(label.into());
151 self
152 }
153
154 pub fn fade(mut self, fade: Semantic) -> Self {
156 self.fade = fade;
157 self
158 }
159}
160
161struct 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 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 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 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 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}