1use std::path::PathBuf;
2
3use crate::core::{DialogMode, FileDialogError, LayoutStyle, Selection};
4use crate::custom_pane::{CustomPane, CustomPaneCtx};
5use crate::dialog_core::{ConfirmGate, CoreEvent};
6use crate::dialog_state::CustomPaneDock;
7use crate::dialog_state::FileDialogState;
8use crate::fs::{FileSystem, StdFileSystem};
9use crate::thumbnails::ThumbnailBackend;
10use dear_imgui_rs::Ui;
11use dear_imgui_rs::input::MouseCursor;
12use dear_imgui_rs::sys;
13
14mod file_table;
15mod footer;
16mod header;
17mod igfd_path_popup;
18mod ops;
19mod path_bar;
20mod places;
21mod popups;
22
23#[derive(Clone, Debug)]
25pub struct WindowHostConfig {
26 pub title: String,
28 pub initial_size: [f32; 2],
30 pub size_condition: dear_imgui_rs::Condition,
32 pub min_size: Option<[f32; 2]>,
34 pub max_size: Option<[f32; 2]>,
36}
37
38impl WindowHostConfig {
39 pub fn for_mode(mode: DialogMode) -> Self {
41 let title = match mode {
42 DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
43 DialogMode::PickFolder => "Select Folder",
44 DialogMode::SaveFile => "Save",
45 };
46 Self {
47 title: title.to_string(),
48 initial_size: [760.0, 520.0],
49 size_condition: dear_imgui_rs::Condition::FirstUseEver,
50 min_size: None,
51 max_size: None,
52 }
53 }
54}
55
56#[derive(Clone, Debug)]
61pub struct ModalHostConfig {
62 pub popup_label: String,
64 pub initial_size: [f32; 2],
66 pub size_condition: dear_imgui_rs::Condition,
68 pub min_size: Option<[f32; 2]>,
70 pub max_size: Option<[f32; 2]>,
72}
73
74impl ModalHostConfig {
75 pub fn for_mode(mode: DialogMode) -> Self {
77 let title = match mode {
78 DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
79 DialogMode::PickFolder => "Select Folder",
80 DialogMode::SaveFile => "Save",
81 };
82 Self {
83 popup_label: format!("{title}###FileBrowserModal"),
84 initial_size: [760.0, 520.0],
85 size_condition: dear_imgui_rs::Condition::FirstUseEver,
86 min_size: None,
87 max_size: None,
88 }
89 }
90}
91
92pub struct FileBrowser<'ui> {
94 pub ui: &'ui Ui,
95}
96
97pub trait FileDialogExt {
99 fn file_browser(&self) -> FileBrowser<'_>;
101}
102
103impl FileDialogExt for Ui {
104 fn file_browser(&self) -> FileBrowser<'_> {
105 FileBrowser { ui: self }
106 }
107}
108
109impl<'ui> FileBrowser<'ui> {
110 pub fn draw_contents(
117 &self,
118 state: &mut FileDialogState,
119 ) -> Option<Result<Selection, FileDialogError>> {
120 self.draw_contents_with(state, &StdFileSystem, None, None)
121 }
122
123 pub fn draw_contents_with(
129 &self,
130 state: &mut FileDialogState,
131 fs: &dyn FileSystem,
132 mut custom_pane: Option<&mut dyn CustomPane>,
133 mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
134 ) -> Option<Result<Selection, FileDialogError>> {
135 draw_contents_with_fs_and_hooks(
136 self.ui,
137 state,
138 fs,
139 custom_pane.take(),
140 thumbnails_backend.take(),
141 )
142 }
143
144 pub fn show(&self, state: &mut FileDialogState) -> Option<Result<Selection, FileDialogError>> {
147 let cfg = WindowHostConfig::for_mode(state.core.mode);
148 self.show_windowed(state, &cfg)
149 }
150
151 pub fn show_windowed(
154 &self,
155 state: &mut FileDialogState,
156 cfg: &WindowHostConfig,
157 ) -> Option<Result<Selection, FileDialogError>> {
158 self.show_windowed_with(state, cfg, &StdFileSystem, None, None)
159 }
160
161 pub fn show_windowed_with(
163 &self,
164 state: &mut FileDialogState,
165 cfg: &WindowHostConfig,
166 fs: &dyn FileSystem,
167 mut custom_pane: Option<&mut dyn CustomPane>,
168 mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
169 ) -> Option<Result<Selection, FileDialogError>> {
170 if !state.ui.visible {
171 return None;
172 }
173
174 let mut out: Option<Result<Selection, FileDialogError>> = None;
175 let mut window = self
176 .ui
177 .window(&cfg.title)
178 .size(cfg.initial_size, cfg.size_condition);
179 if let Some((min_size, max_size)) =
180 resolve_host_size_constraints(cfg.min_size, cfg.max_size)
181 {
182 window = window.size_constraints(min_size, max_size);
183 }
184 window.build(|| {
185 out = draw_contents_with_fs_and_hooks(
186 self.ui,
187 state,
188 fs,
189 custom_pane.take(),
190 thumbnails_backend.take(),
191 );
192 });
193 out
194 }
195
196 pub fn show_modal(
199 &self,
200 state: &mut FileDialogState,
201 ) -> Option<Result<Selection, FileDialogError>> {
202 let cfg = ModalHostConfig::for_mode(state.core.mode);
203 self.show_modal_with(state, &cfg, &StdFileSystem, None, None)
204 }
205
206 pub fn show_modal_with(
208 &self,
209 state: &mut FileDialogState,
210 cfg: &ModalHostConfig,
211 fs: &dyn FileSystem,
212 mut custom_pane: Option<&mut dyn CustomPane>,
213 mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
214 ) -> Option<Result<Selection, FileDialogError>> {
215 if !state.ui.visible {
216 return None;
217 }
218
219 if !self.ui.is_popup_open(&cfg.popup_label) {
220 self.ui.open_popup(&cfg.popup_label);
221 }
222
223 if let Some((min_size, max_size)) =
224 resolve_host_size_constraints(cfg.min_size, cfg.max_size)
225 {
226 unsafe {
227 let min_vec = sys::ImVec2_c {
228 x: min_size[0],
229 y: min_size[1],
230 };
231 let max_vec = sys::ImVec2_c {
232 x: max_size[0],
233 y: max_size[1],
234 };
235 sys::igSetNextWindowSizeConstraints(min_vec, max_vec, None, std::ptr::null_mut());
236 }
237 }
238
239 unsafe {
240 let size_vec = sys::ImVec2 {
241 x: cfg.initial_size[0],
242 y: cfg.initial_size[1],
243 };
244 sys::igSetNextWindowSize(size_vec, cfg.size_condition as i32);
245 }
246
247 let Some(_popup) = self.ui.begin_modal_popup(&cfg.popup_label) else {
248 return None;
249 };
250
251 let out = draw_contents_with_fs_and_hooks(
252 self.ui,
253 state,
254 fs,
255 custom_pane.take(),
256 thumbnails_backend.take(),
257 );
258 if out.is_some() {
259 self.ui.close_current_popup();
260 }
261 out
262 }
263}
264
265fn resolve_host_size_constraints(
266 min_size: Option<[f32; 2]>,
267 max_size: Option<[f32; 2]>,
268) -> Option<([f32; 2], [f32; 2])> {
269 if min_size.is_none() && max_size.is_none() {
270 return None;
271 }
272
273 let sanitize = |value: f32, fallback: f32| -> f32 {
274 if value.is_finite() {
275 value.max(0.0)
276 } else {
277 fallback
278 }
279 };
280
281 let mut min = min_size.unwrap_or([0.0, 0.0]);
282 min[0] = sanitize(min[0], 0.0);
283 min[1] = sanitize(min[1], 0.0);
284
285 let mut max = max_size.unwrap_or([f32::MAX, f32::MAX]);
286 max[0] = sanitize(max[0], f32::MAX);
287 max[1] = sanitize(max[1], f32::MAX);
288
289 max[0] = max[0].max(min[0]);
290 max[1] = max[1].max(min[1]);
291
292 Some((min, max))
293}
294
295fn draw_contents_with_fs_and_hooks(
296 ui: &Ui,
297 state: &mut FileDialogState,
298 fs: &dyn FileSystem,
299 mut custom_pane: Option<&mut dyn CustomPane>,
300 mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
301) -> Option<Result<Selection, FileDialogError>> {
302 if !state.ui.visible {
303 return None;
304 }
305
306 let _dialog_id_scope = ui.push_id(state as *mut FileDialogState);
310
311 let has_thumbnail_backend = thumbnails_backend.is_some();
312 let mut request_confirm = false;
313 let mut confirm_gate = ConfirmGate::default();
314
315 header::draw_chrome(ui, state, fs, has_thumbnail_backend);
316
317 let avail = ui.content_region_avail();
319 let footer_h = state
320 .ui
321 .footer_height_last
322 .max(footer::estimate_footer_height(ui, state));
323 let content_h = (avail[1] - footer_h).max(0.0);
324 match state.ui.layout {
325 LayoutStyle::Standard => {
326 if state.ui.places_pane_shown {
327 const MIN_PLACES_W: f32 = 120.0;
328 const MIN_FILE_LIST_W: f32 = 180.0;
329
330 let splitter_w = splitter_width(ui);
331 let spacing_x = ui.clone_style().item_spacing()[0];
332 let max_places_w =
333 (avail[0] - MIN_FILE_LIST_W - splitter_w - spacing_x * 2.0).max(0.0);
334 let mut places_w = state.ui.places_pane_width.clamp(0.0, max_places_w);
335 if max_places_w >= MIN_PLACES_W {
336 places_w = places_w.clamp(MIN_PLACES_W, max_places_w);
337 }
338 let file_w = (avail[0] - places_w - splitter_w - spacing_x * 2.0).max(0.0);
339
340 let mut new_cwd: Option<PathBuf> = None;
341 ui.child_window("places_pane")
342 .size([places_w, content_h])
343 .border(true)
344 .build(ui, || {
345 new_cwd = places::draw_places_pane(ui, state);
346 });
347 if let Some(p) = new_cwd {
348 let _ = state.core.handle_event(CoreEvent::NavigateTo(p));
349 }
350
351 ui.same_line();
352 ui.invisible_button("places_pane_splitter", [splitter_w, content_h]);
353 if ui.is_item_hovered() || ui.is_item_active() {
354 ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
355 }
356 if ui.is_item_active() {
357 let dx = ui.io().mouse_delta()[0];
358 let new_w = (places_w + dx).clamp(0.0, max_places_w);
359 state.ui.places_pane_width = if max_places_w >= MIN_PLACES_W {
360 new_w.clamp(MIN_PLACES_W, max_places_w)
361 } else {
362 new_w
363 };
364 }
365
366 ui.same_line();
367 ui.child_window("file_list")
368 .size([file_w, content_h])
369 .build(ui, || {
370 let inner = ui.content_region_avail();
371 let show_pane =
372 state.ui.custom_pane_enabled && custom_pane.as_deref_mut().is_some();
373 if !show_pane {
374 file_table::draw_file_table(
375 ui,
376 state,
377 [inner[0], inner[1]],
378 fs,
379 &mut request_confirm,
380 thumbnails_backend.as_deref_mut(),
381 );
382 return;
383 }
384
385 match state.ui.custom_pane_dock {
386 CustomPaneDock::Bottom => {
387 let style = ui.clone_style();
388 let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
389 let pane_h =
390 state.ui.custom_pane_height.clamp(0.0, inner[1].max(0.0));
391 let mut table_h = inner[1];
392 if pane_h > 0.0 {
393 table_h = (table_h - pane_h - sep_h).max(0.0);
394 }
395
396 file_table::draw_file_table(
397 ui,
398 state,
399 [inner[0], table_h],
400 fs,
401 &mut request_confirm,
402 thumbnails_backend.as_deref_mut(),
403 );
404
405 if let Some(pane) = custom_pane.as_deref_mut() {
406 if state.ui.custom_pane_enabled && pane_h > 0.0 {
407 ui.separator();
408 ui.child_window("custom_pane")
409 .size([inner[0], pane_h])
410 .border(true)
411 .build(ui, || {
412 let selected_entry_ids =
413 state.core.selected_entry_ids();
414 let selected_paths =
415 ops::selected_entry_paths_from_ids(state);
416 let (selected_files_count, selected_dirs_count) =
417 ops::selected_entry_counts_from_ids(state);
418 let ctx = CustomPaneCtx {
419 mode: state.core.mode,
420 cwd: &state.core.cwd,
421 selected_entry_ids: &selected_entry_ids,
422 selected_paths: &selected_paths,
423 selected_files_count,
424 selected_dirs_count,
425 save_name: &state.core.save_name,
426 active_filter: state.core.active_filter(),
427 };
428 confirm_gate = pane.draw(ui, ctx);
429 });
430 }
431 }
432 }
433 CustomPaneDock::Right => {
434 const MIN_TABLE_W: f32 = 120.0;
435 const MIN_PANE_W: f32 = 120.0;
436
437 let splitter_w = splitter_width(ui);
438 let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
439 let mut pane_w = state.ui.custom_pane_width.clamp(0.0, max_pane_w);
440 if max_pane_w >= MIN_PANE_W {
441 pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
442 }
443
444 let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
445
446 ui.child_window("file_table_rightdock")
447 .size([table_w, inner[1]])
448 .build(ui, || {
449 file_table::draw_file_table(
450 ui,
451 state,
452 [table_w, inner[1]],
453 fs,
454 &mut request_confirm,
455 thumbnails_backend.as_deref_mut(),
456 );
457 });
458
459 ui.same_line();
460 ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
461 if ui.is_item_hovered() || ui.is_item_active() {
462 ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
463 }
464 if ui.is_item_active() {
465 let dx = ui.io().mouse_delta()[0];
466 let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
467 state.ui.custom_pane_width = if max_pane_w >= MIN_PANE_W {
468 new_w.clamp(MIN_PANE_W, max_pane_w)
469 } else {
470 new_w
471 };
472 }
473
474 ui.same_line();
475 ui.child_window("custom_pane_rightdock")
476 .size([pane_w, inner[1]])
477 .border(true)
478 .build(ui, || {
479 if let Some(pane) = custom_pane.as_deref_mut() {
480 let selected_entry_ids =
481 state.core.selected_entry_ids();
482 let selected_paths =
483 ops::selected_entry_paths_from_ids(state);
484 let (selected_files_count, selected_dirs_count) =
485 ops::selected_entry_counts_from_ids(state);
486 let ctx = CustomPaneCtx {
487 mode: state.core.mode,
488 cwd: &state.core.cwd,
489 selected_entry_ids: &selected_entry_ids,
490 selected_paths: &selected_paths,
491 selected_files_count,
492 selected_dirs_count,
493 save_name: &state.core.save_name,
494 active_filter: state.core.active_filter(),
495 };
496 confirm_gate = pane.draw(ui, ctx);
497 }
498 });
499 }
500 }
501 });
502 } else {
503 ui.child_window("file_list")
504 .size([avail[0], content_h])
505 .build(ui, || {
506 let inner = ui.content_region_avail();
507 let show_pane =
508 state.ui.custom_pane_enabled && custom_pane.as_deref_mut().is_some();
509 if !show_pane {
510 file_table::draw_file_table(
511 ui,
512 state,
513 [inner[0], inner[1]],
514 fs,
515 &mut request_confirm,
516 thumbnails_backend.as_deref_mut(),
517 );
518 return;
519 }
520
521 match state.ui.custom_pane_dock {
522 CustomPaneDock::Bottom => {
523 let style = ui.clone_style();
524 let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
525 let pane_h =
526 state.ui.custom_pane_height.clamp(0.0, inner[1].max(0.0));
527 let mut table_h = inner[1];
528 if pane_h > 0.0 {
529 table_h = (table_h - pane_h - sep_h).max(0.0);
530 }
531
532 file_table::draw_file_table(
533 ui,
534 state,
535 [inner[0], table_h],
536 fs,
537 &mut request_confirm,
538 thumbnails_backend.as_deref_mut(),
539 );
540
541 if let Some(pane) = custom_pane.as_deref_mut() {
542 if state.ui.custom_pane_enabled && pane_h > 0.0 {
543 ui.separator();
544 ui.child_window("custom_pane")
545 .size([inner[0], pane_h])
546 .border(true)
547 .build(ui, || {
548 let selected_entry_ids =
549 state.core.selected_entry_ids();
550 let selected_paths =
551 ops::selected_entry_paths_from_ids(state);
552 let (selected_files_count, selected_dirs_count) =
553 ops::selected_entry_counts_from_ids(state);
554 let ctx = CustomPaneCtx {
555 mode: state.core.mode,
556 cwd: &state.core.cwd,
557 selected_entry_ids: &selected_entry_ids,
558 selected_paths: &selected_paths,
559 selected_files_count,
560 selected_dirs_count,
561 save_name: &state.core.save_name,
562 active_filter: state.core.active_filter(),
563 };
564 confirm_gate = pane.draw(ui, ctx);
565 });
566 }
567 }
568 }
569 CustomPaneDock::Right => {
570 const MIN_TABLE_W: f32 = 120.0;
571 const MIN_PANE_W: f32 = 120.0;
572
573 let splitter_w = splitter_width(ui);
574 let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
575 let mut pane_w = state.ui.custom_pane_width.clamp(0.0, max_pane_w);
576 if max_pane_w >= MIN_PANE_W {
577 pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
578 }
579
580 let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
581
582 ui.child_window("file_table_rightdock")
583 .size([table_w, inner[1]])
584 .build(ui, || {
585 file_table::draw_file_table(
586 ui,
587 state,
588 [table_w, inner[1]],
589 fs,
590 &mut request_confirm,
591 thumbnails_backend.as_deref_mut(),
592 );
593 });
594
595 ui.same_line();
596 ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
597 if ui.is_item_hovered() || ui.is_item_active() {
598 ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
599 }
600 if ui.is_item_active() {
601 let dx = ui.io().mouse_delta()[0];
602 let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
603 state.ui.custom_pane_width = if max_pane_w >= MIN_PANE_W {
604 new_w.clamp(MIN_PANE_W, max_pane_w)
605 } else {
606 new_w
607 };
608 }
609
610 ui.same_line();
611 ui.child_window("custom_pane_rightdock")
612 .size([pane_w, inner[1]])
613 .border(true)
614 .build(ui, || {
615 if let Some(pane) = custom_pane.as_deref_mut() {
616 let selected_entry_ids =
617 state.core.selected_entry_ids();
618 let selected_paths =
619 ops::selected_entry_paths_from_ids(state);
620 let (selected_files_count, selected_dirs_count) =
621 ops::selected_entry_counts_from_ids(state);
622 let ctx = CustomPaneCtx {
623 mode: state.core.mode,
624 cwd: &state.core.cwd,
625 selected_entry_ids: &selected_entry_ids,
626 selected_paths: &selected_paths,
627 selected_files_count,
628 selected_dirs_count,
629 save_name: &state.core.save_name,
630 active_filter: state.core.active_filter(),
631 };
632 confirm_gate = pane.draw(ui, ctx);
633 }
634 });
635 }
636 }
637 });
638 }
639 }
640 LayoutStyle::Minimal => {
641 ui.child_window("file_list_min")
642 .size([avail[0], content_h])
643 .build(ui, || {
644 let inner = ui.content_region_avail();
645 let show_pane =
646 state.ui.custom_pane_enabled && custom_pane.as_deref_mut().is_some();
647 if !show_pane {
648 file_table::draw_file_table(
649 ui,
650 state,
651 [inner[0], inner[1]],
652 fs,
653 &mut request_confirm,
654 thumbnails_backend.as_deref_mut(),
655 );
656 return;
657 }
658
659 match state.ui.custom_pane_dock {
660 CustomPaneDock::Bottom => {
661 let style = ui.clone_style();
662 let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
663 let pane_h = state.ui.custom_pane_height.clamp(0.0, inner[1].max(0.0));
664 let mut table_h = inner[1];
665 if pane_h > 0.0 {
666 table_h = (table_h - pane_h - sep_h).max(0.0);
667 }
668
669 file_table::draw_file_table(
670 ui,
671 state,
672 [inner[0], table_h],
673 fs,
674 &mut request_confirm,
675 thumbnails_backend.as_deref_mut(),
676 );
677
678 if let Some(pane) = custom_pane.as_deref_mut() {
679 if state.ui.custom_pane_enabled && pane_h > 0.0 {
680 ui.separator();
681 ui.child_window("custom_pane")
682 .size([inner[0], pane_h])
683 .border(true)
684 .build(ui, || {
685 let selected_entry_ids =
686 state.core.selected_entry_ids();
687 let selected_paths =
688 ops::selected_entry_paths_from_ids(state);
689 let (selected_files_count, selected_dirs_count) =
690 ops::selected_entry_counts_from_ids(state);
691 let ctx = CustomPaneCtx {
692 mode: state.core.mode,
693 cwd: &state.core.cwd,
694 selected_entry_ids: &selected_entry_ids,
695 selected_paths: &selected_paths,
696 selected_files_count,
697 selected_dirs_count,
698 save_name: &state.core.save_name,
699 active_filter: state.core.active_filter(),
700 };
701 confirm_gate = pane.draw(ui, ctx);
702 });
703 }
704 }
705 }
706 CustomPaneDock::Right => {
707 const MIN_TABLE_W: f32 = 120.0;
708 const MIN_PANE_W: f32 = 120.0;
709
710 let splitter_w = splitter_width(ui);
711 let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
712 let mut pane_w = state.ui.custom_pane_width.clamp(0.0, max_pane_w);
713 if max_pane_w >= MIN_PANE_W {
714 pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
715 }
716
717 let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
718
719 ui.child_window("file_table_rightdock")
720 .size([table_w, inner[1]])
721 .build(ui, || {
722 file_table::draw_file_table(
723 ui,
724 state,
725 [table_w, inner[1]],
726 fs,
727 &mut request_confirm,
728 thumbnails_backend.as_deref_mut(),
729 );
730 });
731
732 ui.same_line();
733 ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
734 if ui.is_item_hovered() || ui.is_item_active() {
735 ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
736 }
737 if ui.is_item_active() {
738 let dx = ui.io().mouse_delta()[0];
739 let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
740 state.ui.custom_pane_width = if max_pane_w >= MIN_PANE_W {
741 new_w.clamp(MIN_PANE_W, max_pane_w)
742 } else {
743 new_w
744 };
745 }
746
747 ui.same_line();
748 ui.child_window("custom_pane_rightdock")
749 .size([pane_w, inner[1]])
750 .border(true)
751 .build(ui, || {
752 if let Some(pane) = custom_pane.as_deref_mut() {
753 let selected_entry_ids = state.core.selected_entry_ids();
754 let selected_paths =
755 ops::selected_entry_paths_from_ids(state);
756 let (selected_files_count, selected_dirs_count) =
757 ops::selected_entry_counts_from_ids(state);
758 let ctx = CustomPaneCtx {
759 mode: state.core.mode,
760 cwd: &state.core.cwd,
761 selected_entry_ids: &selected_entry_ids,
762 selected_paths: &selected_paths,
763 selected_files_count,
764 selected_dirs_count,
765 save_name: &state.core.save_name,
766 active_filter: state.core.active_filter(),
767 };
768 confirm_gate = pane.draw(ui, ctx);
769 }
770 });
771 }
772 }
773 });
774 }
775 }
776
777 if let Some(p) = igfd_path_popup::draw_igfd_path_popup(ui, state, fs, [avail[0], content_h]) {
779 let _ = state.core.handle_event(CoreEvent::NavigateTo(p));
780 }
781
782 places::draw_minimal_places_popup(ui, state);
783 popups::draw_columns_popup(ui, state);
784 popups::draw_options_popup(ui, state, has_thumbnail_backend);
785
786 places::draw_places_io_modal(ui, state);
787 places::draw_places_edit_modal(ui, state, fs);
788 popups::draw_new_folder_modal(ui, state, fs);
789 popups::draw_rename_modal(ui, state, fs);
790 popups::draw_delete_confirm_modal(ui, state, fs);
791 popups::draw_paste_conflict_modal(ui, state, fs);
792
793 footer::draw_footer(ui, state, fs, &confirm_gate, &mut request_confirm);
794
795 let out = state.core.take_result();
796 if out.is_some() {
797 state.close();
798 }
799 out
800}
801
802fn splitter_width(ui: &Ui) -> f32 {
803 let w = ui.frame_height() * 0.25;
805 w.clamp(4.0, 10.0)
806}
807
808#[cfg(test)]
809mod tests {
810 use super::file_table::{ListColumnLayout, list_column_layout, merged_order_with_current};
811 use super::ops::{open_delete_modal_from_selection, open_rename_modal_from_selection};
812 use super::resolve_host_size_constraints;
813 use crate::core::DialogMode;
814 use crate::dialog_core::EntryId;
815 use crate::dialog_state::{
816 FileDialogState, FileListColumnWeightOverrides, FileListColumnsConfig, FileListDataColumn,
817 };
818 use crate::fs::{FileSystem, FsEntry, FsMetadata};
819 use std::path::{Path, PathBuf};
820
821 fn columns_config(
822 show_size: bool,
823 show_modified: bool,
824 order: [FileListDataColumn; 4],
825 ) -> FileListColumnsConfig {
826 FileListColumnsConfig {
827 show_size,
828 show_modified,
829 order,
830 ..FileListColumnsConfig::default()
831 }
832 }
833
834 #[test]
835 fn resolve_host_size_constraints_returns_none_when_unset() {
836 assert!(resolve_host_size_constraints(None, None).is_none());
837 }
838
839 #[test]
840 fn resolve_host_size_constraints_supports_one_sided_values() {
841 let (min, max) = resolve_host_size_constraints(Some([200.0, 150.0]), None).unwrap();
842 assert_eq!(min, [200.0, 150.0]);
843 assert_eq!(max, [f32::MAX, f32::MAX]);
844
845 let (min, max) = resolve_host_size_constraints(None, Some([900.0, 700.0])).unwrap();
846 assert_eq!(min, [0.0, 0.0]);
847 assert_eq!(max, [900.0, 700.0]);
848 }
849
850 #[test]
851 fn resolve_host_size_constraints_normalizes_invalid_values() {
852 let (min, max) =
853 resolve_host_size_constraints(Some([300.0, f32::NAN]), Some([100.0, f32::INFINITY]))
854 .unwrap();
855 assert_eq!(min, [300.0, 0.0]);
856 assert_eq!(max, [300.0, f32::MAX]);
857 }
858
859 #[derive(Clone, Default)]
860 struct UiTestFs {
861 entries: Vec<FsEntry>,
862 }
863
864 impl FileSystem for UiTestFs {
865 fn read_dir(&self, _dir: &Path) -> std::io::Result<Vec<FsEntry>> {
866 Ok(self.entries.clone())
867 }
868
869 fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
870 Ok(path.to_path_buf())
871 }
872
873 fn metadata(&self, path: &Path) -> std::io::Result<FsMetadata> {
874 self.entries
875 .iter()
876 .find(|entry| entry.path == path)
877 .map(|entry| FsMetadata {
878 is_dir: entry.is_dir,
879 is_symlink: entry.is_symlink,
880 })
881 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
882 }
883
884 fn create_dir(&self, _path: &Path) -> std::io::Result<()> {
885 Err(std::io::Error::new(
886 std::io::ErrorKind::Unsupported,
887 "create_dir not supported in UiTestFs",
888 ))
889 }
890
891 fn rename(&self, _from: &Path, _to: &Path) -> std::io::Result<()> {
892 Err(std::io::Error::new(
893 std::io::ErrorKind::Unsupported,
894 "rename not supported in UiTestFs",
895 ))
896 }
897
898 fn remove_file(&self, _path: &Path) -> std::io::Result<()> {
899 Err(std::io::Error::new(
900 std::io::ErrorKind::Unsupported,
901 "remove_file not supported in UiTestFs",
902 ))
903 }
904
905 fn remove_dir(&self, _path: &Path) -> std::io::Result<()> {
906 Err(std::io::Error::new(
907 std::io::ErrorKind::Unsupported,
908 "remove_dir not supported in UiTestFs",
909 ))
910 }
911
912 fn remove_dir_all(&self, _path: &Path) -> std::io::Result<()> {
913 Err(std::io::Error::new(
914 std::io::ErrorKind::Unsupported,
915 "remove_dir_all not supported in UiTestFs",
916 ))
917 }
918
919 fn copy_file(&self, _from: &Path, _to: &Path) -> std::io::Result<u64> {
920 Err(std::io::Error::new(
921 std::io::ErrorKind::Unsupported,
922 "copy_file not supported in UiTestFs",
923 ))
924 }
925 }
926
927 fn file_entry(path: &str) -> FsEntry {
928 let path = PathBuf::from(path);
929 let name = path
930 .file_name()
931 .and_then(|name| name.to_str())
932 .unwrap_or(path.as_os_str().to_string_lossy().as_ref())
933 .to_string();
934 FsEntry {
935 name,
936 path,
937 is_dir: false,
938 is_symlink: false,
939 size: None,
940 modified: None,
941 }
942 }
943 #[test]
944 fn list_column_layout_all_columns_visible_without_preview() {
945 let cfg = columns_config(
946 true,
947 true,
948 [
949 FileListDataColumn::Name,
950 FileListDataColumn::Extension,
951 FileListDataColumn::Size,
952 FileListDataColumn::Modified,
953 ],
954 );
955 assert_eq!(
956 list_column_layout(false, &cfg),
957 ListColumnLayout {
958 data_columns: vec![
959 FileListDataColumn::Name,
960 FileListDataColumn::Extension,
961 FileListDataColumn::Size,
962 FileListDataColumn::Modified,
963 ],
964 name: 0,
965 extension: Some(1),
966 size: Some(2),
967 modified: Some(3),
968 }
969 );
970 }
971
972 #[test]
973 fn list_column_layout_hides_extension_column() {
974 let mut cfg = columns_config(
975 true,
976 true,
977 [
978 FileListDataColumn::Name,
979 FileListDataColumn::Extension,
980 FileListDataColumn::Size,
981 FileListDataColumn::Modified,
982 ],
983 );
984 cfg.show_extension = false;
985
986 assert_eq!(
987 list_column_layout(false, &cfg),
988 ListColumnLayout {
989 data_columns: vec![
990 FileListDataColumn::Name,
991 FileListDataColumn::Size,
992 FileListDataColumn::Modified,
993 ],
994 name: 0,
995 extension: None,
996 size: Some(1),
997 modified: Some(2),
998 }
999 );
1000 }
1001
1002 #[test]
1003 fn list_column_layout_all_columns_visible_with_preview() {
1004 let cfg = columns_config(
1005 true,
1006 true,
1007 [
1008 FileListDataColumn::Name,
1009 FileListDataColumn::Extension,
1010 FileListDataColumn::Size,
1011 FileListDataColumn::Modified,
1012 ],
1013 );
1014 assert_eq!(
1015 list_column_layout(true, &cfg),
1016 ListColumnLayout {
1017 data_columns: vec![
1018 FileListDataColumn::Name,
1019 FileListDataColumn::Extension,
1020 FileListDataColumn::Size,
1021 FileListDataColumn::Modified,
1022 ],
1023 name: 1,
1024 extension: Some(2),
1025 size: Some(3),
1026 modified: Some(4),
1027 }
1028 );
1029 }
1030
1031 #[test]
1032 fn list_column_layout_hides_size_column() {
1033 let cfg = columns_config(
1034 false,
1035 true,
1036 [
1037 FileListDataColumn::Name,
1038 FileListDataColumn::Extension,
1039 FileListDataColumn::Size,
1040 FileListDataColumn::Modified,
1041 ],
1042 );
1043 assert_eq!(
1044 list_column_layout(false, &cfg),
1045 ListColumnLayout {
1046 data_columns: vec![
1047 FileListDataColumn::Name,
1048 FileListDataColumn::Extension,
1049 FileListDataColumn::Modified,
1050 ],
1051 name: 0,
1052 extension: Some(1),
1053 size: None,
1054 modified: Some(2),
1055 }
1056 );
1057 }
1058
1059 #[test]
1060 fn list_column_layout_hides_modified_column() {
1061 let cfg = columns_config(
1062 true,
1063 false,
1064 [
1065 FileListDataColumn::Name,
1066 FileListDataColumn::Extension,
1067 FileListDataColumn::Size,
1068 FileListDataColumn::Modified,
1069 ],
1070 );
1071 assert_eq!(
1072 list_column_layout(false, &cfg),
1073 ListColumnLayout {
1074 data_columns: vec![
1075 FileListDataColumn::Name,
1076 FileListDataColumn::Extension,
1077 FileListDataColumn::Size,
1078 ],
1079 name: 0,
1080 extension: Some(1),
1081 size: Some(2),
1082 modified: None,
1083 }
1084 );
1085 }
1086
1087 #[test]
1088 fn list_column_layout_hides_size_and_modified_columns() {
1089 let cfg = columns_config(
1090 false,
1091 false,
1092 [
1093 FileListDataColumn::Name,
1094 FileListDataColumn::Extension,
1095 FileListDataColumn::Size,
1096 FileListDataColumn::Modified,
1097 ],
1098 );
1099 assert_eq!(
1100 list_column_layout(false, &cfg),
1101 ListColumnLayout {
1102 data_columns: vec![FileListDataColumn::Name, FileListDataColumn::Extension],
1103 name: 0,
1104 extension: Some(1),
1105 size: None,
1106 modified: None,
1107 }
1108 );
1109 }
1110
1111 #[test]
1112 fn list_column_layout_respects_custom_order() {
1113 let cfg = columns_config(
1114 true,
1115 true,
1116 [
1117 FileListDataColumn::Name,
1118 FileListDataColumn::Size,
1119 FileListDataColumn::Modified,
1120 FileListDataColumn::Extension,
1121 ],
1122 );
1123 assert_eq!(
1124 list_column_layout(false, &cfg),
1125 ListColumnLayout {
1126 data_columns: vec![
1127 FileListDataColumn::Name,
1128 FileListDataColumn::Size,
1129 FileListDataColumn::Modified,
1130 FileListDataColumn::Extension,
1131 ],
1132 name: 0,
1133 extension: Some(3),
1134 size: Some(1),
1135 modified: Some(2),
1136 }
1137 );
1138 }
1139
1140 #[test]
1141 fn merged_order_with_current_keeps_hidden_columns() {
1142 let merged = merged_order_with_current(
1143 &[FileListDataColumn::Name, FileListDataColumn::Modified],
1144 [
1145 FileListDataColumn::Name,
1146 FileListDataColumn::Size,
1147 FileListDataColumn::Modified,
1148 FileListDataColumn::Extension,
1149 ],
1150 );
1151 assert_eq!(
1152 merged,
1153 [
1154 FileListDataColumn::Name,
1155 FileListDataColumn::Modified,
1156 FileListDataColumn::Size,
1157 FileListDataColumn::Extension,
1158 ]
1159 );
1160 }
1161
1162 #[test]
1163 fn move_column_order_up_swaps_adjacent_items() {
1164 let mut order = [
1165 FileListDataColumn::Name,
1166 FileListDataColumn::Extension,
1167 FileListDataColumn::Size,
1168 FileListDataColumn::Modified,
1169 ];
1170 assert!(super::file_table::move_column_order_up(&mut order, 2));
1171 assert_eq!(
1172 order,
1173 [
1174 FileListDataColumn::Name,
1175 FileListDataColumn::Size,
1176 FileListDataColumn::Extension,
1177 FileListDataColumn::Modified,
1178 ]
1179 );
1180 }
1181
1182 #[test]
1183 fn move_column_order_down_swaps_adjacent_items() {
1184 let mut order = [
1185 FileListDataColumn::Name,
1186 FileListDataColumn::Extension,
1187 FileListDataColumn::Size,
1188 FileListDataColumn::Modified,
1189 ];
1190 assert!(super::file_table::move_column_order_down(&mut order, 1));
1191 assert_eq!(
1192 order,
1193 [
1194 FileListDataColumn::Name,
1195 FileListDataColumn::Size,
1196 FileListDataColumn::Extension,
1197 FileListDataColumn::Modified,
1198 ]
1199 );
1200 }
1201
1202 #[test]
1203 fn move_column_order_up_rejects_first_item() {
1204 let mut order = [
1205 FileListDataColumn::Name,
1206 FileListDataColumn::Extension,
1207 FileListDataColumn::Size,
1208 FileListDataColumn::Modified,
1209 ];
1210 assert!(!super::file_table::move_column_order_up(&mut order, 0));
1211 assert_eq!(
1212 order,
1213 [
1214 FileListDataColumn::Name,
1215 FileListDataColumn::Extension,
1216 FileListDataColumn::Size,
1217 FileListDataColumn::Modified,
1218 ]
1219 );
1220 }
1221
1222 #[test]
1223 fn apply_compact_column_layout_updates_visibility_and_order_only() {
1224 let expected_weights = FileListColumnWeightOverrides {
1225 preview: Some(0.11),
1226 name: Some(0.57),
1227 extension: Some(0.14),
1228 size: Some(0.18),
1229 modified: Some(0.22),
1230 };
1231
1232 let mut cfg = FileListColumnsConfig {
1233 show_preview: true,
1234 show_extension: true,
1235 show_size: false,
1236 show_modified: true,
1237 order: [
1238 FileListDataColumn::Modified,
1239 FileListDataColumn::Size,
1240 FileListDataColumn::Extension,
1241 FileListDataColumn::Name,
1242 ],
1243 weight_overrides: expected_weights.clone(),
1244 };
1245
1246 super::file_table::apply_compact_column_layout(&mut cfg);
1247
1248 assert!(!cfg.show_preview);
1249 assert!(cfg.show_size);
1250 assert!(!cfg.show_modified);
1251 assert_eq!(
1252 cfg.order,
1253 [
1254 FileListDataColumn::Name,
1255 FileListDataColumn::Extension,
1256 FileListDataColumn::Size,
1257 FileListDataColumn::Modified,
1258 ]
1259 );
1260 assert_eq!(cfg.weight_overrides, expected_weights);
1261 }
1262
1263 #[test]
1264 fn apply_balanced_column_layout_updates_visibility_and_order_only() {
1265 let expected_weights = FileListColumnWeightOverrides {
1266 preview: Some(0.13),
1267 name: Some(0.54),
1268 extension: Some(0.16),
1269 size: Some(0.17),
1270 modified: Some(0.21),
1271 };
1272
1273 let mut cfg = FileListColumnsConfig {
1274 show_preview: false,
1275 show_extension: true,
1276 show_size: false,
1277 show_modified: false,
1278 order: [
1279 FileListDataColumn::Size,
1280 FileListDataColumn::Name,
1281 FileListDataColumn::Modified,
1282 FileListDataColumn::Extension,
1283 ],
1284 weight_overrides: expected_weights.clone(),
1285 };
1286
1287 super::file_table::apply_balanced_column_layout(&mut cfg);
1288
1289 assert!(cfg.show_preview);
1290 assert!(cfg.show_size);
1291 assert!(cfg.show_modified);
1292 assert_eq!(
1293 cfg.order,
1294 [
1295 FileListDataColumn::Name,
1296 FileListDataColumn::Extension,
1297 FileListDataColumn::Size,
1298 FileListDataColumn::Modified,
1299 ]
1300 );
1301 assert_eq!(cfg.weight_overrides, expected_weights);
1302 }
1303
1304 #[test]
1305 fn open_rename_modal_from_selection_prefills_name_from_id() {
1306 let mut state = FileDialogState::new(DialogMode::OpenFiles);
1307 state.core.set_cwd(PathBuf::from("/tmp"));
1308
1309 let fs = UiTestFs {
1310 entries: vec![file_entry("/tmp/a.txt")],
1311 };
1312 state.core.rescan_if_needed(&fs);
1313
1314 let id = state
1315 .core
1316 .entries()
1317 .iter()
1318 .find(|entry| entry.path == Path::new("/tmp/a.txt"))
1319 .map(|entry| entry.id)
1320 .expect("missing /tmp/a.txt entry id");
1321 state.core.focus_and_select_by_id(id);
1322
1323 open_rename_modal_from_selection(&mut state);
1324
1325 assert_eq!(state.ui.rename_target_id, Some(id));
1326 assert_eq!(state.ui.rename_to, "a.txt");
1327 assert!(state.ui.rename_open_next);
1328 assert!(state.ui.rename_focus_next);
1329 }
1330
1331 #[test]
1332 fn open_rename_modal_from_selection_ignores_unresolved_id() {
1333 let mut state = FileDialogState::new(DialogMode::OpenFiles);
1334 let id = EntryId::from_path(Path::new("/tmp/missing.txt"));
1335 state.core.focus_and_select_by_id(id);
1336
1337 open_rename_modal_from_selection(&mut state);
1338
1339 assert_eq!(state.ui.rename_target_id, None);
1340 assert!(state.ui.rename_to.is_empty());
1341 assert!(!state.ui.rename_open_next);
1342 }
1343
1344 #[test]
1345 fn open_delete_modal_from_selection_stores_selected_ids() {
1346 let mut state = FileDialogState::new(DialogMode::OpenFiles);
1347 state.core.set_cwd(PathBuf::from("/tmp"));
1348
1349 let fs = UiTestFs {
1350 entries: vec![file_entry("/tmp/a.txt"), file_entry("/tmp/b.txt")],
1351 };
1352 state.core.rescan_if_needed(&fs);
1353
1354 let a = state
1355 .core
1356 .entries()
1357 .iter()
1358 .find(|entry| entry.path == Path::new("/tmp/a.txt"))
1359 .map(|entry| entry.id)
1360 .expect("missing /tmp/a.txt entry id");
1361 let b = state
1362 .core
1363 .entries()
1364 .iter()
1365 .find(|entry| entry.path == Path::new("/tmp/b.txt"))
1366 .map(|entry| entry.id)
1367 .expect("missing /tmp/b.txt entry id");
1368 state.core.replace_selection_by_ids([b, a]);
1369
1370 open_delete_modal_from_selection(&mut state);
1371
1372 assert_eq!(state.ui.delete_target_ids, vec![b, a]);
1373 assert!(state.ui.delete_open_next);
1374 }
1375}