浏览代码

Fixes #3696 Remove custom navigation in FileDialog (#3712)

* Remove custom navigation in FileDialog and change Ok/Cancel to use AddButton

* Fix most tests

* Update drawing tests to use contents asserts instead

* Update positioning to be less absolute

* TableView to not swallow cursors if no selection change manifests

* Make SetSelection cleverer about how it decides selection changed

* Refactor TableView key handling/Tab to be simpler and faster

* Tests for TableView selection moving out

* Add test for TableView selection change swallow cursor

* Fix formatting

* Fix split container height when buttons have shadows

* Fix alignment of buttons and input boxes
Thomas Nind 11 月之前
父节点
当前提交
5b2e10e6d0

+ 37 - 115
Terminal.Gui/Views/FileDialog.cs

@@ -10,6 +10,9 @@ namespace Terminal.Gui;
 /// </summary>
 public class FileDialog : Dialog
 {
+    private const int alignmentGroupInput = 32;
+    private const int alignmentGroupComplete = 55;
+
     /// <summary>Gets the Path separators for the operating system</summary>
     internal static char [] Separators =
     [
@@ -71,24 +74,20 @@ public class FileDialog : Dialog
 
         _btnOk = new Button
         {
-            Y = Pos.AnchorEnd (1), X = Pos.Func (CalculateOkButtonPosX), IsDefault = true, Text = Style.OkButtonText
+            X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, alignmentGroupComplete),
+            Y = Pos.AnchorEnd (),
+            IsDefault = true, Text = Style.OkButtonText
         };
         _btnOk.Accept += (s, e) => Accept (true);
 
-        _btnOk.KeyDown += (s, k) =>
-                          {
-                              NavigateIf (k, KeyCode.CursorLeft, _btnCancel);
-                              NavigateIf (k, KeyCode.CursorUp, _tableView);
-                          };
 
-        _btnCancel = new Button { Y = Pos.AnchorEnd (1), X = Pos.Right (_btnOk) + 1, Text = Strings.btnCancel };
+        _btnCancel = new Button
+        {
+            X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, alignmentGroupComplete),
+            Y = Pos.AnchorEnd(),
+            Text = Strings.btnCancel
+        };
 
-        _btnCancel.KeyDown += (s, k) =>
-                              {
-                                  NavigateIf (k, KeyCode.CursorLeft, _btnToggleSplitterCollapse);
-                                  NavigateIf (k, KeyCode.CursorUp, _tableView);
-                                  NavigateIf (k, KeyCode.CursorRight, _btnOk);
-                              };
         _btnCancel.Accept += (s, e) =>
         {
             Canceled = true;
@@ -121,7 +120,13 @@ public class FileDialog : Dialog
         _tbPath.Autocomplete = new AppendAutocomplete (_tbPath);
         _tbPath.Autocomplete.SuggestionGenerator = new FilepathSuggestionGenerator ();
 
-        _splitContainer = new TileView { X = 0, Y = 2, Width = Dim.Fill (), Height = Dim.Fill (1) };
+        _splitContainer = new TileView
+        {
+            X = 0,
+            Y = Pos.Bottom (_btnBack),
+            Width = Dim.Fill (),
+            Height = Dim.Fill (Dim.Func (() => IsInitialized ? _btnOk.Frame.Height : 1)),
+        };
 
         Initialized += (s, e) =>
                        {
@@ -129,7 +134,7 @@ public class FileDialog : Dialog
                            _splitContainer.Tiles.ElementAt (0).ContentView.Visible = false;
                        };
 
-        //			this.splitContainer.Border.BorderStyle = BorderStyle.None;
+        // this.splitContainer.Border.BorderStyle = BorderStyle.None;
 
         _tableView = new TableView
         {
@@ -158,28 +163,7 @@ public class FileDialog : Dialog
         ColumnStyle typeStyle = Style.TableStyle.GetOrCreateColumnStyle (3);
         typeStyle.MinWidth = 6;
         typeStyle.ColorGetter = ColorGetter;
-
-        _tableView.KeyDown += (s, k) =>
-                              {
-                                  if (_tableView.SelectedRow <= 0)
-                                  {
-                                      NavigateIf (k, KeyCode.CursorUp, _tbPath);
-                                  }
-
-                                  if (_tableView.SelectedRow == _tableView.Table.Rows - 1)
-                                  {
-                                      NavigateIf (k, KeyCode.CursorDown, _btnToggleSplitterCollapse);
-                                  }
-
-                                  if (_splitContainer.Tiles.First ().ContentView.Visible && _tableView.SelectedColumn == 0)
-                                  {
-                                      NavigateIf (k, KeyCode.CursorLeft, _treeView);
-                                  }
-
-                                  if (k.Handled)
-                                  { }
-                              };
-
+        
         _treeView = new TreeView<IFileSystemInfo> { Width = Dim.Fill (), Height = Dim.Fill () };
 
         var fileDialogTreeBuilder = new FileSystemTreeBuilder ();
@@ -192,7 +176,11 @@ public class FileDialog : Dialog
         _splitContainer.Tiles.ElementAt (0).ContentView.Add (_treeView);
         _splitContainer.Tiles.ElementAt (1).ContentView.Add (_tableView);
 
-        _btnToggleSplitterCollapse = new Button { Y = Pos.AnchorEnd (1), Text = GetToggleSplitterText (false) };
+        _btnToggleSplitterCollapse = new Button
+        {
+            X = Pos.Align (Alignment.Start, AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput),
+            Y = Pos.AnchorEnd (), Text = GetToggleSplitterText (false)
+        };
 
         _btnToggleSplitterCollapse.Accept += (s, e) =>
                                               {
@@ -206,13 +194,13 @@ public class FileDialog : Dialog
 
         _tbFind = new TextField
         {
-            X = Pos.Right (_btnToggleSplitterCollapse) + 1,
+            X = Pos.Align (Alignment.Start,AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput),
             CaptionColor = new Color (Color.Black),
             Width = 30,
-            Y = Pos.AnchorEnd (1),
+            Y = Pos.Top (_btnToggleSplitterCollapse),
             HotKey = Key.F.WithAlt
         };
-        _spinnerView = new SpinnerView { X = Pos.Right (_tbFind) + 1, Y = Pos.AnchorEnd (1), Visible = false };
+        _spinnerView = new SpinnerView { X = Pos.Align (Alignment.Start, AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput), Y = Pos.AnchorEnd (1), Visible = false };
 
         _tbFind.TextChanged += (s, o) => RestartSearch ();
 
@@ -231,16 +219,6 @@ public class FileDialog : Dialog
                                        o.Handled = true;
                                    }
                                }
-
-                               if (_tbFind.CursorIsAtEnd ())
-                               {
-                                   NavigateIf (o, KeyCode.CursorRight, _btnCancel);
-                               }
-
-                               if (_tbFind.CursorIsAtStart ())
-                               {
-                                   NavigateIf (o, KeyCode.CursorLeft, _btnToggleSplitterCollapse);
-                               }
                            };
 
         _tableView.Style.ShowHorizontalHeaderOverline = true;
@@ -262,48 +240,22 @@ public class FileDialog : Dialog
         _tableView.KeyBindings.ReplaceCommands (Key.End, Command.BottomEnd);
         _tableView.KeyBindings.ReplaceCommands (Key.Home.WithShift, Command.TopHomeExtend);
         _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.BottomEndExtend);
-
-        _treeView.KeyDown += (s, k) =>
-                             {
-                                 IFileSystemInfo selected = _treeView.SelectedObject;
-
-                                 if (selected is { })
-                                 {
-                                     if (!_treeView.CanExpand (selected) || _treeView.IsExpanded (selected))
-                                     {
-                                         NavigateIf (k, KeyCode.CursorRight, _tableView);
-                                     }
-                                     else if (_treeView.GetObjectRow (selected) == 0)
-                                     {
-                                         NavigateIf (k, KeyCode.CursorUp, _tbPath);
-                                     }
-                                 }
-
-                                 if (k.Handled)
-                                 {
-                                     return;
-                                 }
-
-                                 k.Handled = TreeView_KeyDown (k);
-                             };
-
+        
         AllowsMultipleSelection = false;
 
         UpdateNavigationVisibility ();
 
-        // BUGBUG: This TabOrder is counter-intuitive. The tab order for a dialog should match the
-        // order the Views' are presented, left to right, top to bottom.
-        // Determines tab order
-        Add (_btnToggleSplitterCollapse);
-        Add (_tbFind);
-        Add (_spinnerView);
-        Add (_btnOk);
-        Add (_btnCancel);
+        Add (_tbPath);
         Add (_btnUp);
         Add (_btnBack);
         Add (_btnForward);
-        Add (_tbPath);
         Add (_splitContainer);
+        Add (_btnToggleSplitterCollapse);
+        Add (_tbFind);
+        Add (_spinnerView);
+
+        Add(_btnOk);
+        Add(_btnCancel);
     }
 
     /// <summary>
@@ -1041,23 +993,6 @@ public class FileDialog : Dialog
         return toReturn;
     }
 
-    private bool NavigateIf (Key keyEvent, KeyCode isKey, View to)
-    {
-        if (keyEvent.KeyCode == isKey)
-        {
-            to.FocusDeepest (NavigationDirection.Forward, null);
-
-            if (to == _tbPath)
-            {
-                _tbPath.MoveEnd ();
-            }
-
-            return true;
-        }
-
-        return false;
-    }
-
     private void New ()
     {
         if (State is { })
@@ -1430,19 +1365,6 @@ public class FileDialog : Dialog
         }
     }
 
-    private bool TreeView_KeyDown (Key keyEvent)
-    {
-        if (_treeView.HasFocus && Separators.Contains ((char)keyEvent))
-        {
-            _tbPath.FocusDeepest (NavigationDirection.Forward, null);
-
-            // let that keystroke go through on the tbPath instead
-            return true;
-        }
-
-        return false;
-    }
-
     private void TreeView_SelectionChanged (object sender, SelectionChangedEventArgs<IFileSystemInfo> e)
     {
         if (e.NewValue is null)

+ 39 - 32
Terminal.Gui/Views/TableView/TableView.cs

@@ -54,47 +54,19 @@ public class TableView : View
         // Things this view knows how to do
         AddCommand (
                     Command.Right,
-                    () =>
-                    {
-                        // BUGBUG: SHould return false if selectokn doesn't change (to support nav to next view)
-                        ChangeSelectionByOffset (1, 0, false);
-
-                        return true;
-                    }
-                   );
+                    () => ChangeSelectionByOffsetWithReturn (1, 0));
 
         AddCommand (
                     Command.Left,
-                    () =>
-                    {
-                        // BUGBUG: SHould return false if selectokn doesn't change (to support nav to next view)
-                        ChangeSelectionByOffset (-1, 0, false);
-
-                        return true;
-                    }
-                   );
+                    () => ChangeSelectionByOffsetWithReturn (-1, 0));
 
         AddCommand (
                     Command.LineUp,
-                    () =>
-                    {
-                        // BUGBUG: SHould return false if selectokn doesn't change (to support nav to next view)
-                        ChangeSelectionByOffset (0, -1, false);
-
-                        return true;
-                    }
-                   );
+                    () => ChangeSelectionByOffsetWithReturn (0, -1));
 
         AddCommand (
                     Command.LineDown,
-                    () =>
-                    {
-                        // BUGBUG: SHould return false if selectokn doesn't change (to support nav to next view)
-                        ChangeSelectionByOffset (0, 1, false);
-
-                        return true;
-                    }
-                   );
+                    () => ChangeSelectionByOffsetWithReturn (0, 1));
 
         AddCommand (
                     Command.PageUp,
@@ -519,6 +491,41 @@ public class TableView : View
         return new Point (colHit.X, tableRow + headerHeight - RowOffset);
     }
 
+    /// <summary>
+    /// Private override of <see cref="ChangeSelectionByOffset"/> that returns true if the selection has
+    /// changed as a result of moving the selection. Used by key handling logic to determine whether e.g.
+    /// the cursor right resulted in a change or should be forwarded on to toggle logic handling.
+    /// </summary>
+    /// <param name="offsetX"></param>
+    /// <param name="offsetY"></param>
+    /// <returns></returns>
+    private bool ChangeSelectionByOffsetWithReturn (int offsetX, int offsetY)
+    {
+        var oldSelection = GetSelectionSnapshot ();
+        SetSelection (SelectedColumn + offsetX, SelectedRow + offsetY, false);
+        Update ();
+
+        return !SelectionIsSame (oldSelection);
+    }
+
+    private TableViewSelectionSnapshot GetSelectionSnapshot ()
+    {
+        return new (
+                    SelectedColumn,
+                    SelectedRow,
+                    MultiSelectedRegions.Select (s => s.Rectangle).ToArray ());
+    }
+
+    private bool SelectionIsSame (TableViewSelectionSnapshot oldSelection)
+    {
+        var newSelection = GetSelectionSnapshot ();
+
+        return oldSelection.SelectedColumn == newSelection.SelectedColumn
+               && oldSelection.SelectedRow == newSelection.SelectedRow
+               && oldSelection.multiSelection.SequenceEqual (newSelection.multiSelection);
+    }
+    private record TableViewSelectionSnapshot (int SelectedColumn, int SelectedRow, Rectangle [] multiSelection);
+
     /// <summary>
     ///     Moves the <see cref="SelectedRow"/> and <see cref="SelectedColumn"/> by the provided offsets. Optionally
     ///     starting a box selection (see <see cref="MultiSelect"/>)

+ 160 - 90
UnitTests/FileServices/FileDialogTests.cs

@@ -99,12 +99,13 @@ public class FileDialogTests (ITestOutputHelper output)
         string openIn = Path.Combine (Environment.CurrentDirectory, "zz");
         Directory.CreateDirectory (openIn);
         dlg.Path = openIn + Path.DirectorySeparatorChar;
-        Application.OnKeyDown (Key.Tab);
-        Application.OnKeyDown (Key.Tab);
-        Application.OnKeyDown (Key.Tab);
+
+        var tf = GetTextField (dlg, FileDialogPart.SearchField);
+        tf.SetFocus ();
 
         Assert.IsType<TextField> (dlg.MostFocused);
-        var tf = (TextField)dlg.MostFocused;
+        Assert.Same (tf, dlg.MostFocused);
+
         Assert.Equal ("Enter Search", tf.Caption);
 
         // Dialog has not yet been confirmed with a choice
@@ -140,6 +141,10 @@ public class FileDialogTests (ITestOutputHelper output)
 
         Assert.IsType<TextField> (dlg.MostFocused);
         Send ('v', ConsoleKey.DownArrow);
+
+        var tv = GetTableView(dlg);
+        tv.SetFocus ();
+
         Assert.IsType<TableView> (dlg.MostFocused);
 
         // ".." should be the first thing selected
@@ -177,8 +182,10 @@ public class FileDialogTests (ITestOutputHelper output)
         IReadOnlyCollection<string> eventMultiSelected = null;
         dlg.FilesSelected += (s, e) => { eventMultiSelected = e.Dialog.MultiSelected; };
 
-        Assert.IsType<TextField> (dlg.MostFocused);
-        Send ('v', ConsoleKey.DownArrow);
+
+        var tv = GetTableView (dlg);
+        tv.SetFocus ();
+
         Assert.IsType<TableView> (dlg.MostFocused);
 
         // Try to toggle '..'
@@ -232,8 +239,9 @@ public class FileDialogTests (ITestOutputHelper output)
         IReadOnlyCollection<string> eventMultiSelected = null;
         dlg.FilesSelected += (s, e) => { eventMultiSelected = e.Dialog.MultiSelected; };
 
-        Assert.IsType<TextField> (dlg.MostFocused);
-        Send ('v', ConsoleKey.DownArrow);
+        var tv = GetTableView (dlg);
+        tv.SetFocus ();
+
         Assert.IsType<TableView> (dlg.MostFocused);
 
         // Move selection to subfolder
@@ -284,8 +292,9 @@ public class FileDialogTests (ITestOutputHelper output)
         IReadOnlyCollection<string> eventMultiSelected = null;
         dlg.FilesSelected += (s, e) => { eventMultiSelected = e.Dialog.MultiSelected; };
 
-        Assert.IsType<TextField> (dlg.MostFocused);
-        Send ('v', ConsoleKey.DownArrow);
+        var tv = GetTableView (dlg);
+        tv.SetFocus ();
+
         Assert.IsType<TableView> (dlg.MostFocused);
 
         // Move selection to subfolder
@@ -327,8 +336,9 @@ public class FileDialogTests (ITestOutputHelper output)
         dlg.OpenMode = openModeMixed ? OpenMode.Mixed : OpenMode.Directory;
         dlg.AllowsMultipleSelection = multiple;
 
-        Assert.IsType<TextField> (dlg.MostFocused);
-        Send ('v', ConsoleKey.DownArrow);
+        var tv = GetTableView (dlg);
+        tv.SetFocus ();
+
         Assert.IsType<TableView> (dlg.MostFocused);
 
         // Should be selecting ..
@@ -421,45 +431,60 @@ public class FileDialogTests (ITestOutputHelper output)
 
         fd.Draw ();
 
-        var expected =
-            @$"
-┌─────────────────────────────────────────────────────────────────────────┐
-│/demo/                                                                   │
-│{
-    CM.Glyphs.LeftBracket
-}▲{
-    CM.Glyphs.RightBracket
-}                                                                      │
-│┌────────────┬──────────┬──────────────────────────────┬────────────────┐│
-││Filename (▲)│Size      │Modified                      │Type            ││
-│├────────────┼──────────┼──────────────────────────────┼────────────────┤│
-││..          │          │                              │<Directory>     ││
-││/subfolder  │          │2002-01-01T22:42:10           │<Directory>     ││
-││image.gif   │4.00 B    │2002-01-01T22:42:10           │.gif            ││
-││jQuery.js   │7.00 B    │2001-01-01T11:44:42           │.js             ││
-│                                                                         │
-│                                                                         │
-│                                                                         │
-│{
-    CM.Glyphs.LeftBracket
-} ►► {
-    CM.Glyphs.RightBracket
-} Enter Search                                 {
-    CM.Glyphs.LeftBracket
-}{
-    CM.Glyphs.LeftDefaultIndicator
-} OK {
-    CM.Glyphs.RightDefaultIndicator
-}{
-    CM.Glyphs.RightBracket
-} {
-    CM.Glyphs.LeftBracket
-} Cancel {
-    CM.Glyphs.RightBracket
-}  │
-└─────────────────────────────────────────────────────────────────────────┘
-";
-        TestHelpers.AssertDriverContentsAre (expected, output, ignoreLeadingWhitespace: true);
+        /*
+         *
+         *
+           ┌─────────────────────────────────────────────────────────────────────────┐
+           │/demo/                                                                   │
+           │⟦▲⟧                                                                      │
+           │┌────────────┬──────────┬──────────────────────────────┬────────────────┐│
+           ││Filename (▲)│Size      │Modified                      │Type            ││
+           │├────────────┼──────────┼──────────────────────────────┼────────────────┤│
+           ││..          │          │                              │<Directory>     ││
+           ││/subfolder  │          │2002-01-01T22:42:10           │<Directory>     ││
+           ││image.gif   │4.00 B    │2002-01-01T22:42:10           │.gif            ││
+           ││jQuery.js   │7.00 B    │2001-01-01T11:44:42           │.js             ││
+           │                                                                         │
+           │                                                                         │
+           │                                                                         │
+           │⟦ ►► ⟧ Enter Search                                 ⟦► OK ◄⟧ ⟦ Cancel ⟧  │
+           └─────────────────────────────────────────────────────────────────────────┘
+
+         *
+         */
+
+        var path = GetTextField (fd, FileDialogPart.Path);
+        Assert.Equal ("/demo/", path.Text);
+
+        var tv = GetTableView (fd);
+
+        // Asserting the headers
+        Assert.Equal ("Filename (▲)", tv.Table.ColumnNames.ElementAt (0));
+        Assert.Equal ("Size", tv.Table.ColumnNames.ElementAt (1));
+        Assert.Equal ("Modified", tv.Table.ColumnNames.ElementAt (2));
+        Assert.Equal ("Type", tv.Table.ColumnNames.ElementAt (3));
+
+        // Asserting the table contents
+        Assert.Equal ("..", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [0, 0]));
+        Assert.Equal ("/subfolder", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [1, 0]));
+        Assert.Equal ("image.gif", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [2, 0]));
+        Assert.Equal ("jQuery.js", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [3, 0]));
+
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [0, 1]));
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [1, 1]));
+        Assert.Equal ("4.00 B", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [2, 1]));
+        Assert.Equal ("7.00 B", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [3, 1]));
+
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [0, 2]));
+        Assert.Equal ("2002-01-01T22:42:10", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [1, 2]));
+        Assert.Equal ("2002-01-01T22:42:10", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [2, 2]));
+        Assert.Equal ("2001-01-01T11:44:42", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [3, 2]));
+
+        Assert.Equal ("<Directory>", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [0, 3]));
+        Assert.Equal ("<Directory>", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [1, 3]));
+        Assert.Equal (".gif", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [2, 3]));
+        Assert.Equal (".js", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [3, 3]));
+
         fd.Dispose ();
     }
 
@@ -479,45 +504,64 @@ public class FileDialogTests (ITestOutputHelper output)
 
         fd.Draw ();
 
-        var expected =
-            @$"
-┌─────────────────────────────────────────────────────────────────────────┐
-│c:\demo\                                                                 │
-│{
-    CM.Glyphs.LeftBracket
-}▲{
-    CM.Glyphs.RightBracket
-}                                                                      │
-│┌────────────┬──────────┬──────────────────────────────┬────────────────┐│
-││Filename (▲)│Size      │Modified                      │Type            ││
-│├────────────┼──────────┼──────────────────────────────┼────────────────┤│
-││..          │          │                              │<Directory>     ││
-││\subfolder  │          │2002-01-01T22:42:10           │<Directory>     ││
-││image.gif   │4.00 B    │2002-01-01T22:42:10           │.gif            ││
-││jQuery.js   │7.00 B    │2001-01-01T11:44:42           │.js             ││
-││mybinary.exe│7.00 B    │2001-01-01T11:44:42           │.exe            ││
-│                                                                         │
-│                                                                         │
-│{
-    CM.Glyphs.LeftBracket
-} ►► {
-    CM.Glyphs.RightBracket
-} Enter Search                                 {
-    CM.Glyphs.LeftBracket
-}{
-    CM.Glyphs.LeftDefaultIndicator
-} OK {
-    CM.Glyphs.RightDefaultIndicator
-}{
-    CM.Glyphs.RightBracket
-} {
-    CM.Glyphs.LeftBracket
-} Cancel {
-    CM.Glyphs.RightBracket
-}  │
-└─────────────────────────────────────────────────────────────────────────┘
-";
-        TestHelpers.AssertDriverContentsAre (expected, output, ignoreLeadingWhitespace: true);
+        /*
+         *
+         *
+           ┌─────────────────────────────────────────────────────────────────────────┐
+           │c:\demo\                                                                 │
+           │⟦▲⟧                                                                      │
+           │┌────────────┬──────────┬──────────────────────────────┬────────────────┐│
+           ││Filename (▲)│Size      │Modified                      │Type            ││
+           │├────────────┼──────────┼──────────────────────────────┼────────────────┤│
+           ││..          │          │                              │<Directory>     ││
+           ││\subfolder  │          │2002-01-01T22:42:10           │<Directory>     ││
+           ││image.gif   │4.00 B    │2002-01-01T22:42:10           │.gif            ││
+           ││jQuery.js   │7.00 B    │2001-01-01T11:44:42           │.js             ││
+           ││mybinary.exe│7.00 B    │2001-01-01T11:44:42           │.exe            ││
+           │                                                                         │
+           │                                                                         │
+           │⟦ ►► ⟧ Enter Search                                 ⟦► OK ◄⟧ ⟦ Cancel ⟧  │
+           └─────────────────────────────────────────────────────────────────────────┘
+           
+         *
+         */
+
+        var path = GetTextField (fd, FileDialogPart.Path);
+        Assert.Equal ("c:\\demo\\",path.Text);
+
+        var tv = GetTableView (fd);
+
+        // Asserting the headers
+        Assert.Equal ("Filename (▲)", tv.Table.ColumnNames.ElementAt (0));
+        Assert.Equal ("Size", tv.Table.ColumnNames.ElementAt (1));
+        Assert.Equal ("Modified", tv.Table.ColumnNames.ElementAt (2));
+        Assert.Equal ("Type", tv.Table.ColumnNames.ElementAt (3));
+
+        // Asserting the table contents
+        Assert.Equal ("..", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [0, 0]));
+        Assert.Equal (@"\subfolder", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [1, 0]));
+        Assert.Equal ("image.gif", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [2, 0]));
+        Assert.Equal ("jQuery.js", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [3, 0]));
+        Assert.Equal ("mybinary.exe", tv.Style.GetOrCreateColumnStyle (0).GetRepresentation (tv.Table [4, 0]));
+
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [0, 1]));
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [1, 1]));
+        Assert.Equal ("4.00 B", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [2, 1]));
+        Assert.Equal ("7.00 B", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [3, 1]));
+        Assert.Equal ("7.00 B", tv.Style.GetOrCreateColumnStyle (1).GetRepresentation (tv.Table [4, 1]));
+
+        Assert.Equal ("", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [0, 2]));
+        Assert.Equal ("2002-01-01T22:42:10", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [1, 2]));
+        Assert.Equal ("2002-01-01T22:42:10", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [2, 2]));
+        Assert.Equal ("2001-01-01T11:44:42", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [3, 2]));
+        Assert.Equal ("2001-01-01T11:44:42", tv.Style.GetOrCreateColumnStyle (2).GetRepresentation (tv.Table [4, 2]));
+
+        Assert.Equal ("<Directory>", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [0, 3]));
+        Assert.Equal ("<Directory>", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [1, 3]));
+        Assert.Equal (".gif", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [2, 3]));
+        Assert.Equal (".js", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [3, 3]));
+        Assert.Equal (".exe", tv.Style.GetOrCreateColumnStyle (3).GetRepresentation (tv.Table [4, 3]));
+
         fd.Dispose ();
     }
 
@@ -734,4 +778,30 @@ public class FileDialogTests (ITestOutputHelper output)
             Send ('\\', ConsoleKey.Separator);
         }
     }
+
+    private TextField GetTextField (FileDialog dlg, FileDialogPart part)
+    {
+        switch (part)
+        {
+            case FileDialogPart.Path:
+                return dlg.Subviews.OfType<TextField> ().ElementAt (0);
+            case FileDialogPart.SearchField:
+                return dlg.Subviews.OfType<TextField> ().ElementAt (1);
+                break;
+            default:
+                throw new ArgumentOutOfRangeException (nameof (part), part, null);
+        }
+    }
+
+    private TableView GetTableView (FileDialog dlg)
+    {
+        var tile = dlg.Subviews.OfType<TileView> ().Single ();
+        return (TableView)tile.Tiles.ElementAt (1).ContentView.Subviews.ElementAt(0);
+    }
+
+    private enum FileDialogPart
+    {
+        Path,
+        SearchField,
+    }
 }

+ 172 - 3
UnitTests/Views/TableViewTests.cs

@@ -1068,7 +1068,7 @@ public class TableViewTests (ITestOutputHelper output)
         Application.Begin (top);
 
         tv.HasFocus = focused;
-        Assert.Equal(focused, tv.HasFocus);
+        Assert.Equal (focused, tv.HasFocus);
 
         tv.Draw ();
 
@@ -1155,7 +1155,7 @@ public class TableViewTests (ITestOutputHelper output)
 
         // when B is 2 use the custom highlight color for the row
         tv.Style.RowColorGetter += e => Convert.ToInt32 (e.Table [e.RowIndex, 1]) == 2 ? rowHighlight : null;
-        
+
         var top = new Toplevel ();
         top.Add (tv);
         Application.Begin (top);
@@ -3169,7 +3169,7 @@ A B C
     }
 
     [Fact]
-    public void TestDataColumnCaption()
+    public void TestDataColumnCaption ()
     {
         var tableView = new TableView ();
 
@@ -3191,6 +3191,175 @@ A B C
         Assert.Equal ("Column Name 2", cn [1]);
     }
 
+
+    [Fact]
+    public void CanTabOutOfTableViewUsingCursor_Left ()
+    {
+        GetTableViewWithSiblings (out var tf1, out var tableView, out var tf2);
+
+        // Make the selected cell one in
+        tableView.SelectedColumn = 1;
+
+        // Pressing left should move us to the first column without changing focus
+        Application.OnKeyDown (Key.CursorLeft);
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+        // Because we are now on the leftmost cell a further left press should move focus
+        Application.OnKeyDown (Key.CursorLeft);
+
+        Assert.NotSame (tableView, Application.Current.MostFocused);
+        Assert.False (tableView.HasFocus);
+
+        Assert.Same (tf1, Application.Current.MostFocused);
+        Assert.True (tf1.HasFocus);
+
+        Application.Current.Dispose ();
+    }
+
+    [Fact]
+    public void CanTabOutOfTableViewUsingCursor_Up ()
+    {
+        GetTableViewWithSiblings (out var tf1, out var tableView, out var tf2);
+
+        // Make the selected cell one in
+        tableView.SelectedRow = 1;
+
+        // First press should move us up
+        Application.OnKeyDown (Key.CursorUp);
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+        // Because we are now on the top row a further press should move focus
+        Application.OnKeyDown (Key.CursorUp);
+
+        Assert.NotSame (tableView, Application.Current.MostFocused);
+        Assert.False (tableView.HasFocus);
+
+        Assert.Same (tf1, Application.Current.MostFocused);
+        Assert.True (tf1.HasFocus);
+
+        Application.Current.Dispose ();
+    }
+    [Fact]
+    public void CanTabOutOfTableViewUsingCursor_Right ()
+    {
+        GetTableViewWithSiblings (out var tf1, out var tableView, out var tf2);
+
+        // Make the selected cell one in from the rightmost column
+        tableView.SelectedColumn = tableView.Table.Columns - 2;
+
+        // First press should move us to the rightmost column without changing focus
+        Application.OnKeyDown (Key.CursorRight);
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+        // Because we are now on the rightmost cell, a further right press should move focus
+        Application.OnKeyDown (Key.CursorRight);
+
+        Assert.NotSame (tableView, Application.Current.MostFocused);
+        Assert.False (tableView.HasFocus);
+
+        Assert.Same (tf2, Application.Current.MostFocused);
+        Assert.True (tf2.HasFocus);
+
+        Application.Current.Dispose ();
+    }
+
+    [Fact]
+    public void CanTabOutOfTableViewUsingCursor_Down ()
+    {
+        GetTableViewWithSiblings (out var tf1, out var tableView, out var tf2);
+
+        // Make the selected cell one in from the bottommost row
+        tableView.SelectedRow = tableView.Table.Rows - 2;
+
+        // First press should move us to the bottommost row without changing focus
+        Application.OnKeyDown (Key.CursorDown);
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+        // Because we are now on the bottommost cell, a further down press should move focus
+        Application.OnKeyDown (Key.CursorDown);
+
+        Assert.NotSame (tableView, Application.Current.MostFocused);
+        Assert.False (tableView.HasFocus);
+
+        Assert.Same (tf2, Application.Current.MostFocused);
+        Assert.True (tf2.HasFocus);
+
+        Application.Current.Dispose ();
+    }
+
+
+    [Fact]
+    public void CanTabOutOfTableViewUsingCursor_Left_ClearsSelectionFirst ()
+    {
+        GetTableViewWithSiblings (out var tf1, out var tableView, out var tf2);
+
+        // Make the selected cell one in
+        tableView.SelectedColumn = 1;
+
+        // Pressing shift-left should give us a multi selection
+        Application.OnKeyDown (Key.CursorLeft.WithShift);
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+        Assert.Equal (2, tableView.GetAllSelectedCells ().Count ());
+
+        // Because we are now on the leftmost cell a further left press would normally move focus
+        // However there is an ongoing selection so instead the operation clears the selection and
+        // gets swallowed (not resulting in a focus change)
+        Application.OnKeyDown (Key.CursorLeft);
+
+        // Selection 'clears' just to the single cell and we remain focused
+        Assert.Single (tableView.GetAllSelectedCells ());
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+        // A further left will switch focus
+        Application.OnKeyDown (Key.CursorLeft);
+
+        Assert.NotSame (tableView, Application.Current.MostFocused);
+        Assert.False (tableView.HasFocus);
+
+        Assert.Same (tf1, Application.Current.MostFocused);
+        Assert.True (tf1.HasFocus);
+
+        Application.Current.Dispose ();
+    }
+
+    /// <summary>
+    /// Creates 3 views on <see cref="Application.Current"/> with the focus in the
+    /// <see cref="TableView"/>.  This is a helper method to setup tests that want to
+    /// explore moving input focus out of a tableview.
+    /// </summary>
+    /// <param name="tv"></param>
+    /// <param name="tf1"></param>
+    /// <param name="tf2"></param>
+    private void GetTableViewWithSiblings (out TextField tf1, out TableView tableView, out TextField tf2)
+    {
+        tableView = new TableView ();
+        tableView.BeginInit ();
+        tableView.EndInit ();
+
+        Application.Navigation = new ();
+        Application.Current = new ();
+        tf1 = new TextField ();
+        tf2 = new TextField ();
+        Application.Current.Add (tf1);
+        Application.Current.Add (tableView);
+        Application.Current.Add (tf2);
+
+        tableView.SetFocus ();
+
+        Assert.Same (tableView, Application.Current.MostFocused);
+        Assert.True (tableView.HasFocus);
+
+
+        // Set big table
+        tableView.Table = BuildTable (25, 50);
+    }
+
     private TableView GetABCDEFTableView (out DataTable dt)
     {
         var tableView = new TableView ();