Files
mmd-pdf/Controls/FileDialog.xaml.cs
T
kua-agent 6610acfa44 feat: rebrand to MMD PDF, single corporate theme, and remove original text under an edit
- Services/PdfRedactText.cs: strip text whose origin falls inside a CoverAnnotation
  from the page content stream at save time, hooked into PdfBurn.DrawAnnotationsIntoDoc.
  Fixes edited values staying recoverable by text extraction.
- Themes/MMD.xaml replaces all thirteen themes; picker and accent strip removed; no dark mode.
- Rename KillerPDF -> MMD PDF across code, resources, packaging and locale strings; new icon.
- Remove the upstream author credit and the in-app install button.
2026-08-27 07:37:09 +02:00

1381 lines
65 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
namespace MmdPdf.Controls
{
/// <summary>Open or Save. Picked at construction; changes the accept button and the rules.</summary>
public enum FileDialogMode { Open, Save }
/// <summary>
/// Themed stand-in for Microsoft.Win32.OpenFileDialog / SaveFileDialog. Same chrome, places
/// rail, view modes and sortable columns as FolderPickerDialog (row styles shared from
/// Controls.xaml), plus a file name box and a filter combo.
///
/// The property surface mirrors the Win32 dialogs on purpose - Title, Filter, FilterIndex,
/// FileName, InitialDirectory, DefaultExt, AddExtension, OverwritePrompt, CheckFileExists -
/// so adopting it at a call site is a one-word change:
///
/// var dlg = new FileDialog(FileDialogMode.Save) { Title = ..., Filter = ..., FileName = ... };
/// if (dlg.ShowDialog(owner) == true) Use(dlg.FileName);
///
/// Multiselect is deliberately NOT implemented yet - nothing in the app needs it, and
/// a half-working Multiselect is worse than an absent one. Add it when something wants it.
/// </summary>
public partial class FileDialog : Window
{
// ── Win32-compatible surface ─────────────────────────────────────────────
/// <summary>Win32 filter syntax: "Desc|*.a;*.b|Other|*.c". Empty means every file.</summary>
public string Filter { get; set; } = "";
/// <summary>1-based, like the Win32 dialogs. Out of range is clamped.</summary>
public int FilterIndex { get; set; } = 1;
/// <summary>Seeded with a suggested name; on OK, the full chosen path.</summary>
public string FileName { get; set; } = "";
public string InitialDirectory { get; set; } = "";
/// <summary>Appended on save when the typed name has no extension. No leading dot needed.</summary>
public string DefaultExt { get; set; } = "";
public bool AddExtension { get; set; } = true;
/// <summary>Save mode: confirm before replacing an existing file.</summary>
public bool OverwritePrompt { get; set; } = true;
/// <summary>Open mode: refuse to return a path that does not exist.</summary>
public bool CheckFileExists { get; set; } = true;
/// <summary>Refuse a path whose DIRECTORY does not exist. On by default, matching the
/// Win32 dialogs - the picker never creates folder trees on the user's behalf, so turning
/// this off only means the caller has agreed to handle a missing directory itself.</summary>
public bool CheckPathExists { get; set; } = true;
/// <summary>Open mode only: let the user pick several files at once (Ctrl/Shift click, or
/// a drag over the list). Ignored in Save mode, where "several names" is meaningless.
/// Set it BEFORE ShowDialog - the list's selection mode is applied there.</summary>
public bool Multiselect { get; set; }
/// <summary>Show a live preview pane for image-selection workflows. The pane is opt-in so
/// ordinary Open and Save dialogs keep their compact layout.</summary>
public bool ShowImagePreview { get; set; }
/// <summary>Every path chosen. Always populated on success, so a caller can read this
/// whether or not it asked for Multiselect - single selection yields one entry, matching
/// the Win32 dialogs' FileNames. FileName remains the first of them.</summary>
public string[] FileNames { get; private set; } = [];
// ── internals ────────────────────────────────────────────────────────────
private readonly FileDialogMode _mode;
public ObservableCollection<PickerPlace> Places { get; } = [];
public ObservableCollection<PickerEntry> Entries { get; } = [];
private readonly List<PickerEntry> _raw = [];
private string _currentDir = string.Empty;
private bool _navigating;
private bool _built; // suppresses filter events during construction
private int _viewMode; // 0 list, 1 icons, 2 details
private int _sortKey; // 0 name, 1 size, 2 modified
private bool _sortAsc = true;
private int _imagePreviewGeneration;
private static readonly HashSet<string> PreviewImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"
};
// Per-filter-entry patterns, parallel to FilterCombo's items. Empty list = show all.
private readonly List<string[]> _filterPatterns = [];
private static readonly string ArrowUp = ((char)0xE70E).ToString();
private static readonly string ArrowDown = ((char)0xE70D).ToString();
// ── Tree / pinned places / recents / hidden state ────────────────────────
public ObservableCollection<FolderNode> TreeRoots { get; } = [];
private bool _treeSyncing; // tree selection navigates, navigation selects: no ping-pong
private bool _showHidden;
private const string ShowHiddenKey = "FileDlgShowHidden";
private const string RecentsKey = "FileDlgRecents";
private const string PinnedKey = "FileDlgPinned";
private const string PlacesHKey = "FileDlgPlacesH";
private const string LastOpenKey = "FileDlgLastOpenDir";
private const string LastSaveKey = "FileDlgLastSaveDir";
// Image pickers (every caller with ShowImagePreview) remember their own folder, separate
// from the document open/save memory. One shared key meant Insert Image always started
// wherever the last PDF was opened from, never where the user last picked an image.
private string LastDirKey =>
(_mode == FileDialogMode.Open ? LastOpenKey : LastSaveKey) + (ShowImagePreview ? "Img" : "");
private const int RecentsMax = 12;
// Guards the fade-then-close re-entry below. Without it OnClosing would cancel forever.
private bool _fadingOut;
/// <summary>The result Accept wants, held until the window is actually allowed to close.
/// Null means cancel (the X, Escape, the Cancel button - none of them set it).</summary>
private bool? _pendingResult;
/// <summary>Fades the dialog out before it actually closes. The first pass cancels the
/// close and runs the fade; the second sees the flag and lets it through.
///
/// The result CANNOT be assigned before the fade. Assigning Window.DialogResult is itself a
/// close request, so it lands in this handler, which cancels that close - and WPF resets
/// DialogResult to null whenever a close is canceled. Accept therefore records what it
/// wants in _pendingResult and the assignment happens in the fade's completion callback,
/// where nothing will cancel it. Assigning DialogResult there also closes the window, which
/// is why that branch does not call Close() as well.</summary>
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_fadingOut)
{
_fadingOut = true;
e.Cancel = true;
Anim.FadeOut(RootFade, () =>
{
if (_pendingResult.HasValue) DialogResult = _pendingResult; // this closes it
else Close(); // cancel path
});
return;
}
base.OnClosing(e);
}
public FileDialog(FileDialogMode mode = FileDialogMode.Open)
{
_mode = mode;
InitializeComponent();
Loaded += (_, _) => Anim.FadeIn(RootFade);
// Size and placement remembered separately from the folder picker: this dialog is a
// different shape and sharing the keys would make each one fight the other.
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if (double.TryParse(App.GetSetting("FileDlgW"),
System.Globalization.NumberStyles.Float, ci, out double w) &&
double.TryParse(App.GetSetting("FileDlgH"),
System.Globalization.NumberStyles.Float, ci, out double h))
{
Width = Math.Max(MinWidth, Math.Min(w, SystemParameters.WorkArea.Width));
Height = Math.Max(MinHeight, Math.Min(h, SystemParameters.WorkArea.Height));
}
if (double.TryParse(App.GetSetting("FileDlgX"),
System.Globalization.NumberStyles.Float, ci, out double x) &&
double.TryParse(App.GetSetting("FileDlgY"),
System.Globalization.NumberStyles.Float, ci, out double y))
{
var wa = SystemParameters.WorkArea;
if (x > wa.Left - Width + 80 && x < wa.Right - 80 &&
y > wa.Top - 20 && y < wa.Bottom - 80)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = x;
Top = y;
}
}
if (double.TryParse(App.GetSetting(PlacesHKey),
System.Globalization.NumberStyles.Float, ci, out double ph) && ph >= 56)
PlacesRow.Height = new GridLength(Math.Min(ph, 600));
}
catch { /* registry unavailable - defaults are fine */ }
_showHidden = App.GetSetting(ShowHiddenKey) == "1";
FolderNode.ShowHidden = _showHidden;
ApplyShowHiddenButton();
Closing += (_, _) =>
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
App.SetSetting("FileDlgW", ActualWidth.ToString(ci));
App.SetSetting("FileDlgH", ActualHeight.ToString(ci));
App.SetSetting("FileDlgX", Left.ToString(ci));
App.SetSetting("FileDlgY", Top.ToString(ci));
App.SetSetting(PlacesHKey, PlacesRow.ActualHeight.ToString(ci));
}
catch { /* not worth failing the close */ }
};
// NO DwmChrome calls, deliberately. On an AllowsTransparency window the DWM corner
// preference makes DWM composite its own rounded frame around the WINDOW rect - the
// transparent 10px halo included - and SetThemeBorder tints it: that WAS the gray
// band. The other four dialogs are AllowsTransparency with no DWM calls and have
// never shown one; the card draws its own border and shadow, so DWM has nothing to
// add. The WM_ERASEBKGND hook that lived here went too - a layered window is rendered
// via UpdateLayeredWindow and never receives it. (2026-07-30, fifth attempt)
}
/// <summary>
/// Sets the owner and shows modally. Everything that depends on Filter / FileName /
/// InitialDirectory is wired HERE rather than in the constructor, because callers set
/// those as object-initializer properties after construction.
/// </summary>
public bool? ShowDialog(Window? owner)
{
if (owner != null && owner.IsVisible) Owner = owner;
// The picker uses the caller's operation title in its caption. Reuse the owner's
// canonical close-button template so 98SE gets the square raised caption button.
if (owner?.TryFindResource("ChromeCloseButton") is Style closeStyle)
CaptionCloseButton.Style = closeStyle;
AcceptButton.Content = Loc(_mode == FileDialogMode.Save ? "Str_Btn_Save" : "Str_Btn_Open");
ConfigureImagePreview();
// Extended, not Multiple: Extended is the Explorer behavior (plain click replaces the
// selection, Ctrl adds, Shift ranges). Multiple toggles on every click, which feels
// broken to anyone who has used a file dialog before.
FileList.SelectionMode = Multiselect && _mode == FileDialogMode.Open
? SelectionMode.Extended
: SelectionMode.Single;
// Open mode has nothing to name, so the box is for typing/filtering a path, not a
// new file. It stays visible: typing an exact name is faster than hunting for it.
BuildFilters();
BuildPlaces();
PlacesList.ItemsSource = Places;
FileList.ItemsSource = Entries;
InitTree();
ApplyView();
// A seeded FileName can be a bare name ("export.ics"), a full path, or empty.
string startDir = InitialDirectory;
string seedName = "";
if (!string.IsNullOrWhiteSpace(FileName))
{
if (FileName.IndexOfAny(['\\', '/']) >= 0)
{
var d = Path.GetDirectoryName(FileName);
if (!string.IsNullOrEmpty(d) && Directory.Exists(d)) startDir = d!;
seedName = Path.GetFileName(FileName);
}
else seedName = FileName;
}
if (string.IsNullOrWhiteSpace(startDir) || !Directory.Exists(startDir))
{
string? remembered = App.GetSetting(LastDirKey);
startDir = !string.IsNullOrWhiteSpace(remembered) && Directory.Exists(remembered)
? remembered!
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
_built = true;
NavigateTo(startDir);
FileNameBox.Text = seedName;
// Save: preselect the stem so typing replaces the name but keeps the extension
// visible. Open: caret at the end.
FileNameBox.Focus();
if (_mode == FileDialogMode.Save && seedName.Length > 0)
{
int dot = seedName.LastIndexOf('.');
FileNameBox.Select(0, dot > 0 ? dot : seedName.Length);
}
else FileNameBox.CaretIndex = FileNameBox.Text.Length;
return ShowDialog();
}
// ── Filters ──────────────────────────────────────────────────────────────
/// <summary>
/// Parses Win32 filter syntax into the combo plus a parallel pattern list. A malformed
/// filter (odd number of segments) degrades to "all files" rather than throwing - a bad
/// filter string should not stop someone opening a file.
/// </summary>
private void BuildFilters()
{
FilterCombo.Items.Clear();
_filterPatterns.Clear();
var parts = (Filter ?? "").Split('|');
for (int i = 0; i + 1 < parts.Length; i += 2)
{
var label = parts[i].Trim();
var pats = parts[i + 1].Split(';')
.Select(p => p.Trim())
.Where(p => p.Length > 0)
.ToArray();
if (label.Length == 0 || pats.Length == 0) continue;
FilterCombo.Items.Add(label);
_filterPatterns.Add(pats);
}
if (FilterCombo.Items.Count == 0)
{
FilterCombo.Items.Add(Loc("Str_Dlg_AllFiles"));
_filterPatterns.Add(["*.*"]);
}
int idx = FilterIndex - 1;
FilterCombo.SelectedIndex = idx >= 0 && idx < FilterCombo.Items.Count ? idx : 0;
FilterLabel.Visibility = FilterCombo.Visibility =
FilterCombo.Items.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void Filter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!_built) return;
FilterIndex = FilterCombo.SelectedIndex + 1;
// Save mode follows the Win32 dialogs: switching the type swaps the typed name's
// extension - but only when the current one belongs to another entry of THIS filter.
// An extension the user typed by hand is theirs and is left alone.
if (_mode == FileDialogMode.Save)
{
string? newExt = ActiveFilterExt();
string name = FileNameBox.Text?.Trim() ?? "";
string cur = name.Length == 0 ? "" : Path.GetExtension(name);
if (newExt != null && cur.Length > 0 &&
!cur.Equals(newExt, StringComparison.OrdinalIgnoreCase) &&
AllFilterExts().Contains(cur, StringComparer.OrdinalIgnoreCase))
{
FileNameBox.Text = Path.ChangeExtension(name, newExt);
}
}
ApplySort();
}
/// <summary>
/// The active filter entry's own extension (".csv"), or null when its first pattern is a
/// wildcard-any or a multi-pattern catch-all that names no single extension.
/// </summary>
private string? ActiveFilterExt()
{
int i = FilterCombo.SelectedIndex;
if (i < 0 || i >= _filterPatterns.Count) return null;
string p = _filterPatterns[i][0];
if (p.Length > 2 && p.StartsWith("*.") && p.IndexOfAny(['*', '?'], 2) < 0)
return p.Substring(1);
return null;
}
/// <summary>Every concrete extension the filter list names, for the swap test above.</summary>
private IEnumerable<string> AllFilterExts()
{
foreach (var pats in _filterPatterns)
foreach (var p in pats)
if (p.Length > 2 && p.StartsWith("*.") && p.IndexOfAny(['*', '?'], 2) < 0)
yield return p.Substring(1);
}
/// <summary>True when the name passes the active filter. Folders are never filtered out.</summary>
private bool PassesFilter(PickerEntry en)
{
if (en.IsFolder) return true;
int i = FilterCombo.SelectedIndex;
if (i < 0 || i >= _filterPatterns.Count) return true;
var pats = _filterPatterns[i];
return pats.Any(p => p == "*.*" || p == "*" || WildcardMatch(en.Name, p));
}
/// <summary>Case-insensitive glob. Anchored, so "*.ics" does not match "a.icsx".</summary>
private static bool WildcardMatch(string name, string pattern)
{
var rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(name, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
// ── Quick places (pinned + drives) ───────────────────────────────────────
/// <summary>
/// Pinned folders first (persisted, user-editable via right-click), then the ready
/// drives. Drives are enumerated live every build - they come and go with USB sticks -
/// and are not pinned, so they carry no remove menu.
/// </summary>
private void BuildPlaces()
{
Places.Clear();
var added = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var p in PinnedPaths())
if (added.Add(p.TrimEnd('\\'))) AddPlace(LabelFor(p), p, pinned: true);
foreach (var place in ExplorerQuickAccessPlaces())
if (added.Add(place.Path.TrimEnd('\\'))) AddPlace(place.Label, place.Path);
foreach (var d in DriveInfo.GetDrives().Where(d => d.IsReady))
{
string label;
try { label = string.IsNullOrWhiteSpace(d.VolumeLabel) ? d.DriveType.ToString() : d.VolumeLabel.Trim(); }
catch { label = d.DriveType.ToString(); }
if (added.Add(d.RootDirectory.FullName.TrimEnd('\\')))
AddPlace($"{d.Name.TrimEnd('\\')} {label}", d.RootDirectory.FullName);
}
}
/// <summary>
/// Explorer's Quick Access entries, read through the Shell.Application COM object.
///
/// Returns a materialized list rather than an iterator so the whole body can sit inside a
/// real try/catch: a yield-return method is only allowed a finally, so every failure here
/// used to escape into BuildPlaces and take the Open dialog down with it. Each call below
/// is late-bound COM and can fail outright - Wine and CrossOver register the
/// Shell.Application ProgID but implement no NameSpace, so the ProgID null check passes
/// and the binder then throws RuntimeBinderException. Quick Access is a convenience, so
/// any failure just drops it and leaves the pinned folders and the drives.
/// </summary>
private static List<(string Label, string Path)> ExplorerQuickAccessPlaces()
{
const string QuickAccess = "shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}";
var places = new List<(string Label, string Path)>();
object? shell = null, folder = null, items = null;
try
{
var type = Type.GetTypeFromProgID("Shell.Application");
if (type == null) return places;
shell = Activator.CreateInstance(type);
folder = ((dynamic)shell!).NameSpace(QuickAccess);
if (folder == null) return places;
items = ((dynamic)folder).Items();
int count = ((dynamic)items).Count;
for (int i = 0; i < count; i++)
{
object? item = null;
try
{
item = ((dynamic)items).Item(i);
if (item == null) continue;
dynamic quickItem = item;
if (!Convert.ToBoolean(quickItem.IsFolder)) continue;
string path = Convert.ToString(quickItem.Path) ?? "";
string name = Convert.ToString(quickItem.Name) ?? "";
if (Directory.Exists(path)) places.Add((name.Length > 0 ? name : LabelFor(path), path));
}
catch { /* one unreadable entry must not lose the rest */ }
finally { if (item != null && Marshal.IsComObject(item)) Marshal.FinalReleaseComObject(item); }
}
}
catch { /* no shell, or a shell without Quick Access: keep whatever was read */ }
finally
{
if (items != null && Marshal.IsComObject(items)) Marshal.FinalReleaseComObject(items);
if (folder != null && Marshal.IsComObject(folder)) Marshal.FinalReleaseComObject(folder);
if (shell != null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
}
return places;
}
/// <summary>
/// The persisted pin list. First run (key absent, null) seeds the five standard folders;
/// an EMPTY stored value means the user unpinned everything and must stay empty.
/// </summary>
private static List<string> PinnedPaths()
{
string? saved = App.GetSetting(PinnedKey);
if (saved != null)
return [.. saved.Split('|').Where(s => s.Length > 0)];
return [.. new List<string>
{
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
}.Where(p => !string.IsNullOrEmpty(p))];
}
/// <summary>Localized label for the five standard folders, plain folder name otherwise.</summary>
private static string LabelFor(string path)
{
string p = path.TrimEnd('\\');
bool Is(string other) => other.Length > 0 &&
p.Equals(other.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) return Loc("Str_QA_Home");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.Desktop))) return Loc("Str_QA_Desktop");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments))) return Loc("Str_QA_Documents");
if (Is(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads")))
return Loc("Str_QA_Downloads");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures))) return Loc("Str_QA_Pictures");
var name = Path.GetFileName(p);
return name.Length == 0 ? p : name;
}
private void AddPlace(string label, string path, bool pinned = false)
{
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
Places.Add(new PickerPlace(label, path, pinned));
}
private void PinPlace(string path)
{
var list = PinnedPaths();
if (list.Any(p => p.TrimEnd('\\').Equals(path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)))
return;
list.Add(path);
App.SetSetting(PinnedKey, string.Join("|", list));
BuildPlaces();
SyncPlacesSelection();
}
private PickerPlace? _placesMenuPlace;
private void Places_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_placesMenuPlace = ItemUnder<PickerPlace>(e.OriginalSource as DependencyObject);
// Drives are dynamic, not pinned - nothing to remove; empty space likewise.
if (_placesMenuPlace is not { Pinned: true }) e.Handled = true;
}
private void UnpinPlace_Click(object sender, RoutedEventArgs e)
{
if (_placesMenuPlace is not { Pinned: true } pl) return;
var list = PinnedPaths()
.Where(p => !p.TrimEnd('\\').Equals(pl.Path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
.ToList();
App.SetSetting(PinnedKey, string.Join("|", list));
BuildPlaces();
SyncPlacesSelection();
}
private PickerEntry? _filesMenuEntry;
private void Files_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_filesMenuEntry = ItemUnder<PickerEntry>(e.OriginalSource as DependencyObject);
if (_filesMenuEntry is not { IsFolder: true }) e.Handled = true; // only folders pin
}
private void FilePin_Click(object sender, RoutedEventArgs e)
{
if (_filesMenuEntry is { IsFolder: true } en) PinPlace(en.FullPath);
}
/// <summary>Marks the place matching the current folder, or clears the marker.</summary>
private void SyncPlacesSelection()
{
bool was = _navigating;
_navigating = true;
PlacesList.SelectedItem = _currentDir.Length == 0 ? null : Places.FirstOrDefault(p =>
p.Path.TrimEnd('\\').Equals(_currentDir.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
_navigating = was;
}
/// <summary>The row model under a right-click, resolved by walking up to the ListBoxItem.</summary>
private static T? ItemUnder<T>(DependencyObject? d) where T : class
{
while (d != null)
{
if (d is ListBoxItem lbi) return lbi.DataContext as T;
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? System.Windows.Media.VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return null;
}
private static string Loc(string key)
=> Application.Current.TryFindResource(key) as string ?? key;
// ── Navigation ───────────────────────────────────────────────────────────
private void NavigateTo(string dir)
{
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) return;
_navigating = true;
UpdateImagePreview(null);
_currentDir = dir;
PathBox.Text = dir;
_raw.Clear();
try
{
// The toggle gates two things together: attribute Hidden/System AND leading-dot
// names - the Unix convention is all over a Windows home folder (.gradle, .ssh)
// and those carry no Hidden attribute. Same gate in the folder tree (FolderTree.cs).
foreach (var sub in Directory.EnumerateDirectories(dir))
{
DirectoryInfo info;
try { info = new DirectoryInfo(sub); } catch { continue; }
if (!_showHidden)
{
if ((info.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) continue;
if (info.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
_raw.Add(new PickerEntry(info.Name, sub, true, 0, SafeTime(() => info.LastWriteTime)));
}
foreach (var file in Directory.EnumerateFiles(dir))
{
FileInfo fi;
try { fi = new FileInfo(file); } catch { continue; }
if (!_showHidden)
{
if ((fi.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) continue;
if (fi.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
_raw.Add(new PickerEntry(fi.Name, file, false, SafeLen(fi), SafeTime(() => fi.LastWriteTime)));
}
}
catch { /* unauthorized / unreadable - show what we have */ }
ApplySort();
UpButton.IsEnabled = Directory.GetParent(dir) != null;
UpdateInfoSummary();
SyncPlacesSelection();
_navigating = false;
RecordRecent(dir);
_ = RevealInTree(dir);
}
private static DateTime SafeTime(Func<DateTime> get)
{
try { return get(); } catch { return DateTime.MinValue; }
}
private static long SafeLen(FileInfo fi)
{
try { return fi.Length; } catch { return 0; }
}
private void Up_Click(object sender, RoutedEventArgs e)
{
var parent = Directory.GetParent(_currentDir);
if (parent != null) NavigateTo(parent.FullName);
}
private void Places_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_navigating) return;
if (PlacesList.SelectedItem is PickerPlace p) NavigateTo(p.Path);
}
// ── Folder tree (ported from KillerShell, see Controls/FolderTree.cs) ────
/// <summary>Ready drives only - an empty optical drive or a dropped mapping would sit
/// there as a node that throws the moment anyone touches it.</summary>
private void InitTree()
{
if (TreeRoots.Count > 0) return;
FolderTreeCtl.ItemsSource = TreeRoots;
DriveInfo[] drives;
try { drives = DriveInfo.GetDrives(); }
catch (IOException) { return; }
foreach (var d in drives)
{
bool ready;
try { ready = d.IsReady; }
catch (IOException) { continue; }
catch (UnauthorizedAccessException) { continue; }
if (ready) TreeRoots.Add(new FolderNode(d));
}
// Edge fades follow the scroll position (KillerShell TreePanel.cs). ScrollChanged is
// handled at the TreeView rather than dug out of its template: it bubbles, so the
// inner ScrollViewer is reached without needing to have found it first. Loaded and
// SizeChanged cover the passes where nothing scrolled but the extent moved.
FolderTreeCtl.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); }));
FolderTreeCtl.SizeChanged += (_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); };
FolderTreeCtl.Loaded += (_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); };
// The places list gets the same treatment (2026-07-30). No scrollbar lift:
// horizontal scrolling is disabled on it.
PlacesList.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => SyncPlacesEdgeFades()));
PlacesList.SizeChanged += (_, _) => SyncPlacesEdgeFades();
PlacesList.Loaded += (_, _) => SyncPlacesEdgeFades();
}
/// <summary>Places-list twin of SyncTreeEdgeFades: same ramp, same rules.</summary>
private void SyncPlacesEdgeFades()
{
var sv = FindDescendant<ScrollViewer>(PlacesList);
if (sv == null) return;
PlacesFadeTop.Opacity = Ramp(sv.VerticalOffset, PlacesFadeTop.Height, 18);
PlacesFadeBottom.Opacity = Ramp(sv.ExtentHeight - sv.ViewportHeight - sv.VerticalOffset,
PlacesFadeBottom.Height, 22);
}
/// <summary>
/// Fade each edge only while there is something PAST it, ramped over the fade's own
/// height: none at the very top, none at the very bottom, full in between. A proportional
/// ramp rather than a flip - at one pixel of scroll it is one pixel's worth of fade, so
/// neither edge ever pops. (KillerShell TreePanel.SyncTreeEdgeFades, verbatim.)
/// </summary>
private void SyncTreeEdgeFades()
{
var sv = FindDescendant<ScrollViewer>(FolderTreeCtl);
if (sv == null) return;
TreeFadeTop.Opacity = Ramp(sv.VerticalOffset, TreeFadeTop.Height, 18);
TreeFadeBottom.Opacity = Ramp(sv.ExtentHeight - sv.ViewportHeight - sv.VerticalOffset,
TreeFadeBottom.Height, 22);
}
// Height is NaN until the border has been laid out, hence the fallback.
private static double Ramp(double distance, double height, double fallback)
{
double h = double.IsNaN(height) || height <= 0 ? fallback : height;
return Math.Min(1, Math.Max(0, distance) / h);
}
/// <summary>
/// Keep the bottom edge fade sitting on the tree's last visible ROW rather than on the
/// horizontal scrollbar underneath it. The bar's real height is measured, not taken from
/// SystemParameters - the themed template is not the system metric. Base 4 is the tree's
/// own bottom margin. (KillerShell TreePanel.SyncTreeFade, adapted.)
/// </summary>
private void SyncTreeFade()
{
var sv = FindDescendant<ScrollViewer>(FolderTreeCtl);
double lift = 0;
if (sv != null && sv.ComputedHorizontalScrollBarVisibility == Visibility.Visible)
{
var bar = FindHorizontalBar(sv);
lift = bar?.ActualHeight ?? SystemParameters.HorizontalScrollBarHeight;
}
var m = TreeFadeBottom.Margin;
double want = 4 + lift;
if (Math.Abs(m.Bottom - want) < 0.5) return; // no churn on every layout pass
TreeFadeBottom.Margin = new Thickness(m.Left, m.Top, m.Right, want);
}
private void FolderTree_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
var sv = FindDescendant<ScrollViewer>(FolderTreeCtl);
if (sv is null) return;
bool horizontal = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
if (horizontal)
{
if (sv.ScrollableWidth <= 0) return;
sv.ScrollToHorizontalOffset(sv.HorizontalOffset - e.Delta * 0.5);
}
else
{
if (sv.ScrollableHeight <= 0) return;
sv.ScrollToVerticalOffset(sv.VerticalOffset - e.Delta * 0.5);
}
e.Handled = true;
}
private static T? FindDescendant<T>(DependencyObject root) where T : DependencyObject
{
int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < n; i++)
{
var c = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
if (c is T hit) return hit;
var deeper = FindDescendant<T>(c);
if (deeper != null) return deeper;
}
return null;
}
// FindDescendant takes the FIRST match of a type, and a ScrollViewer has two scrollbars,
// so the orientation has to be checked rather than assumed.
private static System.Windows.Controls.Primitives.ScrollBar? FindHorizontalBar(DependencyObject root)
{
int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < n; i++)
{
var c = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
if (c is System.Windows.Controls.Primitives.ScrollBar sb &&
sb.Orientation == Orientation.Horizontal) return sb;
var deeper = FindHorizontalBar(c);
if (deeper != null) return deeper;
}
return null;
}
// TreeViewItem.Expanded is attached at the TreeView, so this fires for every node at any
// depth - which is the point: one handler drives the whole lazy load.
private async void FolderTree_Expanded(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not TreeViewItem tvi) return;
if (tvi.DataContext is not FolderNode node) return;
await node.LoadChildrenAsync();
}
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (_treeSyncing) return;
if (e.NewValue is not FolderNode node) return;
if (string.IsNullOrEmpty(node.Path)) return; // the placeholder, mid-load
NavigateTo(node.Path);
}
private FolderNode? _treeMenuNode;
private void FolderTree_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_treeMenuNode = NodeUnder(Mouse.DirectlyOver as DependencyObject)
?? NodeUnder(e.OriginalSource as DependencyObject);
// Drives are already in places; empty space has nothing to pin.
if (_treeMenuNode == null || _treeMenuNode.IsDrive) e.Handled = true;
}
private void TreePin_Click(object sender, RoutedEventArgs e)
{
if (_treeMenuNode is { IsDrive: false } n && !string.IsNullOrEmpty(n.Path))
PinPlace(n.Path);
}
private static FolderNode? NodeUnder(DependencyObject? d)
{
while (d != null)
{
if (d is TreeViewItem tvi) return tvi.DataContext as FolderNode;
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? System.Windows.Media.VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return null;
}
/// <summary>
/// Points the tree at a folder reached from somewhere else - places, the path box, a
/// double-click. Expands the chain of ANCESTORS and selects the folder; the destination's
/// own expander is left exactly as the user had it (KillerShell's rule - forcing it
/// collapsed the branch under the cursor and the whole tree jumped).
/// </summary>
private async Task RevealInTree(string folder)
{
if (string.IsNullOrEmpty(folder)) return;
string full;
try { full = Path.GetFullPath(folder); }
catch { return; }
var root = TreeRoots.FirstOrDefault(
r => full.StartsWith(r.Path, StringComparison.OrdinalIgnoreCase));
if (root == null) return;
var segments = RelativeSegments(root.Path, full).ToList();
var current = root;
if (segments.Count > 0)
{
await current.LoadChildrenAsync();
current.IsExpanded = true;
}
for (int i = 0; i < segments.Count; i++)
{
var next = current.Children.FirstOrDefault(
c => string.Equals(c.Name, segments[i], StringComparison.OrdinalIgnoreCase));
if (next == null) return; // hidden by the filter, or gone since the listing
current = next;
if (i == segments.Count - 1) break;
await current.LoadChildrenAsync(); // needed to match the NEXT segment
current.IsExpanded = true;
}
_treeSyncing = true;
current.IsSelected = true;
_treeSyncing = false;
}
private static IEnumerable<string> RelativeSegments(string rootPath, string fullPath)
{
string rest = fullPath.Substring(rootPath.Length);
return rest.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries);
}
// ── Recent locations ─────────────────────────────────────────────────────
private static List<string> LoadRecents()
=> [.. (App.GetSetting(RecentsKey) ?? "")
.Split('|').Where(s => s.Length > 0)];
private static void RecordRecent(string dir)
{
var list = LoadRecents();
list.RemoveAll(p => p.TrimEnd('\\').Equals(dir.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
list.Insert(0, dir);
if (list.Count > RecentsMax) list.RemoveRange(RecentsMax, list.Count - RecentsMax);
App.SetSetting(RecentsKey, string.Join("|", list));
}
private void RecentsBtn_Click(object sender, RoutedEventArgs e)
{
// Stale entries (unplugged drive, deleted folder) are filtered at open rather than
// scrubbed from the store - the drive may be back tomorrow.
var list = LoadRecents().Where(Directory.Exists).ToList();
if (list.Count == 0) return;
_navigating = true; // rebinding must not raise a navigation
RecentsList.ItemsSource = list;
RecentsList.SelectedItem = null;
_navigating = false;
RecentsPopup.IsOpen = true;
}
private void RecentsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_navigating) return;
if (RecentsList.SelectedItem is not string dir) return;
RecentsPopup.IsOpen = false;
NavigateTo(dir);
}
// ── Hidden / dot files ───────────────────────────────────────────────────
private void ShowHidden_Click(object sender, RoutedEventArgs e)
{
_showHidden = !_showHidden;
App.SetSetting(ShowHiddenKey, _showHidden ? "1" : "0");
FolderNode.ShowHidden = _showHidden;
ApplyShowHiddenButton();
if (_currentDir.Length > 0) NavigateTo(_currentDir);
// Re-enumerate loaded tree branches in place, keeping expansion (FolderTree.cs).
foreach (var r in TreeRoots.ToList()) _ = r.RefreshAsync();
}
private void ApplyShowHiddenButton()
{
// E7B3 eye at rest, E890 while showing - KillerShell's build-proven pair
// (ViewOptions.cs). Codepoints, never literal PUA glyphs (family rule).
ShowHiddenBtn.Content = ((char)(_showHidden ? 0xE890 : 0xE7B3)).ToString();
ShowHiddenBtn.Tag = _showHidden ? "on" : null;
}
private void Files_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_navigating) return;
if (FileList.SelectedItem is PickerEntry en)
{
// Selecting a FILE fills the name box - that is the value being chosen. Selecting
// a folder does not: it is a navigation target, and overwriting the typed name
// with a folder name would lose what the user was in the middle of typing.
if (!en.IsFolder) FileNameBox.Text = en.Name;
SelName.Text = en.Name;
SelMeta.Text = en.IsFolder ? en.ModifiedLabel : $"{en.SizeLabel} | {en.ModifiedLabel}";
UpdateImagePreview(en);
}
else
{
UpdateInfoSummary();
UpdateImagePreview(null);
}
}
private void ConfigureImagePreview()
{
if (!ShowImagePreview)
{
ImagePreviewHost.Visibility = Visibility.Collapsed;
ImagePreviewGapColumn.Width = new GridLength(0);
ImagePreviewColumn.Width = new GridLength(0);
return;
}
ImagePreviewHost.Visibility = Visibility.Visible;
ImagePreviewGapColumn.Width = new GridLength(8);
ImagePreviewColumn.Width = new GridLength(230);
// A preview should not consume the file list. Only image pickers grow; the remembered
// size remains the user's starting point and is still clamped to the work area.
MinWidth = 780;
Width = Math.Min(SystemParameters.WorkArea.Width, Math.Max(Width, 880));
}
private async void UpdateImagePreview(PickerEntry? entry)
{
int generation = ++_imagePreviewGeneration;
ImagePreview.Source = null;
ImagePreviewPlaceholder.Visibility = Visibility.Visible;
if (!ShowImagePreview || entry == null || entry.IsFolder ||
!PreviewImageExtensions.Contains(Path.GetExtension(entry.FullPath)))
return;
string path = entry.FullPath;
try
{
// BitmapCacheOption.OnLoad closes the file after decoding, and DecodePixelWidth
// caps memory for camera images while retaining enough detail for this pane.
var bitmap = await Task.Run(() =>
{
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
image.DecodePixelWidth = 900;
image.UriSource = new Uri(path, UriKind.Absolute);
image.EndInit();
image.Freeze();
return image;
});
if (generation != _imagePreviewGeneration) return;
ImagePreview.Source = bitmap;
ImagePreviewPlaceholder.Visibility = Visibility.Collapsed;
}
catch
{
// Corrupt, unsupported, or transiently unavailable image: leave the neutral
// placeholder visible and keep the picker fully usable.
}
}
private void Files_DoubleClick(object sender, MouseButtonEventArgs e)
{
if (FileList.SelectedItem is not PickerEntry en) return;
if (en.IsFolder) NavigateTo(en.FullPath);
else Accept();
}
private void PathBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
var typed = PathBox.Text?.Trim();
if (!string.IsNullOrEmpty(typed) && Directory.Exists(typed)) NavigateTo(typed!);
e.Handled = true;
}
private void FileNameBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
var typed = FileNameBox.Text?.Trim() ?? "";
// A directory typed into the name box navigates instead of accepting - matches the
// Win32 dialogs, and is how people paste a path in.
if (typed.Length > 0)
{
var asDir = Path.IsPathRooted(typed) ? typed : Path.Combine(_currentDir, typed);
if (Directory.Exists(asDir)) { NavigateTo(asDir); FileNameBox.Clear(); return; }
}
// A wildcard retargets the listing rather than naming a file.
if (typed.IndexOfAny(['*', '?']) >= 0)
{
_filterPatterns.Insert(0, [typed]);
FilterCombo.Items.Insert(0, typed);
FilterCombo.SelectedIndex = 0;
FileNameBox.Clear();
return;
}
Accept();
}
private void UpdateInfoSummary()
{
int folders = _raw.Count(x => x.IsFolder);
int shown = Entries.Count(x => !x.IsFolder);
var leaf = Path.GetFileName(_currentDir.TrimEnd('\\'));
SelName.Text = leaf.Length == 0 ? _currentDir : leaf;
SelMeta.Text = string.Format(Loc("Str_Sum_Counts"), folders, shown);
}
// ── View modes ───────────────────────────────────────────────────────────
private void ViewList_Click(object sender, RoutedEventArgs e) => SetView(0);
private void ViewIcons_Click(object sender, RoutedEventArgs e) => SetView(1);
private void ViewDetails_Click(object sender, RoutedEventArgs e) => SetView(2);
private void SetView(int mode)
{
_viewMode = mode;
ApplyView();
}
/// <summary>
/// The three views differ in panel, template AND scroll direction - that last one is the
/// part that is easy to miss. List view wraps into columns and scrolls sideways, which only
/// works if vertical scrolling is DISABLED: an enabled vertical ScrollViewer hands the panel
/// infinite height, so a vertical WrapPanel never wraps and you get one tall column.
/// </summary>
private void ApplyView()
{
switch (_viewMode)
{
case 1: // icons: grid, wraps across, scrolls down
FileList.ItemsPanel = (ItemsPanelTemplate)FindResource("PanelIconGrid");
FileList.ItemTemplate = (DataTemplate)FindResource("IconTemplate");
ScrollViewer.SetHorizontalScrollBarVisibility(FileList, ScrollBarVisibility.Disabled);
ScrollViewer.SetVerticalScrollBarVisibility(FileList, ScrollBarVisibility.Auto);
break;
case 2: // details: one row per entry, scrolls down
FileList.ItemsPanel = (ItemsPanelTemplate)FindResource("PanelStack");
FileList.ItemTemplate = (DataTemplate)FindResource("DetailsTemplate");
ScrollViewer.SetHorizontalScrollBarVisibility(FileList, ScrollBarVisibility.Disabled);
ScrollViewer.SetVerticalScrollBarVisibility(FileList, ScrollBarVisibility.Auto);
break;
default: // list: columns of small icons, scrolls RIGHT
FileList.ItemsPanel = (ItemsPanelTemplate)FindResource("PanelListCols");
FileList.ItemTemplate = (DataTemplate)FindResource("RowTemplate");
ScrollViewer.SetHorizontalScrollBarVisibility(FileList, ScrollBarVisibility.Auto);
ScrollViewer.SetVerticalScrollBarVisibility(FileList, ScrollBarVisibility.Disabled);
break;
}
DetailsHeader.Visibility = _viewMode == 2 ? Visibility.Visible : Visibility.Collapsed;
ViewListBtn.Tag = _viewMode == 0 ? "on" : null;
ViewIconsBtn.Tag = _viewMode == 1 ? "on" : null;
ViewDetailsBtn.Tag = _viewMode == 2 ? "on" : null;
}
/// <summary>
/// List view wraps entries into columns and scrolls horizontally. A normal mouse wheel
/// only asks WPF to scroll vertically, which is disabled in this view, so translate the
/// wheel delta to the horizontal scrollbar. Icon and details views remain vertical.
/// </summary>
private void FileList_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (_viewMode != 0) return;
var sv = FindDescendant<ScrollViewer>(FileList);
if (sv is null) return;
sv.ScrollToHorizontalOffset(sv.HorizontalOffset - e.Delta);
e.Handled = true;
}
// ── Sorting ──────────────────────────────────────────────────────────────
private void SortName_Click(object sender, RoutedEventArgs e) => SetSort(0);
private void SortSize_Click(object sender, RoutedEventArgs e) => SetSort(1);
private void SortModified_Click(object sender, RoutedEventArgs e) => SetSort(2);
private void SetSort(int key)
{
if (_sortKey == key) _sortAsc = !_sortAsc;
else { _sortKey = key; _sortAsc = true; }
ApplySort();
}
/// <summary>
/// Rebuilds Entries from _raw: filter applied, folders always before files, then the
/// active sort key. Folders-first is not a sort key - it is the frame the sort runs in.
/// </summary>
private void ApplySort()
{
var visible = _raw.Where(PassesFilter);
IOrderedEnumerable<PickerEntry> ordered = _sortKey switch
{
1 => _sortAsc ? visible.OrderBy(x => !x.IsFolder).ThenBy(x => x.SizeBytes)
: visible.OrderBy(x => !x.IsFolder).ThenByDescending(x => x.SizeBytes),
2 => _sortAsc ? visible.OrderBy(x => !x.IsFolder).ThenBy(x => x.Modified)
: visible.OrderBy(x => !x.IsFolder).ThenByDescending(x => x.Modified),
_ => _sortAsc ? visible.OrderBy(x => !x.IsFolder).ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
: visible.OrderBy(x => !x.IsFolder).ThenByDescending(x => x.Name, StringComparer.OrdinalIgnoreCase),
};
Entries.Clear();
foreach (var e in ordered) Entries.Add(e);
NameArrow.Text = _sortKey == 0 ? (_sortAsc ? ArrowUp : ArrowDown) : "";
SizeArrow.Text = _sortKey == 1 ? (_sortAsc ? ArrowUp : ArrowDown) : "";
ModArrow.Text = _sortKey == 2 ? (_sortAsc ? ArrowUp : ArrowDown) : "";
EmptyHint.Visibility = Entries.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
}
// ── Accept / cancel ──────────────────────────────────────────────────────
private void OK_Click(object sender, RoutedEventArgs e) => Accept();
/// <summary>
/// Resolves the name box to a full path and applies the mode's rules. Anything that fails
/// leaves the dialog OPEN with focus back in the name box - a file dialog that closes on a
/// bad name and makes you start over is the worst outcome.
/// </summary>
private void Accept()
{
// Multiselect: several files highlighted wins outright. The name box shows a quoted
// list in that state and is not worth re-parsing - the selection IS the answer. A
// single highlighted file falls through to the normal path so every rule below
// (extension, existence) still applies.
if (Multiselect && _mode == FileDialogMode.Open)
{
var picked = FileList.SelectedItems.OfType<PickerEntry>()
.Where(x => !x.IsFolder)
.Select(x => x.FullPath)
.ToArray();
if (picked.Length > 1)
{
FileNames = picked;
FileName = picked[0];
RememberAcceptedDirectory();
_pendingResult = true; // applied after the fade - see OnClosing
Close();
return;
}
}
var typed = FileNameBox.Text?.Trim().Trim('"') ?? "";
if (typed.Length == 0)
{
// Nothing typed but a file is highlighted: take that.
if (FileList.SelectedItem is PickerEntry sel && !sel.IsFolder) typed = sel.Name;
else { FileNameBox.Focus(); return; }
}
var full = Path.IsPathRooted(typed) ? typed : Path.Combine(_currentDir, typed);
if (_mode == FileDialogMode.Save)
{
// The extension follows the ACTIVE filter, so picking "CSV files" in the type
// combo is enough to get a .csv - DefaultExt only decides when the filter names
// no single extension (a wildcard or a multi-pattern entry).
if (AddExtension && string.IsNullOrEmpty(Path.GetExtension(full)))
{
string? ext = ActiveFilterExt();
if (ext == null && !string.IsNullOrEmpty(DefaultExt))
ext = DefaultExt.StartsWith(".") ? DefaultExt : "." + DefaultExt;
if (ext != null) full += ext;
}
// The directory must exist; we do not silently create trees on the user's behalf.
var dir = Path.GetDirectoryName(full);
if (CheckPathExists && (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)))
{
SelMeta.Text = Loc("Str_Dlg_NoSuchFolder");
FileNameBox.Focus();
return;
}
if (OverwritePrompt && File.Exists(full))
{
// Killendar asks this through its own ConfirmDialog. MmdPdf already has a
// themed confirm (KillerDialog) that every other prompt in the app uses, so
// this goes through that instead of importing a second one - two dialogs that
// ask the same kind of question is exactly how a UI starts to look stitched
// together. Same question, same buttons, one implementation.
var answer = KillerDialog.Show(this,
string.Format(Loc("Str_Dlg_OverwriteMsg"), Path.GetFileName(full)),
"MmdPdf", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (answer != MessageBoxResult.Yes) { FileNameBox.Focus(); return; }
}
}
else
{
if (CheckFileExists && !File.Exists(full))
{
SelMeta.Text = Loc("Str_Dlg_NoSuchFile");
FileNameBox.Focus();
FileNameBox.SelectAll();
return;
}
}
FileName = full;
FileNames = [full]; // always populated on success, so callers can read either
RememberAcceptedDirectory();
_pendingResult = true; // applied after the fade - see OnClosing
Close();
}
private void RememberAcceptedDirectory()
{
if (_currentDir.Length > 0 && Directory.Exists(_currentDir))
App.SetSetting(LastDirKey, _currentDir);
}
private void Cancel_Click(object sender, RoutedEventArgs e) => Close();
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// Handled, or the press bubbles on to Resize_MouseDown AFTER DragMove's modal loop
// returns - by then the button is UP, and WM_NCLBUTTONDOWN with no button held puts
// Windows into its sticky keyboard-style size loop: the window chases the mouse,
// resizing, until a click. (2026-07-30)
e.Handled = true;
DragMove();
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.Escape) { Close(); e.Handled = true; return; }
base.OnKeyDown(e);
}
// ---- edge resize, done by hand ----
//
// This dialog carries no shell:WindowChrome - on an AllowsTransparency window it fills its
// own non-client area and that paints as a flat band around the card. So the 10px halo does
// the job instead: work out which edge the pointer is in and hand the drag to Windows with
// WM_NCLBUTTONDOWN, exactly as Shell/Chrome.cs does for the main window's corner grip.
// Windows then runs its own resize loop, so this gets the real snapping and live preview
// rather than a hand-rolled approximation. (2026-07-30)
private const int WM_NCLBUTTONDOWN = 0x00A1;
private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13,
HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
/// <summary>Width of the grab band, matching the ResizeBorderThickness WindowChrome used.</summary>
private const double ResizeEdge = 8;
/// <summary>Which edge the pointer is in, or 0 for none.</summary>
private int HitTestEdge(Point p)
{
bool left = p.X <= ResizeEdge;
bool right = p.X >= ActualWidth - ResizeEdge;
bool top = p.Y <= ResizeEdge;
bool bottom = p.Y >= ActualHeight - ResizeEdge;
if (top && left) return HTTOPLEFT;
if (top && right) return HTTOPRIGHT;
if (bottom && left) return HTBOTTOMLEFT;
if (bottom && right) return HTBOTTOMRIGHT;
if (left) return HTLEFT;
if (right) return HTRIGHT;
if (top) return HTTOP;
if (bottom) return HTBOTTOM;
return 0;
}
private void Resize_MouseMove(object sender, MouseEventArgs e)
{
Cursor = HitTestEdge(e.GetPosition(this)) switch
{
HTLEFT or HTRIGHT => Cursors.SizeWE,
HTTOP or HTBOTTOM => Cursors.SizeNS,
HTTOPLEFT or HTBOTTOMRIGHT => Cursors.SizeNWSE,
HTTOPRIGHT or HTBOTTOMLEFT => Cursors.SizeNESW,
_ => Cursors.Arrow,
};
}
private void Resize_MouseDown(object sender, MouseButtonEventArgs e)
{
// Only a press on the halo Grid ITSELF may start a resize. Every press on the card
// bubbles up here too (with OriginalSource somewhere in the card's tree), and a stale
// bubbled press must never reach WM_NCLBUTTONDOWN - see TitleBar_MouseLeftButtonDown.
if (!ReferenceEquals(e.OriginalSource, sender)) return;
int ht = HitTestEdge(e.GetPosition(this));
if (ht == 0) return;
e.Handled = true;
SendMessage(new System.Windows.Interop.WindowInteropHelper(this).Handle,
WM_NCLBUTTONDOWN, new IntPtr(ht), IntPtr.Zero);
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
}
}