|
@@ -1,1053 +1,1634 @@
|
|
|
-//
|
|
|
-// FileDialog.cs: File system dialogs for open and save
|
|
|
-//
|
|
|
-// TODO:
|
|
|
-// * Add directory selector
|
|
|
-// * Implement subclasses
|
|
|
-// * Figure out why message text does not show
|
|
|
-// * Remove the extra space when message does not show
|
|
|
-// * Use a line separator to show the file listing, so we can use same colors as the rest
|
|
|
-// * DirListView: Add mouse support
|
|
|
-
|
|
|
using System;
|
|
|
using System.Collections.Generic;
|
|
|
-using NStack;
|
|
|
+using System.Data;
|
|
|
using System.IO;
|
|
|
+using System.IO.Abstractions;
|
|
|
using System.Linq;
|
|
|
+using System.Text.RegularExpressions;
|
|
|
+using System.Threading;
|
|
|
+using System.Threading.Tasks;
|
|
|
+using NStack;
|
|
|
using Terminal.Gui.Resources;
|
|
|
+using static Terminal.Gui.ConfigurationManager;
|
|
|
|
|
|
namespace Terminal.Gui {
|
|
|
- internal class DirListView : View {
|
|
|
- int top, selected;
|
|
|
- DirectoryInfo dirInfo;
|
|
|
- FileSystemWatcher watcher;
|
|
|
- List<(string, bool, bool)> infos;
|
|
|
- internal bool canChooseFiles = true;
|
|
|
- internal bool canChooseDirectories = false;
|
|
|
- internal bool allowsMultipleSelection = false;
|
|
|
- FileDialog host;
|
|
|
-
|
|
|
- public DirListView (FileDialog host)
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Modal dialog for selecting files/directories. Has auto-complete and expandable
|
|
|
+ /// navigation pane (Recent, Root drives etc).
|
|
|
+ /// </summary>
|
|
|
+ public partial class FileDialog : Dialog {
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Gets settings for controlling how visual elements behave. Style changes should
|
|
|
+ /// be made before the <see cref="Dialog"/> is loaded and shown to the user for the
|
|
|
+ /// first time.
|
|
|
+ /// </summary>
|
|
|
+ public FileDialogStyle Style { get; } = new FileDialogStyle ();
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// The maximum number of results that will be collected
|
|
|
+ /// when searching before stopping.
|
|
|
+ /// </summary>
|
|
|
+ /// <remarks>
|
|
|
+ /// This prevents performance issues e.g. when searching
|
|
|
+ /// root of file system for a common letter (e.g. 'e').
|
|
|
+ /// </remarks>
|
|
|
+ [SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
|
|
|
+ public static int MaxSearchResults { get; set; } = 10000;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// True if the file/folder must exist already to be selected.
|
|
|
+ /// This prevents user from entering the name of something that
|
|
|
+ /// doesn't exist. Defaults to false.
|
|
|
+ /// </summary>
|
|
|
+ public bool MustExist { get; set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Gets the Path separators for the operating system
|
|
|
+ /// </summary>
|
|
|
+ internal static char [] Separators = new []
|
|
|
{
|
|
|
- infos = new List<(string, bool, bool)> ();
|
|
|
- CanFocus = true;
|
|
|
- this.host = host;
|
|
|
- }
|
|
|
+ System.IO.Path.AltDirectorySeparatorChar,
|
|
|
+ System.IO.Path.DirectorySeparatorChar,
|
|
|
+ };
|
|
|
|
|
|
- bool IsAllowed (FileSystemInfo fsi)
|
|
|
+ /// <summary>
|
|
|
+ /// Characters to prevent entry into <see cref="tbPath"/>. Note that this is not using
|
|
|
+ /// <see cref="System.IO.Path.GetInvalidFileNameChars"/> because we do want to allow directory
|
|
|
+ /// separators, arrow keys etc.
|
|
|
+ /// </summary>
|
|
|
+ private static char [] badChars = new []
|
|
|
{
|
|
|
- if (fsi.Attributes.HasFlag (FileAttributes.Directory))
|
|
|
- return true;
|
|
|
- if (allowedFileTypes == null)
|
|
|
- return true;
|
|
|
- foreach (var ft in allowedFileTypes)
|
|
|
- if (fsi.Name.EndsWith (ft, StringComparison.InvariantCultureIgnoreCase) || ft == ".*")
|
|
|
- return true;
|
|
|
- return false;
|
|
|
+ '"','<','>','|','*','?',
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// The UI selected <see cref="IAllowedType"/> from combo box. May be null.
|
|
|
+ /// </summary>
|
|
|
+ public IAllowedType CurrentFilter { get; private set; }
|
|
|
+
|
|
|
+ private bool pushingState = false;
|
|
|
+ private bool loaded = false;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Gets the currently open directory and known children presented in the dialog.
|
|
|
+ /// </summary>
|
|
|
+ internal FileDialogState State { get; private set; }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Locking object for ensuring only a single <see cref="SearchState"/> executes at once.
|
|
|
+ /// </summary>
|
|
|
+ internal object onlyOneSearchLock = new object ();
|
|
|
+
|
|
|
+ private bool disposed = false;
|
|
|
+ private IFileSystem fileSystem;
|
|
|
+ private TextField tbPath;
|
|
|
+
|
|
|
+ private FileDialogSorter sorter;
|
|
|
+ private FileDialogHistory history;
|
|
|
+
|
|
|
+ private DataTable dtFiles;
|
|
|
+ private TableView tableView;
|
|
|
+ private TreeView<object> treeView;
|
|
|
+ private TileView splitContainer;
|
|
|
+ private Button btnOk;
|
|
|
+ private Button btnCancel;
|
|
|
+ private Button btnToggleSplitterCollapse;
|
|
|
+ private Button btnForward;
|
|
|
+ private Button btnBack;
|
|
|
+ private Button btnUp;
|
|
|
+ private string feedback;
|
|
|
+
|
|
|
+ private CollectionNavigator collectionNavigator = new CollectionNavigator ();
|
|
|
+
|
|
|
+ private TextField tbFind;
|
|
|
+ private SpinnerView spinnerView;
|
|
|
+ private MenuBar allowedTypeMenuBar;
|
|
|
+ private MenuBarItem allowedTypeMenu;
|
|
|
+ private MenuItem [] allowedTypeMenuItems;
|
|
|
+ private DataColumn filenameColumn;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Event fired when user attempts to confirm a selection (or multi selection).
|
|
|
+ /// Allows you to cancel the selection or undertake alternative behavior e.g.
|
|
|
+ /// open a dialog "File already exists, Overwrite? yes/no".
|
|
|
+ /// </summary>
|
|
|
+ public event EventHandler<FilesSelectedEventArgs> FilesSelected;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Gets or sets behavior of the <see cref="FileDialog"/> when the user attempts
|
|
|
+ /// to delete a selected file(s). Set to null to prevent deleting.
|
|
|
+ /// </summary>
|
|
|
+ /// <remarks>Ensure you use a try/catch block with appropriate
|
|
|
+ /// error handling (e.g. showing a <see cref="MessageBox"/></remarks>
|
|
|
+ public IFileOperations FileOperationsHandler { get; set; } = new DefaultFileOperations ();
|
|
|
+
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Initializes a new instance of the <see cref="FileDialog"/> class.
|
|
|
+ /// </summary>
|
|
|
+ public FileDialog () : this(new FileSystem())
|
|
|
+ {
|
|
|
+
|
|
|
}
|
|
|
|
|
|
- internal bool Reload (ustring value = null)
|
|
|
+ /// <summary>
|
|
|
+ /// Initializes a new instance of the <see cref="FileDialog"/> class with
|
|
|
+ /// a custom <see cref="IFileSystem"/>.
|
|
|
+ /// </summary>
|
|
|
+ /// <remarks>This overload is mainly useful for testing.</remarks>
|
|
|
+ public FileDialog (IFileSystem fileSystem)
|
|
|
{
|
|
|
- bool valid = false;
|
|
|
- try {
|
|
|
- dirInfo = new DirectoryInfo (value == null ? directory.ToString () : value.ToString ());
|
|
|
-
|
|
|
- // Dispose of the old watcher
|
|
|
- watcher?.Dispose ();
|
|
|
-
|
|
|
- watcher = new FileSystemWatcher (dirInfo.FullName);
|
|
|
- watcher.NotifyFilter = NotifyFilters.Attributes
|
|
|
- | NotifyFilters.CreationTime
|
|
|
- | NotifyFilters.DirectoryName
|
|
|
- | NotifyFilters.FileName
|
|
|
- | NotifyFilters.LastAccess
|
|
|
- | NotifyFilters.LastWrite
|
|
|
- | NotifyFilters.Security
|
|
|
- | NotifyFilters.Size;
|
|
|
- watcher.Changed += Watcher_Changed;
|
|
|
- watcher.Created += Watcher_Changed;
|
|
|
- watcher.Deleted += Watcher_Changed;
|
|
|
- watcher.Renamed += Watcher_Changed;
|
|
|
- watcher.Error += Watcher_Error;
|
|
|
- watcher.EnableRaisingEvents = true;
|
|
|
- infos = (from x in dirInfo.GetFileSystemInfos ()
|
|
|
- where IsAllowed (x) && (!canChooseFiles ? x.Attributes.HasFlag (FileAttributes.Directory) : true)
|
|
|
- orderby (!x.Attributes.HasFlag (FileAttributes.Directory)) + x.Name
|
|
|
- select (x.Name, x.Attributes.HasFlag (FileAttributes.Directory), false)).ToList ();
|
|
|
- infos.Insert (0, ("..", true, false));
|
|
|
- top = 0;
|
|
|
- selected = 0;
|
|
|
- valid = true;
|
|
|
- } catch (Exception ex) {
|
|
|
- switch (ex) {
|
|
|
- case DirectoryNotFoundException _:
|
|
|
- case ArgumentException _:
|
|
|
- dirInfo = null;
|
|
|
- watcher?.Dispose ();
|
|
|
- watcher = null;
|
|
|
- infos.Clear ();
|
|
|
- valid = true;
|
|
|
- break;
|
|
|
- default:
|
|
|
- valid = false;
|
|
|
- break;
|
|
|
+ this.fileSystem = fileSystem;
|
|
|
+ this.btnOk = new Button (Style.OkButtonText) {
|
|
|
+ Y = Pos.AnchorEnd (1),
|
|
|
+ X = Pos.Function (() =>
|
|
|
+ this.Bounds.Width
|
|
|
+ - btnOk.Bounds.Width
|
|
|
+ // TODO: Fiddle factor, seems the Bounds are wrong for someone
|
|
|
+ - 2)
|
|
|
+ };
|
|
|
+ this.btnOk.Clicked += (s, e) => this.Accept ();
|
|
|
+ this.btnOk.KeyPress += (s, k) => {
|
|
|
+ this.NavigateIf (k, Key.CursorLeft, this.btnCancel);
|
|
|
+ this.NavigateIf (k, Key.CursorUp, this.tableView);
|
|
|
+ };
|
|
|
+
|
|
|
+ this.btnCancel = new Button ("Cancel") {
|
|
|
+ Y = Pos.AnchorEnd (1),
|
|
|
+ X = Pos.Function (() =>
|
|
|
+ this.Bounds.Width
|
|
|
+ - btnOk.Bounds.Width
|
|
|
+ - btnCancel.Bounds.Width
|
|
|
+ - 1
|
|
|
+ // TODO: Fiddle factor, seems the Bounds are wrong for someone
|
|
|
+ - 2
|
|
|
+ )
|
|
|
+ };
|
|
|
+ this.btnCancel.KeyPress += (s, k) => {
|
|
|
+ this.NavigateIf (k, Key.CursorLeft, this.btnToggleSplitterCollapse);
|
|
|
+ this.NavigateIf (k, Key.CursorUp, this.tableView);
|
|
|
+ this.NavigateIf (k, Key.CursorRight, this.btnOk);
|
|
|
+ };
|
|
|
+ this.btnCancel.Clicked += (s, e) => {
|
|
|
+ Application.RequestStop ();
|
|
|
+ };
|
|
|
+
|
|
|
+ this.btnUp = new Button () { X = 0, Y = 1, NoPadding = true };
|
|
|
+ btnUp.Text = GetUpButtonText ();
|
|
|
+ this.btnUp.Clicked += (s, e) => this.history.Up ();
|
|
|
+
|
|
|
+ this.btnBack = new Button () { X = Pos.Right (btnUp) + 1, Y = 1, NoPadding = true };
|
|
|
+ btnBack.Text = GetBackButtonText ();
|
|
|
+ this.btnBack.Clicked += (s, e) => this.history.Back ();
|
|
|
+
|
|
|
+ this.btnForward = new Button () { X = Pos.Right (btnBack) + 1, Y = 1, NoPadding = true };
|
|
|
+ btnForward.Text = GetForwardButtonText();
|
|
|
+ this.btnForward.Clicked += (s, e) => this.history.Forward ();
|
|
|
+
|
|
|
+ this.tbPath = new TextField {
|
|
|
+ Width = Dim.Fill (0),
|
|
|
+ Caption = Style.PathCaption,
|
|
|
+ CaptionColor = Color.Black
|
|
|
+ };
|
|
|
+ this.tbPath.KeyPress += (s, k) => {
|
|
|
+
|
|
|
+ ClearFeedback ();
|
|
|
+
|
|
|
+ this.AcceptIf (k, Key.Enter);
|
|
|
+
|
|
|
+ this.SuppressIfBadChar (k);
|
|
|
+ };
|
|
|
+
|
|
|
+ tbPath.Autocomplete = new AppendAutocomplete (tbPath);
|
|
|
+ tbPath.Autocomplete.SuggestionGenerator = new FilepathSuggestionGenerator ();
|
|
|
+
|
|
|
+ this.splitContainer = new TileView () {
|
|
|
+ X = 0,
|
|
|
+ Y = 2,
|
|
|
+ Width = Dim.Fill (0),
|
|
|
+ Height = Dim.Fill (1),
|
|
|
+ };
|
|
|
+ this.splitContainer.SetSplitterPos (0, 30);
|
|
|
+// this.splitContainer.Border.BorderStyle = BorderStyle.None;
|
|
|
+ this.splitContainer.Tiles.ElementAt (0).ContentView.Visible = false;
|
|
|
+
|
|
|
+ this.tableView = new TableView () {
|
|
|
+ Width = Dim.Fill (),
|
|
|
+ Height = Dim.Fill (),
|
|
|
+ FullRowSelect = true,
|
|
|
+ };
|
|
|
+
|
|
|
+ this.tableView.AddKeyBinding (Key.Space, Command.ToggleChecked);
|
|
|
+ Style.TableStyle = tableView.Style;
|
|
|
+
|
|
|
+ this.tableView.KeyPress += (s, k) => {
|
|
|
+ if (this.tableView.SelectedRow <= 0) {
|
|
|
+ this.NavigateIf (k, Key.CursorUp, this.tbPath);
|
|
|
}
|
|
|
- } finally {
|
|
|
- if (valid) {
|
|
|
- SetNeedsDisplay ();
|
|
|
+ if (this.tableView.SelectedRow == this.tableView.Table.Rows.Count-1) {
|
|
|
+ this.NavigateIf (k, Key.CursorDown, this.btnToggleSplitterCollapse);
|
|
|
}
|
|
|
- }
|
|
|
- return valid;
|
|
|
- }
|
|
|
|
|
|
- private bool _disposedValue;
|
|
|
- protected override void Dispose (bool disposing)
|
|
|
- {
|
|
|
- if (!_disposedValue) {
|
|
|
- if (disposing) {
|
|
|
- if (watcher != null) {
|
|
|
- watcher.Changed -= Watcher_Changed;
|
|
|
- watcher.Created -= Watcher_Changed;
|
|
|
- watcher.Deleted -= Watcher_Changed;
|
|
|
- watcher.Renamed -= Watcher_Changed;
|
|
|
- watcher.Error -= Watcher_Error;
|
|
|
- }
|
|
|
- watcher?.Dispose ();
|
|
|
- watcher = null;
|
|
|
+ if (splitContainer.Tiles.First ().ContentView.Visible && tableView.SelectedColumn == 0) {
|
|
|
+ this.NavigateIf (k, Key.CursorLeft, this.treeView);
|
|
|
}
|
|
|
|
|
|
- _disposedValue = true;
|
|
|
- }
|
|
|
+ if (k.Handled) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- // Call base class implementation.
|
|
|
- base.Dispose (disposing);
|
|
|
- }
|
|
|
+ if (this.tableView.HasFocus &&
|
|
|
+ !k.KeyEvent.Key.HasFlag (Key.CtrlMask) &&
|
|
|
+ !k.KeyEvent.Key.HasFlag (Key.AltMask) &&
|
|
|
+ char.IsLetterOrDigit ((char)k.KeyEvent.KeyValue)) {
|
|
|
+ CycleToNextTableEntryBeginningWith (k);
|
|
|
+ }
|
|
|
|
|
|
- void Watcher_Error (object sender, ErrorEventArgs e)
|
|
|
- {
|
|
|
- if (Application.MainLoop == null)
|
|
|
- return;
|
|
|
|
|
|
- Application.MainLoop.Invoke (() => Reload ());
|
|
|
- }
|
|
|
+ };
|
|
|
|
|
|
- void Watcher_Changed (object sender, FileSystemEventArgs e)
|
|
|
- {
|
|
|
- if (Application.MainLoop == null)
|
|
|
- return;
|
|
|
+ this.treeView = new TreeView<object> () {
|
|
|
+ Width = Dim.Fill (),
|
|
|
+ Height = Dim.Fill (),
|
|
|
+ };
|
|
|
|
|
|
- Application.MainLoop.Invoke (() => Reload ());
|
|
|
- }
|
|
|
+ this.treeView.TreeBuilder = new FileDialogTreeBuilder ();
|
|
|
+ this.treeView.AspectGetter = (m) => m is IDirectoryInfo d ? d.Name : m.ToString ();
|
|
|
+ this.Style.TreeStyle = treeView.Style;
|
|
|
|
|
|
- ustring directory;
|
|
|
- public ustring Directory {
|
|
|
- get => directory;
|
|
|
- set {
|
|
|
- if (directory == value) {
|
|
|
- return;
|
|
|
+ this.treeView.SelectionChanged += this.TreeView_SelectionChanged;
|
|
|
+
|
|
|
+ this.splitContainer.Tiles.ElementAt (0).ContentView.Add (this.treeView);
|
|
|
+ this.splitContainer.Tiles.ElementAt (1).ContentView.Add (this.tableView);
|
|
|
+
|
|
|
+ this.btnToggleSplitterCollapse = new Button (GetToggleSplitterText (false)) {
|
|
|
+ Y = Pos.AnchorEnd (1),
|
|
|
+ };
|
|
|
+ this.btnToggleSplitterCollapse.Clicked += (s, e) => {
|
|
|
+ var tile = this.splitContainer.Tiles.ElementAt (0);
|
|
|
+
|
|
|
+ var newState = !tile.ContentView.Visible;
|
|
|
+ tile.ContentView.Visible = newState;
|
|
|
+ this.btnToggleSplitterCollapse.Text = GetToggleSplitterText (newState);
|
|
|
+ this.LayoutSubviews();
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+ tbFind = new TextField {
|
|
|
+ X = Pos.Right (this.btnToggleSplitterCollapse) + 1,
|
|
|
+ Caption = Style.SearchCaption,
|
|
|
+ CaptionColor = Color.Black,
|
|
|
+ Width = 30,
|
|
|
+ Y = Pos.AnchorEnd (1),
|
|
|
+ };
|
|
|
+ spinnerView = new SpinnerView () {
|
|
|
+ X = Pos.Right (tbFind) + 1,
|
|
|
+ Y = Pos.AnchorEnd (1),
|
|
|
+ Visible = false,
|
|
|
+ };
|
|
|
+
|
|
|
+ tbFind.TextChanged += (s, o) => RestartSearch ();
|
|
|
+ tbFind.KeyPress += (s, o) => {
|
|
|
+ if (o.KeyEvent.Key == Key.Enter) {
|
|
|
+ RestartSearch ();
|
|
|
+ o.Handled = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if(o.KeyEvent.Key == Key.Esc) {
|
|
|
+ if(CancelSearch()) {
|
|
|
+ o.Handled = true;
|
|
|
+ }
|
|
|
}
|
|
|
- if (Reload (value)) {
|
|
|
- directory = value;
|
|
|
+ if(tbFind.CursorIsAtEnd()) {
|
|
|
+ NavigateIf (o, Key.CursorRight, btnCancel);
|
|
|
}
|
|
|
- }
|
|
|
- }
|
|
|
+ if (tbFind.CursorIsAtStart ()) {
|
|
|
+ NavigateIf (o, Key.CursorLeft, btnToggleSplitterCollapse);
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
- public override void PositionCursor ()
|
|
|
- {
|
|
|
- Move (0, selected - top);
|
|
|
- }
|
|
|
+ this.tableView.Style.ShowHorizontalHeaderOverline = true;
|
|
|
+ this.tableView.Style.ShowVerticalCellLines = true;
|
|
|
+ this.tableView.Style.ShowVerticalHeaderLines = true;
|
|
|
+ this.tableView.Style.AlwaysShowHeaders = true;
|
|
|
+ this.tableView.Style.ShowHorizontalHeaderUnderline = true;
|
|
|
+ this.tableView.Style.ShowHorizontalScrollIndicators = true;
|
|
|
|
|
|
- int lastSelected;
|
|
|
- bool shiftOnWheel;
|
|
|
- public override bool MouseEvent (MouseEvent me)
|
|
|
- {
|
|
|
- if ((me.Flags & (MouseFlags.Button1Clicked | MouseFlags.Button1DoubleClicked |
|
|
|
- MouseFlags.WheeledUp | MouseFlags.WheeledDown)) == 0)
|
|
|
- return false;
|
|
|
|
|
|
- if (!HasFocus)
|
|
|
- SetFocus ();
|
|
|
+ this.SetupTableColumns ();
|
|
|
|
|
|
- if (infos == null)
|
|
|
- return false;
|
|
|
+ this.sorter = new FileDialogSorter (this, this.tableView);
|
|
|
+ this.history = new FileDialogHistory (this);
|
|
|
|
|
|
- if (me.Y + top >= infos.Count)
|
|
|
- return true;
|
|
|
+ this.tableView.Table = this.dtFiles;
|
|
|
|
|
|
- int lastSelectedCopy = shiftOnWheel ? lastSelected : selected;
|
|
|
+ this.tbPath.TextChanged += (s, e) => this.PathChanged ();
|
|
|
|
|
|
- switch (me.Flags) {
|
|
|
- case MouseFlags.Button1Clicked:
|
|
|
- SetSelected (me);
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
- break;
|
|
|
- case MouseFlags.Button1DoubleClicked:
|
|
|
- UnMarkAll ();
|
|
|
- SetSelected (me);
|
|
|
- if (ExecuteSelection ()) {
|
|
|
- host.canceled = false;
|
|
|
- Application.RequestStop ();
|
|
|
+ this.tableView.CellActivated += this.CellActivate;
|
|
|
+ this.tableView.KeyUp += (s, k) => k.Handled = this.TableView_KeyUp (k.KeyEvent);
|
|
|
+ this.tableView.SelectedCellChanged += this.TableView_SelectedCellChanged;
|
|
|
+
|
|
|
+ this.tableView.AddKeyBinding (Key.Home, Command.TopHome);
|
|
|
+ this.tableView.AddKeyBinding (Key.End, Command.BottomEnd);
|
|
|
+ this.tableView.AddKeyBinding (Key.Home | Key.ShiftMask, Command.TopHomeExtend);
|
|
|
+ this.tableView.AddKeyBinding (Key.End | Key.ShiftMask, Command.BottomEndExtend);
|
|
|
+
|
|
|
+ this.treeView.KeyDown += (s, k) => {
|
|
|
+
|
|
|
+
|
|
|
+ var selected = treeView.SelectedObject;
|
|
|
+ if (selected != null) {
|
|
|
+ if (!treeView.CanExpand (selected) || treeView.IsExpanded (selected)) {
|
|
|
+ this.NavigateIf (k, Key.CursorRight, this.tableView);
|
|
|
+ } else
|
|
|
+ if (treeView.GetObjectRow (selected) == 0) {
|
|
|
+ this.NavigateIf (k, Key.CursorUp, this.tbPath);
|
|
|
+ }
|
|
|
}
|
|
|
- return true;
|
|
|
- case MouseFlags.Button1Clicked | MouseFlags.ButtonShift:
|
|
|
- SetSelected (me);
|
|
|
- if (shiftOnWheel)
|
|
|
- lastSelected = lastSelectedCopy;
|
|
|
- shiftOnWheel = false;
|
|
|
- PerformMultipleSelection (lastSelected);
|
|
|
- return true;
|
|
|
- case MouseFlags.Button1Clicked | MouseFlags.ButtonCtrl:
|
|
|
- SetSelected (me);
|
|
|
- PerformMultipleSelection ();
|
|
|
- return true;
|
|
|
- case MouseFlags.WheeledUp:
|
|
|
- SetSelected (me);
|
|
|
- selected = lastSelected;
|
|
|
- MoveUp ();
|
|
|
- return true;
|
|
|
- case MouseFlags.WheeledDown:
|
|
|
- SetSelected (me);
|
|
|
- selected = lastSelected;
|
|
|
- MoveDown ();
|
|
|
- return true;
|
|
|
- case MouseFlags.WheeledUp | MouseFlags.ButtonShift:
|
|
|
- SetSelected (me);
|
|
|
- selected = lastSelected;
|
|
|
- lastSelected = lastSelectedCopy;
|
|
|
- shiftOnWheel = true;
|
|
|
- MoveUp ();
|
|
|
- return true;
|
|
|
- case MouseFlags.WheeledDown | MouseFlags.ButtonShift:
|
|
|
- SetSelected (me);
|
|
|
- selected = lastSelected;
|
|
|
- lastSelected = lastSelectedCopy;
|
|
|
- shiftOnWheel = true;
|
|
|
- MoveDown ();
|
|
|
- return true;
|
|
|
- }
|
|
|
|
|
|
- return true;
|
|
|
+ if (k.Handled) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ k.Handled = this.TreeView_KeyDown (k.KeyEvent);
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.AllowsMultipleSelection = false;
|
|
|
+
|
|
|
+ this.UpdateNavigationVisibility ();
|
|
|
+
|
|
|
+ // Determines tab order
|
|
|
+ this.Add (this.btnToggleSplitterCollapse);
|
|
|
+ this.Add (this.tbFind);
|
|
|
+ this.Add (this.spinnerView);
|
|
|
+ this.Add (this.btnOk);
|
|
|
+ this.Add (this.btnCancel);
|
|
|
+ this.Add (this.btnUp);
|
|
|
+ this.Add (this.btnBack);
|
|
|
+ this.Add (this.btnForward);
|
|
|
+ this.Add (this.tbPath);
|
|
|
+ this.Add (this.splitContainer);
|
|
|
+
|
|
|
+ // Default sort order is by name
|
|
|
+ sorter.SortColumn(this.filenameColumn,true);
|
|
|
}
|
|
|
|
|
|
- void UnMarkAll ()
|
|
|
+ private string GetForwardButtonText ()
|
|
|
{
|
|
|
- for (int i = 0; i < infos.Count; i++) {
|
|
|
- if (infos [i].Item3) {
|
|
|
- infos [i] = (infos [i].Item1, infos [i].Item2, false);
|
|
|
- }
|
|
|
- }
|
|
|
+ return "-" + Driver.RightArrow;
|
|
|
}
|
|
|
|
|
|
- void SetSelected (MouseEvent me)
|
|
|
+ private string GetBackButtonText ()
|
|
|
{
|
|
|
- lastSelected = selected;
|
|
|
- selected = top + me.Y;
|
|
|
+ return Driver.LeftArrow + "-";
|
|
|
}
|
|
|
|
|
|
- void DrawString (int line, string str)
|
|
|
+ private string GetUpButtonText ()
|
|
|
{
|
|
|
- var f = Frame;
|
|
|
- var width = f.Width;
|
|
|
- var ustr = ustring.Make (str);
|
|
|
-
|
|
|
- Move (allowsMultipleSelection ? 3 : 2, line);
|
|
|
- int byteLen = ustr.Length;
|
|
|
- int used = allowsMultipleSelection ? 2 : 1;
|
|
|
- for (int i = 0; i < byteLen;) {
|
|
|
- (var rune, var size) = Utf8.DecodeRune (ustr, i, i - byteLen);
|
|
|
- var count = Rune.ColumnWidth (rune);
|
|
|
- if (used + count >= width)
|
|
|
- break;
|
|
|
- Driver.AddRune (rune);
|
|
|
- used += count;
|
|
|
- i += size;
|
|
|
- }
|
|
|
- for (; used < width - 1; used++) {
|
|
|
- Driver.AddRune (' ');
|
|
|
- }
|
|
|
+ return Style.UseUnicodeCharacters ? "◭" : "▲";
|
|
|
}
|
|
|
|
|
|
- public override void Redraw (Rect bounds)
|
|
|
+ private string GetToggleSplitterText (bool isExpanded)
|
|
|
{
|
|
|
- var current = ColorScheme.Focus;
|
|
|
- Driver.SetAttribute (current);
|
|
|
- Move (0, 0);
|
|
|
- var f = Frame;
|
|
|
- var item = top;
|
|
|
- bool focused = HasFocus;
|
|
|
- var width = bounds.Width;
|
|
|
-
|
|
|
- for (int row = 0; row < f.Height; row++, item++) {
|
|
|
- bool isSelected = item == selected;
|
|
|
- Move (0, row);
|
|
|
- var newcolor = focused ? (isSelected ? ColorScheme.HotNormal : ColorScheme.Focus)
|
|
|
- : Enabled ? ColorScheme.Focus : ColorScheme.Disabled;
|
|
|
- if (newcolor != current) {
|
|
|
- Driver.SetAttribute (newcolor);
|
|
|
- current = newcolor;
|
|
|
- }
|
|
|
- if (item >= infos.Count) {
|
|
|
- for (int c = 0; c < f.Width; c++)
|
|
|
- Driver.AddRune (' ');
|
|
|
- continue;
|
|
|
- }
|
|
|
- var fi = infos [item];
|
|
|
-
|
|
|
- Driver.AddRune (isSelected ? '>' : ' ');
|
|
|
+ return isExpanded ?
|
|
|
+ new string ((char)Driver.LeftArrow, 2) :
|
|
|
+ new string ((char)Driver.RightArrow, 2);
|
|
|
+ }
|
|
|
|
|
|
- if (allowsMultipleSelection)
|
|
|
- Driver.AddRune (fi.Item3 ? '*' : ' ');
|
|
|
+ private void Delete ()
|
|
|
+ {
|
|
|
+ var toDelete = GetFocusedFiles ();
|
|
|
|
|
|
- if (fi.Item2)
|
|
|
- Driver.AddRune ('/');
|
|
|
- else
|
|
|
- Driver.AddRune (' ');
|
|
|
- DrawString (row, fi.Item1);
|
|
|
+ if (toDelete != null && FileOperationsHandler.Delete (toDelete)) {
|
|
|
+ RefreshState ();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public Action<(string, bool)> SelectedChanged { get; set; }
|
|
|
- public Action<ustring> DirectoryChanged { get; set; }
|
|
|
- public Action<ustring> FileChanged { get; set; }
|
|
|
+ private void Rename ()
|
|
|
+ {
|
|
|
+ var toRename = GetFocusedFiles ();
|
|
|
|
|
|
- string splitString = ",";
|
|
|
+ if (toRename?.Length == 1) {
|
|
|
+ var newNamed = FileOperationsHandler.Rename (this.fileSystem, toRename.Single ());
|
|
|
|
|
|
- void OnSelectionChanged ()
|
|
|
- {
|
|
|
- if (allowsMultipleSelection) {
|
|
|
- if (FilePaths.Count > 0) {
|
|
|
- FileChanged?.Invoke (string.Join (splitString, GetFilesName (FilePaths)));
|
|
|
- } else {
|
|
|
- FileChanged?.Invoke (infos [selected].Item2 && !canChooseDirectories ? "" : Path.GetFileName (infos [selected].Item1));
|
|
|
+ if (newNamed != null) {
|
|
|
+ RefreshState ();
|
|
|
+ RestoreSelection (newNamed);
|
|
|
}
|
|
|
- } else {
|
|
|
- var sel = infos [selected];
|
|
|
- SelectedChanged?.Invoke ((sel.Item1, sel.Item2));
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- List<string> GetFilesName (IReadOnlyList<string> files)
|
|
|
+ private void New ()
|
|
|
{
|
|
|
- List<string> filesName = new List<string> ();
|
|
|
-
|
|
|
- foreach (var file in files) {
|
|
|
- filesName.Add (Path.GetFileName (file));
|
|
|
+ if (State != null) {
|
|
|
+ var created = FileOperationsHandler.New (this.fileSystem, State.Directory);
|
|
|
+ if (created != null) {
|
|
|
+ RefreshState ();
|
|
|
+ RestoreSelection (created);
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
- return filesName;
|
|
|
}
|
|
|
-
|
|
|
- public bool GetValidFilesName (string files, out string result)
|
|
|
+ private IFileSystemInfo [] GetFocusedFiles ()
|
|
|
{
|
|
|
- result = string.Empty;
|
|
|
- if (infos?.Count == 0) {
|
|
|
- return false;
|
|
|
+
|
|
|
+ if (!tableView.HasFocus || !tableView.CanFocus || FileOperationsHandler == null) {
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- var valid = true;
|
|
|
- IReadOnlyList<string> filesList = new List<string> (files.Split (splitString.ToArray (), StringSplitOptions.None));
|
|
|
- var filesName = new List<string> ();
|
|
|
- UnMarkAll ();
|
|
|
+ tableView.EnsureValidSelection ();
|
|
|
|
|
|
- foreach (var file in filesList) {
|
|
|
- if (!allowsMultipleSelection && filesName.Count > 0) {
|
|
|
- break;
|
|
|
- }
|
|
|
- var idx = infos.IndexOf (x => x.Item1.IndexOf (file, StringComparison.OrdinalIgnoreCase) >= 0);
|
|
|
- if (idx > -1 && string.Equals (infos [idx].Item1, file, StringComparison.OrdinalIgnoreCase)) {
|
|
|
- if (canChooseDirectories && !canChooseFiles && !infos [idx].Item2) {
|
|
|
- valid = false;
|
|
|
- }
|
|
|
- if (allowsMultipleSelection && !infos [idx].Item3) {
|
|
|
- infos [idx] = (infos [idx].Item1, infos [idx].Item2, true);
|
|
|
- }
|
|
|
- if (!allowsMultipleSelection) {
|
|
|
- selected = idx;
|
|
|
- }
|
|
|
- filesName.Add (Path.GetFileName (infos [idx].Item1));
|
|
|
- } else if (idx > -1) {
|
|
|
- valid = false;
|
|
|
- filesName.Add (Path.GetFileName (file));
|
|
|
- }
|
|
|
- }
|
|
|
- result = string.Join (splitString, filesName);
|
|
|
- if (string.IsNullOrEmpty (result)) {
|
|
|
- valid = false;
|
|
|
+ if (tableView.SelectedRow < 0) {
|
|
|
+ return null;
|
|
|
}
|
|
|
- return valid;
|
|
|
+
|
|
|
+ return tableView.GetAllSelectedCells ()
|
|
|
+ .Select (c => c.Y)
|
|
|
+ .Distinct ()
|
|
|
+ .Select (RowToStats)
|
|
|
+ .Where (s => !s.IsParent)
|
|
|
+ .Select (d => d.FileSystemInfo)
|
|
|
+ .ToArray ();
|
|
|
}
|
|
|
|
|
|
- public override bool ProcessKey (KeyEvent keyEvent)
|
|
|
- {
|
|
|
- switch (keyEvent.Key) {
|
|
|
- case Key.CursorUp:
|
|
|
- case Key.P | Key.CtrlMask:
|
|
|
- MoveUp ();
|
|
|
- return true;
|
|
|
|
|
|
- case Key.CursorDown:
|
|
|
- case Key.N | Key.CtrlMask:
|
|
|
- MoveDown ();
|
|
|
+
|
|
|
+ /// <inheritdoc/>
|
|
|
+ public override bool ProcessHotKey (KeyEvent keyEvent)
|
|
|
+ {
|
|
|
+ if (this.NavigateIf (keyEvent, Key.CtrlMask | Key.F, this.tbFind)) {
|
|
|
return true;
|
|
|
+ }
|
|
|
|
|
|
- case Key.V | Key.CtrlMask:
|
|
|
- case Key.PageDown:
|
|
|
- var n = (selected + Frame.Height);
|
|
|
- if (n > infos.Count)
|
|
|
- n = infos.Count - 1;
|
|
|
- if (n != selected) {
|
|
|
- selected = n;
|
|
|
- if (infos.Count >= Frame.Height)
|
|
|
- top = selected;
|
|
|
- else
|
|
|
- top = 0;
|
|
|
- OnSelectionChanged ();
|
|
|
+ ClearFeedback ();
|
|
|
|
|
|
- SetNeedsDisplay ();
|
|
|
- }
|
|
|
- return true;
|
|
|
+ if (allowedTypeMenuBar != null &&
|
|
|
+ keyEvent.Key == Key.Tab &&
|
|
|
+ allowedTypeMenuBar.IsMenuOpen) {
|
|
|
+ allowedTypeMenuBar.CloseMenu (false, false, false);
|
|
|
+ }
|
|
|
|
|
|
- case Key.Enter:
|
|
|
- UnMarkAll ();
|
|
|
- if (ExecuteSelection ())
|
|
|
- return false;
|
|
|
- else
|
|
|
- return true;
|
|
|
-
|
|
|
- case Key.PageUp:
|
|
|
- n = (selected - Frame.Height);
|
|
|
- if (n < 0)
|
|
|
- n = 0;
|
|
|
- if (n != selected) {
|
|
|
- selected = n;
|
|
|
- top = selected;
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
- }
|
|
|
- return true;
|
|
|
+ return base.ProcessHotKey (keyEvent);
|
|
|
+ }
|
|
|
+ private void RestartSearch ()
|
|
|
+ {
|
|
|
+ if (disposed || State?.Directory == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- case Key.Space:
|
|
|
- case Key.T | Key.CtrlMask:
|
|
|
- PerformMultipleSelection ();
|
|
|
- return true;
|
|
|
+ if (State is SearchState oldSearch) {
|
|
|
+ oldSearch.Cancel ();
|
|
|
+ }
|
|
|
|
|
|
- case Key.Home:
|
|
|
- MoveFirst ();
|
|
|
- return true;
|
|
|
+ // user is clearing search terms
|
|
|
+ if (tbFind.Text == null || tbFind.Text.Length == 0) {
|
|
|
|
|
|
- case Key.End:
|
|
|
- MoveLast ();
|
|
|
- return true;
|
|
|
+ // Wait for search cancellation (if any) to finish
|
|
|
+ // then push the current dir state
|
|
|
+ lock (onlyOneSearchLock) {
|
|
|
+ PushState (new FileDialogState (State.Directory, this), false);
|
|
|
+ }
|
|
|
+ return;
|
|
|
}
|
|
|
- return base.ProcessKey (keyEvent);
|
|
|
- }
|
|
|
|
|
|
- void MoveLast ()
|
|
|
- {
|
|
|
- selected = infos.Count - 1;
|
|
|
- top = infos.Count () - 1;
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
+ PushState (new SearchState (State?.Directory, this, tbFind.Text.ToString ()), true);
|
|
|
}
|
|
|
|
|
|
- void MoveFirst ()
|
|
|
+ /// <inheritdoc/>
|
|
|
+ protected override void Dispose (bool disposing)
|
|
|
{
|
|
|
- selected = 0;
|
|
|
- top = 0;
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
+ disposed = true;
|
|
|
+ base.Dispose (disposing);
|
|
|
+
|
|
|
+ CancelSearch ();
|
|
|
}
|
|
|
|
|
|
- void MoveDown ()
|
|
|
+ private bool CancelSearch ()
|
|
|
{
|
|
|
- if (selected + 1 < infos.Count) {
|
|
|
- selected++;
|
|
|
- if (selected >= top + Frame.Height)
|
|
|
- top++;
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
+ if (State is SearchState search) {
|
|
|
+ return search.Cancel ();
|
|
|
}
|
|
|
+
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- void MoveUp ()
|
|
|
+ private void ClearFeedback ()
|
|
|
{
|
|
|
- if (selected > 0) {
|
|
|
- selected--;
|
|
|
- if (selected < top)
|
|
|
- top = selected;
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
- }
|
|
|
+ feedback = null;
|
|
|
}
|
|
|
|
|
|
- internal bool ExecuteSelection (bool navigateFolder = true)
|
|
|
+ private void CycleToNextTableEntryBeginningWith (KeyEventEventArgs keyEvent)
|
|
|
{
|
|
|
- if (infos.Count == 0) {
|
|
|
- return false;
|
|
|
+ if (tableView.Table.Rows.Count == 0) {
|
|
|
+ return;
|
|
|
}
|
|
|
- var isDir = infos [selected].Item2;
|
|
|
|
|
|
- if (isDir) {
|
|
|
- Directory = Path.GetFullPath (Path.Combine (Path.GetFullPath (Directory.ToString ()), infos [selected].Item1));
|
|
|
- DirectoryChanged?.Invoke (Directory);
|
|
|
- if (canChooseDirectories && !navigateFolder) {
|
|
|
- return true;
|
|
|
- }
|
|
|
- } else {
|
|
|
- OnSelectionChanged ();
|
|
|
- if (canChooseFiles) {
|
|
|
- // Ensures that at least one file is selected.
|
|
|
- if (FilePaths.Count == 0)
|
|
|
- PerformMultipleSelection ();
|
|
|
- // Let the OK handler take it over
|
|
|
- return true;
|
|
|
- }
|
|
|
- // No files allowed, do not let the default handler take it.
|
|
|
- }
|
|
|
- return false;
|
|
|
- }
|
|
|
+ var row = tableView.SelectedRow;
|
|
|
|
|
|
- void PerformMultipleSelection (int? firstSelected = null)
|
|
|
- {
|
|
|
- if (allowsMultipleSelection) {
|
|
|
- int first = Math.Min (firstSelected ?? selected, selected);
|
|
|
- int last = Math.Max (selected, firstSelected ?? selected);
|
|
|
- for (int i = first; i <= last; i++) {
|
|
|
- if ((canChooseFiles && infos [i].Item2 == false) ||
|
|
|
- (canChooseDirectories && infos [i].Item2 &&
|
|
|
- infos [i].Item1 != "..")) {
|
|
|
- infos [i] = (infos [i].Item1, infos [i].Item2, !infos [i].Item3);
|
|
|
- }
|
|
|
- }
|
|
|
- OnSelectionChanged ();
|
|
|
- SetNeedsDisplay ();
|
|
|
+ // There is a multi select going on and not just for the current row
|
|
|
+ if (tableView.GetAllSelectedCells ().Any (c => c.Y != row)) {
|
|
|
+ return;
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- string [] allowedFileTypes;
|
|
|
- public string [] AllowedFileTypes {
|
|
|
- get => allowedFileTypes;
|
|
|
- set {
|
|
|
- allowedFileTypes = value;
|
|
|
- Reload ();
|
|
|
+ int match = collectionNavigator.GetNextMatchingItem (row, (char)keyEvent.KeyEvent.KeyValue);
|
|
|
+
|
|
|
+ if (match != -1) {
|
|
|
+ tableView.SelectedRow = match;
|
|
|
+ tableView.EnsureValidSelection ();
|
|
|
+ tableView.EnsureSelectedCellIsVisible ();
|
|
|
+ keyEvent.Handled = true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- public string MakePath (string relativePath)
|
|
|
+ private void UpdateCollectionNavigator ()
|
|
|
{
|
|
|
- var dir = Directory.ToString ();
|
|
|
- return string.IsNullOrEmpty (dir) ? "" : Path.GetFullPath (Path.Combine (dir, relativePath));
|
|
|
- }
|
|
|
|
|
|
- public IReadOnlyList<string> FilePaths {
|
|
|
- get {
|
|
|
- if (allowsMultipleSelection) {
|
|
|
- var res = new List<string> ();
|
|
|
- foreach (var item in infos) {
|
|
|
- if (item.Item3)
|
|
|
- res.Add (MakePath (item.Item1));
|
|
|
- }
|
|
|
- if (res.Count == 0 && infos.Count > 0 && infos [selected].Item1 != "..") {
|
|
|
- res.Add (MakePath (infos [selected].Item1));
|
|
|
- }
|
|
|
- return res;
|
|
|
- } else {
|
|
|
- if (infos.Count == 0) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- if (infos [selected].Item2) {
|
|
|
- if (canChooseDirectories) {
|
|
|
- var sel = infos [selected].Item1;
|
|
|
- return sel == ".." ? new List<string> () : new List<string> () { MakePath (infos [selected].Item1) };
|
|
|
- }
|
|
|
- return Array.Empty<string> ();
|
|
|
- } else {
|
|
|
- if (canChooseFiles) {
|
|
|
- return new List<string> () { MakePath (infos [selected].Item1) };
|
|
|
- }
|
|
|
- return Array.Empty<string> ();
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ var collection = tableView
|
|
|
+ .Table
|
|
|
+ .Rows
|
|
|
+ .Cast<DataRow> ()
|
|
|
+ .Select ((o, idx) => RowToStats (idx))
|
|
|
+ .Select (s => s.FileSystemInfo.Name)
|
|
|
+ .ToArray ();
|
|
|
+
|
|
|
+ collectionNavigator = new CollectionNavigator (collection);
|
|
|
}
|
|
|
|
|
|
- ///<inheritdoc/>
|
|
|
- public override bool OnEnter (View view)
|
|
|
- {
|
|
|
- Application.Driver.SetCursorVisibility (CursorVisibility.Invisible);
|
|
|
+ /// <summary>
|
|
|
+ /// Gets or Sets which <see cref="System.IO.FileSystemInfo"/> type can be selected.
|
|
|
+ /// Defaults to <see cref="OpenMode.Mixed"/> (i.e. <see cref="DirectoryInfo"/> or
|
|
|
+ /// <see cref="FileInfo"/>).
|
|
|
+ /// </summary>
|
|
|
+ public OpenMode OpenMode { get; set; } = OpenMode.Mixed;
|
|
|
|
|
|
- return base.OnEnter (view);
|
|
|
+ /// <summary>
|
|
|
+ /// Gets or Sets the selected path in the dialog. This is the result that should
|
|
|
+ /// be used if <see cref="AllowsMultipleSelection"/> is off and <see cref="Canceled"/>
|
|
|
+ /// is true.
|
|
|
+ /// </summary>
|
|
|
+ public string Path {
|
|
|
+ get => this.tbPath.Text.ToString ();
|
|
|
+ set {
|
|
|
+ this.tbPath.Text = value;
|
|
|
+ this.tbPath.MoveEnd ();
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Base class for the <see cref="OpenDialog"/> and the <see cref="SaveDialog"/>
|
|
|
- /// </summary>
|
|
|
- public class FileDialog : Dialog {
|
|
|
- Button prompt, cancel;
|
|
|
- Label nameFieldLabel, message, nameDirLabel;
|
|
|
- TextField dirEntry, nameEntry;
|
|
|
- internal DirListView dirListView;
|
|
|
- ComboBox cmbAllowedTypes;
|
|
|
+ /// <summary>
|
|
|
+ /// Defines how the dialog matches files/folders when using the search
|
|
|
+ /// box. Provide a custom implementation if you want to tailor how matching
|
|
|
+ /// is performed.
|
|
|
+ /// </summary>
|
|
|
+ public ISearchMatcher SearchMatcher { get; set; } = new DefaultSearchMatcher ();
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new <see cref="FileDialog"/>.
|
|
|
+ /// Gets or Sets a value indicating whether to allow selecting
|
|
|
+ /// multiple existing files/directories. Defaults to false.
|
|
|
/// </summary>
|
|
|
- public FileDialog () : this (title: string.Empty, prompt: string.Empty,
|
|
|
- nameFieldLabel: string.Empty, message: string.Empty)
|
|
|
- { }
|
|
|
+ public bool AllowsMultipleSelection {
|
|
|
+ get => this.tableView.MultiSelect;
|
|
|
+ set => this.tableView.MultiSelect = value;
|
|
|
+ }
|
|
|
+
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new instance of <see cref="FileDialog"/>
|
|
|
+ /// Gets or Sets a collection of file types that the user can/must select. Only applies
|
|
|
+ /// when <see cref="OpenMode"/> is <see cref="OpenMode.File"/> or <see cref="OpenMode.Mixed"/>.
|
|
|
/// </summary>
|
|
|
- /// <param name="title">The title.</param>
|
|
|
- /// <param name="prompt">The prompt.</param>
|
|
|
- /// <param name="nameFieldLabel">The name of the file field label..</param>
|
|
|
- /// <param name="message">The message.</param>
|
|
|
- /// <param name="allowedTypes">The allowed types.</param>
|
|
|
- public FileDialog (ustring title, ustring prompt, ustring nameFieldLabel, ustring message, List<string> allowedTypes = null)
|
|
|
- : this (title, prompt, ustring.Empty, nameFieldLabel, message, allowedTypes) { }
|
|
|
+ /// <remarks><see cref="AllowedTypeAny"/> adds the option to select any type (*.*). If this
|
|
|
+ /// collection is empty then any type is supported and no Types drop-down is shown.</remarks>
|
|
|
+ public List<IAllowedType> AllowedTypes { get; set; } = new List<IAllowedType> ();
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new instance of <see cref="FileDialog"/>
|
|
|
+ /// Gets a value indicating whether the <see cref="FileDialog"/> was closed
|
|
|
+ /// without confirming a selection.
|
|
|
/// </summary>
|
|
|
- /// <param name="title">The title.</param>
|
|
|
- /// <param name="prompt">The prompt.</param>
|
|
|
- /// <param name="message">The message.</param>
|
|
|
- /// <param name="allowedTypes">The allowed types.</param>
|
|
|
- public FileDialog (ustring title, ustring prompt, ustring message, List<string> allowedTypes)
|
|
|
- : this (title, prompt, ustring.Empty, message, allowedTypes) { }
|
|
|
+ public bool Canceled { get; private set; } = true;
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new instance of <see cref="FileDialog"/>
|
|
|
+ /// Gets all files/directories selected or an empty collection
|
|
|
+ /// <see cref="AllowsMultipleSelection"/> is <see langword="false"/> or <see cref="Canceled"/>.
|
|
|
/// </summary>
|
|
|
- /// <param name="title">The title.</param>
|
|
|
- /// <param name="prompt">The prompt.</param>
|
|
|
- /// <param name="nameDirLabel">The name of the directory field label.</param>
|
|
|
- /// <param name="nameFieldLabel">The name of the file field label..</param>
|
|
|
- /// <param name="message">The message.</param>
|
|
|
- /// <param name="allowedTypes">The allowed types.</param>
|
|
|
- public FileDialog (ustring title, ustring prompt, ustring nameDirLabel, ustring nameFieldLabel, ustring message,
|
|
|
- List<string> allowedTypes = null) : base (title)//, Driver.Cols - 20, Driver.Rows - 5, null)
|
|
|
+ /// <remarks>If selecting only a single file/directory then you should use <see cref="Path"/> instead.</remarks>
|
|
|
+ public IReadOnlyList<string> MultiSelected { get; private set; }
|
|
|
+ = Enumerable.Empty<string> ().ToList ().AsReadOnly ();
|
|
|
+
|
|
|
+
|
|
|
+ /// <inheritdoc/>
|
|
|
+ public override void Redraw (Rect bounds)
|
|
|
{
|
|
|
- this.message = new Label (message) {
|
|
|
- X = 1,
|
|
|
- Y = 0,
|
|
|
- };
|
|
|
- Add (this.message);
|
|
|
- var msgLines = TextFormatter.MaxLines (message, Driver.Cols - 20);
|
|
|
+ base.Redraw (bounds);
|
|
|
|
|
|
- this.nameDirLabel = new Label (nameDirLabel.IsEmpty ? $"{Strings.fdDirectory}: " : $"{nameDirLabel}: ") {
|
|
|
- X = 1,
|
|
|
- Y = 1 + msgLines,
|
|
|
- AutoSize = true
|
|
|
- };
|
|
|
+ if (!string.IsNullOrWhiteSpace (feedback)) {
|
|
|
+ var feedbackWidth = feedback.Sum (c => Rune.ColumnWidth (c));
|
|
|
+ var feedbackPadLeft = ((bounds.Width - feedbackWidth) / 2) - 1;
|
|
|
|
|
|
- dirEntry = new TextField ("") {
|
|
|
- X = Pos.Right (this.nameDirLabel),
|
|
|
- Y = 1 + msgLines,
|
|
|
- Width = Dim.Fill () - 1,
|
|
|
- };
|
|
|
- dirEntry.TextChanged += (s, e) => {
|
|
|
- DirectoryPath = dirEntry.Text;
|
|
|
- nameEntry.Text = ustring.Empty;
|
|
|
- };
|
|
|
- Add (this.nameDirLabel, dirEntry);
|
|
|
+ feedbackPadLeft = Math.Min (bounds.Width, feedbackPadLeft);
|
|
|
+ feedbackPadLeft = Math.Max (0, feedbackPadLeft);
|
|
|
|
|
|
- this.nameFieldLabel = new Label (nameFieldLabel.IsEmpty ? $"{Strings.fdFile}: " : $"{nameFieldLabel}: ") {
|
|
|
- X = 1,
|
|
|
- Y = 3 + msgLines,
|
|
|
- AutoSize = true
|
|
|
- };
|
|
|
- nameEntry = new TextField ("") {
|
|
|
- X = Pos.Left (dirEntry),
|
|
|
- Y = 3 + msgLines,
|
|
|
- Width = Dim.Percent (70, true)
|
|
|
- };
|
|
|
- Add (this.nameFieldLabel, nameEntry);
|
|
|
-
|
|
|
- cmbAllowedTypes = new ComboBox () {
|
|
|
- X = Pos.Right (nameEntry) + 2,
|
|
|
- Y = Pos.Top (nameEntry),
|
|
|
- Width = Dim.Fill (1),
|
|
|
- Height = SetComboBoxHeight (allowedTypes),
|
|
|
- Text = allowedTypes?.Count > 0 ? allowedTypes [0] : string.Empty,
|
|
|
- SelectedItem = allowedTypes?.Count > 0 ? 0 : -1,
|
|
|
- ReadOnly = true,
|
|
|
- HideDropdownListOnClick = true
|
|
|
- };
|
|
|
- cmbAllowedTypes.SetSource (allowedTypes ?? new List<string> ());
|
|
|
- cmbAllowedTypes.OpenSelectedItem += (s, e) => {
|
|
|
- dirListView.AllowedFileTypes = cmbAllowedTypes.Text.ToString ().Split (';');
|
|
|
- dirListView.Reload ();
|
|
|
- };
|
|
|
- Add (cmbAllowedTypes);
|
|
|
+ var feedbackPadRight = bounds.Width - (feedbackPadLeft + feedbackWidth + 2);
|
|
|
+ feedbackPadRight = Math.Min (bounds.Width, feedbackPadRight);
|
|
|
+ feedbackPadRight = Math.Max (0, feedbackPadRight);
|
|
|
|
|
|
- dirListView = new DirListView (this) {
|
|
|
- X = 1,
|
|
|
- Y = 3 + msgLines + 2,
|
|
|
- Width = Dim.Fill () - 1,
|
|
|
- Height = Dim.Fill () - 2,
|
|
|
- };
|
|
|
- DirectoryPath = Path.GetFullPath (Environment.CurrentDirectory);
|
|
|
- Add (dirListView);
|
|
|
-
|
|
|
- AllowedFileTypes = allowedTypes?.Count > 0 ? allowedTypes?.ToArray () : null;
|
|
|
- dirListView.DirectoryChanged = (dir) => { nameEntry.Text = ustring.Empty; dirEntry.Text = dir; };
|
|
|
- dirListView.FileChanged = (file) => nameEntry.Text = file == ".." ? "" : file;
|
|
|
- dirListView.SelectedChanged = (file) => nameEntry.Text = file.Item1 == ".." ? "" : file.Item1;
|
|
|
- this.cancel = new Button ("Cancel");
|
|
|
- this.cancel.Clicked += (s,e) => {
|
|
|
- Cancel ();
|
|
|
- };
|
|
|
- AddButton (cancel);
|
|
|
+ Move (0, Bounds.Height / 2);
|
|
|
|
|
|
- this.prompt = new Button (prompt.IsEmpty ? "Ok" : prompt) {
|
|
|
- IsDefault = true,
|
|
|
- Enabled = nameEntry.Text.IsEmpty ? false : true
|
|
|
- };
|
|
|
- this.prompt.Clicked += (s,e) => {
|
|
|
- if (this is OpenDialog) {
|
|
|
- if (!dirListView.GetValidFilesName (nameEntry.Text.ToString (), out string res)) {
|
|
|
- nameEntry.Text = res;
|
|
|
- dirListView.SetNeedsDisplay ();
|
|
|
- return;
|
|
|
- }
|
|
|
- if (!dirListView.canChooseDirectories && !dirListView.ExecuteSelection (false)) {
|
|
|
- return;
|
|
|
- }
|
|
|
- } else if (this is SaveDialog) {
|
|
|
- var name = nameEntry.Text.ToString ();
|
|
|
- if (FilePath.IsEmpty || name.Split (',').Length > 1) {
|
|
|
- return;
|
|
|
- }
|
|
|
- var ext = name.EndsWith (cmbAllowedTypes.Text.ToString ())
|
|
|
- ? "" : cmbAllowedTypes.Text.ToString ();
|
|
|
- FilePath = Path.Combine (FilePath.ToString (), $"{name}{ext}");
|
|
|
- }
|
|
|
- canceled = false;
|
|
|
- Application.RequestStop ();
|
|
|
- };
|
|
|
- AddButton (this.prompt);
|
|
|
+ Driver.SetAttribute (new Attribute (Color.Red, this.ColorScheme.Normal.Background));
|
|
|
+ Driver.AddStr (new string (' ', feedbackPadLeft));
|
|
|
+ Driver.AddStr (feedback);
|
|
|
+ Driver.AddStr (new string (' ', feedbackPadRight));
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- nameEntry.TextChanged += (s,e) => {
|
|
|
- if (nameEntry.Text.IsEmpty) {
|
|
|
- this.prompt.Enabled = false;
|
|
|
- } else {
|
|
|
- this.prompt.Enabled = true;
|
|
|
- }
|
|
|
- };
|
|
|
+ /// <inheritdoc/>
|
|
|
+ public override void OnLoaded ()
|
|
|
+ {
|
|
|
+ base.OnLoaded ();
|
|
|
+ if (loaded) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ loaded = true;
|
|
|
+
|
|
|
+ // May have been updated after instance was constructed
|
|
|
+ this.btnOk.Text = Style.OkButtonText;
|
|
|
+ this.btnUp.Text = this.GetUpButtonText();
|
|
|
+ this.btnBack.Text = this.GetBackButtonText();
|
|
|
+ this.btnForward.Text = this.GetForwardButtonText();
|
|
|
+ this.btnToggleSplitterCollapse.Text = this.GetToggleSplitterText(false);
|
|
|
+
|
|
|
+ tbPath.Autocomplete.ColorScheme.Normal = Attribute.Make (Color.Black, tbPath.ColorScheme.Normal.Background);
|
|
|
+
|
|
|
+ treeView.AddObjects (Style.TreeRootGetter ());
|
|
|
+
|
|
|
+ // if filtering on file type is configured then create the ComboBox and establish
|
|
|
+ // initial filtering by extension(s)
|
|
|
+ if (this.AllowedTypes.Any ()) {
|
|
|
+
|
|
|
+ this.CurrentFilter = this.AllowedTypes [0];
|
|
|
+
|
|
|
+ // Fiddle factor
|
|
|
+ var width = this.AllowedTypes.Max (a => a.ToString ().Length) + 6;
|
|
|
+
|
|
|
+ allowedTypeMenu = new MenuBarItem ("<placeholder>",
|
|
|
+ allowedTypeMenuItems = AllowedTypes.Select (
|
|
|
+ (a, i) => new MenuItem (a.ToString (), null, () => {
|
|
|
+ AllowedTypeMenuClicked (i);
|
|
|
+ }))
|
|
|
+ .ToArray ());
|
|
|
+
|
|
|
+ allowedTypeMenuBar = new MenuBar (new [] { allowedTypeMenu }) {
|
|
|
+ Width = width,
|
|
|
+ Y = 1,
|
|
|
+ X = Pos.AnchorEnd (width),
|
|
|
+
|
|
|
+ // TODO: Does not work, if this worked then we could tab to it instead
|
|
|
+ // of having to hit F9
|
|
|
+ CanFocus = true,
|
|
|
+ TabStop = true
|
|
|
+ };
|
|
|
+ AllowedTypeMenuClicked (0);
|
|
|
+
|
|
|
+ allowedTypeMenuBar.Enter += (s, e) => {
|
|
|
+ allowedTypeMenuBar.OpenMenu (0);
|
|
|
+ };
|
|
|
+
|
|
|
+ allowedTypeMenuBar.DrawContentComplete += (s, e) => {
|
|
|
+
|
|
|
+ allowedTypeMenuBar.Move (e.Rect.Width - 1, 0);
|
|
|
+ Driver.AddRune (Driver.DownArrow);
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ this.Add (allowedTypeMenuBar);
|
|
|
+ }
|
|
|
|
|
|
- Width = Dim.Percent (80);
|
|
|
- Height = Dim.Percent (80);
|
|
|
+ // if no path has been provided
|
|
|
+ if (this.tbPath.Text.Length <= 0) {
|
|
|
+ this.tbPath.Text = Environment.CurrentDirectory;
|
|
|
+ }
|
|
|
|
|
|
- // On success, we will set this to false.
|
|
|
- canceled = true;
|
|
|
+ // to streamline user experience and allow direct typing of paths
|
|
|
+ // with zero navigation we start with focus in the text box and any
|
|
|
+ // default/current path fully selected and ready to be overwritten
|
|
|
+ this.tbPath.FocusFirst ();
|
|
|
+ this.tbPath.SelectAll ();
|
|
|
|
|
|
- KeyPress += (s, e) => {
|
|
|
- if (e.KeyEvent.Key == Key.Esc) {
|
|
|
- Cancel ();
|
|
|
- e.Handled = true;
|
|
|
+ if (ustring.IsNullOrEmpty (Title)) {
|
|
|
+ switch (OpenMode) {
|
|
|
+ case OpenMode.File:
|
|
|
+ this.Title = $"{Strings.fdOpen} {(MustExist ? Strings.fdExisting + " " : "")}{Strings.fdFile}";
|
|
|
+ break;
|
|
|
+ case OpenMode.Directory:
|
|
|
+ this.Title = $"{Strings.fdOpen} {(MustExist ? Strings.fdExisting + " " : "")}{Strings.fdDirectory}";
|
|
|
+ break;
|
|
|
+ case OpenMode.Mixed:
|
|
|
+ this.Title = $"{Strings.fdOpen} {(MustExist ? Strings.fdExisting : "")}";
|
|
|
+ break;
|
|
|
}
|
|
|
- };
|
|
|
- void Cancel ()
|
|
|
- {
|
|
|
- canceled = true;
|
|
|
- Application.RequestStop ();
|
|
|
}
|
|
|
+ this.LayoutSubviews ();
|
|
|
}
|
|
|
|
|
|
- private static int SetComboBoxHeight (List<string> allowedTypes)
|
|
|
+ private void AllowedTypeMenuClicked (int idx)
|
|
|
{
|
|
|
- return allowedTypes != null ? Math.Min (allowedTypes.Count + 1, 8) : 8;
|
|
|
+
|
|
|
+ var allow = AllowedTypes [idx];
|
|
|
+ for (int i = 0; i < AllowedTypes.Count; i++) {
|
|
|
+ allowedTypeMenuItems [i].Checked = i == idx;
|
|
|
+ }
|
|
|
+ allowedTypeMenu.Title = allow.ToString ();
|
|
|
+
|
|
|
+ this.CurrentFilter = allow;
|
|
|
+
|
|
|
+ this.tbPath.ClearAllSelection ();
|
|
|
+ this.tbPath.Autocomplete.ClearSuggestions ();
|
|
|
+
|
|
|
+ if (this.State != null) {
|
|
|
+ this.State.RefreshChildren ();
|
|
|
+ this.WriteStateToTableView ();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- internal bool canceled;
|
|
|
+ private void SuppressIfBadChar (KeyEventEventArgs k)
|
|
|
+ {
|
|
|
+ // don't let user type bad letters
|
|
|
+ var ch = (char)k.KeyEvent.KeyValue;
|
|
|
|
|
|
- ///<inheritdoc/>
|
|
|
- public override void WillPresent ()
|
|
|
+ if (badChars.Contains (ch)) {
|
|
|
+ k.Handled = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private bool TreeView_KeyDown (KeyEvent keyEvent)
|
|
|
{
|
|
|
- base.WillPresent ();
|
|
|
- dirListView.SetFocus ();
|
|
|
+ if (this.treeView.HasFocus && Separators.Contains ((char)keyEvent.KeyValue)) {
|
|
|
+ this.tbPath.FocusFirst ();
|
|
|
+
|
|
|
+ // let that keystroke go through on the tbPath instead
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- //protected override void Dispose (bool disposing)
|
|
|
- //{
|
|
|
- // message?.Dispose ();
|
|
|
- // base.Dispose (disposing);
|
|
|
- //}
|
|
|
+ private void AcceptIf (KeyEventEventArgs keyEvent, Key isKey)
|
|
|
+ {
|
|
|
+ if (!keyEvent.Handled && keyEvent.KeyEvent.Key == isKey) {
|
|
|
+ keyEvent.Handled = true;
|
|
|
+ this.Accept ();
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets the prompt label for the <see cref="Button"/> displayed to the user
|
|
|
- /// </summary>
|
|
|
- /// <value>The prompt.</value>
|
|
|
- public ustring Prompt {
|
|
|
- get => prompt.Text;
|
|
|
- set {
|
|
|
- prompt.Text = value;
|
|
|
+ private void Accept (IEnumerable<FileSystemInfoStats> toMultiAccept)
|
|
|
+ {
|
|
|
+ if (!this.AllowsMultipleSelection) {
|
|
|
+ return;
|
|
|
}
|
|
|
+
|
|
|
+ this.MultiSelected = toMultiAccept.Select (s => s.FileSystemInfo.FullName).ToList ().AsReadOnly ();
|
|
|
+ this.tbPath.Text = this.MultiSelected.Count == 1 ? this.MultiSelected [0] : string.Empty;
|
|
|
+
|
|
|
+ FinishAccept ();
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets the name of the directory field label.
|
|
|
- /// </summary>
|
|
|
- /// <value>The name of the directory field label.</value>
|
|
|
- public ustring NameDirLabel {
|
|
|
- get => nameDirLabel.Text;
|
|
|
- set {
|
|
|
- nameDirLabel.Text = $"{value}: ";
|
|
|
+
|
|
|
+ private void Accept (IFileInfo f)
|
|
|
+ {
|
|
|
+ if (!this.IsCompatibleWithOpenMode (f.FullName, out var reason)) {
|
|
|
+ feedback = reason;
|
|
|
+ SetNeedsDisplay ();
|
|
|
+ return;
|
|
|
}
|
|
|
+
|
|
|
+ this.tbPath.Text = f.FullName;
|
|
|
+
|
|
|
+ if (AllowsMultipleSelection) {
|
|
|
+ this.MultiSelected = new List<string> { f.FullName }.AsReadOnly ();
|
|
|
+ }
|
|
|
+
|
|
|
+ FinishAccept ();
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets the name field label.
|
|
|
- /// </summary>
|
|
|
- /// <value>The name field label.</value>
|
|
|
- public ustring NameFieldLabel {
|
|
|
- get => nameFieldLabel.Text;
|
|
|
- set {
|
|
|
- nameFieldLabel.Text = $"{value}: ";
|
|
|
+ private void Accept ()
|
|
|
+ {
|
|
|
+ if (!this.IsCompatibleWithOpenMode (this.tbPath.Text.ToString (), out string reason)) {
|
|
|
+ if (reason != null) {
|
|
|
+ feedback = reason;
|
|
|
+ SetNeedsDisplay ();
|
|
|
+ }
|
|
|
+ return;
|
|
|
}
|
|
|
+
|
|
|
+ FinishAccept ();
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets the message displayed to the user, defaults to nothing
|
|
|
- /// </summary>
|
|
|
- /// <value>The message.</value>
|
|
|
- public ustring Message {
|
|
|
- get => message.Text;
|
|
|
- set {
|
|
|
- message.Text = value;
|
|
|
+ private void FinishAccept ()
|
|
|
+ {
|
|
|
+ var e = new FilesSelectedEventArgs (this);
|
|
|
+
|
|
|
+ this.FilesSelected?.Invoke (this, e);
|
|
|
+
|
|
|
+ if (e.Cancel) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // if user uses Path selection mode (e.g. Enter in text box)
|
|
|
+ // then also copy to MultiSelected
|
|
|
+ if (AllowsMultipleSelection && (!MultiSelected.Any ())) {
|
|
|
+
|
|
|
+ MultiSelected = string.IsNullOrWhiteSpace (Path) ?
|
|
|
+ Enumerable.Empty<string> ().ToList ().AsReadOnly () :
|
|
|
+ new List<string> () { Path }.AsReadOnly ();
|
|
|
}
|
|
|
+
|
|
|
+ this.Canceled = false;
|
|
|
+ Application.RequestStop ();
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="FileDialog"/> can create directories.
|
|
|
- /// </summary>
|
|
|
- /// <value><c>true</c> if can create directories; otherwise, <c>false</c>.</value>
|
|
|
- public bool CanCreateDirectories { get; set; }
|
|
|
+ private void NavigateIf (KeyEventEventArgs keyEvent, Key isKey, View to)
|
|
|
+ {
|
|
|
+ if (!keyEvent.Handled) {
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="FileDialog"/> is extension hidden.
|
|
|
- /// </summary>
|
|
|
- /// <value><c>true</c> if is extension hidden; otherwise, <c>false</c>.</value>
|
|
|
- public bool IsExtensionHidden { get; set; }
|
|
|
+ if (NavigateIf (keyEvent.KeyEvent, isKey, to)) {
|
|
|
+ keyEvent.Handled = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets the directory path for this panel
|
|
|
- /// </summary>
|
|
|
- /// <value>The directory path.</value>
|
|
|
- public ustring DirectoryPath {
|
|
|
- get => dirEntry.Text;
|
|
|
- set {
|
|
|
- dirEntry.Text = value;
|
|
|
- dirListView.Directory = value;
|
|
|
+ private bool NavigateIf (KeyEvent keyEvent, Key isKey, View to)
|
|
|
+ {
|
|
|
+ if (keyEvent.Key == isKey) {
|
|
|
+
|
|
|
+ to.FocusFirst ();
|
|
|
+ if (to == tbPath) {
|
|
|
+ tbPath.MoveEnd ();
|
|
|
+ }
|
|
|
+ return true;
|
|
|
}
|
|
|
+
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- private string [] allowedFileTypes;
|
|
|
+ private void TreeView_SelectionChanged (object sender, SelectionChangedEventArgs<object> e)
|
|
|
+ {
|
|
|
+ if (e.NewValue == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.tbPath.Text = FileDialogTreeBuilder.NodeToDirectory (e.NewValue).FullName;
|
|
|
+ }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// The array of filename extensions allowed, or null if all file extensions are allowed.
|
|
|
- /// </summary>
|
|
|
- /// <value>The allowed file types.</value>
|
|
|
- public string [] AllowedFileTypes {
|
|
|
- get => allowedFileTypes;
|
|
|
- set {
|
|
|
- allowedFileTypes = value;
|
|
|
- var selected = cmbAllowedTypes.SelectedItem;
|
|
|
- cmbAllowedTypes.SetSource (value);
|
|
|
- cmbAllowedTypes.SelectedItem = selected > -1 ? selected : 0;
|
|
|
- SetComboBoxHeight (value?.ToList ());
|
|
|
- dirListView.AllowedFileTypes = value != null
|
|
|
- ? value [cmbAllowedTypes.SelectedItem].Split (';')
|
|
|
- : null;
|
|
|
+ private void UpdateNavigationVisibility ()
|
|
|
+ {
|
|
|
+ this.btnBack.Visible = this.history.CanBack ();
|
|
|
+ this.btnForward.Visible = this.history.CanForward ();
|
|
|
+ this.btnUp.Visible = this.history.CanUp ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void TableView_SelectedCellChanged (object sender, SelectedCellChangedEventArgs obj)
|
|
|
+ {
|
|
|
+ if (!this.tableView.HasFocus || obj.NewRow == -1 || obj.Table.Rows.Count == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.tableView.MultiSelect && this.tableView.MultiSelectedRegions.Any ()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var stats = this.RowToStats (obj.NewRow);
|
|
|
+
|
|
|
+ if (stats == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ IFileSystemInfo dest;
|
|
|
+
|
|
|
+ if (stats.IsParent) {
|
|
|
+ dest = State.Directory;
|
|
|
+ } else {
|
|
|
+ dest = stats.FileSystemInfo;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.pushingState = true;
|
|
|
+
|
|
|
+ this.tbPath.Text = dest.FullName;
|
|
|
+ this.State.Selected = stats;
|
|
|
+ this.tbPath.Autocomplete.ClearSuggestions ();
|
|
|
+
|
|
|
+ } finally {
|
|
|
+
|
|
|
+ this.pushingState = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="FileDialog"/> allows the file to be saved with a different extension
|
|
|
- /// </summary>
|
|
|
- /// <value><c>true</c> if allows other file types; otherwise, <c>false</c>.</value>
|
|
|
- public bool AllowsOtherFileTypes { get; set; }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// The File path that is currently shown on the panel
|
|
|
- /// </summary>
|
|
|
- /// <value>The absolute file path for the file path entered.</value>
|
|
|
- public ustring FilePath {
|
|
|
- get => dirListView.MakePath (nameEntry.Text.ToString ());
|
|
|
- set {
|
|
|
- nameEntry.Text = Path.GetFileName (value.ToString ());
|
|
|
+ private bool TableView_KeyUp (KeyEvent keyEvent)
|
|
|
+ {
|
|
|
+ if (keyEvent.Key == Key.Backspace) {
|
|
|
+ return this.history.Back ();
|
|
|
}
|
|
|
+ if (keyEvent.Key == (Key.ShiftMask | Key.Backspace)) {
|
|
|
+ return this.history.Forward ();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (keyEvent.Key == Key.DeleteChar) {
|
|
|
+
|
|
|
+ Delete ();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (keyEvent.Key == (Key.CtrlMask | Key.R)) {
|
|
|
+
|
|
|
+ Rename ();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (keyEvent.Key == (Key.CtrlMask | Key.N)) {
|
|
|
+ New ();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Check if the dialog was or not canceled.
|
|
|
- /// </summary>
|
|
|
- public bool Canceled { get => canceled; }
|
|
|
- }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// The <see cref="SaveDialog"/> provides an interactive dialog box for users to pick a file to
|
|
|
- /// save.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// <para>
|
|
|
- /// To use, create an instance of <see cref="SaveDialog"/>, and pass it to
|
|
|
- /// <see cref="Application.Run(Func{Exception, bool})"/>. This will run the dialog modally,
|
|
|
- /// and when this returns, the <see cref="FileName"/>property will contain the selected file name or
|
|
|
- /// null if the user canceled.
|
|
|
- /// </para>
|
|
|
- /// </remarks>
|
|
|
- public class SaveDialog : FileDialog {
|
|
|
- /// <summary>
|
|
|
- /// Initializes a new <see cref="SaveDialog"/>.
|
|
|
- /// </summary>
|
|
|
- public SaveDialog () : this (title: string.Empty, message: string.Empty) { }
|
|
|
+ private void SetupTableColumns ()
|
|
|
+ {
|
|
|
+ this.dtFiles = new DataTable ();
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Initializes a new <see cref="SaveDialog"/>.
|
|
|
- /// </summary>
|
|
|
- /// <param name="title">The title.</param>
|
|
|
- /// <param name="message">The message.</param>
|
|
|
- /// <param name="allowedTypes">The allowed types.</param>
|
|
|
- public SaveDialog (ustring title, ustring message, List<string> allowedTypes = null)
|
|
|
- : base (title, prompt: Strings.fdSave, nameFieldLabel: $"{Strings.fdSaveAs}:", message: message, allowedTypes) { }
|
|
|
+ var nameStyle = this.tableView.Style.GetOrCreateColumnStyle (
|
|
|
+ filenameColumn = this.dtFiles.Columns.Add (Style.FilenameColumnName, typeof (int))
|
|
|
+ );
|
|
|
+ nameStyle.RepresentationGetter = (i) => {
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Gets the name of the file the user selected for saving, or null
|
|
|
- /// if the user canceled the <see cref="SaveDialog"/>.
|
|
|
- /// </summary>
|
|
|
- /// <value>The name of the file.</value>
|
|
|
- public ustring FileName {
|
|
|
- get {
|
|
|
- if (canceled)
|
|
|
- return null;
|
|
|
- return Path.GetFileName (FilePath.ToString ());
|
|
|
+ var stats = this.State?.Children [(int)i];
|
|
|
+
|
|
|
+ if (stats == null) {
|
|
|
+ return string.Empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ var icon = stats.IsParent ? null : Style.IconGetter?.Invoke (stats.FileSystemInfo);
|
|
|
+
|
|
|
+ if (icon != null) {
|
|
|
+ return icon + stats.Name;
|
|
|
+ }
|
|
|
+ return stats.Name;
|
|
|
+ };
|
|
|
+
|
|
|
+ nameStyle.MinWidth = 50;
|
|
|
+
|
|
|
+ var sizeStyle = this.tableView.Style.GetOrCreateColumnStyle (this.dtFiles.Columns.Add (Style.SizeColumnName, typeof (int)));
|
|
|
+ sizeStyle.RepresentationGetter = (i) => this.State?.Children [(int)i].HumanReadableLength ?? string.Empty;
|
|
|
+ nameStyle.MinWidth = 10;
|
|
|
+
|
|
|
+ var dateModifiedStyle = this.tableView.Style.GetOrCreateColumnStyle (this.dtFiles.Columns.Add (Style.ModifiedColumnName, typeof (int)));
|
|
|
+ dateModifiedStyle.RepresentationGetter = (i) =>
|
|
|
+ {
|
|
|
+ var s = this.State?.Children [(int)i];
|
|
|
+ if(s == null || s.IsParent || s.LastWriteTime == null)
|
|
|
+ {
|
|
|
+ return string.Empty;
|
|
|
+ }
|
|
|
+ return s.LastWriteTime.Value.ToString (Style.DateFormat);
|
|
|
+ };
|
|
|
+
|
|
|
+ dateModifiedStyle.MinWidth = 30;
|
|
|
+
|
|
|
+ var typeStyle = this.tableView.Style.GetOrCreateColumnStyle (this.dtFiles.Columns.Add (Style.TypeColumnName, typeof (int)));
|
|
|
+ typeStyle.RepresentationGetter = (i) => this.State?.Children [(int)i].Type ?? string.Empty;
|
|
|
+ typeStyle.MinWidth = 6;
|
|
|
+
|
|
|
+ foreach(var colStyle in Style.TableStyle.ColumnStyles) {
|
|
|
+ colStyle.Value.ColorGetter = this.ColorGetter;
|
|
|
}
|
|
|
+
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// The <see cref="OpenDialog"/>provides an interactive dialog box for users to select files or directories.
|
|
|
- /// </summary>
|
|
|
- /// <remarks>
|
|
|
- /// <para>
|
|
|
- /// The open dialog can be used to select files for opening, it can be configured to allow
|
|
|
- /// multiple items to be selected (based on the AllowsMultipleSelection) variable and
|
|
|
- /// you can control whether this should allow files or directories to be selected.
|
|
|
- /// </para>
|
|
|
- /// <para>
|
|
|
- /// To use, create an instance of <see cref="OpenDialog"/>, and pass it to
|
|
|
- /// <see cref="Application.Run(Func{Exception, bool})"/>. This will run the dialog modally,
|
|
|
- /// and when this returns, the list of files will be available on the <see cref="FilePaths"/> property.
|
|
|
- /// </para>
|
|
|
- /// <para>
|
|
|
- /// To select more than one file, users can use the spacebar, or control-t.
|
|
|
- /// </para>
|
|
|
- /// </remarks>
|
|
|
- public class OpenDialog : FileDialog {
|
|
|
- OpenMode openMode;
|
|
|
+ private void CellActivate (object sender, CellActivatedEventArgs obj)
|
|
|
+ {
|
|
|
+ var multi = this.MultiRowToStats ();
|
|
|
+ string reason = null;
|
|
|
+ if (multi.Any ()) {
|
|
|
+ if (multi.All (m => this.IsCompatibleWithOpenMode (m.FileSystemInfo.FullName, out reason))) {
|
|
|
+ this.Accept (multi);
|
|
|
+ return;
|
|
|
+ } else {
|
|
|
+ if (reason != null) {
|
|
|
+ feedback = reason;
|
|
|
+ SetNeedsDisplay ();
|
|
|
+ }
|
|
|
|
|
|
- /// <summary>
|
|
|
- /// Determine which <see cref="System.IO"/> type to open.
|
|
|
- /// </summary>
|
|
|
- public enum OpenMode {
|
|
|
- /// <summary>
|
|
|
- /// Opens only file or files.
|
|
|
- /// </summary>
|
|
|
- File,
|
|
|
- /// <summary>
|
|
|
- /// Opens only directory or directories.
|
|
|
- /// </summary>
|
|
|
- Directory,
|
|
|
- /// <summary>
|
|
|
- /// Opens files and directories.
|
|
|
- /// </summary>
|
|
|
- Mixed
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ var stats = this.RowToStats (obj.Row);
|
|
|
+
|
|
|
+
|
|
|
+ if (stats.FileSystemInfo is IDirectoryInfo d) {
|
|
|
+ this.PushState (d, true);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (stats.FileSystemInfo is IFileInfo f) {
|
|
|
+ this.Accept (f);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new <see cref="OpenDialog"/>.
|
|
|
+ /// Returns true if there are no <see cref="AllowedTypes"/> or one of them agrees
|
|
|
+ /// that <paramref name="file"/> <see cref="IAllowedType.IsAllowed(string)"/>.
|
|
|
/// </summary>
|
|
|
- public OpenDialog () : this (title: string.Empty, message: string.Empty) { }
|
|
|
+ /// <param name="file"></param>
|
|
|
+ /// <returns></returns>
|
|
|
+ public bool IsCompatibleWithAllowedExtensions (IFileInfo file)
|
|
|
+ {
|
|
|
+ // no restrictions
|
|
|
+ if (!this.AllowedTypes.Any ()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return this.MatchesAllowedTypes (file);
|
|
|
+ }
|
|
|
+
|
|
|
+ private bool IsCompatibleWithAllowedExtensions (string path)
|
|
|
+ {
|
|
|
+ // no restrictions
|
|
|
+ if (!this.AllowedTypes.Any ()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return this.AllowedTypes.Any (t => t.IsAllowed (path));
|
|
|
+ }
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Initializes a new <see cref="OpenDialog"/>.
|
|
|
+ /// Returns true if any <see cref="AllowedTypes"/> matches <paramref name="file"/>.
|
|
|
/// </summary>
|
|
|
- /// <param name="title">The title.</param>
|
|
|
- /// <param name="message">The message.</param>
|
|
|
- /// <param name="allowedTypes">The allowed types.</param>
|
|
|
- /// <param name="openMode">The open mode.</param>
|
|
|
- public OpenDialog (ustring title, ustring message, List<string> allowedTypes = null, OpenMode openMode = OpenMode.File) : base (title,
|
|
|
- prompt: openMode == OpenMode.File ? Strings.fdOpen : openMode == OpenMode.Directory ? Strings.fdSelectFolder : Strings.fdSelectMixed,
|
|
|
- nameFieldLabel: Strings.fdOpen, message: message, allowedTypes)
|
|
|
+ /// <param name="file"></param>
|
|
|
+ /// <returns></returns>
|
|
|
+ private bool MatchesAllowedTypes (IFileInfo file)
|
|
|
{
|
|
|
- this.openMode = openMode;
|
|
|
- switch (openMode) {
|
|
|
- case OpenMode.File:
|
|
|
- CanChooseFiles = true;
|
|
|
- CanChooseDirectories = false;
|
|
|
- break;
|
|
|
+ return this.AllowedTypes.Any (t => t.IsAllowed (file.FullName));
|
|
|
+ }
|
|
|
+ private bool IsCompatibleWithOpenMode (string s, out string reason)
|
|
|
+ {
|
|
|
+ reason = null;
|
|
|
+ if (string.IsNullOrWhiteSpace (s)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.IsCompatibleWithAllowedExtensions (s)) {
|
|
|
+ reason = Style.WrongFileTypeFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ switch (this.OpenMode) {
|
|
|
case OpenMode.Directory:
|
|
|
- CanChooseFiles = false;
|
|
|
- CanChooseDirectories = true;
|
|
|
- break;
|
|
|
+ if (MustExist && !Directory.Exists (s)) {
|
|
|
+ reason = Style.DirectoryMustExistFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (File.Exists (s)) {
|
|
|
+ reason = Style.FileAlreadyExistsFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ case OpenMode.File:
|
|
|
+
|
|
|
+ if (MustExist && !File.Exists (s)) {
|
|
|
+ reason = Style.FileMustExistFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (Directory.Exists (s)) {
|
|
|
+ reason = Style.DirectoryAlreadyExistsFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
case OpenMode.Mixed:
|
|
|
- CanChooseFiles = true;
|
|
|
- CanChooseDirectories = true;
|
|
|
- AllowsMultipleSelection = true;
|
|
|
- break;
|
|
|
+ if (MustExist && !File.Exists (s) && !Directory.Exists (s)) {
|
|
|
+ reason = Style.FileOrDirectoryMustExistFeedback;
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ default: throw new ArgumentOutOfRangeException (nameof (this.OpenMode));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="Terminal.Gui.OpenDialog"/> can choose files.
|
|
|
+ /// Changes the dialog such that <paramref name="d"/> is being explored.
|
|
|
/// </summary>
|
|
|
- /// <value><c>true</c> if can choose files; otherwise, <c>false</c>. Defaults to <c>true</c></value>
|
|
|
- public bool CanChooseFiles {
|
|
|
- get => dirListView.canChooseFiles;
|
|
|
- set {
|
|
|
- dirListView.canChooseFiles = value;
|
|
|
- dirListView.Reload ();
|
|
|
+ /// <param name="d"></param>
|
|
|
+ /// <param name="addCurrentStateToHistory"></param>
|
|
|
+ /// <param name="setPathText"></param>
|
|
|
+ /// <param name="clearForward"></param>
|
|
|
+ internal void PushState (IDirectoryInfo d, bool addCurrentStateToHistory, bool setPathText = true, bool clearForward = true)
|
|
|
+ {
|
|
|
+ // no change of state
|
|
|
+ if (d == this.State?.Directory) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (d.FullName == this.State?.Directory.FullName) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ PushState (new FileDialogState (d, this), addCurrentStateToHistory, setPathText, clearForward);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void RefreshState ()
|
|
|
+ {
|
|
|
+ State.RefreshChildren ();
|
|
|
+ PushState (State, false, false, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void PushState (FileDialogState newState, bool addCurrentStateToHistory, bool setPathText = true, bool clearForward = true)
|
|
|
+ {
|
|
|
+ if (State is SearchState search) {
|
|
|
+ search.Cancel ();
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.pushingState = true;
|
|
|
+
|
|
|
+ // push the old state to history
|
|
|
+ if (addCurrentStateToHistory) {
|
|
|
+ this.history.Push (this.State, clearForward);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.tbPath.Autocomplete.ClearSuggestions ();
|
|
|
+
|
|
|
+ if (setPathText) {
|
|
|
+ this.tbPath.Text = newState.Directory.FullName;
|
|
|
+ this.tbPath.MoveEnd ();
|
|
|
+ }
|
|
|
+
|
|
|
+ this.State = newState;
|
|
|
+ this.tbPath.Autocomplete.GenerateSuggestions (
|
|
|
+ new AutocompleteFilepathContext (tbPath.Text, tbPath.CursorPosition, this.State));
|
|
|
+
|
|
|
+ this.WriteStateToTableView ();
|
|
|
+
|
|
|
+ if (clearForward) {
|
|
|
+ this.history.ClearForward ();
|
|
|
+ }
|
|
|
+
|
|
|
+ this.tableView.RowOffset = 0;
|
|
|
+ this.tableView.SelectedRow = 0;
|
|
|
+
|
|
|
+ this.SetNeedsDisplay ();
|
|
|
+ this.UpdateNavigationVisibility ();
|
|
|
+
|
|
|
+ } finally {
|
|
|
+
|
|
|
+ this.pushingState = false;
|
|
|
+ }
|
|
|
+ ClearFeedback ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void WriteStateToTableView ()
|
|
|
+ {
|
|
|
+ if (this.State == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dtFiles.Rows.Clear ();
|
|
|
+
|
|
|
+ for (int i = 0; i < this.State.Children.Length; i++) {
|
|
|
+ this.BuildRow (i);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.sorter.ApplySort ();
|
|
|
+ this.tableView.Update ();
|
|
|
+ UpdateCollectionNavigator ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void BuildRow (int idx)
|
|
|
+ {
|
|
|
+ this.tableView.Table.Rows.Add (idx, idx, idx, idx);
|
|
|
+ }
|
|
|
+
|
|
|
+ private ColorScheme ColorGetter (TableView.CellColorGetterArgs args)
|
|
|
+ {
|
|
|
+ var stats = this.RowToStats (args.RowIndex);
|
|
|
+
|
|
|
+ if (!Style.UseColors) {
|
|
|
+ return tableView.ColorScheme;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (stats.IsDir ()) {
|
|
|
+ return Style.ColorSchemeDirectory;
|
|
|
+ }
|
|
|
+ if (stats.IsImage ()) {
|
|
|
+ return Style.ColorSchemeImage;
|
|
|
+ }
|
|
|
+ if (stats.IsExecutable ()) {
|
|
|
+ return Style.ColorSchemeExeOrRecommended;
|
|
|
}
|
|
|
+ if (stats.FileSystemInfo is IFileInfo f && this.MatchesAllowedTypes (f)) {
|
|
|
+ return Style.ColorSchemeExeOrRecommended;
|
|
|
+ }
|
|
|
+
|
|
|
+ return Style.ColorSchemeOther;
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="OpenDialog"/> can choose directories.
|
|
|
+ /// If <see cref="TableView.MultiSelect"/> is on and multiple rows are selected
|
|
|
+ /// this returns a union of all <see cref="FileSystemInfoStats"/> in the selection.
|
|
|
/// </summary>
|
|
|
- /// <value><c>true</c> if can choose directories; otherwise, <c>false</c> defaults to <c>false</c>.</value>
|
|
|
- public bool CanChooseDirectories {
|
|
|
- get => dirListView.canChooseDirectories;
|
|
|
- set {
|
|
|
- dirListView.canChooseDirectories = value;
|
|
|
- dirListView.Reload ();
|
|
|
+ /// <remarks>Returns an empty collection if there are not at least 2 rows in the selection</remarks>
|
|
|
+ /// <returns></returns>
|
|
|
+ private IEnumerable<FileSystemInfoStats> MultiRowToStats ()
|
|
|
+ {
|
|
|
+ var toReturn = new HashSet<FileSystemInfoStats> ();
|
|
|
+
|
|
|
+ if (this.AllowsMultipleSelection && this.tableView.MultiSelectedRegions.Any ()) {
|
|
|
+
|
|
|
+ foreach (var p in this.tableView.GetAllSelectedCells ()) {
|
|
|
+
|
|
|
+ var add = this.State?.Children [(int)this.tableView.Table.Rows [p.Y] [0]];
|
|
|
+ if (add != null) {
|
|
|
+ toReturn.Add (add);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return toReturn.Count > 1 ? toReturn : Enumerable.Empty<FileSystemInfoStats> ();
|
|
|
+ }
|
|
|
+ private FileSystemInfoStats RowToStats (int rowIndex)
|
|
|
+ {
|
|
|
+ return this.State?.Children [(int)this.tableView.Table.Rows [rowIndex] [0]];
|
|
|
+ }
|
|
|
+ private int? StatsToRow (IFileSystemInfo fileSystemInfo)
|
|
|
+ {
|
|
|
+ // find array index of the current state for the stats
|
|
|
+ var idx = State?.Children.IndexOf ((f) => f.FileSystemInfo.FullName == fileSystemInfo.FullName);
|
|
|
+
|
|
|
+ if (idx != -1 && idx != null) {
|
|
|
+
|
|
|
+ // find the row number in our DataTable where the cell
|
|
|
+ // contains idx
|
|
|
+ var match = tableView.Table.Rows
|
|
|
+ .Cast<DataRow> ()
|
|
|
+ .Select ((r, rIdx) => new { row = r, rowIdx = rIdx })
|
|
|
+ .Where (t => (int)t.row [0] == idx)
|
|
|
+ .ToArray ();
|
|
|
+
|
|
|
+ if (match.Length == 1) {
|
|
|
+ return match [0].rowIdx;
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ private void PathChanged ()
|
|
|
+ {
|
|
|
+ // avoid re-entry
|
|
|
+ if (this.pushingState) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var path = this.tbPath.Text?.ToString ();
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace (path)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var dir = this.StringToDirectoryInfo (path);
|
|
|
+
|
|
|
+ if (dir.Exists) {
|
|
|
+ this.PushState (dir, true, false);
|
|
|
+ } else
|
|
|
+ if (dir.Parent?.Exists ?? false) {
|
|
|
+ this.PushState (dir.Parent, true, false);
|
|
|
+ }
|
|
|
+
|
|
|
+ tbPath.Autocomplete.GenerateSuggestions (new AutocompleteFilepathContext (tbPath.Text, tbPath.CursorPosition, State));
|
|
|
+ }
|
|
|
+
|
|
|
+ private IDirectoryInfo StringToDirectoryInfo (string path)
|
|
|
+ {
|
|
|
+ // if you pass new DirectoryInfo("C:") you get a weird object
|
|
|
+ // where the FullName is in fact the current working directory.
|
|
|
+ // really not what most users would expect
|
|
|
+ if (Regex.IsMatch (path, "^\\w:$")) {
|
|
|
+ return fileSystem.DirectoryInfo.New(path + System.IO.Path.DirectorySeparatorChar);
|
|
|
+ }
|
|
|
+
|
|
|
+ return fileSystem.DirectoryInfo.New(path);
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
- /// Gets or sets a value indicating whether this <see cref="OpenDialog"/> allows multiple selection.
|
|
|
+ /// Select <paramref name="toRestore"/> in the table view (if present)
|
|
|
/// </summary>
|
|
|
- /// <value><c>true</c> if allows multiple selection; otherwise, <c>false</c>, defaults to false.</value>
|
|
|
- public bool AllowsMultipleSelection {
|
|
|
- get => dirListView.allowsMultipleSelection;
|
|
|
- set {
|
|
|
- if (!value && openMode == OpenMode.Mixed) {
|
|
|
+ /// <param name="toRestore"></param>
|
|
|
+ internal void RestoreSelection (IFileSystemInfo toRestore)
|
|
|
+ {
|
|
|
+ var toReselect = StatsToRow (toRestore);
|
|
|
+
|
|
|
+ if (toReselect.HasValue) {
|
|
|
+ tableView.SelectedRow = toReselect.Value;
|
|
|
+ tableView.EnsureSelectedCellIsVisible ();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ private class FileDialogSorter {
|
|
|
+ private readonly FileDialog dlg;
|
|
|
+ private TableView tableView;
|
|
|
+
|
|
|
+ private DataColumn currentSort = null;
|
|
|
+ private bool currentSortIsAsc = true;
|
|
|
+
|
|
|
+ public FileDialogSorter (FileDialog dlg, TableView tableView)
|
|
|
+ {
|
|
|
+ this.dlg = dlg;
|
|
|
+ this.tableView = tableView;
|
|
|
+
|
|
|
+ // if user clicks the mouse in TableView
|
|
|
+ this.tableView.MouseClick += (s, e) => {
|
|
|
+
|
|
|
+ var clickedCell = this.tableView.ScreenToCell (e.MouseEvent.X, e.MouseEvent.Y, out DataColumn clickedCol);
|
|
|
+
|
|
|
+ if (clickedCol != null) {
|
|
|
+ if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) {
|
|
|
+
|
|
|
+ // left click in a header
|
|
|
+ this.SortColumn (clickedCol);
|
|
|
+ } else if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) {
|
|
|
+
|
|
|
+ // right click in a header
|
|
|
+ this.ShowHeaderContextMenu (clickedCol, e);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (clickedCell != null && e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) {
|
|
|
+
|
|
|
+ // right click in rest of table
|
|
|
+ this.ShowCellContextMenu (clickedCell, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ };
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ internal void ApplySort ()
|
|
|
+ {
|
|
|
+ var col = this.currentSort;
|
|
|
+
|
|
|
+ // TODO: Consider preserving selection
|
|
|
+ this.tableView.Table.Rows.Clear ();
|
|
|
+
|
|
|
+ var colName = col == null ? null : StripArrows (col.ColumnName);
|
|
|
+
|
|
|
+ var stats = this.dlg.State?.Children ?? new FileSystemInfoStats [0];
|
|
|
+
|
|
|
+ // Do we sort on a column or just use the default sort order?
|
|
|
+ Func<FileSystemInfoStats, object> sortAlgorithm;
|
|
|
+
|
|
|
+ if (colName == null) {
|
|
|
+ sortAlgorithm = (v) => v.GetOrderByDefault ();
|
|
|
+ this.currentSortIsAsc = true;
|
|
|
+ } else {
|
|
|
+ sortAlgorithm = (v) => v.GetOrderByValue (dlg, colName);
|
|
|
+ }
|
|
|
+
|
|
|
+ // This portion is never reordered (aways .. at top then folders)
|
|
|
+ var forcedOrder = stats.Select ((v, i) => new { v, i })
|
|
|
+ .OrderByDescending (f => f.v.IsParent)
|
|
|
+ .ThenBy (f => f.v.IsDir() ? -1:100);
|
|
|
+
|
|
|
+ // This portion is flexible based on the column clicked (e.g. alphabetical)
|
|
|
+ var ordered =
|
|
|
+ this.currentSortIsAsc ?
|
|
|
+ forcedOrder.ThenBy (f => sortAlgorithm (f.v)):
|
|
|
+ forcedOrder.ThenByDescending (f => sortAlgorithm (f.v));
|
|
|
+
|
|
|
+ foreach (var o in ordered) {
|
|
|
+ this.dlg.BuildRow (o.i);
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (DataColumn c in this.tableView.Table.Columns) {
|
|
|
+
|
|
|
+ // remove any lingering sort indicator
|
|
|
+ c.ColumnName = StripArrows (c.ColumnName);
|
|
|
+
|
|
|
+ // add a new one if this the one that is being sorted
|
|
|
+ if (c == col) {
|
|
|
+ c.ColumnName += this.currentSortIsAsc ? " (▲)" : " (▼)";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.tableView.Update ();
|
|
|
+ dlg.UpdateCollectionNavigator ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string StripArrows (string columnName)
|
|
|
+ {
|
|
|
+ return columnName.Replace (" (▼)", string.Empty).Replace (" (▲)", string.Empty);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void SortColumn (DataColumn clickedCol)
|
|
|
+ {
|
|
|
+ this.GetProposedNewSortOrder (clickedCol, out var isAsc);
|
|
|
+ this.SortColumn (clickedCol, isAsc);
|
|
|
+ }
|
|
|
+
|
|
|
+ internal void SortColumn (DataColumn col, bool isAsc)
|
|
|
+ {
|
|
|
+ // set a sort order
|
|
|
+ this.currentSort = col;
|
|
|
+ this.currentSortIsAsc = isAsc;
|
|
|
+
|
|
|
+ this.ApplySort ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private string GetProposedNewSortOrder (DataColumn clickedCol, out bool isAsc)
|
|
|
+ {
|
|
|
+ // work out new sort order
|
|
|
+ if (this.currentSort == clickedCol && this.currentSortIsAsc) {
|
|
|
+ isAsc = false;
|
|
|
+ return $"{clickedCol.ColumnName} DESC";
|
|
|
+ } else {
|
|
|
+ isAsc = true;
|
|
|
+ return $"{clickedCol.ColumnName} ASC";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ShowHeaderContextMenu (DataColumn clickedCol, MouseEventEventArgs e)
|
|
|
+ {
|
|
|
+ var sort = this.GetProposedNewSortOrder (clickedCol, out var isAsc);
|
|
|
+
|
|
|
+ var contextMenu = new ContextMenu (
|
|
|
+ e.MouseEvent.X + 1,
|
|
|
+ e.MouseEvent.Y + 1,
|
|
|
+ new MenuBarItem (new MenuItem []
|
|
|
+ {
|
|
|
+ new MenuItem($"Hide {StripArrows(clickedCol.ColumnName)}", string.Empty, () => this.HideColumn(clickedCol)),
|
|
|
+ new MenuItem($"Sort {StripArrows(sort)}",string.Empty, ()=> this.SortColumn(clickedCol,isAsc)),
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ contextMenu.Show ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void ShowCellContextMenu (Point? clickedCell, MouseEventEventArgs e)
|
|
|
+ {
|
|
|
+ if (clickedCell == null) {
|
|
|
return;
|
|
|
}
|
|
|
- dirListView.allowsMultipleSelection = value;
|
|
|
- dirListView.Reload ();
|
|
|
+
|
|
|
+ var contextMenu = new ContextMenu (
|
|
|
+ e.MouseEvent.X + 1,
|
|
|
+ e.MouseEvent.Y + 1,
|
|
|
+ new MenuBarItem (new MenuItem []
|
|
|
+ {
|
|
|
+ new MenuItem($"New", string.Empty, () => dlg.New()),
|
|
|
+ new MenuItem($"Rename",string.Empty, ()=> dlg.Rename()),
|
|
|
+ new MenuItem($"Delete",string.Empty, ()=> dlg.Delete()),
|
|
|
+ })
|
|
|
+ );
|
|
|
+
|
|
|
+ dlg.tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false);
|
|
|
+
|
|
|
+ contextMenu.Show ();
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
+ private void HideColumn (DataColumn clickedCol)
|
|
|
+ {
|
|
|
+ var style = this.tableView.Style.GetOrCreateColumnStyle (clickedCol);
|
|
|
+ style.Visible = false;
|
|
|
+ this.tableView.Update ();
|
|
|
+ }
|
|
|
+ }
|
|
|
/// <summary>
|
|
|
- /// Returns the selected files, or an empty list if nothing has been selected
|
|
|
+ /// State representing a recursive search from <see cref="FileDialogState.Directory"/>
|
|
|
+ /// downwards.
|
|
|
/// </summary>
|
|
|
- /// <value>The file paths.</value>
|
|
|
- public IReadOnlyList<string> FilePaths {
|
|
|
- get => dirListView.FilePaths;
|
|
|
+ internal class SearchState : FileDialogState {
|
|
|
+
|
|
|
+ bool cancel = false;
|
|
|
+ bool finished = false;
|
|
|
+
|
|
|
+ // TODO: Add thread safe child adding
|
|
|
+ List<FileSystemInfoStats> found = new List<FileSystemInfoStats> ();
|
|
|
+ object oLockFound = new object ();
|
|
|
+ CancellationTokenSource token = new CancellationTokenSource ();
|
|
|
+
|
|
|
+ public SearchState (IDirectoryInfo dir, FileDialog parent, string searchTerms) : base (dir, parent)
|
|
|
+ {
|
|
|
+ parent.SearchMatcher.Initialize (searchTerms);
|
|
|
+ Children = new FileSystemInfoStats [0];
|
|
|
+ BeginSearch ();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void BeginSearch ()
|
|
|
+ {
|
|
|
+ Task.Run (() => {
|
|
|
+ RecursiveFind (Directory);
|
|
|
+ finished = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ Task.Run (() => {
|
|
|
+ UpdateChildren ();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpdateChildren ()
|
|
|
+ {
|
|
|
+ lock (Parent.onlyOneSearchLock) {
|
|
|
+ while (!cancel && !finished) {
|
|
|
+
|
|
|
+ try {
|
|
|
+ Task.Delay (250).Wait (token.Token);
|
|
|
+ } catch (OperationCanceledException) {
|
|
|
+ cancel = true;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ if (cancel || finished) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ UpdateChildrenToFound ();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (finished && !cancel) {
|
|
|
+ UpdateChildrenToFound ();
|
|
|
+ }
|
|
|
+
|
|
|
+ Application.MainLoop.Invoke (() => {
|
|
|
+ Parent.spinnerView.Visible = false;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void UpdateChildrenToFound ()
|
|
|
+ {
|
|
|
+ lock (oLockFound) {
|
|
|
+ Children = found.ToArray ();
|
|
|
+ }
|
|
|
+
|
|
|
+ Application.MainLoop.Invoke (() => {
|
|
|
+ Parent.tbPath.Autocomplete.GenerateSuggestions (
|
|
|
+ new AutocompleteFilepathContext (Parent.tbPath.Text, Parent.tbPath.CursorPosition, this)
|
|
|
+ );
|
|
|
+ Parent.WriteStateToTableView ();
|
|
|
+
|
|
|
+ Parent.spinnerView.Visible = true;
|
|
|
+ Parent.spinnerView.SetNeedsDisplay ();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private void RecursiveFind (IDirectoryInfo directory)
|
|
|
+ {
|
|
|
+ foreach (var f in GetChildren (directory)) {
|
|
|
+
|
|
|
+ if (cancel) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (f.IsParent) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ lock (oLockFound) {
|
|
|
+ if (found.Count >= FileDialog.MaxSearchResults) {
|
|
|
+ finished = true;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Parent.SearchMatcher.IsMatch (f.FileSystemInfo)) {
|
|
|
+ lock (oLockFound) {
|
|
|
+ found.Add (f);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (f.FileSystemInfo is IDirectoryInfo sub) {
|
|
|
+ RecursiveFind (sub);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ internal override void RefreshChildren ()
|
|
|
+ {
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Cancels the current search (if any). Returns true if a search
|
|
|
+ /// was running and cancellation was successfully set.
|
|
|
+ /// </summary>
|
|
|
+ /// <returns></returns>
|
|
|
+ internal bool Cancel ()
|
|
|
+ {
|
|
|
+ var alreadyCancelled = token.IsCancellationRequested || cancel;
|
|
|
+
|
|
|
+ cancel = true;
|
|
|
+ token.Cancel ();
|
|
|
+
|
|
|
+ return !alreadyCancelled;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
-}
|
|
|
+}
|