Skip to main content

dear_file_browser/
dialog_state.rs

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