From c1a578891b5f88c557464aa5355d66a1a012d4c6 Mon Sep 17 00:00:00 2001 From: Thomas Nind <31306100+tznind@users.noreply.github.com> Date: Sun, 9 Apr 2023 10:28:56 +0100 Subject: [PATCH] Fixes #2150. Revamping FileDialog (#2259) * Investigating new file dialog * Column sorting * Add navigationStack * Append autocomplete half working * Change autocomplete append to use draw effect instead of selection effect * WIP on FileDialog2 * Refactor sort order and add more 'ls' colors * Refactor history to its own class * FileDialog2 navigation fixes/improvements * Added centered Title * Add tree view and split container * Add FileDialogState * Add AllowsMultipleSelection * Add result fields and Scenario * Added FileDialo2 test file * Fix FileDialog2 Redraw padding to respect max/min view bounds * Fix unit tests and warnings * Started on better keyboard navigation in FileDialog2 * Update to match new SplitContainer API * Quality of Life improvements * Fix recommending parent folder * Remove border from SplitContainer in FileDialog2 and fixed tests * Bugfixes and improvements to FileDialog2 * usability improvements to FileDialog2 * Add multi select and OpenMode * Enforce OpenMode when making a multi selection * Prevent typing illegal characters * Added AllowedTypes to FileDialog2 * Added combo box filter AllowedType * Improve code readability by reordering members * Do not update FileDialog2 text box when selecting ".." in TableView * Fix history navigation in FileDialog2 * Restore selection after navigating backwards in history * Add FileDialog2 tests * Make FileDialog2 Title user configurable * Fix DirectTyping_Allowed unit test when running on linux * Change Home/End to go to first/last cell in table in FileDialog2 * Remove overloaded Title property * Switch to `ustring.IsNullOrEmpty` * Update to latest TileView API * Add TableView navigation by letter using CollectionNavigator * Fix recreating search navigator too often * Add tests for proper disposing * Make Ctrl+F10 toggle split line focusability * Fix layout bug in first tile when orientation is horizontal * Switch to GenerateImage * Fix not calling base constructor * Revert "Merges latest LineCanvas into TileView" * Fix keyboard tab navigation problems * Workaround for changing CanFocus throwing Exceptions sometimes * Update to latest splitcontainer API * Adjust suggestions to be gray and properly update on keystrokes * Move ok and cancel to bottom * Add MustExist and fix multi selection of 1 result * bugfixes and quality of life * Navigating to .. clears path up to current dir * Better arrow key navigation * Make title pretty and informative * Fix test * Fix test * Trim default Titles to be more compact and readable * Fix bad merge changes * Remove EscSeqReq files that are not in v2... came from develop?! * Fix nullable and enable toggle select on table view * Fix multi select return value * Add icon and monochrome support * Add search elements * Add search for current directory * WIP: Async search * Thread safety and disposal * Improve UX * Fix for rapid search results * Fix warnings and whitespace * Don't add more than 10000 search results * Add support for adjusting search matching * Added ISearchMatcher example to FileDialog2Example * Remove double spaces after periods * Make MaxSearchResults a config setting * Localization for FileDialog2 * Fix build error * Support for custom open button Text * Improve file dialog scenario UX * Change feedback to a drawing effect in center of screen * Explore MenuBar instead of ComboBox for AllowedTypes * Fix prompt and move file open into try/catch for errors reading files * Open menu when tabbed to * FileDialog2 improvements - Expose table/tree style properties - Rename Monochrome to UseColors and default to false - IconGetter no longer forces space - On Windows in Scenario just use a backslash for folder icon (i.e. not unicode) - * Add style settings in scenario and make autocomplete case insensitive on Windows * Move ok button text to Style * xmldoc * Remove old FileDialog and re-wire OpenDialog and SaveDialog to use FileDialog2 base * localization * Move open/save dialog to their own files * Rename FileDialog2 to FileDialog * Fix repetition in string * Add IAllowedType * Get rid of AllowedTypesIsStrict User now adds the `IAllowedType` implementation `AllowedTypeAny` * Add max length to AllowedType ToString * Pressing Enter in find restarts search instead of confirm selection * Add TreeRootGetter for customizing the quick access items in FileDialog * Add FilesSelected event Allows user to do things like confirm dialogs on selecting existing file(s) * Update to new sender, event args signature * Fix naming on MouseEventArgs * Fix mouse events naming * Revert "Fix naming on MouseEventArgs" This reverts commit 2f557f52d9581e2ca20cc6c022cf1de3c0c326dc. * Add deletion support * Move delete keybinding to tableview * Scaffold for rename and new operations * Prevent delete dialog popping up again on cancel * Add rename and new folder implementations * Add rename,delete,new to context menu * On rename or new, reselect the file in its new location in tree * Support searching on multiple terms * Localization support for new/rename/delete * Refactor internal classes and add class diagram * Move some visual properties to FileDialogStyle * Ensure MultiSelected is never null and always contains Path if relevant * Fix spacing/indentation * WIP: Add new namespace Terminal.Gui.FileServices * Improve appearance of back/forward/up * Move SpinnerLabel to Views * Add SpinnerView * Code formatting * Add AutoSpin test * Avoid ever removing spinner timeout twice * Make SpinnerView show/hide instead of stopping * WIP: Refactor to use 3 sub PRs - SpinnerView - Suggest Append Autocomplete - Caption TextField * Add FilepathSuggestionGenerator * WIP: FileDialog autocomplete append mostly working again * Improve file autocomplete * Move IconGetter to Style and provide default implementation - Depends on `UseUnicodeCharacters` - Also updated up/back/collapse/expand tree to use spicier icons * Fix failing unit test * Improved colors and layout * Adjust use of unicode * Fix UseUnicodeCharacters * Update table style to include scroll indicators and lines * Fix cycle suggestion with down cursor * Adjust sort indicators * Add default sort order on isDir then name * Always use left/right unicode arrows * Fix autocomplete suggesting in empty textbox * Press escape to cancel ongoing search (when search box is focused) * When entering a TreeView if there is no selection then select first object * Move CursorIsAtEnd to TextField * Improve layout * Change UseColors to be a cell color fill * Fxied tests for new apis * Remove manual title drawing code * Fix MoveEnd name conflicting with base class * Fix merge conflicts * Switched to IFileSystem for unit testing * Add unit test * Revert "Fix MoveEnd name conflicting with base class" This reverts commit a5f9c070223815ac2aac0aa1d60a37bb5d61ff8b. * Fix MoveEnd name collision with 'new' keyword * Fixed tree not toggling * DateTime fixes and mocking * Fix TestDirectoryContents_Windows * Expose UseColors and UseUnicodeCharacters as config settings * Fix linter settings having removed curly brackets * Fix namespace on test * Move tests to file services folder * Remove the FileServices namespace * Updated class diagram * Clear title from tests for futureproofing * Localization support for FileDialog title * Remove trailing whitespace in "open existing" * Fix listing suggestions immediately after folder path entered --------- Co-authored-by: Tig --- Terminal.Gui/FileServices/AllowedType.cs | 108 + .../FileServices/DefaultFileOperations.cs | 139 + .../FileServices/DefaultSearchMatcher.cs | 29 + .../FileServices/FileDialogHistory.cs | 108 + .../FileServices/FileDialogRootTreeNode.cs | 48 + Terminal.Gui/FileServices/FileDialogState.cs | 77 + Terminal.Gui/FileServices/FileDialogStyle.cs | 263 ++ .../FileServices/FileDialogTreeBuilder.cs | 38 + .../FileServices/FileSystemInfoStats.cs | 134 + .../FileServices/FilesSelectedEventArgs.cs | 30 + Terminal.Gui/FileServices/IFileOperations.cs | 47 + Terminal.Gui/FileServices/ISearchMatcher.cs | 23 + Terminal.Gui/Resources/Strings.Designer.cs | 216 ++ Terminal.Gui/Resources/Strings.resx | 76 + Terminal.Gui/Terminal.Gui.csproj | 1 + .../Text/Autocomplete/AppendAutocomplete.cs | 15 +- .../Views/AutocompleteFilepathContext.cs | 91 + Terminal.Gui/Views/Button.cs | 20 +- Terminal.Gui/Views/DateField.cs | 2 +- Terminal.Gui/Views/FileDialog.cd | 162 ++ Terminal.Gui/Views/FileDialog.cs | 2279 +++++++++++------ Terminal.Gui/Views/OpenDialog.cs | 90 + Terminal.Gui/Views/SaveDialog.cs | 67 + Terminal.Gui/Views/TableView/TableView.cs | 2 +- Terminal.Gui/Views/TextField.cs | 26 +- Terminal.Gui/Views/TimeField.cs | 2 +- Terminal.Gui/Views/TreeView/TreeView.cs | 8 +- UICatalog/Resources/config.json | 3 + UICatalog/Scenarios/CsvEditor.cs | 13 +- UICatalog/Scenarios/Editor.cs | 23 +- UICatalog/Scenarios/FileDialogExamples.cs | 214 ++ UICatalog/Scenarios/HexEditor.cs | 2 +- UICatalog/Scenarios/Notepad.cs | 6 +- UnitTests/FileServices/FileDialogTests.cs | 317 +++ UnitTests/TestHelpers.cs | 9 +- UnitTests/UnitTests.csproj | 1 + 36 files changed, 3803 insertions(+), 886 deletions(-) create mode 100644 Terminal.Gui/FileServices/AllowedType.cs create mode 100644 Terminal.Gui/FileServices/DefaultFileOperations.cs create mode 100644 Terminal.Gui/FileServices/DefaultSearchMatcher.cs create mode 100644 Terminal.Gui/FileServices/FileDialogHistory.cs create mode 100644 Terminal.Gui/FileServices/FileDialogRootTreeNode.cs create mode 100644 Terminal.Gui/FileServices/FileDialogState.cs create mode 100644 Terminal.Gui/FileServices/FileDialogStyle.cs create mode 100644 Terminal.Gui/FileServices/FileDialogTreeBuilder.cs create mode 100644 Terminal.Gui/FileServices/FileSystemInfoStats.cs create mode 100644 Terminal.Gui/FileServices/FilesSelectedEventArgs.cs create mode 100644 Terminal.Gui/FileServices/IFileOperations.cs create mode 100644 Terminal.Gui/FileServices/ISearchMatcher.cs create mode 100644 Terminal.Gui/Views/AutocompleteFilepathContext.cs create mode 100644 Terminal.Gui/Views/FileDialog.cd create mode 100644 Terminal.Gui/Views/OpenDialog.cs create mode 100644 Terminal.Gui/Views/SaveDialog.cs create mode 100644 UICatalog/Scenarios/FileDialogExamples.cs create mode 100644 UnitTests/FileServices/FileDialogTests.cs diff --git a/Terminal.Gui/FileServices/AllowedType.cs b/Terminal.Gui/FileServices/AllowedType.cs new file mode 100644 index 0000000000..7072c95e9a --- /dev/null +++ b/Terminal.Gui/FileServices/AllowedType.cs @@ -0,0 +1,108 @@ +using System; +using System.CodeDom; +using System.Data; +using System.IO; +using System.Linq; +using Terminal.Gui.Resources; + +namespace Terminal.Gui { + + /// + /// Interface for restrictions on which file type(s) the + /// user is allowed to select/enter. + /// + public interface IAllowedType + { + /// + /// Returns true if the file at is compatible with this + /// allow option. Note that the file may not exist (e.g. in the case of saving). + /// + /// + /// + bool IsAllowed (string path); + } + + + /// + /// that allows selection of any types (*.*). + /// + public class AllowedTypeAny : IAllowedType { + + /// + public bool IsAllowed (string path) + { + return true; + } + + /// + public override string ToString () + { + return Strings.fdAnyFiles + "(*.*)"; + } + } + + /// + /// Describes a requirement on what can be selected. + /// This can be combined with other in a + /// to for example show only .csv files but let user change to open any if they want. + /// + public class AllowedType : IAllowedType { + + /// + /// Initializes a new instance of the class. + /// + /// The human readable text to display. + /// Extension(s) to match e.g. .csv. + public AllowedType (string description, params string [] extensions) + { + if (extensions.Length == 0) { + throw new ArgumentException ("You must supply at least one extension"); + } + + this.Description = description; + this.Extensions = extensions; + } + + /// + /// Gets or Sets the human readable description for the file type + /// e.g. "Comma Separated Values". + /// + public string Description { get; set; } + + /// + /// Gets or Sets the permitted file extension(s) (e.g. ".csv"). + /// + public string [] Extensions { get; set; } + + + /// + /// Returns plus all separated by semicolons. + /// + public override string ToString () + { + const int maxLength = 30; + + var desc = $"{this.Description} ({string.Join (";", this.Extensions.Select (e => '*' + e).ToArray ())})"; + + if(desc.Length > maxLength) { + return desc.Substring (0, maxLength-2) + "…"; + } + return desc; + } + + /// + public bool IsAllowed(string path) + { + var extension = Path.GetExtension (path); + + // There is a requirement to have a particular extension and we have none + if (string.IsNullOrEmpty (extension)) { + return false; + } + + + return this.Extensions.Any (e => e.Equals (extension)); + } + } + +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/DefaultFileOperations.cs b/Terminal.Gui/FileServices/DefaultFileOperations.cs new file mode 100644 index 0000000000..bf403a98be --- /dev/null +++ b/Terminal.Gui/FileServices/DefaultFileOperations.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using Terminal.Gui.Resources; + +namespace Terminal.Gui { + /// + /// Default file operation handlers using modal dialogs. + /// + public class DefaultFileOperations : IFileOperations { + + /// + public bool Delete (IEnumerable toDelete) + { + // Default implementation does not allow deleting multiple files + if (toDelete.Count () != 1) { + return false; + } + var d = toDelete.Single (); + var adjective = d.Name; + + int result = MessageBox.Query ( + string.Format (Strings.fdDeleteTitle, adjective), + string.Format (Strings.fdDeleteBody, adjective), + Strings.fdYes, Strings.fdNo); + + try { + if (result == 0) { + if (d is IFileInfo) { + d.Delete (); + } else { + ((IDirectoryInfo)d).Delete (true); + } + + return true; + } + } catch (Exception ex) { + MessageBox.ErrorQuery (Strings.fdDeleteFailedTitle, ex.Message, "Ok"); + } + + return false; + } + + private bool Prompt (string title, string defaultText, out string result) + { + + bool confirm = false; + var btnOk = new Button ("Ok") { + IsDefault = true, + }; + btnOk.Clicked += (s, e) => { + confirm = true; + Application.RequestStop (); + }; + var btnCancel = new Button ("Cancel"); + btnCancel.Clicked += (s, e) => { + confirm = false; + Application.RequestStop (); + }; + + var lbl = new Label (Strings.fdRenamePrompt); + var tf = new TextField (defaultText) { + X = Pos.Right (lbl), + Width = Dim.Fill (), + }; + tf.SelectAll (); + + var dlg = new Dialog (title) { + Width = Dim.Percent (50), + Height = 4 + }; + dlg.Add (lbl); + dlg.Add (tf); + + // Add buttons last so tab order is friendly + // and TextField gets focus + dlg.AddButton (btnOk); + dlg.AddButton (btnCancel); + + Application.Run (dlg); + + result = tf.Text?.ToString (); + + return confirm; + } + + /// + public IFileSystemInfo Rename (IFileSystem fileSystem, IFileSystemInfo toRename) + { + // Don't allow renaming C: or D: or / (on linux) etc + if (toRename is IDirectoryInfo dir && dir.Parent == null) { + return null; + } + + if (Prompt (Strings.fdRenameTitle, toRename.Name, out var newName)) { + if (!string.IsNullOrWhiteSpace (newName)) { + try { + if (toRename is IFileInfo f) { + + var newLocation = fileSystem.FileInfo.New (Path.Combine (f.Directory.FullName, newName)); + f.MoveTo (newLocation.FullName); + return newLocation; + + } else { + var d = (IDirectoryInfo)toRename; + + var newLocation = fileSystem.DirectoryInfo.New (Path.Combine (d.Parent.FullName, newName)); + d.MoveTo (newLocation.FullName); + return newLocation; + } + } catch (Exception ex) { + MessageBox.ErrorQuery (Strings.fdRenameFailedTitle, ex.Message, "Ok"); + } + } + } + + return null; + } + + /// + public IFileSystemInfo New (IFileSystem fileSystem, IDirectoryInfo inDirectory) + { + if (Prompt (Strings.fdNewTitle, "", out var named)) { + if (!string.IsNullOrWhiteSpace (named)) { + try { + var newDir = fileSystem.DirectoryInfo.New (Path.Combine (inDirectory.FullName, named)); + newDir.Create (); + return newDir; + } catch (Exception ex) { + MessageBox.ErrorQuery (Strings.fdNewFailed, ex.Message, "Ok"); + } + } + } + return null; + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/DefaultSearchMatcher.cs b/Terminal.Gui/FileServices/DefaultSearchMatcher.cs new file mode 100644 index 0000000000..f1930fe437 --- /dev/null +++ b/Terminal.Gui/FileServices/DefaultSearchMatcher.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using System.Linq; + +namespace Terminal.Gui { + class DefaultSearchMatcher : ISearchMatcher { + string [] terms; + + public void Initialize (string terms) + { + this.terms = terms.Split (new string [] { " " }, StringSplitOptions.RemoveEmptyEntries); + } + + public bool IsMatch (IFileSystemInfo f) + { + //Contains overload with StringComparison is not available in (net472) or (netstandard2.0) + //return f.Name.Contains (terms, StringComparison.OrdinalIgnoreCase); + + return + // At least one term must match the file name only e.g. "my" in "myfile.csv" + terms.Any (t => f.Name.IndexOf (t, StringComparison.OrdinalIgnoreCase) >= 0) + && + // All terms must exist in full path e.g. "dos my" can match "c:\documents\myfile.csv" + terms.All (t => f.FullName.IndexOf (t, StringComparison.OrdinalIgnoreCase) >= 0); + } + } + +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileDialogHistory.cs b/Terminal.Gui/FileServices/FileDialogHistory.cs new file mode 100644 index 0000000000..02c181313e --- /dev/null +++ b/Terminal.Gui/FileServices/FileDialogHistory.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; + +namespace Terminal.Gui { + + internal class FileDialogHistory { + private Stack back = new Stack (); + private Stack forward = new Stack (); + private FileDialog dlg; + + public FileDialogHistory (FileDialog dlg) + { + this.dlg = dlg; + } + + public bool Back () + { + + IDirectoryInfo goTo = null; + FileSystemInfoStats restoreSelection = null; + + if (this.CanBack ()) { + + var backTo = this.back.Pop (); + goTo = backTo.Directory; + restoreSelection = backTo.Selected; + } else if (this.CanUp ()) { + goTo = this.dlg.State?.Directory.Parent; + } + + // nowhere to go + if (goTo == null) { + return false; + } + + this.forward.Push (this.dlg.State); + this.dlg.PushState (goTo, false, true, false); + + if (restoreSelection != null) { + this.dlg.RestoreSelection (restoreSelection.FileSystemInfo); + } + + return true; + } + + internal bool CanBack () + { + return this.back.Count > 0; + } + + internal bool Forward () + { + if (this.forward.Count > 0) { + + this.dlg.PushState (this.forward.Pop ().Directory, true, true, false); + return true; + } + + return false; + } + + internal bool Up () + { + var parent = this.dlg.State?.Directory.Parent; + if (parent != null) { + + this.back.Push (new FileDialogState (parent, this.dlg)); + this.dlg.PushState (parent, false); + return true; + } + + return false; + } + + internal bool CanUp () + { + return this.dlg.State?.Directory.Parent != null; + } + + + internal void Push (FileDialogState state, bool clearForward) + { + if (state == null) { + return; + } + + // if changing to a new directory push onto the Back history + if (this.back.Count == 0 || this.back.Peek ().Directory.FullName != state.Directory.FullName) { + + this.back.Push (state); + if (clearForward) { + this.ClearForward (); + } + } + } + + internal bool CanForward () + { + return this.forward.Count > 0; + } + + internal void ClearForward () + { + this.forward.Clear (); + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileDialogRootTreeNode.cs b/Terminal.Gui/FileServices/FileDialogRootTreeNode.cs new file mode 100644 index 0000000000..0355ae4edb --- /dev/null +++ b/Terminal.Gui/FileServices/FileDialogRootTreeNode.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.IO; + +namespace Terminal.Gui { + + /// + /// Delegate for providing an implementation that returns all + /// that should be shown in a (in the collapse-able tree area of the dialog). + /// + /// + public delegate IEnumerable FileDialogTreeRootGetter (); + + /// + /// Describes a top level directory that should be offered to the user in the + /// tree view section of a . For example "Desktop", + /// "Downloads", "Documents" etc. + /// + public class FileDialogRootTreeNode { + + /// + /// Creates a new instance of the class + /// + /// + /// + public FileDialogRootTreeNode (string displayName, DirectoryInfo path) + { + this.DisplayName = displayName; + this.Path = path; + } + + /// + /// Gets the text that should be displayed in the tree for this item. + /// + public string DisplayName { get; } + + /// + /// Gets the path that should be shown/explored when selecting this node + /// of the tree. + /// + public DirectoryInfo Path { get; } + + /// + public override string ToString () + { + return this.DisplayName; + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileDialogState.cs b/Terminal.Gui/FileServices/FileDialogState.cs new file mode 100644 index 0000000000..6db932e656 --- /dev/null +++ b/Terminal.Gui/FileServices/FileDialogState.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; + +namespace Terminal.Gui { + + internal class FileDialogState { + + public FileSystemInfoStats Selected { get; set; } + protected readonly FileDialog Parent; + public FileDialogState (IDirectoryInfo dir, FileDialog parent) + { + this.Directory = dir; + Parent = parent; + + this.RefreshChildren (); + } + + public IDirectoryInfo Directory { get; } + + public FileSystemInfoStats [] Children { get; protected set; } + + internal virtual void RefreshChildren () + { + var dir = this.Directory; + Children = GetChildren (dir).ToArray (); + } + + protected virtual IEnumerable GetChildren (IDirectoryInfo dir) + { + try { + + List children; + + // if directories only + if (Parent.OpenMode == OpenMode.Directory) { + children = dir.GetDirectories ().Select (e => new FileSystemInfoStats (e)).ToList (); + } else { + children = dir.GetFileSystemInfos ().Select (e => new FileSystemInfoStats (e)).ToList (); + } + + // if only allowing specific file types + if (Parent.AllowedTypes.Any () && Parent.OpenMode == OpenMode.File) { + + children = children.Where ( + c => c.IsDir () || + (c.FileSystemInfo is IFileInfo f && Parent.IsCompatibleWithAllowedExtensions (f))) + .ToList (); + } + + // if theres a UI filter in place too + if (Parent.CurrentFilter != null) { + children = children.Where (MatchesApiFilter).ToList (); + } + + + // allow navigating up as '..' + if (dir.Parent != null) { + children.Add (new FileSystemInfoStats (dir.Parent) { IsParent = true }); + } + + return children; + } catch (Exception) { + // Access permissions Exceptions, Dir not exists etc + return Enumerable.Empty (); + } + } + + protected bool MatchesApiFilter (FileSystemInfoStats arg) + { + return arg.IsDir () || + (arg.FileSystemInfo is IFileInfo f && Parent.CurrentFilter.IsAllowed (f.FullName)); + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileDialogStyle.cs b/Terminal.Gui/FileServices/FileDialogStyle.cs new file mode 100644 index 0000000000..7c4f61d5d9 --- /dev/null +++ b/Terminal.Gui/FileServices/FileDialogStyle.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using Terminal.Gui.Resources; +using static System.Environment; +using static Terminal.Gui.ConfigurationManager; + +namespace Terminal.Gui { + + /// + /// Stores style settings for . + /// + public class FileDialogStyle { + + /// + /// Gets or sets the default value to use for . + /// This can be populated from .tui config files via + /// + [SerializableConfigurationProperty(Scope = typeof (SettingsScope))] + public static bool DefaultUseColors { get; set; } + + + /// + /// Gets or sets the default value to use for . + /// This can be populated from .tui config files via + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool DefaultUseUnicodeCharacters { get; set; } + + /// + /// Gets or Sets a value indicating whether different colors + /// should be used for different file types/directories. Defaults + /// to false. + /// + public bool UseColors { get; set; } + + /// + /// Sets a to use for directories rows of + /// the . + /// + public ColorScheme ColorSchemeDirectory { get; set; } + + /// + /// Sets a to use for file rows with an image extension + /// of the . Defaults to White text on Black background. + /// + public ColorScheme ColorSchemeImage { get; set; } + + /// + /// Sets a to use for file rows with an executable extension + /// or that match in the . + /// + public ColorScheme ColorSchemeExeOrRecommended { get; set; } + + /// + /// Colors to use when is true but file does not match any other + /// classification (, etc). + /// + public ColorScheme ColorSchemeOther { get; set; } + + /// + /// Gets or sets the header text displayed in the Filename column of the files table. + /// + public string FilenameColumnName { get; set; } = Strings.fdFilename; + + /// + /// Gets or sets the header text displayed in the Size column of the files table. + /// + public string SizeColumnName { get; set; } = Strings.fdSize; + + /// + /// Gets or sets the header text displayed in the Modified column of the files table. + /// + public string ModifiedColumnName { get; set; } = Strings.fdModified; + + /// + /// Gets or sets the header text displayed in the Type column of the files table. + /// + public string TypeColumnName { get; set; } = Strings.fdType; + + /// + /// Gets or sets the text displayed in the 'Search' text box when user has not supplied any input yet. + /// + public string SearchCaption { get; internal set; } = Strings.fdSearchCaption; + + /// + /// Gets or sets the text displayed in the 'Path' text box when user has not supplied any input yet. + /// + public string PathCaption { get; internal set; } = Strings.fdPathCaption; + + /// + /// Gets or sets the text on the 'Ok' button. Typically you may want to change this to + /// "Open" or "Save" etc. + /// + public string OkButtonText { get; set; } = "Ok"; + + /// + /// Gets or sets error message when user attempts to select a file type that is not one of + /// + public string WrongFileTypeFeedback { get; internal set; } = Strings.fdWrongFileTypeFeedback; + + /// + /// Gets or sets error message when user selects a directory that does not exist and + /// is and is . + /// + public string DirectoryMustExistFeedback { get; internal set; } = Strings.fdDirectoryMustExistFeedback; + + /// + /// Gets or sets error message when user is + /// and user enters the name of an existing file (File system cannot have a folder with the same name as a file). + /// + public string FileAlreadyExistsFeedback { get; internal set; } = Strings.fdFileAlreadyExistsFeedback; + + /// + /// Gets or sets error message when user selects a file that does not exist and + /// is and is . + /// + public string FileMustExistFeedback { get; internal set; } = Strings.fdFileMustExistFeedback; + + /// + /// Gets or sets error message when user is + /// and user enters the name of an existing directory (File system cannot have a folder with the same name as a file). + /// + public string DirectoryAlreadyExistsFeedback { get; internal set; } = Strings.fdDirectoryAlreadyExistsFeedback; + + /// + /// Gets or sets error message when user selects a file/dir that does not exist and + /// is and is . + /// + public string FileOrDirectoryMustExistFeedback { get; internal set; } = Strings.fdFileOrDirectoryMustExistFeedback; + + /// + /// Gets the style settings for the table of files (in currently selected directory). + /// + public TableView.TableStyle TableStyle { get; internal set; } + + /// + /// Gets the style settings for the collapse-able directory/places tree + /// + public TreeStyle TreeStyle { get; internal set; } + + /// + /// Gets or Sets the method for getting the root tree objects that are displayed in + /// the collapse-able tree in the . Defaults to all accessible + /// and unique + /// . + /// + /// Must be configured before showing the dialog. + public FileDialogTreeRootGetter TreeRootGetter { get; set; } = DefaultTreeRootGetter; + + /// + /// Gets or sets whether to use advanced unicode characters which might not be installed + /// on all users computers. + /// + public bool UseUnicodeCharacters { get; set; } = DefaultUseUnicodeCharacters; + + + /// + /// User defined delegate for picking which character(s)/unicode + /// symbol(s) to use as an 'icon' for files/folders. + /// + public Func IconGetter { get; set; } + + + /// + /// Gets or sets the format to use for date/times in the Modified column. + /// Defaults to + /// of the + /// + public string DateFormat { get; set; } + + + /// + /// Creates a new instance of the class. + /// + public FileDialogStyle () + { + IconGetter = DefaultIconGetter; + DateFormat = CultureInfo.CurrentCulture.DateTimeFormat.SortableDateTimePattern; + + ColorSchemeDirectory = new ColorScheme { + Normal = Application.Driver.MakeAttribute (Color.Blue, Color.Black), + HotNormal = Application.Driver.MakeAttribute (Color.Blue, Color.Black), + Focus = Application.Driver.MakeAttribute (Color.Black, Color.Blue), + HotFocus = Application.Driver.MakeAttribute (Color.Black, Color.Blue), + + }; + + ColorSchemeImage = new ColorScheme { + Normal = Application.Driver.MakeAttribute (Color.Magenta, Color.Black), + HotNormal = Application.Driver.MakeAttribute (Color.Magenta, Color.Black), + Focus = Application.Driver.MakeAttribute (Color.Black, Color.Magenta), + HotFocus = Application.Driver.MakeAttribute (Color.Black, Color.Magenta), + }; + ColorSchemeExeOrRecommended = new ColorScheme { + Normal = Application.Driver.MakeAttribute (Color.Green, Color.Black), + HotNormal = Application.Driver.MakeAttribute (Color.Green, Color.Black), + Focus = Application.Driver.MakeAttribute (Color.Black, Color.Green), + HotFocus = Application.Driver.MakeAttribute (Color.Black, Color.Green), + }; + ColorSchemeOther = new ColorScheme { + Normal = Application.Driver.MakeAttribute (Color.White, Color.Black), + HotNormal = Application.Driver.MakeAttribute (Color.White, Color.Black), + Focus = Application.Driver.MakeAttribute (Color.Black, Color.White), + HotFocus = Application.Driver.MakeAttribute (Color.Black, Color.White), + }; + + } + + private string DefaultIconGetter (IFileSystemInfo arg) + { + if (arg is IDirectoryInfo) { + return UseUnicodeCharacters ? "\ua909 " : "\\"; + } + + return UseUnicodeCharacters ? "\u2630 " : ""; + + } + + private static IEnumerable DefaultTreeRootGetter () + { + var roots = new List (); + try { + foreach (var d in Environment.GetLogicalDrives ()) { + roots.Add (new FileDialogRootTreeNode (d, new DirectoryInfo (d))); + } + + } catch (Exception) { + // Cannot get the system disks thats fine + } + + + try { + foreach (var special in Enum.GetValues (typeof (Environment.SpecialFolder)).Cast ()) { + try { + var path = Environment.GetFolderPath (special); + if ( + !string.IsNullOrWhiteSpace (path) + && Directory.Exists (path) + && !roots.Any (r => string.Equals (r.Path.FullName, path))) { + + roots.Add (new FileDialogRootTreeNode ( + special.ToString (), + new DirectoryInfo (Environment.GetFolderPath (special)))); + } + } catch (Exception) { + // Special file exists but contents are unreadable (permissions?) + // skip it anyway + } + } + } catch (Exception) { + // Cannot get the special files for this OS oh well + } + + return roots; + } + } + +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileDialogTreeBuilder.cs b/Terminal.Gui/FileServices/FileDialogTreeBuilder.cs new file mode 100644 index 0000000000..408a0d762a --- /dev/null +++ b/Terminal.Gui/FileServices/FileDialogTreeBuilder.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + + +namespace Terminal.Gui { + + class FileDialogTreeBuilder : ITreeBuilder { + public bool SupportsCanExpand => true; + + public bool CanExpand (object toExpand) + { + return this.TryGetDirectories (NodeToDirectory (toExpand)).Any (); + } + + public IEnumerable GetChildren (object forObject) + { + return this.TryGetDirectories (NodeToDirectory (forObject)); + } + + internal static DirectoryInfo NodeToDirectory (object toExpand) + { + return toExpand is FileDialogRootTreeNode f ? f.Path : (DirectoryInfo)toExpand; + } + + private IEnumerable TryGetDirectories (DirectoryInfo directoryInfo) + { + try { + return directoryInfo.EnumerateDirectories (); + } catch (Exception) { + + return Enumerable.Empty (); + } + } + + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FileSystemInfoStats.cs b/Terminal.Gui/FileServices/FileSystemInfoStats.cs new file mode 100644 index 0000000000..463d0a6eb1 --- /dev/null +++ b/Terminal.Gui/FileServices/FileSystemInfoStats.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; + +namespace Terminal.Gui { + + /// + /// Wrapper for that contains additional information + /// (e.g. ) and helper methods. + /// + internal class FileSystemInfoStats { + + + /* ---- Colors used by the ls command line tool ---- + * + * Blue: Directory + * Green: Executable or recognized data file + * Cyan (Sky Blue): Symbolic link file + * Yellow with black background: Device + * Magenta (Pink): Graphic image file + * Red: Archive file + * Red with black background: Broken link + */ + + private const long ByteConversion = 1024; + + private static readonly string [] SizeSuffixes = { "bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" }; + private static readonly List ImageExtensions = new List { ".JPG", ".JPEG", ".JPE", ".BMP", ".GIF", ".PNG" }; + private static readonly List ExecutableExtensions = new List { ".EXE", ".BAT" }; + + /// + /// Initializes a new instance of the class. + /// + /// The directory of path to wrap. + public FileSystemInfoStats (IFileSystemInfo fsi) + { + this.FileSystemInfo = fsi; + this.LastWriteTime = fsi.LastWriteTime; + + if (fsi is IFileInfo fi) { + this.MachineReadableLength = fi.Length; + this.HumanReadableLength = GetHumanReadableFileSize (this.MachineReadableLength); + this.Type = fi.Extension; + } else { + this.HumanReadableLength = string.Empty; + this.Type = "dir"; + } + } + + /// + /// Gets the wrapped (directory or file). + /// + public IFileSystemInfo FileSystemInfo { get; } + public string HumanReadableLength { get; } + public long MachineReadableLength { get; } + public DateTime? LastWriteTime { get; } + public string Type { get; } + + /// + /// Gets or Sets a value indicating whether this instance represents + /// the parent of the current state (i.e. ".."). + /// + public bool IsParent { get; internal set; } + public string Name => this.IsParent ? ".." : this.FileSystemInfo.Name; + + public bool IsDir () + { + return this.Type == "dir"; + } + + public bool IsImage () + { + return this.FileSystemInfo is FileSystemInfo f && + ImageExtensions.Contains ( + f.Extension, + StringComparer.InvariantCultureIgnoreCase); + } + + public bool IsExecutable () + { + // TODO: handle linux executable status + return this.FileSystemInfo is FileSystemInfo f && + ExecutableExtensions.Contains ( + f.Extension, + StringComparer.InvariantCultureIgnoreCase); + } + + internal object GetOrderByValue (FileDialog dlg, string columnName) + { + if (dlg.Style.FilenameColumnName == columnName) + return this.FileSystemInfo.Name; + + if (dlg.Style.SizeColumnName == columnName) + return this.MachineReadableLength; + + if (dlg.Style.ModifiedColumnName == columnName) + return this.LastWriteTime; + + if (dlg.Style.TypeColumnName == columnName) + return this.Type; + + throw new ArgumentOutOfRangeException ("Unknown column " + nameof (columnName)); + } + + internal object GetOrderByDefault () + { + if (this.IsDir ()) { + return -1; + } + + return 100; + } + + private static string GetHumanReadableFileSize (long value) + { + + if (value < 0) { + return "-" + GetHumanReadableFileSize (-value); + } + + if (value == 0) { + return "0.0 bytes"; + } + + int mag = (int)Math.Log (value, ByteConversion); + double adjustedSize = value / Math.Pow (1000, mag); + + + return string.Format ("{0:n2} {1}", adjustedSize, SizeSuffixes [mag]); + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/FilesSelectedEventArgs.cs b/Terminal.Gui/FileServices/FilesSelectedEventArgs.cs new file mode 100644 index 0000000000..85d3c34b8e --- /dev/null +++ b/Terminal.Gui/FileServices/FilesSelectedEventArgs.cs @@ -0,0 +1,30 @@ +using System; + +namespace Terminal.Gui { + /// + /// Event args for the event + /// + public class FilesSelectedEventArgs : EventArgs { + /// + /// Set to true if you want to prevent the selection + /// going ahead (this will leave the + /// still showing). + /// + public bool Cancel { get; set; } + + /// + /// The dialog where the choice is being made. Use + /// and/or to evaluate the users choice. + /// + public FileDialog Dialog { get; } + + /// + /// Creates a new instance of the + /// + /// + public FilesSelectedEventArgs (FileDialog dialog) + { + Dialog = dialog; + } + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/IFileOperations.cs b/Terminal.Gui/FileServices/IFileOperations.cs new file mode 100644 index 0000000000..e586b62d46 --- /dev/null +++ b/Terminal.Gui/FileServices/IFileOperations.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; + +namespace Terminal.Gui { + /// + /// Interface for defining how to handle file/directory + /// deletion, rename and newing attempts in . + /// + public interface IFileOperations { + /// + /// Specifies how to handle file/directory deletion attempts + /// in . + /// + /// + /// if operation was completed or + /// if cancelled + /// Ensure you use a try/catch block with appropriate + /// error handling (e.g. showing a + bool Delete (IEnumerable toDelete); + + + /// + /// Specifies how to handle file/directory rename attempts + /// in . + /// + /// + /// + /// The new name for the file or null if cancelled + /// Ensure you use a try/catch block with appropriate + /// error handling (e.g. showing a + IFileSystemInfo Rename (IFileSystem fileSystem, IFileSystemInfo toRename); + + + /// + /// Specifies how to handle 'new directory' operation + /// in . + /// + /// + /// The parent directory in which the new + /// directory should be created + /// The newly created directory or null if cancelled. + /// Ensure you use a try/catch block with appropriate + /// error handling (e.g. showing a + IFileSystemInfo New (IFileSystem fileSystem, IDirectoryInfo inDirectory); + } +} \ No newline at end of file diff --git a/Terminal.Gui/FileServices/ISearchMatcher.cs b/Terminal.Gui/FileServices/ISearchMatcher.cs new file mode 100644 index 0000000000..7bc4c2a1aa --- /dev/null +++ b/Terminal.Gui/FileServices/ISearchMatcher.cs @@ -0,0 +1,23 @@ +using System.IO; +using System.IO.Abstractions; + +namespace Terminal.Gui { + + /// + /// Defines whether a given file/directory matches a set of + /// search terms. + /// + public interface ISearchMatcher { + /// + /// Called once for each new search. Defines the string + /// the user has provided as search terms. + /// + void Initialize (string terms); + + /// + /// Return true if is a match to the + /// last provided search terms + /// + bool IsMatch (IFileSystemInfo f); + } +} \ No newline at end of file diff --git a/Terminal.Gui/Resources/Strings.Designer.cs b/Terminal.Gui/Resources/Strings.Designer.cs index 4c10f6e7df..00d978a766 100644 --- a/Terminal.Gui/Resources/Strings.Designer.cs +++ b/Terminal.Gui/Resources/Strings.Designer.cs @@ -123,6 +123,42 @@ internal static string ctxUndo { } } + /// + /// Looks up a localized string similar to Any Files. + /// + internal static string fdAnyFiles { + get { + return ResourceManager.GetString("fdAnyFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Are you sure you want to delete '{0}'? This operation is permanent. + /// + internal static string fdDeleteBody { + get { + return ResourceManager.GetString("fdDeleteBody", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Failed. + /// + internal static string fdDeleteFailedTitle { + get { + return ResourceManager.GetString("fdDeleteFailedTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete {0}. + /// + internal static string fdDeleteTitle { + get { + return ResourceManager.GetString("fdDeleteTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Directory. /// @@ -132,6 +168,33 @@ internal static string fdDirectory { } } + /// + /// Looks up a localized string similar to Directory already exists with that name. + /// + internal static string fdDirectoryAlreadyExistsFeedback { + get { + return ResourceManager.GetString("fdDirectoryAlreadyExistsFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Must select an existing directory. + /// + internal static string fdDirectoryMustExistFeedback { + get { + return ResourceManager.GetString("fdDirectoryMustExistFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Existing. + /// + internal static string fdExisting { + get { + return ResourceManager.GetString("fdExisting", resourceCulture); + } + } + /// /// Looks up a localized string similar to File. /// @@ -141,6 +204,78 @@ internal static string fdFile { } } + /// + /// Looks up a localized string similar to File already exists with that name. + /// + internal static string fdFileAlreadyExistsFeedback { + get { + return ResourceManager.GetString("fdFileAlreadyExistsFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Must select an existing file. + /// + internal static string fdFileMustExistFeedback { + get { + return ResourceManager.GetString("fdFileMustExistFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Filename. + /// + internal static string fdFilename { + get { + return ResourceManager.GetString("fdFilename", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Must select an existing file or directory. + /// + internal static string fdFileOrDirectoryMustExistFeedback { + get { + return ResourceManager.GetString("fdFileOrDirectoryMustExistFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Modified. + /// + internal static string fdModified { + get { + return ResourceManager.GetString("fdModified", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New Failed. + /// + internal static string fdNewFailed { + get { + return ResourceManager.GetString("fdNewFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to New Folder. + /// + internal static string fdNewTitle { + get { + return ResourceManager.GetString("fdNewTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No. + /// + internal static string fdNo { + get { + return ResourceManager.GetString("fdNo", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open. /// @@ -150,6 +285,42 @@ internal static string fdOpen { } } + /// + /// Looks up a localized string similar to Enter Path. + /// + internal static string fdPathCaption { + get { + return ResourceManager.GetString("fdPathCaption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rename Failed. + /// + internal static string fdRenameFailedTitle { + get { + return ResourceManager.GetString("fdRenameFailedTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name:. + /// + internal static string fdRenamePrompt { + get { + return ResourceManager.GetString("fdRenamePrompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rename. + /// + internal static string fdRenameTitle { + get { + return ResourceManager.GetString("fdRenameTitle", resourceCulture); + } + } + /// /// Looks up a localized string similar to Save. /// @@ -168,6 +339,15 @@ internal static string fdSaveAs { } } + /// + /// Looks up a localized string similar to Enter Search. + /// + internal static string fdSearchCaption { + get { + return ResourceManager.GetString("fdSearchCaption", resourceCulture); + } + } + /// /// Looks up a localized string similar to Select folder. /// @@ -186,6 +366,42 @@ internal static string fdSelectMixed { } } + /// + /// Looks up a localized string similar to Size. + /// + internal static string fdSize { + get { + return ResourceManager.GetString("fdSize", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Type. + /// + internal static string fdType { + get { + return ResourceManager.GetString("fdType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong file type. + /// + internal static string fdWrongFileTypeFeedback { + get { + return ResourceManager.GetString("fdWrongFileTypeFeedback", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Yes. + /// + internal static string fdYes { + get { + return ResourceManager.GetString("fdYes", resourceCulture); + } + } + /// /// Looks up a localized string similar to _Back. /// diff --git a/Terminal.Gui/Resources/Strings.resx b/Terminal.Gui/Resources/Strings.resx index f6cfb7b4b6..873c6184d5 100644 --- a/Terminal.Gui/Resources/Strings.resx +++ b/Terminal.Gui/Resources/Strings.resx @@ -168,4 +168,80 @@ _Next... + + Directory already exists with that name + When trying to save a file with a name already taken by a directory + + + Must select an existing directory + + + File already exists with that name + + + Must select an existing file + When trying to save a directory with a name already used by a file + + + Filename + + + Must select an existing file or directory + + + Modified + + + Enter Path + + + Enter Search + + + Size + + + Type + + + Wrong file type + When trying to open/save a file that does not match the provided filter (e.g. csv) + + + Any Files + Describes an AllowedType that matches anything + + + Are you sure you want to delete '{0}'? This operation is permanent + + + Delete Failed + + + Delete {0} + + + New Failed + + + New Folder + + + No + + + Rename Failed + + + Name: + + + Rename + + + Yes + + + Existing + \ No newline at end of file diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj index a54bc1cac2..e9f2903d82 100644 --- a/Terminal.Gui/Terminal.Gui.csproj +++ b/Terminal.Gui/Terminal.Gui.csproj @@ -27,6 +27,7 @@ + diff --git a/Terminal.Gui/Text/Autocomplete/AppendAutocomplete.cs b/Terminal.Gui/Text/Autocomplete/AppendAutocomplete.cs index e774722dfa..5f6caaa9ea 100644 --- a/Terminal.Gui/Text/Autocomplete/AppendAutocomplete.cs +++ b/Terminal.Gui/Text/Autocomplete/AppendAutocomplete.cs @@ -138,7 +138,7 @@ internal bool AcceptSelectionIfAny () newText += insert.Replacement; textField.Text = newText; - this.MoveCursorToEnd (); + this.textField.MoveEnd(); this.ClearSuggestions (); return true; @@ -147,11 +147,6 @@ internal bool AcceptSelectionIfAny () return false; } - internal void MoveCursorToEnd () - { - textField.ClearAllSelection (); - textField.CursorPosition = textField.Text.Length; - } internal void SetTextTo (FileSystemInfo fileSystemInfo) { @@ -160,13 +155,9 @@ internal void SetTextTo (FileSystemInfo fileSystemInfo) newText += System.IO.Path.DirectorySeparatorChar; } textField.Text = newText; - this.MoveCursorToEnd (); + textField.MoveEnd (); } - internal bool CursorIsAtEnd () - { - return textField.CursorPosition == textField.Text.Length; - } /// /// Returns true if there is a suggestion that can be made and the control @@ -176,7 +167,7 @@ internal bool CursorIsAtEnd () /// private bool MakingSuggestion () { - return Suggestions.Any () && this.SelectedIdx != -1 && textField.HasFocus && this.CursorIsAtEnd (); + return Suggestions.Any () && this.SelectedIdx != -1 && textField.HasFocus && textField.CursorIsAtEnd (); } private bool CycleSuggestion (int direction) diff --git a/Terminal.Gui/Views/AutocompleteFilepathContext.cs b/Terminal.Gui/Views/AutocompleteFilepathContext.cs new file mode 100644 index 0000000000..eb8af28890 --- /dev/null +++ b/Terminal.Gui/Views/AutocompleteFilepathContext.cs @@ -0,0 +1,91 @@ +using NStack; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Terminal.Gui { + internal class AutocompleteFilepathContext : AutocompleteContext { + public FileDialogState State { get; set; } + + public AutocompleteFilepathContext (ustring currentLine, int cursorPosition, FileDialogState state) + : base (currentLine.ToRuneList (), cursorPosition) + { + this.State = state; + } + } + + + internal class FilepathSuggestionGenerator : ISuggestionGenerator { + + FileDialogState state; + public IEnumerable GenerateSuggestions (AutocompleteContext context) + { + if (context is AutocompleteFilepathContext fileState) { + this.state = fileState.State; + } + + if (state == null) { + return Enumerable.Empty (); + } + + var path = ustring.Make (context.CurrentLine).ToString (); + var last = path.LastIndexOfAny (FileDialog.Separators); + + if(string.IsNullOrWhiteSpace(path) || !Path.IsPathRooted(path)) { + return Enumerable.Empty (); + } + + var term = path.Substring (last + 1); + + // If path is /tmp/ then don't just list everything in it + if(string.IsNullOrWhiteSpace(term)) + { + return Enumerable.Empty (); + } + + if (term.Equals (state?.Directory?.Name)) { + // Clear suggestions + return Enumerable.Empty (); + } + + bool isWindows = RuntimeInformation.IsOSPlatform (OSPlatform.Windows); + + var suggestions = state.Children.Where(d=> !d.IsParent).Select ( + e => e.FileSystemInfo is IDirectoryInfo d + ? d.Name + System.IO.Path.DirectorySeparatorChar + : e.FileSystemInfo.Name) + .ToArray (); + + var validSuggestions = suggestions + .Where (s => s.StartsWith (term, isWindows ? + StringComparison.InvariantCultureIgnoreCase : + StringComparison.InvariantCulture)) + .OrderBy (m => m.Length) + .ToArray (); + + + // nothing to suggest + if (validSuggestions.Length == 0 || validSuggestions [0].Length == term.Length) { + return Enumerable.Empty (); + } + + return validSuggestions.Select ( + f => new Suggestion (term.Length, f, f)).ToList (); + } + + public bool IsWordChar (Rune rune) + { + if (rune == '\n') { + return false; + } + + + return true; + } + + + } +} \ No newline at end of file diff --git a/Terminal.Gui/Views/Button.cs b/Terminal.Gui/Views/Button.cs index 43d83e1123..ff615ce56f 100644 --- a/Terminal.Gui/Views/Button.cs +++ b/Terminal.Gui/Views/Button.cs @@ -163,13 +163,31 @@ public override Key HotKey { } } + public bool NoDecorations {get;set;} + public bool NoPadding {get;set;} + /// protected override void UpdateTextFormatterText () { + if(NoDecorations) + { + TextFormatter.Text = Text; + } + else if (IsDefault) TextFormatter.Text = ustring.Make (_leftBracket) + ustring.Make (_leftDefault) + " " + Text + " " + ustring.Make (_rightDefault) + ustring.Make (_rightBracket); else - TextFormatter.Text = ustring.Make (_leftBracket) + " " + Text + " " + ustring.Make (_rightBracket); + { + if(NoPadding) + { + TextFormatter.Text = ustring.Make (_leftBracket) + Text + ustring.Make (_rightBracket); + } + else + { + TextFormatter.Text = ustring.Make (_leftBracket) + " " + Text + " " + ustring.Make (_rightBracket); + } + } + } /// diff --git a/Terminal.Gui/Views/DateField.cs b/Terminal.Gui/Views/DateField.cs index 8f0c1f5f2e..f537fb25ae 100644 --- a/Terminal.Gui/Views/DateField.cs +++ b/Terminal.Gui/Views/DateField.cs @@ -354,7 +354,7 @@ bool MoveRight () return true; } - bool MoveEnd () + new bool MoveEnd () { CursorPosition = fieldLen; return true; diff --git a/Terminal.Gui/Views/FileDialog.cd b/Terminal.Gui/Views/FileDialog.cd new file mode 100644 index 0000000000..2b42f6c715 --- /dev/null +++ b/Terminal.Gui/Views/FileDialog.cd @@ -0,0 +1,162 @@ + + + + + + + + + + + + + Windows\FileDialog.cs + + + + + Windows\FileDialog.cs + + + + + goYYDAEnEDIZgHMByFAikQDFSIUQpUDABoZIFRSQwgQ= + Views\FileDialog.cs + + + + + + + + + + + + + + AAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAABAA= + FileServices\AllowedType.cs + + + + + + + AAAAAAAAAAAgAAAEAAAAAAAAAAAAAAAAAAgAAAAABAA= + FileServices\AllowedType.cs + + + + + + + AAAAAAAAAAAAACAAAAAAACAAAAEAAAAAAAAAAAAAgAA= + FileServices\DefaultFileOperations.cs + + + + + + + AAACAAAAAAgAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + FileServices\DefaultSearchMatcher.cs + + + + + + + AQABAgEAAAAAAAAAIACAAAAAAAAAAQAAAAAAAAAADAI= + FileServices\FileDialogHistory.cs + + + + + + + + + AAAAEAAAAAAAAAIEAAAAAAAAAAAAAAAAAAAAAAAAAAA= + FileServices\FileDialogRootTreeNode.cs + + + + + + AABAABAAAAAAAAAAAAAEQAAAAAAAQAAAAgAAAAAAAAI= + FileServices\FileDialogState.cs + + + + + + + + + GgBIAAFEAAAAuAAAAAAEEACABAACKRkAAAEYACCAAAA= + FileServices\FileDialogStyle.cs + + + + + + EAAAAAAAAAAAAAAAQAAAAAAAAAAAQAAAAAAAAAQACAA= + FileServices\FileDialogTreeBuilder.cs + + + + + + + + + + AAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAA= + FileServices\FilesSelectedEventArgs.cs + + + + + + ABAIQAIIIAAAAAACQAAAAIQAAAQAAIAAAQABAAAYAAI= + FileServices\FileSystemInfoStats.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA= + FileServices\AllowedType.cs + + + + + + AAAAAAAAAAAAACAAAAAAAAAAAAEAAAAAAAAAAAAAgAA= + FileServices\IFileOperations.cs + + + + + + AAACAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + FileServices\ISearchMatcher.cs + + + + + + AAAAABAAAAAAACAAAAAAAAAAAAAEAAAAAAAAAAAAAAA= + Views\OpenDialog.cs + + + + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAA= + FileServices\FileDialogRootTreeNode.cs + + + + \ No newline at end of file diff --git a/Terminal.Gui/Views/FileDialog.cs b/Terminal.Gui/Views/FileDialog.cs index eb8df038f8..eb43d1ec09 100644 --- a/Terminal.Gui/Views/FileDialog.cs +++ b/Terminal.Gui/Views/FileDialog.cs @@ -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) + + /// + /// Modal dialog for selecting files/directories. Has auto-complete and expandable + /// navigation pane (Recent, Root drives etc). + /// + public partial class FileDialog : Dialog { + + /// + /// Gets settings for controlling how visual elements behave. Style changes should + /// be made before the is loaded and shown to the user for the + /// first time. + /// + public FileDialogStyle Style { get; } = new FileDialogStyle (); + + /// + /// The maximum number of results that will be collected + /// when searching before stopping. + /// + /// + /// This prevents performance issues e.g. when searching + /// root of file system for a common letter (e.g. 'e'). + /// + [SerializableConfigurationProperty (Scope = typeof (SettingsScope))] + public static int MaxSearchResults { get; set; } = 10000; + + /// + /// 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. + /// + public bool MustExist { get; set; } + + /// + /// Gets the Path separators for the operating system + /// + 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) + /// + /// Characters to prevent entry into . Note that this is not using + /// because we do want to allow directory + /// separators, arrow keys etc. + /// + 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; + '"','<','>','|','*','?', + }; + + + /// + /// The UI selected from combo box. May be null. + /// + public IAllowedType CurrentFilter { get; private set; } + + private bool pushingState = false; + private bool loaded = false; + + /// + /// Gets the currently open directory and known children presented in the dialog. + /// + internal FileDialogState State { get; private set; } + + /// + /// Locking object for ensuring only a single executes at once. + /// + 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 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; + + /// + /// 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". + /// + public event EventHandler FilesSelected; + + /// + /// Gets or sets behavior of the when the user attempts + /// to delete a selected file(s). Set to null to prevent deleting. + /// + /// Ensure you use a try/catch block with appropriate + /// error handling (e.g. showing a + public IFileOperations FileOperationsHandler { get; set; } = new DefaultFileOperations (); + + + /// + /// Initializes a new instance of the class. + /// + public FileDialog () : this(new FileSystem()) + { + } - internal bool Reload (ustring value = null) + /// + /// Initializes a new instance of the class with + /// a custom . + /// + /// This overload is mainly useful for testing. + 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 () { + 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 DirectoryChanged { get; set; } - public Action 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 GetFilesName (IReadOnlyList files) + private void New () { - List filesName = new List (); - - 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 filesList = new List (files.Split (splitString.ToArray (), StringSplitOptions.None)); - var filesName = new List (); - 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 (); + + /// + 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 () + /// + 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 FilePaths { - get { - if (allowsMultipleSelection) { - var res = new List (); - 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 () : new List () { MakePath (infos [selected].Item1) }; - } - return Array.Empty (); - } else { - if (canChooseFiles) { - return new List () { MakePath (infos [selected].Item1) }; - } - return Array.Empty (); - } - } - } + var collection = tableView + .Table + .Rows + .Cast () + .Select ((o, idx) => RowToStats (idx)) + .Select (s => s.FileSystemInfo.Name) + .ToArray (); + + collectionNavigator = new CollectionNavigator (collection); } - /// - public override bool OnEnter (View view) - { - Application.Driver.SetCursorVisibility (CursorVisibility.Invisible); + /// + /// Gets or Sets which type can be selected. + /// Defaults to (i.e. or + /// ). + /// + public OpenMode OpenMode { get; set; } = OpenMode.Mixed; - return base.OnEnter (view); + /// + /// Gets or Sets the selected path in the dialog. This is the result that should + /// be used if is off and + /// is true. + /// + public string Path { + get => this.tbPath.Text.ToString (); + set { + this.tbPath.Text = value; + this.tbPath.MoveEnd (); + } } - } - /// - /// Base class for the and the - /// - public class FileDialog : Dialog { - Button prompt, cancel; - Label nameFieldLabel, message, nameDirLabel; - TextField dirEntry, nameEntry; - internal DirListView dirListView; - ComboBox cmbAllowedTypes; + /// + /// 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. + /// + public ISearchMatcher SearchMatcher { get; set; } = new DefaultSearchMatcher (); /// - /// Initializes a new . + /// Gets or Sets a value indicating whether to allow selecting + /// multiple existing files/directories. Defaults to false. /// - 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; + } + /// - /// Initializes a new instance of + /// Gets or Sets a collection of file types that the user can/must select. Only applies + /// when is or . /// - /// The title. - /// The prompt. - /// The name of the file field label.. - /// The message. - /// The allowed types. - public FileDialog (ustring title, ustring prompt, ustring nameFieldLabel, ustring message, List allowedTypes = null) - : this (title, prompt, ustring.Empty, nameFieldLabel, message, allowedTypes) { } + /// adds the option to select any type (*.*). If this + /// collection is empty then any type is supported and no Types drop-down is shown. + public List AllowedTypes { get; set; } = new List (); /// - /// Initializes a new instance of + /// Gets a value indicating whether the was closed + /// without confirming a selection. /// - /// The title. - /// The prompt. - /// The message. - /// The allowed types. - public FileDialog (ustring title, ustring prompt, ustring message, List allowedTypes) - : this (title, prompt, ustring.Empty, message, allowedTypes) { } + public bool Canceled { get; private set; } = true; /// - /// Initializes a new instance of + /// Gets all files/directories selected or an empty collection + /// is or . /// - /// The title. - /// The prompt. - /// The name of the directory field label. - /// The name of the file field label.. - /// The message. - /// The allowed types. - public FileDialog (ustring title, ustring prompt, ustring nameDirLabel, ustring nameFieldLabel, ustring message, - List allowedTypes = null) : base (title)//, Driver.Cols - 20, Driver.Rows - 5, null) + /// If selecting only a single file/directory then you should use instead. + public IReadOnlyList MultiSelected { get; private set; } + = Enumerable.Empty ().ToList ().AsReadOnly (); + + + /// + 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 ()); - 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; - } - }; + /// + 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 ("", + 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 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; - /// - 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 (); + } + } - /// - /// Gets or sets the prompt label for the displayed to the user - /// - /// The prompt. - public ustring Prompt { - get => prompt.Text; - set { - prompt.Text = value; + private void Accept (IEnumerable 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 (); } - /// - /// Gets or sets the name of the directory field label. - /// - /// The name of the directory field label. - 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 { f.FullName }.AsReadOnly (); + } + + FinishAccept (); } - /// - /// Gets or sets the name field label. - /// - /// The name field label. - 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 (); } - /// - /// Gets or sets the message displayed to the user, defaults to nothing - /// - /// The message. - 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 ().ToList ().AsReadOnly () : + new List () { Path }.AsReadOnly (); } + + this.Canceled = false; + Application.RequestStop (); } - /// - /// Gets or sets a value indicating whether this can create directories. - /// - /// true if can create directories; otherwise, false. - public bool CanCreateDirectories { get; set; } + private void NavigateIf (KeyEventEventArgs keyEvent, Key isKey, View to) + { + if (!keyEvent.Handled) { - /// - /// Gets or sets a value indicating whether this is extension hidden. - /// - /// true if is extension hidden; otherwise, false. - public bool IsExtensionHidden { get; set; } + if (NavigateIf (keyEvent.KeyEvent, isKey, to)) { + keyEvent.Handled = true; + } + } + } - /// - /// Gets or sets the directory path for this panel - /// - /// The directory path. - 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 e) + { + if (e.NewValue == null) { + return; + } + + this.tbPath.Text = FileDialogTreeBuilder.NodeToDirectory (e.NewValue).FullName; + } - /// - /// The array of filename extensions allowed, or null if all file extensions are allowed. - /// - /// The allowed file types. - 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; } } - /// - /// Gets or sets a value indicating whether this allows the file to be saved with a different extension - /// - /// true if allows other file types; otherwise, false. - public bool AllowsOtherFileTypes { get; set; } - /// - /// The File path that is currently shown on the panel - /// - /// The absolute file path for the file path entered. - 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; } - /// - /// Check if the dialog was or not canceled. - /// - public bool Canceled { get => canceled; } - } - /// - /// The provides an interactive dialog box for users to pick a file to - /// save. - /// - /// - /// - /// To use, create an instance of , and pass it to - /// . This will run the dialog modally, - /// and when this returns, the property will contain the selected file name or - /// null if the user canceled. - /// - /// - public class SaveDialog : FileDialog { - /// - /// Initializes a new . - /// - public SaveDialog () : this (title: string.Empty, message: string.Empty) { } + private void SetupTableColumns () + { + this.dtFiles = new DataTable (); - /// - /// Initializes a new . - /// - /// The title. - /// The message. - /// The allowed types. - public SaveDialog (ustring title, ustring message, List 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) => { - /// - /// Gets the name of the file the user selected for saving, or null - /// if the user canceled the . - /// - /// The name of the file. - 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; } + } - } - /// - /// The provides an interactive dialog box for users to select files or directories. - /// - /// - /// - /// 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. - /// - /// - /// To use, create an instance of , and pass it to - /// . This will run the dialog modally, - /// and when this returns, the list of files will be available on the property. - /// - /// - /// To select more than one file, users can use the spacebar, or control-t. - /// - /// - 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 (); + } - /// - /// Determine which type to open. - /// - public enum OpenMode { - /// - /// Opens only file or files. - /// - File, - /// - /// Opens only directory or directories. - /// - Directory, - /// - /// Opens files and directories. - /// - 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); + } } /// - /// Initializes a new . + /// Returns true if there are no or one of them agrees + /// that . /// - public OpenDialog () : this (title: string.Empty, message: string.Empty) { } + /// + /// + 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)); + } /// - /// Initializes a new . + /// Returns true if any matches . /// - /// The title. - /// The message. - /// The allowed types. - /// The open mode. - public OpenDialog (ustring title, ustring message, List 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) + /// + /// + 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)); } } /// - /// Gets or sets a value indicating whether this can choose files. + /// Changes the dialog such that is being explored. /// - /// true if can choose files; otherwise, false. Defaults to true - public bool CanChooseFiles { - get => dirListView.canChooseFiles; - set { - dirListView.canChooseFiles = value; - dirListView.Reload (); + /// + /// + /// + /// + 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; } /// - /// Gets or sets a value indicating whether this can choose directories. + /// If is on and multiple rows are selected + /// this returns a union of all in the selection. /// - /// true if can choose directories; otherwise, false defaults to false. - public bool CanChooseDirectories { - get => dirListView.canChooseDirectories; - set { - dirListView.canChooseDirectories = value; - dirListView.Reload (); + /// Returns an empty collection if there are not at least 2 rows in the selection + /// + private IEnumerable MultiRowToStats () + { + var toReturn = new HashSet (); + + 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 (); + } + 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 () + .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); } /// - /// Gets or sets a value indicating whether this allows multiple selection. + /// Select in the table view (if present) /// - /// true if allows multiple selection; otherwise, false, defaults to false. - public bool AllowsMultipleSelection { - get => dirListView.allowsMultipleSelection; - set { - if (!value && openMode == OpenMode.Mixed) { + /// + 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 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 (); + } + } /// - /// Returns the selected files, or an empty list if nothing has been selected + /// State representing a recursive search from + /// downwards. /// - /// The file paths. - public IReadOnlyList FilePaths { - get => dirListView.FilePaths; + internal class SearchState : FileDialogState { + + bool cancel = false; + bool finished = false; + + // TODO: Add thread safe child adding + List found = new List (); + 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 () + { + } + + /// + /// Cancels the current search (if any). Returns true if a search + /// was running and cancellation was successfully set. + /// + /// + internal bool Cancel () + { + var alreadyCancelled = token.IsCancellationRequested || cancel; + + cancel = true; + token.Cancel (); + + return !alreadyCancelled; + } } } -} +} \ No newline at end of file diff --git a/Terminal.Gui/Views/OpenDialog.cs b/Terminal.Gui/Views/OpenDialog.cs new file mode 100644 index 0000000000..bcf4d908db --- /dev/null +++ b/Terminal.Gui/Views/OpenDialog.cs @@ -0,0 +1,90 @@ +// +// 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.IO; +using System.Linq; +using Terminal.Gui.Resources; +using System.Collections.ObjectModel; + +namespace Terminal.Gui { + + /// + /// Determine which type to open. + /// + public enum OpenMode { + /// + /// Opens only file or files. + /// + File, + /// + /// Opens only directory or directories. + /// + Directory, + /// + /// Opens files and directories. + /// + Mixed + } + + /// + /// The provides an interactive dialog box for users to select files or directories. + /// + /// + /// + /// 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. + /// + /// + /// To use, create an instance of , and pass it to + /// . This will run the dialog modally, + /// and when this returns, the list of files will be available on the property. + /// + /// + /// To select more than one file, users can use the spacebar, or control-t. + /// + /// + public class OpenDialog : FileDialog { + + /// + /// Initializes a new . + /// + public OpenDialog () : this (title: string.Empty) { } + + /// + /// Initializes a new . + /// + /// The title. + /// The allowed types. + /// The open mode. + public OpenDialog (ustring title, List allowedTypes = null, OpenMode openMode = OpenMode.File) + { + this.OpenMode = openMode; + Title = title; + Style.OkButtonText = openMode == OpenMode.File ? Strings.fdOpen : openMode == OpenMode.Directory ? Strings.fdSelectFolder : Strings.fdSelectMixed; + + if (allowedTypes != null) { + AllowedTypes = allowedTypes; + } + } + /// + /// Returns the selected files, or an empty list if nothing has been selected + /// + /// The file paths. + public IReadOnlyList FilePaths { + get => Canceled ? Enumerable.Empty ().ToList().AsReadOnly() + : AllowsMultipleSelection ? base.MultiSelected : new ReadOnlyCollection(new [] { Path }); + } + } +} diff --git a/Terminal.Gui/Views/SaveDialog.cs b/Terminal.Gui/Views/SaveDialog.cs new file mode 100644 index 0000000000..7f7ed1ebad --- /dev/null +++ b/Terminal.Gui/Views/SaveDialog.cs @@ -0,0 +1,67 @@ +// +// 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 Terminal.Gui.Resources; + +namespace Terminal.Gui { + /// + /// The provides an interactive dialog box for users to pick a file to + /// save. + /// + /// + /// + /// To use, create an instance of , and pass it to + /// . This will run the dialog modally, + /// and when this returns, the property will contain the selected file name or + /// null if the user canceled. + /// + /// + public class SaveDialog : FileDialog { + /// + /// Initializes a new . + /// + public SaveDialog () : this (title: string.Empty) { } + + /// + /// Initializes a new . + /// + /// The title. + /// The allowed types. + public SaveDialog (ustring title, List allowedTypes = null) + { + //: base (title, prompt: Strings.fdSave, nameFieldLabel: $"{Strings.fdSaveAs}:", message: message, allowedTypes) { } + Title = title; + Style.OkButtonText = Strings.fdSave; + + if(allowedTypes != null) { + AllowedTypes = allowedTypes; + } + } + + + /// + /// Gets the name of the file the user selected for saving, or null + /// if the user canceled the . + /// + /// The name of the file. + public ustring FileName { + get { + if (Canceled) + return null; + + return Path; + } + } + } +} diff --git a/Terminal.Gui/Views/TableView/TableView.cs b/Terminal.Gui/Views/TableView/TableView.cs index 1befcc0437..d38d021704 100644 --- a/Terminal.Gui/Views/TableView/TableView.cs +++ b/Terminal.Gui/Views/TableView/TableView.cs @@ -1,4 +1,4 @@ -using NStack; +using NStack; using System; using System.Collections.Generic; using System.Data; diff --git a/Terminal.Gui/Views/TextField.cs b/Terminal.Gui/Views/TextField.cs index 29ca05c5fc..0cdf180f0a 100644 --- a/Terminal.Gui/Views/TextField.cs +++ b/Terminal.Gui/Views/TextField.cs @@ -516,6 +516,7 @@ private void RenderCaption () Driver.AddStr (render); } + private void GenerateSuggestions () { var currentLine = Text.ToRuneList (); @@ -786,7 +787,10 @@ void MoveRight () Adjust (); } - void MoveEnd () + /// + /// Moves cursor to the end of the typed text. + /// + public void MoveEnd () { ClearAllSelection (); point = text.Count; @@ -1355,6 +1359,26 @@ public void ClearHistoryChanges () { historyText.Clear (Text); } + + /// + /// Returns if the current cursor position is + /// at the end of the . This includes when it is empty. + /// + /// + internal bool CursorIsAtEnd () + { + return CursorPosition == Text.Length; + } + + /// + /// Returns if the current cursor position is + /// at the start of the . + /// + /// + internal bool CursorIsAtStart() + { + return CursorPosition <= 0; + } } /// /// Renders an overlay on another view at a given point that allows selecting diff --git a/Terminal.Gui/Views/TimeField.cs b/Terminal.Gui/Views/TimeField.cs index b2f9ba0181..128e5b726f 100644 --- a/Terminal.Gui/Views/TimeField.cs +++ b/Terminal.Gui/Views/TimeField.cs @@ -272,7 +272,7 @@ bool MoveRight () return true; } - bool MoveEnd () + new bool MoveEnd () { CursorPosition = fieldLen; return true; diff --git a/Terminal.Gui/Views/TreeView/TreeView.cs b/Terminal.Gui/Views/TreeView/TreeView.cs index e69c42e34a..286debb6e0 100644 --- a/Terminal.Gui/Views/TreeView/TreeView.cs +++ b/Terminal.Gui/Views/TreeView/TreeView.cs @@ -306,6 +306,10 @@ public override bool OnEnter (View view) { Application.Driver.SetCursorVisibility (DesiredCursorVisibility); + if (SelectedObject == null && Objects.Any ()) { + SelectedObject = Objects.First (); + } + return base.OnEnter (view); } @@ -545,7 +549,7 @@ private IReadOnlyCollection> BuildLineMap () } cachedLineMap = new ReadOnlyCollection> (toReturn); - + // Update the collection used for search-typing KeystrokeNavigator.Collection = cachedLineMap.Select (b => AspectGetter (b.Model)).ToArray (); return cachedLineMap; @@ -712,7 +716,7 @@ public void ScrollUp () /// protected virtual void OnObjectActivated (ObjectActivatedEventArgs e) { - ObjectActivated?.Invoke (this,e); + ObjectActivated?.Invoke (this, e); } /// diff --git a/UICatalog/Resources/config.json b/UICatalog/Resources/config.json index e2daf23042..211d6c638d 100644 --- a/UICatalog/Resources/config.json +++ b/UICatalog/Resources/config.json @@ -3,6 +3,9 @@ "Application.QuitKey": { "Key": "Esc" }, + "FileDialog.MaxSearchResults": 10000, + "FileDialogStyle.DefaultUseColors": false, + "FileDialogStyle.DefaultUseUnicodeCharacters": false, "AppSettings": { "UICatalog.StatusBar": true, "ConfigurationEditor.EditorColorScheme": { diff --git a/UICatalog/Scenarios/CsvEditor.cs b/UICatalog/Scenarios/CsvEditor.cs index 971a6587c7..738b57515c 100644 --- a/UICatalog/Scenarios/CsvEditor.cs +++ b/UICatalog/Scenarios/CsvEditor.cs @@ -7,6 +7,7 @@ using NStack; using Terminal.Gui; using CsvHelper; +using System.Collections.Generic; namespace UICatalog.Scenarios { @@ -390,14 +391,15 @@ private void Save () private void Open () { - var ofd = new FileDialog ("Select File", "Open", "File", "Select a CSV file to open (does not support newlines, escaping etc)") { - AllowedFileTypes = new string [] { ".csv" } + var ofd = new FileDialog () { + AllowedTypes = new List { new AllowedType("Comma Separated Values", ".csv") } }; + ofd.Style.OkButtonText = "Open"; Application.Run (ofd); - if (!ofd.Canceled && !string.IsNullOrWhiteSpace (ofd.FilePath?.ToString ())) { - Open (ofd.FilePath.ToString ()); + if (!ofd.Canceled && !string.IsNullOrWhiteSpace (ofd.Path?.ToString ())) { + Open (ofd.Path.ToString ()); } } @@ -407,9 +409,10 @@ private void Open (string filename) int lineNumber = 0; currentFile = null; - using var reader = new CsvReader (File.OpenText (filename), CultureInfo.InvariantCulture); try { + using var reader = new CsvReader (File.OpenText (filename), CultureInfo.InvariantCulture); + var dt = new DataTable (); reader.Read (); diff --git a/UICatalog/Scenarios/Editor.cs b/UICatalog/Scenarios/Editor.cs index b2ae6aa147..9ef1b27d46 100644 --- a/UICatalog/Scenarios/Editor.cs +++ b/UICatalog/Scenarios/Editor.cs @@ -367,8 +367,11 @@ private void Open () if (!CanCloseFile ()) { return; } - var aTypes = new List () { ".txt;.bin;.xml;.json", ".txt", ".bin", ".xml", ".json", ".*" }; - var d = new OpenDialog ("Open", "Choose the path where to open the file.", aTypes) { AllowsMultipleSelection = false }; + var aTypes = new List () { + new AllowedType("Text",".txt;.bin;.xml;.json", ".txt", ".bin", ".xml", ".json"), + new AllowedTypeAny() + }; + var d = new OpenDialog ("Open", aTypes) { AllowsMultipleSelection = false }; Application.Run (d); if (!d.Canceled && d.FilePaths.Count > 0) { @@ -390,22 +393,26 @@ private bool Save () private bool SaveAs () { - var aTypes = new List () { ".txt", ".bin", ".xml", ".*" }; - var sd = new SaveDialog ("Save file", "Choose the path where to save the file.", aTypes); - sd.FilePath = System.IO.Path.Combine (sd.FilePath.ToString (), Win.Title.ToString ()); + var aTypes = new List () { + new AllowedType("Text Files", ".txt", ".bin", ".xml"), + new AllowedTypeAny() + }; + var sd = new SaveDialog ("Save file", aTypes); + + sd.Path = System.IO.Path.Combine (sd.FileName.ToString (), Win.Title.ToString ()); Application.Run (sd); if (!sd.Canceled) { - if (System.IO.File.Exists (sd.FilePath.ToString ())) { + if (System.IO.File.Exists (sd.Path.ToString ())) { if (MessageBox.Query ("Save File", "File already exists. Overwrite any way?", "No", "Ok") == 1) { - return SaveFile (sd.FileName.ToString (), sd.FilePath.ToString ()); + return SaveFile (sd.FileName.ToString (), sd.Path.ToString ()); } else { _saved = false; return _saved; } } else { - return SaveFile (sd.FileName.ToString (), sd.FilePath.ToString ()); + return SaveFile (sd.FileName.ToString (), sd.Path.ToString ()); } } else { _saved = false; diff --git a/UICatalog/Scenarios/FileDialogExamples.cs b/UICatalog/Scenarios/FileDialogExamples.cs new file mode 100644 index 0000000000..bc54811cbe --- /dev/null +++ b/UICatalog/Scenarios/FileDialogExamples.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Runtime.InteropServices; +using Terminal.Gui; +using static Terminal.Gui.OpenDialog; + +namespace UICatalog.Scenarios { + [ScenarioMetadata (Name: "FileDialog", Description: "Demonstrates how to the FileDialog class")] + [ScenarioCategory ("Dialogs")] + [ScenarioCategory ("Files and IO")] + public class FileDialogExamples : Scenario { + private CheckBox cbMustExist; + private CheckBox cbUnicode; + private CheckBox cbUseColors; + private CheckBox cbCaseSensitive; + private CheckBox cbAllowMultipleSelection; + private CheckBox cbShowTreeBranchLines; + private CheckBox cbAlwaysTableShowHeaders; + private CheckBox cbDrivesOnlyInTree; + + private RadioGroup rgCaption; + private RadioGroup rgOpenMode; + private RadioGroup rgAllowedTypes; + + public override void Setup () + { + var y = 0; + var x = 1; + + cbMustExist = new CheckBox ("Must Exist") { Checked = true, Y = y++, X = x }; + Win.Add (cbMustExist); + + + cbUnicode = new CheckBox ("UseUnicode") { Checked = FileDialogStyle.DefaultUseUnicodeCharacters, Y = y++, X = x }; + Win.Add (cbUnicode); + + cbUseColors = new CheckBox ("Use Colors") { Checked = FileDialogStyle.DefaultUseColors, Y = y++, X = x }; + Win.Add (cbUseColors); + + cbCaseSensitive = new CheckBox ("Case Sensitive Search") { Checked = false, Y = y++, X = x }; + Win.Add (cbCaseSensitive); + + cbAllowMultipleSelection = new CheckBox ("Multiple") { Checked = false, Y = y++, X = x }; + Win.Add (cbAllowMultipleSelection); + + cbShowTreeBranchLines = new CheckBox ("Tree Branch Lines") { Checked = true, Y = y++, X = x }; + Win.Add (cbShowTreeBranchLines); + + cbAlwaysTableShowHeaders = new CheckBox ("Always Show Headers") { Checked = true, Y = y++, X = x }; + Win.Add (cbAlwaysTableShowHeaders); + + cbDrivesOnlyInTree = new CheckBox ("Only Show Drives") { Checked = false, Y = y++, X = x }; + Win.Add (cbDrivesOnlyInTree); + + y = 0; + x = 24; + + Win.Add (new LineView (Orientation.Vertical) { + X = x++, + Y = 1, + Height = 4 + }); + Win.Add (new Label ("Caption") { X = x++, Y = y++ }); + + rgCaption = new RadioGroup { X = x, Y = y }; + rgCaption.RadioLabels = new NStack.ustring [] { "Ok", "Open", "Save" }; + Win.Add (rgCaption); + + y = 0; + x = 37; + + Win.Add (new LineView (Orientation.Vertical) { + X = x++, + Y = 1, + Height = 4 + }); + Win.Add (new Label ("OpenMode") { X = x++, Y = y++ }); + + rgOpenMode = new RadioGroup { X = x, Y = y }; + rgOpenMode.RadioLabels = new NStack.ustring [] { "File", "Directory", "Mixed" }; + Win.Add (rgOpenMode); + + y = 5; + x = 24; + + Win.Add (new LineView (Orientation.Vertical) { + X = x++, + Y = y + 1, + Height = 4 + }); + Win.Add (new Label ("Allowed") { X = x++, Y = y++ }); + + rgAllowedTypes = new RadioGroup { X = x, Y = y }; + rgAllowedTypes.RadioLabels = new NStack.ustring [] { "Any", "Csv (Recommended)", "Csv (Strict)" }; + Win.Add (rgAllowedTypes); + + var btn = new Button ($"Run Dialog") { + X = 1, + Y = 9 + }; + + SetupHandler (btn); + Win.Add (btn); + } + + private void SetupHandler (Button btn) + { + btn.Clicked += (s,e) => { + try + { + CreateDialog(); + } + catch(Exception ex) + { + MessageBox.ErrorQuery("Error",ex.ToString(),"Ok"); + + } + }; + } + + private void CreateDialog () + { + + var fd = new FileDialog () { + OpenMode = Enum.Parse ( + rgOpenMode.RadioLabels [rgOpenMode.SelectedItem].ToString ()), + MustExist = cbMustExist.Checked ?? false, + AllowsMultipleSelection = cbAllowMultipleSelection.Checked ?? false, + }; + + fd.Style.OkButtonText = rgCaption.RadioLabels [rgCaption.SelectedItem].ToString (); + + // If Save style dialog then give them an overwrite prompt + if(rgCaption.SelectedItem == 2) { + fd.FilesSelected += ConfirmOverwrite; + } + + fd.Style.UseUnicodeCharacters = cbUnicode.Checked ?? false; + + if (cbCaseSensitive.Checked ?? false) { + + fd.SearchMatcher = new CaseSensitiveSearchMatcher (); + } + + fd.Style.UseColors = cbUseColors.Checked ?? false; + + fd.Style.TreeStyle.ShowBranchLines = cbShowTreeBranchLines.Checked ?? false; + fd.Style.TableStyle.AlwaysShowHeaders = cbAlwaysTableShowHeaders.Checked ?? false; + + if (cbDrivesOnlyInTree.Checked ?? false) { + fd.Style.TreeRootGetter = () => { + return System.Environment.GetLogicalDrives () + .Select (d => new FileDialogRootTreeNode (d, new DirectoryInfo (d))); + }; + } + + if (rgAllowedTypes.SelectedItem > 0) { + fd.AllowedTypes.Add (new AllowedType ("Data File", ".csv", ".tsv")); + + if (rgAllowedTypes.SelectedItem == 1) { + fd.AllowedTypes.Insert (1, new AllowedTypeAny ()); + } + + } + + Application.Run (fd); + + if (fd.Canceled) { + MessageBox.Query ( + "Canceled", + "You canceled navigation and did not pick anything", + "Ok"); + } else if (cbAllowMultipleSelection.Checked ?? false) { + MessageBox.Query ( + "Chosen!", + "You chose:" + Environment.NewLine + + string.Join (Environment.NewLine, fd.MultiSelected.Select (m => m)), + "Ok"); + } else { + MessageBox.Query ( + "Chosen!", + "You chose:" + Environment.NewLine + fd.Path, + "Ok"); + } + } + + private void ConfirmOverwrite (object sender, FilesSelectedEventArgs e) + { + if (!string.IsNullOrWhiteSpace (e.Dialog.Path)) { + if(File.Exists(e.Dialog.Path)) { + int result = MessageBox.Query ("Overwrite?", "File already exists", "Yes", "No"); + e.Cancel = result == 1; + } + } + } + + private class CaseSensitiveSearchMatcher : ISearchMatcher { + private string terms; + + public void Initialize (string terms) + { + this.terms = terms; + } + + public bool IsMatch (IFileSystemInfo f) + { + return f.Name.Contains (terms, StringComparison.CurrentCulture); + } + } + } +} diff --git a/UICatalog/Scenarios/HexEditor.cs b/UICatalog/Scenarios/HexEditor.cs index 841cf20e57..ca347bf976 100644 --- a/UICatalog/Scenarios/HexEditor.cs +++ b/UICatalog/Scenarios/HexEditor.cs @@ -124,7 +124,7 @@ private void Copy () private void Open () { - var d = new OpenDialog ("Open", "Open a file") { AllowsMultipleSelection = false }; + var d = new OpenDialog ("Open") { AllowsMultipleSelection = false }; Application.Run (d); if (!d.Canceled) { diff --git a/UICatalog/Scenarios/Notepad.cs b/UICatalog/Scenarios/Notepad.cs index 33ce0d92a9..620ab4dd55 100644 --- a/UICatalog/Scenarios/Notepad.cs +++ b/UICatalog/Scenarios/Notepad.cs @@ -253,7 +253,7 @@ private void Close (TabView tv, TabView.Tab tabToClose) private void Open () { - var open = new OpenDialog ("Open", "Open a file") { AllowsMultipleSelection = true }; + var open = new OpenDialog ("Open") { AllowsMultipleSelection = true }; Application.Run (open); @@ -312,7 +312,7 @@ public bool SaveAs () var fd = new SaveDialog (); Application.Run (fd); - if (string.IsNullOrWhiteSpace (fd.FilePath?.ToString ())) { + if (string.IsNullOrWhiteSpace (fd.Path?.ToString ())) { return false; } @@ -320,7 +320,7 @@ public bool SaveAs () return false; } - tab.File = new FileInfo (fd.FilePath.ToString ()); + tab.File = new FileInfo (fd.Path.ToString ()); tab.Text = fd.FileName.ToString (); tab.Save (); diff --git a/UnitTests/FileServices/FileDialogTests.cs b/UnitTests/FileServices/FileDialogTests.cs new file mode 100644 index 0000000000..94a1d331ad --- /dev/null +++ b/UnitTests/FileServices/FileDialogTests.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using Terminal.Gui; +using Xunit; +using Xunit.Abstractions; + +namespace Terminal.Gui.FileServicesTests { + public class FileDialogTests { + + readonly ITestOutputHelper output; + + public FileDialogTests (ITestOutputHelper output) + { + this.output = output; + } + + [Fact, AutoInitShutdown] + public void OnLoad_TextBoxIsFocused () + { + var dlg = GetInitializedFileDialog (); + + var tf = dlg.Subviews.FirstOrDefault (t => t.HasFocus); + Assert.NotNull (tf); + Assert.IsType (tf); + } + + [Fact, AutoInitShutdown] + public void DirectTyping_Allowed () + { + var dlg = GetInitializedFileDialog (); + var tf = dlg.Subviews.OfType ().First (t => t.HasFocus); + tf.ClearAllSelection (); + tf.CursorPosition = tf.Text.Length; + Assert.True (tf.HasFocus); + + SendSlash (); + + Assert.Equal ( + new DirectoryInfo (Environment.CurrentDirectory + Path.DirectorySeparatorChar).FullName, + new DirectoryInfo (dlg.Path + Path.DirectorySeparatorChar).FullName + ); + + // continue typing the rest of the path + Send ("bob"); + Send ('.', ConsoleKey.OemPeriod, false); + Send ("csv"); + + Assert.True (dlg.Canceled); + + Send ('\n', ConsoleKey.Enter, false); + Assert.False (dlg.Canceled); + Assert.Equal ("bob.csv", Path.GetFileName (dlg.Path)); + } + + private void SendSlash () + { + if (Path.DirectorySeparatorChar == '/') { + Send ('/', ConsoleKey.Separator, false); + } else { + Send ('\\', ConsoleKey.Separator, false); + } + } + + [Fact, AutoInitShutdown] + public void DirectTyping_AutoComplete () + { + var dlg = GetInitializedFileDialog (); + var openIn = Path.Combine (Environment.CurrentDirectory, "zz"); + + Directory.CreateDirectory (openIn); + + var expectedDest = Path.Combine (openIn, "xx"); + Directory.CreateDirectory (expectedDest); + + dlg.Path = openIn + Path.DirectorySeparatorChar; + + Send ("x"); + + // nothing selected yet + Assert.True (dlg.Canceled); + Assert.Equal ("x", Path.GetFileName (dlg.Path)); + + // complete auto typing + Send ('\t', ConsoleKey.Tab, false); + + // but do not close dialog + Assert.True (dlg.Canceled); + Assert.EndsWith ("xx" + Path.DirectorySeparatorChar, dlg.Path); + + // press enter again to confirm the dialog + Send ('\n', ConsoleKey.Enter, false); + Assert.False (dlg.Canceled); + Assert.EndsWith ("xx" + Path.DirectorySeparatorChar, dlg.Path); + } + + [Fact, AutoInitShutdown] + public void DoNotConfirmSelectionWhenFindFocused () + { + var dlg = GetInitializedFileDialog (); + var openIn = Path.Combine (Environment.CurrentDirectory, "zz"); + Directory.CreateDirectory (openIn); + dlg.Path = openIn + Path.DirectorySeparatorChar; + + Send ('f', ConsoleKey.F, false, false, true); + + Assert.IsType (dlg.MostFocused); + var tf = (TextField)dlg.MostFocused; + Assert.Equal ("Enter Search", tf.Caption); + + // Dialog has not yet been confirmed with a choice + Assert.True (dlg.Canceled); + + //pressing enter while search focused should not confirm path + Send ('\n', ConsoleKey.Enter, false); + + Assert.True (dlg.Canceled); + + // tabbing out of search + Send ('\t', ConsoleKey.Tab, false); + + //should allow enter to confirm path + Send ('\n', ConsoleKey.Enter, false); + + // Dialog has not yet been confirmed with a choice + Assert.False (dlg.Canceled); + } + + [Fact, AutoInitShutdown] + public void TestDirectoryContents_Linux () + { + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform (System.Runtime.InteropServices.OSPlatform.Windows)) { + // Cannot run test except on linux :( + // See: https://github.com/TestableIO/System.IO.Abstractions/issues/800 + return; + } + + // Arrange + var fileSystem = new MockFileSystem (new Dictionary (), "/"); + fileSystem.MockTime (() => new DateTime (2010, 01, 01, 11, 12, 43)); + + fileSystem.AddFile (@"/myfile.txt", new MockFileData ("Testing is meh.") { LastWriteTime = new DateTime (2001, 01, 01, 11, 12, 11) }); + fileSystem.AddFile (@"/demo/jQuery.js", new MockFileData ("some js") { LastWriteTime = new DateTime (2001, 01, 01, 11, 44, 42) }); + fileSystem.AddFile (@"/demo/image.gif", new MockFileData (new byte [] { 0x12, 0x34, 0x56, 0xd2 }) { LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10) }); + + var m = (MockDirectoryInfo)fileSystem.DirectoryInfo.New (@"/demo/subfolder"); + m.Create (); + m.LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10); + + fileSystem.AddFile (@"/demo/subfolder/image2.gif", new MockFileData (new byte [] { 0x12, 0x34, 0x56, 0xd2 }) { LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10) }); + + var fd = new FileDialog (fileSystem) { + Height = 15 + }; + fd.Path = @"/demo/"; + Begin (fd); + fd.Title = string.Empty; + + fd.Redraw (fd.Bounds); + + fd.Style.DateFormat = "yyyy-MM-dd hh:mm:ss"; + + string expected = + @" + ┌──────────────────────────────────────────────────────────────────┐ + │/demo/ │ + │[▲] │ + │┌────────────┬──────────┬──────────────────────────────┬─────────┐│ + ││Filename (▲)│Size │Modified │Type ││ + │├────────────┼──────────┼──────────────────────────────┼─────────┤│ + ││.. │ │ │dir ││ + ││\subfolder │ │2002-01-01T22:42:10 │dir ││ + ││image.gif │4.00 bytes│2002-01-01T22:42:10 │.gif ││ + ││jQuery.js │7.00 bytes│2001-01-01T11:44:42 │.js ││ + │ │ + │ │ + │ │ + │[ ►► ] Enter Search [ Cancel ] [ Ok ] │ + └──────────────────────────────────────────────────────────────────┘ +"; + TestHelpers.AssertDriverContentsAre (expected, output, true); + } + + + [Fact, AutoInitShutdown] + public void TestDirectoryContents_Windows () + { + if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform (System.Runtime.InteropServices.OSPlatform.Windows)) { + // Can only run this test on windows :( + // See: https://github.com/TestableIO/System.IO.Abstractions/issues/800 + return; + } + // Arrange + var fileSystem = new MockFileSystem (new Dictionary (), @"c:\"); + fileSystem.MockTime (() => new DateTime (2010, 01, 01, 11, 12, 43)); + + fileSystem.AddFile (@"c:\myfile.txt", new MockFileData ("Testing is meh.") { LastWriteTime = new DateTime (2001, 01, 01, 11, 12, 11) }); + fileSystem.AddFile (@"c:\demo\jQuery.js", new MockFileData ("some js") { LastWriteTime = new DateTime (2001, 01, 01, 11, 44, 42) }); + fileSystem.AddFile (@"c:\demo\image.gif", new MockFileData (new byte [] { 0x12, 0x34, 0x56, 0xd2 }) { LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10) }); + + var m = (MockDirectoryInfo)fileSystem.DirectoryInfo.New (@"c:\demo\subfolder"); + m.Create (); + m.LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10); + + fileSystem.AddFile (@"c:\demo\subfolder\image2.gif", new MockFileData (new byte [] { 0x12, 0x34, 0x56, 0xd2 }) { LastWriteTime = new DateTime (2002, 01, 01, 22, 42, 10) }); + + var fd = new FileDialog (fileSystem) { + Height = 15 + }; + fd.Path = @"c:\demo\"; + Begin (fd); + fd.Title = string.Empty; + + fd.Redraw (fd.Bounds); + + fd.Style.DateFormat = "yyyy-MM-dd hh:mm:ss"; + + string expected = + @" +┌──────────────────────────────────────────────────────────────────┐ +│c:\demo\ │ +│[▲] │ +│┌────────────┬──────────┬──────────────────────────────┬─────────┐│ +││Filename (▲)│Size │Modified │Type ││ +│├────────────┼──────────┼──────────────────────────────┼─────────┤│ +││.. │ │ │dir ││ +││\subfolder │ │2002-01-01T22:42:10 │dir ││ +││image.gif │4.00 bytes│2002-01-01T22:42:10 │.gif ││ +││jQuery.js │7.00 bytes│2001-01-01T11:44:42 │.js ││ +│ │ +│ │ +│ │ +│[ ►► ] Enter Search [ Cancel ] [ Ok ] │ +└──────────────────────────────────────────────────────────────────┘ +"; + TestHelpers.AssertDriverContentsAre (expected, output, true); + } + + [Theory, AutoInitShutdown] + [InlineData (true)] + [InlineData (false)] + public void CancelSelection (bool cancel) + { + var dlg = GetInitializedFileDialog (); + var openIn = Path.Combine (Environment.CurrentDirectory, "zz"); + Directory.CreateDirectory (openIn); + dlg.Path = openIn + Path.DirectorySeparatorChar; + + dlg.FilesSelected += (s, e) => e.Cancel = cancel; + + //pressing enter will complete the current selection + // unless the event cancels the confirm + Send ('\n', ConsoleKey.Enter, false); + + Assert.Equal (cancel, dlg.Canceled); + } + + private void Send (char ch, ConsoleKey ck, bool shift = false, bool alt = false, bool control = false) + { + Application.Driver.SendKeys (ch, ck, shift, alt, control); + } + private void Send (string chars) + { + foreach (var ch in chars) { + Application.Driver.SendKeys (ch, ConsoleKey.NoName, false, false, false); + } + + } + /* + [Fact, AutoInitShutdown] + public void Autocomplete_NoSuggestion_WhenTextMatchesExactly () + { + var tb = new TextFieldWithAppendAutocomplete (); + ForceFocus (tb); + + tb.Text = "/bob/fish"; + tb.CursorPosition = tb.Text.Length; + tb.GenerateSuggestions (null, "fish", "fishes"); + + // should not report success for autocompletion because we already have that exact + // string + Assert.False (tb.AcceptSelectionIfAny ()); + } + + + [Fact, AutoInitShutdown] + public void Autocomplete_AcceptSuggstion () + { + var tb = new TextFieldWithAppendAutocomplete (); + ForceFocus (tb); + + tb.Text = @"/bob/fi"; + tb.CursorPosition = tb.Text.Length; + tb.GenerateSuggestions (null, "fish", "fishes"); + + Assert.True (tb.AcceptSelectionIfAny ()); + Assert.Equal (@"/bob/fish", tb.Text); + }*/ + + + private FileDialog GetInitializedFileDialog () + { + var dlg = new FileDialog (); + Begin (dlg); + + return dlg; + } + private void Begin (FileDialog dlg) + { + dlg.BeginInit (); + dlg.EndInit (); + Application.Begin (dlg); + } + } +} \ No newline at end of file diff --git a/UnitTests/TestHelpers.cs b/UnitTests/TestHelpers.cs index 9ed21f8d62..1b8d7ce662 100644 --- a/UnitTests/TestHelpers.cs +++ b/UnitTests/TestHelpers.cs @@ -84,7 +84,7 @@ public override void After (MethodInfo methodUnderTest) class TestHelpers { #pragma warning disable xUnit1013 // Public method should be marked as test - public static void AssertDriverContentsAre (string expectedLook, ITestOutputHelper output) + public static void AssertDriverContentsAre (string expectedLook, ITestOutputHelper output, bool ignoreLeadingWhitespace = false) { #pragma warning restore xUnit1013 // Public method should be marked as test @@ -114,11 +114,18 @@ public static void AssertDriverContentsAre (string expectedLook, ITestOutputHelp // ignore trailing whitespace on each line var trailingWhitespace = new Regex (@"\s+$", RegexOptions.Multiline); + var leadingWhitespace = new Regex(@"^\s+",RegexOptions.Multiline); // get rid of trailing whitespace on each line (and leading/trailing whitespace of start/end of full string) expectedLook = trailingWhitespace.Replace (expectedLook, "").Trim (); actualLook = trailingWhitespace.Replace (actualLook, "").Trim (); + if(ignoreLeadingWhitespace) + { + expectedLook = leadingWhitespace.Replace (expectedLook, "").Trim (); + actualLook = leadingWhitespace.Replace (actualLook, "").Trim (); + } + // standardize line endings for the comparison expectedLook = expectedLook.Replace ("\r\n", "\n"); actualLook = actualLook.Replace ("\r\n", "\n"); diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index 1d5885e179..e2b3f78d20 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -24,6 +24,7 @@ + all