diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 688472fc..a672d7e2 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -208,6 +208,16 @@ public partial class TableView /// public static readonly DependencyProperty UseRightClickForColumnFilterProperty = DependencyProperty.Register(nameof(UseRightClickForColumnFilter), typeof(bool), typeof(TableView), new PropertyMetadata(false)); + /// + /// Identifies the EnterKeyNavigation dependency property. + /// + public static readonly DependencyProperty EnterKeyNavigationProperty = DependencyProperty.Register(nameof(EnterKeyNavigation), typeof(TableViewEnterKeyNavigation), typeof(TableView), new PropertyMetadata(TableViewEnterKeyNavigation.Down)); + + /// + /// Identifies the ContinueEditingOnNavigation dependency property. + /// + public static readonly DependencyProperty ContinueEditingOnNavigationProperty = DependencyProperty.Register(nameof(ContinueEditingOnNavigation), typeof(bool), typeof(TableView), new PropertyMetadata(true)); + /// /// Identifies the VerticalOffset dependency property. /// @@ -356,6 +366,27 @@ public bool UseRightClickForColumnFilter set => SetValue(UseRightClickForColumnFilterProperty, value); } + /// + /// Gets or sets where the Enter key moves the current cell. The default, , + /// moves to the cell below; moves to the next cell to the right. + /// + public TableViewEnterKeyNavigation EnterKeyNavigation + { + get => (TableViewEnterKeyNavigation)GetValue(EnterKeyNavigationProperty); + set => SetValue(EnterKeyNavigationProperty, value); + } + + /// + /// Gets or sets a value indicating whether Tab or Enter pressed while editing a cell begins editing the + /// cell it moves to. The default is . When , the edit is + /// committed and the next cell is only made current and selected, as in a spreadsheet. + /// + public bool ContinueEditingOnNavigation + { + get => (bool)GetValue(ContinueEditingOnNavigationProperty); + set => SetValue(ContinueEditingOnNavigationProperty, value); + } + /// /// Gets the collection view associated with the TableView. /// diff --git a/src/TableView.cs b/src/TableView.cs index e7c9ceb4..0220f2df 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -50,6 +50,7 @@ public partial class TableView : ListView private TableViewCellSlotRange? _lastDragSelectionCellRange; private ItemIndexRange? _lastDragSelectionRowRange; private bool _cellStateDispatchPending; + private (TableViewCellSlot Slot, TaskCompletionSource Completion)? _pendingEdit; private readonly HashSet _pendingCellStateRows = []; private TableViewColumn? _resizingColumn; private double _resizingOriginalWidth; @@ -946,56 +947,13 @@ private void HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool ctrlKey // Handle navigation keys else if (e.Key is VirtualKey.Tab or VirtualKey.Enter) { - var isEditing = IsEditing; - - var newSlot = CurrentCellSlot ?? new(); - - do - { - newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter); - - } while (isEditing && Columns[newSlot.Column].IsReadOnly); - - if (isEditing && currentCell is not null) - { - if (!EndCellEditing(TableViewEditAction.Commit, currentCell)) return; - - if (CurrentCellSlot == newSlot || GetCellFromSlot(newSlot) is not { } nextCell || !nextCell.BeginCellEditing(e)) - { - SetIsEditing(false); - } - } - - MakeSelection(newSlot, false); - - e.Handled = true; + // A cancelled commit leaves the key unhandled, as before the move was factored out. + e.Handled = HandleTabOrEnter(e.Key, shiftKey, currentCell, e); } else if ((e.Key is VirtualKey.Left or VirtualKey.Right or VirtualKey.Up or VirtualKey.Down) && !IsEditing) { - var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1; - var column = CurrentCellSlot?.Column ?? -1; - - if (row == -1 && column == -1) - { - row = column = 0; - } - else if (e.Key is VirtualKey.Left or VirtualKey.Right) - { - column = e.Key is VirtualKey.Left ? ctrlKey ? 0 : column - 1 : ctrlKey ? Columns.VisibleColumns.Count - 1 : column + 1; - if (column >= Columns.VisibleColumns.Count) - { - column = 0; - row++; - } - } - else - { - row = e.Key == VirtualKey.Up ? ctrlKey ? 0 : row - 1 : ctrlKey ? Items.Count - 1 : row + 1; - } - - var newSlot = new TableViewCellSlot(row, column); - MakeSelection(newSlot, shiftKey); + MakeSelection(GetArrowSlot(e.Key, ctrlKey), shiftKey); e.Handled = true; } else if (e.Key is VirtualKey.Home or VirtualKey.End) @@ -1034,6 +992,184 @@ private int CalculateAvailablePageSize() return (int)Math.Floor(availableHeight / rowHeight); } + /// + /// Moves the current cell as Tab or Enter does. While editing, the edit is committed first; the cell + /// moved to is then edited too unless is . + /// + /// if committing the edit was cancelled, so nothing moved. + internal bool HandleTabOrEnter(VirtualKey key, bool shiftKey, TableViewCell? currentCell, RoutedEventArgs editingArgs) + { + var isEditing = IsEditing; + + var newSlot = CurrentCellSlot ?? new(); + + do + { + newSlot = GetNextSlot(newSlot, shiftKey, key is VirtualKey.Enter && EnterKeyNavigation is TableViewEnterKeyNavigation.Down); + + } while (isEditing && Columns[newSlot.Column].IsReadOnly); + + if (isEditing && currentCell is not null) + { + if (!EndCellEditing(TableViewEditAction.Commit, currentCell)) return false; + + if (!ContinueEditingOnNavigation || CurrentCellSlot == newSlot || GetCellFromSlot(newSlot) is not { } nextCell || !nextCell.BeginCellEditing(editingArgs)) + { + SetIsEditing(false); + } + } + + MakeSelection(newSlot, false); + return true; + } + + /// + /// Commits the edit in progress, if any, and ends the edit session, leaving the edited cell current. + /// + /// + /// This is what Tab or Enter do to the cell being left, without moving. is + /// raised, and a handler that cancels it keeps the edit open. + /// + /// if no edit is in progress any more; if the + /// commit was cancelled. + public bool CommitEdit() + { + if (!IsEditing) + { + return true; + } + + if (CurrentCellSlot is { } slot && GetCellFromSlot(slot) is { } cell) + { + // Move focus to the cell BEFORE the editing element is torn down, as the Escape path does. + cell.Focus(FocusState.Programmatic); + + if (!EndCellEditing(TableViewEditAction.Commit, cell)) + { + return false; + } + } + + SetIsEditing(false); + return true; + } + + /// + /// Moves the current cell as the given navigation key would, when no edit is in progress. + /// + /// + /// Supports the arrow keys, Tab and Enter (honouring ). A caller that + /// handles a key inside a cell editor - for example an arrow key that should commit the edit and move, + /// as in a spreadsheet entry mode - calls and then this. + /// + /// if the key was a supported navigation key and no edit is in progress. + public bool NavigateFromCurrentCell(VirtualKey key, bool shiftKey = false, bool ctrlKey = false) + { + if (IsEditing) + { + return false; + } + + if (key is VirtualKey.Left or VirtualKey.Right or VirtualKey.Up or VirtualKey.Down) + { + MakeSelection(GetArrowSlot(key, ctrlKey), shiftKey); + return true; + } + + if (key is VirtualKey.Tab or VirtualKey.Enter) + { + MakeSelection(GetNextSlot(CurrentCellSlot ?? new(), shiftKey, key is VirtualKey.Enter && EnterKeyNavigation is TableViewEnterKeyNavigation.Down), false); + return true; + } + + return false; + } + + /// + /// Makes the cell at the current cell and begins editing it, as pressing F2 on + /// that cell does. + /// + /// + /// This is for a cell that exposes an affordance of its own while it is not being edited - a + /// drop-down arrow, a picker button - and needs a single click on that affordance to open the editor. + /// The control starts an edit only from a double tap and from F2, and a single click is not a gesture it + /// can infer. + /// The sequence matches the existing gestures: + /// + /// If another cell is being edited, that edit is committed first, as tapping or tabbing to a + /// different cell does. If committing is cancelled, no edit begins. + /// The cell becomes the and is selected, as keyboard navigation to + /// it does, and is scrolled into view and focused. + /// The edit then begins. is raised, and a handler that cancels it is + /// honoured. + /// + /// No edit begins for a read-only table, column or cell, for a column that draws itself through + /// (a double tap does not begin one either), or when the + /// cell is already being edited. + /// + /// The cell to edit. + /// A task whose result is if the cell entered edit mode; otherwise + /// . + public async Task BeginEditAsync(TableViewCellSlot slot) + { + if (IsReadOnly || !slot.IsValid(this)) + { + return false; + } + + if (IsEditing) + { + if (CurrentCellSlot == slot) + { + return false; + } + + if (CurrentCellSlot is { } currentSlot + && GetCellFromSlot(currentSlot) is { } currentCell + && !EndCellEditing(TableViewEditAction.Commit, currentCell)) + { + return false; + } + + SetIsEditing(false); + } + + // A later request supersedes an earlier one that has not started yet. + _pendingEdit?.Completion.TrySetResult(false); + _pendingEdit = null; + + if (CurrentCellSlot == slot) + { + // Already current, so OnCurrentCellChanged will not run. A realized cell is edited directly, + // as F2 does, so the call completes synchronously; only a cell scrolled out of view waits. + return TryBeginEdit(GetCellFromSlot(slot) ?? await ScrollCellIntoView(slot)); + } + + var pending = new TaskCompletionSource(); + _pendingEdit = (slot, pending); + + MakeSelection(slot, false); + + if (CurrentCellSlot != slot) + { + // Selection declined to make the cell current, so OnCurrentCellChanged will not start the edit. + _pendingEdit = null; + return false; + } + + return await pending.Task; + } + + /// + /// Begins editing if the same conditions a double tap applies allow it. + /// + private bool TryBeginEdit(TableViewCell? cell) + { + return cell is { IsReadOnly: false, Column.UseSingleElement: false } + && !IsEditing + && cell.BeginCellEditing(new RoutedEventArgs()); + } + /// /// Ends the editing of a cell, committing or canceling the edit based on the specified action. /// @@ -1256,6 +1392,35 @@ private void OnScrollContentPresenterPointerWheelChanged(object sender, PointerR } } + /// + /// Gets the slot an arrow key moves the current cell to. + /// + private TableViewCellSlot GetArrowSlot(VirtualKey key, bool ctrlKey) + { + var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1; + var column = CurrentCellSlot?.Column ?? -1; + + if (row == -1 && column == -1) + { + row = column = 0; + } + else if (key is VirtualKey.Left or VirtualKey.Right) + { + column = key is VirtualKey.Left ? ctrlKey ? 0 : column - 1 : ctrlKey ? Columns.VisibleColumns.Count - 1 : column + 1; + if (column >= Columns.VisibleColumns.Count) + { + column = 0; + row++; + } + } + else + { + row = key == VirtualKey.Up ? ctrlKey ? 0 : row - 1 : ctrlKey ? Items.Count - 1 : row + 1; + } + + return new TableViewCellSlot(row, column); + } + /// /// Gets the next cell slot based on the current slot and input keys. /// @@ -2181,6 +2346,15 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel var cell = await ScrollCellIntoView(newSlot.Value); cell?.ApplyCurrentCellState(); cell?.Focus(FocusState.Programmatic); + + // A BeginEditAsync call that moved the current cell starts its edit here, once the cell has + // been scrolled into view and focused, so that the editing element is the last thing to take + // focus rather than racing the Focus call above. + if (_pendingEdit is { } pending && pending.Slot == newSlot.Value) + { + _pendingEdit = null; + pending.Completion.TrySetResult(CurrentCellSlot == newSlot && TryBeginEdit(cell)); + } } } diff --git a/src/TableViewEnterKeyNavigation.cs b/src/TableViewEnterKeyNavigation.cs new file mode 100644 index 00000000..1681c9cd --- /dev/null +++ b/src/TableViewEnterKeyNavigation.cs @@ -0,0 +1,18 @@ +namespace WinUI.TableView; + +/// +/// Specifies where the Enter key moves the current cell. +/// +public enum TableViewEnterKeyNavigation +{ + /// + /// Enter moves to the cell below; Shift+Enter to the cell above. This is the default. + /// + Down, + + /// + /// Enter moves to the next cell to the right, wrapping to the first cell of the next row; + /// Shift+Enter moves left. This is how the gINT data-entry grid behaves. + /// + Right +} diff --git a/tests/TableViewBeginEditTests.cs b/tests/TableViewBeginEditTests.cs new file mode 100644 index 00000000..fa4cb488 --- /dev/null +++ b/tests/TableViewBeginEditTests.cs @@ -0,0 +1,162 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Threading.Tasks; + +namespace WinUI.TableView.Tests; + +[TestClass] +public class TableViewBeginEditTests +{ + [UITestMethod] + public async Task BeginEditAsync_MakesTheCellCurrentAndBeginsEditingIt() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(1, 1); + + var began = await tableView.BeginEditAsync(slot); + + Assert.IsTrue(began); + Assert.AreEqual(slot, tableView.CurrentCellSlot); + Assert.IsTrue(tableView.IsEditing); + Assert.IsInstanceOfType(tableView.GetCellFromSlot(slot)!.Content); + } + + [UITestMethod] + public async Task BeginEditAsync_OnTheCurrentCell_BeginsEditingIt() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(0, 1); + tableView.CurrentCellSlot = slot; + await Task.Delay(100); + + var began = await tableView.BeginEditAsync(slot); + + Assert.IsTrue(began); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(slot, tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task BeginEditAsync_OnTheCurrentVisibleCell_CompletesSynchronously() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(1, 0); + tableView.CurrentCellSlot = slot; + await Task.Delay(100); + + // A caller handling a key or character event must know before its handler returns whether + // the edit began, as it does for F2; waiting on a scroll here would make that impossible. + var begin = tableView.BeginEditAsync(slot); + + Assert.IsTrue(begin.IsCompleted, "BeginEditAsync on the current, realized cell did not complete synchronously."); + Assert.IsTrue(await begin); + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_CommitsTheEditInProgressOnAnotherCell() + { + var tableView = await CreateTableViewAsync(); + var ended = new System.Collections.Generic.List(); + tableView.CellEditEnded += (_, e) => ended.Add(e.EditAction); + + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(1, 1))); + + CollectionAssert.AreEqual(new[] { TableViewEditAction.Commit }, ended); + Assert.AreEqual(new TableViewCellSlot(1, 1), tableView.CurrentCellSlot); + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_OnTheCellAlreadyBeingEdited_ReturnsFalseAndKeepsEditing() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(0, 0); + Assert.IsTrue(await tableView.BeginEditAsync(slot)); + + Assert.IsFalse(await tableView.BeginEditAsync(slot)); + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_HonoursACancelledBeginningEdit() + { + var tableView = await CreateTableViewAsync(); + tableView.BeginningEdit += (_, e) => e.Cancel = true; + + var began = await tableView.BeginEditAsync(new TableViewCellSlot(1, 0)); + + Assert.IsFalse(began); + Assert.IsFalse(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_DoesNotEditAReadOnlyColumn() + { + var tableView = await CreateTableViewAsync(); + tableView.Columns[1].IsReadOnly = true; + + var began = await tableView.BeginEditAsync(new TableViewCellSlot(0, 1)); + + Assert.IsFalse(began); + Assert.IsFalse(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_DoesNotEditAReadOnlyTable() + { + var tableView = await CreateTableViewAsync(); + tableView.IsReadOnly = true; + + Assert.IsFalse(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + Assert.IsFalse(tableView.IsEditing); + } + + [UITestMethod] + public async Task BeginEditAsync_RejectsASlotOutsideTheTable() + { + var tableView = await CreateTableViewAsync(); + + Assert.IsFalse(await tableView.BeginEditAsync(new TableViewCellSlot(5, 0))); + Assert.IsFalse(tableView.IsEditing); + } + + private static async Task CreateTableViewAsync() + { + var tableView = new TableView + { + SelectionMode = ListViewSelectionMode.Extended, + SelectionUnit = TableViewSelectionUnit.Cell, + }; + + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath(nameof(EditItem.Name)), Mode = BindingMode.TwoWay } + }); + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Note", + Binding = new Binding { Path = new PropertyPath(nameof(EditItem.Note)), Mode = BindingMode.TwoWay } + }); + tableView.ItemsSource = new[] + { + new EditItem { Name = "A", Note = "first" }, + new EditItem { Name = "B", Note = "second" } + }; + + await UnitTestApp.Current.MainWindow.LoadTestContentAsync(tableView); + + return tableView; + } + + public sealed class EditItem + { + public string Name { get; set; } = string.Empty; + public string Note { get; set; } = string.Empty; + } +} diff --git a/tests/TableViewKeyboardNavigationTests.cs b/tests/TableViewKeyboardNavigationTests.cs new file mode 100644 index 00000000..ab811a6c --- /dev/null +++ b/tests/TableViewKeyboardNavigationTests.cs @@ -0,0 +1,242 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Collections.Generic; +using System.Threading.Tasks; +using Windows.System; + +namespace WinUI.TableView.Tests; + +[TestClass] +public class TableViewKeyboardNavigationTests +{ + [UITestMethod] + public async Task Enter_moves_down_by_default() + { + var tableView = await CreateTableViewAsync(); + await MakeCurrentAsync(tableView, new TableViewCellSlot(0, 0)); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Enter)); + + Assert.AreEqual(new TableViewCellSlot(1, 0), tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task Enter_moves_right_when_EnterKeyNavigation_is_Right() + { + var tableView = await CreateTableViewAsync(); + tableView.EnterKeyNavigation = TableViewEnterKeyNavigation.Right; + await MakeCurrentAsync(tableView, new TableViewCellSlot(0, 0)); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Enter)); + + Assert.AreEqual(new TableViewCellSlot(0, 1), tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task Enter_moving_right_wraps_from_the_last_column_to_the_first_cell_of_the_next_row() + { + var tableView = await CreateTableViewAsync(); + tableView.EnterKeyNavigation = TableViewEnterKeyNavigation.Right; + await MakeCurrentAsync(tableView, new TableViewCellSlot(0, 2)); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Enter)); + + Assert.AreEqual(new TableViewCellSlot(1, 0), tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task Shift_Enter_moving_right_goes_left() + { + var tableView = await CreateTableViewAsync(); + tableView.EnterKeyNavigation = TableViewEnterKeyNavigation.Right; + await MakeCurrentAsync(tableView, new TableViewCellSlot(1, 1)); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Enter, shiftKey: true)); + + Assert.AreEqual(new TableViewCellSlot(1, 0), tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task Right_arrow_and_Tab_move_to_the_next_cell_to_the_right() + { + var tableView = await CreateTableViewAsync(); + await MakeCurrentAsync(tableView, new TableViewCellSlot(0, 0)); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Right)); + Assert.AreEqual(new TableViewCellSlot(0, 1), tableView.CurrentCellSlot); + + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Tab)); + Assert.AreEqual(new TableViewCellSlot(0, 2), tableView.CurrentCellSlot); + } + + [UITestMethod] + public async Task CommitEdit_commits_and_ends_the_edit_leaving_the_cell_current() + { + var tableView = await CreateTableViewAsync(); + var ended = new List(); + tableView.CellEditEnded += (_, e) => ended.Add(e.EditAction); + var slot = new TableViewCellSlot(1, 1); + Assert.IsTrue(await tableView.BeginEditAsync(slot)); + + Assert.IsTrue(tableView.CommitEdit()); + + Assert.IsFalse(tableView.IsEditing); + Assert.AreEqual(slot, tableView.CurrentCellSlot); + CollectionAssert.AreEqual(new[] { TableViewEditAction.Commit }, ended); + } + + [UITestMethod] + public async Task CommitEdit_keeps_editing_when_the_commit_is_cancelled() + { + var tableView = await CreateTableViewAsync(); + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + tableView.CellEditEnding += (_, e) => e.Cancel = true; + + Assert.IsFalse(tableView.CommitEdit()); + + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task CommitEdit_with_no_edit_in_progress_returns_true() + { + var tableView = await CreateTableViewAsync(); + + Assert.IsTrue(tableView.CommitEdit()); + } + + [UITestMethod] + public async Task NavigateFromCurrentCell_is_refused_while_editing() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(0, 0); + Assert.IsTrue(await tableView.BeginEditAsync(slot)); + + Assert.IsFalse(tableView.NavigateFromCurrentCell(VirtualKey.Right)); + + Assert.AreEqual(slot, tableView.CurrentCellSlot); + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task Commit_then_navigate_moves_right_like_entry_mode() + { + var tableView = await CreateTableViewAsync(); + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + + Assert.IsTrue(tableView.CommitEdit()); + Assert.IsTrue(tableView.NavigateFromCurrentCell(VirtualKey.Right)); + + Assert.AreEqual(new TableViewCellSlot(0, 1), tableView.CurrentCellSlot); + Assert.IsFalse(tableView.IsEditing); + } + + [UITestMethod] + public async Task Enter_while_editing_edits_the_next_cell_by_default() + { + var tableView = await CreateTableViewAsync(); + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + + Assert.IsTrue(tableView.HandleTabOrEnter(VirtualKey.Enter, false, CurrentCell(tableView), new RoutedEventArgs())); + + Assert.AreEqual(new TableViewCellSlot(1, 0), tableView.CurrentCellSlot); + Assert.IsTrue(tableView.IsEditing); + } + + [UITestMethod] + public async Task Enter_while_editing_commits_and_only_selects_the_cell_to_the_right_when_not_continuing() + { + var tableView = await CreateTableViewAsync(); + tableView.EnterKeyNavigation = TableViewEnterKeyNavigation.Right; + tableView.ContinueEditingOnNavigation = false; + var ended = new List(); + tableView.CellEditEnded += (_, e) => ended.Add(e.EditAction); + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(0, 0))); + + Assert.IsTrue(tableView.HandleTabOrEnter(VirtualKey.Enter, false, CurrentCell(tableView), new RoutedEventArgs())); + + Assert.AreEqual(new TableViewCellSlot(0, 1), tableView.CurrentCellSlot); + Assert.IsFalse(tableView.IsEditing); + CollectionAssert.AreEqual(new[] { TableViewEditAction.Commit }, ended); + } + + [UITestMethod] + public async Task Tab_while_editing_only_selects_the_next_cell_when_not_continuing() + { + var tableView = await CreateTableViewAsync(); + tableView.ContinueEditingOnNavigation = false; + Assert.IsTrue(await tableView.BeginEditAsync(new TableViewCellSlot(1, 2))); + + Assert.IsTrue(tableView.HandleTabOrEnter(VirtualKey.Tab, false, CurrentCell(tableView), new RoutedEventArgs())); + + Assert.AreEqual(new TableViewCellSlot(2, 0), tableView.CurrentCellSlot); + Assert.IsFalse(tableView.IsEditing); + } + + [UITestMethod] + public async Task Enter_while_editing_moves_nothing_when_the_commit_is_cancelled() + { + var tableView = await CreateTableViewAsync(); + var slot = new TableViewCellSlot(0, 0); + Assert.IsTrue(await tableView.BeginEditAsync(slot)); + tableView.CellEditEnding += (_, e) => e.Cancel = true; + + Assert.IsFalse(tableView.HandleTabOrEnter(VirtualKey.Enter, false, CurrentCell(tableView), new RoutedEventArgs())); + + Assert.AreEqual(slot, tableView.CurrentCellSlot); + Assert.IsTrue(tableView.IsEditing); + } + + private static TableViewCell? CurrentCell(TableView tableView) + { + return tableView.CurrentCellSlot is { } slot ? tableView.GetCellFromSlot(slot) : null; + } + + private static async Task MakeCurrentAsync(TableView tableView, TableViewCellSlot slot) + { + tableView.MakeSelection(slot, false); + await Task.Delay(100); + } + + private static async Task CreateTableViewAsync() + { + var tableView = new TableView + { + // Exactly the three columns declared below; the default would add three generated ones, and the wrap + // test's "last column" would then not be the last (CI run 36832285874: landed on column 3 of 6). + AutoGenerateColumns = false, + SelectionMode = ListViewSelectionMode.Extended, + SelectionUnit = TableViewSelectionUnit.Cell, + }; + + foreach (var name in new[] { nameof(NavItem.A), nameof(NavItem.B), nameof(NavItem.C) }) + { + tableView.Columns.Add(new TableViewTextColumn + { + Header = name, + Binding = new Binding { Path = new PropertyPath(name), Mode = BindingMode.TwoWay } + }); + } + + tableView.ItemsSource = new[] + { + new NavItem { A = "a0", B = "b0", C = "c0" }, + new NavItem { A = "a1", B = "b1", C = "c1" }, + new NavItem { A = "a2", B = "b2", C = "c2" }, + }; + + await UnitTestApp.Current.MainWindow.LoadTestContentAsync(tableView); + + return tableView; + } + + public sealed class NavItem + { + public string A { get; set; } = string.Empty; + public string B { get; set; } = string.Empty; + public string C { get; set; } = string.Empty; + } +}