1use crate::store::{BrowserEntry, BrowserPlace, ModelStore, PlaceKind, PINNED_KEY};
21use eframe::egui;
22use egui_extras::{Column, TableBuilder};
23use std::cmp::Ordering;
24
25#[derive(Clone, Copy)]
26pub struct FileExplorerOptions<'a> {
27 pub hit_prefix: &'a str,
28 pub empty_label: &'a str,
29 pub row_icon: &'a str,
30 pub current: Option<&'a str>,
31 pub allow_delete: bool,
32 pub allow_import: bool,
33 pub import_label: &'a str,
34 pub import_hit: &'a str,
35 pub show_cancel: bool,
36 pub confirm_label: Option<&'a str>,
42 pub extensions: &'a [&'a str],
43}
44
45impl<'a> FileExplorerOptions<'a> {
46 pub fn open(current: Option<&'a str>) -> Self {
47 Self {
48 hit_prefix: "open",
49 empty_label: "(no saved models)",
50 row_icon: "\u{1F5CE}",
51 current,
52 allow_delete: true,
53 allow_import: false,
54 import_label: "Upload\u{2026}",
55 import_hit: "upload",
56 show_cancel: true,
57 confirm_label: Some("Open"),
58 extensions: &["BREP.json", "json"],
59 }
60 }
61}
62
63#[derive(Default)]
64pub struct FileExplorerOutput {
65 pub activated: Option<String>,
68 pub selected: Option<String>,
73 pub picked: Option<String>,
77 pub remove: Option<String>,
78 pub import: bool,
79 pub cancel: bool,
80 pub hits: Vec<(String, egui::Rect)>,
81}
82
83#[derive(Clone, Copy, PartialEq, Eq, Default)]
85enum SortKey {
86 #[default]
87 Name,
88 Kind,
89 Size,
90 Date,
91}
92
93#[derive(Default)]
96pub struct FileExplorer {
97 query: String,
98 new_folder: String,
99 error: String,
100 selected: Option<String>,
105 selected_at: String,
108 history: Vec<String>,
110 forward: Vec<String>,
111 editing_path: Option<String>,
113 path_focus: bool,
115 nav_files: Vec<String>,
118 sort_key: SortKey,
120 sort_desc: bool,
121 show_hidden: bool,
124}
125
126impl FileExplorer {
127 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn selected(&self) -> Option<&str> {
135 self.selected.as_deref()
136 }
137
138 pub fn show_store(
140 &mut self,
141 ui: &mut egui::Ui,
142 store: &dyn ModelStore,
143 options: FileExplorerOptions<'_>,
144 ) -> FileExplorerOutput {
145 let entries = store.browser_entries(options.extensions);
146 let location = store.browser_location();
147 if location != self.selected_at {
148 self.selected = None;
149 self.selected_at = location.clone();
150 }
151 let places = store.browser_places();
152 let pins = Self::read_pins(store);
153 let mut out = FileExplorerOutput::default();
154
155 let selected_before = self.selected.clone();
156 self.show_nav_bar(ui, store, &location, options, &mut out);
157 if !self.error.is_empty() {
158 ui.colored_label(ui.visuals().error_fg_color, &self.error);
159 }
160 ui.add_space(2.0);
161 ui.separator();
162
163 bottom_panel(ui, "brep-file-explorer-footer", options.hit_prefix, |ui| {
170 self.show_footer(ui, options, &mut out)
171 });
172 ui.horizontal_top(|ui| {
173 if !places.is_empty() {
174 ui.vertical(|ui| {
175 self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
176 });
177 ui.separator();
178 }
179 ui.vertical(|ui| {
180 self.show_list(ui, store, &entries, options, &mut out);
181 });
182 });
183
184 self.handle_keys(ui, options, &mut out);
185 out.selected = self.selected.clone();
186 if out.picked.is_some() || self.selected != selected_before {
191 ui.ctx().request_repaint();
192 }
193 out
194 }
195
196 fn nav<F: FnOnce() -> Result<(), String>>(&mut self, store: &dyn ModelStore, action: F) {
202 let before = store.browser_location();
203 match action() {
204 Ok(()) => {
205 let after = store.browser_location();
206 if after != before {
207 self.history.push(before);
208 self.forward.clear();
209 }
210 self.error.clear();
211 self.selected = None;
212 }
213 Err(error) => self.error = error,
214 }
215 }
216
217 fn back(&mut self, store: &dyn ModelStore) {
220 if let Some(prev) = self.history.pop() {
221 let current = store.browser_location();
222 if store.browser_navigate(&prev).is_ok() {
223 self.forward.push(current);
224 self.selected = None;
225 self.error.clear();
226 } else {
227 self.history.push(prev);
228 }
229 }
230 }
231
232 fn forward_go(&mut self, store: &dyn ModelStore) {
233 if let Some(next) = self.forward.pop() {
234 let current = store.browser_location();
235 if store.browser_navigate(&next).is_ok() {
236 self.history.push(current);
237 self.selected = None;
238 self.error.clear();
239 } else {
240 self.forward.push(next);
241 }
242 }
243 }
244
245 fn show_nav_bar(
248 &mut self,
249 ui: &mut egui::Ui,
250 store: &dyn ModelStore,
251 location: &str,
252 options: FileExplorerOptions<'_>,
253 out: &mut FileExplorerOutput,
254 ) {
255 ui.horizontal(|ui| {
256 let back = ui.add_enabled(!self.history.is_empty(), egui::Button::new("\u{25C0}").small());
257 out.hits.push((format!("{}:back", options.hit_prefix), back.rect));
258 if back.clicked() {
259 self.back(store);
260 }
261 let fwd = {
262 let b = crate::icon_text::icon_button(ui, "\u{25B6}").small();
263 ui.add_enabled(!self.forward.is_empty(), b)
264 };
265 out.hits.push((format!("{}:forward", options.hit_prefix), fwd.rect));
266 if fwd.clicked() {
267 self.forward_go(store);
268 }
269 let up = ui.small_button("\u{2191}");
270 out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
271 if up.clicked() {
272 self.nav(store, || store.browser_up());
273 }
274 ui.separator();
275 self.show_breadcrumb(ui, store, location, options, out);
276 });
277 ui.horizontal(|ui| {
278 let create = ui.small_button("+ Folder");
279 out.hits
280 .push((format!("{}:new-folder", options.hit_prefix), create.rect));
281 let field = ui.add(
282 egui::TextEdit::singleline(&mut self.new_folder)
283 .hint_text("New folder")
284 .desired_width(140.0),
285 );
286 out.hits
287 .push((format!("{}:new-folder-name", options.hit_prefix), field.rect));
288 let submit = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
289 if (create.clicked() || submit) && !self.new_folder.trim().is_empty() {
290 match store.browser_create_dir(self.new_folder.trim()) {
291 Ok(()) => {
292 self.new_folder.clear();
293 self.error.clear();
294 }
295 Err(error) => self.error = error,
296 }
297 }
298 });
299 }
300
301 fn show_breadcrumb(
304 &mut self,
305 ui: &mut egui::Ui,
306 store: &dyn ModelStore,
307 location: &str,
308 options: FileExplorerOptions<'_>,
309 out: &mut FileExplorerOutput,
310 ) {
311 if self.editing_path.is_some() {
312 let mut buf = self.editing_path.take().unwrap();
313 let resp = ui.add(
314 egui::TextEdit::singleline(&mut buf)
315 .hint_text("type a path, Enter to go")
316 .desired_width(300.0),
317 );
318 out.hits
319 .push((format!("{}:path-edit", options.hit_prefix), resp.rect));
320 if self.path_focus {
321 resp.request_focus();
322 self.path_focus = false;
323 }
324 let go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
325 if go {
326 self.nav(store, || store.browser_navigate(&buf));
327 } else if !resp.lost_focus() {
328 self.editing_path = Some(buf); }
330 return;
332 }
333 let avail = ui.available_width();
334 egui::ScrollArea::horizontal()
335 .id_salt(format!("{}-crumbs", options.hit_prefix))
336 .max_width(avail)
337 .auto_shrink([false, true])
341 .show(ui, |ui| {
342 ui.horizontal(|ui| {
343 for (i, (label, path)) in breadcrumb_segments(location).into_iter().enumerate() {
344 if i > 0 {
345 ui.weak("\u{203A}"); }
347 let seg = ui.add(egui::Button::new(label).frame(false).small());
348 out.hits
349 .push((format!("{}:crumb:{i}", options.hit_prefix), seg.rect));
350 if seg.clicked() {
351 self.nav(store, || store.browser_navigate(&path));
352 }
353 }
354 let edit = {
355 let b = crate::icon_text::icon_button(ui, "\u{270E}").small();
356 ui.add(b)
357 }; out.hits
359 .push((format!("{}:path-edit-toggle", options.hit_prefix), edit.rect));
360 if edit.clicked() {
361 self.editing_path = Some(location.to_string());
362 self.path_focus = true;
363 }
364 });
365 });
366 }
367
368 #[allow(clippy::too_many_arguments)]
371 fn show_sidebar(
372 &mut self,
373 ui: &mut egui::Ui,
374 store: &dyn ModelStore,
375 places: &[BrowserPlace],
376 pins: &[String],
377 location: &str,
378 options: FileExplorerOptions<'_>,
379 out: &mut FileExplorerOutput,
380 ) {
381 ui.set_min_width(132.0);
382 ui.set_max_width(152.0);
383 egui::ScrollArea::vertical()
384 .id_salt(format!("{}-sidebar", options.hit_prefix))
385 .auto_shrink([false, false])
386 .show(ui, |ui| {
387 ui.weak("Places");
388 for place in places {
389 let glyph = place_glyph(place.kind);
390 let resp = ui.selectable_label(false, format!("{glyph} {}", place.label));
391 out.hits
392 .push((format!("{}:place:{}", options.hit_prefix, place.label), resp.rect));
393 if resp.clicked() {
394 let loc = place.location.clone();
395 self.nav(store, || store.browser_navigate(&loc));
396 }
397 }
398 if !pins.is_empty() {
399 ui.add_space(6.0);
400 ui.weak("Pinned");
401 for pin in pins {
402 ui.horizontal(|ui| {
403 let label = pin.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(pin);
404 let resp = ui.selectable_label(false, format!("\u{1F4CC} {label}"));
405 out.hits
406 .push((format!("{}:pin:{label}", options.hit_prefix), resp.rect));
407 if resp.clicked() {
408 let loc = pin.clone();
409 self.nav(store, || store.browser_navigate(&loc));
410 }
411 let x = {
412 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
413 ui.add(b)
414 };
415 out.hits
416 .push((format!("{}:unpin:{label}", options.hit_prefix), x.rect));
417 if x.clicked() {
418 Self::set_pin(store, pin, false);
419 }
420 });
421 }
422 }
423 ui.add_space(6.0);
424 let pinned_now = pins.iter().any(|p| p == location);
425 let label = if pinned_now {
426 "\u{2715} Unpin folder"
427 } else {
428 "\u{1F4CC} Pin folder"
429 };
430 let btn = ui.small_button(label);
431 out.hits
432 .push((format!("{}:pin-current", options.hit_prefix), btn.rect));
433 if btn.clicked() {
434 Self::set_pin(store, location, !pinned_now);
435 }
436 });
437 }
438
439 fn read_pins(store: &dyn ModelStore) -> Vec<String> {
441 store
442 .read(PINNED_KEY)
443 .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
444 .unwrap_or_default()
445 }
446
447 fn set_pin(store: &dyn ModelStore, location: &str, want: bool) {
449 let mut pins = Self::read_pins(store);
450 let has = pins.iter().any(|p| p == location);
451 if want && !has {
452 pins.push(location.to_string());
453 } else if !want && has {
454 pins.retain(|p| p != location);
455 } else {
456 return;
457 }
458 let _ = store.write(
459 PINNED_KEY,
460 &serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into()),
461 );
462 }
463
464 fn show_list(
467 &mut self,
468 ui: &mut egui::Ui,
469 store: &dyn ModelStore,
470 entries: &[BrowserEntry],
471 options: FileExplorerOptions<'_>,
472 out: &mut FileExplorerOutput,
473 ) {
474 ui.horizontal(|ui| {
476 let hidden = ui.checkbox(&mut self.show_hidden, "Hidden");
477 out.hits
478 .push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
479 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
480 let search = ui.add(
481 egui::TextEdit::singleline(&mut self.query)
482 .hint_text("Filter files")
483 .desired_width(150.0),
484 );
485 out.hits
486 .push((format!("{}:filter", options.hit_prefix), search.rect));
487 });
488 });
489
490 let query = self.query.trim().to_ascii_lowercase();
491 let mut visible: Vec<&BrowserEntry> = entries
492 .iter()
493 .filter(|entry| {
494 let hidden = entry.name.starts_with('.');
496 (self.show_hidden || !hidden)
497 && (entry.is_dir
498 || query.is_empty()
499 || entry.name.to_ascii_lowercase().contains(&query))
500 })
501 .collect();
502 let (key, desc) = (self.sort_key, self.sort_desc);
504 visible.sort_by(|a, b| {
505 b.is_dir.cmp(&a.is_dir).then_with(|| {
506 let ord = match key {
507 SortKey::Name => cmp_name(a, b),
508 SortKey::Kind => entry_kind(a).cmp(&entry_kind(b)).then_with(|| cmp_name(a, b)),
509 SortKey::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)).then_with(|| cmp_name(a, b)),
510 SortKey::Date => cmp_opt(a.modified, b.modified).then_with(|| cmp_name(a, b)),
511 };
512 if desc { ord.reverse() } else { ord }
513 })
514 });
515 self.nav_files = visible
516 .iter()
517 .filter(|entry| !entry.is_dir)
518 .map(|entry| entry.identity.clone())
519 .collect();
520
521 if visible.is_empty() {
522 ui.separator();
523 ui.weak(options.empty_label);
524 return;
525 }
526
527 let row_h = egui::TextStyle::Body.resolve(ui.style()).size + 6.0;
531 let mut clicked_sort: Option<SortKey> = None;
532 TableBuilder::new(ui)
533 .id_salt(format!("{}-table", options.hit_prefix))
534 .striped(true)
535 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
536 .column(Column::remainder().at_least(150.0).clip(true)) .column(Column::auto().at_least(72.0)) .column(Column::auto().at_least(66.0)) .column(Column::auto().at_least(120.0)) .column(Column::auto().at_least(20.0)) .header(row_h, |mut header| {
542 for (label, col) in [
543 ("Name", SortKey::Name),
544 ("Type", SortKey::Kind),
545 ("Size", SortKey::Size),
546 ("Date", SortKey::Date),
547 ] {
548 header.col(|ui| {
549 let arrow = if key == col {
550 if desc {
551 " \u{25BE}"
552 } else {
553 " \u{25B4}"
554 }
555 } else {
556 ""
557 };
558 let btn =
559 ui.add(egui::Button::new(format!("{label}{arrow}")).frame(false));
560 out.hits.push((
561 format!("{}:sort:{}", options.hit_prefix, sort_slug(col)),
562 btn.rect,
563 ));
564 if btn.clicked() {
565 clicked_sort = Some(col);
566 }
567 });
568 }
569 header.col(|_ui| {});
570 })
571 .body(|body| {
572 body.rows(row_h, visible.len(), |mut row| {
573 let entry = visible[row.index()];
574 let icon = if entry.is_dir {
575 "\u{1F5C0}"
576 } else {
577 options.row_icon
578 };
579 let highlight = match &self.selected {
582 Some(sel) => !entry.is_dir && sel == &entry.identity,
583 None => options.current == Some(entry.identity.as_str()),
584 };
585 row.col(|ui| {
586 let r = crate::icon_text::selectable_icon_label(
587 ui,
588 highlight,
589 &format!("{icon} {}", entry.name),
590 );
591 out.hits
592 .push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
593 if r.double_clicked() {
594 if entry.is_dir {
595 self.nav(store, || store.browser_enter(&entry.identity));
596 } else {
597 self.selected = Some(entry.identity.clone());
598 out.activated = Some(entry.identity.clone());
599 }
600 } else if r.clicked() {
601 if entry.is_dir {
602 self.nav(store, || store.browser_enter(&entry.identity));
603 } else {
604 self.selected = Some(entry.identity.clone());
605 out.picked = Some(entry.identity.clone());
606 }
607 }
608 });
609 row.col(|ui| {
610 ui.weak(entry_kind(entry));
611 });
612 row.col(|ui| {
613 ui.weak(fmt_size(entry.size));
614 });
615 row.col(|ui| {
616 ui.weak(fmt_date(entry.modified));
617 });
618 row.col(|ui| {
619 if options.allow_delete && !entry.is_dir {
620 let del = {
621 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
622 ui.add(b)
623 };
624 out.hits.push((format!("del:{}", entry.name), del.rect));
625 if del.clicked() {
626 out.remove = Some(entry.identity.clone());
627 }
628 }
629 });
630 });
631 });
632 if let Some(col) = clicked_sort {
633 if self.sort_key == col {
634 self.sort_desc = !self.sort_desc;
635 } else {
636 self.sort_key = col;
637 self.sort_desc = false;
638 }
639 }
640 }
641
642 fn handle_keys(
646 &mut self,
647 ui: &egui::Ui,
648 options: FileExplorerOptions<'_>,
649 out: &mut FileExplorerOutput,
650 ) {
651 if options.confirm_label.is_none() || ui.ctx().egui_wants_keyboard_input() {
652 return;
653 }
654 if self.nav_files.is_empty() {
655 return;
656 }
657 let (down, up, enter) = ui.input(|i| {
658 (
659 i.key_pressed(egui::Key::ArrowDown),
660 i.key_pressed(egui::Key::ArrowUp),
661 i.key_pressed(egui::Key::Enter),
662 )
663 });
664 if down || up {
665 let current = self
666 .selected
667 .as_deref()
668 .and_then(|s| self.nav_files.iter().position(|f| f == s));
669 let next = match current {
670 Some(i) if down => (i + 1).min(self.nav_files.len() - 1),
671 Some(i) => i.saturating_sub(1),
672 None => 0,
673 };
674 self.selected = Some(self.nav_files[next].clone());
675 }
676 if enter {
677 if let Some(sel) = &self.selected {
678 out.activated = Some(sel.clone());
679 }
680 }
681 }
682
683 fn show_footer(
684 &self,
685 ui: &mut egui::Ui,
686 options: FileExplorerOptions<'_>,
687 out: &mut FileExplorerOutput,
688 ) {
689 ui.separator();
690 ui.horizontal(|ui| {
691 if let Some(label) = options.confirm_label {
692 let confirm =
693 ui.add_enabled(self.selected.is_some(), egui::Button::new(label));
694 out.hits
695 .push((format!("{}:confirm", options.hit_prefix), confirm.rect));
696 if confirm.clicked() {
697 out.activated = self.selected.clone();
698 }
699 }
700 if options.allow_import {
701 let import = {
702 let label = format!("\u{2B06} {}", options.import_label);
703 let b = crate::icon_text::icon_button(ui, &label);
704 ui.add(b)
705 };
706 out.hits.push((options.import_hit.into(), import.rect));
707 out.import = import.clicked();
708 }
709 if options.show_cancel {
710 let cancel = ui.button("Cancel");
711 out.hits.push(("cancel".into(), cancel.rect));
712 out.cancel = cancel.clicked();
713 }
714 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
716 if let Some(sel) = &self.selected {
717 let name = sel.rsplit(['/', '\\']).next().unwrap_or(sel);
718 ui.add(
722 egui::Label::new(egui::RichText::new(name).weak()).truncate(),
723 );
724 }
725 });
726 });
727 }
728
729}
730
731pub fn dialog_body<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
741 let screen = ui.ctx().content_rect().size();
742 let max = egui::vec2((screen.x - 40.0).max(320.0), (screen.y - 80.0).max(240.0));
743 egui::Resize::default()
744 .id_salt("brep-file-dialog-size")
745 .with_stroke(false)
748 .min_size(egui::vec2(460.0_f32.min(max.x), 300.0_f32.min(max.y)))
749 .max_size(max)
750 .default_size(egui::vec2(620.0_f32.min(max.x), 520.0_f32.min(max.y)))
751 .show(ui, add_contents)
752}
753
754pub fn dialog_footer<R>(
761 ui: &mut egui::Ui,
762 salt: &str,
763 add_contents: impl FnOnce(&mut egui::Ui) -> R,
764) -> R {
765 bottom_panel(ui, "brep-file-dialog-footer", salt, add_contents)
766}
767
768fn bottom_panel<R>(
772 ui: &mut egui::Ui,
773 base: &str,
774 salt: &str,
775 add_contents: impl FnOnce(&mut egui::Ui) -> R,
776) -> R {
777 egui::containers::panel::Panel::bottom(egui::Id::new((base, salt)))
778 .frame(egui::Frame::NONE)
779 .show_separator_line(false)
780 .show(ui, add_contents)
781 .inner
782}
783
784fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
786 a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
787}
788
789fn cmp_opt(a: Option<f64>, b: Option<f64>) -> Ordering {
791 match (a, b) {
792 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
793 (Some(_), None) => Ordering::Less,
794 (None, Some(_)) => Ordering::Greater,
795 (None, None) => Ordering::Equal,
796 }
797}
798
799fn entry_kind(entry: &BrowserEntry) -> String {
801 if entry.is_dir {
802 return "Folder".into();
803 }
804 let lower = entry.name.to_ascii_lowercase();
805 if lower.ends_with(".brep.json") {
806 return "BREP model".into();
807 }
808 match entry.name.rsplit_once('.') {
809 Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_uppercase(),
810 _ => "File".into(),
811 }
812}
813
814fn sort_slug(key: SortKey) -> &'static str {
816 match key {
817 SortKey::Name => "name",
818 SortKey::Kind => "type",
819 SortKey::Size => "size",
820 SortKey::Date => "date",
821 }
822}
823
824fn fmt_size(size: Option<u64>) -> String {
826 match size {
827 None => "\u{2014}".into(),
828 Some(bytes) => {
829 let b = bytes as f64;
830 if bytes < 1024 {
831 format!("{bytes} B")
832 } else if b < 1024.0 * 1024.0 {
833 format!("{:.1} KB", b / 1024.0)
834 } else if b < 1024.0 * 1024.0 * 1024.0 {
835 format!("{:.1} MB", b / (1024.0 * 1024.0))
836 } else {
837 format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
838 }
839 }
840 }
841}
842
843fn fmt_date(modified: Option<f64>) -> String {
845 let Some(secs) = modified else {
846 return "\u{2014}".into();
847 };
848 let days = (secs / 86_400.0).floor() as i64;
849 let (y, m, d) = civil_from_days(days);
850 let sod = ((secs as i64) % 86_400 + 86_400) % 86_400;
851 format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", sod / 3600, (sod % 3600) / 60)
852}
853
854fn civil_from_days(z: i64) -> (i64, u32, u32) {
858 let z = z + 719_468;
859 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
860 let doe = z - era * 146_097;
861 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
862 let y = yoe + era * 400;
863 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
864 let mp = (5 * doy + 2) / 153;
865 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
866 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
867 (if m <= 2 { y + 1 } else { y }, m, d)
868}
869
870fn breadcrumb_segments(location: &str) -> Vec<(String, String)> {
874 let mut out = vec![("/".to_string(), "/".to_string())];
875 let mut acc = String::new();
876 for comp in location.split('/').filter(|s| !s.is_empty()) {
877 acc.push('/');
878 acc.push_str(comp);
879 out.push((comp.to_string(), acc.clone()));
880 }
881 out
882}
883
884fn place_glyph(kind: PlaceKind) -> &'static str {
886 match kind {
887 PlaceKind::Home => "\u{2302}", PlaceKind::Documents => "\u{1F5CE}", PlaceKind::Downloads => "\u{2B07}", PlaceKind::Models => "\u{1F5C0}", PlaceKind::Root => "/",
892 }
893}
894
895