Skip to content

Commit 0dd74ce

Browse files
DH-1981 Spreadsheet navigation options, BeginEditAsync, CommitEdit/NavigateFromCurrentCell (#5)
* DH-1831 Add TableView.BeginEditAsync(slot) (w-ahmad#431) Makes a cell current and selected through MakeSelection and begins editing it as F2 does, committing any edit in progress on another cell first; a realized current cell is edited synchronously. Brought onto the 1.5.0 line Hub ships so it can be tested in Hub before w-ahmad#431 goes further upstream. TableViewCell.BeginEdit() is kept so current callers still compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * DH-1981 Add spreadsheet navigation options and commit/navigate APIs EnterKeyNavigation (Down by default, Right moves to the next cell and wraps, as gINT does); ContinueEditingOnNavigation (true by default; false makes Tab/Enter commit and only select the next cell, as Excel does); CommitEdit() and NavigateFromCurrentCell(key) so a host can implement entry mode (an arrow key that commits and moves). The arrow-slot computation is extracted so the key handler and the public method share it. Defaults keep the stock behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * DH-1981 Pin the navigation tests to their declared columns AutoGenerateColumns defaults true, so the test grid carried three generated columns beside the three declared ones and the wrap test's last column was not the last (CI run 36832285874). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * DH-1981 Test Tab and Enter while editing; fix a doc comment The commit-and-move path ran only from OnKeyDown, which needs a KeyRoutedEventArgs no test can construct, so ContinueEditingOnNavigation and EnterKeyNavigation=Right while editing had no test at all. - Factor the Tab/Enter branch into internal HandleTabOrEnter (same behaviour; a cancelled commit still leaves the key unhandled). - Four tests: default continues editing; Right + not continuing commits and only selects; Tab wraps and only selects; a cancelled commit moves nothing. - GetArrowSlot had been inserted under GetNextSlot's summary, giving it two summaries and GetNextSlot none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8cd3165 commit 0dd74ce

5 files changed

Lines changed: 673 additions & 46 deletions

File tree

‎src/TableView.Properties.cs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ public partial class TableView
208208
/// </summary>
209209
public static readonly DependencyProperty UseRightClickForColumnFilterProperty = DependencyProperty.Register(nameof(UseRightClickForColumnFilter), typeof(bool), typeof(TableView), new PropertyMetadata(false));
210210

211+
/// <summary>
212+
/// Identifies the EnterKeyNavigation dependency property.
213+
/// </summary>
214+
public static readonly DependencyProperty EnterKeyNavigationProperty = DependencyProperty.Register(nameof(EnterKeyNavigation), typeof(TableViewEnterKeyNavigation), typeof(TableView), new PropertyMetadata(TableViewEnterKeyNavigation.Down));
215+
216+
/// <summary>
217+
/// Identifies the ContinueEditingOnNavigation dependency property.
218+
/// </summary>
219+
public static readonly DependencyProperty ContinueEditingOnNavigationProperty = DependencyProperty.Register(nameof(ContinueEditingOnNavigation), typeof(bool), typeof(TableView), new PropertyMetadata(true));
220+
211221
/// <summary>
212222
/// Identifies the VerticalOffset dependency property.
213223
/// </summary>
@@ -356,6 +366,27 @@ public bool UseRightClickForColumnFilter
356366
set => SetValue(UseRightClickForColumnFilterProperty, value);
357367
}
358368

369+
/// <summary>
370+
/// Gets or sets where the Enter key moves the current cell. The default, <see cref="TableViewEnterKeyNavigation.Down"/>,
371+
/// moves to the cell below; <see cref="TableViewEnterKeyNavigation.Right"/> moves to the next cell to the right.
372+
/// </summary>
373+
public TableViewEnterKeyNavigation EnterKeyNavigation
374+
{
375+
get => (TableViewEnterKeyNavigation)GetValue(EnterKeyNavigationProperty);
376+
set => SetValue(EnterKeyNavigationProperty, value);
377+
}
378+
379+
/// <summary>
380+
/// Gets or sets a value indicating whether Tab or Enter pressed while editing a cell begins editing the
381+
/// cell it moves to. The default is <see langword="true"/>. When <see langword="false"/>, the edit is
382+
/// committed and the next cell is only made current and selected, as in a spreadsheet.
383+
/// </summary>
384+
public bool ContinueEditingOnNavigation
385+
{
386+
get => (bool)GetValue(ContinueEditingOnNavigationProperty);
387+
set => SetValue(ContinueEditingOnNavigationProperty, value);
388+
}
389+
359390
/// <summary>
360391
/// Gets the collection view associated with the TableView.
361392
/// </summary>

‎src/TableView.cs‎

Lines changed: 220 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public partial class TableView : ListView
5050
private TableViewCellSlotRange? _lastDragSelectionCellRange;
5151
private ItemIndexRange? _lastDragSelectionRowRange;
5252
private bool _cellStateDispatchPending;
53+
private (TableViewCellSlot Slot, TaskCompletionSource<bool> Completion)? _pendingEdit;
5354
private readonly HashSet<int> _pendingCellStateRows = [];
5455
private TableViewColumn? _resizingColumn;
5556
private double _resizingOriginalWidth;
@@ -946,56 +947,13 @@ private void HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool ctrlKey
946947
// Handle navigation keys
947948
else if (e.Key is VirtualKey.Tab or VirtualKey.Enter)
948949
{
949-
var isEditing = IsEditing;
950-
951-
var newSlot = CurrentCellSlot ?? new();
952-
953-
do
954-
{
955-
newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter);
956-
957-
} while (isEditing && Columns[newSlot.Column].IsReadOnly);
958-
959-
if (isEditing && currentCell is not null)
960-
{
961-
if (!EndCellEditing(TableViewEditAction.Commit, currentCell)) return;
962-
963-
if (CurrentCellSlot == newSlot || GetCellFromSlot(newSlot) is not { } nextCell || !nextCell.BeginCellEditing(e))
964-
{
965-
SetIsEditing(false);
966-
}
967-
}
968-
969-
MakeSelection(newSlot, false);
970-
971-
e.Handled = true;
950+
// A cancelled commit leaves the key unhandled, as before the move was factored out.
951+
e.Handled = HandleTabOrEnter(e.Key, shiftKey, currentCell, e);
972952
}
973953
else if ((e.Key is VirtualKey.Left or VirtualKey.Right or VirtualKey.Up or VirtualKey.Down)
974954
&& !IsEditing)
975955
{
976-
var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1;
977-
var column = CurrentCellSlot?.Column ?? -1;
978-
979-
if (row == -1 && column == -1)
980-
{
981-
row = column = 0;
982-
}
983-
else if (e.Key is VirtualKey.Left or VirtualKey.Right)
984-
{
985-
column = e.Key is VirtualKey.Left ? ctrlKey ? 0 : column - 1 : ctrlKey ? Columns.VisibleColumns.Count - 1 : column + 1;
986-
if (column >= Columns.VisibleColumns.Count)
987-
{
988-
column = 0;
989-
row++;
990-
}
991-
}
992-
else
993-
{
994-
row = e.Key == VirtualKey.Up ? ctrlKey ? 0 : row - 1 : ctrlKey ? Items.Count - 1 : row + 1;
995-
}
996-
997-
var newSlot = new TableViewCellSlot(row, column);
998-
MakeSelection(newSlot, shiftKey);
956+
MakeSelection(GetArrowSlot(e.Key, ctrlKey), shiftKey);
999957
e.Handled = true;
1000958
}
1001959
else if (e.Key is VirtualKey.Home or VirtualKey.End)
@@ -1034,6 +992,184 @@ private int CalculateAvailablePageSize()
1034992
return (int)Math.Floor(availableHeight / rowHeight);
1035993
}
1036994

995+
/// <summary>
996+
/// Moves the current cell as Tab or Enter does. While editing, the edit is committed first; the cell
997+
/// moved to is then edited too unless <see cref="ContinueEditingOnNavigation"/> is <see langword="false"/>.
998+
/// </summary>
999+
/// <returns><see langword="false"/> if committing the edit was cancelled, so nothing moved.</returns>
1000+
internal bool HandleTabOrEnter(VirtualKey key, bool shiftKey, TableViewCell? currentCell, RoutedEventArgs editingArgs)
1001+
{
1002+
var isEditing = IsEditing;
1003+
1004+
var newSlot = CurrentCellSlot ?? new();
1005+
1006+
do
1007+
{
1008+
newSlot = GetNextSlot(newSlot, shiftKey, key is VirtualKey.Enter && EnterKeyNavigation is TableViewEnterKeyNavigation.Down);
1009+
1010+
} while (isEditing && Columns[newSlot.Column].IsReadOnly);
1011+
1012+
if (isEditing && currentCell is not null)
1013+
{
1014+
if (!EndCellEditing(TableViewEditAction.Commit, currentCell)) return false;
1015+
1016+
if (!ContinueEditingOnNavigation || CurrentCellSlot == newSlot || GetCellFromSlot(newSlot) is not { } nextCell || !nextCell.BeginCellEditing(editingArgs))
1017+
{
1018+
SetIsEditing(false);
1019+
}
1020+
}
1021+
1022+
MakeSelection(newSlot, false);
1023+
return true;
1024+
}
1025+
1026+
/// <summary>
1027+
/// Commits the edit in progress, if any, and ends the edit session, leaving the edited cell current.
1028+
/// </summary>
1029+
/// <remarks>
1030+
/// This is what Tab or Enter do to the cell being left, without moving. <see cref="CellEditEnding"/> is
1031+
/// raised, and a handler that cancels it keeps the edit open.
1032+
/// </remarks>
1033+
/// <returns><see langword="true"/> if no edit is in progress any more; <see langword="false"/> if the
1034+
/// commit was cancelled.</returns>
1035+
public bool CommitEdit()
1036+
{
1037+
if (!IsEditing)
1038+
{
1039+
return true;
1040+
}
1041+
1042+
if (CurrentCellSlot is { } slot && GetCellFromSlot(slot) is { } cell)
1043+
{
1044+
// Move focus to the cell BEFORE the editing element is torn down, as the Escape path does.
1045+
cell.Focus(FocusState.Programmatic);
1046+
1047+
if (!EndCellEditing(TableViewEditAction.Commit, cell))
1048+
{
1049+
return false;
1050+
}
1051+
}
1052+
1053+
SetIsEditing(false);
1054+
return true;
1055+
}
1056+
1057+
/// <summary>
1058+
/// Moves the current cell as the given navigation key would, when no edit is in progress.
1059+
/// </summary>
1060+
/// <remarks>
1061+
/// Supports the arrow keys, Tab and Enter (honouring <see cref="EnterKeyNavigation"/>). A caller that
1062+
/// handles a key inside a cell editor - for example an arrow key that should commit the edit and move,
1063+
/// as in a spreadsheet entry mode - calls <see cref="CommitEdit"/> and then this.
1064+
/// </remarks>
1065+
/// <returns><see langword="true"/> if the key was a supported navigation key and no edit is in progress.</returns>
1066+
public bool NavigateFromCurrentCell(VirtualKey key, bool shiftKey = false, bool ctrlKey = false)
1067+
{
1068+
if (IsEditing)
1069+
{
1070+
return false;
1071+
}
1072+
1073+
if (key is VirtualKey.Left or VirtualKey.Right or VirtualKey.Up or VirtualKey.Down)
1074+
{
1075+
MakeSelection(GetArrowSlot(key, ctrlKey), shiftKey);
1076+
return true;
1077+
}
1078+
1079+
if (key is VirtualKey.Tab or VirtualKey.Enter)
1080+
{
1081+
MakeSelection(GetNextSlot(CurrentCellSlot ?? new(), shiftKey, key is VirtualKey.Enter && EnterKeyNavigation is TableViewEnterKeyNavigation.Down), false);
1082+
return true;
1083+
}
1084+
1085+
return false;
1086+
}
1087+
1088+
/// <summary>
1089+
/// Makes the cell at <paramref name="slot"/> the current cell and begins editing it, as pressing F2 on
1090+
/// that cell does.
1091+
/// </summary>
1092+
/// <remarks>
1093+
/// <para>This is for a cell that exposes an affordance of its own while it is not being edited - a
1094+
/// drop-down arrow, a picker button - and needs a single click on that affordance to open the editor.
1095+
/// The control starts an edit only from a double tap and from F2, and a single click is not a gesture it
1096+
/// can infer.</para>
1097+
/// <para>The sequence matches the existing gestures:</para>
1098+
/// <list type="number">
1099+
/// <item>If another cell is being edited, that edit is committed first, as tapping or tabbing to a
1100+
/// different cell does. If committing is cancelled, no edit begins.</item>
1101+
/// <item>The cell becomes the <see cref="CurrentCellSlot"/> and is selected, as keyboard navigation to
1102+
/// it does, and is scrolled into view and focused.</item>
1103+
/// <item>The edit then begins. <see cref="BeginningEdit"/> is raised, and a handler that cancels it is
1104+
/// honoured.</item>
1105+
/// </list>
1106+
/// <para>No edit begins for a read-only table, column or cell, for a column that draws itself through
1107+
/// <see cref="TableViewColumn.UseSingleElement"/> (a double tap does not begin one either), or when the
1108+
/// cell is already being edited.</para>
1109+
/// </remarks>
1110+
/// <param name="slot">The cell to edit.</param>
1111+
/// <returns>A task whose result is <see langword="true"/> if the cell entered edit mode; otherwise
1112+
/// <see langword="false"/>.</returns>
1113+
public async Task<bool> BeginEditAsync(TableViewCellSlot slot)
1114+
{
1115+
if (IsReadOnly || !slot.IsValid(this))
1116+
{
1117+
return false;
1118+
}
1119+
1120+
if (IsEditing)
1121+
{
1122+
if (CurrentCellSlot == slot)
1123+
{
1124+
return false;
1125+
}
1126+
1127+
if (CurrentCellSlot is { } currentSlot
1128+
&& GetCellFromSlot(currentSlot) is { } currentCell
1129+
&& !EndCellEditing(TableViewEditAction.Commit, currentCell))
1130+
{
1131+
return false;
1132+
}
1133+
1134+
SetIsEditing(false);
1135+
}
1136+
1137+
// A later request supersedes an earlier one that has not started yet.
1138+
_pendingEdit?.Completion.TrySetResult(false);
1139+
_pendingEdit = null;
1140+
1141+
if (CurrentCellSlot == slot)
1142+
{
1143+
// Already current, so OnCurrentCellChanged will not run. A realized cell is edited directly,
1144+
// as F2 does, so the call completes synchronously; only a cell scrolled out of view waits.
1145+
return TryBeginEdit(GetCellFromSlot(slot) ?? await ScrollCellIntoView(slot));
1146+
}
1147+
1148+
var pending = new TaskCompletionSource<bool>();
1149+
_pendingEdit = (slot, pending);
1150+
1151+
MakeSelection(slot, false);
1152+
1153+
if (CurrentCellSlot != slot)
1154+
{
1155+
// Selection declined to make the cell current, so OnCurrentCellChanged will not start the edit.
1156+
_pendingEdit = null;
1157+
return false;
1158+
}
1159+
1160+
return await pending.Task;
1161+
}
1162+
1163+
/// <summary>
1164+
/// Begins editing <paramref name="cell"/> if the same conditions a double tap applies allow it.
1165+
/// </summary>
1166+
private bool TryBeginEdit(TableViewCell? cell)
1167+
{
1168+
return cell is { IsReadOnly: false, Column.UseSingleElement: false }
1169+
&& !IsEditing
1170+
&& cell.BeginCellEditing(new RoutedEventArgs());
1171+
}
1172+
10371173
/// <summary>
10381174
/// Ends the editing of a cell, committing or canceling the edit based on the specified action.
10391175
/// </summary>
@@ -1256,6 +1392,35 @@ private void OnScrollContentPresenterPointerWheelChanged(object sender, PointerR
12561392
}
12571393
}
12581394

1395+
/// <summary>
1396+
/// Gets the slot an arrow key moves the current cell to.
1397+
/// </summary>
1398+
private TableViewCellSlot GetArrowSlot(VirtualKey key, bool ctrlKey)
1399+
{
1400+
var row = (LastSelectionUnit is TableViewSelectionUnit.Row ? CurrentRowIndex : CurrentCellSlot?.Row) ?? -1;
1401+
var column = CurrentCellSlot?.Column ?? -1;
1402+
1403+
if (row == -1 && column == -1)
1404+
{
1405+
row = column = 0;
1406+
}
1407+
else if (key is VirtualKey.Left or VirtualKey.Right)
1408+
{
1409+
column = key is VirtualKey.Left ? ctrlKey ? 0 : column - 1 : ctrlKey ? Columns.VisibleColumns.Count - 1 : column + 1;
1410+
if (column >= Columns.VisibleColumns.Count)
1411+
{
1412+
column = 0;
1413+
row++;
1414+
}
1415+
}
1416+
else
1417+
{
1418+
row = key == VirtualKey.Up ? ctrlKey ? 0 : row - 1 : ctrlKey ? Items.Count - 1 : row + 1;
1419+
}
1420+
1421+
return new TableViewCellSlot(row, column);
1422+
}
1423+
12591424
/// <summary>
12601425
/// Gets the next cell slot based on the current slot and input keys.
12611426
/// </summary>
@@ -2181,6 +2346,15 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel
21812346
var cell = await ScrollCellIntoView(newSlot.Value);
21822347
cell?.ApplyCurrentCellState();
21832348
cell?.Focus(FocusState.Programmatic);
2349+
2350+
// A BeginEditAsync call that moved the current cell starts its edit here, once the cell has
2351+
// been scrolled into view and focused, so that the editing element is the last thing to take
2352+
// focus rather than racing the Focus call above.
2353+
if (_pendingEdit is { } pending && pending.Slot == newSlot.Value)
2354+
{
2355+
_pendingEdit = null;
2356+
pending.Completion.TrySetResult(CurrentCellSlot == newSlot && TryBeginEdit(cell));
2357+
}
21842358
}
21852359
}
21862360

‎src/TableViewEnterKeyNavigation.cs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace WinUI.TableView;
2+
3+
/// <summary>
4+
/// Specifies where the Enter key moves the current cell.
5+
/// </summary>
6+
public enum TableViewEnterKeyNavigation
7+
{
8+
/// <summary>
9+
/// Enter moves to the cell below; Shift+Enter to the cell above. This is the default.
10+
/// </summary>
11+
Down,
12+
13+
/// <summary>
14+
/// Enter moves to the next cell to the right, wrapping to the first cell of the next row;
15+
/// Shift+Enter moves left. This is how the gINT data-entry grid behaves.
16+
/// </summary>
17+
Right
18+
}

0 commit comments

Comments
 (0)