Files
Terminal.Gui/Terminal.Gui/Windows/FileDialog2.cs

2195 lines
59 KiB
C#

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using NStack;
using Terminal.Gui.Resources;
using Terminal.Gui.Trees;
using static System.Environment;
using static Terminal.Gui.Configuration.ConfigurationManager;
using static Terminal.Gui.OpenDialog;
namespace Terminal.Gui {
/// <summary>
/// Modal dialog for selecting files/directories. Has auto-complete and expandable
/// navigation pane (Recent, Root drives etc).
/// </summary>
public class FileDialog2 : Dialog {
public FileDialog2Style Style { get; set; } = new FileDialog2Style();
public class FileDialog2Style
{
public string FilenameColumnName { get; set; } = Strings.fdFilename;
public string SizeColumnName { get; set; } = Strings.fdSize;
public string ModifiedColumnName { get; set; } = Strings.fdModified;
public string TypeColumnName { get; set; } = Strings.fdType;
public string SearchCaption { get; internal set; } = Strings.fdSearchCaption;
public string PathCaption { get; internal set; } = Strings.fdPathCaption;
public string WrongFileTypeFeedback { get; internal set; } = Strings.fdWrongFileTypeFeedback;
public string DirectoryMustExistFeedback { get; internal set; } = Strings.fdDirectoryMustExistFeedback;
public string FileAlreadyExistsFeedback { get; internal set; } = Strings.fdFileAlreadyExistsFeedback;
public string FileMustExistFeedback { get; internal set; } = Strings.fdFileMustExistFeedback;
public string DirectoryAlreadyExistsFeedback { get; internal set; } = Strings.fdDirectoryAlreadyExistsFeedback;
public string FileOrDirectoryMustExistFeedback { get; internal set; } = Strings.fdFileOrDirectoryMustExistFeedback;
}
/// <summary>
/// The maximum number of results that will be collected
/// when searching before stopping.
/// </summary>
/// <remarks>
/// This prevents performance issues e.g. when searching
/// root of file system for a common letter (e.g. 'e').
/// </remarks>
[SerializableConfigurationProperty (Scope = typeof(SettingsScope))]
public static int MaxSearchResults {get;set;} = 10000;
/// <summary>
/// True if the file/folder must exist already to be selected.
/// This prevents user from entering the name of something that
/// doesn't exist. Defaults to false.
/// </summary>
public bool MustExist { get; set; }
private static char [] separators = new []
{
System.IO.Path.AltDirectorySeparatorChar,
System.IO.Path.DirectorySeparatorChar,
};
/// <summary>
/// Characters to prevent entry into <see cref="tbPath"/>. Note that this is not using
/// <see cref="System.IO.Path.GetInvalidFileNameChars"/> because we do want to allow directory
/// separators, arrow keys etc.
/// </summary>
private static char [] badChars = new []
{
'"','<','>','|','*','?',
};
/// <summary>
/// The UI selected <see cref="AllowedType"/> from combo box. May be null.
/// </summary>
private AllowedType currentFilter;
private bool pushingState = false;
private bool loaded = false;
private FileDialogState state;
private object onlyOneSearchLock = new object ();
private bool disposed = false;
private TextFieldWithAppendAutocomplete tbPath;
private FileDialogSorter sorter;
private FileDialogHistory history;
private DataTable dtFiles;
private TableView tableView;
private TreeView<object> treeView;
private TileView splitContainer;
private Button btnOk;
private Button btnCancel;
private Button btnToggleSplitterCollapse;
private Label lblForward;
private Label lblBack;
private Label lblUp;
private string feedback;
private CollectionNavigator collectionNavigator = new CollectionNavigator ();
private CaptionedTextField tbFind;
private SpinnerLabel spinnerLabel;
private MenuBarItem allowedTypeMenu;
private MenuItem [] allowedTypeMenuItems;
/// <summary>
/// Initializes a new instance of the <see cref="FileDialog2"/> class.
/// </summary>
public FileDialog2 () :this ("Ok")
{
}
public FileDialog2 (string okCaption)
{
const int cancelWidth = 10;
var lblPath = new Label (">");
this.btnOk = new Button (okCaption) {
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 += this.Accept;
this.btnOk.KeyPress += (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 += (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 += () => {
Application.RequestStop ();
};
this.lblUp = new Label (Driver.UpArrow.ToString ()) { X = 0, Y = 1 };
this.lblUp.Clicked += () => this.history.Up ();
this.lblBack = new Label (Driver.LeftArrow.ToString ()) { X = 2, Y = 1 };
this.lblBack.Clicked += () => this.history.Back ();
this.lblForward = new Label (Driver.RightArrow.ToString ()) { X = 3, Y = 1 };
this.lblForward.Clicked += () => this.history.Forward ();
this.tbPath = new TextFieldWithAppendAutocomplete {
X = Pos.Right (lblPath),
Width = Dim.Fill (1),
Caption = Style.PathCaption,
CaptionColor = Color.DarkGray,
};
this.tbPath.KeyPress += (k) => {
ClearFeedback ();
this.NavigateIf (k, Key.CursorDown, this.tableView);
if (this.tbPath.CursorIsAtEnd ()) {
this.NavigateIf (k, Key.CursorRight, this.btnOk);
}
this.AcceptIf (k, Key.Enter);
this.SuppressIfBadChar (k);
};
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);
this.tableView.KeyPress += (k) => {
if (this.tableView.SelectedRow <= 0) {
this.NavigateIf (k, Key.CursorUp, this.tbPath);
} else {
if (splitContainer.Tiles.First ().ContentView.Visible && tableView.SelectedColumn == 0) {
this.NavigateIf (k, Key.CursorLeft, this.treeView);
}
if (this.tableView.HasFocus &&
!k.KeyEvent.Key.HasFlag (Key.CtrlMask) &&
!k.KeyEvent.Key.HasFlag (Key.AltMask) &&
char.IsLetterOrDigit ((char)k.KeyEvent.KeyValue)) {
CycleToNextTableEntryBeginningWith (k);
}
}
};
this.treeView = new TreeView<object> () {
Width = Dim.Fill (),
Height = Dim.Fill (),
};
this.treeView.TreeBuilder = new FileDialogTreeBuilder ();
this.treeView.AspectGetter = (m) => m is DirectoryInfo d ? d.Name : m.ToString ();
try {
this.treeView.AddObjects (
Environment.GetLogicalDrives ()
.Select (d =>
new FileDialogRootTreeNode (d, new DirectoryInfo (d))));
} catch (Exception) {
// Cannot get the system disks thats fine
}
this.treeView.AddObjects (
Enum.GetValues (typeof (SpecialFolder))
.Cast<SpecialFolder> ()
.Where (this.IsValidSpecialFolder)
.Select (this.GetTreeNode));
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 (">>") {
Y = Pos.AnchorEnd (1),
};
this.btnToggleSplitterCollapse.Clicked += () => {
var tile = this.splitContainer.Tiles.ElementAt (0);
var newState = !tile.ContentView.Visible;
tile.ContentView.Visible = newState;
this.btnToggleSplitterCollapse.Text = newState ? "<<" : ">>";
};
tbFind = new CaptionedTextField {
X = Pos.Right (this.btnToggleSplitterCollapse) + 1,
Caption = Style.SearchCaption,
Width = 16,
Y = Pos.AnchorEnd (1),
};
spinnerLabel = new SpinnerLabel () {
X = Pos.Right (tbFind) + 1,
Y = Pos.AnchorEnd (1),
Width = 1,
Height = 1,
Visible = false,
};
tbFind.TextChanged += (o) => RestartSearch ();
this.tableView.Style.ShowHorizontalHeaderOverline = false;
this.tableView.Style.ShowVerticalCellLines = false;
this.tableView.Style.ShowVerticalHeaderLines = false;
this.tableView.Style.AlwaysShowHeaders = true;
this.SetupColorSchemes ();
this.SetupTableColumns ();
this.sorter = new FileDialogSorter (this, this.tableView);
this.history = new FileDialogHistory (this);
this.tableView.Table = this.dtFiles;
this.tbPath.TextChanged += (s) => this.PathChanged ();
this.tableView.CellActivated += this.CellActivate;
this.tableView.KeyUp += (k) => k.Handled = this.TableView_KeyUp (k.KeyEvent);
this.tableView.SelectedCellChanged += this.TableView_SelectedCellChanged;
this.tableView.ColorScheme = ColorSchemeDefault;
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.ColorScheme = ColorSchemeDefault;
this.treeView.KeyDown += (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);
}
}
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.spinnerLabel);
this.Add (this.btnOk);
this.Add (this.btnCancel);
this.Add (this.lblUp);
this.Add (this.lblBack);
this.Add (this.lblForward);
this.Add (lblPath);
this.Add (this.tbPath);
this.Add (this.splitContainer);
}
/// <inheritdoc/>
public override bool ProcessHotKey (KeyEvent keyEvent)
{
if (this.NavigateIf (keyEvent, Key.CtrlMask | Key.F, this.tbFind)) {
return true;
}
ClearFeedback();
return base.ProcessHotKey (keyEvent);
}
private void RestartSearch ()
{
if (disposed || state?.Directory == null) {
return;
}
if (state is SearchState oldSearch) {
oldSearch.Cancel ();
}
// user is clearing search terms
if (tbFind.Text == null || tbFind.Text.Length == 0) {
// Wait for search cancellation (if any) to finish
// then push the current dir state
lock (onlyOneSearchLock) {
PushState (new FileDialogState (state.Directory, this), false);
}
return;
}
PushState (new SearchState (state?.Directory, this, tbFind.Text.ToString ()), true);
}
/// <inheritdoc/>
protected override void Dispose (bool disposing)
{
disposed = true;
base.Dispose (disposing);
if (state is SearchState search) {
search.Cancel ();
}
}
private void ClearFeedback ()
{
feedback = null;
}
private void CycleToNextTableEntryBeginningWith (KeyEventEventArgs keyEvent)
{
if (tableView.Table.Rows.Count == 0) {
return;
}
var row = tableView.SelectedRow;
// There is a multi select going on and not just for the current row
if (tableView.GetAllSelectedCells ().Any (c => c.Y != row)) {
return;
}
int match = collectionNavigator.GetNextMatchingItem (row, (char)keyEvent.KeyEvent.KeyValue);
if (match != -1) {
tableView.SelectedRow = match;
tableView.EnsureValidSelection ();
tableView.EnsureSelectedCellIsVisible ();
keyEvent.Handled = true;
}
}
private void UpdateCollectionNavigator ()
{
var collection = tableView
.Table
.Rows
.Cast<DataRow> ()
.Select ((o, idx) => RowToStats (idx))
.Select (s => s.FileSystemInfo.Name)
.ToArray ();
collectionNavigator = new CollectionNavigator (collection);
}
/// <summary>
/// Gets or sets a value indicating whether to use Utc dates for date modified.
/// Defaults to <see langword="false"/>.
/// </summary>
public static bool UseUtcDates { get; set; } = false;
/// <summary>
/// Sets a <see cref="ColorScheme"/> to use for directories rows of
/// the <see cref="TableView"/>.
/// </summary>
public ColorScheme ColorSchemeDirectory { private get; set; }
/// <summary>
/// Sets a <see cref="ColorScheme"/> to use for regular file rows of
/// the <see cref="TableView"/>. Defaults to White text on Black background.
/// </summary>
public ColorScheme ColorSchemeDefault { private get; set; }
/// <summary>
/// Sets a <see cref="ColorScheme"/> to use for file rows with an image extension
/// of the <see cref="TableView"/>. Defaults to White text on Black background.
/// </summary>
public ColorScheme ColorSchemeImage { private get; set; }
/// <summary>
/// Sets a <see cref="ColorScheme"/> to use for file rows with an executable extension
/// or that match <see cref="AllowedTypes"/> in the <see cref="TableView"/>.
/// </summary>
public ColorScheme ColorSchemeExeOrRecommended { private get; set; }
/// <summary>
/// Gets or Sets which <see cref="System.IO.FileSystemInfo"/> type can be selected.
/// Defaults to <see cref="OpenMode.Mixed"/> (i.e. <see cref="DirectoryInfo"/> or
/// <see cref="FileInfo"/>).
/// </summary>
public OpenMode OpenMode { get; set; } = OpenMode.Mixed;
/// <summary>
/// Gets or Sets the selected path in the dialog. This is the result that should
/// be used if <see cref="AllowsMultipleSelection"/> is off and <see cref="Canceled"/>
/// is true.
/// </summary>
public string Path {
get => this.tbPath.Text.ToString ();
set {
this.tbPath.Text = value;
this.tbPath.MoveCursorToEnd ();
}
}
/// <summary>
/// User defined delegate for picking which character(s)/unicode
/// symbol(s) to use as an 'icon' for files/folders. Defaults to
/// null (i.e. no icons).
/// </summary>
public Func<FileSystemInfo, string> IconGetter { get; set; } = null;
/// <summary>
/// Defines how the dialog matches files/folders when using the search
/// box. Provide a custom implementation if you want to tailor how matching
/// is performed.
/// </summary>
public ISearchMatcher SearchMatcher {get;set;} = new DefaultSearchMatcher();
/// <summary>
/// Gets or Sets a value indicating whether to allow selecting
/// multiple existing files/directories.
/// </summary>
public bool AllowsMultipleSelection {
get => this.tableView.MultiSelect;
set => this.tableView.MultiSelect = value;
}
/// <summary>
/// Gets or Sets a value indicating whether different colors
/// should be used for different file types/directories.
/// </summary>
public bool Monochrome { get; set; }
/// <summary>
/// Gets or Sets a collection of file types that the user can/must select. Only applies
/// when <see cref="OpenMode"/> is <see cref="OpenMode.File"/>. See also
/// <see cref="AllowedTypesIsStrict"/> if you only want to highlight files.
/// </summary>
public List<AllowedType> AllowedTypes { get; set; } = new List<AllowedType> ();
/// <summary>
/// Gets or sets a value indicating whether <see cref="AllowedTypes"/> is a strict
/// requirement or simply a recommendation. Defaults to <see langword="true"/> (i.e.
/// strict).
/// </summary>
public bool AllowedTypesIsStrict { get; set; }
/// <summary>
/// Gets a value indicating whether the <see cref="FileDialog"/> was closed
/// without confirming a selection.
/// </summary>
public bool Canceled { get; private set; } = true;
/// <summary>
/// Gets all files/directories selected or an empty collection
/// <see cref="AllowsMultipleSelection"/> is <see langword="false"/> or <see cref="Canceled"/>.
/// </summary>
/// <remarks>If selecting only a single file/directory then you should use <see cref="Path"/> instead.</remarks>
public IReadOnlyList<string> MultiSelected { get; private set; }
/// <inheritdoc/>
public override void Redraw (Rect bounds)
{
base.Redraw (bounds);
this.Move (1, 0, false);
// TODO: Refactor this to some Title drawing options class
if (ustring.IsNullOrEmpty (Title)) {
return;
}
var title = this.Title.ToString ();
var titleWidth = title.Sum (c => Rune.ColumnWidth (c));
if (titleWidth > bounds.Width) {
title = title.Substring (0, bounds.Width);
} else {
if (titleWidth + 2 < bounds.Width) {
title = '╡' + this.Title.ToString () + '╞';
}
titleWidth += 2;
}
var padLeft = ((bounds.Width - titleWidth) / 2) - 1;
padLeft = Math.Min (bounds.Width, padLeft);
padLeft = Math.Max (0, padLeft);
var padRight = bounds.Width - (padLeft + titleWidth + 2);
padRight = Math.Min (bounds.Width, padRight);
padRight = Math.Max (0, padRight);
Driver.SetAttribute (
new Attribute (this.ColorScheme.Normal.Foreground, this.ColorScheme.Normal.Background));
Driver.AddStr (ustring.Make (Enumerable.Repeat (Driver.HDLine, padLeft)));
Driver.SetAttribute (
new Attribute (this.ColorScheme.Normal.Foreground, this.ColorScheme.Normal.Background));
Driver.AddStr (title);
Driver.SetAttribute (
new Attribute (this.ColorScheme.Normal.Foreground, this.ColorScheme.Normal.Background));
Driver.AddStr (ustring.Make (Enumerable.Repeat (Driver.HDLine, padRight)));
if(!string.IsNullOrWhiteSpace(feedback))
{
var feedbackWidth = feedback.Sum (c => Rune.ColumnWidth (c));
var feedbackPadLeft = ((bounds.Width - feedbackWidth) / 2) - 1;
feedbackPadLeft = Math.Min (bounds.Width, feedbackPadLeft);
feedbackPadLeft = Math.Max (0, feedbackPadLeft);
var feedbackPadRight = bounds.Width - (feedbackPadLeft + feedbackWidth + 2);
feedbackPadRight = Math.Min (bounds.Width, feedbackPadRight);
feedbackPadRight = Math.Max (0, feedbackPadRight);
Move(0,Bounds.Height/2);
Driver.SetAttribute( new Attribute (Color.Red, this.ColorScheme.Normal.Background));
Driver.AddStr (new string (' ', feedbackPadLeft));
Driver.AddStr (feedback);
Driver.AddStr(new string(' ',feedbackPadRight));
}
}
/// <inheritdoc/>
public override void OnLoaded ()
{
base.OnLoaded ();
if(loaded) {
return;
}
loaded = true;
// 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];
if (!this.AllowedTypesIsStrict) {
AllowedTypes.Insert (0, AllowedType.Any);
}
// Fiddle factor
var width = this.AllowedTypes.Max (a => a.ToString ().Length) + 6;
// TODO: Put a max on this
// TODO: Add a hint that the user should use F9 to open this menu
allowedTypeMenu = new MenuBarItem ("<placeholder>",
allowedTypeMenuItems = AllowedTypes.Select (
(a,i) => new MenuItem (a.ToString (), null, () => {
AllowedTypeMenuClicked (i);
}))
.ToArray ());
var dropdownMenu = new MenuBar (new [] { allowedTypeMenu }){
// Fiddle factor
Width = width-2,
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 (this.AllowedTypesIsStrict ? 0 : 1);
this.Add (dropdownMenu);
this.LayoutSubviews ();
}
// if no path has been provided
if (this.tbPath.Text.Length <= 0) {
this.tbPath.Text = Environment.CurrentDirectory;
}
// 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 ();
if (ustring.IsNullOrEmpty (Title)) {
switch (OpenMode) {
case OpenMode.File:
this.Title = $"OPEN {(MustExist ? "EXISTING " : "")}FILE";
break;
case OpenMode.Directory:
this.Title = $"OPEN {(MustExist ? "EXISTING " : "")}DIRECTORY";
break;
case OpenMode.Mixed:
this.Title = $"OPEN{(MustExist ? " EXISTING" : "")}";
break;
}
}
}
private void AllowedTypeMenuClicked (int idx)
{
var allow = AllowedTypes [idx];
for (int i = 0; i < AllowedTypes.Count; i++) {
allowedTypeMenuItems [i].Checked = i == idx;
}
allowedTypeMenu.Title = allow.ToString ();
this.currentFilter = allow == null || allow.IsAny ? null : allow;
this.tbPath.ClearAllSelection ();
this.tbPath.ClearSuggestions ();
if (this.state != null) {
this.state.RefreshChildren ();
this.WriteStateToTableView ();
}
}
private void SuppressIfBadChar (KeyEventEventArgs k)
{
// don't let user type bad letters
var ch = (char)k.KeyEvent.KeyValue;
if (badChars.Contains (ch)) {
k.Handled = true;
}
}
private bool TreeView_KeyDown (KeyEvent keyEvent)
{
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;
}
private void AcceptIf (KeyEventEventArgs keyEvent, Key isKey)
{
if (!keyEvent.Handled && keyEvent.KeyEvent.Key == isKey) {
keyEvent.Handled = true;
this.Accept ();
}
}
private void Accept (IEnumerable<FileSystemInfoStats> toMultiAccept)
{
if (!this.AllowsMultipleSelection) {
return;
}
this.MultiSelected = toMultiAccept.Select (s => s.FileSystemInfo.FullName).ToList ().AsReadOnly ();
this.tbPath.Text = this.MultiSelected.Count == 1 ? this.MultiSelected [0] : string.Empty;
this.Canceled = false;
Application.RequestStop ();
}
private void Accept (FileInfo f)
{
if (!this.IsCompatibleWithOpenMode (f.FullName, out var reason)) {
feedback = reason;
SetNeedsDisplay();
return;
}
this.tbPath.Text = f.FullName;
if (AllowsMultipleSelection) {
this.MultiSelected = new List<string> { f.FullName }.AsReadOnly ();
}
this.Canceled = false;
Application.RequestStop ();
}
private void Accept ()
{
if (!this.IsCompatibleWithOpenMode (this.tbPath.Text.ToString (), out string reason)) {
if (reason != null) {
feedback = reason;
SetNeedsDisplay();
}
return;
}
this.Canceled = false;
Application.RequestStop ();
}
private void NavigateIf (KeyEventEventArgs keyEvent, Key isKey, View to)
{
if (!keyEvent.Handled) {
if (NavigateIf (keyEvent.KeyEvent, isKey, to)) {
keyEvent.Handled = true;
}
}
}
private bool NavigateIf (KeyEvent keyEvent, Key isKey, View to)
{
if (keyEvent.Key == isKey) {
to.FocusFirst ();
if (to == tbPath) {
tbPath.MoveCursorToEnd ();
}
return true;
}
return false;
}
private void TreeView_SelectionChanged (object sender, SelectionChangedEventArgs<object> e)
{
if (e.NewValue == null) {
return;
}
this.tbPath.Text = FileDialogTreeBuilder.NodeToDirectory (e.NewValue).FullName;
}
private bool IsValidSpecialFolder (SpecialFolder arg)
{
try {
var path = Environment.GetFolderPath (arg);
return !string.IsNullOrWhiteSpace (path) && Directory.Exists (path);
} catch (Exception) {
return false;
}
}
private FileDialogRootTreeNode GetTreeNode (SpecialFolder arg)
{
return new FileDialogRootTreeNode (
arg.ToString (),
new DirectoryInfo (Environment.GetFolderPath (arg)));
}
private void UpdateNavigationVisibility ()
{
this.lblBack.Visible = this.history.CanBack ();
this.lblForward.Visible = this.history.CanForward ();
this.lblUp.Visible = this.history.CanUp ();
}
private void TableView_SelectedCellChanged (TableView.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;
}
FileSystemInfo dest;
if (stats.IsParent) {
dest = state.Directory;
} else {
dest = stats.FileSystemInfo;
}
try {
this.pushingState = true;
this.tbPath.SetTextTo (dest);
this.state.Selected = stats;
this.tbPath.ClearSuggestions ();
} finally {
this.pushingState = false;
}
}
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 ();
}
return false;
}
private void SetupColorSchemes ()
{
if (ColorSchemeDirectory != null) {
return;
}
ColorSchemeDirectory = new ColorScheme {
Normal = Driver.MakeAttribute (Color.Blue, Color.Black),
HotNormal = Driver.MakeAttribute (Color.Blue, Color.Black),
Focus = Driver.MakeAttribute (Color.Black, Color.Blue),
HotFocus = Driver.MakeAttribute (Color.Black, Color.Blue),
};
ColorSchemeDefault = new ColorScheme {
Normal = Driver.MakeAttribute (Color.White, Color.Black),
HotNormal = Driver.MakeAttribute (Color.White, Color.Black),
Focus = Driver.MakeAttribute (Color.Black, Color.White),
HotFocus = Driver.MakeAttribute (Color.Black, Color.White),
};
ColorSchemeImage = new ColorScheme {
Normal = Driver.MakeAttribute (Color.Magenta, Color.Black),
HotNormal = Driver.MakeAttribute (Color.Magenta, Color.Black),
Focus = Driver.MakeAttribute (Color.Black, Color.Magenta),
HotFocus = Driver.MakeAttribute (Color.Black, Color.Magenta),
};
ColorSchemeExeOrRecommended = new ColorScheme {
Normal = Driver.MakeAttribute (Color.Green, Color.Black),
HotNormal = Driver.MakeAttribute (Color.Green, Color.Black),
Focus = Driver.MakeAttribute (Color.Black, Color.Green),
HotFocus = Driver.MakeAttribute (Color.Black, Color.Green),
};
}
private void SetupTableColumns ()
{
this.dtFiles = new DataTable ();
var nameStyle = this.tableView.Style.GetOrCreateColumnStyle (this.dtFiles.Columns.Add (Style.FilenameColumnName, typeof (int)));
nameStyle.RepresentationGetter = (i) => {
var stats = this.state?.Children [(int)i];
if (stats == null) {
return string.Empty;
}
var icon = stats.IsParent ? null : 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) => this.state?.Children [(int)i].DateModified?.ToString () ?? string.Empty;
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;
this.tableView.Style.RowColorGetter = this.ColorGetter;
}
private void CellActivate (TableView.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();
}
return;
}
}
var stats = this.RowToStats (obj.Row);
if (stats.FileSystemInfo is DirectoryInfo d) {
this.PushState (d, true);
return;
}
if (stats.FileSystemInfo is FileInfo f) {
this.Accept (f);
}
}
private bool IsCompatibleWithAllowedExtensions (FileInfo file)
{
// no restrictions
if (!this.AllowedTypes.Any () || !this.AllowedTypesIsStrict) {
return true;
}
return this.MatchesAllowedTypes (file);
}
private bool IsCompatibleWithAllowedExtensions (string path)
{
// no restrictions
if (!this.AllowedTypes.Any () || !this.AllowedTypesIsStrict) {
return true;
}
var extension = System.IO.Path.GetExtension (path);
// There is a requirement to have a particular extension and we have none
if (string.IsNullOrEmpty (extension)) {
return false;
}
return this.AllowedTypes.Any (t => t.Matches (extension, false));
}
/// <summary>
/// Returns true if any <see cref="AllowedTypes"/> matches <paramref name="file"/>
/// regardless of <see cref="AllowedTypesIsStrict"/> status.
/// </summary>
/// <param name="file"></param>
/// <returns></returns>
private bool MatchesAllowedTypes (FileInfo file)
{
return this.AllowedTypes.Any (t => t.Matches (file.Extension, true));
}
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:
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:
if (MustExist && !File.Exists (s) && !Directory.Exists (s)) {
reason = Style.FileOrDirectoryMustExistFeedback;
return false;
}
return true;
default: throw new ArgumentOutOfRangeException (nameof (this.OpenMode));
}
}
private void PushState (DirectoryInfo 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 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.ClearSuggestions ();
if (setPathText) {
this.tbPath.Text = newState.Directory.FullName;
this.tbPath.MoveCursorToEnd ();
}
this.state = newState;
this.tbPath.GenerateSuggestions (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.RowColorGetterArgs args)
{
var stats = this.RowToStats (args.RowIndex);
if (Monochrome) {
return ColorSchemeDefault;
}
if (stats.IsDir ()) {
return ColorSchemeDirectory;
}
if (stats.IsImage ()) {
return ColorSchemeImage;
}
if (stats.IsExecutable ()) {
return ColorSchemeExeOrRecommended;
}
if (stats.FileSystemInfo is FileInfo f && this.MatchesAllowedTypes (f)) {
return ColorSchemeExeOrRecommended;
}
return ColorSchemeDefault;
}
/// <summary>
/// If <see cref="TableView.MultiSelect"/> is on and multiple rows are selected
/// this returns a union of all <see cref="FileSystemInfoStats"/> in the selection.
/// </summary>
/// <remarks>Returns an empty collection if there are not at least 2 rows in the selection</remarks>
/// <returns></returns>
private IEnumerable<FileSystemInfoStats> MultiRowToStats ()
{
var toReturn = new HashSet<FileSystemInfoStats> ();
if (this.AllowsMultipleSelection && this.tableView.MultiSelectedRegions.Any ()) {
foreach (var p in this.tableView.GetAllSelectedCells ()) {
var add = this.state?.Children [(int)this.tableView.Table.Rows [p.Y] [0]];
if (add != null) {
toReturn.Add (add);
}
}
}
return toReturn.Count > 1 ? toReturn : Enumerable.Empty<FileSystemInfoStats> ();
}
private FileSystemInfoStats RowToStats (int rowIndex)
{
return this.state?.Children [(int)this.tableView.Table.Rows [rowIndex] [0]];
}
private int? StatsToRow (FileSystemInfoStats stats)
{
// find array index of the current state for the stats
var idx = state?.Children.IndexOf ((f) => f.FileSystemInfo.FullName == stats.FileSystemInfo.FullName);
if (idx != -1 && idx != null) {
// find the row number in our DataTable where the cell
// contains idx
var match = tableView.Table.Rows
.Cast<DataRow> ()
.Select ((r, rIdx) => new { row = r, rowIdx = rIdx })
.Where (t => (int)t.row [0] == idx)
.ToArray ();
if (match.Length == 1) {
return match [0].rowIdx;
}
}
return null;
}
private void PathChanged ()
{
// avoid re-entry
if (this.pushingState) {
return;
}
var path = this.tbPath.Text?.ToString ();
if (string.IsNullOrWhiteSpace (path)) {
return;
}
var dir = this.StringToDirectoryInfo (path);
if (dir.Exists) {
this.PushState (dir, true, false);
} else
if (dir.Parent?.Exists ?? false) {
this.PushState (dir.Parent, true, false);
}
tbPath.GenerateSuggestions (state);
}
private DirectoryInfo 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 new DirectoryInfo (path + System.IO.Path.DirectorySeparatorChar);
}
return new DirectoryInfo (path);
}
/// <summary>
/// Describes a requirement on what <see cref="FileInfo"/> can be selected
/// in a <see cref="FileDialog2"/>.
/// </summary>
public class AllowedType {
/// <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 a value of <see cref="AllowedType"/> that matches any file.
/// </summary>
public static AllowedType Any { get; } = new AllowedType ("Any Files", ".*");
/// <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>
/// Gets a value indicating whether this instance is the
/// static <see cref="Any"/> value which indicates matching
/// any files.
/// </summary>
public bool IsAny => this == Any;
/// <summary>
/// Returns <see cref="Description"/> plus all <see cref="Extensions"/> separated by semicolons.
/// </summary>
public override string ToString ()
{
return $"{this.Description} ({string.Join (";", this.Extensions.Select (e => '*' + e).ToArray ())})";
}
internal bool Matches (string extension, bool strict)
{
if (this.IsAny) {
return !strict;
}
return this.Extensions.Any (e => e.Equals (extension));
}
}
/// <summary>
/// Defines whether a given file/directory matches a set of
/// search terms.
/// </summary>
public interface ISearchMatcher
{
/// <summary>
/// Called once for each new search. Defines the string
/// the user has provided as search terms.
/// </summary>
void Initialize(string terms);
/// <summary>
/// Return true if <paramref name="f"/> is a match to the
/// last provided search terms
/// </summary>
bool IsMatch(FileSystemInfo f);
}
class DefaultSearchMatcher : ISearchMatcher
{
string terms;
public void Initialize (string terms)
{
this.terms = terms;
}
public bool IsMatch (FileSystemInfo f)
{
//Contains overload with StringComparison is not available in (net472) or (netstandard2.0)
//return f.Name.Contains (terms, StringComparison.OrdinalIgnoreCase);
// This is the same
return f.Name.IndexOf (terms, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
/// <summary>
/// Wrapper for <see cref="FileSystemInfo"/> that contains additional information
/// (e.g. <see cref="IsParent"/>) and helper methods.
/// </summary>
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<string> ImageExtensions = new List<string> { ".JPG", ".JPEG", ".JPE", ".BMP", ".GIF", ".PNG" };
private static readonly List<string> ExecutableExtensions = new List<string> { ".EXE", ".BAT" };
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemInfoStats"/> class.
/// </summary>
/// <param name="fsi">The directory of path to wrap.</param>
public FileSystemInfoStats (FileSystemInfo fsi)
{
this.FileSystemInfo = fsi;
if (fsi is FileInfo fi) {
this.MachineReadableLength = fi.Length;
this.HumanReadableLength = GetHumanReadableFileSize (this.MachineReadableLength);
this.DateModified = FileDialog2.UseUtcDates ? File.GetLastWriteTimeUtc (fi.FullName) : File.GetLastWriteTime (fi.FullName);
this.Type = fi.Extension;
} else {
this.HumanReadableLength = string.Empty;
this.Type = "dir";
}
}
/// <summary>
/// Gets the wrapped <see cref="FileSystemInfo"/> (directory or file).
/// </summary>
public FileSystemInfo FileSystemInfo { get; }
public string HumanReadableLength { get; }
public long MachineReadableLength { get; }
public DateTime? DateModified { get; }
public string Type { get; }
/// <summary>
/// Gets or Sets a value indicating whether this instance represents
/// the parent of the current state (i.e. "..").
/// </summary>
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 (FileDialog2 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.DateModified;
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]);
}
}
/// <summary>
/// State representing a recursive search from <see cref="FileDialogState.Directory"/>
/// downwards.
/// </summary>
internal class SearchState : FileDialogState {
bool cancel = false;
bool finished = false;
// TODO: Add thread safe child adding
List<FileSystemInfoStats> found = new List<FileSystemInfoStats> ();
object oLockFound = new object ();
CancellationTokenSource token = new CancellationTokenSource ();
public SearchState (DirectoryInfo dir, FileDialog2 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.spinnerLabel.Visible = false;
});
}
}
private void UpdateChildrenToFound ()
{
lock (oLockFound) {
Children = found.ToArray ();
}
Application.MainLoop.Invoke (() => {
Parent.tbPath.GenerateSuggestions (this);
Parent.WriteStateToTableView ();
Parent.spinnerLabel.Visible = true;
Parent.spinnerLabel.SetNeedsDisplay ();
});
}
private void RecursiveFind (DirectoryInfo directory)
{
foreach (var f in GetChildren (directory)) {
if (cancel) {
return;
}
if (f.IsParent) {
continue;
}
lock (oLockFound) {
if(found.Count >= FileDialog2.MaxSearchResults)
{
finished = true;
return;
}
}
if (Parent.SearchMatcher.IsMatch(f.FileSystemInfo)) {
lock (oLockFound) {
found.Add (f);
}
}
if (f.FileSystemInfo is DirectoryInfo sub) {
RecursiveFind (sub);
}
}
}
internal override void RefreshChildren ()
{
}
internal void Cancel ()
{
cancel = true;
token.Cancel ();
}
}
internal class FileDialogState {
public FileSystemInfoStats Selected { get; set; }
protected readonly FileDialog2 Parent;
public FileDialogState (DirectoryInfo dir, FileDialog2 parent)
{
this.Directory = dir;
Parent = parent;
this.RefreshChildren ();
}
public DirectoryInfo Directory { get; }
public FileSystemInfoStats [] Children { get; protected set; }
internal virtual void RefreshChildren ()
{
var dir = this.Directory;
Children = GetChildren (dir).ToArray ();
}
protected virtual IEnumerable<FileSystemInfoStats> GetChildren (DirectoryInfo dir)
{
try {
List<FileSystemInfoStats> 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.AllowedTypesIsStrict && Parent.OpenMode == OpenMode.File) {
children = children.Where (
c => c.IsDir () ||
(c.FileSystemInfo is FileInfo 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<FileSystemInfoStats> ();
}
}
protected bool MatchesApiFilter (FileSystemInfoStats arg)
{
return arg.IsDir () ||
(arg.FileSystemInfo is FileInfo f && Parent.currentFilter.Matches (f.Extension, true));
}
}
internal class CaptionedTextField : TextField {
/// <summary>
/// A text prompt to display in the field when it does not
/// have focus and no text is yet entered.
/// </summary>
public ustring Caption { get; set; }
/// <summary>
/// The foreground color to use for the caption
/// </summary>
public Color CaptionColor { get; set; } = Color.Black;
public override void Redraw (Rect bounds)
{
base.Redraw (bounds);
if (HasFocus || Caption == null || Caption.Length == 0
|| Text?.Length > 0) {
return;
}
var color = new Attribute (CaptionColor, GetNormalColor ().Background);
Driver.SetAttribute (color);
Move (0, 0);
var render = Caption;
if (render.ConsoleWidth > Bounds.Width) {
render = render.RuneSubstring (0, Bounds.Width);
}
Driver.AddStr (render);
}
}
internal class SpinnerLabel : Label {
private Rune [] runes = new Rune [] { '|', '/', '\u2500', '\\'};
private int currentIdx = 0;
private DateTime lastRender = DateTime.MinValue;
public override void Redraw (Rect bounds)
{
if (DateTime.Now - lastRender > TimeSpan.FromMilliseconds (250)) {
currentIdx = (currentIdx + 1) % runes.Length;
Text = "" + runes [currentIdx];
}
base.Redraw (bounds);
}
}
internal class TextFieldWithAppendAutocomplete : CaptionedTextField {
private int? currentFragment = null;
private string [] validFragments = new string [0];
public TextFieldWithAppendAutocomplete ()
{
this.KeyPress += (k) => {
var key = k.KeyEvent.Key;
if (key == Key.Tab) {
k.Handled = this.AcceptSelectionIfAny ();
} else
if (key == Key.CursorUp) {
k.Handled = this.CycleSuggestion (1);
} else
if (key == Key.CursorDown) {
k.Handled = this.CycleSuggestion (-1);
}
};
this.ColorScheme = new ColorScheme {
Normal = new Attribute (Color.White, Color.Black),
HotNormal = new Attribute (Color.White, Color.Black),
Focus = new Attribute (Color.White, Color.Black),
HotFocus = new Attribute (Color.White, Color.Black),
};
}
public override void Redraw (Rect bounds)
{
base.Redraw (bounds);
if (!this.MakingSuggestion ()) {
return;
}
// draw it like its selected even though its not
Driver.SetAttribute (new Attribute (Color.DarkGray, Color.Black));
this.Move (this.Text.Length, 0);
Driver.AddStr (this.validFragments [this.currentFragment.Value]);
}
/// <summary>
/// Accepts the current autocomplete suggestion displaying in the text box.
/// Returns true if a valid suggestion was being rendered and acceptable or
/// false if no suggestion was showing.
/// </summary>
/// <returns></returns>
internal bool AcceptSelectionIfAny ()
{
if (this.MakingSuggestion ()) {
this.Text += this.validFragments [this.currentFragment.Value];
this.MoveCursorToEnd ();
this.ClearSuggestions ();
return true;
}
return false;
}
internal void MoveCursorToEnd ()
{
this.ClearAllSelection ();
this.CursorPosition = this.Text.Length;
}
internal void GenerateSuggestions (FileDialogState state, params string [] suggestions)
{
if (!this.CursorIsAtEnd ()) {
return;
}
var path = this.Text.ToString ();
var last = path.LastIndexOfAny (FileDialog2.separators);
if (last == -1 || suggestions.Length == 0 || last >= path.Length - 1) {
this.currentFragment = null;
return;
}
var term = path.Substring (last + 1);
if (term.Equals (state?.Directory?.Name)) {
this.ClearSuggestions ();
return;
}
// TODO: Be case insensitive on Windows
var validSuggestions = suggestions
.Where (s => s.StartsWith (term))
.OrderBy (m => m.Length)
.ToArray ();
// nothing to suggest
if (validSuggestions.Length == 0 || validSuggestions [0].Length == term.Length) {
this.ClearSuggestions ();
return;
}
this.validFragments = validSuggestions.Select (f => f.Substring (term.Length)).ToArray ();
this.currentFragment = 0;
}
internal void ClearSuggestions ()
{
this.currentFragment = null;
this.validFragments = new string [0];
this.SetNeedsDisplay ();
}
internal void GenerateSuggestions (FileDialogState state)
{
if (state == null) {
return;
}
var suggestions = state.Children.Select (
e => e.FileSystemInfo is DirectoryInfo d
? d.Name + System.IO.Path.DirectorySeparatorChar
: e.FileSystemInfo.Name)
.ToArray ();
this.GenerateSuggestions (state, suggestions);
}
internal void SetTextTo (FileSystemInfo fileSystemInfo)
{
var newText = fileSystemInfo.FullName;
if (fileSystemInfo is DirectoryInfo) {
newText += System.IO.Path.DirectorySeparatorChar;
}
this.Text = newText;
this.MoveCursorToEnd ();
}
internal bool CursorIsAtEnd ()
{
return this.CursorPosition == this.Text.Length;
}
/// <summary>
/// Returns true if there is a suggestion that can be made and the control
/// is in a state where user would expect to see auto-complete (i.e. focused and
/// cursor in right place).
/// </summary>
/// <returns></returns>
private bool MakingSuggestion ()
{
return this.currentFragment != null && this.HasFocus && this.CursorIsAtEnd ();
}
private bool CycleSuggestion (int direction)
{
if (this.currentFragment == null || this.validFragments.Length <= 1) {
return false;
}
this.currentFragment = (this.currentFragment + direction) % this.validFragments.Length;
if (this.currentFragment < 0) {
this.currentFragment = this.validFragments.Length - 1;
}
this.SetNeedsDisplay ();
return true;
}
}
internal class FileDialogHistory {
private Stack<FileDialogState> back = new Stack<FileDialogState> ();
private Stack<FileDialogState> forward = new Stack<FileDialogState> ();
private FileDialog2 dlg;
public FileDialogHistory (FileDialog2 dlg)
{
this.dlg = dlg;
}
public bool Back ()
{
DirectoryInfo 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);
}
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 ();
}
}
private void RestoreSelection (FileSystemInfoStats toRestore)
{
var toReselect = StatsToRow (toRestore);
if (toReselect.HasValue) {
tableView.SelectedRow = toReselect.Value;
}
}
private class FileDialogSorter {
private readonly FileDialog2 dlg;
private TableView tableView;
private DataColumn currentSort = null;
private bool currentSortIsAsc = true;
public FileDialogSorter (FileDialog2 dlg, TableView tableView)
{
this.dlg = dlg;
this.tableView = tableView;
// if user clicks the mouse in TableView
this.tableView.MouseClick += e => {
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);
}
}
};
}
internal void ApplySort ()
{
var col = this.currentSort;
// TODO: Consider preserving selection
this.tableView.Table.Rows.Clear ();
var colName = col == null ? null : StripArrows (col.ColumnName);
var stats = this.dlg.state?.Children ?? new FileSystemInfoStats [0];
// Do we sort on a column or just use the default sort order?
Func<FileSystemInfoStats, object> sortAlgorithm;
if (colName == null) {
sortAlgorithm = (v) => v.GetOrderByDefault ();
this.currentSortIsAsc = true;
} else {
sortAlgorithm = (v) => v.GetOrderByValue (dlg,colName);
}
var ordered =
this.currentSortIsAsc ?
stats.Select ((v, i) => new { v, i })
.OrderByDescending (f => f.v.IsParent)
.ThenBy (f => sortAlgorithm (f.v))
.ToArray () :
stats.Select ((v, i) => new { v, i })
.OrderByDescending (f => f.v.IsParent)
.ThenByDescending (f => sortAlgorithm (f.v))
.ToArray ();
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 = TrimArrows (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 TrimArrows (string columnName)
{
return columnName.TrimEnd ('▼', '▲');
}
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);
}
private 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, View.MouseEventArgs 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 {TrimArrows(clickedCol.ColumnName)}", string.Empty, () => this.HideColumn(clickedCol)),
new MenuItem($"Sort {StripArrows(sort)}",string.Empty, ()=> this.SortColumn(clickedCol,isAsc)),
})
);
contextMenu.Show ();
}
private void HideColumn (DataColumn clickedCol)
{
var style = this.tableView.Style.GetOrCreateColumnStyle (clickedCol);
style.Visible = false;
this.tableView.Update ();
}
}
private class FileDialogRootTreeNode {
public FileDialogRootTreeNode (string displayName, DirectoryInfo path)
{
this.DisplayName = displayName;
this.Path = path;
}
public string DisplayName { get; set; }
public DirectoryInfo Path { get; set; }
public override string ToString ()
{
return this.DisplayName;
}
}
private class FileDialogTreeBuilder : ITreeBuilder<object> {
public bool SupportsCanExpand => true;
public bool CanExpand (object toExpand)
{
return this.TryGetDirectories (NodeToDirectory (toExpand)).Any ();
}
public IEnumerable<object> 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<DirectoryInfo> TryGetDirectories (DirectoryInfo directoryInfo)
{
try {
return directoryInfo.EnumerateDirectories ();
} catch (Exception) {
return Enumerable.Empty<DirectoryInfo> ();
}
}
}
}
}