Преглед на файлове

Fixes #2582 - Refactors FileDialog for cleaner data model (#2583)

* WIP: Add ITableDataSource

* WIP: Refactor TableView

* WIP: Port CSVEditor

* WIP: Port TableEditor

* WIP: Port MultiColouredTable scenario

* Fix bug of adding duplicate column styles

* Update tests to use DataTableSource

* Tidy up

* Add EnumerableTableDataSource<T>

* Add test for EnumerableTableDataSource

* Add test for EnumerableTableDataSource

* Add code example to xmldoc

* Add ProcessTable scenario

* Rename ITableDataSource to ITableSource and update docs

* Rename EnumerableTableDataSource to EnumerableTableSource

* Fixed Frame != Bounds; changed UICat Scenarios list to use tableview!

* Fix scroll resetting in ProcessTable scenario

* Fix unit tests by setting Frame to same as Bounds

* Document why we have to measure our data for use with TableView

* WIP: Simplify FileDialogs use of TableView

* WIP start migrating sorter

* WIP new filedialog table source mostly working

* WIP remove sorter class

* Refactor GetOrderByValue to be adjacent to GetColumnValue

* Fix collection navigator back so it ignores icon

* Fix unit tests

* Tidy up

* Fix UseColors

* Add test for UseColors

---------

Co-authored-by: Tig Kindel <[email protected]>
Thomas Nind преди 2 години
родител
ревизия
130fc5713d

+ 1 - 1
Terminal.Gui/FileServices/FileDialogState.cs

@@ -28,7 +28,7 @@ namespace Terminal.Gui {
 
 		public IDirectoryInfo Directory { get; }
 
-		public FileSystemInfoStats [] Children { get; protected set; }
+		public FileSystemInfoStats [] Children { get; internal set; }
 
 		internal virtual void RefreshChildren ()
 		{

+ 1 - 1
Terminal.Gui/FileServices/FileDialogStyle.cs

@@ -40,7 +40,7 @@ namespace Terminal.Gui {
 		/// <summary>
 		/// Gets or sets the culture to use (e.g. for number formatting).
 		/// Defaults to <see cref="CultureInfo.CurrentUICulture"/>.
-		/// <summary>
+		/// </summary>
 		public CultureInfo Culture {get;set;} = CultureInfo.CurrentUICulture;
 
 		/// <summary>

+ 2 - 28
Terminal.Gui/FileServices/FileSystemInfoStats.cs

@@ -73,7 +73,7 @@ namespace Terminal.Gui {
 
 		public bool IsImage ()
 		{
-			return this.FileSystemInfo is FileSystemInfo f &&
+			return this.FileSystemInfo is IFileSystemInfo f &&
 				ImageExtensions.Contains (
 					f.Extension,
 					StringComparer.InvariantCultureIgnoreCase);
@@ -82,38 +82,12 @@ namespace Terminal.Gui {
 		public bool IsExecutable ()
 		{
 			// TODO: handle linux executable status
-			return this.FileSystemInfo is FileSystemInfo f &&
+			return this.FileSystemInfo is IFileSystemInfo 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, CultureInfo culture)
 		{
 

+ 131 - 268
Terminal.Gui/Views/FileDialog.cs

@@ -85,10 +85,8 @@ namespace Terminal.Gui {
 		private IFileSystem fileSystem;
 		private TextField tbPath;
 
-		private FileDialogSorter sorter;
 		private FileDialogHistory history;
 
-		private DataTable dtFiles;
 		private TableView tableView;
 		private TreeView<object> treeView;
 		private TileView splitContainer;
@@ -107,7 +105,10 @@ namespace Terminal.Gui {
 		private MenuBar allowedTypeMenuBar;
 		private MenuBarItem allowedTypeMenu;
 		private MenuItem [] allowedTypeMenuItems;
-		private int filenameColumn;
+
+		private int currentSortColumn;
+		
+		private bool currentSortIsAsc = true;
 
 		/// <summary>
 		/// Event fired when user attempts to confirm a selection (or multi selection).
@@ -220,8 +221,25 @@ namespace Terminal.Gui {
 			};
 
 			this.tableView.AddKeyBinding (Key.Space, Command.ToggleChecked);
+			this.tableView.MouseClick += OnTableViewMouseClick;
 			Style.TableStyle = tableView.Style;
 
+			var nameStyle = Style.TableStyle.GetOrCreateColumnStyle (0);
+			nameStyle.MinWidth = 10;
+			nameStyle.ColorGetter = this.ColorGetter;
+
+			var sizeStyle = Style.TableStyle.GetOrCreateColumnStyle (1);
+			sizeStyle.MinWidth = 10;
+			sizeStyle.ColorGetter = this.ColorGetter;
+
+			var dateModifiedStyle = Style.TableStyle.GetOrCreateColumnStyle (2);
+			dateModifiedStyle.MinWidth = 30;
+			dateModifiedStyle.ColorGetter = this.ColorGetter;
+
+			var typeStyle = Style.TableStyle.GetOrCreateColumnStyle (3);
+			typeStyle.MinWidth = 6;
+			typeStyle.ColorGetter = this.ColorGetter;
+
 			this.tableView.KeyPress += (s, k) => {
 				if (this.tableView.SelectedRow <= 0) {
 					this.NavigateIf (k, Key.CursorUp, this.tbPath);
@@ -313,13 +331,8 @@ namespace Terminal.Gui {
 			this.tableView.Style.ShowHorizontalHeaderUnderline = true;
 			this.tableView.Style.ShowHorizontalScrollIndicators = true;
 
-			this.SetupTableColumns ();
-
-			this.sorter = new FileDialogSorter (this, this.tableView);
 			this.history = new FileDialogHistory (this);
 
-			this.tableView.Table = new DataTableSource(this.dtFiles);
-
 			this.tbPath.TextChanged += (s, e) => this.PathChanged ();
 
 			this.tableView.CellActivated += this.CellActivate;
@@ -366,9 +379,29 @@ namespace Terminal.Gui {
 			this.Add (this.btnForward);
 			this.Add (this.tbPath);
 			this.Add (this.splitContainer);
+		}
+
+		private void OnTableViewMouseClick (object sender, MouseEventEventArgs e)
+		{
+			var clickedCell = this.tableView.ScreenToCell (e.MouseEvent.X, e.MouseEvent.Y, out int? clickedCol);
+
+			if (clickedCol != null) {
+				if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) {
+
+					// left click in a header
+					this.SortColumn (clickedCol.Value);
+				} else if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) {
+
+					// right click in a header
+					this.ShowHeaderContextMenu (clickedCol.Value, e);
+				}
+			} else {
+				if (clickedCell != null && e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) {
 
-			// Default sort order is by name
-			sorter.SortColumn(this.filenameColumn,true);
+					// right click in rest of table
+					this.ShowCellContextMenu (clickedCell, e);
+				}
+			}
 		}
 
 		private string GetForwardButtonText ()
@@ -541,15 +574,7 @@ namespace Terminal.Gui {
 			var col = tableView.SelectedColumn;
 			var style = tableView.Style.GetColumnStyleIfAny (col);
 
-
-			var collection = dtFiles
-				.Rows
-				.Cast<DataRow> ()
-				.Select ((o, idx) => col == 0 ? 
-					RowToStats(idx).FileSystemInfo.Name :
-					style.GetRepresentation (o [0])?.TrimStart('.'))
-				.ToArray ();
-
+			var collection = State.Children.Select (s=> FileDialogTableSource.GetRawColumnValue(col,s));
 			collectionNavigator = new CollectionNavigator (collection);
 		}
 
@@ -963,61 +988,6 @@ namespace Terminal.Gui {
 			return false;
 		}
 
-		private void SetupTableColumns ()
-		{
-			this.dtFiles = new DataTable ();
-
-			var nameStyle = this.tableView.Style.GetOrCreateColumnStyle (
-				filenameColumn = this.dtFiles.Columns.Add (Style.FilenameColumnName, typeof (int)).Ordinal
-				);
-			nameStyle.RepresentationGetter = (i) => {
-
-				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)).Ordinal);
-			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)).Ordinal);
-			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)).Ordinal);
-			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;
-			}
-			
-		}
-
 		private void CellActivate (object sender, CellActivatedEventArgs obj)
 		{
 			if(TryAcceptMulti())
@@ -1225,23 +1195,13 @@ namespace Terminal.Gui {
 			if (this.State == null) {
 				return;
 			}
+			this.tableView.Table = new FileDialogTableSource (this.State, this.Style, currentSortColumn, currentSortIsAsc);
 
-			this.dtFiles.Rows.Clear ();
-
-			for (int i = 0; i < this.State.Children.Length; i++) {
-				this.BuildRow (i);
-			}
-
-			this.sorter.ApplySort ();
+			this.ApplySort ();
 			this.tableView.Update ();
 			UpdateCollectionNavigator ();
 		}
 
-		private void BuildRow (int idx)
-		{
-			dtFiles.Rows.Add (idx, idx, idx, idx);
-		}
-
 		private ColorScheme ColorGetter (TableView.CellColorGetterArgs args)
 		{
 			var stats = this.RowToStats (args.RowIndex);
@@ -1279,7 +1239,7 @@ namespace Terminal.Gui {
 
 				foreach (var p in this.tableView.GetAllSelectedCells ()) {
 
-					var add = this.State?.Children [(int)this.tableView.Table[p.Y, 0]];
+					var add = this.State?.Children [p.Y];
 					if (add != null) {
 						toReturn.Add (add);
 					}
@@ -1290,31 +1250,9 @@ namespace Terminal.Gui {
 		}
 		private FileSystemInfoStats RowToStats (int rowIndex)
 		{
-			return this.State?.Children [(int)this.tableView.Table[rowIndex,0]];
+			return this.State?.Children [rowIndex];
 		}
-		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 = dtFiles.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
@@ -1358,185 +1296,110 @@ namespace Terminal.Gui {
 		/// <param name="toRestore"></param>
 		internal void RestoreSelection (IFileSystemInfo toRestore)
 		{
-			var toReselect = StatsToRow (toRestore);
-
-			if (toReselect.HasValue) {
-				tableView.SelectedRow = toReselect.Value;
-				tableView.EnsureSelectedCellIsVisible ();
-			}
+			tableView.SelectedRow = State.Children.IndexOf (r=>r.FileSystemInfo == toRestore);
+			tableView.EnsureSelectedCellIsVisible ();
 		}
-		private class FileDialogSorter {
-			private readonly FileDialog dlg;
-			private TableView tableView;
 
-			private int? 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 int? clickedCol);
-
-					if (clickedCol != null) {
-						if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) {
-
-							// left click in a header
-							this.SortColumn (clickedCol.Value);
-						} else if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) {
-
-							// right click in a header
-							this.ShowHeaderContextMenu (clickedCol.Value, 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;
-
-				if(col == null) {
-					return;
-				}
-
-				// TODO: Consider preserving selection
-				dlg.dtFiles.Rows.Clear ();
-
-				var colName = col == null ? null : StripArrows (tableView.Table.ColumnNames[col.Value]);
-
-				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);
-				}
+		internal void ApplySort ()
+		{
+			var stats = State?.Children ?? new FileSystemInfoStats [0];
 
-				// 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 never reordered (aways .. at top then folders)
+			var forcedOrder = stats
+			.OrderByDescending (f => f.IsParent)
+					.ThenBy (f => f.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));
+			// This portion is flexible based on the column clicked (e.g. alphabetical)
+			var ordered = 
+				this.currentSortIsAsc ?
+					forcedOrder.ThenBy (f => FileDialogTableSource.GetRawColumnValue(currentSortColumn,f)):
+					forcedOrder.ThenByDescending (f => FileDialogTableSource.GetRawColumnValue (currentSortColumn, f));
 
-				foreach (var o in ordered) {
-					this.dlg.BuildRow (o.i);
-				}
+			State.Children = ordered.ToArray();
 
-				foreach (DataColumn c in dlg.dtFiles.Columns) {
-
-					// remove any lingering sort indicator
-					c.ColumnName = StripArrows (c.ColumnName);
+			this.tableView.Update ();
+			UpdateCollectionNavigator ();
+		}
 
-					// add a new one if this the one that is being sorted
-					if (c.Ordinal == col) {
-						c.ColumnName += this.currentSortIsAsc ? " (▲)" : " (▼)";
-					}
-				}
+		private void SortColumn (int clickedCol)
+		{
+			this.GetProposedNewSortOrder (clickedCol, out var isAsc);
+			this.SortColumn (clickedCol, isAsc);
+			this.tableView.Table = new FileDialogTableSource(State,Style,currentSortColumn,currentSortIsAsc);
+		}
 
-				this.tableView.Update ();
-				dlg.UpdateCollectionNavigator ();
-			}
+		internal void SortColumn (int col, bool isAsc)
+		{
+			// set a sort order
+			this.currentSortColumn = col;
+			this.currentSortIsAsc = isAsc;
 
-			private static string StripArrows (string columnName)
-			{
-				return columnName.Replace (" (▼)", string.Empty).Replace (" (▲)", string.Empty);
-			}
+			this.ApplySort ();
+		}
 
-			private void SortColumn (int clickedCol)
-			{
-				this.GetProposedNewSortOrder (clickedCol, out var isAsc);
-				this.SortColumn (clickedCol, isAsc);
+		private string GetProposedNewSortOrder (int clickedCol, out bool isAsc)
+		{
+			// work out new sort order
+			if (this.currentSortColumn == clickedCol && this.currentSortIsAsc) {
+				isAsc = false;
+				return $"{tableView.Table.ColumnNames[clickedCol]} DESC";
+			} else {
+				isAsc = true;
+				return $"{tableView.Table.ColumnNames [clickedCol]} ASC";
 			}
+		}
 
-			internal void SortColumn (int col, bool isAsc)
-			{
-				// set a sort order
-				this.currentSort = col;
-				this.currentSortIsAsc = isAsc;
-
-				this.ApplySort ();
-			}
+		private void ShowHeaderContextMenu (int clickedCol, MouseEventEventArgs e)
+		{
+			var sort = this.GetProposedNewSortOrder (clickedCol, out var isAsc);
 
-			private string GetProposedNewSortOrder (int clickedCol, out bool isAsc)
-			{
-				// work out new sort order
-				if (this.currentSort == clickedCol && this.currentSortIsAsc) {
-					isAsc = false;
-					return $"{tableView.Table.ColumnNames[clickedCol]} DESC";
-				} else {
-					isAsc = true;
-					return $"{tableView.Table.ColumnNames [clickedCol]} ASC";
-				}
-			}
+			var contextMenu = new ContextMenu (
+				e.MouseEvent.X + 1,
+				e.MouseEvent.Y + 1,
+				new MenuBarItem (new MenuItem []
+				{
+					new MenuItem($"Hide {StripArrows(tableView.Table.ColumnNames[clickedCol])}", string.Empty, () => this.HideColumn(clickedCol)),
+					new MenuItem($"Sort {StripArrows(sort)}",string.Empty, ()=> this.SortColumn(clickedCol,isAsc)),
+				})
+			);
 
-			private void ShowHeaderContextMenu (int clickedCol, MouseEventEventArgs e)
-			{
-				var sort = this.GetProposedNewSortOrder (clickedCol, out var isAsc);
+			contextMenu.Show ();
+		}
 
-				var contextMenu = new ContextMenu (
-					e.MouseEvent.X + 1,
-					e.MouseEvent.Y + 1,
-					new MenuBarItem (new MenuItem []
-					{
-						new MenuItem($"Hide {StripArrows(tableView.Table.ColumnNames[clickedCol])}", string.Empty, () => this.HideColumn(clickedCol)),
-						new MenuItem($"Sort {StripArrows(sort)}",string.Empty, ()=> this.SortColumn(clickedCol,isAsc)),
-					})
-				);
+		private static string StripArrows (string columnName)
+		{
+			return columnName.Replace (" (▼)", string.Empty).Replace (" (▲)", string.Empty);
+		}
 
-				contextMenu.Show ();
+		private void ShowCellContextMenu (Point? clickedCell, MouseEventEventArgs e)
+		{
+			if (clickedCell == null) {
+				return;
 			}
 
-			private void ShowCellContextMenu (Point? clickedCell, MouseEventEventArgs e)
-			{
-				if (clickedCell == null) {
-					return;
-				}
-
-				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()),
-					})
-				);
+			var contextMenu = new ContextMenu (
+				e.MouseEvent.X + 1,
+				e.MouseEvent.Y + 1,
+				new MenuBarItem (new MenuItem []
+				{
+					new MenuItem($"New", string.Empty, () => New()),
+					new MenuItem($"Rename",string.Empty, ()=>  Rename()),
+					new MenuItem($"Delete",string.Empty, ()=>  Delete()),
+				})
+			);
 
-				dlg.tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false);
+			tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false);
 
-				contextMenu.Show ();
-			}
+			contextMenu.Show ();
+		}
 
-			private void HideColumn (int clickedCol)
-			{
-				var style = this.tableView.Style.GetOrCreateColumnStyle (clickedCol);
-				style.Visible = false;
-				this.tableView.Update ();
-			}
+		private void HideColumn (int clickedCol)
+		{
+			var style = this.tableView.Style.GetOrCreateColumnStyle (clickedCol);
+			style.Visible = false;
+			this.tableView.Update ();
 		}
+		
 		/// <summary>
 		/// State representing a recursive search from <see cref="FileDialogState.Directory"/>
 		/// downwards.

+ 72 - 0
Terminal.Gui/Views/FileDialogTableSource.cs

@@ -0,0 +1,72 @@
+using System;
+using System.Linq;
+
+namespace Terminal.Gui {
+	internal class FileDialogTableSource : ITableSource {
+		readonly FileDialogStyle style;
+		readonly int currentSortColumn;
+		readonly bool currentSortIsAsc;
+		readonly FileDialogState state;
+
+		public FileDialogTableSource (FileDialogState state, FileDialogStyle style, int currentSortColumn, bool currentSortIsAsc)
+		{
+			this.style = style;
+			this.currentSortColumn = currentSortColumn;
+			this.currentSortIsAsc = currentSortIsAsc;
+			this.state = state;
+		}
+
+		public object this [int row, int col] => GetColumnValue (col, state.Children [row]);
+
+		private object GetColumnValue (int col, FileSystemInfoStats stats)
+		{
+			switch (col) {
+			case 0:
+				var icon = stats.IsParent ? null : style.IconGetter?.Invoke (stats.FileSystemInfo);
+				return icon + (stats?.Name ?? string.Empty);
+			case 1:
+				return stats?.HumanReadableLength ?? string.Empty;
+			case 2:
+				if (stats == null || stats.IsParent || stats.LastWriteTime == null) {
+					return string.Empty;
+				}
+				return stats.LastWriteTime.Value.ToString (style.DateFormat);
+			case 3:
+				return stats?.Type ?? string.Empty;
+			default:
+				throw new ArgumentOutOfRangeException (nameof (col));
+			}
+		}
+
+		internal static object GetRawColumnValue (int col, FileSystemInfoStats stats)
+		{
+			switch (col) {
+			case 0: return stats.FileSystemInfo.Name;
+			case 1: return stats.MachineReadableLength;
+			case 2: return stats.LastWriteTime;
+			case 3: return stats.Type;
+			}
+
+			throw new ArgumentOutOfRangeException (nameof (col));
+		}
+		public int Rows => state.Children.Count ();
+
+		public int Columns => 4;
+
+		public string [] ColumnNames => new string []{
+			MaybeAddSortArrows(style.FilenameColumnName,0),
+			MaybeAddSortArrows(style.SizeColumnName,1),
+			MaybeAddSortArrows(style.ModifiedColumnName,2),
+			MaybeAddSortArrows(style.TypeColumnName,3)
+		};
+
+		private string MaybeAddSortArrows (string name, int idx)
+		{
+			if (idx == currentSortColumn) {
+				return name + (currentSortIsAsc ? " (▲)" : " (▼)");
+			}
+
+			return name;
+		}
+	}
+}

+ 153 - 130
UnitTests/FileServices/FileDialogTests.cs

@@ -130,13 +130,13 @@ namespace Terminal.Gui.FileServicesTests {
 		}
 
 		[Theory, AutoInitShutdown]
-		[InlineData(true,true)]
-		[InlineData(true,false)]
-		[InlineData(false,true)]
-		[InlineData(false,false)]
+		[InlineData (true, true)]
+		[InlineData (true, false)]
+		[InlineData (false, true)]
+		[InlineData (false, false)]
 		public void PickDirectory_DirectTyping (bool openModeMixed, bool multiple)
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = openModeMixed ? OpenMode.Mixed : OpenMode.Directory;
 			dlg.AllowsMultipleSelection = multiple;
 
@@ -144,61 +144,60 @@ namespace Terminal.Gui.FileServicesTests {
 			// so to add to current path user must press End or right
 			Send ('>', ConsoleKey.RightArrow, false);
 
-			Send("subfolder");
+			Send ("subfolder");
 
 			// Dialog has not yet been confirmed with a choice
-			Assert.True(dlg.Canceled);
+			Assert.True (dlg.Canceled);
 
 			// Now it has
 			Send ('\n', ConsoleKey.Enter, false);
-			Assert.False(dlg.Canceled);
-			AssertIsTheSubfolder(dlg.Path);
+			Assert.False (dlg.Canceled);
+			AssertIsTheSubfolder (dlg.Path);
 		}
 
 		[Theory, AutoInitShutdown]
-		[InlineData(true,true)]
-		[InlineData(true,false)]
-		[InlineData(false,true)]
-		[InlineData(false,false)]
+		[InlineData (true, true)]
+		[InlineData (true, false)]
+		[InlineData (false, true)]
+		[InlineData (false, false)]
 		public void PickDirectory_ArrowNavigation (bool openModeMixed, bool multiple)
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = openModeMixed ? OpenMode.Mixed : OpenMode.Directory;
 			dlg.AllowsMultipleSelection = multiple;
 
-			Assert.IsType<TextField>(dlg.MostFocused);
+			Assert.IsType<TextField> (dlg.MostFocused);
 			Send ('v', ConsoleKey.DownArrow, false);
-			Assert.IsType<TableView>(dlg.MostFocused);
+			Assert.IsType<TableView> (dlg.MostFocused);
 
 			// Should be selecting ..
 			Send ('v', ConsoleKey.DownArrow, false);
 
 			// Down to the directory
-			Assert.True(dlg.Canceled);
+			Assert.True (dlg.Canceled);
 			// Alt+O to open (enter would just navigate into the child dir)
-			Send ('o', ConsoleKey.O, false,true);
-			Assert.False(dlg.Canceled);
+			Send ('o', ConsoleKey.O, false, true);
+			Assert.False (dlg.Canceled);
 
-			AssertIsTheSubfolder(dlg.Path);
+			AssertIsTheSubfolder (dlg.Path);
 		}
 
 		[Theory, AutoInitShutdown]
-		[InlineData(true)]
-		[InlineData(false)]
+		[InlineData (true)]
+		[InlineData (false)]
 		public void MultiSelectDirectory_CannotToggleDotDot (bool acceptWithEnter)
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = OpenMode.Directory;
 			dlg.AllowsMultipleSelection = true;
 			IReadOnlyCollection<string> eventMultiSelected = null;
-			dlg.FilesSelected += (s,e)=>
-			{
-				eventMultiSelected  = e.Dialog.MultiSelected;
+			dlg.FilesSelected += (s, e) => {
+				eventMultiSelected = e.Dialog.MultiSelected;
 			};
 
-			Assert.IsType<TextField>(dlg.MostFocused);
+			Assert.IsType<TextField> (dlg.MostFocused);
 			Send ('v', ConsoleKey.DownArrow, false);
-			Assert.IsType<TableView>(dlg.MostFocused);
+			Assert.IsType<TableView> (dlg.MostFocused);
 
 			// Try to toggle '..'
 			Send (' ', ConsoleKey.Spacebar, false);
@@ -206,202 +205,184 @@ namespace Terminal.Gui.FileServicesTests {
 			// Toggle subfolder
 			Send (' ', ConsoleKey.Spacebar, false);
 
-			Assert.True(dlg.Canceled);
+			Assert.True (dlg.Canceled);
 
-			if(acceptWithEnter)
-			{
+			if (acceptWithEnter) {
 				Send ('\n', ConsoleKey.Enter);
+			} else {
+				Send ('o', ConsoleKey.O, false, true);
 			}
-			else
-			{
-				Send ('o', ConsoleKey.O,false,true);
-			}
-			Assert.False(dlg.Canceled);
+			Assert.False (dlg.Canceled);
 
-			Assert.Multiple(
-				()=>{
+			Assert.Multiple (
+				() => {
 					// Only the subfolder should be selected
-					Assert.Equal(1,dlg.MultiSelected.Count);
-					AssertIsTheSubfolder(dlg.Path);
-					AssertIsTheSubfolder(dlg.MultiSelected.Single());
+					Assert.Equal (1, dlg.MultiSelected.Count);
+					AssertIsTheSubfolder (dlg.Path);
+					AssertIsTheSubfolder (dlg.MultiSelected.Single ());
 				},
-				()=>{
+				() => {
 					// Event should also agree with the final state
-					Assert.NotNull(eventMultiSelected);
-					Assert.Equal(1,eventMultiSelected.Count);
-					AssertIsTheSubfolder(eventMultiSelected.Single());
+					Assert.NotNull (eventMultiSelected);
+					Assert.Equal (1, eventMultiSelected.Count);
+					AssertIsTheSubfolder (eventMultiSelected.Single ());
 				}
 			);
 		}
-		
+
 		[Fact, AutoInitShutdown]
 		public void DotDot_MovesToRoot_ThenPressBack ()
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = OpenMode.Directory;
 			dlg.AllowsMultipleSelection = true;
 			bool selected = false;
-			dlg.FilesSelected += (s,e)=>
-			{
+			dlg.FilesSelected += (s, e) => {
 				selected = true;
 			};
 
-			AssertIsTheStartingDirectory(dlg.Path);
+			AssertIsTheStartingDirectory (dlg.Path);
 
-			Assert.IsType<TextField>(dlg.MostFocused);
+			Assert.IsType<TextField> (dlg.MostFocused);
 			Send ('v', ConsoleKey.DownArrow, false);
-			Assert.IsType<TableView>(dlg.MostFocused);
-			
+			Assert.IsType<TableView> (dlg.MostFocused);
+
 			// ".." should be the first thing selected
 			// ".." should not mess with the displayed path
-			AssertIsTheStartingDirectory(dlg.Path);
+			AssertIsTheStartingDirectory (dlg.Path);
 
 			// Accept navigation up a directory
 			Send ('\n', ConsoleKey.Enter);
 
-			AssertIsTheRootDirectory(dlg.Path);
-			
-			Assert.True(dlg.Canceled);
-			Assert.False(selected);
+			AssertIsTheRootDirectory (dlg.Path);
+
+			Assert.True (dlg.Canceled);
+			Assert.False (selected);
 
 			// Now press the back button (in table view)
 			Send ('<', ConsoleKey.Backspace);
 
 			// Should move us back to the root
-			AssertIsTheStartingDirectory(dlg.Path);
+			AssertIsTheStartingDirectory (dlg.Path);
 
-			Assert.True(dlg.Canceled);
-			Assert.False(selected);
+			Assert.True (dlg.Canceled);
+			Assert.False (selected);
 		}
 
 		[Fact, AutoInitShutdown]
 		public void MultiSelectDirectory_EnterOpensFolder ()
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = OpenMode.Directory;
 			dlg.AllowsMultipleSelection = true;
 			IReadOnlyCollection<string> eventMultiSelected = null;
-			dlg.FilesSelected += (s,e)=>
-			{
-				eventMultiSelected  = e.Dialog.MultiSelected;
+			dlg.FilesSelected += (s, e) => {
+				eventMultiSelected = e.Dialog.MultiSelected;
 			};
 
-			Assert.IsType<TextField>(dlg.MostFocused);
+			Assert.IsType<TextField> (dlg.MostFocused);
 			Send ('v', ConsoleKey.DownArrow, false);
-			Assert.IsType<TableView>(dlg.MostFocused);
+			Assert.IsType<TableView> (dlg.MostFocused);
 			// Move selection to subfolder
 			Send ('v', ConsoleKey.DownArrow, false);
 
 			Send ('\n', ConsoleKey.Enter);
 
 			// Path should update to the newly opened folder
-			AssertIsTheSubfolder(dlg.Path);
+			AssertIsTheSubfolder (dlg.Path);
 
 			// No selection will have been confirmed
-			Assert.True(dlg.Canceled);
-			Assert.Empty(dlg.MultiSelected);
-			Assert.Null(eventMultiSelected);
+			Assert.True (dlg.Canceled);
+			Assert.Empty (dlg.MultiSelected);
+			Assert.Null (eventMultiSelected);
 		}
 
 		[Theory, AutoInitShutdown]
-		[InlineData(true)]
-		[InlineData(false)]
+		[InlineData (true)]
+		[InlineData (false)]
 		public void MultiSelectDirectory_CanToggleThenAccept (bool acceptWithEnter)
 		{
-			var dlg = GetDialog();
+			var dlg = GetDialog ();
 			dlg.OpenMode = OpenMode.Directory;
 			dlg.AllowsMultipleSelection = true;
 			IReadOnlyCollection<string> eventMultiSelected = null;
-			dlg.FilesSelected += (s,e)=>
-			{
-				eventMultiSelected  = e.Dialog.MultiSelected;
+			dlg.FilesSelected += (s, e) => {
+				eventMultiSelected = e.Dialog.MultiSelected;
 			};
 
-			Assert.IsType<TextField>(dlg.MostFocused);
+			Assert.IsType<TextField> (dlg.MostFocused);
 			Send ('v', ConsoleKey.DownArrow, false);
-			Assert.IsType<TableView>(dlg.MostFocused);
+			Assert.IsType<TableView> (dlg.MostFocused);
 			// Move selection to subfolder
 			Send ('v', ConsoleKey.DownArrow, false);
 			// Toggle subfolder
 			Send (' ', ConsoleKey.Spacebar, false);
 
-			Assert.True(dlg.Canceled);
+			Assert.True (dlg.Canceled);
 
-			if(acceptWithEnter)
-			{
+			if (acceptWithEnter) {
 				Send ('\n', ConsoleKey.Enter);
+			} else {
+				Send ('o', ConsoleKey.O, false, true);
 			}
-			else
-			{
-				Send ('o', ConsoleKey.O,false,true);
-			}
-			Assert.False(dlg.Canceled);
+			Assert.False (dlg.Canceled);
 
-			Assert.Multiple(
-				()=>{
+			Assert.Multiple (
+				() => {
 					// Only the subfolder should be selected
-					Assert.Equal(1,dlg.MultiSelected.Count);
-					AssertIsTheSubfolder(dlg.Path);
-					AssertIsTheSubfolder(dlg.MultiSelected.Single());
+					Assert.Equal (1, dlg.MultiSelected.Count);
+					AssertIsTheSubfolder (dlg.Path);
+					AssertIsTheSubfolder (dlg.MultiSelected.Single ());
 				},
-				()=>{
+				() => {
 					// Event should also agree with the final state
-					Assert.NotNull(eventMultiSelected);
-					Assert.Equal(1,eventMultiSelected.Count);
-					AssertIsTheSubfolder(eventMultiSelected.Single());
+					Assert.NotNull (eventMultiSelected);
+					Assert.Equal (1, eventMultiSelected.Count);
+					AssertIsTheSubfolder (eventMultiSelected.Single ());
 				}
 			);
 		}
 
 		private void AssertIsTheStartingDirectory (string path)
 		{
-			if(IsWindows())
-			{
-				Assert.Equal (@"c:\demo\",path);
-			}
-			else
-			{
-				Assert.Equal ("/demo/",path);
+			if (IsWindows ()) {
+				Assert.Equal (@"c:\demo\", path);
+			} else {
+				Assert.Equal ("/demo/", path);
 			}
 		}
 
 		private void AssertIsTheRootDirectory (string path)
 		{
-			if(IsWindows())
-			{
-				Assert.Equal (@"c:\",path);
-			}
-			else
-			{
-				Assert.Equal ("/",path);
+			if (IsWindows ()) {
+				Assert.Equal (@"c:\", path);
+			} else {
+				Assert.Equal ("/", path);
 			}
 		}
 
 		private void AssertIsTheSubfolder (string path)
 		{
-			if(IsWindows())
-			{
-				Assert.Equal (@"c:\demo\subfolder",path);
-			}
-			else
-			{
-				Assert.Equal ("/demo/subfolder",path);
+			if (IsWindows ()) {
+				Assert.Equal (@"c:\demo\subfolder", path);
+			} else {
+				Assert.Equal ("/demo/subfolder", path);
 			}
 		}
 
 		[Fact, AutoInitShutdown]
 		public void TestDirectoryContents_Linux ()
 		{
-			if (IsWindows()) {
+			if (IsWindows ()) {
 				return;
 			}
-			var fd = GetLinuxDialog();
+			var fd = GetLinuxDialog ();
 			fd.Title = string.Empty;
 
-			fd.Style.Culture = new CultureInfo("en-US");
+			fd.Style.Culture = new CultureInfo ("en-US");
 
 			fd.Redraw (fd.Bounds);
-			
+
 			string expected =
 			@"
  ┌──────────────────────────────────────────────────────────────────┐
@@ -426,14 +407,14 @@ namespace Terminal.Gui.FileServicesTests {
 		[Fact, AutoInitShutdown]
 		public void TestDirectoryContents_Windows ()
 		{
-			if (!IsWindows()) {
+			if (!IsWindows ()) {
 				return;
 			}
 
-			var fd = GetWindowsDialog();
+			var fd = GetWindowsDialog ();
 			fd.Title = string.Empty;
 
-			fd.Style.Culture = new CultureInfo("en-US");
+			fd.Style.Culture = new CultureInfo ("en-US");
 
 			fd.Redraw (fd.Bounds);
 
@@ -450,7 +431,7 @@ namespace Terminal.Gui.FileServicesTests {
 ││\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      ││
-│                                                                  
+││mybinary.exe│7.00 bytes│2001-01-01T11:44:42           │.exe     │
 │                                                                  │
 │                                                                  │
 │[ ►► ] Enter Search                            [ Cancel ] [ Ok ]  │
@@ -459,6 +440,47 @@ namespace Terminal.Gui.FileServicesTests {
 			TestHelpers.AssertDriverContentsAre (expected, output, true);
 		}
 
+		[Fact, AutoInitShutdown]
+		public void TestDirectoryContents_Windows_Colors ()
+		{
+			if (!IsWindows ()) {
+				return;
+			}
+
+			var fd = GetWindowsDialog ();
+			fd.Title = string.Empty;
+
+			fd.Style.Culture = new CultureInfo ("en-US");
+			fd.Style.UseColors = true;
+
+			var dir = new Attribute (Color.Magenta);
+			fd.Style.ColorSchemeDirectory = GetColorScheme (dir);
+
+			var img = new Attribute (Color.Cyan);
+			fd.Style.ColorSchemeImage = GetColorScheme (img);
+
+			var other = new Attribute (Color.BrightGreen);
+			fd.Style.ColorSchemeOther = GetColorScheme (other);
+
+			var exe = new Attribute (Color.BrightYellow);
+			fd.Style.ColorSchemeExeOrRecommended = GetColorScheme (exe);
+
+			fd.Redraw (fd.Bounds);
+
+			TestHelpers.AssertDriverUsedColors (other,dir,img,exe);
+		}
+
+		private ColorScheme GetColorScheme (Attribute a)
+		{
+			return new ColorScheme {
+				Normal = a,
+				Focus = a,
+				Disabled = a,
+				HotFocus = a,
+				HotNormal = a,
+			};
+		}
+
 		[Theory, AutoInitShutdown]
 		[InlineData (true)]
 		[InlineData (false)]
@@ -518,17 +540,17 @@ namespace Terminal.Gui.FileServicesTests {
 					Assert.Equal (@"/bob/fish", tb.Text);
 				}*/
 
-		private bool IsWindows()
+		private bool IsWindows ()
 		{
 			return System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform (System.Runtime.InteropServices.OSPlatform.Windows);
 		}
 
-		private FileDialog GetDialog()
+		private FileDialog GetDialog ()
 		{
-			return IsWindows() ? GetWindowsDialog() : GetLinuxDialog();			
+			return IsWindows () ? GetWindowsDialog () : GetLinuxDialog ();
 		}
 
-		private FileDialog GetWindowsDialog()
+		private FileDialog GetWindowsDialog ()
 		{
 			// Arrange
 			var fileSystem = new MockFileSystem (new Dictionary<string, MockFileData> (), @"c:\");
@@ -536,6 +558,7 @@ namespace Terminal.Gui.FileServicesTests {
 
 			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\mybinary.exe", 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");
@@ -552,7 +575,7 @@ namespace Terminal.Gui.FileServicesTests {
 			return fd;
 		}
 
-		private FileDialog GetLinuxDialog()
+		private FileDialog GetLinuxDialog ()
 		{
 			// Arrange
 			var fileSystem = new MockFileSystem (new Dictionary<string, MockFileData> (), "/");

+ 37 - 0
UnitTests/TestHelpers.cs

@@ -305,7 +305,44 @@ class TestHelpers {
 			r++;
 		}
 	}
+	/// <summary>
+	/// Verifies the console used all the <paramref name="expectedColors"/> when rendering.
+	/// If one or more of the expected colors are not used then the failure will output both
+	/// the colors that were found to be used and which of your expectations was not met.
+	/// </summary>
+	/// <param name="expectedColors"></param>
+	internal static void AssertDriverUsedColors (params Attribute [] expectedColors)
+	{
+		var driver = ((FakeDriver)Application.Driver);
+
+		var contents = driver.Contents;
+
+		var toFind = expectedColors.ToList ();
+
+		var colorsUsed = new HashSet<int> ();
+
+		for (int r = 0; r < driver.Rows; r++) {
+			for (int c = 0; c < driver.Cols; c++) {
+				int val = contents [r, c, 1];
+				
+				colorsUsed.Add (val);
+
+				var match = toFind.FirstOrDefault (e => e.Value == val);
+
+				// need to check twice because Attribute is a struct and therefore cannot be null
+				if (toFind.Any (e => e.Value == val)) {
+					toFind.Remove (match);
+				}
+			}
+		}
 
+		if(toFind.Any()) {
+			var sb = new StringBuilder ();
+			sb.AppendLine ("The following colors were not used:" + string.Join ("; ", toFind.Select (a => DescribeColor (a))));
+			sb.AppendLine ("Colors used were:" + string.Join ("; ", colorsUsed.Select (DescribeColor)));
+			throw new Exception (sb.ToString());
+		}
+	}
 	private static object DescribeColor (int userExpected)
 	{
 		var a = new Attribute (userExpected);