Skip to content

Commit

Permalink
Fixes #2150. Revamping FileDialog (#2259)
Browse files Browse the repository at this point in the history
* 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 2f557f5.

* 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 a5f9c07.

* 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 <[email protected]>
  • Loading branch information
tznind and tig authored Apr 9, 2023
1 parent 8ac9273 commit c1a5788
Show file tree
Hide file tree
Showing 36 changed files with 3,803 additions and 886 deletions.
108 changes: 108 additions & 0 deletions Terminal.Gui/FileServices/AllowedType.cs
Original file line number Diff line number Diff line change
@@ -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 {

/// <summary>
/// Interface for <see cref="FileDialog"/> restrictions on which file type(s) the
/// user is allowed to select/enter.
/// </summary>
public interface IAllowedType
{
/// <summary>
/// Returns true if the file at <paramref name="path"/> is compatible with this
/// allow option. Note that the file may not exist (e.g. in the case of saving).
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
bool IsAllowed (string path);
}


/// <summary>
/// <see cref="IAllowedType"/> that allows selection of any types (*.*).
/// </summary>
public class AllowedTypeAny : IAllowedType {

/// <inheritdoc/>
public bool IsAllowed (string path)
{
return true;
}

/// <inheritdoc/>
public override string ToString ()
{
return Strings.fdAnyFiles + "(*.*)";
}
}

/// <summary>
/// Describes a requirement on what <see cref="FileInfo"/> can be selected.
/// This can be combined with other <see cref="IAllowedType"/> in a <see cref="FileDialog"/>
/// to for example show only .csv files but let user change to open any if they want.
/// </summary>
public class AllowedType : IAllowedType {

/// <summary>
/// Initializes a new instance of the <see cref="AllowedType"/> class.
/// </summary>
/// <param name="description">The human readable text to display.</param>
/// <param name="extensions">Extension(s) to match e.g. .csv.</param>
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;
}

/// <summary>
/// Gets or Sets the human readable description for the file type
/// e.g. "Comma Separated Values".
/// </summary>
public string Description { get; set; }

/// <summary>
/// Gets or Sets the permitted file extension(s) (e.g. ".csv").
/// </summary>
public string [] Extensions { get; set; }


/// <summary>
/// Returns <see cref="Description"/> plus all <see cref="Extensions"/> separated by semicolons.
/// </summary>
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;
}

/// <inheritdoc/>
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));
}
}

}
139 changes: 139 additions & 0 deletions Terminal.Gui/FileServices/DefaultFileOperations.cs
Original file line number Diff line number Diff line change
@@ -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 {
/// <summary>
/// Default file operation handlers using modal dialogs.
/// </summary>
public class DefaultFileOperations : IFileOperations {

/// <inheritdoc/>
public bool Delete (IEnumerable<IFileSystemInfo> 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;
}

/// <inheritdoc/>
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;
}

/// <inheritdoc/>
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;
}
}
}
29 changes: 29 additions & 0 deletions Terminal.Gui/FileServices/DefaultSearchMatcher.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
108 changes: 108 additions & 0 deletions Terminal.Gui/FileServices/FileDialogHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;

namespace Terminal.Gui {

internal class FileDialogHistory {
private Stack<FileDialogState> back = new Stack<FileDialogState> ();
private Stack<FileDialogState> forward = new Stack<FileDialogState> ();
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 ();
}
}
}
Loading

0 comments on commit c1a5788

Please sign in to comment.