diff --git a/samples/WinUI.TableView.SampleApp/App.xaml.cs b/samples/WinUI.TableView.SampleApp/App.xaml.cs index 0d7a1074..c602ae72 100644 --- a/samples/WinUI.TableView.SampleApp/App.xaml.cs +++ b/samples/WinUI.TableView.SampleApp/App.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; +using System.Diagnostics; namespace WinUI.TableView.SampleApp; @@ -25,17 +26,17 @@ public App() #if DEBUG && WINDOWS private void DebugSettings_BindingFailed(object sender, BindingFailedEventArgs e) { - System.Diagnostics.Debug.WriteLine(e.Message); + Debug.WriteLine(e.Message); } private void DebugSettings_XamlResourceReferenceFailed(DebugSettings sender, XamlResourceReferenceFailedEventArgs args) { - System.Diagnostics.Debug.WriteLine(args.Message); + Debug.WriteLine(args.Message); } private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) { - if (System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Break(); + if (Debugger.IsAttached) Debugger.Break(); } #endif @@ -45,21 +46,27 @@ private void App_UnhandledException(object sender, Microsoft.UI.Xaml.UnhandledEx /// Details about the launch request and process. protected override void OnLaunched(LaunchActivatedEventArgs args) { -#if DEBUG && !WINDOWS +#if DEBUG + if (Debugger.IsAttached) + { + DebugSettings.EnableFrameRateCounter = true; + } +#if !WINDOWS MainWindow.UseStudio(); MainWindow.SetWindowIcon(); +#endif #endif MainWindow.Activate(); } public static void InitializeLogging() { + #if DEBUG var factory = LoggerFactory.Create(builder => { #if __WASM__ builder.AddProvider(new global::Uno.Extensions.Logging.WebAssembly.WebAssemblyConsoleLoggerProvider()); - // Note: DebugSettings.EnableFrameRateCounter requires an Application instance #elif !WINDOWS builder.AddConsole(); #else diff --git a/src/Extensions/ItemIndexRangeExtensions.cs b/src/Extensions/ItemIndexRangeExtensions.cs index 7afae48c..13a6db7c 100644 --- a/src/Extensions/ItemIndexRangeExtensions.cs +++ b/src/Extensions/ItemIndexRangeExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.UI.Xaml.Data; +using System.Runtime.CompilerServices; namespace WinUI.TableView.Extensions; @@ -21,11 +22,55 @@ public static bool IsInRange(this ItemIndexRange range, int index) /// /// Determines whether the given item index range is valid within the TableView. /// - /// The ItemIndexRange to check. + /// The ItemIndexRange to check. /// The TableView to check against. /// True if the item index range of TableView is valid; otherwise, false. - public static bool IsValid(this ItemIndexRange itemIndexRange, TableView tableView) + public static bool IsValid(this ItemIndexRange range, TableView tableView) + { + return range.FirstIndex >= 0 && range.LastIndex < tableView?.Items.Count; + } + + /// + /// Determines whether the specified range completely contains another range. + /// + /// The range to check. + /// The range to check against. + /// True if the range completely contains the other range; otherwise, false. + public static bool Contains(this ItemIndexRange range, ItemIndexRange other) { - return itemIndexRange.FirstIndex >= 0 && itemIndexRange.LastIndex < tableView?.Items.Count; + return other.FirstIndex >= range.FirstIndex && other.LastIndex <= range.LastIndex; + } + + /// + /// Subtracts another ItemIndexRange from the current range and returns the resulting range. + /// + /// The range to subtract from. + /// The range to subtract. + /// The resulting range after subtraction. + public static IEnumerable Subtract(this ItemIndexRange range, ItemIndexRange other) + { + var start = range.FirstIndex; + var end = start + (int)range.Length - 1; + + var otherStart = other.FirstIndex; + var otherEnd = otherStart + (int)other.Length - 1; + + // No overlap. + if (otherEnd < start || otherStart > end) + { + yield break; + } + + // Left remainder. + if (otherStart > start) + { + yield return new ItemIndexRange(start, (uint)(otherStart - start)); + } + + // Right remainder. + if (otherEnd < end) + { + yield return new ItemIndexRange(otherEnd + 1, (uint)(end - otherEnd)); + } } } diff --git a/src/Extensions/TableViewCellSlotRangeExtensions.cs b/src/Extensions/TableViewCellSlotRangeExtensions.cs new file mode 100644 index 00000000..01fbd49e --- /dev/null +++ b/src/Extensions/TableViewCellSlotRangeExtensions.cs @@ -0,0 +1,189 @@ +using Microsoft.UI.Xaml.Data; + +namespace WinUI.TableView.Extensions; + +/// +/// Provides extension methods for the TableViewCellSlotRange type. +/// +internal static class TableViewCellSlotRangeExtensions +{ + /// + /// Determines whether a specified cell slot is within the range. + /// + /// The TableViewCellSlotRange to check. + /// The cell slot to check. + /// True if the slot is within the range; otherwise, false. + public static bool IsInRange(this TableViewCellSlotRange? range, TableViewCellSlot slot) + { + if (range is null || range.Length <= 0) return false; + + var minRow = Math.Min(range.FirstRow, range.LastRow); + var maxRow = Math.Max(range.FirstRow, range.LastRow); + var minColumn = Math.Min(range.FirstColumn, range.LastColumn); + var maxColumn = Math.Max(range.FirstColumn, range.LastColumn); + + return slot.Row >= minRow && slot.Row <= maxRow + && slot.Column >= minColumn && slot.Column <= maxColumn; + } + + /// + /// Determines whether a specified row index is within the range. + /// + /// The TableViewCellSlotRange to check. + /// The row index to check. + /// True if the row index is within the range; otherwise, false. + public static bool IsRowInRange(this TableViewCellSlotRange? range, int row) + { + return range?.Length > 0 + && row >= Math.Min(range.FirstRow, range.LastRow) + && row <= Math.Max(range.FirstRow, range.LastRow); + } + + /// + /// Determines whether a specified column index is within the range. + /// + /// The TableViewCellSlotRange to check. + /// The column index to check. + /// True if the column index is within the range; otherwise, false. + public static bool IsColumnInRange(this TableViewCellSlotRange? range, int column) + { + return range?.Length > 0 + && column >= Math.Min(range.FirstColumn, range.LastColumn) + && column <= Math.Max(range.FirstColumn, range.LastColumn); + } + + /// + /// Determines whether the given cell slot range is valid within the TableView. + /// + /// The TableViewCellSlotRange to check. + /// The TableView to check against. + /// True if the cell slot range of TableView is valid; otherwise, false. + public static bool IsValid(this TableViewCellSlotRange range, TableView tableView) + { + return range.FirstSlot.IsValid(tableView) && range.LastSlot.IsValid(tableView); + } + + /// + /// Returns all cell slots contained within this range, enumerated row by row. + /// + public static IEnumerable GetSlots(this TableViewCellSlotRange range) + { + for (var row = range.FirstRow; row <= range.LastRow; row++) + { + for (var col = range.FirstColumn; col <= range.LastColumn; col++) + { + yield return new TableViewCellSlot(row, col); + } + } + } + + /// + /// Determines whether a specific cell slot falls within this range. + /// + public static bool Contains(this TableViewCellSlotRange range, int rowIndex, int columnIndex) + { + return rowIndex >= range.FirstRow && rowIndex <= range.LastRow && + columnIndex >= range.FirstColumn && columnIndex <= range.LastColumn; + } + + /// + /// Determines whether another TableViewCellSlotRange is completely contained within this range. + /// + public static bool Contains(this TableViewCellSlotRange? range, TableViewCellSlotRange? other) + { + if (range == null || other == null) return false; + + return range.Contains(other.FirstRow, other.FirstColumn) && + range.Contains(other.LastRow, other.LastColumn); + } + + /// + /// Determines whether another range intersects with this range. + /// + public static bool IntersectsWith(this TableViewCellSlotRange range, TableViewCellSlotRange other) + { + if (other == null) return false; + + return range.FirstRow <= other.LastRow && range.LastRow >= other.FirstRow && + range.FirstColumn <= other.LastColumn && range.LastColumn >= other.FirstColumn; + } + + /// + /// Subtracts another range from this range and returns the resulting ranges. + /// + /// The range to subtract from. + /// The range to subtract. + /// An enumerable of resulting ranges after subtraction. + public static IEnumerable Subtract(this TableViewCellSlotRange range, TableViewCellSlotRange other) + { + // No overlap. + if (!range.IntersectsWith(other)) + { + yield return range; + yield break; + } + + // Intersection rectangle. + var top = Math.Max(range.FirstRow, other.FirstRow); + var left = Math.Max(range.FirstColumn, other.FirstColumn); + var bottom = Math.Min(range.LastRow, other.LastRow); + var right = Math.Min(range.LastColumn, other.LastColumn); + + // Top strip. + if (range.FirstRow < top) + { + yield return new TableViewCellSlotRange( + range.FirstRow, + range.FirstColumn, + top - range.FirstRow, + range.Columns); + } + + // Bottom strip. + if (bottom < range.LastRow) + { + yield return new TableViewCellSlotRange( + bottom + 1, + range.FirstColumn, + range.LastRow - bottom, + range.Columns); + } + + // Left strip. + if (range.FirstColumn < left) + { + yield return new TableViewCellSlotRange( + top, + range.FirstColumn, + bottom - top + 1, + left - range.FirstColumn); + } + + // Right strip. + if (right < range.LastColumn) + { + yield return new TableViewCellSlotRange( + top, + right + 1, + bottom - top + 1, + range.LastColumn - right); + } + } + + /// + /// Merges two TableViewCellSlotRanges into a single range that encompasses both. + /// + public static TableViewCellSlotRange Merge(this TableViewCellSlotRange range, TableViewCellSlotRange other) + { + var firstRow = Math.Min(range.FirstRow, other.FirstRow); + var firstColumn = Math.Min(range.FirstColumn, other.FirstColumn); + var lastRow = Math.Max(range.LastRow, other.LastRow); + var lastColumn = Math.Max(range.LastColumn, other.LastColumn); + + return TableViewCellSlotRange.FromCoordinates( + firstRow, + firstColumn, + lastRow, + lastColumn); + } +} \ No newline at end of file diff --git a/src/Helpers/IndexRangeHelper.cs b/src/Helpers/IndexRangeHelper.cs new file mode 100644 index 00000000..59b73b39 --- /dev/null +++ b/src/Helpers/IndexRangeHelper.cs @@ -0,0 +1,44 @@ +using Microsoft.UI.Xaml.Data; + +namespace WinUI.TableView.Helpers; + +/// +/// Provides helper methods for working with index ranges in a TableView. +/// +internal static class IndexRangeHelper +{ + /// + /// Gets a list of contiguous index ranges from a collection of indexes. + /// + /// The collection of indexes to process. + /// A list of contiguous index ranges. + public static List GetRanges(IEnumerable indexes) + { + var sorted = indexes.Order().ToArray(); + + if (sorted.Length == 0) + return []; + + List ranges = []; + + var first = sorted[0]; + var previous = sorted[0]; + + for (var i = 1; i < sorted.Length; i++) + { + if (sorted[i] == previous + 1) + { + previous = sorted[i]; + continue; + } + + ranges.Add(new ItemIndexRange(first, (uint)(previous - first + 1))); + + first = previous = sorted[i]; + } + + ranges.Add(new ItemIndexRange(first, (uint)(previous - first + 1))); + + return ranges; + } +} diff --git a/src/Helpers/TableViewTrace.cs b/src/Helpers/TableViewTrace.cs new file mode 100644 index 00000000..6f31ca24 --- /dev/null +++ b/src/Helpers/TableViewTrace.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; + +namespace WinUI.TableView.Helpers; + +internal static class TableViewTrace +{ + [Conditional("DEBUG")] + public static void Write(string message) + { + if (Debugger.IsAttached) + { + Debug.WriteLine($"[TableView] {message}"); + } + } +} \ No newline at end of file diff --git a/src/TableView.Events.cs b/src/TableView.Events.cs index 47868c32..5d313047 100644 --- a/src/TableView.Events.cs +++ b/src/TableView.Events.cs @@ -1,6 +1,6 @@ using Microsoft.UI.Xaml; -using System; -using System.ComponentModel; +using System.Diagnostics; +using WinUI.TableView.Helpers; namespace WinUI.TableView; @@ -186,6 +186,7 @@ protected internal virtual void OnClearSorting(TableViewClearSortingEventArgs ar /// protected virtual void OnCellSelectionChanged(TableViewCellSelectionChangedEventArgs args) { + TableViewTrace.Write($"TableViewCellSelectionChanged: Added={args.AddedCells.Count}, Removed={args.RemovedCells.Count}"); CellSelectionChanged?.Invoke(this, args); } diff --git a/src/TableView.Properties.cs b/src/TableView.Properties.cs index 80ac94b0..05f9697b 100644 --- a/src/TableView.Properties.cs +++ b/src/TableView.Properties.cs @@ -416,7 +416,7 @@ public bool ForceRowOrCellSelectionOnContextRequested /// /// Gets the selected cell ranges. /// - internal HashSet> SelectedCellRanges { get; } = []; + internal HashSet SelectedCellRanges { get; } = []; /// /// Gets or sets a value indicating whether the TableView is in editing mode. @@ -897,7 +897,7 @@ private static void OnSelectionModeChanged(DependencyObject d, DependencyPropert if (tableView.SelectionMode is ListViewSelectionMode.Single && tableView.CurrentCellSlot.HasValue) { - tableView.SelectedCellRanges.Add([tableView.CurrentCellSlot.Value]); + tableView.SelectedCellRanges.Add(TableViewCellSlotRange.FromSlots(tableView.CurrentCellSlot.Value)); } tableView.OnCellSelectionChanged(); diff --git a/src/TableView.cs b/src/TableView.cs index 698c482d..16af828f 100644 --- a/src/TableView.cs +++ b/src/TableView.cs @@ -3,18 +3,11 @@ using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using System; using System.Collections; -using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Diagnostics; -using System.IO; -using System.Linq; using System.Reflection; using System.Text; -using System.Threading.Tasks; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; using Windows.Storage; @@ -22,6 +15,7 @@ using Windows.System; using WinUI.TableView.Extensions; using WinUI.TableView.Helpers; +using Pointer = Microsoft.UI.Xaml.Input.Pointer; namespace WinUI.TableView; @@ -42,7 +36,6 @@ public partial class TableView : ListView private readonly CollectionView _collectionView = []; private Border? _dragRectangle; private Point? _dragStartPoint; - private bool _cellSelectionDirty; private bool _suppressSelectionChangedCellClear; private Point? _lastDragCanvasPoint; private DispatcherTimer? _autoScrollTimer; @@ -50,6 +43,12 @@ public partial class TableView : ListView private double _autoScrollHorizontalDelta; private double _dragStartVerticalOffset; private double _dragStartHorizontalOffset; + private Pointer? _tableViewDragPointer; + private UIElement? _pointerCaptureElement; + private TableViewCellSlotRange? _lastDragSelectionCellRange; + private ItemIndexRange? _lastDragSelectionRowRange; + private bool _cellStateDispatchPending; + private readonly HashSet _pendingCellStateRows = []; /// /// Initializes a new instance of the TableView class. @@ -72,6 +71,9 @@ public TableView() Unloaded += OnUnloaded; SelectionChanged += TableView_SelectionChanged; _collectionView.ItemPropertyChanged += OnItemPropertyChanged; + + AddHandler(PointerPressedEvent, new PointerEventHandler(OnAnyPointerPressed), handledEventsToo: true); + AddHandler(PointerReleasedEvent, new PointerEventHandler(OnAnyPointerReleased), handledEventsToo: true); } /// @@ -79,6 +81,8 @@ public TableView() /// private void TableView_SelectionChanged(object sender, SelectionChangedEventArgs e) { + TableViewTrace.Write($"TableViewSelectionChanged: AddedItems={e.AddedItems.Count}, RemovedItems={e.RemovedItems.Count}"); + if (_suppressSelectionChangedCellClear) { _suppressSelectionChangedCellClear = false; @@ -91,11 +95,17 @@ private void TableView_SelectionChanged(object sender, SelectionChangedEventArgs } else { - SelectedCellRanges.RemoveWhere(slots => + var addedIndexes = e.AddedItems + .Select(item => Items.IndexOf(item)) + .Where(i => i >= 0); + + if (Columns.VisibleColumns.Count == 0) return; + + foreach (var range in IndexRangeHelper.GetRanges(addedIndexes)) { - slots.RemoveWhere(slot => SelectedRanges.Any(range => range.IsInRange(slot.Row))); - return slots.Count == 0; - }); + var slotRange = TableViewCellSlotRange.FromCoordinates(range.FirstIndex, 0, range.LastIndex, Columns.VisibleColumns.Count - 1); + SubtractCellRangeFromSelection(slotRange); + } } CurrentCellSlot = null; @@ -108,6 +118,23 @@ private void TableView_SelectionChanged(object sender, SelectionChangedEventArgs } } + /// + /// Subtracts a specified cell range from the current selection. + /// + /// The cell range to subtract from the current selection. + private void SubtractCellRangeFromSelection(TableViewCellSlotRange slotRange) + { + while (SelectedCellRanges.FirstOrDefault(r => r.IntersectsWith(slotRange)) is { } intersectingRange) + { + foreach (var slicedRange in intersectingRange.Subtract(slotRange)) + { + SelectedCellRanges.Add(slicedRange); + } + + SelectedCellRanges.Remove(intersectingRange); + } + } + /// /// Handles the PropertyChanged event of an item in the TableView. /// @@ -134,7 +161,14 @@ protected override void PrepareContainerForItemOverride(DependencyObject element row.TableView = this; row.EnsureCellsStyle(default, item); - row.ApplyCellsSelectionState(); + + _pendingCellStateRows.Add(row.Index); + if (!_cellStateDispatchPending) + { + _cellStateDispatchPending = true; + DispatcherQueue.TryEnqueue(ApplyPendingCellStates); + } + row.RowPresenter?.ApplyDetailsPaneState(item); if (CurrentCellSlot.HasValue) @@ -185,6 +219,257 @@ protected override void OnKeyDown(KeyRoutedEventArgs e) HandleNavigations(e, shiftKey, ctrlKey); } + /// + /// Handles pointer-pressed for all cases, including when elements sets e.Handled = true. + /// + private void OnAnyPointerPressed(object sender, PointerRoutedEventArgs e) + { + var pointerPoint = e.GetCurrentPoint(this); + var position = pointerPoint.Position; + var canvasPoint = GetCanvasPoint(position); + var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); + var isShiftkey = KeyboardHelper.IsShiftKeyDown(); + var orignalSoruce = e.OriginalSource as FrameworkElement; + + if (SelectionMode is ListViewSelectionMode.None // Skip selection when SelectionMode is None + || IsDragSelecting // Skip selection when a drag is already in progress + || orignalSoruce is ScrollBar // Skip selection when the pointer is over the ScrollBar + || orignalSoruce?.FindAscendant() is { } // Skip selection when the pointer is within a ScrollBar + || !pointerPoint.Properties.IsLeftButtonPressed // Skip selection when the left mouse button is not pressed + || canvasPoint is null // Skip selection when canvasPoint is null (e.g., pointer is outside the scroll canvas) + || canvasPoint.Value.Y < 0 // Skip selection when the pointer is in the column header area (above the scroll canvas) + || canvasPoint.Value.X < CellsHorizontalOffset // Skip selection when the pointer is in the row header area (to the left of the scroll canvas) + || isShiftkey) // Skip selection when the Shift key is held + { + return; + } + + _lastDragCanvasPoint = null; + CurrentCellSlot = null; + SelectionStartCellSlot = null; + SelectionStartRowIndex = null; + _lastDragSelectionRowRange = null; + _lastDragSelectionCellRange = null; + LastSelectionUnit = TableViewSelectionUnit.Row; + + if (e.OriginalSource is UIElement element) + { + UIElement? pressedElement = element.FindAscendant(); // Check if the pointer is over a TableViewCell + pressedElement ??= element.FindAscendant(); // If not, check if the pointer is over a TableViewRow + +#if !WINDOWS + _dragStartCell = pressedElement as TableViewCell; + _dragStartRow = element.FindAscendant(); +#endif + + // Skip selection when the pointer is not over a Cell or Row, and ShowDragRectangle is false. + if (pressedElement == null && !ShowDragRectangle) return; + + pressedElement ??= this; // If not, default to the TableView itself + + SelectionStartCellSlot = (pressedElement as TableViewCell)?.Slot; + SelectionStartRowIndex = (pressedElement as TableViewRow)?.Index; + + LastSelectionUnit = SelectionUnit switch + { + TableViewSelectionUnit.Cell => TableViewSelectionUnit.Cell, + TableViewSelectionUnit.Row => TableViewSelectionUnit.Row, + _ => pressedElement is TableViewCell + ? TableViewSelectionUnit.Cell + : TableViewSelectionUnit.Row + }; + + if (SelectionMode is ListViewSelectionMode.Single) + { + _lastDragCanvasPoint = canvasPoint; + MakeSelectionInDragRect(); + SetCurrentCellFromCanvasPoint(_lastDragCanvasPoint.Value); + + return; + } + + pressedElement.Focus(FocusState.Programmatic); +#if WINDOWS + _pointerCaptureElement = pressedElement; +#else + _pointerCaptureElement = this; +#endif + + _pointerCaptureElement.CapturePointer(e.Pointer); + _tableViewDragPointer = e.Pointer; + + if (!ctrlKey && SelectionMode is not ListViewSelectionMode.Multiple && LastSelectionUnit is not TableViewSelectionUnit.Cell) + DeselectAll(); + + StartDragSelection(canvasPoint.Value); + + if (!IsDragSelecting) + { + _pointerCaptureElement?.ReleasePointerCaptures(); + _pointerCaptureElement = null; + _tableViewDragPointer = null; + return; + } + + MakeSelectionInDragRect(); + } + } + + /// + protected override void OnPointerMoved(PointerRoutedEventArgs e) + { + base.OnPointerMoved(e); + + if (!IsDragSelecting) + { + return; + } + + var canvasPoint = GetCanvasPoint(e.GetCurrentPoint(this).Position); + if (canvasPoint is null) + { + return; + } + + // Drive the rect visual for all drag sources (cell-initiated drags bubble pointer events here). + UpdateDragRectangleVisual(canvasPoint.Value); + + // Selection-by-hit-test is only needed for TableView-initiated drags; cell-initiated + // drags perform selection in the cell's OnManipulationDelta via FindCell. + if (_tableViewDragPointer is not null) + { + MakeSelectionInDragRect(); + } + } + + /// + /// Makes selection based on the current drag rectangle, selecting either rows or cells depending on the last selection unit. + /// + private void MakeSelectionInDragRect() + { + if (_lastDragCanvasPoint is null) return; + + if (LastSelectionUnit is not TableViewSelectionUnit.Cell && GetRowIndexAtCanvasPoint(_lastDragCanvasPoint.Value) is int row) + { + SelectionStartRowIndex ??= row; + var minRow = Math.Min(SelectionStartRowIndex.Value, row); + var maxRow = Math.Max(SelectionStartRowIndex.Value, row); + var rows = new ItemIndexRange(minRow, (uint)(maxRow - minRow + 1)); + + SelectRowsInDragRect(rows); + } + else if (LastSelectionUnit is not TableViewSelectionUnit.Row && GetSlotAtCanvasPoint(_lastDragCanvasPoint.Value) is { } slot) + { + if (SelectionStartCellSlot is null) + { + var startColumn = slot.Column; + + if (_dragStartPoint is not null) + { + var horizontalScrollDelta = HorizontalOffset - _dragStartHorizontalOffset; + var startX = _dragStartPoint.Value.X - horizontalScrollDelta; + startColumn = GetColumnIndexAtCanvasX(startX) + ?? (startX < CellsHorizontalOffset - HorizontalOffset ? 0 : Columns.VisibleColumns.Count - 1); + } + + SelectionStartCellSlot = new(SelectionStartRowIndex ?? slot.Row, startColumn); + } + + var startRow = Math.Min(SelectionStartCellSlot.Value.Row, slot.Row); + var endRow = Math.Max(SelectionStartCellSlot.Value.Row, slot.Row); + var startCol = Math.Min(SelectionStartCellSlot.Value.Column, slot.Column); + var endCol = Math.Max(SelectionStartCellSlot.Value.Column, slot.Column); + var cells = TableViewCellSlotRange.FromCoordinates(startRow, startCol, endRow, endCol); + + SelectCellsInDragRect(cells); + } + else if (LastSelectionUnit is not TableViewSelectionUnit.Cell && _lastDragSelectionRowRange?.Length > 0) + { + DeselectRange(_lastDragSelectionRowRange); + + _lastDragSelectionRowRange = null; + SelectionStartRowIndex = null; + } + else if (LastSelectionUnit is not TableViewSelectionUnit.Row && _lastDragSelectionCellRange?.Length > 0) + { + DeselectCellRange(_lastDragSelectionCellRange); + + _lastDragSelectionCellRange = null; + SelectionStartCellSlot = null; + } + } + + /// + /// Selects rows that intersect with the current drag rectangle, updating the selection state accordingly. + /// + private void SelectRowsInDragRect(ItemIndexRange rows) + { + if (_lastDragSelectionRowRange?.FirstIndex == rows.FirstIndex && _lastDragSelectionRowRange?.LastIndex == rows.LastIndex) return; + + if (SelectionMode is ListViewSelectionMode.Single && rows.Length is 1) + { + SelectedIndex = rows.FirstIndex; + } + else if (_lastDragSelectionRowRange is not null && _lastDragSelectionRowRange.Contains(rows)) + { + foreach (var slicedRange in _lastDragSelectionRowRange.Subtract(rows)) + { + DeselectRange(slicedRange); + } + } + else if (rows.Length > 0) + { + SelectRange(rows); + } + + _lastDragSelectionRowRange = rows; + } + + /// + /// Selects cells that intersect with the current drag rectangle, updating the selection state accordingly. + /// + private void SelectCellsInDragRect(TableViewCellSlotRange cells) + { + if (_lastDragSelectionCellRange == cells) return; + + DispatcherQueue.TryEnqueue(() => + { + if (_lastDragSelectionCellRange is null + && !KeyboardHelper.IsCtrlKeyDown() + && SelectionMode is not ListViewSelectionMode.Multiple) + { + DeselectAllItems(); + SelectedCellRanges.Clear(); + } + else if (_lastDragSelectionCellRange is not null && cells is not null) + { + foreach (var range in _lastDragSelectionCellRange.Subtract(cells)) + { + SubtractCellRangeFromSelection(range); + } + } + + if (SelectedCellRanges.Any(r => r == cells)) + { + OnCellSelectionChanged(); + } + else if (cells?.Length > 0) + { + SelectCellRange(cells); + } + + _lastDragSelectionCellRange = cells; + }); + } + + /// + /// Handles pointer-released for all cases, including when elements sets e.Handled = true. + /// + private void OnAnyPointerReleased(object sender, PointerRoutedEventArgs e) + { + EndDragSelection(); + } + /// /// Handles navigation keys. /// @@ -313,6 +598,9 @@ private int CalculateAvailablePageSize() return (int)Math.Floor(availableHeight / rowHeight); } + /// + /// Ends the editing of a cell, committing or canceling the edit based on the specified action. + /// internal bool EndCellEditing(TableViewEditAction editAction, TableViewCell cell) { var editingElement = cell.Content as FrameworkElement; @@ -370,6 +658,7 @@ protected async override void OnApplyTemplate() DragRectangleCanvas = GetTemplateChild("DragRectangleCanvas") as Canvas; _dragRectangle = GetTemplateChild("DragRectangle") as Border; _scrollViewer?.Loaded += OnScrollViewerLoaded; + _scrollViewer?.ViewChanged += OnScrollViewerViewChanged; if (IsLoaded) { @@ -381,6 +670,17 @@ protected async override void OnApplyTemplate() SetHeadersVisibility(); } + /// + /// Handles the ViewChanged event of the ScrollViewer control, updating the position of each row when the view changes. + /// + private void OnScrollViewerViewChanged(object? sender, ScrollViewerViewChangedEventArgs e) + { + foreach (var row in _rows) + { + row.UpdatePosition(); + } + } + /// /// Handles the Loaded event of the ScrollViewer control. /// @@ -573,8 +873,7 @@ internal void CopyToClipboardInternal(bool includeHeaders) { // Clipboard failures are normal on Windows (e.g., CLIPBRD_E_CANT_OPEN). // Swallow to avoid crashing the application. - Debug.WriteLine( - $"TableView: Clipboard.SetContent failed: {ex}"); + TableViewTrace.Write($"TableView: Clipboard.SetContent failed: {ex}"); } } @@ -1053,7 +1352,7 @@ private void SelectAllCells() if (Items.Count > 0 && Columns.VisibleColumns.Count > 0) { SelectedCellRanges.Clear(); - SelectedCellRanges.Add([new TableViewCellSlot(0, 0)]); + SelectedCellRanges.Add(TableViewCellSlotRange.FromSlots(new(0, 0))); } break; case ListViewSelectionMode.Multiple: @@ -1145,7 +1444,7 @@ internal void MakeSelection(TableViewCellSlot slot, bool shiftKey, bool ctrlKey else { if (SelectionUnit is TableViewSelectionUnit.CellWithRow) - { + { SelectRows(slot, shiftKey, ctrlKey); } else if (!ctrlKey) @@ -1246,17 +1545,15 @@ private void SelectCells(TableViewCellSlot slot, bool shiftKey, bool ctrlKey) } } - var selectionRange = (SelectionStartCellSlot is null ? null : SelectedCellRanges.LastOrDefault(x => SelectionStartCellSlot.HasValue && x.Contains(SelectionStartCellSlot.Value))) ?? []; + var selectionRange = (SelectionStartCellSlot is null ? null : SelectedCellRanges.LastOrDefault(x => SelectionStartCellSlot.HasValue && x.Contains(SelectionStartCellSlot.Value.Row, SelectionStartCellSlot.Value.Column))); if (ctrlKey && SelectionMode is ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended) { - selectionRange = SelectedCellRanges.SelectMany(x => x).ToHashSet(); - SelectedCellRanges.Clear(); + // Keep existing ranges; the new slot/range will be added alongside them. } else { - SelectedCellRanges.Remove(selectionRange); - selectionRange.Clear(); + SelectedCellRanges.Remove(selectionRange!); } SelectionStartCellSlot ??= CurrentCellSlot; @@ -1264,36 +1561,14 @@ private void SelectCells(TableViewCellSlot slot, bool shiftKey, bool ctrlKey) if (shiftKey && SelectionMode is ListViewSelectionMode.Multiple or ListViewSelectionMode.Extended) { - var currentSlot = SelectionStartCellSlot.Value; - var startRow = Math.Min(slot.Row, currentSlot.Row); - var endRow = Math.Max(slot.Row, currentSlot.Row); - var startCol = Math.Min(slot.Column, currentSlot.Column); - var endCol = Math.Max(slot.Column, currentSlot.Column); - for (var row = startRow; row <= endRow; row++) - { - for (var column = startCol; column <= endCol; column++) - { - var nextSlot = new TableViewCellSlot(row, column); - selectionRange.Add(nextSlot); - if (SelectedCellRanges.LastOrDefault(x => x.Contains(nextSlot)) is { } range) - { - range.Remove(nextSlot); - } - } - } + var newRange = TableViewCellSlotRange.FromSlots(SelectionStartCellSlot.Value, slot); + SelectedCellRanges.Add(newRange); } else { SelectionStartCellSlot = slot; - selectionRange.Add(slot); - - if (SelectedCellRanges.LastOrDefault(x => x.Contains(slot)) is { } range) - { - range.Remove(slot); - } + SelectedCellRanges.Add(TableViewCellSlotRange.FromSlots(slot)); } - - SelectedCellRanges.Add(selectionRange); OnCellSelectionChanged(); CurrentCellSlot = slot; } @@ -1303,18 +1578,65 @@ private void SelectCells(TableViewCellSlot slot, bool shiftKey, bool ctrlKey) /// internal void DeselectCell(TableViewCellSlot slot) { - var selectionRange = SelectedCellRanges.LastOrDefault(x => x.Contains(slot)); - selectionRange?.Remove(slot); + var singleCellRange = TableViewCellSlotRange.FromSlots(slot); + var containingRanges = SelectedCellRanges.Where(x => x.Contains(slot.Row, slot.Column)).ToList(); - if (selectionRange?.Count == 0) + foreach (var range in containingRanges) { - SelectedCellRanges.Remove(selectionRange); + SelectedCellRanges.Remove(range); + foreach (var remaining in range.Subtract(singleCellRange)) + { + SelectedCellRanges.Add(remaining); + } } CurrentCellSlot = slot; OnCellSelectionChanged(); } + /// + /// Selects all the cells within the specified range, raising the event only once. + /// + /// The range of cell slots to select. + public void SelectCellRange(TableViewCellSlotRange? range) + { + if (range is null || range.Length <= 0 + || !range.IsValid(this) + || SelectionMode is ListViewSelectionMode.None + || SelectionUnit is TableViewSelectionUnit.Row) + { + return; + } + + if (SelectedCellRanges.Any(x => x == range)) return; + + if (SelectionUnit is TableViewSelectionUnit.CellWithRow) + { + _suppressSelectionChangedCellClear = true; + var rowRange = new ItemIndexRange(range.FirstRow, (uint)range.Rows); + SelectRange(rowRange); + } + + SubtractCellRangeFromSelection(range); + SelectedCellRanges.Add(range); + OnCellSelectionChanged(); + } + + /// + /// Deselects all the cells within the specified range, raising the event only once. + /// + /// The range of cell slots to deselect. + public void DeselectCellRange(TableViewCellSlotRange? range) + { + if (range is null || range.Length <= 0 || SelectedCellRanges.Count is 0) + { + return; + } + + SubtractCellRangeFromSelection(range); + OnCellSelectionChanged(); + } + /// /// Handles changes to the current cell in the table view. /// @@ -1333,18 +1655,9 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel if (newSlot.HasValue) { - // During drag selection, skip expensive scroll-into-view and focus operations. - // The drag rectangle handles visual feedback, and focus is restored when dragging ends. - if (IsDragSelecting) - { - var cell = GetCellFromSlot(newSlot.Value); - cell?.ApplyCurrentCellState(skipFocus: true); - } - else - { - var cell = await ScrollCellIntoView(newSlot.Value); - cell?.ApplyCurrentCellState(); - } + var cell = await ScrollCellIntoView(newSlot.Value); + cell?.ApplyCurrentCellState(); + cell?.Focus(FocusState.Programmatic); } } @@ -1353,43 +1666,38 @@ private async Task OnCurrentCellChanged(TableViewCellSlot? oldSlot, TableViewCel /// private void OnCellSelectionChanged() { - if (_cellSelectionDirty) return; - _cellSelectionDirty = true; + var newSelection = SelectedCellRanges.SelectMany(x => x.GetSlots()).ToHashSet(); + var removedCells = SelectedCells.Where(s => !newSelection.Contains(s)).ToList(); + var addedCells = newSelection.Where(s => !SelectedCells.Contains(s)).ToList(); - if (!DispatcherQueue.TryEnqueue(() => - { - _cellSelectionDirty = false; + if (removedCells.Count is 0 && addedCells.Count is 0) return; - var oldSelection = SelectedCells; - SelectedCells = [.. SelectedCellRanges.SelectMany(x => x)]; + foreach (var slot in removedCells) SelectedCells.Remove(slot); + foreach (var slot in addedCells) SelectedCells.Add(slot); - var rowIndexes = oldSelection.Select(x => x.Row).Concat(SelectedCells.Select(x => x.Row)).Distinct(); + OnCellSelectionChanged(new TableViewCellSelectionChangedEventArgs(removedCells, addedCells)); - foreach (var rowIndex in rowIndexes) - { - var row = _rows.FirstOrDefault(x => x.Index == rowIndex); - row?.ApplyCellsSelectionState(); - } + foreach (var slot in removedCells.Concat(addedCells)) + _pendingCellStateRows.Add(slot.Row); - InvokeCellSelectionChangedEvent(oldSelection); - })) + if (!_cellStateDispatchPending) { - _cellSelectionDirty = false; + _cellStateDispatchPending = true; + DispatcherQueue.TryEnqueue(ApplyPendingCellStates); } } - /// - /// Invokes the event to notify subscribers of changes in the selected cells. - /// - private void InvokeCellSelectionChangedEvent(HashSet oldSelection) + private void ApplyPendingCellStates() { - var removedCells = oldSelection.Except(SelectedCells).ToList(); - var addedCells = SelectedCells.Except(oldSelection).ToList(); + _cellStateDispatchPending = false; + if (_pendingCellStateRows.Count is 0) return; - if (removedCells.Count > 0 || addedCells.Count > 0) + foreach (var row in _rows) { - OnCellSelectionChanged(new TableViewCellSelectionChangedEventArgs(removedCells, addedCells)); + if (_pendingCellStateRows.Contains(row.Index)) + row.ApplyCellsSelectionState(); } + _pendingCellStateRows.Clear(); } /// @@ -1414,13 +1722,10 @@ internal void StartDragSelection(Point startPoint) _dragStartVerticalOffset = _scrollViewer?.VerticalOffset ?? 0; _dragStartHorizontalOffset = HorizontalOffset; - if (_scrollViewer is not null) - { - _scrollViewer.ViewChanged += OnScrollViewerViewChangedDuringDrag; - } + _scrollViewer?.ViewChanged += OnScrollViewerViewChangedDuringDrag; // Show the drag rectangle visual if enabled and template parts are available - if (ShowDragRectangle && DragRectangleCanvas is not null && _dragRectangle is not null) + if (DragRectangleCanvas is not null && _dragRectangle is not null) { _dragStartPoint = startPoint; @@ -1429,7 +1734,7 @@ internal void StartDragSelection(Point startPoint) _dragRectangle.Width = 0; _dragRectangle.Height = 0; - _dragRectangle.Visibility = Visibility.Visible; + _dragRectangle.Visibility = ShowDragRectangle ? Visibility.Visible : Visibility.Collapsed; } } @@ -1455,6 +1760,26 @@ internal void UpdateDragRectangleVisual(Point currentPoint) UpdateAutoScroll(currentPoint); } + /// + /// Transforms a point relative to this into coordinates relative to the . + /// Returns null when the canvas is unavailable or the transform cannot be computed. + /// A negative Y value indicates the point is above the scroll area (column header territory). + /// + /// The position relative to this TableView. + /// The canvas-relative point, or null if unavailable. + private Point? GetCanvasPoint(Point position) + { + if (DragRectangleCanvas is null) return null; + try + { + return TransformToVisual(DragRectangleCanvas).TransformPoint(position); + } + catch (ArgumentException) + { + return null; + } + } + /// /// Positions the drag rectangle visual from the scroll-adjusted start point to the current point, /// so the rectangle follows the mouse and extends naturally when content scrolls. @@ -1529,9 +1854,9 @@ private void UpdateAutoScroll(Point canvasPoint) { _autoScrollTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; _autoScrollTimer.Tick += OnAutoScrollTimerTick; + _autoScrollTimer.Start(); } - - _autoScrollTimer.Start(); + // else: timer already running — delta values above are picked up on the next tick } else { @@ -1588,17 +1913,21 @@ private void OnAutoScrollTimerTick(object? sender, object e) return; } - // Horizontal scroll via HorizontalOffset DP does not fire ViewChanged, - // so reposition rectangle and update selection here. - // Vertical scroll fires ViewChanged which handles it via OnScrollViewerViewChangedDuringDrag. - if (Math.Abs(_autoScrollHorizontalDelta) > 0.5 && _lastDragCanvasPoint is not null) + // Horizontal scroll does not fire ViewChanged, so reposition the rectangle here. + // Selection is updated for all scroll directions from the timer tick, not from ViewChanged, + // so that MakeSelectionInDragRect runs after ChangeView completes rather than inside the layout pass. + if (_lastDragCanvasPoint is not null) { - if (_dragStartPoint is not null && DragRectangleCanvas is not null && _dragRectangle is not null) + if (Math.Abs(_autoScrollHorizontalDelta) > 0.5 && + _dragStartPoint is not null && DragRectangleCanvas is not null && _dragRectangle is not null) { PositionDragRectangle(_lastDragCanvasPoint.Value); } - SelectCellAtDragPoint(); + if (_tableViewDragPointer is not null) + { + MakeSelectionInDragRect(); + } } } @@ -1628,52 +1957,11 @@ private void OnScrollViewerViewChangedDuringDrag(object? sender, ScrollViewerVie PositionDragRectangle(_lastDragCanvasPoint.Value); } - // Update selection for newly visible rows during auto-scroll - SelectCellAtDragPoint(); - } - - /// - /// Selects the cell at the last known drag pointer position. - /// Used during auto-scroll to select newly visible cells when the pointer isn't moving. - /// - private void SelectCellAtDragPoint() - { - if (_scrollViewer is null || _lastDragCanvasPoint is null || DragRectangleCanvas is null) - { - return; - } - - // Clamp to the cell area within the viewport. - // CellsHorizontalOffset accounts for row headers so we don't hit-test on header area. - var canvasPoint = _lastDragCanvasPoint.Value; - var minX = CellsHorizontalOffset + 1; - var clampedPoint = new Point( - Math.Clamp(canvasPoint.X, minX, Math.Max(minX, _scrollViewer.ViewportWidth - 1)), - Math.Clamp(canvasPoint.Y, 1, Math.Max(1, _scrollViewer.ViewportHeight - 1))); - - try - { - var screenPoint = DragRectangleCanvas.TransformToVisual(null).TransformPoint(clampedPoint); -#if WINDOWS - var cell = VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer) -#else - var cell = VisualTreeHelper.FindElementsInHostCoordinates(screenPoint, _scrollViewer, true) - .OfType() - .Where(x => x.Name is "Content") - .Select(x => x.FindAscendant() is { } c ? c : default) -#endif - .OfType() - .FirstOrDefault(); - - if (cell is not null && cell.Slot != CurrentCellSlot) - { - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - MakeSelection(cell.Slot, true, ctrlKey); - } - } - catch (ArgumentException) + // During auto-scroll the timer tick owns selection updates to keep MakeSelectionInDragRect + // out of the scroll layout pass. Only update here for non-auto-scroll scrolls (e.g. scroll wheel). + if (_autoScrollTimer is null && _tableViewDragPointer is not null) { - // Element not in visual tree during container recycling + MakeSelectionInDragRect(); } } @@ -1682,36 +1970,60 @@ private void SelectCellAtDragPoint() /// internal async void EndDragSelection() { - if (!IsDragSelecting) return; + if (!IsDragSelecting || _lastDragCanvasPoint is null) return; StopAutoScroll(); - if (_scrollViewer is not null) - { - _scrollViewer.ViewChanged -= OnScrollViewerViewChangedDuringDrag; - } + _pointerCaptureElement?.ReleasePointerCaptures(); + _pointerCaptureElement = null; + _tableViewDragPointer = null; - if (_dragRectangle is not null) - { - _dragRectangle.Visibility = Visibility.Collapsed; - } + _scrollViewer?.ViewChanged -= OnScrollViewerViewChangedDuringDrag; + _dragRectangle?.Visibility = Visibility.Collapsed; + + SetCurrentCellFromCanvasPoint(_lastDragCanvasPoint.Value); IsDragSelecting = false; _dragStartPoint = null; _lastDragCanvasPoint = null; + SelectionStartCellSlot = null; + } - // Restore focus and scroll to the current cell now that dragging has ended - try + private void SetCurrentCellFromCanvasPoint(Point canvasPoint) + { + if (GetSlotAtCanvasPoint(canvasPoint, exactMatch: true) is { } endSlot) { - if (CurrentCellSlot.HasValue) + CurrentRowIndex = endSlot.Row; + + if (!(SelectionUnit is TableViewSelectionUnit.Row && IsReadOnly)) { - var cell = await ScrollCellIntoView(CurrentCellSlot.Value); - cell?.ApplyCurrentCellState(); + CurrentCellSlot = endSlot; +#if !WINDOWS + if (_dragStartCell is not null + && _dragStartPoint is not null + && endSlot != _dragStartCell.Slot) + { + VisualStates.GoToState(_dragStartCell, false, VisualStates.StateNormal); + + if (_dragStartCell.IsSelected) + { + VisualStates.GoToState(_dragStartCell, false, VisualStates.StateSelected); + } + } +#endif } - } - catch (Exception) - { - // Focus restoration is best-effort after drag ends + +#if !WINDOWS + if (_dragStartRow is not null && _dragStartRow.Index != endSlot.Row) + { + VisualStates.GoToState(_dragStartRow, false, VisualStates.StateNormal); + + if (_dragStartRow.IsSelected) + { + VisualStates.GoToState(_dragStartRow, false, VisualStates.StateSelected); + } + } +#endif } } @@ -1828,6 +2140,119 @@ void ViewChanged(object? _, ScrollViewerViewChangedEventArgs e) return slot.IsValid(this) && ContainerFromIndex(slot.Row) is TableViewRow row ? row.Cells[slot.Column] : default; } + /// + /// Returns the index of the row that contains , or the nearest row + /// within the vertical span between the drag start point and when + /// the point falls in empty space. Returns null when no realized row falls in that span. + /// + private int? GetRowIndexAtCanvasPoint(Point canvasPoint, bool exactMatch = false) + { + if (DragRectangleCanvas is null) return null; + + if (exactMatch) + { + foreach (var row in _rows) + { + var rowTop = row.Position; + var rowBottom = rowTop + row.ActualHeight; + + if (canvasPoint.Y >= rowTop && canvasPoint.Y < rowBottom) + { + return row.Index; + } + } + + return null; + } + + // Compute the vertical span of the drag so we can snap to the nearest in-span row when the + // pointer is in empty space. If there is no drag start (called outside a drag), minY == maxY + // == canvasPoint.Y, which collapses back to the original exact hit-test behaviour. + var verticalScrollDelta = (_scrollViewer?.VerticalOffset ?? 0) - _dragStartVerticalOffset; + var adjustedStartY = _dragStartPoint is not null + ? _dragStartPoint.Value.Y - verticalScrollDelta + : canvasPoint.Y; + + var minY = Math.Min(adjustedStartY, canvasPoint.Y); + var maxY = Math.Max(adjustedStartY, canvasPoint.Y); + + TableViewRow? nearestRow = null; + var nearestDistance = double.MaxValue; + + foreach (var row in _rows) + { + var rowTop = row.Position; + var rowBottom = rowTop + row.ActualHeight; + + if (rowBottom <= minY || rowTop >= maxY) + continue; + + var distance = Math.Max(0d, Math.Max(rowTop - canvasPoint.Y, canvasPoint.Y - rowBottom)); + if (distance < nearestDistance) + { + nearestDistance = distance; + nearestRow = row; + } + } + + return nearestRow?.Index; + } + + /// + /// Returns the index of the visible column whose bounds contain the given canvas X coordinate. + /// Returns null when x falls outside the column area or there are no visible columns. + /// + private int? GetColumnIndexAtCanvasX(double x) + { + var columnLeft = CellsHorizontalOffset - HorizontalOffset; + for (var i = 0; i < Columns.VisibleColumns.Count; i++) + { + var columnRight = columnLeft + Columns.VisibleColumns[i].ActualWidth; + if (x >= columnLeft && x < columnRight) + return i; + columnLeft = columnRight; + } + return null; + } + + /// + /// Resolves the cell slot at , snapping to the nearest row and + /// column within the horizontal and vertical span of the current drag when the point falls in + /// empty space. Returns null when no realized row or visible column falls in that span. + /// + private TableViewCellSlot? GetSlotAtCanvasPoint(Point canvasPoint, bool exactMatch = false) + { + if (DragRectangleCanvas is null) return null; + + if (GetRowIndexAtCanvasPoint(canvasPoint, exactMatch) is not int rowIndex) return null; + + // Mirror the row snapping: find the nearest column within the horizontal drag span. + var horizontalScrollDelta = HorizontalOffset - _dragStartHorizontalOffset; + var adjustedPointerX = canvasPoint.X - horizontalScrollDelta; + + var columnLeft = CellsHorizontalOffset - HorizontalOffset; + + for (var i = 0; i < Columns.VisibleColumns.Count; i++) + { + var columnRight = columnLeft + Columns.VisibleColumns[i].ActualWidth; + + if (adjustedPointerX <= columnRight) + { + // Require the pointer to actually be inside the cell. + if (exactMatch && adjustedPointerX < columnLeft) + return null; + + return new(rowIndex, i); + } + + columnLeft = columnRight; + } + + return exactMatch || Columns.VisibleColumns.Count == 0 + ? null + : new(rowIndex, Columns.VisibleColumns.Count - 1); + } + /// /// Gets the columns currently in view. /// diff --git a/src/TableViewCell.cs b/src/TableViewCell.cs index 523f8a46..d1955299 100644 --- a/src/TableViewCell.cs +++ b/src/TableViewCell.cs @@ -5,10 +5,6 @@ using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Shapes; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Windows.Foundation; using WinUI.TableView.Extensions; using WinUI.TableView.Helpers; @@ -29,7 +25,6 @@ namespace WinUI.TableView; #endif public partial class TableViewCell : ContentControl { - private ScrollViewer? _scrollViewer; private ContentPresenter? _contentPresenter; private Border? _selectionBorder; private Rectangle? _v_gridLine; @@ -230,12 +225,6 @@ protected override void OnTapped(TappedRoutedEventArgs e) e.Handled = true; return; } - - if (TableView?.CurrentCellSlot != Slot || TableView?.LastSelectionUnit is TableViewSelectionUnit.Row) - { - MakeSelection(); - e.Handled = true; - } } /// @@ -248,76 +237,15 @@ protected override void OnPointerPressed(PointerRoutedEventArgs e) e.Handled = true; return; } - - if (!KeyboardHelper.IsShiftKeyDown() && TableView is not null) - { - TableView.SelectionStartCellSlot = TableView.SelectionUnit is not TableViewSelectionUnit.Row || !IsReadOnly ? Slot : default; - TableView.SelectionStartRowIndex = Index; - CapturePointer(e.Pointer); - - // Start drag selection (auto-scroll + optional rectangle visual) - var point = e.GetCurrentPoint(this).Position; - var canvasPoint = TransformPointToCanvas(point); - if (canvasPoint.HasValue) - { - TableView.StartDragSelection(canvasPoint.Value); - } - } } - /// protected override void OnPointerReleased(PointerRoutedEventArgs e) { base.OnPointerReleased(e); - if (!KeyboardHelper.IsShiftKeyDown() && TableView is not null) - { - var cell = FindCell(e.GetCurrentPoint(this).Position); - TableView.SelectionStartCellSlot = TableView.SelectionUnit is not TableViewSelectionUnit.Row || !IsReadOnly ? cell?.Slot : default; - TableView.SelectionStartRowIndex = cell?.Slot.Row; - } - - TableView?.EndDragSelection(); - ReleasePointerCaptures(); - - e.Handled = true; - } - - /// - protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) - { - base.OnPointerCaptureLost(e); - - TableView?.EndDragSelection(); - } - - /// - protected override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) - { - base.OnManipulationDelta(e); - - if (PointerCaptures?.Any() is true) + if(TableView?.SelectionUnit is not TableViewSelectionUnit.Row) { - // Update drag rectangle visual and auto-scroll - if (TableView?.IsDragSelecting is true) - { - var canvasPoint = TransformPointToCanvas(e.Position); - if (canvasPoint.HasValue) - { - TableView.UpdateDragRectangleVisual(canvasPoint.Value); - } - } - - // Selection via FindCell — same proven path whether rectangle is on or off. - // When the pointer is outside the viewport, FindCell returns null and selection - // is updated by the ViewChanged handler on the next auto-scroll tick. - var cell = FindCell(e.Position); - - if (cell is not null && cell.Slot != TableView?.CurrentCellSlot) - { - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - TableView?.MakeSelection(cell.Slot, true, ctrlKey); - } + e.Handled = true; } } @@ -350,45 +278,6 @@ private double GetHorizontalGridlineHeight() ? TableView.HorizontalGridLinesStrokeThickness : 0d; } - /// - /// Finds the cell at the specified position. - /// - private TableViewCell? FindCell(Point position) - { - _scrollViewer ??= TableView?.FindDescendant(); - if (_scrollViewer is null) return null; - - var transformedPoint = TransformToVisual(null).TransformPoint(position); -#if WINDOWS - return VisualTreeHelper.FindElementsInHostCoordinates(transformedPoint, _scrollViewer) -#else - return VisualTreeHelper.FindElementsInHostCoordinates(transformedPoint, _scrollViewer, true) - .OfType() - .Where(x => x.Name is "Content") - .Select(x => x.FindAscendant() is { } header ? header : default) -#endif - .OfType() - .FirstOrDefault(); - } - - /// - /// Transforms a point relative to this cell to coordinates relative to the drag rectangle canvas. - /// - private Point? TransformPointToCanvas(Point position) - { - if (TableView?.DragRectangleCanvas is null) return null; - - try - { - var transform = TransformToVisual(TableView.DragRectangleCanvas); - return transform.TransformPoint(position); - } - catch (ArgumentException) - { - return null; - } - } - /// protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) { @@ -400,49 +289,7 @@ protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) base.OnDoubleTapped(e); - if (!IsReadOnly && TableView is not null && !TableView.IsEditing && !Column?.UseSingleElement is true) - { - e.Handled = BeginCellEditing(e); - } - else - { - e.Handled = true; - } - } - - /// - /// Makes a selection based on the current cell. - /// - private void MakeSelection() - { - var shiftKey = KeyboardHelper.IsShiftKeyDown(); - var ctrlKey = KeyboardHelper.IsCtrlKeyDown(); - - if (TableView is null || Column is null) - { - return; - } - - if ((TableView.IsEditing || Column.UseSingleElement) && IsCurrent) - { - return; - } - - if (IsSelected && (ctrlKey || TableView.SelectionMode is ListViewSelectionMode.Multiple) && !shiftKey) - { - TableView.DeselectCell(Slot); - } - else - { - if (Column.UseSingleElement) - { - TableView.DeselectCell(Slot); - } - - TableView.MakeSelection(Slot, shiftKey, ctrlKey); - } - - TableView.SetIsEditing(false); + e.Handled = IsReadOnly || TableView is null || TableView.IsEditing || !Column?.UseSingleElement is not true || BeginCellEditing(e); } /// diff --git a/src/TableViewCellSlot.cs b/src/TableViewCellSlot.cs index 980ef25d..093ce5d2 100644 --- a/src/TableViewCellSlot.cs +++ b/src/TableViewCellSlot.cs @@ -3,4 +3,11 @@ /// /// Represents a slot of a TableView cell, identified by its row and column indices. /// -public readonly record struct TableViewCellSlot(int Row, int Column); +public readonly record struct TableViewCellSlot(int Row, int Column) +{ + /// + public override string ToString() + { + return $"Row: {Row}, Col: {Column}"; + } +} diff --git a/src/TableViewCellSlotRange.cs b/src/TableViewCellSlotRange.cs new file mode 100644 index 00000000..8a70a7c0 --- /dev/null +++ b/src/TableViewCellSlotRange.cs @@ -0,0 +1,133 @@ +namespace WinUI.TableView; + +/// +/// Represents a coordinate-based range of cell slots in a TableView. +/// +public class TableViewCellSlotRange +{ + /// + /// Gets the starting row index of the range. + /// + public int FirstRow { get; } + + /// + /// Gets the starting column index of the range. + /// + public int FirstColumn { get; } + + /// + /// Gets the first cell slot in the range. + /// + public TableViewCellSlot FirstSlot => new(FirstRow, FirstColumn); + + /// + /// Gets the number of rows spanned by this range. + /// + public int Rows { get; } + + /// + /// Gets the number of columns spanned by this range. + /// + public int Columns { get; } + + /// + /// Gets the total number of cell slots in the range. + /// + public int Length => Rows * Columns; + + /// + /// Gets the last row index included in the range. + /// + public int LastRow => FirstRow + Rows - 1; + + /// + /// Gets the last column index included in the range. + /// + public int LastColumn => FirstColumn + Columns - 1; + + /// + /// Gets the last cell slot in the range. + /// + public TableViewCellSlot LastSlot => new(LastRow, LastColumn); + + /// + /// Initializes a new instance of the TableViewCellSlotRange class using starting indices and dimensions. + /// + public TableViewCellSlotRange(int firstRowIndex, int firstColumnIndex, int rowCount, int columnCount) + { + if (firstRowIndex < 0) + throw new ArgumentOutOfRangeException(nameof(firstRowIndex), "Index cannot be negative."); + + if (firstColumnIndex < 0) + throw new ArgumentOutOfRangeException(nameof(firstColumnIndex), "Index cannot be negative."); + + if (rowCount < 1) + throw new ArgumentOutOfRangeException(nameof(rowCount), "Count must be at least 1."); + if (columnCount < 1) + throw new ArgumentOutOfRangeException(nameof(columnCount), "Count must be at least 1."); + + FirstRow = firstRowIndex; + FirstColumn = firstColumnIndex; + Rows = rowCount; + Columns = columnCount; + } + + /// + /// Helper factory to initialize using start and end coordinates directly. + /// + public static TableViewCellSlotRange FromCoordinates(int startRow, int startCol, int endRow, int endCol) + { + var firstRow = Math.Min(startRow, endRow); + var firstCol = Math.Min(startCol, endCol); + var rowCount = Math.Abs(endRow - startRow) + 1; + var colCount = Math.Abs(endCol - startCol) + 1; + + return new TableViewCellSlotRange(firstRow, firstCol, rowCount, colCount); + } + + /// + /// Helper factory to initialize using two TableViewCellSlot instances. + /// + public static TableViewCellSlotRange FromSlots(TableViewCellSlot firstSlot, TableViewCellSlot? lastSlot = default) + { + lastSlot ??= firstSlot; + return FromCoordinates(firstSlot.Row, firstSlot.Column, lastSlot.Value.Row, lastSlot.Value.Column); + } + + /// + public override bool Equals(object? obj) + { + if (obj is TableViewCellSlotRange other) + { + return FirstRow == other.FirstRow && + FirstColumn == other.FirstColumn && + Rows == other.Rows && + Columns == other.Columns; + } + return false; + } + + /// + public static bool operator ==(TableViewCellSlotRange? left, TableViewCellSlotRange? right) + { + return Equals(left, right); + } + + /// + public static bool operator !=(TableViewCellSlotRange? left, TableViewCellSlotRange? right) + { + return !Equals(left, right); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(FirstRow, FirstColumn, Rows, Columns); + } + + /// + public override string ToString() + { + return $"[{FirstSlot}]..[{LastSlot}] ({Length})"; + } +} \ No newline at end of file diff --git a/src/TableViewRow.cs b/src/TableViewRow.cs index b2fe947d..d2a65c31 100644 --- a/src/TableViewRow.cs +++ b/src/TableViewRow.cs @@ -156,44 +156,6 @@ protected override void OnContentChanged(object oldContent, object newContent) TableView?.EnsureAlternateRowColors(); } - /// - protected override void OnPointerPressed(PointerRoutedEventArgs e) - { - if (TableView is { IsEditing: false }) - { - base.OnPointerPressed(e); - } - - if (!KeyboardHelper.IsShiftKeyDown() && TableView is not null) - { - TableView.SelectionStartRowIndex = Index; - } - } - - /// - protected override void OnPointerReleased(PointerRoutedEventArgs e) - { - base.OnPointerReleased(e); - - if (!KeyboardHelper.IsShiftKeyDown() && TableView is not null) - { - TableView.SelectionStartCellSlot = null; - TableView.SelectionStartRowIndex = Index; - } - } - - /// - protected override void OnTapped(TappedRoutedEventArgs e) - { - base.OnTapped(e); - - if (TableView?.SelectionUnit is TableViewSelectionUnit.Row or TableViewSelectionUnit.CellOrRow or TableViewSelectionUnit.CellWithRow) - { - TableView.CurrentRowIndex = Index; - TableView.LastSelectionUnit = TableViewSelectionUnit.Row; - } - } - /// protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) { @@ -213,10 +175,35 @@ protected override Size ArrangeOverride(Size finalSize) var left = Math.Max(cornerRadius.TopLeft, cornerRadius.BottomLeft); _itemPresenter?.Arrange(new Rect(-left, 0, _itemPresenter.ActualWidth + left, _itemPresenter.ActualHeight)); + + UpdatePosition(); return finalSize; } + /// + /// Updates the position of the row relative to the TableView. + /// + internal void UpdatePosition() + { + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + if (TableView is not null) + { + try + { + Position = TransformToVisual(TableView.DragRectangleCanvas).TransformPoint(default).Y; + } + catch { } + } + }); + } + + /// + /// Gets or sets the position of the row relative to the TableView. + /// + internal double Position { get; set; } + /// /// Ensures cells are created for the row. /// diff --git a/src/Tableview.Uno.cs b/src/Tableview.Uno.cs index 91fa2fcf..b69e7061 100644 --- a/src/Tableview.Uno.cs +++ b/src/Tableview.Uno.cs @@ -17,6 +17,8 @@ partial class TableView private const BindingFlags BindingAttr = BindingFlags.NonPublic | BindingFlags.Instance; private PropertyInfo? _disableRaiseSelectionChangedPropertyInfo; private MethodInfo? _invokeSelectionChangedMethodInfo; + private TableViewRow? _dragStartRow; + private TableViewCell? _dragStartCell; private void SetDisableRaiseSelectionChanged(bool value) { diff --git a/tests/DragSelectionRectangleTests.cs b/tests/DragSelectionRectangleTests.cs index a85a3ace..805fddca 100644 --- a/tests/DragSelectionRectangleTests.cs +++ b/tests/DragSelectionRectangleTests.cs @@ -192,4 +192,40 @@ public void UpdateDragRectangleVisual_DoesNotCrash_WhenNotDragging() Assert.IsFalse(tv.IsDragSelecting); } + + [UITestMethod] + public async Task SelectCellRange_SelectsTheProvidedRangeAndRaisesOneSelectionChangedEvent() + { + var tv = await CreateAndLoadTableView(); + var eventCount = 0; + tv.CellSelectionChanged += (_, _) => eventCount++; + + tv.SelectCellRange(TableViewCellSlotRange.FromCoordinates(0, 0, 1, 1)); + + Assert.AreEqual(4, tv.SelectedCells.Count); + Assert.IsTrue(tv.SelectedCells.Contains(new TableViewCellSlot(0, 0))); + Assert.IsTrue(tv.SelectedCells.Contains(new TableViewCellSlot(0, 1))); + Assert.IsTrue(tv.SelectedCells.Contains(new TableViewCellSlot(1, 0))); + Assert.IsTrue(tv.SelectedCells.Contains(new TableViewCellSlot(1, 1))); + Assert.AreEqual(1, eventCount); + + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } + + [UITestMethod] + public async Task DeselectCellRange_DeselectsTheProvidedRangeAndRaisesOneSelectionChangedEvent() + { + var tv = await CreateAndLoadTableView(); + tv.SelectCellRange(TableViewCellSlotRange.FromCoordinates(0, 0, 1, 1)); + + var eventCount = 0; + tv.CellSelectionChanged += (_, _) => eventCount++; + + tv.DeselectCellRange(TableViewCellSlotRange.FromCoordinates(0, 0, 1, 1)); + + Assert.AreEqual(0, tv.SelectedCells.Count); + Assert.AreEqual(1, eventCount); + + await UnitTestApp.Current.MainWindow.UnloadTestContentAsync(tv); + } } diff --git a/tests/TableViewSelectionUnitTests.cs b/tests/TableViewSelectionUnitTests.cs index 2b1009c0..47bc062f 100644 --- a/tests/TableViewSelectionUnitTests.cs +++ b/tests/TableViewSelectionUnitTests.cs @@ -4,6 +4,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting.AppContainer; using System.Threading.Tasks; +using Windows.Foundation; namespace WinUI.TableView.Tests; @@ -15,10 +16,10 @@ public async Task CellWithRow_CellClickSelectsCellAndOwningRow() { var tableView = await CreateTableViewAsync(TableViewSelectionUnit.CellWithRow); - tableView.MakeSelection(new TableViewCellSlot(1, 0), false, false); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(1, 0), new TableViewCellSlot(1, 0))); await Task.Yield(); // Allow selection to propagate - + Assert.IsTrue(tableView.SelectedCells.Contains(new TableViewCellSlot(1, 0))); Assert.AreEqual(1, tableView.SelectedItems.Count); Assert.AreSame(tableView.Items[1], tableView.SelectedItem); @@ -29,7 +30,7 @@ public async Task CellAndRow_RowHeaderClickSelectsOnlyRow() { var tableView = await CreateTableViewAsync(TableViewSelectionUnit.CellWithRow); - tableView.MakeSelection(new TableViewCellSlot(1, -1), false, false); + tableView.SelectRange(new ItemIndexRange(1, 1)); Assert.AreEqual(0, tableView.SelectedCells.Count); Assert.AreEqual(1, tableView.SelectedItems.Count); @@ -41,11 +42,11 @@ public async Task CellSelectionUnitStillSelectsOnlyCells() { var tableView = await CreateTableViewAsync(TableViewSelectionUnit.Cell); - tableView.MakeSelection(new TableViewCellSlot(0, 0), false, false); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(0, 0), new TableViewCellSlot(0, 0))); await Task.Yield(); // Allow selection to propagate - tableView.MakeSelection(new TableViewCellSlot(1, 1), false, true); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(1, 1), new TableViewCellSlot(1, 1))); await Task.Yield(); // Allow selection to propagate @@ -60,10 +61,10 @@ public async Task CellOrRowSelectionUnitStillUsesCellAndRowSemantics() { var tableView = await CreateTableViewAsync(TableViewSelectionUnit.CellOrRow); - tableView.MakeSelection(new TableViewCellSlot(1, 0), false, false); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(1, 0), new TableViewCellSlot(1, 0))); Assert.AreEqual(0, tableView.SelectedItems.Count); - tableView.MakeSelection(new TableViewCellSlot(0, -1), false, false); + tableView.SelectRange(new ItemIndexRange(0, 1)); Assert.AreEqual(1, tableView.SelectedItems.Count); Assert.AreEqual(0, tableView.SelectedCells.Count); } @@ -73,11 +74,11 @@ public async Task CellWithRow_MultiSelectionAddsCellAndRowSelections() { var tableView = await CreateTableViewAsync(TableViewSelectionUnit.CellWithRow, ListViewSelectionMode.Multiple); - tableView.MakeSelection(new TableViewCellSlot(0, 0), false, false); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(0, 0), new TableViewCellSlot(0, 0))); await Task.Yield(); // Allow selection to propagate - tableView.MakeSelection(new TableViewCellSlot(1, 1), false, true); + tableView.SelectCellRange(TableViewCellSlotRange.FromSlots(new TableViewCellSlot(1, 1), new TableViewCellSlot(1, 1))); await Task.Delay(200); // Allow selection to propagate