Skip to main content

dear_file_browser/
dialog_state.rs

1use std::path::PathBuf;
2
3use dear_imgui_rs::FontId;
4
5use crate::core::{ClickAction, DialogMode, LayoutStyle};
6use crate::dialog_core::{EntryId, FileDialogCore, ScanPolicy, ScanStatus};
7use crate::file_style::FileStyleRegistry;
8use crate::thumbnails::{ThumbnailCache, ThumbnailCacheConfig};
9
10/// View mode for the file list region.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum FileListViewMode {
13    /// Table-style list view (columns: name/size/modified, optional thumbnail preview column).
14    List,
15    /// Table-style list view with thumbnails enabled and preview column shown.
16    ///
17    /// This is intended to match IGFD’s “thumbnails list” mode (small thumbs on the same row).
18    ThumbnailsList,
19    /// Thumbnail grid view.
20    Grid,
21}
22
23/// Data column identifier for list view (excluding optional preview thumbnail column).
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub enum FileListDataColumn {
26    /// File name (main selectable cell).
27    Name,
28    /// File extension (derived from name).
29    Extension,
30    /// File size.
31    Size,
32    /// Last-modified timestamp.
33    Modified,
34}
35
36impl FileListDataColumn {
37    fn compact_token(self) -> &'static str {
38        match self {
39            Self::Name => "name",
40            Self::Extension => "ext",
41            Self::Size => "size",
42            Self::Modified => "modified",
43        }
44    }
45
46    fn from_compact_token(token: &str) -> Option<Self> {
47        match token {
48            "name" => Some(Self::Name),
49            "ext" => Some(Self::Extension),
50            "size" => Some(Self::Size),
51            "modified" => Some(Self::Modified),
52            _ => None,
53        }
54    }
55}
56
57/// Optional per-column width overrides for list view.
58///
59/// Values are interpreted as ImGui table stretch weights and must be finite and positive.
60/// When an override is `None`, the built-in heuristic weight is used.
61#[derive(Clone, Debug, PartialEq)]
62pub struct FileListColumnWeightOverrides {
63    /// Preview (thumbnail) column weight.
64    pub preview: Option<f32>,
65    /// Name column weight.
66    pub name: Option<f32>,
67    /// Extension column weight.
68    pub extension: Option<f32>,
69    /// Size column weight.
70    pub size: Option<f32>,
71    /// Modified column weight.
72    pub modified: Option<f32>,
73}
74
75impl Default for FileListColumnWeightOverrides {
76    fn default() -> Self {
77        Self {
78            preview: None,
79            name: None,
80            extension: None,
81            size: None,
82            modified: None,
83        }
84    }
85}
86
87/// Column visibility configuration for list view.
88#[derive(Clone, Debug, PartialEq)]
89pub struct FileListColumnsConfig {
90    /// Show preview column in list view when thumbnails are enabled.
91    pub show_preview: bool,
92    /// Show extension column in list view.
93    pub show_extension: bool,
94    /// Show size column in list view.
95    pub show_size: bool,
96    /// Show modified time column in list view.
97    pub show_modified: bool,
98    /// Render order for data columns (Name/Extension/Size/Modified).
99    ///
100    /// Name/Extension are always shown, while Size/Modified still obey visibility flags.
101    pub order: [FileListDataColumn; 4],
102    /// Optional per-column stretch-weight overrides.
103    pub weight_overrides: FileListColumnWeightOverrides,
104}
105
106/// Error returned by [`FileListColumnsConfig::deserialize_compact`].
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct FileListColumnsDeserializeError {
109    msg: String,
110}
111
112impl FileListColumnsDeserializeError {
113    fn new(msg: impl Into<String>) -> Self {
114        Self { msg: msg.into() }
115    }
116}
117
118impl std::fmt::Display for FileListColumnsDeserializeError {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        write!(f, "file list columns deserialize error: {}", self.msg)
121    }
122}
123
124impl std::error::Error for FileListColumnsDeserializeError {}
125
126impl FileListColumnsConfig {
127    /// Serializes list-column preferences to a compact string.
128    ///
129    /// This is dependency-free (no serde) and intended for app-level persistence.
130    pub fn serialize_compact(&self) -> String {
131        let order = self
132            .normalized_order()
133            .iter()
134            .map(|c| c.compact_token())
135            .collect::<Vec<_>>()
136            .join(",");
137        let weights = [
138            self.weight_overrides.preview,
139            self.weight_overrides.name,
140            self.weight_overrides.extension,
141            self.weight_overrides.size,
142            self.weight_overrides.modified,
143        ]
144        .into_iter()
145        .map(|v| {
146            v.map(|w| format!("{w:.4}"))
147                .unwrap_or_else(|| "auto".to_string())
148        })
149        .collect::<Vec<_>>()
150        .join(",");
151
152        format!(
153            "v1;preview={};ext={};size={};modified={};order={};weights={}",
154            u8::from(self.show_preview),
155            u8::from(self.show_extension),
156            u8::from(self.show_size),
157            u8::from(self.show_modified),
158            order,
159            weights,
160        )
161    }
162
163    /// Deserializes list-column preferences from [`Self::serialize_compact`] format.
164    pub fn deserialize_compact(input: &str) -> Result<Self, FileListColumnsDeserializeError> {
165        let mut version_ok = false;
166        let mut preview = None;
167        let mut ext = None;
168        let mut size = None;
169        let mut modified = None;
170        let mut order = None;
171        let mut weights = None;
172
173        for token in input.split(';').filter(|s| !s.trim().is_empty()) {
174            if token == "v1" {
175                version_ok = true;
176                continue;
177            }
178            if token.starts_with('v') {
179                return Err(FileListColumnsDeserializeError::new(format!(
180                    "unsupported version token `{token}`"
181                )));
182            }
183            let (key, value) = token.split_once('=').ok_or_else(|| {
184                FileListColumnsDeserializeError::new(format!("invalid token `{token}`"))
185            })?;
186            match key {
187                "preview" => preview = Some(parse_compact_bool(value)?),
188                "ext" => ext = Some(parse_compact_bool(value)?),
189                "size" => size = Some(parse_compact_bool(value)?),
190                "modified" => modified = Some(parse_compact_bool(value)?),
191                "order" => order = Some(parse_compact_order(value)?),
192                "weights" => weights = Some(parse_compact_weights(value)?),
193                _ => {
194                    return Err(FileListColumnsDeserializeError::new(format!(
195                        "unknown key `{key}`"
196                    )));
197                }
198            }
199        }
200
201        if !version_ok {
202            return Err(FileListColumnsDeserializeError::new(
203                "missing or unsupported version token",
204            ));
205        }
206
207        Ok(Self {
208            show_preview: preview
209                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `preview`"))?,
210            show_extension: ext
211                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `ext`"))?,
212            show_size: size
213                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `size`"))?,
214            show_modified: modified
215                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `modified`"))?,
216            order: order
217                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `order`"))?,
218            weight_overrides: weights
219                .ok_or_else(|| FileListColumnsDeserializeError::new("missing key `weights`"))?,
220        })
221    }
222
223    /// Returns a deterministic valid order (dedup + append missing columns).
224    pub fn normalized_order(&self) -> [FileListDataColumn; 4] {
225        normalized_order(self.order)
226    }
227}
228
229impl Default for FileListColumnsConfig {
230    fn default() -> Self {
231        Self {
232            show_preview: true,
233            show_extension: true,
234            show_size: true,
235            show_modified: true,
236            order: [
237                FileListDataColumn::Name,
238                FileListDataColumn::Extension,
239                FileListDataColumn::Size,
240                FileListDataColumn::Modified,
241            ],
242            weight_overrides: FileListColumnWeightOverrides::default(),
243        }
244    }
245}
246
247fn normalized_order(order: [FileListDataColumn; 4]) -> [FileListDataColumn; 4] {
248    let mut out = Vec::with_capacity(4);
249    for c in order {
250        if !out.contains(&c) {
251            out.push(c);
252        }
253    }
254    for c in [
255        FileListDataColumn::Name,
256        FileListDataColumn::Extension,
257        FileListDataColumn::Size,
258        FileListDataColumn::Modified,
259    ] {
260        if !out.contains(&c) {
261            out.push(c);
262        }
263    }
264    [out[0], out[1], out[2], out[3]]
265}
266
267fn parse_compact_bool(value: &str) -> Result<bool, FileListColumnsDeserializeError> {
268    match value {
269        "0" => Ok(false),
270        "1" => Ok(true),
271        _ => Err(FileListColumnsDeserializeError::new(format!(
272            "invalid bool value `{value}`"
273        ))),
274    }
275}
276
277fn parse_compact_order(
278    value: &str,
279) -> Result<[FileListDataColumn; 4], FileListColumnsDeserializeError> {
280    let cols = value
281        .split(',')
282        .map(FileListDataColumn::from_compact_token)
283        .collect::<Option<Vec<_>>>()
284        .ok_or_else(|| FileListColumnsDeserializeError::new("invalid column token in `order`"))?;
285    if cols.len() != 4 {
286        return Err(FileListColumnsDeserializeError::new(
287            "`order` must contain exactly 4 columns",
288        ));
289    }
290    let order = [cols[0], cols[1], cols[2], cols[3]];
291    let normalized = normalized_order(order);
292    if normalized != order {
293        return Err(FileListColumnsDeserializeError::new(
294            "`order` must contain each column exactly once",
295        ));
296    }
297    Ok(order)
298}
299
300fn parse_compact_optional_weight(
301    value: &str,
302) -> Result<Option<f32>, FileListColumnsDeserializeError> {
303    if value.eq_ignore_ascii_case("auto") {
304        return Ok(None);
305    }
306    let parsed = value.parse::<f32>().map_err(|_| {
307        FileListColumnsDeserializeError::new(format!("invalid weight value `{value}`"))
308    })?;
309    if !parsed.is_finite() || parsed <= 0.0 {
310        return Err(FileListColumnsDeserializeError::new(format!(
311            "weight must be finite and > 0, got `{value}`"
312        )));
313    }
314    Ok(Some(parsed))
315}
316
317fn parse_compact_weights(
318    value: &str,
319) -> Result<FileListColumnWeightOverrides, FileListColumnsDeserializeError> {
320    let parts: Vec<&str> = value.split(',').collect();
321    if parts.len() != 5 {
322        return Err(FileListColumnsDeserializeError::new(
323            "`weights` must contain exactly 5 values",
324        ));
325    }
326    Ok(FileListColumnWeightOverrides {
327        preview: parse_compact_optional_weight(parts[0])?,
328        name: parse_compact_optional_weight(parts[1])?,
329        extension: parse_compact_optional_weight(parts[2])?,
330        size: parse_compact_optional_weight(parts[3])?,
331        modified: parse_compact_optional_weight(parts[4])?,
332    })
333}
334
335impl Default for FileListViewMode {
336    fn default() -> Self {
337        Self::List
338    }
339}
340
341/// Alignment of the validation button row (Ok/Cancel).
342#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
343pub enum ValidationButtonsAlign {
344    /// Align buttons to the left side of the row.
345    #[default]
346    Left,
347    /// Align buttons to the right side of the row.
348    Right,
349}
350
351/// Button ordering for the validation row (Ok/Cancel).
352#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
353pub enum ValidationButtonsOrder {
354    /// Confirm button, then Cancel.
355    #[default]
356    ConfirmCancel,
357    /// Cancel button, then Confirm.
358    CancelConfirm,
359}
360
361/// Configuration for the validation button row (Ok/Cancel).
362#[derive(Clone, Debug)]
363pub struct ValidationButtonsConfig {
364    /// Row alignment (left/right).
365    pub align: ValidationButtonsAlign,
366    /// Button ordering.
367    pub order: ValidationButtonsOrder,
368    /// Optional confirm label override (defaults to "Open"/"Save"/"Select").
369    pub confirm_label: Option<String>,
370    /// Optional cancel label override (defaults to "Cancel").
371    pub cancel_label: Option<String>,
372    /// Optional width applied to both buttons (in pixels).
373    pub button_width: Option<f32>,
374    /// Optional confirm button width override (in pixels).
375    pub confirm_width: Option<f32>,
376    /// Optional cancel button width override (in pixels).
377    pub cancel_width: Option<f32>,
378}
379
380impl Default for ValidationButtonsConfig {
381    fn default() -> Self {
382        Self {
383            align: ValidationButtonsAlign::Left,
384            order: ValidationButtonsOrder::ConfirmCancel,
385            confirm_label: None,
386            cancel_label: None,
387            button_width: None,
388            confirm_width: None,
389            cancel_width: None,
390        }
391    }
392}
393
394/// Density preset for the dialog top toolbar ("chrome").
395#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
396pub enum ToolbarDensity {
397    /// Use the host's default Dear ImGui style values.
398    #[default]
399    Normal,
400    /// Reduce padding and spacing to fit more controls (IGFD-like).
401    Compact,
402    /// Increase padding and spacing for touch-friendly UIs.
403    Spacious,
404}
405
406/// How to render toolbar buttons when optional icons are provided.
407#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
408pub enum ToolbarIconMode {
409    /// Render text-only labels.
410    #[default]
411    Text,
412    /// Render icon-only labels (falls back to text if an icon is not provided).
413    IconOnly,
414    /// Render icon + text (falls back to text if an icon is not provided).
415    IconAndText,
416}
417
418/// Optional toolbar icons (host-provided glyphs, typically from an icon font).
419#[derive(Clone, Debug, Default)]
420pub struct ToolbarIcons {
421    /// Icon rendering mode.
422    pub mode: ToolbarIconMode,
423    /// Icon for "Places".
424    pub places: Option<String>,
425    /// Icon for "Refresh".
426    pub refresh: Option<String>,
427    /// Icon for "New Folder".
428    pub new_folder: Option<String>,
429    /// Icon for "Columns".
430    pub columns: Option<String>,
431    /// Icon for "Options".
432    pub options: Option<String>,
433}
434
435/// Configuration for the dialog top toolbar ("chrome").
436#[derive(Clone, Debug)]
437pub struct ToolbarConfig {
438    /// Density preset affecting padding/spacing.
439    pub density: ToolbarDensity,
440    /// Optional icon glyphs for toolbar buttons.
441    pub icons: ToolbarIcons,
442    /// Whether to show tooltips for toolbar controls.
443    pub show_tooltips: bool,
444}
445
446impl Default for ToolbarConfig {
447    fn default() -> Self {
448        Self {
449            density: ToolbarDensity::Normal,
450            icons: ToolbarIcons::default(),
451            show_tooltips: true,
452        }
453    }
454}
455
456/// Clipboard operation kind used by the in-UI file browser.
457#[derive(Clone, Copy, Debug, PartialEq, Eq)]
458pub enum ClipboardOp {
459    /// Copy sources into the destination directory on paste.
460    Copy,
461    /// Move sources into the destination directory on paste.
462    Cut,
463}
464
465/// In-dialog clipboard for file operations (copy/cut/paste).
466#[derive(Clone, Debug)]
467pub struct FileClipboard {
468    /// Operation kind.
469    pub op: ClipboardOp,
470    /// Absolute source paths captured when the clipboard was populated.
471    pub sources: Vec<PathBuf>,
472}
473
474/// Conflict action used when a paste target already exists.
475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
476pub(crate) enum PasteConflictAction {
477    /// Replace the existing destination entry.
478    Overwrite,
479    /// Skip this source entry.
480    Skip,
481    /// Keep both entries by allocating a unique destination name.
482    KeepBoth,
483}
484
485/// Pending conflict information shown in the paste conflict modal.
486#[derive(Clone, Debug)]
487pub(crate) struct PasteConflictPrompt {
488    /// Source path currently being pasted.
489    pub source: PathBuf,
490    /// Destination path that already exists.
491    pub dest: PathBuf,
492    /// Whether to reuse the chosen action for all remaining conflicts.
493    pub apply_to_all: bool,
494}
495
496/// In-progress paste job state (supports modal conflict resolution).
497#[derive(Clone, Debug)]
498pub(crate) struct PendingPasteJob {
499    /// Clipboard snapshot captured when paste was triggered.
500    pub clipboard: FileClipboard,
501    /// Destination directory where entries are pasted.
502    pub dest_dir: PathBuf,
503    /// Next source index to process.
504    pub next_index: usize,
505    /// Destination entry names created by this job.
506    pub created: Vec<String>,
507    /// Optional action reused for all remaining conflicts.
508    pub apply_all_conflicts: Option<PasteConflictAction>,
509    /// One-shot action for the next pending conflict only.
510    pub pending_conflict_action: Option<PasteConflictAction>,
511    /// Current conflict waiting for user decision.
512    pub conflict: Option<PasteConflictPrompt>,
513}
514
515/// Places import/export modal mode.
516#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
517pub(crate) enum PlacesIoMode {
518    /// Export the current places into a text buffer.
519    #[default]
520    Export,
521    /// Import places from a text buffer.
522    Import,
523}
524
525/// Places edit modal mode.
526#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
527pub(crate) enum PlacesEditMode {
528    /// Create a new user group.
529    #[default]
530    AddGroup,
531    /// Rename an existing group.
532    RenameGroup,
533    /// Add a new user place into a group.
534    AddPlace,
535    /// Edit an existing user place (label/path).
536    EditPlace,
537    /// Confirm removing a group.
538    RemoveGroupConfirm,
539}
540
541/// UI-only state for hosting a [`FileDialogCore`] in Dear ImGui.
542///
543/// This struct contains transient UI state (visibility, focus requests, text
544/// buffers) and does not affect the core selection/navigation semantics.
545#[derive(Debug)]
546pub struct FileDialogUiState {
547    /// Whether to draw the dialog (show/hide). Prefer [`FileDialogState::open`]/[`FileDialogState::close`].
548    pub visible: bool,
549    /// Header layout style.
550    pub header_style: HeaderStyle,
551    /// Layout style for the dialog UI.
552    pub layout: LayoutStyle,
553    /// Validation button row configuration (Ok/Cancel).
554    pub validation_buttons: ValidationButtonsConfig,
555    /// Top toolbar ("chrome") configuration.
556    pub toolbar: ToolbarConfig,
557    /// Whether to show the left "Places" pane in [`LayoutStyle::Standard`].
558    pub places_pane_shown: bool,
559    /// Width of the left "Places" pane in pixels (Standard layout only).
560    pub places_pane_width: f32,
561    /// File list view mode (list vs grid).
562    pub file_list_view: FileListViewMode,
563    /// List-view column visibility configuration.
564    pub file_list_columns: FileListColumnsConfig,
565    /// Path bar style (editable text input vs breadcrumb-style composer).
566    pub path_bar_style: PathBarStyle,
567    /// When `true` (and `path_bar_style` is [`PathBarStyle::Breadcrumbs`]), show the editable path
568    /// text input instead of the breadcrumb composer.
569    ///
570    /// This mimics IGFD's path composer "Edit" toggle behavior.
571    pub path_input_mode: bool,
572    /// Enable quick parallel directory selection popups when clicking breadcrumb separators.
573    ///
574    /// This mimics IGFD's "quick path selection" feature in the path composer.
575    pub breadcrumbs_quick_select: bool,
576    /// Max breadcrumb segments to display (compress with ellipsis when exceeded).
577    pub breadcrumbs_max_segments: usize,
578    /// Show a hint row when no entries match filters/search.
579    pub empty_hint_enabled: bool,
580    /// RGBA color of the empty hint text.
581    pub empty_hint_color: [f32; 4],
582    /// Custom static hint message when entries list is empty; if None, a default message is built.
583    pub empty_hint_static_message: Option<String>,
584    /// Whether the path input is currently being edited (best-effort; updated by UI).
585    ///
586    /// This is UI-only state and should not be treated as a stable API contract.
587    pub path_edit: bool,
588    /// Path input buffer (editable "address bar").
589    pub path_edit_buffer: String,
590    pub(crate) path_edit_last_cwd: String,
591    pub(crate) breadcrumbs_scroll_to_end_next: bool,
592    pub(crate) opened_cwd: Option<PathBuf>,
593    pub(crate) path_history_index: Option<usize>,
594    pub(crate) path_history_saved_buffer: Option<String>,
595    pub(crate) path_bar_programmatic_edit: bool,
596    /// Focus path edit on next frame.
597    pub focus_path_edit_next: bool,
598    /// Focus search on next frame (Ctrl+F).
599    pub focus_search_next: bool,
600    /// Error string to display in UI (non-fatal).
601    pub ui_error: Option<String>,
602    /// Whether to show and allow the "New Folder" action.
603    pub new_folder_enabled: bool,
604    /// Whether the "New Folder" inline editor is active (toolbar-local, IGFD-like).
605    pub new_folder_inline_active: bool,
606    /// Open the "New Folder" modal on next frame.
607    pub new_folder_open_next: bool,
608    /// New folder name buffer (used by the "New Folder" modal).
609    pub new_folder_name: String,
610    /// Focus the new folder input on next frame.
611    pub new_folder_focus_next: bool,
612    /// Error string shown inside the "New Folder" modal.
613    pub new_folder_error: Option<String>,
614    /// Open the "Rename" modal on next frame.
615    pub rename_open_next: bool,
616    /// Focus the rename input on next frame.
617    pub rename_focus_next: bool,
618    /// Rename target entry id.
619    pub rename_target_id: Option<EntryId>,
620    /// Rename "to" buffer.
621    pub rename_to: String,
622    /// Error string shown inside the rename modal.
623    pub rename_error: Option<String>,
624    /// Open the "Delete" confirmation modal on next frame.
625    pub delete_open_next: bool,
626    /// Pending delete target ids.
627    pub delete_target_ids: Vec<EntryId>,
628    /// Whether directory deletion should be recursive (`remove_dir_all`) instead of requiring empty directories.
629    pub delete_recursive: bool,
630    /// Error string shown inside the delete modal.
631    pub delete_error: Option<String>,
632    /// Clipboard state for copy/cut/paste operations.
633    pub clipboard: Option<FileClipboard>,
634    /// Optional font mapping used by file style `font_token`.
635    pub file_style_fonts: std::collections::HashMap<String, FontId>,
636    /// In-progress paste job state.
637    pub(crate) paste_job: Option<PendingPasteJob>,
638    /// Open the paste conflict modal on next frame.
639    pub(crate) paste_conflict_open_next: bool,
640    /// Reveal (scroll to) a specific entry id on the next draw, then clear.
641    pub(crate) reveal_id_next: Option<EntryId>,
642    /// Style registry used to decorate the file list (icons/colors/tooltips).
643    pub file_styles: FileStyleRegistry,
644    /// Enable thumbnails in the file list (adds a Preview column).
645    pub thumbnails_enabled: bool,
646    /// Thumbnail preview size in pixels.
647    pub thumbnail_size: [f32; 2],
648    /// Thumbnail cache (requests + LRU).
649    pub thumbnails: ThumbnailCache,
650    /// Enable "type-to-select" behavior in the file list (IGFD-style).
651    pub type_select_enabled: bool,
652    /// Timeout (milliseconds) after which the type-to-select buffer resets.
653    pub type_select_timeout_ms: u64,
654    /// Whether to render a custom pane region (when a pane is provided by the caller).
655    pub custom_pane_enabled: bool,
656    /// Dock position for the custom pane.
657    pub custom_pane_dock: CustomPaneDock,
658    /// Height of the custom pane region (in pixels).
659    pub custom_pane_height: f32,
660    /// Width of the custom pane region when right-docked (in pixels).
661    pub custom_pane_width: f32,
662
663    /// Places modal mode (export/import).
664    pub(crate) places_io_mode: PlacesIoMode,
665    /// Places modal text buffer.
666    pub(crate) places_io_buffer: String,
667    /// Open the places modal on next frame.
668    pub(crate) places_io_open_next: bool,
669    /// Whether export should include code-defined places.
670    pub(crate) places_io_include_code: bool,
671    /// Error string shown inside the places modal.
672    pub(crate) places_io_error: Option<String>,
673
674    /// Places edit modal mode.
675    pub(crate) places_edit_mode: PlacesEditMode,
676    /// Open the places edit modal on next frame.
677    pub(crate) places_edit_open_next: bool,
678    /// Focus the first input in the places edit modal on next frame.
679    pub(crate) places_edit_focus_next: bool,
680    /// Error string shown inside the places edit modal.
681    pub(crate) places_edit_error: Option<String>,
682    /// Target group label (add/edit place, rename/remove group).
683    pub(crate) places_edit_group: String,
684    /// Source group label (rename/remove group).
685    pub(crate) places_edit_group_from: Option<String>,
686    /// Source place path for editing (stable identity).
687    pub(crate) places_edit_place_from_path: Option<PathBuf>,
688    /// Place label buffer (add/edit place).
689    pub(crate) places_edit_place_label: String,
690    /// Place path buffer (add/edit place).
691    pub(crate) places_edit_place_path: String,
692
693    pub(crate) type_select_buffer: String,
694    pub(crate) type_select_last_input: Option<std::time::Instant>,
695    /// UI-only selection inside the places pane: (group_label, place_path).
696    pub(crate) places_selected: Option<(String, PathBuf)>,
697
698    /// Inline edit (IGFD-like) state for place labels: (group_label, place_path).
699    pub(crate) places_inline_edit: Option<(String, PathBuf)>,
700    /// Inline edit buffer for the selected place label.
701    pub(crate) places_inline_edit_buffer: String,
702    /// Focus the inline edit input on next frame.
703    pub(crate) places_inline_edit_focus_next: bool,
704
705    /// Current parent dir for the breadcrumb quick-select popup.
706    pub(crate) breadcrumb_quick_parent: Option<PathBuf>,
707
708    /// Last measured footer height (in window coordinates), used to size the content region
709    /// without hard-coded constants. Updated each frame after drawing the footer.
710    pub(crate) footer_height_last: f32,
711
712    /// UI buffer for the footer "File/Folder" input.
713    ///
714    /// - SaveFile uses `core.save_name` instead.
715    /// - OpenFile/OpenFiles can be typed to open a file by name/path (IGFD-style).
716    /// - PickFolder currently uses this for display only.
717    pub(crate) footer_file_name_buffer: String,
718    /// The last auto-generated display string for the footer input, used to keep the field
719    /// synced to selection unless the user edits it.
720    pub(crate) footer_file_name_last_display: String,
721}
722
723impl Default for FileDialogUiState {
724    fn default() -> Self {
725        Self {
726            visible: true,
727            header_style: HeaderStyle::ToolbarAndAddress,
728            layout: LayoutStyle::Standard,
729            validation_buttons: ValidationButtonsConfig::default(),
730            toolbar: ToolbarConfig::default(),
731            places_pane_shown: true,
732            places_pane_width: 150.0,
733            file_list_view: FileListViewMode::default(),
734            file_list_columns: FileListColumnsConfig::default(),
735            path_bar_style: PathBarStyle::TextInput,
736            path_input_mode: false,
737            breadcrumbs_quick_select: true,
738            breadcrumbs_max_segments: 6,
739            empty_hint_enabled: true,
740            empty_hint_color: [0.7, 0.7, 0.7, 1.0],
741            empty_hint_static_message: None,
742            path_edit: false,
743            path_edit_buffer: String::new(),
744            path_edit_last_cwd: String::new(),
745            breadcrumbs_scroll_to_end_next: false,
746            opened_cwd: None,
747            path_history_index: None,
748            path_history_saved_buffer: None,
749            path_bar_programmatic_edit: false,
750            focus_path_edit_next: false,
751            focus_search_next: false,
752            ui_error: None,
753            new_folder_enabled: true,
754            new_folder_inline_active: false,
755            new_folder_open_next: false,
756            new_folder_name: String::new(),
757            new_folder_focus_next: false,
758            new_folder_error: None,
759            rename_open_next: false,
760            rename_focus_next: false,
761            rename_target_id: None,
762            rename_to: String::new(),
763            rename_error: None,
764            delete_open_next: false,
765            delete_target_ids: Vec::new(),
766            delete_recursive: false,
767            delete_error: None,
768            clipboard: None,
769            file_style_fonts: std::collections::HashMap::new(),
770            paste_job: None,
771            paste_conflict_open_next: false,
772            reveal_id_next: None,
773            file_styles: FileStyleRegistry::default(),
774            thumbnails_enabled: false,
775            thumbnail_size: [32.0, 32.0],
776            thumbnails: ThumbnailCache::new(ThumbnailCacheConfig::default()),
777            type_select_enabled: true,
778            type_select_timeout_ms: 750,
779            custom_pane_enabled: true,
780            custom_pane_dock: CustomPaneDock::default(),
781            custom_pane_height: 120.0,
782            custom_pane_width: 250.0,
783            places_io_mode: PlacesIoMode::Export,
784            places_io_buffer: String::new(),
785            places_io_open_next: false,
786            places_io_include_code: false,
787            places_io_error: None,
788            places_edit_mode: PlacesEditMode::default(),
789            places_edit_open_next: false,
790            places_edit_focus_next: false,
791            places_edit_error: None,
792            places_edit_group: String::new(),
793            places_edit_group_from: None,
794            places_edit_place_from_path: None,
795            places_edit_place_label: String::new(),
796            places_edit_place_path: String::new(),
797            type_select_buffer: String::new(),
798            type_select_last_input: None,
799            places_selected: None,
800            places_inline_edit: None,
801            places_inline_edit_buffer: String::new(),
802            places_inline_edit_focus_next: false,
803            breadcrumb_quick_parent: None,
804            footer_height_last: 0.0,
805            footer_file_name_buffer: String::new(),
806            footer_file_name_last_display: String::new(),
807        }
808    }
809}
810
811impl FileDialogUiState {
812    /// Applies an "IGFD classic" UI preset (opt-in).
813    ///
814    /// This tunes UI defaults to feel closer to ImGuiFileDialog (IGFD) while staying Rust-first:
815    /// - standard layout with places pane,
816    /// - IGFD-like single-row header layout,
817    /// - list view as the default,
818    /// - right-docked custom pane (when provided) with a splitter-resizable width,
819    /// - dialog-style button row aligned to the right.
820    pub fn apply_igfd_classic_preset(&mut self) {
821        self.header_style = HeaderStyle::IgfdClassic;
822        self.layout = LayoutStyle::Standard;
823        self.places_pane_shown = true;
824        self.places_pane_width = 150.0;
825        self.file_list_view = FileListViewMode::List;
826        self.thumbnails_enabled = false;
827        self.toolbar.density = ToolbarDensity::Compact;
828        self.path_bar_style = PathBarStyle::Breadcrumbs;
829        self.path_input_mode = false;
830        self.breadcrumbs_scroll_to_end_next = true;
831        self.breadcrumbs_quick_select = true;
832
833        if self.file_styles.rules.is_empty() && self.file_styles.callback.is_none() {
834            self.file_styles = crate::file_style::FileStyleRegistry::igfd_ascii_preset();
835        }
836
837        self.file_list_columns.show_preview = false;
838        self.file_list_columns.show_extension = false;
839        self.file_list_columns.show_size = true;
840        self.file_list_columns.show_modified = true;
841        self.file_list_columns.order = [
842            FileListDataColumn::Name,
843            FileListDataColumn::Extension,
844            FileListDataColumn::Size,
845            FileListDataColumn::Modified,
846        ];
847
848        self.custom_pane_enabled = true;
849        self.custom_pane_dock = CustomPaneDock::Right;
850        self.custom_pane_width = 250.0;
851        self.custom_pane_height = 120.0;
852
853        self.validation_buttons.align = ValidationButtonsAlign::Right;
854        self.validation_buttons.order = ValidationButtonsOrder::CancelConfirm;
855        self.validation_buttons.confirm_label = Some("OK".to_string());
856        self.validation_buttons.cancel_label = Some("Cancel".to_string());
857        self.validation_buttons.button_width = None;
858        self.validation_buttons.confirm_width = None;
859        self.validation_buttons.cancel_width = None;
860    }
861}
862
863/// Header layout style.
864#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
865pub enum HeaderStyle {
866    /// Two-row layout: a top toolbar row plus a separate address/search row.
867    #[default]
868    ToolbarAndAddress,
869    /// A single-row header that mimics ImGuiFileDialog's classic header layout.
870    IgfdClassic,
871}
872
873/// Path bar style (text input vs breadcrumb composer).
874#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
875pub enum PathBarStyle {
876    /// Always show an editable path text input ("address bar").
877    #[default]
878    TextInput,
879    /// Show a breadcrumb-style path composer; edit mode can still be entered via Ctrl+L or context menu.
880    Breadcrumbs,
881}
882
883/// Dock position for the custom pane.
884#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
885pub enum CustomPaneDock {
886    /// Dock the custom pane below the file list (default).
887    #[default]
888    Bottom,
889    /// Dock the custom pane on the right side, similar to IGFD `sidePane`.
890    Right,
891}
892
893/// Combined state for the in-UI file dialog.
894#[derive(Debug)]
895pub struct FileDialogState {
896    /// Core state machine.
897    pub core: FileDialogCore,
898    /// UI-only state.
899    pub ui: FileDialogUiState,
900}
901
902impl FileDialogState {
903    /// Creates a new dialog state for a mode.
904    pub fn new(mode: DialogMode) -> Self {
905        let mut core = FileDialogCore::new(mode);
906        core.set_scan_policy(ScanPolicy::tuned_incremental());
907        Self {
908            core,
909            ui: FileDialogUiState::default(),
910        }
911    }
912
913    /// Opens (or reopens) the dialog.
914    ///
915    /// This mirrors IGFD's `OpenDialog` step before `Display`.
916    pub fn open(&mut self) {
917        self.ui.visible = true;
918        self.ui.opened_cwd = Some(self.core.cwd.clone());
919    }
920
921    /// Reopens the dialog.
922    ///
923    /// Alias of [`FileDialogState::open`].
924    pub fn reopen(&mut self) {
925        self.open();
926    }
927
928    /// Closes the dialog.
929    ///
930    /// This mirrors IGFD's `Close` call.
931    pub fn close(&mut self) {
932        self.ui.visible = false;
933    }
934
935    /// Returns whether the dialog is currently open.
936    pub fn is_open(&self) -> bool {
937        self.ui.visible
938    }
939
940    /// Returns the active scan policy.
941    pub fn scan_policy(&self) -> ScanPolicy {
942        self.core.scan_policy()
943    }
944
945    /// Sets scan policy for future directory refreshes.
946    pub fn set_scan_policy(&mut self, policy: ScanPolicy) {
947        self.core.set_scan_policy(policy);
948    }
949
950    /// Returns the latest scan status from core.
951    pub fn scan_status(&self) -> &ScanStatus {
952        self.core.scan_status()
953    }
954
955    /// Requests a rescan on the next draw tick.
956    pub fn request_rescan(&mut self) {
957        self.core.request_rescan();
958    }
959
960    /// Installs a scan hook on the core listing pipeline.
961    ///
962    /// The hook runs during directory scan and can mutate or drop entries.
963    pub fn set_scan_hook<F>(&mut self, hook: F)
964    where
965        F: FnMut(&mut crate::FsEntry) -> crate::ScanHookAction + 'static,
966    {
967        self.core.set_scan_hook(hook);
968    }
969
970    /// Clears the scan hook and restores raw filesystem listing.
971    pub fn clear_scan_hook(&mut self) {
972        self.core.clear_scan_hook();
973    }
974
975    /// Applies an "IGFD classic" preset for both UI and core.
976    ///
977    /// This is a convenience wrapper over [`FileDialogUiState::apply_igfd_classic_preset`] that
978    /// also tunes core defaults to match typical IGFD behavior.
979    pub fn apply_igfd_classic_preset(&mut self) {
980        self.ui.apply_igfd_classic_preset();
981        self.core.click_action = ClickAction::Navigate;
982        self.core.sort_mode = crate::core::SortMode::Natural;
983        self.core.sort_by = crate::core::SortBy::Name;
984        self.core.sort_ascending = true;
985        self.core.dirs_first = true;
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn igfd_classic_preset_updates_ui_and_core() {
995        let mut state = FileDialogState::new(DialogMode::OpenFile);
996        state.apply_igfd_classic_preset();
997
998        assert_eq!(state.ui.layout, LayoutStyle::Standard);
999        assert_eq!(state.ui.file_list_view, FileListViewMode::List);
1000        assert_eq!(state.ui.custom_pane_dock, CustomPaneDock::Right);
1001        assert!(!state.ui.file_list_columns.show_extension);
1002        assert_eq!(
1003            state.ui.validation_buttons.align,
1004            ValidationButtonsAlign::Right
1005        );
1006        assert_eq!(
1007            state.ui.validation_buttons.order,
1008            ValidationButtonsOrder::CancelConfirm
1009        );
1010        assert_eq!(state.core.click_action, ClickAction::Navigate);
1011        assert_eq!(state.core.sort_mode, crate::core::SortMode::Natural);
1012    }
1013
1014    #[test]
1015    fn open_close_roundtrip() {
1016        let mut state = FileDialogState::new(DialogMode::OpenFile);
1017
1018        assert!(state.is_open());
1019        state.close();
1020        assert!(!state.is_open());
1021
1022        state.open();
1023        assert!(state.is_open());
1024
1025        state.close();
1026        assert!(!state.is_open());
1027
1028        state.reopen();
1029        assert!(state.is_open());
1030    }
1031
1032    #[test]
1033    fn default_scan_policy_is_tuned_incremental() {
1034        let state = FileDialogState::new(DialogMode::OpenFile);
1035        assert_eq!(state.scan_policy(), ScanPolicy::tuned_incremental());
1036    }
1037
1038    #[test]
1039    fn file_list_columns_compact_roundtrip() {
1040        let cfg = FileListColumnsConfig {
1041            show_preview: false,
1042            show_extension: true,
1043            show_size: true,
1044            show_modified: false,
1045            order: [
1046                FileListDataColumn::Name,
1047                FileListDataColumn::Size,
1048                FileListDataColumn::Modified,
1049                FileListDataColumn::Extension,
1050            ],
1051            weight_overrides: FileListColumnWeightOverrides {
1052                preview: Some(0.15),
1053                name: Some(0.61),
1054                extension: Some(0.1),
1055                size: Some(0.17),
1056                modified: None,
1057            },
1058        };
1059
1060        let encoded = cfg.serialize_compact();
1061        let decoded = FileListColumnsConfig::deserialize_compact(&encoded).unwrap();
1062        assert_eq!(decoded, cfg);
1063    }
1064
1065    #[test]
1066    fn file_list_columns_deserialize_rejects_duplicate_order_entries() {
1067        let err = FileListColumnsConfig::deserialize_compact(
1068            "v1;preview=1;ext=1;size=1;modified=1;order=name,name,size,modified;weights=auto,auto,auto,auto,auto",
1069        )
1070        .unwrap_err();
1071        assert!(
1072            err.to_string()
1073                .contains("order` must contain each column exactly once")
1074        );
1075    }
1076
1077    #[test]
1078    fn file_list_columns_deserialize_rejects_non_positive_weight() {
1079        let err = FileListColumnsConfig::deserialize_compact(
1080            "v1;preview=1;ext=1;size=1;modified=1;order=name,ext,size,modified;weights=auto,0,auto,auto,auto",
1081        )
1082        .unwrap_err();
1083        assert!(err.to_string().contains("weight must be finite and > 0"));
1084    }
1085
1086    #[test]
1087    fn file_list_columns_normalized_order_dedupes_and_fills_missing() {
1088        let normalized = normalized_order([
1089            FileListDataColumn::Name,
1090            FileListDataColumn::Name,
1091            FileListDataColumn::Modified,
1092            FileListDataColumn::Modified,
1093        ]);
1094        assert_eq!(
1095            normalized,
1096            [
1097                FileListDataColumn::Name,
1098                FileListDataColumn::Modified,
1099                FileListDataColumn::Extension,
1100                FileListDataColumn::Size,
1101            ]
1102        );
1103    }
1104}