1use std::cell::RefCell;
4use std::collections::BTreeSet;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, Role};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle, Weight};
12
13use super::selection;
14use super::virtual_list::{ScrollCommand, VirtualList};
15use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
16use crate::files::size_text;
17use crate::grid::SelectionMode;
18use crate::view::View;
19use crate::{Breadcrumb, Divider, IconName, util};
20
21type OpenHandler = Rc<dyn Fn(&FileEntry)>;
23
24const ROW_HEIGHT: f32 = 32.0;
25const SIZE_WIDTH: f32 = 96.0;
26const MODIFIED_WIDTH: f32 = 148.0;
27
28#[derive(Clone, Debug, PartialEq)]
31#[non_exhaustive]
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct FileEntry {
34 pub id: String,
36 pub name: String,
38 pub is_folder: bool,
40 pub size: Option<u64>,
42 pub modified: Option<String>,
44 pub modified_order: i64,
46 pub icon: Option<IconName>,
48}
49
50#[cfg_attr(feature = "live", ravenrs_live::expose)]
51impl FileEntry {
52 fn new(name: String, is_folder: bool, size: Option<u64>) -> Self {
53 Self { id: name.clone(), name, is_folder, size, modified: None, modified_order: 0, icon: None }
54 }
55
56 pub fn folder(name: impl Into<String>) -> Self {
58 Self::new(name.into(), true, None)
59 }
60
61 pub fn file(name: impl Into<String>, size: u64) -> Self {
63 Self::new(name.into(), false, Some(size))
64 }
65
66 pub fn id(mut self, id: impl Into<String>) -> Self {
68 self.id = id.into();
69 self
70 }
71
72 pub fn modified(mut self, text: impl Into<String>, order: i64) -> Self {
74 self.modified = Some(text.into());
75 self.modified_order = order;
76 self
77 }
78
79 pub fn icon(mut self, icon: IconName) -> Self {
81 self.icon = Some(icon);
82 self
83 }
84}
85
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88#[non_exhaustive]
89#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
90pub enum FileSortColumn {
91 #[default]
93 Name,
94 Size,
96 Modified,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
103pub struct FileSort {
104 pub column: FileSortColumn,
106 pub ascending: bool,
108}
109
110impl Default for FileSort {
111 fn default() -> Self {
112 Self { column: FileSortColumn::Name, ascending: true }
113 }
114}
115
116fn sorted(entries: &[FileEntry], sort: FileSort) -> Vec<usize> {
118 let names: Vec<String> = entries.iter().map(|entry| entry.name.to_lowercase()).collect();
119 let mut order: Vec<usize> = (0..entries.len()).collect();
120 order.sort_by(|&a, &b| {
121 let (x, y) = (&entries[a], &entries[b]);
122 let by = match sort.column {
123 FileSortColumn::Name => names[a].cmp(&names[b]),
124 FileSortColumn::Size => x.size.cmp(&y.size),
125 FileSortColumn::Modified => x.modified_order.cmp(&y.modified_order),
126 };
127 let by = if sort.ascending { by } else { by.reverse() };
128 y.is_folder.cmp(&x.is_folder).then(by).then_with(|| names[a].cmp(&names[b]))
129 });
130 order
131}
132
133#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
153pub struct FileBrowser {
154 path: Vec<String>,
155 entries: Rc<[FileEntry]>,
156 selection: Option<Binding<BTreeSet<String>>>,
157 sort: Option<Binding<FileSort>>,
158 mode: SelectionMode,
159 on_navigate: Option<Rc<dyn Fn(usize)>>,
160 on_open: Option<OpenHandler>,
161 height: Option<f32>,
162 label: Option<String>,
163}
164
165impl fmt::Debug for FileBrowser {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 f.debug_struct("FileBrowser").field("path", &self.path).finish_non_exhaustive()
168 }
169}
170
171#[cfg_attr(feature = "live", ravenrs_live::expose)]
172impl FileBrowser {
173 pub fn new(
177 path: impl IntoIterator<Item = impl Into<String>>,
178 entries: impl Into<Rc<[FileEntry]>>,
179 ) -> Self {
180 Self {
181 path: path.into_iter().map(Into::into).collect(),
182 entries: entries.into(),
183 selection: None,
184 sort: None,
185 mode: SelectionMode::Multiple,
186 on_navigate: None,
187 on_open: None,
188 height: None,
189 label: None,
190 }
191 }
192
193 pub fn selection(mut self, selection: Binding<BTreeSet<String>>) -> Self {
195 self.selection = Some(selection);
196 self
197 }
198
199 pub fn sort(mut self, sort: Binding<FileSort>) -> Self {
201 self.sort = Some(sort);
202 self
203 }
204
205 pub fn mode(mut self, mode: SelectionMode) -> Self {
207 self.mode = mode;
208 self
209 }
210
211 pub fn on_navigate(mut self, handler: impl Fn(usize) + 'static) -> Self {
213 self.on_navigate = Some(Rc::new(handler));
214 self
215 }
216
217 pub fn on_open(mut self, handler: impl Fn(&FileEntry) + 'static) -> Self {
219 self.on_open = Some(Rc::new(handler));
220 self
221 }
222
223 pub fn height(mut self, height: f32) -> Self {
225 self.height = Some(height);
226 self
227 }
228
229 pub fn label(mut self, label: impl Into<String>) -> Self {
231 self.label = Some(label.into());
232 self
233 }
234}
235
236struct SortHeader {
238 text: String,
239 column: FileSortColumn,
240 sort: Binding<FileSort>,
241 width: Option<f32>,
242 end: bool,
243}
244
245impl View for SortHeader {
246 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
247 let theme = *cx.theme();
248 let now = self.sort.get();
249 let on = now.column == self.column;
250 let lit = cx.is_hovered() || cx.is_focus_visible();
251 let color = theme.color(if on || lit { Semantic::Ink } else { Semantic::InkMuted });
252 let mut parts = vec![util::label(cx, &self.text, TextStyle::Caption, Some(Weight::SEMIBOLD), color)];
253 if on {
254 let icon = if now.ascending { IconName::ArrowUp } else { IconName::ArrowDown };
255 parts.push(util::icon(cx, icon, 12.0, color));
256 }
257 let node = cx.container(
258 LayoutStyle {
259 width: self.width,
260 grow: if self.width.is_none() { 1.0 } else { 0.0 },
261 min_width: Some(0.0),
262 spacing: theme.spacing.s1,
263 align: Align::Center,
264 justify: if self.end { Justify::End } else { Justify::Start },
265 ..LayoutStyle::stack(Axis::Horizontal)
266 },
267 &parts,
268 Paint::None,
269 );
270 let (sort, column) = (self.sort.clone(), self.column);
271 cx.on_click(
272 node,
273 Rc::new(move || {
274 sort.update(|s| {
275 s.ascending = if s.column == column { !s.ascending } else { true };
276 s.column = column;
277 });
278 }),
279 );
280 cx.semantics(node, Semantics::new(Role::ColumnHeader).name(self.text.clone()).selected(on));
281 cx.focusable(node, theme.radii.sm);
282 cx.cursor(node, CursorIcon::Pointer);
283 node
284 }
285}
286
287struct FileRow {
289 entry_index: usize,
290 position: usize,
291 entries: Rc<[FileEntry]>,
292 selected: bool,
293 active: bool,
294 keyboard: bool,
295 dates: bool,
296 pick: Rc<dyn Fn(usize)>,
297 open: Option<OpenHandler>,
298}
299
300impl View for FileRow {
301 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
302 let theme = *cx.theme();
303 let labels = cx.labels();
304 let Some(entry) = self.entries.get(self.entry_index) else { return cx.empty() };
305 let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
306 let icon = entry.icon.unwrap_or(if entry.is_folder { IconName::Folder } else { IconName::File });
307 let tint = if entry.is_folder { theme.color(Semantic::Accent) } else { muted };
308 let mut parts = vec![util::icon(cx, icon, 16.0, tint)];
309 let attrs = util::attrs(&theme, TextStyle::Callout, None);
310 parts.push(cx.text_truncated(
311 &entry.name,
312 &attrs,
313 ink,
314 LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
315 ));
316 let size = entry.size.map(size_text).unwrap_or_default();
317 let column = |width: f32, end: bool| LayoutStyle {
318 width: Some(width),
319 align: Align::Center,
320 justify: if end { Justify::End } else { Justify::Start },
321 ..LayoutStyle::stack(Axis::Horizontal)
322 };
323 let number = util::label(cx, &size, TextStyle::Caption, None, muted);
324 parts.push(cx.container(column(SIZE_WIDTH, true), &[number], Paint::None));
325 if self.dates {
326 let date = entry.modified.as_deref().unwrap_or("");
327 let date = util::label(cx, date, TextStyle::Caption, None, muted);
328 parts.push(cx.container(column(MODIFIED_WIDTH, true), &[date], Paint::None));
329 }
330 let accent = theme.color(Semantic::Accent);
331 let fill = if self.selected {
332 accent.with_alpha(0.16)
333 } else if cx.is_hovered() {
334 ink.with_alpha(0.05)
335 } else {
336 Color::TRANSPARENT
337 };
338 let border = (self.active && self.keyboard).then_some(Border { width: 1.5, color: accent });
339 let node = cx.container(
340 LayoutStyle {
341 grow: 1.0,
342 spacing: theme.spacing.s2,
343 padding: Insets::symmetric(0.0, theme.spacing.s3),
344 align: Align::Center,
345 ..LayoutStyle::stack(Axis::Horizontal)
346 },
347 &parts,
348 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
349 );
350 let (pick, position) = (self.pick.clone(), self.position);
351 cx.on_click(node, Rc::new(move || pick(position)));
352 if let Some(open) = self.open.clone() {
353 let (entries, index) = (self.entries.clone(), self.entry_index);
354 cx.on_double_click(
355 node,
356 Rc::new(move || {
357 if let Some(entry) = entries.get(index) {
358 open(entry);
359 }
360 }),
361 );
362 }
363 cx.cursor(node, CursorIcon::Pointer);
364 let what = if entry.is_folder { labels.folder_word.clone() } else { size };
365 cx.semantics(
366 node,
367 Semantics::new(Role::ListBoxOption)
368 .name(entry.name.clone())
369 .description(what)
370 .selected(self.selected)
371 .active(self.active),
372 );
373 node
374 }
375}
376
377impl View for FileBrowser {
378 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
379 let theme = *cx.theme();
380 let labels = cx.labels();
381 let local_selection: State<BTreeSet<String>> = cx.use_state(BTreeSet::new);
382 let selection = self.selection.clone().unwrap_or_else(|| local_selection.binding());
383 let local_sort: State<FileSort> = cx.use_state(FileSort::default);
384 let sort = self.sort.clone().unwrap_or_else(|| local_sort.binding());
385 let commands: State<Option<ScrollCommand>> = cx.use_state(|| None);
386 let cursor: State<usize> = cx.use_state(|| 0);
387 let anchor: State<usize> = cx.use_state(|| 0);
388 type Sorted = (Rc<[FileEntry]>, FileSort, Rc<Vec<usize>>);
389 let cache: Rc<RefCell<Option<Sorted>>> = cx.use_state(|| Rc::new(RefCell::new(None))).peek().clone();
390 let order = sort.get();
391 let sorted_now = {
392 let mut cached = cache.borrow_mut();
393 match cached.as_ref() {
394 Some((entries, by, found)) if Rc::ptr_eq(entries, &self.entries) && *by == order => {
395 found.clone()
396 }
397 _ => {
398 let found = Rc::new(sorted(&self.entries, order));
399 *cached = Some((self.entries.clone(), order, found.clone()));
400 found
401 }
402 }
403 };
404
405 let count = sorted_now.len();
406 let active = cursor.get().min(count.saturating_sub(1));
407 let selected = Rc::new(selection.get());
408 let keyboard = cx.is_focus_visible();
409 let mode = self.mode;
410 let dates = self.entries.iter().any(|entry| entry.modified.is_some());
411 let id_of = {
412 let (entries, order) = (self.entries.clone(), sorted_now.clone());
413 move |position: usize| {
414 order.get(position).and_then(|i| entries.get(*i)).map_or_else(String::new, |e| e.id.clone())
415 }
416 };
417 let modifiers = cx.modifiers();
418 let apply: Rc<dyn Fn(usize, Modifiers)> = {
419 let (selection, cursor, anchor, id_of) =
420 (selection.clone(), cursor.clone(), anchor.clone(), id_of.clone());
421 Rc::new(move |position, held| {
422 let mut next = anchor.get();
423 selection.update(|set| {
424 next = selection::pick(set, mode, next, position, held, &id_of);
425 });
426 anchor.set(next);
427 cursor.set(position);
428 })
429 };
430 let pick: Rc<dyn Fn(usize)> = {
431 let apply = apply.clone();
432 let modifiers = modifiers.clone();
433 Rc::new(move |position| apply(position, modifiers.get()))
434 };
435
436 let mut crumbs = Breadcrumb::new(self.path.iter().cloned()).root_icon(IconName::House);
437 if let Some(go) = self.on_navigate.clone() {
438 crumbs = crumbs.on_navigate(move |depth| go(depth));
439 }
440 let crumbs = cx.child(&crumbs);
441 let crumbs = cx.container(
442 LayoutStyle {
443 padding: Insets::symmetric(theme.spacing.s2, theme.spacing.s3),
444 align: Align::Start,
445 ..LayoutStyle::stack(Axis::Vertical)
446 },
447 &[crumbs],
448 Paint::None,
449 );
450 let first_line = cx.child(&Divider::horizontal());
451 let mut titles = vec![cx.child(&SortHeader {
452 text: labels.file_name.clone(),
453 column: FileSortColumn::Name,
454 sort: sort.clone(),
455 width: None,
456 end: false,
457 })];
458 titles.push(cx.child(&SortHeader {
459 text: labels.file_size.clone(),
460 column: FileSortColumn::Size,
461 sort: sort.clone(),
462 width: Some(SIZE_WIDTH),
463 end: true,
464 }));
465 if dates {
466 titles.push(cx.child(&SortHeader {
467 text: labels.file_modified.clone(),
468 column: FileSortColumn::Modified,
469 sort: sort.clone(),
470 width: Some(MODIFIED_WIDTH),
471 end: true,
472 }));
473 }
474 let header = cx.container(
475 LayoutStyle {
476 spacing: theme.spacing.s2,
477 padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s3),
479 align: Align::Center,
480 ..LayoutStyle::stack(Axis::Horizontal)
481 },
482 &titles,
483 Paint::Quad(QuadPaint::new(theme.color(Semantic::Surface))),
484 );
485 let second_line = cx.child(&Divider::horizontal());
486
487 let body = if count == 0 {
488 let words = util::label(
489 cx,
490 &labels.folder_empty,
491 TextStyle::Callout,
492 None,
493 theme.color(Semantic::InkMuted),
494 );
495 cx.container(
496 LayoutStyle {
497 grow: 1.0,
498 min_height: Some(0.0),
499 padding: Insets::uniform(theme.spacing.s6),
500 align: Align::Center,
501 justify: Justify::Center,
502 ..LayoutStyle::stack(Axis::Vertical)
503 },
504 &[words],
505 Paint::None,
506 )
507 } else {
508 let (entries, order_now, open, shown, picker) = (
509 self.entries.clone(),
510 sorted_now.clone(),
511 self.on_open.clone(),
512 selected.clone(),
513 pick.clone(),
514 );
515 let id = id_of.clone();
516 let mut list = VirtualList::new(count, move |position| FileRow {
517 entry_index: order_now.get(position).copied().unwrap_or(0),
518 position,
519 entries: entries.clone(),
520 selected: shown.contains(&id(position)),
521 active: position == active,
522 keyboard,
523 dates,
524 pick: picker.clone(),
525 open: open.clone(),
526 })
527 .row_height(ROW_HEIGHT)
528 .bare()
529 .commands(commands.clone());
530 let (keyed_entries, keyed_order) = (self.entries.clone(), sorted_now.clone());
531 list = list.key(move |position| {
532 keyed_order
533 .get(position)
534 .and_then(|i| keyed_entries.get(*i))
535 .map_or(position as u64, |entry| util::hash_text(&entry.id))
536 });
537 cx.keyed_child(1, &list)
538 };
539 let root = cx.container(
540 LayoutStyle {
541 height: self.height,
542 grow: if self.height.is_some() { 0.0 } else { 1.0 },
543 min_height: Some(0.0),
544 min_width: Some(0.0),
545 overflow_hidden: true,
546 align: Align::Stretch,
547 align_self: Some(Align::Stretch),
548 ..LayoutStyle::stack(Axis::Vertical)
549 },
550 &[crumbs, first_line, header, second_line, body],
551 Paint::None,
552 );
553 let name = self.label.clone().unwrap_or_else(|| labels.file_browser_name.clone());
554 cx.semantics(root, Semantics::new(Role::ListBox).name(name).description(count.to_string()));
555 cx.focusable(root, theme.radii.sm);
556 let (entries, order_now, open, up, depth) = (
557 self.entries.clone(),
558 sorted_now,
559 self.on_open.clone(),
560 self.on_navigate.clone(),
561 self.path.len(),
562 );
563 let select_all = selection;
564 cx.on_key(
565 root,
566 Rc::new(move |key, held| {
567 let to = match key {
568 Key::ArrowUp => active.saturating_sub(1),
569 Key::ArrowDown => (active + 1).min(count.saturating_sub(1)),
570 Key::Home => 0,
571 Key::End => count.saturating_sub(1),
572 Key::Enter => {
573 if let (Some(open), Some(entry)) =
574 (&open, order_now.get(active).and_then(|i| entries.get(*i)))
575 {
576 open(entry);
577 }
578 return true;
579 }
580 Key::Space => {
581 if count > 0 {
582 apply(active, Modifiers { ctrl: true, ..Modifiers::default() });
583 }
584 return true;
585 }
586 Key::Backspace => {
587 if let (Some(up), true) = (&up, depth >= 2) {
588 up(depth - 2);
589 }
590 return true;
591 }
592 Key::Character(c) if selection::adds(held) && c.eq_ignore_ascii_case("a") => {
593 if mode != SelectionMode::Multiple {
594 return false;
595 }
596 select_all.set((0..count).map(&id_of).collect());
597 return true;
598 }
599 _ => return false,
600 };
601 if count == 0 {
602 return false;
603 }
604 if selection::adds(held) {
605 cursor.set(to);
606 } else {
607 apply(to, held);
608 }
609 commands.set(Some(ScrollCommand::Reveal(to)));
610 true
611 }),
612 );
613 root
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn folders_stay_first_in_either_direction() {
623 let entries = [
624 FileEntry::file("b.txt", 10),
625 FileEntry::folder("Z"),
626 FileEntry::file("A.txt", 30),
627 FileEntry::folder("c"),
628 ];
629 let by = |column, ascending| sorted(&entries, FileSort { column, ascending });
630 assert_eq!(by(FileSortColumn::Name, true), vec![3, 1, 2, 0]);
631 assert_eq!(by(FileSortColumn::Name, false), vec![1, 3, 0, 2]);
632 assert_eq!(by(FileSortColumn::Size, false), vec![3, 1, 2, 0]);
633 }
634}