Skip to main content

dear_file_browser/ui/
mod.rs

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/// Configuration for hosting the file browser in an ImGui window.
24#[derive(Clone, Debug)]
25pub struct WindowHostConfig {
26    /// Window title
27    pub title: String,
28    /// Initial window size (used with `size_condition`)
29    pub initial_size: [f32; 2],
30    /// Condition used when setting the window size
31    pub size_condition: dear_imgui_rs::Condition,
32    /// Optional minimum size constraint.
33    pub min_size: Option<[f32; 2]>,
34    /// Optional maximum size constraint.
35    pub max_size: Option<[f32; 2]>,
36}
37
38impl WindowHostConfig {
39    /// Default window host configuration for the given dialog mode.
40    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/// Configuration for hosting the file browser in an ImGui modal popup.
57///
58/// `popup_label` must be stable across frames. For multiple concurrent dialogs,
59/// ensure the label includes a unique ID suffix (ImGui `###` syntax is fine).
60#[derive(Clone, Debug)]
61pub struct ModalHostConfig {
62    /// Modal popup label/title (supports `###` id suffix).
63    pub popup_label: String,
64    /// Initial modal size (used with `size_condition`).
65    pub initial_size: [f32; 2],
66    /// Condition used when setting the popup size.
67    pub size_condition: dear_imgui_rs::Condition,
68    /// Optional minimum size constraint.
69    pub min_size: Option<[f32; 2]>,
70    /// Optional maximum size constraint.
71    pub max_size: Option<[f32; 2]>,
72}
73
74impl ModalHostConfig {
75    /// Default modal host configuration for the given dialog mode.
76    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
92/// UI handle for file browser
93pub struct FileBrowser<'ui> {
94    pub ui: &'ui Ui,
95}
96
97/// Extend Ui with a file browser entry point
98pub trait FileDialogExt {
99    /// Entry point for showing the file browser widget
100    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    /// Draw only the contents of the file browser (no window/modal host).
111    ///
112    /// This is useful for embedding the browser into an existing window, popup,
113    /// tab, or child region managed by the caller.
114    ///
115    /// Returns Some(result) once the user confirms/cancels; None otherwise.
116    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    /// Draw only the contents of the file browser (no window/modal host) with explicit hooks.
124    ///
125    /// - `fs`: filesystem backend used by core operations.
126    /// - `custom_pane`: optional custom pane that can render extra UI and block confirm.
127    /// - `thumbnails_backend`: optional backend for thumbnail decode/upload lifecycle.
128    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    /// Draw the file browser in a standard ImGui window with default host config.
145    /// Returns Some(result) once the user confirms/cancels; None otherwise.
146    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    /// Draw the file browser in a standard ImGui window using the given host configuration.
152    /// Returns Some(result) once the user confirms/cancels; None otherwise.
153    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    /// Draw the file browser in a standard ImGui window with explicit hooks.
162    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    /// Draw the file browser in an ImGui modal popup with default host config.
197    /// Returns Some(result) once the user confirms/cancels; None otherwise.
198    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    /// Draw the file browser in an ImGui modal popup with explicit hooks.
207    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    // Make all widget IDs inside this browser instance unique, even when embedding
307    // multiple dialogs in the same host window. This avoids ImGui "conflicting ID"
308    // warnings for internal child windows/popups/tooltips.
309    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    // Content region
318    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    // IGFD-style quick path selection popup (opened from breadcrumb separators).
778    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    // Match IGFD's typical splitter thickness (~4px) but keep it relative to current style.
804    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}