1use crate::automation::hit_keys::HitKeyDoc;
21use crate::store::{BrowserEntry, BrowserPlace, ModelStore, PlaceKind, PINNED_KEY};
22use eframe::egui;
23use egui_extras::{Column, TableBuilder};
24use std::cmp::Ordering;
25
26#[derive(Clone, Copy)]
27pub struct FileExplorerOptions<'a> {
28 pub hit_prefix: &'a str,
29 pub empty_label: &'a str,
30 pub row_icon: &'a str,
31 pub current: Option<&'a str>,
32 pub allow_delete: bool,
33 pub allow_import: bool,
34 pub import_label: &'a str,
35 pub import_hit: &'a str,
36 pub show_cancel: bool,
37 pub confirm_label: Option<&'a str>,
43 pub extensions: &'a [&'a str],
44}
45
46impl<'a> FileExplorerOptions<'a> {
47 pub fn open(current: Option<&'a str>) -> Self {
48 Self {
49 hit_prefix: "open",
50 empty_label: "(no saved models)",
51 row_icon: "\u{1F5CE}",
52 current,
53 allow_delete: true,
54 allow_import: false,
55 import_label: "Upload\u{2026}",
56 import_hit: "upload",
57 show_cancel: true,
58 confirm_label: Some("Open"),
59 extensions: crate::store::MODEL_EXTENSIONS,
60 }
61 }
62}
63
64pub static HIT_KEYS: &[HitKeyDoc] = &[
76 HitKeyDoc { panel: "file", prefix: "open:", meaning: "the Open modal's explorer: open:<file name> (a row), open:confirm, open:filter, open:back / forward / up, open:path-edit, open:path-edit-toggle, open:crumb:<i>, open:place:<label>, open:pin:<label>, open:unpin:<label>, open:pin-current, open:new-folder, open:new-folder-name, open:hidden-toggle, open:sort:<column>", command: Some("doc_load") },
77 HitKeyDoc { panel: "file", prefix: "import:", meaning: "the Import modal's explorer, same key set under the `import` prefix, plus import:upload", command: Some("doc_import") },
78 HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "the Save As modal's destination explorer (saveas:file:<key>) and saveas:upload", command: Some("doc_json") },
79 HitKeyDoc { panel: "file", prefix: "insert:", meaning: "the Insert-component modal: insert:lib:<name> for a parts-library entry, insert:model:<key> for the store explorer, insert:upload", command: None },
80 HitKeyDoc { panel: "file", prefix: "del:", meaning: "delete the file on that explorer row (del:<file name>)", command: None },
81 HitKeyDoc { panel: "file", prefix: "upload", meaning: "the explorer's Upload… action where the platform has real-file interchange", command: None },
82 HitKeyDoc { panel: "stepparts", prefix: "steplib:dest:", meaning: "the parts-library destination explorer, the same key set under the `steplib:dest` prefix", command: None },
83];
84
85#[derive(Default)]
86pub struct FileExplorerOutput {
87 pub activated: Option<String>,
90 pub selected: Option<String>,
95 pub picked: Option<String>,
99 pub remove: Option<String>,
100 pub import: bool,
101 pub cancel: bool,
102 pub hits: Vec<(String, egui::Rect)>,
103}
104
105#[derive(Clone, Copy, PartialEq, Eq, Default)]
107enum SortKey {
108 #[default]
109 Name,
110 Kind,
111 Size,
112 Date,
113}
114
115#[derive(Default)]
118pub struct FileExplorer {
119 query: String,
120 new_folder: String,
121 error: String,
122 selected: Option<String>,
127 selected_at: String,
130 history: Vec<String>,
132 forward: Vec<String>,
133 editing_path: Option<String>,
135 path_focus: bool,
137 nav_files: Vec<String>,
140 sort_key: SortKey,
142 sort_desc: bool,
143 show_hidden: bool,
146}
147
148impl FileExplorer {
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn selected(&self) -> Option<&str> {
157 self.selected.as_deref()
158 }
159
160 pub fn clear_selection(&mut self) {
166 self.selected = None;
167 }
168
169 pub fn show_store(
171 &mut self,
172 ui: &mut egui::Ui,
173 store: &dyn ModelStore,
174 options: FileExplorerOptions<'_>,
175 ) -> FileExplorerOutput {
176 let entries = store.browser_entries(options.extensions);
177 let location = store.browser_location();
178 if location != self.selected_at {
179 self.selected = None;
180 self.selected_at = location.clone();
181 }
182 let places = store.browser_places();
183 let pins = Self::read_pins(store);
184 let mut out = FileExplorerOutput::default();
185
186 let selected_before = self.selected.clone();
187 let compact = crate::viewport::touch::compact(ui.ctx());
188 if compact {
189 ui.horizontal_wrapped(|ui| {
190 let places_menu = ui.menu_button("Places", |ui| {
191 ui.set_height((ui.ctx().content_rect().height() - 100.0).clamp(80.0, 300.0));
192 self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
193 });
194 out.hits.push((format!("{}:places-menu", options.hit_prefix), places_menu.response.rect));
195 let up = ui.add(egui::Button::new("Up").min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
196 out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
197 if up.clicked() { self.nav(store, || store.browser_up()); }
198 let folder = ui.menu_button("Folder", |ui| {
199 self.show_nav_bar(ui, store, &location, options, &mut out);
200 let hidden = ui.checkbox(&mut self.show_hidden, "Show hidden files");
201 out.hits.push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
202 });
203 out.hits.push((format!("{}:folder-menu", options.hit_prefix), folder.response.rect));
204 let filter_width = ui.available_width().clamp(60.0, 150.0);
205 let filter = ui.add(egui::TextEdit::singleline(&mut self.query).margin(crate::viewport::touch::text_edit_margin(ui)).hint_text("Filter files")
206 .desired_width(filter_width).min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
207 out.hits.push((format!("{}:filter", options.hit_prefix), filter.rect));
208 });
209 } else { self.show_nav_bar(ui, store, &location, options, &mut out); }
210 if !self.error.is_empty() {
211 ui.colored_label(ui.visuals().error_fg_color, &self.error);
212 }
213 ui.add_space(2.0);
214 ui.separator();
215
216 bottom_panel(ui, "brep-file-explorer-footer", options.hit_prefix, |ui| {
223 self.show_footer(ui, options, &mut out)
224 });
225 if compact {
226 self.show_list(ui, store, &entries, options, &mut out);
227 } else { ui.horizontal_top(|ui| {
228 if !places.is_empty() {
229 ui.vertical(|ui| {
230 self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
231 });
232 ui.separator();
233 }
234 ui.vertical(|ui| {
235 self.show_list(ui, store, &entries, options, &mut out);
236 });
237 }); }
238
239 self.handle_keys(ui, options, &mut out);
240 out.selected = self.selected.clone();
241 if out.picked.is_some() || self.selected != selected_before {
246 ui.ctx().request_repaint();
247 }
248 out
249 }
250
251 fn nav<F: FnOnce() -> Result<(), String>>(&mut self, store: &dyn ModelStore, action: F) {
257 let before = store.browser_location();
258 match action() {
259 Ok(()) => {
260 let after = store.browser_location();
261 if after != before {
262 self.history.push(before);
263 self.forward.clear();
264 }
265 self.error.clear();
266 self.selected = None;
267 }
268 Err(error) => self.error = error,
269 }
270 }
271
272 fn back(&mut self, store: &dyn ModelStore) {
275 if let Some(prev) = self.history.pop() {
276 let current = store.browser_location();
277 if store.browser_navigate(&prev).is_ok() {
278 self.forward.push(current);
279 self.selected = None;
280 self.error.clear();
281 } else {
282 self.history.push(prev);
283 }
284 }
285 }
286
287 fn forward_go(&mut self, store: &dyn ModelStore) {
288 if let Some(next) = self.forward.pop() {
289 let current = store.browser_location();
290 if store.browser_navigate(&next).is_ok() {
291 self.history.push(current);
292 self.selected = None;
293 self.error.clear();
294 } else {
295 self.forward.push(next);
296 }
297 }
298 }
299
300 fn show_nav_bar(
303 &mut self,
304 ui: &mut egui::Ui,
305 store: &dyn ModelStore,
306 location: &str,
307 options: FileExplorerOptions<'_>,
308 out: &mut FileExplorerOutput,
309 ) {
310 ui.horizontal(|ui| {
311 let back = ui.add_enabled(!self.history.is_empty(), egui::Button::new("\u{25C0}").small()
312 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
313 out.hits.push((format!("{}:back", options.hit_prefix), back.rect));
314 if back.clicked() {
315 self.back(store);
316 }
317 let fwd = {
318 let b = crate::icon_text::icon_button(ui, "\u{25B6}").small()
319 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO));
320 ui.add_enabled(!self.forward.is_empty(), b)
321 };
322 out.hits.push((format!("{}:forward", options.hit_prefix), fwd.rect));
323 if fwd.clicked() {
324 self.forward_go(store);
325 }
326 let up = ui.add(egui::Button::new("\u{2191}").small()
327 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
328 out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
329 if up.clicked() {
330 self.nav(store, || store.browser_up());
331 }
332 ui.separator();
333 self.show_breadcrumb(ui, store, location, options, out);
334 });
335 ui.horizontal(|ui| {
336 let create = ui.add(egui::Button::new("+ Folder").small()
337 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
338 out.hits
339 .push((format!("{}:new-folder", options.hit_prefix), create.rect));
340 let field = ui.add(
341 egui::TextEdit::singleline(&mut self.new_folder).margin(crate::viewport::touch::text_edit_margin(ui))
342 .hint_text("New folder")
343 .desired_width(140.0)
344 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)),
345 );
346 out.hits
347 .push((format!("{}:new-folder-name", options.hit_prefix), field.rect));
348 let submit = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
349 if (create.clicked() || submit) && !self.new_folder.trim().is_empty() {
350 match store.browser_create_dir(self.new_folder.trim()) {
351 Ok(()) => {
352 self.new_folder.clear();
353 self.error.clear();
354 }
355 Err(error) => self.error = error,
356 }
357 }
358 });
359 }
360
361 fn show_breadcrumb(
364 &mut self,
365 ui: &mut egui::Ui,
366 store: &dyn ModelStore,
367 location: &str,
368 options: FileExplorerOptions<'_>,
369 out: &mut FileExplorerOutput,
370 ) {
371 if self.editing_path.is_some() {
372 let mut buf = self.editing_path.take().unwrap();
373 let resp = ui.add(
374 egui::TextEdit::singleline(&mut buf).margin(crate::viewport::touch::text_edit_margin(ui))
375 .hint_text("type a path, Enter to go")
376 .desired_width(300.0),
377 );
378 out.hits
379 .push((format!("{}:path-edit", options.hit_prefix), resp.rect));
380 if self.path_focus {
381 resp.request_focus();
382 self.path_focus = false;
383 }
384 let go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
385 if go {
386 self.nav(store, || store.browser_navigate(&buf));
387 } else if !resp.lost_focus() {
388 self.editing_path = Some(buf); }
390 return;
392 }
393 let edit = ui.add(crate::icon_text::icon_button(ui, "\u{270E}").small()
397 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO))); out.hits
399 .push((format!("{}:path-edit-toggle", options.hit_prefix), edit.rect));
400 if edit.on_hover_text("Type a path").clicked() {
401 self.editing_path = Some(location.to_string());
402 self.path_focus = true;
403 }
404 let avail = ui.available_width();
405 egui::ScrollArea::horizontal()
406 .id_salt(format!("{}-crumbs", options.hit_prefix))
407 .max_width(avail)
408 .auto_shrink([false, true])
412 .stick_to_right(true)
415 .show(ui, |ui| {
416 ui.horizontal(|ui| {
417 for (i, (label, path)) in breadcrumb_segments(location).into_iter().enumerate() {
418 if i > 0 {
419 ui.weak("\u{203A}"); }
421 let seg = ui.add(egui::Button::new(label).frame(false).small()
422 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
423 out.hits
424 .push((format!("{}:crumb:{i}", options.hit_prefix), seg.rect));
425 if seg.clicked() {
426 self.nav(store, || store.browser_navigate(&path));
427 }
428 }
429 });
430 });
431 }
432
433 #[allow(clippy::too_many_arguments)]
436 fn show_sidebar(
437 &mut self,
438 ui: &mut egui::Ui,
439 store: &dyn ModelStore,
440 places: &[BrowserPlace],
441 pins: &[String],
442 location: &str,
443 options: FileExplorerOptions<'_>,
444 out: &mut FileExplorerOutput,
445 ) {
446 ui.set_min_width(132.0);
447 ui.set_max_width(152.0);
448 egui::ScrollArea::vertical()
449 .id_salt(format!("{}-sidebar", options.hit_prefix))
450 .auto_shrink([false, false])
451 .show(ui, |ui| {
452 ui.weak("Places");
453 for place in places {
454 let caption = format!("{} {}", place_glyph(place.kind), place.label);
455 let resp = ui.add(sidebar_row(ui, &caption));
456 out.hits
457 .push((format!("{}:place:{}", options.hit_prefix, place.label), resp.rect));
458 if resp.clicked() {
459 let loc = place.location.clone();
460 self.nav(store, || store.browser_navigate(&loc));
461 }
462 }
463 if !pins.is_empty() {
464 ui.add_space(6.0);
465 ui.weak("Pinned");
466 for pin in pins {
467 ui.horizontal(|ui| {
468 let label = pin.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(pin);
469 let caption = format!("{PIN_GLYPH} {label}");
470 let resp = ui.add(sidebar_row(ui, &caption));
471 out.hits
472 .push((format!("{}:pin:{label}", options.hit_prefix), resp.rect));
473 if resp.clicked() {
474 let loc = pin.clone();
475 self.nav(store, || store.browser_navigate(&loc));
476 }
477 let x = {
478 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
479 ui.add(b)
480 };
481 out.hits
482 .push((format!("{}:unpin:{label}", options.hit_prefix), x.rect));
483 if x.clicked() {
484 Self::set_pin(store, pin, false);
485 }
486 });
487 }
488 }
489 ui.add_space(6.0);
490 let pinned_now = pins.iter().any(|p| p == location);
491 let label = if pinned_now { "\u{2715} Unpin folder" } else { "Pin folder" };
492 let btn = ui.add(crate::icon_text::icon_button(ui, label).small());
493 out.hits
494 .push((format!("{}:pin-current", options.hit_prefix), btn.rect));
495 if btn.clicked() {
496 Self::set_pin(store, location, !pinned_now);
497 }
498 });
499 }
500
501 fn read_pins(store: &dyn ModelStore) -> Vec<String> {
503 store
504 .read(PINNED_KEY)
505 .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
506 .unwrap_or_default()
507 }
508
509 fn set_pin(store: &dyn ModelStore, location: &str, want: bool) {
511 let mut pins = Self::read_pins(store);
512 let has = pins.iter().any(|p| p == location);
513 if want && !has {
514 pins.push(location.to_string());
515 } else if !want && has {
516 pins.retain(|p| p != location);
517 } else {
518 return;
519 }
520 let _ = store.write(
521 PINNED_KEY,
522 &serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into()),
523 );
524 }
525
526 fn show_list(
529 &mut self,
530 ui: &mut egui::Ui,
531 store: &dyn ModelStore,
532 entries: &[BrowserEntry],
533 options: FileExplorerOptions<'_>,
534 out: &mut FileExplorerOutput,
535 ) {
536 if !crate::viewport::touch::compact(ui.ctx()) { ui.horizontal(|ui| {
538 let hidden = ui.checkbox(&mut self.show_hidden, "Hidden");
539 out.hits
540 .push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
541 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
542 let search = ui.add(
543 egui::TextEdit::singleline(&mut self.query).margin(crate::viewport::touch::text_edit_margin(ui))
544 .hint_text("Filter files")
545 .desired_width(150.0),
546 );
547 out.hits
548 .push((format!("{}:filter", options.hit_prefix), search.rect));
549 });
550 }); }
551
552 let query = self.query.trim().to_ascii_lowercase();
553 let mut visible: Vec<&BrowserEntry> = entries
554 .iter()
555 .filter(|entry| {
556 let hidden = entry.name.starts_with('.');
558 (self.show_hidden || !hidden)
559 && (entry.is_dir
560 || query.is_empty()
561 || entry.name.to_ascii_lowercase().contains(&query))
562 })
563 .collect();
564 let (key, desc) = (self.sort_key, self.sort_desc);
566 visible.sort_by(|a, b| {
567 b.is_dir.cmp(&a.is_dir).then_with(|| {
568 let ord = match key {
569 SortKey::Name => cmp_name(a, b),
570 SortKey::Kind => entry_kind(a).cmp(&entry_kind(b)).then_with(|| cmp_name(a, b)),
571 SortKey::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)).then_with(|| cmp_name(a, b)),
572 SortKey::Date => cmp_opt(a.modified, b.modified).then_with(|| cmp_name(a, b)),
573 };
574 if desc { ord.reverse() } else { ord }
575 })
576 });
577 self.nav_files = visible
578 .iter()
579 .filter(|entry| !entry.is_dir)
580 .map(|entry| entry.identity.clone())
581 .collect();
582
583 if visible.is_empty() {
584 ui.separator();
585 ui.weak(options.empty_label);
586 return;
587 }
588
589 if crate::viewport::touch::compact(ui.ctx()) {
590 let sort = ui.menu_button("Sort", |ui| {
591 for (label, key) in [("Name", SortKey::Name), ("Type", SortKey::Kind), ("Size", SortKey::Size), ("Date", SortKey::Date)] {
592 if ui.selectable_label(self.sort_key == key, label).clicked() {
593 if self.sort_key == key { self.sort_desc = !self.sort_desc; }
594 else { self.sort_key = key; self.sort_desc = false; }
595 ui.close();
596 }
597 }
598 });
599 out.hits.push((format!("{}:sort-menu", options.hit_prefix), sort.response.rect));
600 let row_h = crate::viewport::touch::target_size(ui.ctx(), egui::vec2(0.0, egui::TextStyle::Body.resolve(ui.style()).size + 6.0)).y;
601 egui::ScrollArea::vertical().id_salt(format!("{}-compact-list", options.hit_prefix)).show(ui, |ui| {
602 for entry in visible {
603 ui.horizontal(|ui| {
604 let icon = if entry.is_dir { "\u{1F5C0}" } else { options.row_icon };
605 let highlight = self.selected.as_deref().or(options.current) == Some(entry.identity.as_str());
606 let deletable = options.allow_delete && !entry.is_dir;
607 let width = (ui.available_width() - if deletable { 44.0 + ui.spacing().item_spacing.x } else { 0.0 }).max(44.0);
608 let text = format!("{icon} {}", entry.name);
609 let r = ui.add_sized([width, row_h], crate::icon_text::icon_button(ui, &text).selected(highlight).truncate());
610 out.hits.push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
611 if r.double_clicked() && !entry.is_dir {
612 self.selected = Some(entry.identity.clone()); out.activated = Some(entry.identity.clone());
613 } else if r.clicked() {
614 if entry.is_dir { self.nav(store, || store.browser_enter(&entry.identity)); }
615 else { self.selected = Some(entry.identity.clone()); out.picked = Some(entry.identity.clone()); }
616 }
617 if deletable {
618 let delete = ui.add(crate::icon_text::icon_button(ui, "\u{2715}").small()
619 .min_size(crate::viewport::touch::target_size(ui.ctx(), egui::Vec2::ZERO)));
620 out.hits.push((format!("del:{}", entry.name), delete.rect));
621 if delete.clicked() { out.remove = Some(entry.identity.clone()); }
622 }
623 });
624 }
625 });
626 return;
627 }
628
629 let row_h = egui::TextStyle::Body.resolve(ui.style()).size + 6.0;
633 let mut clicked_sort: Option<SortKey> = None;
634 TableBuilder::new(ui)
635 .id_salt(format!("{}-table", options.hit_prefix))
636 .striped(true)
637 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
638 .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| {
644 for (label, col) in [
645 ("Name", SortKey::Name),
646 ("Type", SortKey::Kind),
647 ("Size", SortKey::Size),
648 ("Date", SortKey::Date),
649 ] {
650 header.col(|ui| {
651 let arrow = if key == col {
652 if desc {
653 " \u{25BE}"
654 } else {
655 " \u{25B4}"
656 }
657 } else {
658 ""
659 };
660 let btn =
661 ui.add(egui::Button::new(format!("{label}{arrow}")).frame(false));
662 out.hits.push((
663 format!("{}:sort:{}", options.hit_prefix, sort_slug(col)),
664 btn.rect,
665 ));
666 if btn.clicked() {
667 clicked_sort = Some(col);
668 }
669 });
670 }
671 header.col(|_ui| {});
672 })
673 .body(|body| {
674 body.rows(row_h, visible.len(), |mut row| {
675 let entry = visible[row.index()];
676 let icon = if entry.is_dir {
677 "\u{1F5C0}"
678 } else {
679 options.row_icon
680 };
681 let highlight = match &self.selected {
684 Some(sel) => !entry.is_dir && sel == &entry.identity,
685 None => options.current == Some(entry.identity.as_str()),
686 };
687 row.col(|ui| {
688 let r = crate::icon_text::selectable_icon_label(
689 ui,
690 highlight,
691 &format!("{icon} {}", entry.name),
692 );
693 out.hits
694 .push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
695 if r.double_clicked() {
696 if entry.is_dir {
697 self.nav(store, || store.browser_enter(&entry.identity));
698 } else {
699 self.selected = Some(entry.identity.clone());
700 out.activated = Some(entry.identity.clone());
701 }
702 } else if r.clicked() {
703 if entry.is_dir {
704 self.nav(store, || store.browser_enter(&entry.identity));
705 } else {
706 self.selected = Some(entry.identity.clone());
707 out.picked = Some(entry.identity.clone());
708 }
709 }
710 });
711 row.col(|ui| {
712 ui.weak(entry_kind(entry));
713 });
714 row.col(|ui| {
715 ui.weak(fmt_size(entry.size));
716 });
717 row.col(|ui| {
718 ui.weak(fmt_date(entry.modified));
719 });
720 row.col(|ui| {
721 if options.allow_delete && !entry.is_dir {
722 let del = {
723 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
724 ui.add(b)
725 };
726 out.hits.push((format!("del:{}", entry.name), del.rect));
727 if del.clicked() {
728 out.remove = Some(entry.identity.clone());
729 }
730 }
731 });
732 });
733 });
734 if let Some(col) = clicked_sort {
735 if self.sort_key == col {
736 self.sort_desc = !self.sort_desc;
737 } else {
738 self.sort_key = col;
739 self.sort_desc = false;
740 }
741 }
742 }
743
744 fn handle_keys(
748 &mut self,
749 ui: &egui::Ui,
750 options: FileExplorerOptions<'_>,
751 out: &mut FileExplorerOutput,
752 ) {
753 if options.confirm_label.is_none() || ui.ctx().egui_wants_keyboard_input() {
754 return;
755 }
756 if self.nav_files.is_empty() {
757 return;
758 }
759 let (down, up, enter) = ui.input(|i| {
760 (
761 i.key_pressed(egui::Key::ArrowDown),
762 i.key_pressed(egui::Key::ArrowUp),
763 i.key_pressed(egui::Key::Enter),
764 )
765 });
766 if down || up {
767 let current = self
768 .selected
769 .as_deref()
770 .and_then(|s| self.nav_files.iter().position(|f| f == s));
771 let next = match current {
772 Some(i) if down => (i + 1).min(self.nav_files.len() - 1),
773 Some(i) => i.saturating_sub(1),
774 None => 0,
775 };
776 self.selected = Some(self.nav_files[next].clone());
777 }
778 if enter {
779 if let Some(sel) = &self.selected {
780 out.activated = Some(sel.clone());
781 }
782 }
783 }
784
785 fn show_footer(
786 &self,
787 ui: &mut egui::Ui,
788 options: FileExplorerOptions<'_>,
789 out: &mut FileExplorerOutput,
790 ) {
791 ui.separator();
792 ui.horizontal(|ui| {
793 if let Some(label) = options.confirm_label {
794 let confirm =
795 ui.add_enabled(self.selected.is_some(), egui::Button::new(label));
796 out.hits
797 .push((format!("{}:confirm", options.hit_prefix), confirm.rect));
798 if confirm.clicked() {
799 out.activated = self.selected.clone();
800 }
801 }
802 if options.allow_import {
803 let import = {
804 let label = format!("\u{2B06} {}", options.import_label);
805 let b = crate::icon_text::icon_button(ui, &label);
806 ui.add(b)
807 };
808 out.hits.push((options.import_hit.into(), import.rect));
809 out.import = import.clicked();
810 }
811 if options.show_cancel {
812 let cancel = ui.button("Cancel");
813 out.hits.push(("cancel".into(), cancel.rect));
814 out.cancel = cancel.clicked();
815 }
816 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
818 if let Some(sel) = &self.selected {
819 let name = sel.rsplit(['/', '\\']).next().unwrap_or(sel);
820 ui.add(
824 egui::Label::new(egui::RichText::new(name).weak()).truncate(),
825 );
826 }
827 });
828 });
829 }
830
831}
832
833pub fn dialog_body<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
843 let screen = ui.ctx().content_rect().size();
844 let compact = crate::viewport::touch::compact(ui.ctx());
845 let max = if compact { (screen - egui::Vec2::splat(24.0)).max(egui::Vec2::splat(80.0)) }
846 else { egui::vec2((screen.x - 40.0).max(320.0), (screen.y - 80.0).max(240.0)) };
847 egui::Resize::default()
848 .id_salt(if compact { "brep-file-dialog-size-mobile" } else { "brep-file-dialog-size" })
849 .with_stroke(false)
852 .min_size(egui::vec2(460.0_f32.min(max.x), 300.0_f32.min(max.y)))
853 .max_size(max)
854 .default_size(egui::vec2(620.0_f32.min(max.x), 520.0_f32.min(max.y)))
855 .show(ui, add_contents)
856}
857
858pub fn dialog_footer<R>(
865 ui: &mut egui::Ui,
866 salt: &str,
867 add_contents: impl FnOnce(&mut egui::Ui) -> R,
868) -> R {
869 bottom_panel(ui, "brep-file-dialog-footer", salt, add_contents)
870}
871
872fn bottom_panel<R>(
876 ui: &mut egui::Ui,
877 base: &str,
878 salt: &str,
879 add_contents: impl FnOnce(&mut egui::Ui) -> R,
880) -> R {
881 egui::containers::panel::Panel::bottom(egui::Id::new((base, salt)))
882 .frame(egui::Frame::NONE)
883 .show_separator_line(false)
884 .show(ui, add_contents)
885 .inner
886}
887
888fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
890 a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
891}
892
893fn cmp_opt(a: Option<f64>, b: Option<f64>) -> Ordering {
895 match (a, b) {
896 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
897 (Some(_), None) => Ordering::Less,
898 (None, Some(_)) => Ordering::Greater,
899 (None, None) => Ordering::Equal,
900 }
901}
902
903fn entry_kind(entry: &BrowserEntry) -> String {
905 if entry.is_dir {
906 return "Folder".into();
907 }
908 let lower = entry.name.to_ascii_lowercase();
909 if lower.ends_with(".nbrep") {
910 return "BREP model".into();
911 }
912 match entry.name.rsplit_once('.') {
913 Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_uppercase(),
914 _ => "File".into(),
915 }
916}
917
918fn sort_slug(key: SortKey) -> &'static str {
920 match key {
921 SortKey::Name => "name",
922 SortKey::Kind => "type",
923 SortKey::Size => "size",
924 SortKey::Date => "date",
925 }
926}
927
928fn fmt_size(size: Option<u64>) -> String {
930 match size {
931 None => "\u{2014}".into(),
932 Some(bytes) => {
933 let b = bytes as f64;
934 if bytes < 1024 {
935 format!("{bytes} B")
936 } else if b < 1024.0 * 1024.0 {
937 format!("{:.1} KB", b / 1024.0)
938 } else if b < 1024.0 * 1024.0 * 1024.0 {
939 format!("{:.1} MB", b / (1024.0 * 1024.0))
940 } else {
941 format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
942 }
943 }
944 }
945}
946
947fn fmt_date(modified: Option<f64>) -> String {
949 let Some(secs) = modified else {
950 return "\u{2014}".into();
951 };
952 let days = (secs / 86_400.0).floor() as i64;
953 let (y, m, d) = civil_from_days(days);
954 let sod = ((secs as i64) % 86_400 + 86_400) % 86_400;
955 format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", sod / 3600, (sod % 3600) / 60)
956}
957
958fn civil_from_days(z: i64) -> (i64, u32, u32) {
962 let z = z + 719_468;
963 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
964 let doe = z - era * 146_097;
965 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
966 let y = yoe + era * 400;
967 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
968 let mp = (5 * doy + 2) / 153;
969 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
970 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
971 (if m <= 2 { y + 1 } else { y }, m, d)
972}
973
974fn breadcrumb_segments(location: &str) -> Vec<(String, String)> {
978 let mut out = vec![("/".to_string(), "/".to_string())];
979 let mut acc = String::new();
980 for comp in location.split('/').filter(|s| !s.is_empty()) {
981 acc.push('/');
982 acc.push_str(comp);
983 out.push((comp.to_string(), acc.clone()));
984 }
985 out
986}
987
988const PIN_GLYPH: &str = "\u{E011}";
990
991fn place_glyph(kind: PlaceKind) -> &'static str {
996 match kind {
997 PlaceKind::Home => "\u{2302}", PlaceKind::Documents => "\u{1F5CE}", PlaceKind::Downloads => "\u{2B07}", PlaceKind::Models | PlaceKind::Root => PIN_GLYPH,
1001 }
1002}
1003
1004fn sidebar_row<'a>(ui: &egui::Ui, caption: &'a str) -> egui::Button<'a> {
1007 crate::icon_text::icon_button(ui, caption).frame_when_inactive(false)
1008}
1009