diff --git a/src/TableView.cs b/src/TableView.cs index 69673f4b..ee782fc7 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -47,6 +47,8 @@ private sealed class GroupHeaderRowItem private bool _shouldThrowSelectionModeChangedException; private bool _isUpdatingBaseItemsSource; private bool _ensureColumns = true; + private TableViewRow? _editingHighlightRow; + private int _editingHighlightRowIndex = -1; private readonly List _rows = []; private readonly CollectionView _collectionView = []; internal Canvas? _dragRectangleCanvas; @@ -196,6 +198,18 @@ protected override void PrepareContainerForItemOverride(DependencyObject element { base.PrepareContainerForItemOverride(element, item); + // Reset editing highlight state on recycled containers to prevent + // stale _hasEditingHighlight from blocking EnsureAlternateColors. + if (element is TableViewRow { } recycledRow) + { + recycledRow.ApplyEditingHighlight(false); + + if (_editingHighlightRow == recycledRow) + { + _editingHighlightRow = null; + } + } + DispatcherQueue.TryEnqueue(() => { if (element is TableViewRow row) @@ -203,10 +217,25 @@ protected override void PrepareContainerForItemOverride(DependencyObject element row.EnsureCellsStyle(default, item); row.ApplyCellsSelectionState(); + // Reset current cell border on all cells in recycled containers + // to clear stale "Current" visual state from previous use. + foreach (var cell in row.Cells) + { + cell.ApplyCurrentCellState(); + } + if (CurrentCellSlot.HasValue) { row.ApplyCurrentCellState(CurrentCellSlot.Value); } + + // Apply editing highlight when the editing row scrolls into view + var rowIndex = Items.IndexOf(item); + if (_editingHighlightRowIndex >= 0 && rowIndex == _editingHighlightRowIndex) + { + _editingHighlightRow = row; + row.ApplyEditingHighlight(true); + } } }); } @@ -302,7 +331,7 @@ private async Task HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool c do { - newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter); + newSlot = GetNextSlot(newSlot, shiftKey, e.Key is VirtualKey.Enter || (e.Key is VirtualKey.Tab && SelectionUnit is TableViewSelectionUnit.Row)); } while (isEditing && Columns[newSlot.Column].IsReadOnly); @@ -314,6 +343,21 @@ private async Task HandleNavigations(KeyRoutedEventArgs e, bool shiftKey, bool c { SetIsEditing(false); } + else if (SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow && newSlot.Row != currentCell.Slot.Row) + { + // Editing moved to a different row — move the highlight + _editingHighlightRow?.ApplyEditingHighlight(false); + _editingHighlightRowIndex = newSlot.Row; + if (ContainerFromIndex(newSlot.Row) is TableViewRow newRow) + { + _editingHighlightRow = newRow; + newRow.ApplyEditingHighlight(true); + } + else + { + _editingHighlightRow = null; + } + } } MakeSelection(newSlot, false); @@ -2445,6 +2489,25 @@ internal void SetIsEditing(bool value) IsEditing = value; UpdateCornerButtonState(); + + if (value && SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow) + { + if (CurrentCellSlot.HasValue) + { + _editingHighlightRowIndex = CurrentCellSlot.Value.Row; + if (ContainerFromIndex(CurrentCellSlot.Value.Row) is TableViewRow row) + { + _editingHighlightRow = row; + row.ApplyEditingHighlight(true); + } + } + } + else if (!value) + { + _editingHighlightRow?.ApplyEditingHighlight(false); + _editingHighlightRow = null; + _editingHighlightRowIndex = -1; + } } /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 7c531004..69023012 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -328,7 +328,7 @@ protected override void OnPointerEntered(PointerRoutedEventArgs e) if ((TableView?.SelectionMode is not ListViewSelectionMode.None && TableView?.SelectionUnit is not TableViewSelectionUnit.Row) || !TableView.IsReadOnly - || (TableView?.SelectionUnit is TableViewSelectionUnit.Row && !IsReadOnly)) + || (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow && !IsReadOnly)) { VisualStates.GoToState(this, false, VisualStates.StatePointerOver); } @@ -342,7 +342,7 @@ protected override void OnPointerExited(PointerRoutedEventArgs e) if ((TableView?.SelectionMode is not ListViewSelectionMode.None && TableView?.SelectionUnit is not TableViewSelectionUnit.Row) || !TableView.IsReadOnly - || (TableView?.SelectionUnit is TableViewSelectionUnit.Row && !IsReadOnly)) + || (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow && !IsReadOnly)) { VisualStates.GoToState(this, false, VisualStates.StateNormal); } @@ -374,6 +374,16 @@ protected override async void OnTapped(TappedRoutedEventArgs e) MakeSelection(); e.Handled = true; } + else if (TableView?.SelectionUnit is TableViewSelectionUnit.CellOrRow + && !IsReadOnly + && TableView is not null + && !TableView.IsEditing + && Column?.UseSingleElement is not true) + { + // Second tap on an already-selected cell in CellOrRow mode — start editing + // (like File Explorer's tap-pause-tap to rename). + e.Handled = await BeginCellEditing(e); + } } /// diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index 6d2ab4c1..6bdf2101 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -37,6 +37,8 @@ public partial class TableViewRow : ListViewItem private ListViewItemPresenter? _itemPresenter; private Border? _selectionBackground; private bool _ensureCells = true; + private bool _hasEditingHighlight; + private bool _isBeginningEdit; private Brush? _cellPresenterBackground; private Brush? _cellPresenterForeground; @@ -251,7 +253,7 @@ protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) } /// - protected override void OnTapped(TappedRoutedEventArgs e) + protected override async void OnTapped(TappedRoutedEventArgs e) { if (TableView?.IsGroupHeaderItem(Content) is true) { @@ -266,6 +268,23 @@ protected override void OnTapped(TappedRoutedEventArgs e) TableView.CurrentRowIndex = Index; TableView.LastSelectionUnit = TableViewSelectionUnit.Row; } + + // When SelectionUnit is Row and the row is already selected, forward the + // tap to the target cell so editing can be initiated with a second tap + // (like File Explorer's tap-pause-tap to rename). + if (TableView?.SelectionUnit is TableViewSelectionUnit.Row + && IsSelected + && e.OriginalSource is DependencyObject source + && source.FindAscendant() is { IsReadOnly: false } cell + && !TableView.IsEditing + && !_isBeginningEdit + && cell.Column?.UseSingleElement is not true) + { + _isBeginningEdit = true; + TableView.MakeSelection(cell.Slot, false); + e.Handled = await cell.BeginCellEditing(e); + _isBeginningEdit = false; + } } /// @@ -311,10 +330,13 @@ protected override async void OnDoubleTapped(DoubleTappedRoutedEventArgs e) && e.OriginalSource is DependencyObject source && source.FindAscendant() is { IsReadOnly: false } cell && !TableView.IsEditing + && !_isBeginningEdit && cell.Column?.UseSingleElement is not true) { + _isBeginningEdit = true; TableView.MakeSelection(cell.Slot, false); e.Handled = await cell.BeginCellEditing(e); + _isBeginningEdit = false; return; } @@ -735,7 +757,7 @@ private async void EnsureSelectionIndicatorPosition(double detailsHeight, Border /// internal void EnsureAlternateColors() { - if (TableView is null || RowPresenter is null) return; + if (TableView is null || RowPresenter is null || _hasEditingHighlight) return; RowPresenter.Background = Index % 2 == 1 && TableView.AlternateRowBackground is not null ? TableView.AlternateRowBackground : _cellPresenterBackground; @@ -754,6 +776,44 @@ internal void UpdateSelectCheckMarkOpacity() } } + /// + /// Highlights or unhighlights the row to indicate that a cell is being edited. + /// + internal void ApplyEditingHighlight(bool isEditing) + { + _hasEditingHighlight = isEditing; + + if (isEditing) + { +#if WINDOWS + if (RowPresenter is not null && _itemPresenter?.PointerOverBackground is { } pointerOverBrush) + { + RowPresenter.Background = pointerOverBrush; + } +#else + if (_selectionBackground is not null) + { + _selectionBackground.Opacity = 1; + } +#endif + } + else + { +#if WINDOWS + if (RowPresenter is not null) + { + RowPresenter.Background = _cellPresenterBackground; + } +#else + if (_selectionBackground is not null) + { + _selectionBackground.Opacity = IsSelected ? 1 : 0; + } +#endif + EnsureAlternateColors(); + } + } + /// /// Gets the height of the horizontal gridlines. /// diff --git a/tests/TableViewRowEditingTests.cs b/tests/TableViewRowEditingTests.cs new file mode 100644 index 00000000..0ad526c3 --- /dev/null +++ b/tests/TableViewRowEditingTests.cs @@ -0,0 +1,257 @@ +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; +using System.Collections.ObjectModel; +using System.Threading.Tasks; + +namespace WinUI.TableView.Tests; + +/// +/// Tests for the row-editing feature: tap-to-edit in Row and CellOrRow modes, +/// editing highlight, pointer hover, and virtualization. +/// +[TestClass] +public class TableViewRowEditingTests +{ + // Scenario 1 & 2: Tap-to-edit in Row mode — editing highlight applied to row + [UITestMethod] + public async Task RowMode_EditingHighlight_AppliedToRow() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + + await LoadAsync(tableView); + try + { + tableView.CurrentCellSlot = new TableViewCellSlot(1, 0); + tableView.SetIsEditing(true); + + var row = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(row); + // Editing highlight should block EnsureAlternateColors + row.EnsureAlternateColors(); + + tableView.SetIsEditing(false); + // After stopping, alternate colors should apply normally + row.EnsureAlternateColors(); + } + finally + { + await UnloadAsync(tableView); + } + } + + // Scenario 5: Pointer hover effects — CellOrRow mode prerequisites + [UITestMethod] + public async Task CellOrRowMode_PointerHover_Prerequisites() + { + var items = CreateTestItems(3); + var tableView = CreateTableView(TableViewSelectionUnit.CellOrRow, items); + + await LoadAsync(tableView); + try + { + // OnPointerEntered checks: SelectionUnit is Row or CellOrRow && !IsReadOnly + Assert.AreEqual(TableViewSelectionUnit.CellOrRow, tableView.SelectionUnit); + Assert.IsFalse(tableView.IsReadOnly); + } + finally + { + await UnloadAsync(tableView); + } + } + + // Scenario 6: Tap-to-edit in CellOrRow — highlight + prerequisites + [UITestMethod] + public async Task CellOrRowMode_EditingHighlight_AppliedToRow() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.CellOrRow, items); + + await LoadAsync(tableView); + try + { + tableView.CurrentCellSlot = new TableViewCellSlot(2, 0); + tableView.SetIsEditing(true); + + var row = tableView.ContainerFromIndex(2) as TableViewRow; + Assert.IsNotNull(row); + row.EnsureAlternateColors(); // should not override highlight + + tableView.SetIsEditing(false); + row.EnsureAlternateColors(); // should apply normally now + } + finally + { + await UnloadAsync(tableView); + } + } + + [UITestMethod] + public async Task CellOrRowMode_TapToEdit_Prerequisites() + { + var items = CreateTestItems(3); + var tableView = CreateTableView(TableViewSelectionUnit.CellOrRow, items); + + await LoadAsync(tableView); + try + { + // TableViewCell.OnTapped checks these conditions for second-tap editing + Assert.AreEqual(TableViewSelectionUnit.CellOrRow, tableView.SelectionUnit); + Assert.IsFalse(tableView.IsReadOnly); + Assert.IsFalse(tableView.IsEditing); + + foreach (var column in tableView.Columns) + { + Assert.IsFalse(column.UseSingleElement); + } + } + finally + { + await UnloadAsync(tableView); + } + } + + // Scenario 7: Virtualization — editing highlight cleared on recycled row + [UITestMethod] + public async Task Virtualization_EditingHighlight_ClearedOnRecycle() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + + await LoadAsync(tableView); + try + { + tableView.CurrentCellSlot = new TableViewCellSlot(0, 0); + tableView.SetIsEditing(true); + + tableView.SetIsEditing(false); + tableView.CurrentCellSlot = null; + Assert.IsNull(tableView.CurrentCellSlot); + + var row = tableView.ContainerFromIndex(0) as TableViewRow; + Assert.IsNotNull(row); + row.EnsureAlternateColors(); // should work normally after edit cleared + } + finally + { + await UnloadAsync(tableView); + } + } + + // Scenario 7: Virtualization — cell "Current" border reset on recycled container + [UITestMethod] + public async Task Virtualization_CellCurrentState_ResetOnPrepare() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + + await LoadAsync(tableView); + try + { + tableView.CurrentCellSlot = new TableViewCellSlot(1, 0); + tableView.CurrentCellSlot = null; + + // Simulates PrepareContainerForItemOverride resetting stale borders + var row = tableView.ContainerFromIndex(1) as TableViewRow; + Assert.IsNotNull(row); + foreach (var cell in row.Cells) + { + cell.ApplyCurrentCellState(); + } + } + finally + { + await UnloadAsync(tableView); + } + } + + // Cross-cutting: stop editing clears state, switch rows, restart cleanly + [UITestMethod] + public async Task EditingState_SwitchBetweenRows_ClearsAndRestarts() + { + var items = CreateTestItems(5); + var tableView = CreateTableView(TableViewSelectionUnit.Row, items); + + await LoadAsync(tableView); + try + { + // Edit row 0 + tableView.CurrentCellSlot = new TableViewCellSlot(0, 0); + tableView.SetIsEditing(true); + Assert.IsTrue(tableView.IsEditing); + + // Stop — should fully clear state + tableView.SetIsEditing(false); + Assert.IsFalse(tableView.IsEditing); + + // Switch to row 3, different column — no leftover state + tableView.CurrentCellSlot = new TableViewCellSlot(3, 1); + tableView.SetIsEditing(true); + Assert.IsTrue(tableView.IsEditing); + Assert.AreEqual(3, tableView.CurrentCellSlot?.Row); + + tableView.SetIsEditing(false); + Assert.IsFalse(tableView.IsEditing); + } + finally + { + await UnloadAsync(tableView); + } + } + + // ── Helpers ── + + private static TableView CreateTableView( + TableViewSelectionUnit selectionUnit, + ObservableCollection? items = null) + { + var tableView = new TableView + { + AutoGenerateColumns = false, + SelectionMode = ListViewSelectionMode.Single, + SelectionUnit = selectionUnit, + }; + + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath("Name") } + }); + + tableView.Columns.Add(new TableViewNumberColumn + { + Header = "Value", + Binding = new Binding { Path = new PropertyPath("Value") } + }); + + if (items is not null) + { + tableView.ItemsSource = items; + } + + return tableView; + } + + private static ObservableCollection CreateTestItems(int count) + { + var list = new ObservableCollection(); + for (int i = 0; i < count; i++) + { + list.Add(new TestItem { Id = i, Name = $"Item{i}", Value = count - i }); + } + return list; + } + + private static Task LoadAsync(FrameworkElement content) + { + return UnitTestApp.Current.MainWindow.LoadTestContentAsync(content); + } + + private static Task UnloadAsync(FrameworkElement content) + { + return UnitTestApp.Current.MainWindow.UnloadTestContentAsync(content); + } +}