use std::path::PathBuf;
use std::time::Duration;
use dear_imgui_rs::FontId;
use crate::core::{ClickAction, DialogMode, LayoutStyle};
use crate::dialog_core::{EntryId, FileDialogCore, ScanPolicy, ScanStatus};
use crate::file_style::FileStyleRegistry;
use crate::thumbnails::{ThumbnailCache, ThumbnailCacheConfig};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileListViewMode {
List,
ThumbnailsList,
Grid,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FileListDataColumn {
Name,
Extension,
Size,
Modified,
}
impl FileListDataColumn {
fn compact_token(self) -> &'static str {
match self {
Self::Name => "name",
Self::Extension => "ext",
Self::Size => "size",
Self::Modified => "modified",
}
}
fn from_compact_token(token: &str) -> Option<Self> {
match token {
"name" => Some(Self::Name),
"ext" => Some(Self::Extension),
"size" => Some(Self::Size),
"modified" => Some(Self::Modified),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FileListColumnWeightOverrides {
pub preview: Option<f32>,
pub name: Option<f32>,
pub extension: Option<f32>,
pub size: Option<f32>,
pub modified: Option<f32>,
}
impl Default for FileListColumnWeightOverrides {
fn default() -> Self {
Self {
preview: None,
name: None,
extension: None,
size: None,
modified: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FileListColumnsConfig {
pub show_preview: bool,
pub show_extension: bool,
pub show_size: bool,
pub show_modified: bool,
pub order: [FileListDataColumn; 4],
pub weight_overrides: FileListColumnWeightOverrides,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileListColumnsDeserializeError {
msg: String,
}
impl FileListColumnsDeserializeError {
fn new(msg: impl Into<String>) -> Self {
Self { msg: msg.into() }
}
}
impl std::fmt::Display for FileListColumnsDeserializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "file list columns deserialize error: {}", self.msg)
}
}
impl std::error::Error for FileListColumnsDeserializeError {}
impl FileListColumnsConfig {
pub fn serialize_compact(&self) -> String {
let order = self
.normalized_order()
.iter()
.map(|c| c.compact_token())
.collect::<Vec<_>>()
.join(",");
let weights = [
self.weight_overrides.preview,
self.weight_overrides.name,
self.weight_overrides.extension,
self.weight_overrides.size,
self.weight_overrides.modified,
]
.into_iter()
.map(|v| {
v.map(|w| format!("{w:.4}"))
.unwrap_or_else(|| "auto".to_string())
})
.collect::<Vec<_>>()
.join(",");
format!(
"v1;preview={};ext={};size={};modified={};order={};weights={}",
u8::from(self.show_preview),
u8::from(self.show_extension),
u8::from(self.show_size),
u8::from(self.show_modified),
order,
weights,
)
}
pub fn deserialize_compact(input: &str) -> Result<Self, FileListColumnsDeserializeError> {
let mut version_ok = false;
let mut preview = None;
let mut ext = None;
let mut size = None;
let mut modified = None;
let mut order = None;
let mut weights = None;
for token in input.split(';').filter(|s| !s.trim().is_empty()) {
if token == "v1" {
version_ok = true;
continue;
}
if token.starts_with('v') {
return Err(FileListColumnsDeserializeError::new(format!(
"unsupported version token `{token}`"
)));
}
let (key, value) = token.split_once('=').ok_or_else(|| {
FileListColumnsDeserializeError::new(format!("invalid token `{token}`"))
})?;
match key {
"preview" => preview = Some(parse_compact_bool(value)?),
"ext" => ext = Some(parse_compact_bool(value)?),
"size" => size = Some(parse_compact_bool(value)?),
"modified" => modified = Some(parse_compact_bool(value)?),
"order" => order = Some(parse_compact_order(value)?),
"weights" => weights = Some(parse_compact_weights(value)?),
_ => {
return Err(FileListColumnsDeserializeError::new(format!(
"unknown key `{key}`"
)));
}
}
}
if !version_ok {
return Err(FileListColumnsDeserializeError::new(
"missing or unsupported version token",
));
}
Ok(Self {
show_preview: preview
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `preview`"))?,
show_extension: ext
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `ext`"))?,
show_size: size
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `size`"))?,
show_modified: modified
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `modified`"))?,
order: order
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `order`"))?,
weight_overrides: weights
.ok_or_else(|| FileListColumnsDeserializeError::new("missing key `weights`"))?,
})
}
pub fn normalized_order(&self) -> [FileListDataColumn; 4] {
normalized_order(self.order)
}
}
impl Default for FileListColumnsConfig {
fn default() -> Self {
Self {
show_preview: true,
show_extension: true,
show_size: true,
show_modified: true,
order: [
FileListDataColumn::Name,
FileListDataColumn::Extension,
FileListDataColumn::Size,
FileListDataColumn::Modified,
],
weight_overrides: FileListColumnWeightOverrides::default(),
}
}
}
fn normalized_order(order: [FileListDataColumn; 4]) -> [FileListDataColumn; 4] {
let mut out = Vec::with_capacity(4);
for c in order {
if !out.contains(&c) {
out.push(c);
}
}
for c in [
FileListDataColumn::Name,
FileListDataColumn::Extension,
FileListDataColumn::Size,
FileListDataColumn::Modified,
] {
if !out.contains(&c) {
out.push(c);
}
}
[out[0], out[1], out[2], out[3]]
}
fn parse_compact_bool(value: &str) -> Result<bool, FileListColumnsDeserializeError> {
match value {
"0" => Ok(false),
"1" => Ok(true),
_ => Err(FileListColumnsDeserializeError::new(format!(
"invalid bool value `{value}`"
))),
}
}
fn parse_compact_order(
value: &str,
) -> Result<[FileListDataColumn; 4], FileListColumnsDeserializeError> {
let cols = value
.split(',')
.map(FileListDataColumn::from_compact_token)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| FileListColumnsDeserializeError::new("invalid column token in `order`"))?;
if cols.len() != 4 {
return Err(FileListColumnsDeserializeError::new(
"`order` must contain exactly 4 columns",
));
}
let order = [cols[0], cols[1], cols[2], cols[3]];
let normalized = normalized_order(order);
if normalized != order {
return Err(FileListColumnsDeserializeError::new(
"`order` must contain each column exactly once",
));
}
Ok(order)
}
fn parse_compact_optional_weight(
value: &str,
) -> Result<Option<f32>, FileListColumnsDeserializeError> {
if value.eq_ignore_ascii_case("auto") {
return Ok(None);
}
let parsed = value.parse::<f32>().map_err(|_| {
FileListColumnsDeserializeError::new(format!("invalid weight value `{value}`"))
})?;
if !parsed.is_finite() || parsed <= 0.0 {
return Err(FileListColumnsDeserializeError::new(format!(
"weight must be finite and > 0, got `{value}`"
)));
}
Ok(Some(parsed))
}
fn parse_compact_weights(
value: &str,
) -> Result<FileListColumnWeightOverrides, FileListColumnsDeserializeError> {
let parts: Vec<&str> = value.split(',').collect();
if parts.len() != 5 {
return Err(FileListColumnsDeserializeError::new(
"`weights` must contain exactly 5 values",
));
}
Ok(FileListColumnWeightOverrides {
preview: parse_compact_optional_weight(parts[0])?,
name: parse_compact_optional_weight(parts[1])?,
extension: parse_compact_optional_weight(parts[2])?,
size: parse_compact_optional_weight(parts[3])?,
modified: parse_compact_optional_weight(parts[4])?,
})
}
impl Default for FileListViewMode {
fn default() -> Self {
Self::List
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ValidationButtonsAlign {
#[default]
Left,
Right,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ValidationButtonsOrder {
#[default]
ConfirmCancel,
CancelConfirm,
}
#[derive(Clone, Debug)]
pub struct ValidationButtonsConfig {
pub align: ValidationButtonsAlign,
pub order: ValidationButtonsOrder,
pub confirm_label: Option<String>,
pub cancel_label: Option<String>,
pub button_width: Option<f32>,
pub confirm_width: Option<f32>,
pub cancel_width: Option<f32>,
}
impl Default for ValidationButtonsConfig {
fn default() -> Self {
Self {
align: ValidationButtonsAlign::Left,
order: ValidationButtonsOrder::ConfirmCancel,
confirm_label: None,
cancel_label: None,
button_width: None,
confirm_width: None,
cancel_width: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarDensity {
#[default]
Normal,
Compact,
Spacious,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ToolbarIconMode {
#[default]
Text,
IconOnly,
IconAndText,
}
#[derive(Clone, Debug, Default)]
pub struct ToolbarIcons {
pub mode: ToolbarIconMode,
pub places: Option<String>,
pub refresh: Option<String>,
pub new_folder: Option<String>,
pub columns: Option<String>,
pub options: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ToolbarConfig {
pub density: ToolbarDensity,
pub icons: ToolbarIcons,
pub show_tooltips: bool,
}
impl Default for ToolbarConfig {
fn default() -> Self {
Self {
density: ToolbarDensity::Normal,
icons: ToolbarIcons::default(),
show_tooltips: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardOp {
Copy,
Cut,
}
#[derive(Clone, Debug)]
pub struct FileClipboard {
pub op: ClipboardOp,
pub sources: Vec<PathBuf>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PasteConflictAction {
Overwrite,
Skip,
KeepBoth,
}
#[derive(Clone, Debug)]
pub(crate) struct PasteConflictPrompt {
pub source: PathBuf,
pub dest: PathBuf,
pub apply_to_all: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct PendingPasteJob {
pub clipboard: FileClipboard,
pub dest_dir: PathBuf,
pub next_index: usize,
pub created: Vec<String>,
pub apply_all_conflicts: Option<PasteConflictAction>,
pub pending_conflict_action: Option<PasteConflictAction>,
pub conflict: Option<PasteConflictPrompt>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum PlacesIoMode {
#[default]
Export,
Import,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum PlacesEditMode {
#[default]
AddGroup,
RenameGroup,
AddPlace,
EditPlace,
RemoveGroupConfirm,
}
#[derive(Debug)]
pub struct FileDialogUiConfig {
pub header_style: HeaderStyle,
pub layout: LayoutStyle,
pub validation_buttons: ValidationButtonsConfig,
pub toolbar: ToolbarConfig,
pub places_pane_shown: bool,
pub places_pane_width: f32,
pub file_list_view: FileListViewMode,
pub file_list_columns: FileListColumnsConfig,
pub path_bar_style: PathBarStyle,
pub breadcrumbs_quick_select: bool,
pub breadcrumbs_max_segments: usize,
pub empty_hint_enabled: bool,
pub empty_hint_color: [f32; 4],
pub empty_hint_static_message: Option<String>,
pub new_folder_enabled: bool,
pub file_style_fonts: std::collections::HashMap<String, FontId>,
pub file_styles: FileStyleRegistry,
pub thumbnails_enabled: bool,
pub thumbnail_size: [f32; 2],
pub type_select_enabled: bool,
pub type_select_timeout: Duration,
pub custom_pane_enabled: bool,
pub custom_pane_dock: CustomPaneDock,
pub custom_pane_height: f32,
pub custom_pane_width: f32,
}
impl Default for FileDialogUiConfig {
fn default() -> Self {
Self {
header_style: HeaderStyle::ToolbarAndAddress,
layout: LayoutStyle::Standard,
validation_buttons: ValidationButtonsConfig::default(),
toolbar: ToolbarConfig::default(),
places_pane_shown: true,
places_pane_width: 150.0,
file_list_view: FileListViewMode::default(),
file_list_columns: FileListColumnsConfig::default(),
path_bar_style: PathBarStyle::TextInput,
breadcrumbs_quick_select: true,
breadcrumbs_max_segments: 6,
empty_hint_enabled: true,
empty_hint_color: [0.7, 0.7, 0.7, 1.0],
empty_hint_static_message: None,
new_folder_enabled: true,
file_style_fonts: std::collections::HashMap::new(),
file_styles: FileStyleRegistry::default(),
thumbnails_enabled: false,
thumbnail_size: [32.0, 32.0],
type_select_enabled: true,
type_select_timeout: Duration::from_millis(750),
custom_pane_enabled: true,
custom_pane_dock: CustomPaneDock::default(),
custom_pane_height: 120.0,
custom_pane_width: 250.0,
}
}
}
impl FileDialogUiConfig {
pub fn apply_igfd_classic_preset(&mut self) {
self.header_style = HeaderStyle::IgfdClassic;
self.layout = LayoutStyle::Standard;
self.places_pane_shown = true;
self.places_pane_width = 150.0;
self.file_list_view = FileListViewMode::List;
self.thumbnails_enabled = false;
self.toolbar.density = ToolbarDensity::Compact;
self.path_bar_style = PathBarStyle::Breadcrumbs;
self.breadcrumbs_quick_select = true;
if self.file_styles.rules.is_empty() && self.file_styles.callback.is_none() {
self.file_styles = crate::file_style::FileStyleRegistry::igfd_ascii_preset();
}
self.file_list_columns.show_preview = false;
self.file_list_columns.show_extension = false;
self.file_list_columns.show_size = true;
self.file_list_columns.show_modified = true;
self.file_list_columns.order = [
FileListDataColumn::Name,
FileListDataColumn::Extension,
FileListDataColumn::Size,
FileListDataColumn::Modified,
];
self.custom_pane_enabled = true;
self.custom_pane_dock = CustomPaneDock::Right;
self.custom_pane_width = 250.0;
self.custom_pane_height = 120.0;
self.validation_buttons.align = ValidationButtonsAlign::Right;
self.validation_buttons.order = ValidationButtonsOrder::CancelConfirm;
self.validation_buttons.confirm_label = Some("OK".to_string());
self.validation_buttons.cancel_label = Some("Cancel".to_string());
self.validation_buttons.button_width = None;
self.validation_buttons.confirm_width = None;
self.validation_buttons.cancel_width = None;
}
}
#[derive(Debug, Default)]
pub(crate) struct FileDialogUiRuntime {
pub(crate) path: PathUiRuntime,
pub(crate) opened_cwd: Option<PathBuf>,
pub(crate) focus_search_next: bool,
pub(crate) error: Option<String>,
pub(crate) type_select_buffer: String,
pub(crate) type_select_last_input: Option<std::time::Instant>,
pub(crate) breadcrumb: BreadcrumbUiRuntime,
pub(crate) footer: FooterUiRuntime,
}
#[derive(Debug, Default)]
pub(crate) struct PathUiRuntime {
pub(crate) input_mode: bool,
pub(crate) edit: bool,
pub(crate) buffer: String,
pub(crate) last_cwd: String,
pub(crate) history_index: Option<usize>,
pub(crate) history_saved_buffer: Option<String>,
pub(crate) programmatic_edit: bool,
pub(crate) focus_next: bool,
}
#[derive(Debug, Default)]
pub(crate) struct BreadcrumbUiRuntime {
pub(crate) scroll_to_end_next: bool,
pub(crate) quick_parent: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub(crate) struct FooterUiRuntime {
pub(crate) height_last: f32,
pub(crate) file_name_buffer: String,
pub(crate) file_name_last_display: String,
}
#[derive(Debug, Default)]
pub(crate) struct FileDialogOperationState {
pub(crate) new_folder: NewFolderOperationState,
pub(crate) rename: RenameOperationState,
pub(crate) delete: DeleteOperationState,
pub(crate) paste: PasteOperationState,
pub(crate) places: PlacesOperationState,
pub(crate) reveal_id_next: Option<EntryId>,
}
#[derive(Debug, Default)]
pub(crate) struct NewFolderOperationState {
pub(crate) inline_active: bool,
pub(crate) open_next: bool,
pub(crate) name: String,
pub(crate) focus_next: bool,
pub(crate) error: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct RenameOperationState {
pub(crate) open_next: bool,
pub(crate) focus_next: bool,
pub(crate) target_id: Option<EntryId>,
pub(crate) to: String,
pub(crate) error: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct DeleteOperationState {
pub(crate) open_next: bool,
pub(crate) target_ids: Vec<EntryId>,
pub(crate) recursive: bool,
pub(crate) error: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct PasteOperationState {
pub(crate) clipboard: Option<FileClipboard>,
pub(crate) job: Option<PendingPasteJob>,
pub(crate) conflict_open_next: bool,
}
#[derive(Debug, Default)]
pub(crate) struct PlacesOperationState {
pub(crate) io: PlacesIoOperationState,
pub(crate) edit: PlacesEditOperationState,
pub(crate) selected: Option<(String, PathBuf)>,
pub(crate) inline_edit: PlacesInlineEditState,
}
#[derive(Debug, Default)]
pub(crate) struct PlacesIoOperationState {
pub(crate) mode: PlacesIoMode,
pub(crate) buffer: String,
pub(crate) open_next: bool,
pub(crate) include_code: bool,
pub(crate) error: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct PlacesEditOperationState {
pub(crate) mode: PlacesEditMode,
pub(crate) open_next: bool,
pub(crate) focus_next: bool,
pub(crate) error: Option<String>,
pub(crate) group: String,
pub(crate) group_from: Option<String>,
pub(crate) place_from_path: Option<PathBuf>,
pub(crate) place_label: String,
pub(crate) place_path: String,
}
#[derive(Debug, Default)]
pub(crate) struct PlacesInlineEditState {
pub(crate) target: Option<(String, PathBuf)>,
pub(crate) buffer: String,
pub(crate) focus_next: bool,
}
#[derive(Debug)]
pub struct FileDialogUiState {
pub visible: bool,
pub config: FileDialogUiConfig,
pub(crate) runtime: FileDialogUiRuntime,
pub(crate) operations: FileDialogOperationState,
pub thumbnails: ThumbnailCache,
}
impl Default for FileDialogUiState {
fn default() -> Self {
Self {
visible: true,
config: FileDialogUiConfig::default(),
runtime: FileDialogUiRuntime::default(),
operations: FileDialogOperationState::default(),
thumbnails: ThumbnailCache::new(ThumbnailCacheConfig::default()),
}
}
}
impl FileDialogUiState {
pub fn apply_igfd_classic_preset(&mut self) {
self.config.apply_igfd_classic_preset();
self.runtime.path.input_mode = false;
self.runtime.breadcrumb.scroll_to_end_next = true;
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HeaderStyle {
#[default]
ToolbarAndAddress,
IgfdClassic,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PathBarStyle {
#[default]
TextInput,
Breadcrumbs,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CustomPaneDock {
#[default]
Bottom,
Right,
}
#[derive(Debug)]
pub struct FileDialogState {
pub core: FileDialogCore,
pub ui: FileDialogUiState,
}
impl FileDialogState {
pub fn new(mode: DialogMode) -> Self {
let mut core = FileDialogCore::new(mode);
core.set_scan_policy(ScanPolicy::tuned_incremental());
Self {
core,
ui: FileDialogUiState::default(),
}
}
pub fn open(&mut self) {
self.ui.visible = true;
self.ui.runtime.opened_cwd = Some(self.core.cwd.clone());
}
pub fn reopen(&mut self) {
self.open();
}
pub fn close(&mut self) {
self.ui.visible = false;
}
pub fn is_open(&self) -> bool {
self.ui.visible
}
pub fn scan_policy(&self) -> ScanPolicy {
self.core.scan_policy()
}
pub fn set_scan_policy(&mut self, policy: ScanPolicy) {
self.core.set_scan_policy(policy);
}
pub fn scan_status(&self) -> &ScanStatus {
self.core.scan_status()
}
pub fn request_rescan(&mut self) {
self.core.request_rescan();
}
pub fn set_scan_hook<F>(&mut self, hook: F)
where
F: FnMut(&mut crate::FsEntry) -> crate::ScanHookAction + 'static,
{
self.core.set_scan_hook(hook);
}
pub fn clear_scan_hook(&mut self) {
self.core.clear_scan_hook();
}
pub fn apply_igfd_classic_preset(&mut self) {
self.ui.apply_igfd_classic_preset();
self.core.click_action = ClickAction::Navigate;
self.core.sort_mode = crate::core::SortMode::Natural;
self.core.sort_by = crate::core::SortBy::Name;
self.core.sort_ascending = true;
self.core.dirs_first = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn igfd_classic_preset_updates_ui_and_core() {
let mut state = FileDialogState::new(DialogMode::OpenFile);
state.apply_igfd_classic_preset();
assert_eq!(state.ui.config.layout, LayoutStyle::Standard);
assert_eq!(state.ui.config.file_list_view, FileListViewMode::List);
assert_eq!(state.ui.config.custom_pane_dock, CustomPaneDock::Right);
assert!(!state.ui.config.file_list_columns.show_extension);
assert_eq!(
state.ui.config.validation_buttons.align,
ValidationButtonsAlign::Right
);
assert_eq!(
state.ui.config.validation_buttons.order,
ValidationButtonsOrder::CancelConfirm
);
assert_eq!(state.core.click_action, ClickAction::Navigate);
assert_eq!(state.core.sort_mode, crate::core::SortMode::Natural);
}
#[test]
fn open_close_roundtrip() {
let mut state = FileDialogState::new(DialogMode::OpenFile);
assert!(state.is_open());
state.close();
assert!(!state.is_open());
state.open();
assert!(state.is_open());
state.close();
assert!(!state.is_open());
state.reopen();
assert!(state.is_open());
}
#[test]
fn default_scan_policy_is_tuned_incremental() {
let state = FileDialogState::new(DialogMode::OpenFile);
assert_eq!(state.scan_policy(), ScanPolicy::tuned_incremental());
}
#[test]
fn ui_config_defaults_own_caller_facing_ui_knobs() {
let state = FileDialogUiState::default();
assert_eq!(state.config.header_style, HeaderStyle::ToolbarAndAddress);
assert_eq!(state.config.layout, LayoutStyle::Standard);
assert_eq!(state.config.file_list_view, FileListViewMode::default());
assert_eq!(state.config.path_bar_style, PathBarStyle::TextInput);
assert!(state.config.breadcrumbs_quick_select);
assert_eq!(state.config.type_select_timeout, Duration::from_millis(750));
assert!(!state.config.thumbnails_enabled);
assert_eq!(state.config.thumbnail_size, [32.0, 32.0]);
}
#[test]
fn ui_config_igfd_classic_preset_updates_config_without_runtime_buffers() {
let mut state = FileDialogUiState::default();
state.runtime.path.buffer = "keep-runtime-buffer".to_string();
state.runtime.path.input_mode = true;
state.apply_igfd_classic_preset();
assert_eq!(state.config.header_style, HeaderStyle::IgfdClassic);
assert_eq!(state.config.layout, LayoutStyle::Standard);
assert_eq!(state.config.file_list_view, FileListViewMode::List);
assert_eq!(state.config.toolbar.density, ToolbarDensity::Compact);
assert_eq!(state.config.path_bar_style, PathBarStyle::Breadcrumbs);
assert_eq!(state.config.custom_pane_dock, CustomPaneDock::Right);
assert!(!state.config.file_list_columns.show_extension);
assert_eq!(
state.config.validation_buttons.align,
ValidationButtonsAlign::Right
);
assert_eq!(state.runtime.path.buffer, "keep-runtime-buffer");
assert!(!state.runtime.path.input_mode);
assert!(state.runtime.breadcrumb.scroll_to_end_next);
}
#[test]
fn ui_runtime_and_operation_state_are_internal_to_ui_state() {
let state = FileDialogUiState::default();
assert!(state.config.new_folder_enabled);
assert!(state.config.type_select_enabled);
assert!(state.runtime.type_select_buffer.is_empty());
assert!(state.runtime.type_select_last_input.is_none());
assert!(!state.operations.new_folder.inline_active);
assert!(!state.operations.new_folder.open_next);
assert!(state.operations.new_folder.name.is_empty());
assert!(!state.operations.new_folder.focus_next);
assert!(state.operations.new_folder.error.is_none());
assert!(!state.runtime.path.input_mode);
assert!(!state.runtime.path.edit);
assert!(state.runtime.path.buffer.is_empty());
assert!(state.runtime.path.history_index.is_none());
assert!(state.runtime.path.history_saved_buffer.is_none());
assert!(!state.runtime.focus_search_next);
assert!(state.runtime.error.is_none());
assert!(state.runtime.breadcrumb.quick_parent.is_none());
assert_eq!(state.runtime.footer.height_last, 0.0);
assert!(state.runtime.footer.file_name_buffer.is_empty());
assert!(state.operations.rename.target_id.is_none());
assert!(!state.operations.rename.open_next);
assert!(state.operations.rename.to.is_empty());
assert!(state.operations.delete.target_ids.is_empty());
assert!(!state.operations.delete.open_next);
assert!(state.operations.paste.clipboard.is_none());
assert!(state.operations.paste.job.is_none());
assert!(!state.operations.paste.conflict_open_next);
assert!(state.operations.reveal_id_next.is_none());
assert!(state.operations.places.io.buffer.is_empty());
assert!(state.operations.places.selected.is_none());
assert!(state.operations.places.inline_edit.target.is_none());
}
#[test]
fn file_list_columns_compact_roundtrip() {
let cfg = FileListColumnsConfig {
show_preview: false,
show_extension: true,
show_size: true,
show_modified: false,
order: [
FileListDataColumn::Name,
FileListDataColumn::Size,
FileListDataColumn::Modified,
FileListDataColumn::Extension,
],
weight_overrides: FileListColumnWeightOverrides {
preview: Some(0.15),
name: Some(0.61),
extension: Some(0.1),
size: Some(0.17),
modified: None,
},
};
let encoded = cfg.serialize_compact();
let decoded = FileListColumnsConfig::deserialize_compact(&encoded).unwrap();
assert_eq!(decoded, cfg);
}
#[test]
fn file_list_columns_deserialize_rejects_duplicate_order_entries() {
let err = FileListColumnsConfig::deserialize_compact(
"v1;preview=1;ext=1;size=1;modified=1;order=name,name,size,modified;weights=auto,auto,auto,auto,auto",
)
.unwrap_err();
assert!(
err.to_string()
.contains("order` must contain each column exactly once")
);
}
#[test]
fn file_list_columns_deserialize_rejects_non_positive_weight() {
let err = FileListColumnsConfig::deserialize_compact(
"v1;preview=1;ext=1;size=1;modified=1;order=name,ext,size,modified;weights=auto,0,auto,auto,auto",
)
.unwrap_err();
assert!(err.to_string().contains("weight must be finite and > 0"));
}
#[test]
fn file_list_columns_normalized_order_dedupes_and_fills_missing() {
let normalized = normalized_order([
FileListDataColumn::Name,
FileListDataColumn::Name,
FileListDataColumn::Modified,
FileListDataColumn::Modified,
]);
assert_eq!(
normalized,
[
FileListDataColumn::Name,
FileListDataColumn::Modified,
FileListDataColumn::Extension,
FileListDataColumn::Size,
]
);
}
}